Column Formatter
Format cell values from the column facet, override per column, and switch formatters live from a header control.
A value formatter turns a raw cell value into the string the grid draws - for
example, 107789 into 107,789, or $107,789. It runs before any cell renderer,
so the renderer receives the already-formatted string while the untouched number
stays on dataCtx.rawValue. It's a hook you can use to format numbers, dates, or
anything else.
type ValueFormatter = (value, dataCtx) => string | null | undefined;
const currency: ValueFormatter = (value) =>
typeof value === "number" ? value.toLocaleString("en-US", { style: "currency", currency: "USD" }) : "";A formatter can be declared in two places - on a column facet
(options.facetDefs.col[], where it becomes the default for every column beneath
it) or on a single track def (options.vTrackDefs[], where it applies to one
column and overrides the facet default).
Format from the column facet
Put a valueFormatter on a column facet def and it becomes the default for every
data cell under that facet. With two facet levels, the grid uses the deepest
level that defines one - so a formatter on the top group level (with the label
level defining none) is a single higher-order default inherited by all columns.
facetDefs: {
row: [],
// formatter on the top (group) level; the label level defines none, so the
// group formatter is the default for every column below it
col: [{ text: "", valueFormatter: localeNumber }, { text: "" }],
axis: "col",
},Try it below: every numeric column shows locale-grouped numbers from one formatter declared on the Pay/Employee group row - no per-column wiring. Text columns pass through untouched because the formatter only transforms numbers.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { ValueFormatter } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow, SampleValue } from "../../types";
// Two column-facet levels: a group banner on top, the per-column label below.
// Columns that share a `group` merge into one spanning banner on the top row.
const COLUMNS: { field: string; label: string; group: string }[] = [
{ field: "agency_name", label: "Agency", group: "Employee" },
{ field: "title_description", label: "Title", group: "Employee" },
{ field: "base_salary", label: "Base Salary", group: "Pay" },
{ field: "regular_gross_paid", label: "Regular Gross", group: "Pay" },
{ field: "total_ot_paid", label: "Overtime", group: "Pay" },
];
const GRID_HEIGHT = 420;
// Static fractional track widths: the two Employee columns take twice the share
// of the three Pay columns, so the table fills the grid width with no scroll.
const COL_FR = [2, 2, 1, 1, 1];
// A single higher-order formatter, placed on the TOP column-facet level. Because
// the label level below it defines no formatter, every data cell inherits this
// one: numbers get locale grouping (107789 -> "107,789") and text passes straight
// through, so Agency and Title are left untouched.
const localeNumber: ValueFormatter<SampleValue> = (value) =>
typeof value === "number" ? value.toLocaleString("en-US") : value == null ? "" : String(value);
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)),
// Top level is the group banner, bottom is the column label.
columnFacets: [COLUMNS.map((column) => column.group), COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((_, i) => ({ colSize: { strategy: "static", width: COL_FR[i], unit: "fr" } })),
// The formatter lives on the top (group) facet def; the label level below
// defines none, so the group's formatter is the default for every column.
facetDefs: {
row: [],
col: [{ text: "", valueFormatter: localeNumber }, { text: "" }],
axis: "col",
},
},
});
grid.draw();
});
return () => {
disposed = true;
disposeTheme();
el.removeChild(gridMount);
};
}Override for one column
The facet formatter is only a default. Set a valueFormatter on a column's track
def and it wins for that column - a track def formatter takes precedence over the
facet default (def.valueFormatter ?? facetValueFormatter). The group formatter
stays in place for every other column.
vTrackDefs: columns.map((column) => ({
colSize,
// this column's own formatter overrides the inherited group default
...(column.field === "base_salary" ? { valueFormatter: currency } : {}),
})),
facetDefs: { row: [], col: [{ text: "", valueFormatter: localeNumber }, { text: "" }], axis: "col" },Try it below: Base Salary now renders as $-currency from its own track def,
while Regular Gross and Overtime keep the locale grouping inherited from the group
facet.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { ValueFormatter } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow, SampleValue } from "../../types";
// The column that opts out of the inherited group formatter with its own.
const OVERRIDE_FIELD = "base_salary";
const COLUMNS: { field: string; label: string; group: string }[] = [
{ field: "agency_name", label: "Agency", group: "Employee" },
{ field: "title_description", label: "Title", group: "Employee" },
{ field: OVERRIDE_FIELD, label: "Base Salary", group: "Pay" },
{ field: "regular_gross_paid", label: "Regular Gross", group: "Pay" },
{ field: "total_ot_paid", label: "Overtime", group: "Pay" },
];
const GRID_HEIGHT = 420;
const COL_FR = [2, 2, 1, 1, 1];
// The group-level default (same as the previous section): locale grouping for
// numbers, text passed through. Every Pay column inherits it.
const localeNumber: ValueFormatter<SampleValue> = (value) =>
typeof value === "number" ? value.toLocaleString("en-US") : value == null ? "" : String(value);
// A per-column formatter set on one vTrackDef. Because a track def formatter
// takes precedence over the facet default, only Base Salary renders as currency
// (`$107,789`) while the other Pay columns keep the inherited locale grouping.
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const currency: ValueFormatter<SampleValue> = (value) => (typeof value === "number" ? money.format(value) : "");
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)),
columnFacets: [COLUMNS.map((column) => column.group), COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
// Base Salary carries its own valueFormatter; it wins over the group
// default for that column only. The rest carry width alone and inherit.
vTrackDefs: COLUMNS.map((column, i) => ({
colSize: { strategy: "static", width: COL_FR[i], unit: "fr" },
...(column.field === OVERRIDE_FIELD ? { valueFormatter: currency } : {}),
})),
facetDefs: {
row: [],
col: [{ text: "", valueFormatter: localeNumber }, { text: "" }],
axis: "col",
},
},
});
grid.draw();
});
return () => {
disposed = true;
disposeTheme();
el.removeChild(gridMount);
};
}Switch formatters live
A formatter can also be applied as a selection override, so switching it live
never rebuilds the viewmodel. grid.selectAll(...) matches the Pay group header,
.selectAllCell(...) extends the selection onto its data cells, and
.prop({ valueFormatter }) overrides the formatter there. It is just a rule the
renderer reads on the next grid.draw(). A mini locale dropdown in the Pay group
header (rendered through the facet's trackRenderer) undoes the previous rule and
applies a new one.
let active: Selection | null = null;
const applyLocale = () => {
active?.undo(); // drop the previous locale rule
active = grid.selectAll((_dim, value) => value === "Pay");
active.selectAllCell(() => true).prop({ valueFormatter: localeFormatter(localeId) });
grid.draw(); // repaint - no new viewmodel
};
const payHeader: FacetCellRenderer = (label) =>
label !== "Pay" ? label : { content: label, right: localeSelect(localeId, (next) => { localeId = next; applyLocale(); }) };Try it below: use the dropdown on the Pay header to switch between USD, EUR, JPY, and INR. Every Pay column reformats at once because they all inherit the group-level formatter.
Source
import Grid, { FlattenedDataViewModel, Selection } from "grid/dist/renderer";
import type { FacetCellRenderer, ValueFormatter } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow, SampleValue } from "../../types";
// The group banner whose columns are reformatted, and which hosts the switcher.
const PAY_GROUP = "Pay";
const COLUMNS: { field: string; label: string; group: string }[] = [
{ field: "agency_name", label: "Agency", group: "Employee" },
{ field: "title_description", label: "Title", group: "Employee" },
{ field: "base_salary", label: "Base Salary", group: PAY_GROUP },
{ field: "regular_gross_paid", label: "Regular Gross", group: PAY_GROUP },
{ field: "total_ot_paid", label: "Overtime", group: PAY_GROUP },
];
const GRID_HEIGHT = 420;
const COL_FR = [2, 2, 1, 1, 1];
// The locales the header dropdown switches between. Each builds a currency
// formatter; the choice drives the selection override applied on the next draw.
const LOCALES = [
{ id: "en-US", label: "USD", currency: "USD" },
{ id: "de-DE", label: "EUR", currency: "EUR" },
{ id: "ja-JP", label: "JPY", currency: "JPY" },
{ id: "en-IN", label: "INR", currency: "INR" },
];
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;
let localeId = LOCALES[0].id;
el.append(gridMount);
// Re-target the Pay columns' data cells with the chosen locale's formatter.
// `selectAll` matches the Pay group header, `selectAllCell` extends onto its
// data cells, and `prop` overrides their formatter. It is just a rule the
// renderer reads on the next draw, so switching locales swaps one rule and
// redraws - the viewmodel is never rebuilt.
let active: Selection | null = null;
const applyLocale = (): void => {
active?.undo();
active = grid.selectAll((_dim, value) => value === PAY_GROUP);
active.selectAllCell(() => true).prop({ valueFormatter: localeFormatter(localeId) });
grid.draw();
};
// Only the Pay banner gets the dropdown; every other group facet renders plain.
const payHeader: FacetCellRenderer = (label) => {
if (label !== PAY_GROUP) return label;
const select = localeSelect(localeId, (next) => {
localeId = next;
applyLocale();
});
return { content: label, right: select };
};
ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
if (disposed) return;
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
columnFacets: [COLUMNS.map((column) => column.group), COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((_, i) => ({ colSize: { strategy: "static", width: COL_FR[i], unit: "fr" } })),
facetDefs: {
row: [],
col: [{ text: "", trackRenderer: payHeader }, { text: "" }],
axis: "col",
},
},
});
grid.draw();
// Format with the initial locale on first render.
applyLocale();
});
return () => {
disposed = true;
disposeTheme();
active?.undo();
el.removeChild(gridMount);
};
}
/* ===================================== utils ===================================== */
// A currency formatter for the given locale. Only the Pay columns' numeric data
// cells receive it, so nulls fall back to an empty cell.
function localeFormatter(localeId: string): ValueFormatter<SampleValue> {
const locale = LOCALES.find((l) => l.id === localeId)!;
const fmt = new Intl.NumberFormat(locale.id, {
style: "currency",
currency: locale.currency,
maximumFractionDigits: 0,
});
return (value) => (typeof value === "number" ? fmt.format(value) : "");
}
// The mini locale dropdown drawn into the Pay banner's right slot. `mousedown`
// is stopped so clicking it drives the select rather than any header behaviour.
function localeSelect(current: string, onChange: (next: string) => void): HTMLSelectElement {
const select = document.createElement("select");
select.style.cssText =
"font:inherit;font-size:11px;line-height:1;height:20px;padding:0 4px;cursor:pointer;" +
"color:inherit;background:transparent;border-radius:5px;" +
"border:1px solid color-mix(in srgb, currentColor 30%, transparent);";
for (const locale of LOCALES) {
const option = document.createElement("option");
option.value = locale.id;
option.textContent = locale.label;
select.appendChild(option);
}
select.value = current;
select.addEventListener("mousedown", (event) => event.stopPropagation());
select.addEventListener("change", () => onChange(select.value));
return select;
}