Column Grouping
Group columns of a flat table under shared banner headers with a second column facet level.
The col facet can carry more than one level. Stacking a second level draws a
banner row above the column labels, so a plain flat table gains grouped headers
without becoming a pivot - it is just an extra header row over the same rows.
grid.data = new FlattenedDataViewModel({
// top level is the group row, bottom is the column label row
columnFacets: [
["Employment details", "Employment details", "Base Salary", "Extra expense", "Extra expense"],
["Agency", "Title", null, "Regular Gross Paid", "Total OT Paid"],
],
options: {
// one facet def per level: the group row, then the label row
facetDefs: { row: [], col: [{ text: "" }, { text: "" }], axis: "col" },
},
});Group columns
Merging works two ways. Repeating a value across columns on the group row spans
it horizontally into one banner. Leaving a lower level null spans the cell
above it vertically: put a column's label on the top level and null below,
and that label fills both header rows instead of getting a banner of its own.
columnFacets: [
// repeat a group value to span it horizontally; a bare label on the top level
// (Base Salary) will span down when the level below it is null
["Employment details", "Employment details", "Base Salary", "Extra expense", "Extra expense"],
// null under Base Salary → its label spans both rows vertically
["Agency", "Title", null, "Regular Gross Paid", "Total OT Paid"],
],Try it below: Agency and Title sit under a shared Employment details banner, and Regular Gross Paid and Total OT Paid under Extra expense. Base Salary has no banner - its label spans both header rows vertically.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow } from "../../types";
// A flat table (no pivot). Each column reads from `field`; `label` is the column
// header and `group` is the shared header drawn above it. Columns that share a
// group name merge into one spanning cell on the top facet row. A column with no
// `group` has its label span both header rows vertically instead.
const COLUMNS: { field: string; label: string; group?: string }[] = [
{ field: "agency_name", label: "Agency", group: "Employment details" },
{ field: "title_description", label: "Title", group: "Employment details" },
{ field: "base_salary", label: "Base Salary" },
{ field: "regular_gross_paid", label: "Regular Gross Paid", group: "Extra expense" },
{ field: "total_ot_paid", label: "Total OT Paid", group: "Extra expense" },
];
const GRID_HEIGHT = 420;
// Static fractional track widths: the two Employment details columns take twice
// the share of the three expense columns, so the table fills the grid width with
// no horizontal scroll.
const COL_FR = [2, 2, 1, 1, 1];
export function mount(el: HTMLElement, ctx: SampleContext): () => void {
const gridMount = createGridMount(el, GRID_HEIGHT);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
let disposed = false;
el.append(gridMount);
ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
if (disposed) return;
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
// Two column facet levels stacked top-to-bottom: the group row, then the
// per-column label row. Repeating a group value across adjacent columns
// merges them into one spanning header on the group row. A column with no
// group puts its label on the top level and `null` below it, so a `null`
// on the lower level makes the label span both rows vertically.
columnFacets: [
COLUMNS.map((column) => column.group ?? column.label),
COLUMNS.map((column) => (column.group ? column.label : null)),
],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((_, i) => ({ colSize: { strategy: "static", width: COL_FR[i], unit: "fr" } })),
// One facet def per level: the group row on top, the label row below.
facetDefs: {
row: [],
col: [{ text: "" }, { text: "" }],
axis: "col",
},
},
});
grid.draw();
});
return () => {
disposed = true;
disposeTheme();
el.removeChild(gridMount);
};
}Flag values over a threshold
The group banner doubles as a selection target. grid.selectAll matches the
Extra expense group, selectAllCell narrows the selection to the data cells
whose value crosses a threshold, and style paints them - so conditional
formatting rides on the same group structure, with no per-column wiring.
grid
.selectAll((_dim, value) => value === "Extra expense") // the group's header
.selectAllCell((value) => typeof value === "number" && value > threshold)
.style((cell) => { cell.style.color = "#ef4444"; }); // over-threshold cells onlyThe formatter/style only reaches data cells because the rule carries a cell
predicate (selectAllCell); a facet-only selectAll(...).style(...) styles the
group header instead. Re-flagging on a new threshold is active.undo() then a
fresh selection, so the old red never lingers.
Try it below: raise or lower the threshold in the toolbar. Regular Gross Paid and Total OT Paid cells above it turn red, while the Employment details columns are never touched.
Source
import Grid, { FlattenedDataViewModel, Selection } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { createToolbar, toolbarSelect } from "../../runtime/toolbar";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow } from "../../types";
// The same grouped table as the grouping demo. Here the Extra expense group -
// Regular Gross Paid and Total OT Paid - is the target: its cells turn red when
// their value crosses the chosen threshold.
const COLUMNS: { field: string; label: string; group?: string }[] = [
{ field: "agency_name", label: "Agency", group: "Employment details" },
{ field: "title_description", label: "Title", group: "Employment details" },
{ field: "base_salary", label: "Base Salary" },
{ field: "regular_gross_paid", label: "Regular Gross Paid", group: "Extra expense" },
{ field: "total_ot_paid", label: "Total OT Paid", group: "Extra expense" },
];
const EXTRA_EXPENSE = "Extra expense";
const GRID_HEIGHT = 420;
const COL_FR = [2, 2, 1, 1, 1];
// The thresholds the toolbar offers; an Extra expense cell turns red when its
// value is above the selected one.
const THRESHOLDS = [10000, 25000, 50000, 100000];
const DEFAULT_THRESHOLD = 25000;
export function mount(el: HTMLElement, ctx: SampleContext): () => void {
const gridMount = createGridMount(el, GRID_HEIGHT);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
let disposed = false;
// Paint the Extra expense cells above the threshold red. `selectAll` matches the
// group's header, `selectAllCell` narrows the selection to the data cells whose
// value crosses the threshold, and `style` colours them. `undo` drops the
// previous flagging so a new threshold does not stack on the old one.
let active: Selection | null = null;
const highlight = (threshold: number): void => {
active?.undo();
active = grid.selectAll((_dim, value) => value === EXTRA_EXPENSE);
active
.selectAllCell((value) => typeof value === "number" && value > threshold)
.style((cell) => {
cell.style.color = "#ef4444";
cell.style.fontWeight = "600";
});
grid.draw();
};
ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
if (disposed) return;
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
// Two column facet levels: the group banner on top, the label row below -
// see the grouping demo above for how the merging works.
columnFacets: [
COLUMNS.map((column) => column.group ?? column.label),
COLUMNS.map((column) => (column.group ? column.label : null)),
],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((_, i) => ({ colSize: { strategy: "static", width: COL_FR[i], unit: "fr" } })),
facetDefs: {
row: [],
col: [{ text: "" }, { text: "" }],
axis: "col",
},
},
});
grid.draw();
highlight(DEFAULT_THRESHOLD);
});
const toolbar = createToolbar();
const caption = document.createElement("span");
caption.textContent = "Flag Extra expense over";
caption.style.cssText = "font-size:12px;opacity:0.75;";
const select = toolbarSelect(THRESHOLDS.map(formatUsd), formatUsd(DEFAULT_THRESHOLD));
toolbar.append(caption, select);
el.append(toolbar, gridMount);
select.addEventListener("change", () => highlight(THRESHOLDS[select.selectedIndex]));
return () => {
disposed = true;
disposeTheme();
active?.undo();
el.removeChild(toolbar);
el.removeChild(gridMount);
};
}
/* ===================================== utils ===================================== */
function formatUsd(amount: number): string {
return `$${amount.toLocaleString("en-US")}`;
}