Column

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;
}

On this page