demo D312
Merged cells in Angular
A region cell merged over its own rows, and a note row merged across every column to its right, real Excel merges on export
Column.rowSpan · Column.colSpan
This regional sales table merges the Region cell down over its own rows, so a value that already applies to several rows reads once instead of repeating on every line, and merges a closing note row across every column to its right. Group by region and the same merge stops right at the heading above it: a merge never straddles one.
This is the Angular version. A standalone component takes the whole configuration through one config input, surfaces grid events as outputs, and exposes the live grid on a getter for anything the inputs do not cover.
The configuration
import { Component, signal, viewChild } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { LatticeGridComponent, provideLattice } from '@toclocoinc/lattice-grid/angular';
// The two-line auto-merge recipe: look up, and if the row above already
// carries the same value you are not the start of a run; otherwise count down
// while the value holds. A heading row (no region of its own) stops a run on
// its own once grouping is on.
const mergeRun = (field: string) => (p: any) => {
const at = (i: number) => p.grid.rows.get(i)?.data?.[field];
if (at(p.index - 1) === p.data[field]) return 1;
let n = 1;
while (at(p.index + n) === p.data[field]) n++;
return n;
};
const columns = [
{ field: 'region', title: 'Region', layout: { width: 130 }, rowSpan: mergeRun('region') },
{
field: 'rep', title: 'Rep', layout: { width: 160 },
// The closing row of a region carries a sentence in this field instead of
// a name, and spans over Quarter and Revenue to make room for it.
colSpan: (p: any) => (p.data.noteRow ? 3 : 1),
},
{ field: 'quarter', title: 'Quarter', layout: { width: 110 } },
{
field: 'revenue', title: 'Revenue', type: 'number', layout: { width: 150 },
format: { style: 'currency', currency: 'USD' }, total: 'sum',
},
];
// Four regions of five rows each: four quarters of rep and revenue, closed by
// a note row whose sentence lives in the same "rep" field the others use for
// a name. East and West follow the same shape.
const rows = [/* 20 rows: four regions, four quarters and a note row each */];
@Component({
selector: 'app-root',
standalone: true,
imports: [LatticeGridComponent],
template:
'<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">' +
' <span>Group by region</span>' +
' <button type="button" [attr.aria-pressed]="!grouped()" (click)="setGrouped(false)">Off</button>' +
' <button type="button" [attr.aria-pressed]="grouped()" (click)="setGrouped(true)">On</button>' +
'</div>' +
'<lattice-grid #grid [config]="config" (grid-ready)="onReady($event)" style="display:block;height:500px"></lattice-grid>',
})
export class AppComponent {
grid = viewChild<LatticeGridComponent>('grid');
grouped = signal(false);
config = { rowKey: 'id', columns: columns, rows: rows };
private instance: any = null;
onReady(grid: any) { this.instance = grid; }
setGrouped(on: boolean) {
this.grouped.set(on);
this.instance?.columns?.group(on ? ['region'] : []);
}
}
bootstrapApplication(AppComponent, {
providers: [provideLattice({ createGrid })],
});
A cell that merges down over its own rows
rowSpan on a column says how many rows that cell covers, and the grid paints it once, at the top of the run, rather than repeating the value down every row beneath it. Here the Region column merges North’s four rows into one cell, then South’s four into the next, and so on, so a reader scans one label per region instead of the same word four times over. The cell is one thing everywhere it matters: an arrow key into it lands on its top edge and an arrow out leaves from its bottom, a range drawn across any part of it takes the whole cell, and a written value goes to the row at its origin. Sort this grid by Revenue and the merge follows: it is worked out fresh from whichever rows are actually next to each other, never from a stored value that could fall out of step with what changed underneath it.
A row that merges across every column to its right
The row closing each region carries a sentence instead of a figure, so it merges across Rep, Quarter and Revenue with colSpan rather than squeezing that sentence into a 160 pixel column. colSpan works the same way turned sideways: a column decides, per row, how many columns its own cell covers, and only that row’s decision changes, so Region still shows the ordinary merged cell beside it. Export the grid to CSV, the clipboard, or Excel and both merges travel with it: a CSV or clipboard export writes the value once at the origin and blanks in the cells it covers, and Excel opens the file with a real merged range rather than a repeated value.
How do I merge cells that already share a value, without hand-coding each run?
Compare each row to the one above it, in whatever order the grid is actually showing: if the row above already carries the same value you are not the start of a run, and otherwise you count forward while the value holds. That is the whole recipe:
const mergeRun = (field) => (p) => {
const at = (i) => p.grid.rows.get(i)?.data?.[field];
if (at(p.index - 1) === p.data[field]) return 1;
let n = 1;
while (at(p.index + n) === p.data[field]) n++;
return n;
};
// columns: [{ field: 'region', rowSpan: mergeRun('region') }]
Reading through p.grid.rows.get rather than trusting the source array is what keeps a merge honest after a sort or a filter reorders what is next to what. Turn on “Group by region” above the grid and the region heading it adds sits above each run; the recipe above never needs to know about that heading, because a heading row carries no region value of its own, so the run stops there on its own before the grid’s own clip at a group boundary would ever have to step in.