# Data grid

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/data-grid
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=506-22089

```tsx
'use client';

import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

const rows = [
  { id: 'revenue', label: 'Revenue', actual: 450000, budget: 420000 },
  {
    id: 'expenses',
    label: 'Operating expenses',
    actual: 185000,
    budget: 190000,
  },
];

const DataGridBasic = () => (
  <DataGrid
    label="Quarterly financial summary"
    rows={rows}
    rowConfig={{ getId: (row) => row.id }}
    rowHeaderConfig={{
      header: 'Line item',
      getLabel: (row) => row.label,
      width: 200,
    }}
    columns={[
      {
        id: 'q1',
        header: 'Q1 2026',
        columns: [
          {
            id: 'actual',
            header: 'Actual',
            align: 'end',
            width: 160,
            cell: (row) => <TableNumberCell value={row.actual} unit="USD" />,
          },
          {
            id: 'budget',
            header: 'Budget',
            align: 'end',
            width: 160,
            cell: (row) => <TableNumberCell value={row.budget} unit="USD" />,
          },
        ],
      },
    ]}
  />
);

export default DataGridBasic;
```


Use DataGrid when readers compare values across periods or entities. Each record supplies a row label and values; the grid preserves that context while navigating cells. Use [Table](/docs/components/table) for searchable record lists with selection and pagination.

## Usage [#usage]

```tsx
'use client';

import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';
```

Import `@commenda/pathfinder/styles.css` once in your application layout. Each example's **Code** tab contains its complete source with formatting and line numbers. Start with stable row IDs, a `rowHeaderConfig`, and value columns; add grouping or editing when needed.

## Grouped headers [#grouped-headers]


```tsx
import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridPatternsDefault = () => {
  const rows = [
    { id: 'revenue', label: 'Revenue', actual: 125400, budget: 120000 },
    { id: 'expenses', label: 'Expenses', actual: 86000, budget: 84000 },
  ];

  return (
    <DataGrid
      label="Nested reporting bands"
      rows={rows}
      rowConfig={{ getId: (row) => row.id }}
      rowHeaderConfig={{ header: 'Line item', getLabel: (row) => row.label }}
      columns={[
        {
          id: 'year',
          header: 'FY 2026',
          columns: [
            {
              id: 'quarter',
              header: 'Q1',
              columns: [
                {
                  id: 'actual',
                  header: 'Actual',
                  width: 160,
                  cell: (row) => (
                    <TableNumberCell value={row.actual} unit="USD" />
                  ),
                },
                {
                  id: 'budget',
                  header: 'Budget',
                  width: 160,
                  cell: (row) => (
                    <TableNumberCell value={row.budget} unit="USD" />
                  ),
                },
              ],
            },
            {
              id: 'total',
              header: 'Total actual',
              width: 160,
              cell: (row) => <TableNumberCell value={row.actual} unit="USD" />,
            },
          ],
        },
        {
          id: 'variance',
          header: 'Variance',
          width: 160,
          cell: (row) => (
            <TableNumberCell
              value={row.actual - row.budget}
              unit="USD"
              tone={row.id === 'expenses' ? 'critical' : 'positive'}
            />
          ),
        },
      ]}
    />
  );
};
```


FY 2026 contains Q1 Actual/Budget and a Total column. Variance sits outside the year. This intentionally mixes header depths: groups span their descendant leaves and shallower branches span the remaining levels.

A group has `{ id, header, columns }`; a value column has an accessor or renderer. Groups recurse, but all IDs must be unique across the tree and every group must contain columns. Use a Total column per period group when the domain supports that aggregate.

## Expandable rows and totals [#expandable-rows-and-totals]


```tsx
import { useState } from 'react';

import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridGuidesDefault = () => {
  interface Account {
    id: string;
    label: string;
    amount: number;
    children?: Account[];
  }

  const rows: Account[] = [
    {
      id: 'expenses',
      label: 'Operating expenses',
      amount: 86000,
      children: [
        { id: 'salary', label: 'Salaries', amount: 70000 },
        { id: 'rent', label: 'Office rent', amount: 16000 },
      ],
    },
  ];
  const [expanded, setExpanded] = useState<readonly string[]>(['expenses']);

  return (
    <DataGrid
      label="Account hierarchy"
      rows={rows}
      rowConfig={{
        getId: (row) => row.id,
        getChildren: (row) => row.children ?? [],
        getSubtotal: (row) => ({
          ...row,
          label: `Total ${row.label.toLowerCase()}`,
        }),
      }}
      rowHeaderConfig={{ header: 'Account', getLabel: (row) => row.label }}
      expansionConfig={{ value: expanded, onValueChange: setExpanded }}
      columns={[
        {
          id: 'amount',
          header: 'Amount',
          width: 240,
          cell: (row) => (
            <TableNumberCell
              value={row.amount}
              unit="USD"
              format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
            />
          ),
        },
      ]}
      totalConfig={{
        label: 'Grand total',
        row: { id: 'total', label: 'Grand total', amount: 86000 },
      }}
    />
  );
};
```


Collapse Operating expenses. The parent now shows its supplied aggregate; expand it to reveal Salaries, Office rent and a subtotal below the children. The grand total remains independent of expansion.

Control expanded IDs with `expansionConfig`. `getChildren` supplies the hierarchy and `getSubtotal` supplies the subtotal record. The app owns every aggregate. Do not add parent aggregates and their children together, or assume averages and ratios are additive.

## Currency and trends [#currency-and-trends]


```tsx
import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridGuidesFormatting = () => {
  const rows = [
    { id: 'revenue', label: 'Revenue', amount: 125400.5, change: 0.12 },
    { id: 'expenses', label: 'Expenses', amount: 86000, change: 0.04 },
    { id: 'other', label: 'Other income', amount: null, change: null },
  ];

  return (
    <DataGrid
      label="Formatted financial values"
      rows={rows}
      rowConfig={{ getId: (row) => row.id }}
      rowHeaderConfig={{ header: 'Line item', getLabel: (row) => row.label }}
      columns={[
        {
          id: 'amount',
          header: 'Amount',
          width: 200,
          cell: (row) => (
            <TableNumberCell
              value={row.amount}
              unit="USD"
              format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
            />
          ),
        },
        {
          id: 'change',
          header: 'Change',
          width: 180,
          cell: (row) => (
            <TableNumberCell
              value={row.change}
              format={{
                style: 'percent',
                minimumFractionDigits: 1,
                maximumFractionDigits: 1,
                signDisplay: 'exceptZero',
              }}
              tone={
                row.id === 'expenses'
                  ? 'critical'
                  : row.id === 'revenue'
                    ? 'positive'
                    : 'neutral'
              }
              trend={
                row.change === null
                  ? undefined
                  : {
                      direction: 'up',
                      intent: row.id === 'expenses' ? 'critical' : 'positive',
                    }
              }
            />
          ),
        },
      ]}
    />
  );
};
```


Amounts keep two decimal places and a separate USD code. Missing income is an en dash. Both trends point up, but rising expenses are critical while rising revenue is positive: direction does not determine business meaning.

`TableNumberCell` formats at the display boundary. Use `unit="USD"` for a separate ISO code; avoid also asking Intl to add a currency symbol unless repetition is intended. `style: 'percent'` expects a fraction (`0.12` → 12%). For whole percentage values use `value={12}` with `suffix="%"` instead. Accounting precision, rounding and conversion belong to the application.

## Frozen headers and scrolling [#frozen-headers-and-scrolling]


```tsx
import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridGuidesScrolling = () => {
  const rows = Array.from({ length: 16 }, (_, index) => ({
    id: `account-${String(index)}`,
    label: `Account ${String(index + 1).padStart(2, '0')}`,
    monthly: (index + 1) * 1250,
  }));

  return (
    <DataGrid<(typeof rows)[number]>
      label="Scrollable monthly budget"
      rows={rows}
      rowConfig={{ getId: (row) => row.id }}
      rowHeaderConfig={{
        header: 'Account',
        getLabel: (row) => row.label,
        width: 200,
      }}
      scrollConfig={{ maxHeight: 260 }}
      columns={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'].map((month) => ({
        id: month,
        header: month,
        width: 160,
        cell: (row) => <TableNumberCell value={row.monthly} unit="USD" />,
      }))}
      totalConfig={{
        label: 'Total budget',
        row: {
          id: 'total',
          label: 'Total budget',
          monthly: rows.reduce((sum, row) => sum + row.monthly, 0),
        },
      }}
    />
  );
};
```


Scroll through sixteen accounts and six monthly columns. The first column preserves row context, headers stay above the values, and the grand total pins below them. Set a bounded `scrollConfig.maxHeight` rather than allowing a long matrix to dominate the page.

At narrow widths, horizontal pinning releases when the label plus one value column cannot fit. Values remain reachable through horizontal scrolling. The example repeats a monthly budget for clarity; real monthly amounts and totals should come from your reporting data.

## Editing allocations [#editing-allocations]


```tsx
import { useState } from 'react';

import { DataGrid } from '@commenda/pathfinder/data-grid';
import { DEFlag } from '@commenda/pathfinder/flags/DEFlag';
import { GBFlag } from '@commenda/pathfinder/flags/GBFlag';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { TableCell, TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridEditing = () => {
  const [rows, setRows] = useState([
    {
      id: 'de',
      label: 'Meridian GmbH',
      it: 40,
      hr: 25,
      finance: 30,
      legal: 35,
    },
    {
      id: 'us',
      label: 'Meridian US Inc.',
      it: 35,
      hr: 45,
      finance: 35,
      legal: 40,
    },
    {
      id: 'uk',
      label: 'Meridian UK Ltd.',
      it: 25,
      hr: 30,
      finance: 25,
      legal: 25,
    },
  ]);
  const [saveStatus, setSaveStatus] = useState('All changes saved');

  const sum = (key: 'it' | 'hr' | 'finance' | 'legal') =>
    rows.reduce((total, row) => total + row[key], 0);

  return (
    <div className="pf:w-full pf:min-w-0">
      <DataGrid<(typeof rows)[number]>
        label="Cost allocation"
        rows={rows}
        rowConfig={{ getId: (row) => row.id }}
        rowHeaderConfig={{
          header: 'Entity',
          getLabel: (row) => row.label,
          width: 240,
          cell: (row) => (
            <TableCell
              type="entity"
              label={row.label}
              icon={
                row.id === 'de' ? (
                  <DEFlag />
                ) : row.id === 'uk' ? (
                  <GBFlag />
                ) : (
                  <USFlag />
                )
              }
            />
          ),
        }}
        columns={(['it', 'hr', 'legal'] as const).map((key) => ({
          id: key,
          header: key === 'it' ? 'IT' : key === 'hr' ? 'HR' : 'Legal',
          width: 128,
          cell: (row) => (
            <TableNumberCell
              value={row[key]}
              tone={
                row.id === 'total' && Math.abs(row[key] - 100) > 0.001
                  ? 'critical'
                  : 'neutral'
              }
              suffix="%"
              format={{
                minimumFractionDigits: 2,
                maximumFractionDigits: 2,
              }}
            />
          ),
          validationMessage: (row) =>
            row.id === 'total' && Math.abs(row[key] - 100) > 0.001
              ? `${key.toUpperCase()} adds up to ${String(row[key])}%. Allocations need to total 100%.`
              : undefined,
          editConfig: {
            editor: { type: 'number', min: 0, max: 100, suffix: '%' },
            getValue: (row) => String(row[key]),
            validate: (value) =>
              value.trim() === '' ||
              !Number.isFinite(Number(value)) ||
              Number(value) < 0 ||
              Number(value) > 100
                ? 'Enter a percentage between 0 and 100.'
                : undefined,
            onCommit: async (row, value) => {
              setSaveStatus('Saving changes…');
              await new Promise((resolve) => setTimeout(resolve, 250));
              setRows((current) =>
                current.map((item) =>
                  item.id === row.id ? { ...item, [key]: Number(value) } : item
                )
              );
              setSaveStatus('Saved just now');
            },
          },
        }))}
        totalConfig={{
          label: 'Total',
          row: {
            id: 'total',
            label: 'Total',
            it: sum('it'),
            hr: sum('hr'),
            finance: sum('finance'),
            legal: sum('legal'),
          },
        }}
      />
      <p
        role="status"
        className="pf:mt-lg pf:text-label-12 pf:text-soft-pearl-800"
      >
        {saveStatus}
      </p>
    </div>
  );
};
```


Click the German entity's Legal allocation, change **35** to **40**, then press Enter. The example saves the value, updates “Saved just now”, and marks the Legal total **105%** with an explanation. A successful save and a valid aggregate are separate states.

Only leaf record cells edit; parents, subtotals and totals remain read-only. Enter or leaving a changed text/number cell saves; Escape cancels. Finish or cancel an open editor before starting another. Select/calendar portals remain part of the same editing session.

The app owns persistence and the status line. After the request resolves, replace the row immutably with the canonical response. Use server versions or another concurrency policy to reject stale writes from other sessions.

## Save failure and retry [#save-failure-and-retry]


```tsx
import { useState } from 'react';

import { Checkbox } from '@commenda/pathfinder/checkbox';
import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridPatternsRecovery = () => {
  const [amount, setAmount] = useState('40.00');
  const [rejectSave, setRejectSave] = useState(true);
  const [status, setStatus] = useState(
    'Changes are saved locally in this demo'
  );

  return (
    <div className="pf:w-full pf:space-y-lg">
      <Checkbox
        label="Simulate a failed save"
        checked={rejectSave}
        onCheckedChange={setRejectSave}
      />
      <DataGrid
        label="Save recovery"
        rows={[{ id: 'de', label: 'Acme GmbH', amount }]}
        rowConfig={{ getId: (row) => row.id }}
        rowHeaderConfig={{ header: 'Entity', getLabel: (row) => row.label }}
        columns={[
          {
            id: 'allocation',
            header: 'Allocation',
            width: 240,
            cell: (row) => (
              <TableNumberCell
                value={Number(row.amount)}
                suffix="%"
                format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
              />
            ),
            editConfig: {
              outlined: true,
              editor: { type: 'number', min: 0, max: 100, suffix: '%' },
              getValue: (row) => row.amount,
              validate: (value) =>
                value.trim() ? undefined : 'Enter an allocation.',
              onCommit: async (_row, value) => {
                setStatus('Saving…');
                await new Promise((resolve) => setTimeout(resolve, 300));
                if (rejectSave) {
                  setStatus('Save failed; your draft is retained');
                  throw new Error(
                    'Could not save. Check your connection and retry.'
                  );
                }
                setAmount(value);
                setStatus('Saved just now');
              },
            },
          },
        ]}
      />
      <p role="status" className="pf:text-label-12 pf:text-soft-pearl-800">
        {status}
      </p>
    </div>
  );
};
```


Change the outlined allocation and press Enter with simulated failure enabled. The draft stays available. Disable the simulation and choose Retry save. Success is announced only after the simulated request resolves; the example makes no network calls.

| Event                                   | Result                                                  |
| --------------------------------------- | ------------------------------------------------------- |
| Click, Enter or F2 on an editable value | Opens one editor with its canonical string draft        |
| Valid changed draft + Enter or leaving  | Starts one commit transaction                           |
| Escape before persistence               | Restores the original value and cell focus              |
| Validation failure                      | Keeps the editor and explanation visible                |
| Pending request                         | Prevents duplicate commits                              |
| Rejected request                        | Retains the draft and exposes Retry save                |
| Resolved request                        | Closes the editor; app supplies updated data and status |

Use `outlined` for persistent resting borders. Text and number editors use compact 28px sizing; existing Select/DatePicker controls retain their accessible dimensions. Numeric drafts remain strings until validated and parsed by the application.

## Profit and loss [#profit-and-loss]


```tsx
import { DataGrid } from '@commenda/pathfinder/data-grid';
import { TableNumberCell } from '@commenda/pathfinder/table';

export const DataGridPivot = () => {
  interface Line {
    id: string;
    label: string;
    q1: number;
    q2: number;
    q3: number;
    q4: number;
    previous: number;
    children?: Line[];
  }

  const rows: Line[] = [
    {
      id: 'revenue',
      label: 'Revenue',
      q1: 450000,
      q2: 480000,
      q3: 510000,
      q4: 560000,
      previous: 1800000,
      children: [
        {
          id: 'services',
          label: 'Services',
          q1: 350000,
          q2: 380000,
          q3: 410000,
          q4: 450000,
          previous: 1400000,
        },
        {
          id: 'licences',
          label: 'Licences',
          q1: 100000,
          q2: 100000,
          q3: 100000,
          q4: 110000,
          previous: 400000,
        },
      ],
    },
    {
      id: 'expenses',
      label: 'Operating expenses',
      q1: 185000,
      q2: 195000,
      q3: 200000,
      q4: 210000,
      previous: 730000,
      children: [
        {
          id: 'salaries',
          label: 'Salaries',
          q1: 140000,
          q2: 150000,
          q3: 155000,
          q4: 165000,
          previous: 550000,
        },
        {
          id: 'rent',
          label: 'Office rent',
          q1: 30000,
          q2: 30000,
          q3: 30000,
          q4: 30000,
          previous: 120000,
        },
        {
          id: 'software',
          label: 'Software',
          q1: 15000,
          q2: 15000,
          q3: 15000,
          q4: 15000,
          previous: 60000,
        },
      ],
    },
    {
      id: 'costs',
      label: 'Cost of sales',
      q1: 95000,
      q2: 105000,
      q3: 115000,
      q4: 130000,
      previous: 420000,
      children: [
        {
          id: 'delivery',
          label: 'Delivery',
          q1: 95000,
          q2: 105000,
          q3: 115000,
          q4: 130000,
          previous: 420000,
        },
      ],
    },
  ];

  const currency = (value: number) => (
    <TableNumberCell value={value} unit="USD" />
  );

  const annual = (row: Line) => row.q1 + row.q2 + row.q3 + row.q4;

  return (
    <DataGrid
      label="Profit and loss"
      scrollConfig={{ maxHeight: 560 }}
      rows={rows}
      rowConfig={{
        getId: (row) => row.id,
        getChildren: (row) => row.children ?? [],
        getSubtotal: (row) => ({
          ...row,
          label: `Total ${row.label.toLowerCase()}`,
        }),
      }}
      rowHeaderConfig={{
        header: 'Line item',
        getLabel: (row) => row.label,
        width: 240,
      }}
      expansionConfig={{ defaultValue: ['revenue', 'expenses'] }}
      columns={[
        {
          id: 'fy2026',
          header: 'FY 2026',
          columns: [
            ...(['q1', 'q2', 'q3', 'q4'] as const).map((key) => ({
              id: key,
              header: key.toUpperCase(),
              width: 128,
              cell: (row: Line) => currency(row[key]),
            })),
            {
              id: 'current',
              header: 'Total',
              width: 144,
              cell: (row) => currency(annual(row)),
            },
          ],
        },
        {
          id: 'fy2025',
          header: 'FY 2025',
          columns: [
            {
              id: 'previous',
              header: 'Total',
              width: 144,
              cell: (row) => currency(row.previous),
            },
          ],
        },
        {
          id: 'change',
          header: 'Change vs FY 2025',
          width: 160,
          cell: (row) => (
            <TableNumberCell
              value={(annual(row) - row.previous) / row.previous}
              tone={
                row.id === 'expenses' ||
                row.id === 'costs' ||
                row.id === 'salaries'
                  ? 'critical'
                  : 'positive'
              }
              format={{
                style: 'percent',
                signDisplay: 'exceptZero',
                minimumFractionDigits: 1,
                maximumFractionDigits: 1,
              }}
              trend={{
                direction: annual(row) >= row.previous ? 'up' : 'down',
                intent:
                  row.id === 'expenses' || row.id === 'costs'
                    ? 'critical'
                    : 'positive',
              }}
            />
          ),
        },
      ]}
      totalConfig={{
        label: 'Operating profit',
        row: {
          id: 'profit',
          label: 'Operating profit',
          q1: 170000,
          q2: 180000,
          q3: 195000,
          q4: 220000,
          previous: 650000,
        },
      }}
    />
  );
};
```


This complete example combines year/quarter bands, expandable account groups, period totals, prior-year comparison and operating profit. It demonstrates composition after the smaller examples above. Subtotals and profit are supplied deliberately; the grid is not a formula engine.

## Loading, empty and error [#loading-empty-and-error]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { DataGrid } from '@commenda/pathfinder/data-grid';

export const DataGridPatternsStates = () => {
  const [state, setState] = useState<'loading' | 'empty' | 'error'>('loading');

  return (
    <div className="pf:w-full pf:space-y-lg">
      <div className="pf:flex pf:flex-wrap pf:gap-md">
        {(['loading', 'empty', 'error'] as const).map((value) => (
          <Button
            key={value}
            size="sm"
            variant="secondary"
            onClick={() => {
              setState(value);
            }}
          >
            {value === 'loading'
              ? 'Loading'
              : value === 'empty'
                ? 'Empty'
                : 'Error'}
          </Button>
        ))}
      </div>
      <DataGrid
        label="Matrix states"
        rows={[]}
        rowConfig={{ getId: () => '' }}
        rowHeaderConfig={{ header: 'Entity', getLabel: () => '' }}
        columns={[{ id: 'value', header: 'Allocation' }]}
        stateConfig={{
          isLoading: state === 'loading',
          error:
            state === 'error'
              ? {
                  message:
                    'Could not load allocations. Your reporting scope is preserved.',
                  onRetry: () => {
                    setState('loading');
                  },
                }
              : undefined,
          empty: {
            title: 'No allocations yet',
            description: 'Choose a reporting period with allocation data.',
          },
        }}
      />
    </div>
  );
};
```


Switch between skeletons, no allocation data and a retryable failure. These reuse Pathfinder's existing state components. Keep the reporting scope stable during loading and retry; distinguish “no data in this period” from a failed request.

## Keyboard navigation [#keyboard-navigation]

| Key               | Behavior                                                        |
| ----------------- | --------------------------------------------------------------- |
| Tab               | Enters the active owning cell; leaves the grid when not editing |
| Arrow keys        | Moves among cells; horizontal direction follows RTL             |
| Home / End        | Moves to the row edges                                          |
| Ctrl + Home / End | Moves to the first/last grid cell                               |
| Enter / F2        | Edits a value or activates the row-group control                |
| Escape            | Cancels an edit and restores cell focus                         |

Native controls retain their own keyboard interactions while editing. Avoid independently tabbable action clusters in every matrix cell; record-heavy actions usually belong in Table.

## Scope and performance [#scope-and-performance]

The grid renders its scoped matrix without virtualization. Use bounded periods/entities and profile representative row and column counts. Storybook's 1,000-row adversarial fixture is a stress scenario, not an unlimited-data performance guarantee. Range selection, fill handle, bulk paste and undo remain outside the Figma v1 scope.

See the [Figma source coverage and deliberate differences](/docs/components/data-grid#design-rules) for provenance, dimensions and accessibility adjustments.

## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `label` | `string` | required | Required accessible grid name |
| `rows` | `readonly TRow[]` | required | Root rows; app owns immutable updates |
| `columns` | `readonly DataGridColumn<TRow>[]` | required | Recursive bands or value columns |
| `rowConfig` | `{ getId: (row: TRow) => string; getChildren?: (row: TRow) => readonly TRow[]; getSubtotal?: (row: TRow) => TRow \| undefined; }` | required | Stable IDs; optional child and subtotal accessors |
| `rowHeaderConfig` | `{ header: string; getLabel: (row: TRow) => string; cell?: (row: TRow) => ReactNode; width?: number; }` | required | Frozen row axis; width defaults to 240px |
| `expansionConfig` | `SelectionProps<readonly string[]>` | optional | Controlled/default expanded row IDs |
| `totalConfig` | `{ row: TRow; label: string; }` | optional | App-supplied grand total pinned at the bottom |
| `scrollConfig` | `{ maxHeight?: number; }` | optional | Bounded scrolling; default maximum height 480px |
| `stateConfig` | `TabularStateConfig` | optional | Skeleton, retryable error and first-use state |
| `dataMode` | `"local" \| "remote"` | optional | Local filters matching leaf paths; remote renders supplied rows unchanged |
| `searchConfig` | `SelectionProps<string> & { getText?: ((row: TRow) => string) \| undefined; placeholder?: string; }` | optional | Controlled or local search; defaults to row labels |
| `filterConfig` | `{ filters: readonly TableFilter<TRow>[]; operators?: SelectionProps<Readonly<Record<string, TableFilterOperator>>>; value: Readonly<Record<string, readonly string[]>>; onValueChange: (value: Record<string, readonly string[]>) => void; }` | optional | Definitions, controlled values/callback, controlled or default operators |
| `toolbarConfig` | `TableToolbarConfig` | optional | Primary, secondary, export and overflow actions |
| `toolbar` | `ReactNode` | optional | App-owned matrix controls above the grid |
| `className` | `string` | optional | Root classes |

### rowConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `getId` | `(row: TRow) => string` | Yes |  |
| `getChildren` | `((row: TRow) => readonly TRow[])` | No |  |
| `getSubtotal` | `((row: TRow) => TRow \| undefined)` | No |  |

### rowHeaderConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `header` | `string` | Yes |  |
| `getLabel` | `(row: TRow) => string` | Yes |  |
| `cell` | `((row: TRow) => ReactNode)` | No |  |
| `width` | `number` | No |  |

### expansionConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `readonly string[]` | Yes |  |
| `onValueChange` | `(value: readonly string[]) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `readonly string[]` | No |  |
| `onValueChange` | `((value: readonly string[]) => void)` | No |  |

Do not supply: `value`.

### totalConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `row` | `TRow` | Yes |  |
| `label` | `string` | Yes |  |

### scrollConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `maxHeight` | `number` | No |  |

### stateConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `isLoading` | `boolean` | No |  |
| `error` | `{ message: string; onRetry: () => void; }` | No |  |
| `empty` | `{ title: string; description?: string; action?: TableAction; }` | No |  |

### stateConfig.error fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | `string` | Yes |  |
| `onRetry` | `() => void` | Yes |  |

### stateConfig.empty fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | `string` | Yes |  |
| `description` | `string` | No |  |
| `action` | `TableAction` | No |  |

### stateConfig.empty.action fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |
| `label` | `string` | Yes |  |
| `onAction` | `() => void` | Yes |  |
| `disabled` | `boolean` | No |  |

### filterConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `filters` | `readonly TableFilter<TRow>[]` | Yes |  |
| `operators` | `SelectionProps<Readonly<Record<string, TableFilterOperator>>>` | No |  |
| `value` | `Readonly<Record<string, readonly string[]>>` | Yes |  |
| `onValueChange` | `(value: Record<string, readonly string[]>) => void` | Yes |  |

### filterConfig.operators fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `Readonly<Record<string, TableFilterOperator>>` | Yes |  |
| `onValueChange` | `(value: Readonly<Record<string, TableFilterOperator>>) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Readonly<Record<string, TableFilterOperator>>` | No |  |
| `onValueChange` | `((value: Readonly<Record<string, TableFilterOperator>>) => void)` | No |  |

Do not supply: `value`.

### toolbarConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `actions` | `readonly TableAction[]` | No | Prefer named actions. Legacy actions use first=primary, second=secondary, remainder=overflow. |
| `primaryAction` | `(TableAction & { menu?: DropdownMenu; icon?: ReactNode; })` | No |  |
| `secondaryAction` | `TableAction` | No |  |
| `onExport` | `(() => void)` | No |  |
| `moreMenu` | `DropdownMenu` | No |  |

### toolbarConfig.secondaryAction fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |
| `label` | `string` | Yes |  |
| `onAction` | `() => void` | Yes |  |
| `disabled` | `boolean` | No |  |

### toolbarConfig.moreMenu fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `groups` | `DropdownGroup[]` | Yes |  |

A column group is `{ id, header, columns }`; groups can nest recursively. A value column is `{ id, header, value?, cell?, width?, align?, editConfig?, validationMessage? }`. Default width is 128px and alignment is `end`. IDs must be unique across the column tree; groups cannot be empty. Row IDs must be unique throughout an acyclic row tree. Invalid recursive structures throw a clear configuration error.

Use `cell` with TableCell/TableNumberCell for entity flags, numeric/currency formatting and trends. Header bands span their descendant leaves; shorter branches span the remaining header levels. The first column and headers remain visible while scrolling horizontally and vertically. If the container cannot fit the row header plus a value column, horizontal pinning releases so values remain reachable; column headers still pin vertically. Keyboard arrows follow logical order in RTL.

### Editing configuration [#editing-configuration]

A value column's `editConfig` contains:

| Property   | Type                                    | Behavior                                               |
| ---------- | --------------------------------------- | ------------------------------------------------------ |
| `editor`   | TableCellEditor                         | Text, numeric draft, Select or segmented date/calendar |
| `outlined` | boolean                                 | Keeps resting editor outlines visible                  |
| `getValue` | `(row) => string`                       | Editable canonical draft                               |
| `canEdit`  | `(row) => boolean`                      | Optional app permission predicate                      |
| `validate` | `(value, row) => string or undefined`   | App validation before saving                           |
| `onCommit` | `(row, value) => void or Promise<void>` | App persistence and row update                         |

Only leaf record rows edit; groups, subtotals and totals stay read-only. One editor opens at a time. Leaving a changed cell saves; Enter saves text/numbers; Escape discards the draft. Pending saves cannot duplicate. Rejection retains the draft with a readable error and retry. While an editor is still open, finish or cancel it before editing another cell. Do not silently accept failed requests.

`validationMessage(row)` exposes invalid totals with an accessible explanation and error color. The allocation example calculates totals in its application layer; changing Legal from 35 to 40 produces a 105% invalid total, not a modal alert. Number strings are parsed by the app after validation. Financial precision, permissions, stale-write protection and server transactions remain application responsibilities.

## Design rules [#design-rules]

Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.

### Shared cells [#shared-cells]

- **CELL-001:** Text starts at the logical leading edge; numbers align at the trailing edge and use tabular figures. Empty values show an en dash. Entity flags are 16px, person avatars 20px. Entity names wrap; do not truncate monetary amounts. Cells compose existing Badge, Tag, Avatar, ProgressBar and exact icon assets.
- **CELL-002:** Currency units remain quiet and separate from values. Fixed decimals are supplied via Intl format options. Trend direction and semantic intent are separate. A cost increase can be critical even though its arrow points up.
- **CELL-003:** At most primary bordered icon, secondary icon and overflow in a row action group. Do not repeat text CTAs in every row.
- **CELL-EDIT-001:** Compact text/number editor minimum 28px and radius 6px; calm border at rest, outline on hover/focus, persistent invalid treatment. Existing Select and DatePicker keep their accessible field geometry; the DatePicker retains the user's approved Kakashi DateField padding rather than shrinking its segmented inputs.
- **CELL-EDIT-002:** Enter or leaving the editor commits one cell; Escape cancels. Pending work cannot duplicate; errors retain drafts and offer retry. React portal focus stays within the same editing session. Application code validates and persists canonical values.

The Figma cell master depicts combinations, not 32 separate exported component classes. The shared content family composes those visual patterns. Toggle and upload actions use existing Switch/FileUpload application workflows in custom cells; they do not pretend to be string editors. TableCell content, TableNumberCell, TableRowActions and TableEditCell work in both owning components.

### Matrix grid [#matrix-grid]

- **GRID-001:** Pearl-100 headers, 36px bands, 40px minimum body rows, 0.5px vertical/horizontal gridlines. Freeze the first column and headers. Grouped columns recurse; row-header width defaults to 240px and value columns to 128px.
- **GRID-002:** Collapsed groups display supplied aggregates. Expanded groups place supplied subtotals below children; subtotal background is pearl-100. Grand total pins to the bottom with a pearl-400 1px top border. No inferred formulas or double-counting.
- **GRID-003:** One active cell with inset blue-700 outline and blue-200 ring. Arrow keys navigate; Enter/F2 edits or opens a group; Escape cancels. No range selection, fill handle, bulk paste, undo or virtualization in this version.
- **GRID-004:** Only leaf record cells edit. Totals/aggregates remain read-only. Invalid totals explain the problem with error styling and a tooltip, not a modal. The app owns calculations, precision, constraints, permission checks and persistence.

Quiet secondary text uses pearl-800 where the design's lighter text would fail contrast at these small sizes. Narrow layouts release horizontal pinning when the frozen columns would hide readable values and scroll within their own container; financial values retain their width. Storybook's shared worst-case fixture covers long CJK/emoji names, large negative values, 1,000 paginated records, empty data, RTL and a 320px container.

### Explicit differences and boundaries [#explicit-differences-and-boundaries]

| Reference                                                                                                 | Coverage                                                                                                                                                                                                                                                                                                                                                     |
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Cell master 430:13537](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=430-13537) | Text/description/icon/badge, people/groups/email, entities, files, tags, boolean, progress, copy/link, numbers/units/currency/trends, loading and totals compose TableCell, TableNumberCell and TableRowActions. Tree parent/child/grandchild behavior belongs to the recursive DataGrid examples. Hover and selection share the row tint in both densities. |

- **Accessibility:** quiet text uses pearl-800 for contrast. Long content and text zoom can grow rows. Narrow viewports release horizontal pinning. Copy controls remain visible on touch, and long status labels expose their full text on focus/hover.

- **Application patterns:** presets, summary aggregates, “Saved just now”, network requests, exports, permissions and financial calculations are owned by the app. Demos perform local updates or simulated requests and say so.

- **States and scale:** matrix loading/error fallbacks reuse existing primitives. No virtualization, range operations or spreadsheet formulas are claimed. Stress fixtures do not establish an unlimited-data performance guarantee.

AI consumers should read this page together with the component API and integration pages. Do not infer a public prop from a Figma variant name, or treat a prototype's sample amounts as domain validation rules.

### Header and cell master reconciliation [#header-and-cell-master-reconciliation]

| Reference                                                                                                        | Coverage                                                                                                                                                                 |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Header master 430:13536](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=430-13536)      | Start/center/end alignment, unsorted/ascending/descending, hover/focus, optional information, checkbox and empty action header; Headers, Selection and Pinning examples. |
| [Groups and add row 445:13858](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=445-13858) | Grouped filings show flags, counts, collapse and page-selection persistence; editable lines show totals and Add line. Application callbacks own actual persistence.      |

Long content and zoom can grow rows beyond Figma's nominal heights. Linear progress and ring progress use their existing Pathfinder primitives; cell recipes do not introduce a new progress implementation. A file-picker example selects a local file and displays its name; production upload, progress and retry belong to the application's upload workflow. These are deliberate compositional boundaries, not built-in backend features.

## Search and filters [#search-and-filters]


```tsx
import { useState } from 'react';

import { DataGrid } from '@commenda/pathfinder/data-grid';

const records = [
  {
    id: 'uk',
    label: 'Meridian UK',
    jurisdiction: 'GB',
    status: 'review',
    due: '2026-10-20',
    amount: 1200,
  },
  {
    id: 'us',
    label: 'Meridian US',
    jurisdiction: 'US',
    status: 'complete',
    due: '2026-11-10',
    amount: 2400,
  },
  {
    id: 'de',
    label: 'Meridian Germany',
    jurisdiction: 'DE',
    status: 'review',
    due: '2026-10-25',
    amount: 800,
  },
];

export const DataGridFiltersDefault = () => {
  const [filters, setFilters] = useState<Record<string, readonly string[]>>({});

  return (
    <DataGrid
      label="Entity allocations"
      rows={records}
      rowConfig={{ getId: (row) => row.id }}
      rowHeaderConfig={{ header: 'Entity', getLabel: (row) => row.label }}
      columns={[
        {
          id: 'jurisdiction',
          header: 'Country',
          value: (row) => row.jurisdiction,
          align: 'start',
        },
        { id: 'amount', header: 'Allocation', value: (row) => row.amount },
        { id: 'due', header: 'Due date', value: (row) => row.due },
      ]}
      searchConfig={{ defaultValue: '' }}
      filterConfig={{
        value: filters,
        onValueChange: setFilters,
        filters: [
          {
            id: 'jurisdiction',
            label: 'Country',
            type: 'multiple',
            searchable: true,
            getValue: (row) => row.jurisdiction,
            options: [
              { value: 'GB', label: 'United Kingdom' },
              { value: 'US', label: 'United States' },
              { value: 'DE', label: 'Germany' },
            ],
          },
          {
            id: 'status',
            label: 'Status',
            type: 'single',
            getValue: (row) => row.status,
            options: [
              { value: 'review', label: 'In review' },
              { value: 'complete', label: 'Complete' },
            ],
          },
          {
            id: 'due',
            label: 'Due date',
            type: 'date',
            getValue: (row) => row.due,
            presets: [
              {
                id: 'october',
                label: 'October 2026',
                start: '2026-10-01',
                end: '2026-10-31',
              },
            ],
          },
          {
            id: 'amount',
            label: 'Allocation',
            type: 'number',
            initiallyVisible: false,
            getValue: (row) => row.amount,
          },
        ],
      }}
    />
  );
};
```


`searchConfig`, `filterConfig` and `toolbarConfig` share Table’s contracts. The grid reuses its single/multiple choice, searchable options, date presets/ranges, numeric operators, active chips, overflow and clear controls. Supply `toolbarConfig` for named primary/secondary/overflow/export actions; `toolbar` remains available for custom controls.

Local mode searches row labels by default; `searchConfig.getText` adds searchable fields. Filters combine with AND between fields. Matching leaves retain and expand their ancestor paths, including groups whose labels match the search. Clearing the search and filters restores the previous expansion state. Expansion controls are unavailable while showing filtered paths.

**GRID-FILTER-001:** Local filtering hides subtotals and grand totals because arbitrary cell values cannot safely be aggregated. Clear filters to restore complete totals. If the API computes filtered aggregates, pass those with `dataMode="remote"`.

### API-owned filtering [#api-owned-filtering]


```tsx
import { useState } from 'react';

import {
  DataGrid,
  type DataGridFilterOperator,
} from '@commenda/pathfinder/data-grid';

const records = [
  {
    id: 'uk',
    label: 'Meridian UK',
    jurisdiction: 'GB',
    status: 'review',
    due: '2026-10-20',
    amount: 1200,
  },
  {
    id: 'us',
    label: 'Meridian US',
    jurisdiction: 'US',
    status: 'complete',
    due: '2026-11-10',
    amount: 2400,
  },
  {
    id: 'de',
    label: 'Meridian Germany',
    jurisdiction: 'DE',
    status: 'review',
    due: '2026-10-25',
    amount: 800,
  },
];

export const DataGridFiltersRemote = () => {
  const [query, setQuery] = useState('');
  const [operators, setOperators] = useState<
    Readonly<Record<string, DataGridFilterOperator>>
  >({});
  const [filters, setFilters] = useState<Record<string, readonly string[]>>({});
  // Simulates a server response. Real applications fetch with query/filter/operator state and cancel stale requests.
  const response = records.filter(
    (row) =>
      row.label.toLowerCase().includes(query.toLowerCase()) &&
      (!Object.hasOwn(filters, 'status') ||
        filters.status.length === 0 ||
        (operators.status === 'isNot'
          ? !filters.status.includes(row.status)
          : filters.status.includes(row.status)))
  );

  return (
    <div style={{ display: 'grid', gap: 12, width: '100%' }}>
      <p>
        Simulated API response. Search and filter callbacks own the request
        state.
      </p>
      <DataGrid
        label="API allocations"
        dataMode="remote"
        rows={response}
        rowConfig={{ getId: (row) => row.id }}
        rowHeaderConfig={{ header: 'Entity', getLabel: (row) => row.label }}
        columns={[
          { id: 'amount', header: 'Allocation', value: (row) => row.amount },
        ]}
        searchConfig={{ value: query, onValueChange: setQuery }}
        filterConfig={{
          operators: { value: operators, onValueChange: setOperators },
          value: filters,
          onValueChange: setFilters,
          filters: [
            {
              id: 'status',
              label: 'Status',
              type: 'single',
              options: [
                { value: 'review', label: 'In review' },
                { value: 'complete', label: 'Complete' },
              ],
            },
          ],
        }}
      />
    </div>
  );
};
```


**GRID-FILTER-002:** In `dataMode="remote"`, the component renders the rows supplied by the application without applying local filtering. Control query, values and operators with their callbacks, cancel stale requests, and supply `stateConfig` for loading, errors and retry. The example simulates a response in memory; it does not perform a network request.
