# DataGrid

_Box Kite 2.2.5 · a markdown copy of https://www.box-kite.dev/datagrid/_

A powerful data grid component with sorting, filtering, and custom cells. (Work in Progress)

**Import**

```jsx
import DataGrid from '@box-kite/react/components/dataGrid';
```

## Keyboard and roles

This is the APG grid pattern, over a virtualized body — the case most grids skip. The scrolling element is a `role="grid"` of `rowgroup`s, rows and `gridcell`s, and it tells assistive technology the size of the whole grid rather than the size of the window it renders: `aria-rowcount` counts every row, `aria-rowindex` numbers each one where it really is, and a jump to a row nobody has scrolled to brings it into view first. You write none of that.

Focus lives on the cells, and the grid is one tab stop

One cell is in the tab order at a time, so Tab enters the grid in a single press and **the next one leaves it** — APG's rule, since a grid is a composite widget. The arrow keys move that cell; a sortable header sorts on Enter; a cell that holds a control hands the keyboard over on Enter or F2, where Tab then walks that cell's own controls, and Escape takes it back. Nothing the grid draws is a tab stop of its own, so a wide grid costs one press to cross rather than one per checkbox.

Space selects the row, and Tab can walk the cells if you ask

**Space on any cell selects its row** — or all of them, from the header cell the select-all box sits in — which is how a grid is selected without a mouse now that the checkboxes are reached through their cells. For a data-entry screen that wants the spreadsheet reading instead, `def.tabNavigation="cells"` makes Tab walk to the next cell and on into the rows after it, the way a spreadsheet does; the cost is that Tab is then no longer the way out of the grid.

Give it a title

A grid is not named by the rows in it. Pass `def.title` and the grid points `aria-labelledby` at it.

Everything the grid draws for itself has a name

The column chooser, the select-all box, each row’s checkbox and expander, each column’s menu and each filter input are named after what they act on — “Select row 4”, “Filter Country”, “Column options for Age” — rather than after the icon drawn on them.

A column can be resized without a mouse

Each resizer is a `role="separator"` — APG’s window splitter — with the column’s width in pixels on `aria-valuenow`, so a screen reader reads the new width out as it changes.

**Full Featured DataGrid**

```jsx
// A def that never changes: the quick filters read their state from context, not from def.
const def = {
  title: 'All Features Demo',
  topBar: true,
  bottomBar: true,
  globalFilter: true,
  topBarContent: <CustomTopBarFilter />,
  rowSelection: { pinned: true },
  showRowNumber: { pinned: true },
  rowHeight: 40,
  visibleRowsCount: 8,
  columns: [...],
};

const filters = useMemo(() => {
  const result = [];
  if (gender) result.push((row) => row.gender === gender);
  if (age === 'under30') result.push((row) => row.age < 30);
  return result;
}, [gender, age]);

// The control answers on the click; the grid re-filters in a render React may interrupt.
const deferredFilters = useDeferredValue(filters);

<DataGrid data={allData} filters={deferredFilters} def={def} />
```

## On a phone, a list

A table does not fit a phone, and shrinking one only makes it unreadable. Below `def.list.breakpoint` (576px by default) of **its own width** the grid draws each row as a list item instead: a title with one value opposite it, a line above or under it, and a few labelled values. Pressing an item opens the whole row in a sheet, one row at a time, with a pager. Sort, filters and the search move into a toolbar of bottom sheets. It is the same grid with the same rows, filters, sort, selection and edits, drawn a different way. `layout: 'table'` or `'list'` chooses one for good.

A column says where it goes in an item

`list: 'title' | 'kicker' | 'subtitle' | 'end' | 'field' | 'detail' | 'hidden'` on a column. Left alone, the first column is the title, the first end-aligned one sits opposite it, the next four are fields (`def.list.fields`) and the rest wait for the record view. A column's own `Cell` draws its value in both layouts, so a badge is a badge in each.

Filters are a sheet, and the button says what you will get

Each filterable column gets a section with a control a finger can use: a full-width field, the phone's own number picker, chips for a few options, a searchable checklist for many. A filter applies as you set it, so the main button already reads “Show 42 rows”. The filters in force sit under the search as chips, each one removable.

Selecting is a mode

A checkbox on every item is a target a thumb hits by accident, so `rowSelection` adds a Select button: while it is on, a press ticks the row instead of opening it, and a bar pinned to the bottom of the screen counts what is ticked.

A list, not a restyled grid

The markup is a `<ul>` of items with a `<dl>` in each, so every value keeps its label for a screen reader, and each item says where it is with `aria-posinset`/`aria-setsize`. The list grows twenty items per “Show more”, a button rather than an endless scroll, so the totals and the rest of the page stay reachable.

**The same grid, at a phone's width**

```jsx
<DataGrid
  data={people}
  def={{
    title: 'People',
    globalFilter: true,
    rowSelection: true,
    // 'auto' is the default: a list below 576px of the grid's own width.
    layout: 'auto',
    columns: [
      {
        key: 'first_name',
        header: 'Name',
        list: 'title',
        Cell: ({ cell }) => `${cell.row.data.first_name} ${cell.row.data.last_name}`,
      },
      { key: 'job_title', header: 'Job', list: 'kicker' },
      { key: 'email', header: 'Email', list: 'subtitle' },
      { key: 'salary', header: 'Salary', align: 'end', list: 'end', filterable: { type: 'number' } },
      { key: 'country', header: 'Country', filterable: { type: 'multiselect' } },
      { key: 'gender', header: 'Gender', filterable: { type: 'multiselect' } },
      { key: 'age', header: 'Age', filterable: { type: 'number' } },
      { key: 'city', header: 'City' },
      { key: 'phone_number', header: 'Phone', list: 'detail' },
    ],
  }}
/>
```

**Basic DataGrid**

```jsx
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      {
        key: 'age',
        header: 'Age',
        width: 90,
        align: 'right',
        Cell: ({ cell }) => {
          // better to define this function outside to avoid re-creation on each render
          return (
            <Flex
              bgColor="violet-50"
              height="fit"
              width="fit"
              ai="center"
              jc="center"
              overflow="hidden"
              className="parent"
              theme={{ dark: { bgColor: 'violet-600' } }}
            >
              <Box
                px={4}
                textOverflow="ellipsis"
                overflow="hidden"
                textWrap="nowrap"
                color="violet-700"
                fontWeight={600}
                hoverGroup={{ parent: { rotate: 180 } }}
                theme={{ dark: { color: 'violet-300' } }}
              >
                {cell.row.data.age}
              </Box>
            </Flex>
          );
        },
      },
      { key: 'email', header: 'Email', width: 300 },
      { key: 'street_address' },
      { key: 'city' },
      { key: 'country' },
      { key: 'favorite_color' },
      { key: 'gender' },
      { key: 'ssn' },
      { key: 'birthdate' },
      { key: 'phone_number' },
      { key: 'username' },
      { key: 'credit_card_number' },
      { key: 'salary' },
      { key: 'company_name' },
      { key: 'language' },
      { key: 'currency_code' },
    ],
    rowHeight: 40,
    visibleRowsCount: 5,
  }}
/>
```

**DataGrid with Global Filter and Column Filters**

```jsx
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name', filterable: true },
      { key: 'last_name', header: 'Last name', filterable: true },
      { key: 'age', header: 'Age', width: 120, align: 'right', filterable: { type: 'number' } },
      { key: 'email', header: 'Email', width: 300, filterable: true },
      { key: 'country', filterable: { type: 'multiselect' } },
      { key: 'gender', filterable: { type: 'multiselect' } },
    ],
    rowHeight: 40,
    visibleRowsCount: 8,
    topBar: true,
    bottomBar: true,
    globalFilter: true,
  }}
/>
```

**Grouped Columns with Row Selection**

```jsx
<DataGrid
  data={data}
  def={{
    columns: [
      {
        key: 'person',
        header: 'Person',
        columns: [
          { key: 'first_name', header: 'First name' },
          { key: 'last_name', header: 'Last name' },
        ],
      },
      {
        key: 'contact',
        header: 'Contact',
        columns: [
          { key: 'email', header: 'Email', width: 300 },
          { key: 'phone_number', header: 'Phone' },
        ],
      },
      { key: 'country' },
      { key: 'city' },
    ],
    rowSelection: { pinned: true },
    showRowNumber: { pinned: true },
  }}
/>
```

## Aggregation and totals

A column with an `aggregate` totals itself — over the rows under each group row, and over the whole grid when `def.footer` is on. Five built-ins (`sum`, `avg`, `min`, `max`, `count`) or a function of your own, which is handed the column's values and the rows they came from. It covers the rows the filters left, so filtering the grid changes the totals; formatting is an `AggregateCell`, the aggregate's twin of `Cell`. Open the Country column's menu and choose **Group By** to put the same numbers on group rows.

**Per-group totals and a pinned grand total**

```jsx
<DataGrid
  data={data}
  def={{
    // Grand totals pinned under the rows. The label goes in the first
    // column that is not aggregating; `footer: { label }` replaces it.
    footer: true,
    columns: [
      { key: 'country' },
      { key: 'company_name', header: 'Company' },
      // 'count' counts rows, so a blank cell is still a row.
      { key: 'username', header: 'People', aggregate: 'count' },
      { key: 'age', header: 'Avg age', aggregate: 'avg' },
      {
        key: 'salary',
        header: 'Payroll',
        aggregate: 'sum',
        // The aggregate's own renderer — 'sum' answers a number, this formats it.
        AggregateCell: ({ cell }) => (
          <Box px={3} textWrap="nowrap">
            {cell.value === null ? '—' : `$${Math.round(cell.value as number).toLocaleString('en-US')}`}
          </Box>
        ),
      },
    ],
  }}
/>
```

## Export to Excel and CSV

`def.export` puts the two buttons in the top bar; a `ref` on the grid gives you `exportXlsx` and `exportCsv` to call from a toolbar of your own. The file is what the grid is showing — the visible columns in their pinned order, the rows the filters and the sort left, the group rows and their totals — and the workbook is a real `.xlsx`: a bold header on a frozen row, column widths, values that keep their type, and the grouping as Excel's own outline levels, so a collapsed group opens collapsed. Both writers are behind a dynamic import, so a grid nobody exports from carries none of that code. There is no ExcelJS to install and nothing to configure.

**Two buttons, and a workbook with its groups intact**

```jsx
<DataGrid<Person>
  data={data}
  def={{
    topBar: true,
    title: 'Payroll',
    // `true` is both formats. `{ csv: false }` or `{ xlsx: false }` drops one,
    // and `fileName` names the file — the title, otherwise.
    export: { fileName: 'payroll' },
    footer: true,
    columns: [
      { key: 'country' },
      // An export runs no React, so a column drawn by a `Cell` says what it writes.
      { key: 'company_name', header: 'Company', exportValue: (row) => row.company_name.toUpperCase() },
      { key: 'username', header: 'People', aggregate: 'count' },
      // The number format the cell wears in the workbook.
      { key: 'salary', header: 'Payroll', aggregate: 'sum', exportFormat: '$#,##0' },
    ],
  }}
/>
```

**Row Detail — Orders with Items**

```jsx
// A detail row already arrives joined to the row that opened it: they share a surface and a
// 2px accent runs down the inline start of both (isExpandedFirstLeaf on the row, detailRow.content
// under it). A custom tree only has to re-colour that block. 'orders-datagrid' does the surfaces;
// 'subgrid' strips the chrome off the grid inside the panel. Both names need the same .d.ts
// augmentation every Box.components() entry does; the Theme Setup page shows it.
Box.components({
  'orders-datagrid': {
    extends: 'datagrid',
    children: {
      body: {
        children: {
          // isExpanded is on every cell of the open row; isExpandedFirstLeaf / isExpandedLastLeaf
          // name its two ends, which is where the default accent lives. A neutral, not a hue: this
          // surface can cover half the grid, and the 2px accent is where colour belongs.
          cell: {
            variants: {
              isExpanded: {
                bgColor: 'slate-100',
                group: { 'grid-row/hover': { bgColor: 'slate-200' } },
              },
            },
          },
          detailRow: { styles: { bgColor: 'slate-100', borderColor: 'slate-300' } },
        },
      },
    },
  },
  // A grid inside a row is a list, not a second card: no border, no radius, no shadow and no
  // surface of its own, so the drawer it sits in stays the only panel on screen.
  subgrid: {
    extends: 'datagrid',
    styles: { b: 0, borderRadius: 0, shadow: 'none', bgColor: 'transparent' },
    children: {
      header: {
        styles: { bgColor: 'transparent' },
        children: {
          cell: {
            styles: {
              bgColor: 'transparent',
              minHeight: 0,
              py: 2,
              fontSize: 11,
              letterSpacing: 0.4,
              textTransform: 'uppercase',
              // gray-500 is what the outer header uses, but that is measured on gray-50: on the
              // drawer's tint the same pair is 4.33:1, so the nested header goes one step darker.
              color: 'gray-600',
              borderColor: 'gray-300',
            },
          },
        },
      },
      body: {
        children: {
          cell: { styles: { bgColor: 'transparent', fontSize: 13, borderColor: 'gray-200' } },
        },
      },
    },
  },
});

<DataGrid
  component="orders-datagrid"
  data={orders}
  def={{
    rowKey: 'orderId',
    topBar: true,
    bottomBar: true,
    title: 'Orders',
    columns: [
      { key: 'orderId', header: 'Order #', width: 100, flexible: false },
      { key: 'customer', header: 'Customer' },
      { key: 'date', header: 'Date', width: 120 },
      { key: 'status', header: 'Status', width: 120 },
      { key: 'total', header: 'Total', width: 100, align: 'right' },
    ],
    rowDetail: {
      content: (order) => (
        <Box px={5} py={4}>
          {/* ps={3} lines the caption up with the first column's text, which a cell pads by 3. */}
          <Flex ai="center" gap={2} mb={3} ps={3}>
            <Box fontSize={11} fontWeight={600} letterSpacing={0.4} textTransform="uppercase" color="gray-600">
              Items
            </Box>
            <Box fontSize={11} fontWeight={600} px={1.5} borderRadius={4} bgColor="indigo-100" color="indigo-700">
              {order.items.length}
            </Box>
          </Flex>
          <DataGrid
            component="subgrid"
            data={order.items}
            def={{
              columns: [
                { key: 'product', header: 'Product' },
                { key: 'qty', header: 'Qty', width: 80, align: 'right' },
                { key: 'price', header: 'Price', width: 100, align: 'right' },
              ],
              visibleRowsCount: 'all',
              rowHeight: 36,
              // Three rows need no column menu and no resizing.
              contextMenu: false,
              resizable: false,
            }}
          />
        </Box>
      ),
      pinned: true,
      expandOnRowClick: true,
    },
  }}
/>
```

## Grouped rows, declared rather than clicked

**Group By** in a column's menu has always grouped the rows; `def.groupBy` is where the grid _starts_. Name the columns to group by, outermost first, and the grid opens grouped — which is also the only way a page can server-render a grouped grid, or a test can see one. `def.groupDefaultExpanded` says which of those groups start open: `true` for all of them, a number for how many levels down. The menu takes it from there, and a group somebody opens or shuts stays where they left it.

**Declared grouping**

```jsx
<DataGrid
  data={people}
  def={{
    // Outermost first. A grouped column is hidden, the way the menu hides one.
    groupBy: ['country'],
    // `true` opens every group; a number is how many levels down to open.
    groupDefaultExpanded: 1,
    footer: true,
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      { key: 'country', header: 'Country' },
      { key: 'salary', header: 'Salary', aggregate: 'avg', align: 'end' },
    ],
  }}
/>
```

## Rows that hold rows

`def.treeData` is a grid whose rows are a tree: a file system, an org chart, a bill of materials. The shape comes out of the data in one of two ways — **nested**, where a row carries its children (`childrenKey`, or `getChildren` to work them out), or **flat**, where every row carries the path to itself (`pathKey`/`getPath`), which is the shape an export or a `WHERE path LIKE` hands you. The grid does the rest: one column grows a chevron and an indent, and the whole thing reports itself as a `role="treegrid"` — which is what makes a row's `aria-level` and `aria-expanded` mean something, since the rows under a row are its siblings in the markup rather than its children.

A filter keeps the path to a match

Filtering a tree row by row would hide every match that lives three folders down. A row survives when it matches _or something under it does_, so what is left is the matches and the way to them — and a row that matches on its own keeps none of the children that did not.

A shut row costs nothing

Nothing under a shut row is built at all — no models, no cells — so ten thousand rows nobody has opened cost the page its top level. Opening one builds that level and no more.

Ticking a parent is a decision, so it is opt-in

`selection: 'cascade'` makes a checkbox take everything under the row with it, and a row whose children are partly ticked reads as indeterminate. Without it a checkbox is the row it is on, which is what every other row in the grid does.

**Tree data**

```jsx
<DataGrid
  data={files}
  def={{
    rowKey: 'id',
    rowSelection: true,
    globalFilter: true,
    topBar: true,
    // The tree is nested in the data. A flat list says `pathKey: 'path'` instead.
    treeData: {
      childrenKey: 'children',
      // Which column carries the chevrons. Default: the first column of your own.
      column: 'name',
      // `true` for every row, a number for a depth, or a list of keys.
      defaultExpanded: 1,
      // Ticking a folder ticks everything in it.
      selection: 'cascade',
    },
    columns: [
      { key: 'name', header: 'Name', width: 320 },
      { key: 'size', header: 'Size', align: 'end' },
      { key: 'modified', header: 'Modified' },
    ],
  }}
/>
```

**Server-Side Pagination & Filters**

```jsx
const [data, setData] = useState([]);
const [page, setPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const pageSize = 8;

// onServerStateChange fires on every page/sort/filter change, with what moved beside the snapshot:
// (state, { reason }), where reason is 'page' | 'page-size' | 'sort' | 'filter' | 'clear'.
// state = { page, pageSize, sortColumn, sortDirection, globalFilterValue, columnFilters }
// columnFilters example: { first_name: { type: 'text', value: 'Jo' }, age: { type: 'number', operator: 'gte', value: 30 }, country: { type: 'multiselect', values: ['Brazil'] } }
const fetchData = useCallback((state) => {
  setLoading(true);
  api.getUsers({
    Page: state.page, PageSize: state.pageSize,
    SortBy: state.sortColumn, SortDir: state.sortDirection,
    Search: state.globalFilterValue,
    Filters: state.columnFilters,
  }).then((res) => {
    setData(res.Items);
    setTotalCount(res.TotalCount);
    setPage(res.Page);
    setLoading(false);
  });
}, []);

useEffect(() => { fetchData({ page: 1, pageSize }); }, []);

<DataGrid
  data={data}
  loading={loading}
  page={page}
  onServerStateChange={fetchData}
  def={{
    columns: [
      { key: 'first_name', header: 'First Name', filterable: true },
      { key: 'age', header: 'Age', filterable: { type: 'number', placeholder: 'Min age' } },
      // In server-side mode the grid only has the current page, so provide
      // all possible options explicitly (fetch them from your API once).
      { key: 'country', filterable: { type: 'multiselect', options: countryOptions } },
      ...
    ],
    globalFilter: true,
    visibleRowsCount: pageSize,
    topBar: true, bottomBar: true,
    title: 'Server-Side Pagination & Filters',
    pagination: { totalCount },
  }}
/>
```

## A million rows, fetched a block at a time

`def.dataSource` is the other way round from everything above: instead of handing the grid rows, you hand it a function and it asks. Sorting, filtering and paging stop being work the browser does over an array and become part of a request — so the grid can stand in front of a table nobody could send to a browser at all. Rows the server has not answered for yet are drawn as skeletons and are still counted, so the scrollbar and `aria-rowcount` describe the whole result set from the first block.

A block is the unit of everything

One request, one wait, one failure. `blockSize` is how many rows each asks for (the page size when `def.pagination` is beside it, 100 otherwise), and `maxBlocks` is how many are kept before the ones furthest from the viewport are dropped and fetched again on the way back.

A late answer to an old question is thrown away

Every request carries an `AbortSignal` that fires when the sort or the filters change underneath it — hand it to `fetch` and a superseded request costs the network nothing. A reply that arrives anyway is dropped rather than written over the newer one.

What the grid cannot see is refresh()

The grid invalidates its own cache whenever it changes the query. For the half it cannot see — a filter of the page’s own, a row somebody saved — call `refresh()` on the grid’s ref.

**Server row model**

```jsx
// One object, stable across renders: the grid reads it when it asks for a block.
const dataSource = useMemo(() => ({
  blockSize: 100,
  async getRows({ startRow, endRow, page, pageSize, sort, globalFilter, columnFilters, signal }) {
    const res = await fetch('/api/people?' + new URLSearchParams({
      offset: String(startRow),
      limit: String(endRow - startRow),
      sort: sort ? `${sort.columnKey}:${sort.direction}` : '',
      q: globalFilter,
    }), { signal });

    const body = await res.json();

    // `totalCount` sizes the scrollbar. Omit it and the grid follows the rows instead,
    // treating a block shorter than it asked for as the end of the data.
    return { rows: body.items, totalCount: body.total };
  },
}), []);

<DataGrid
  def={{
    dataSource,
    title: 'One million people',
    topBar: true,
    globalFilter: true,
    visibleRowsCount: 12,
    columns: [
      { key: 'id', header: '#', sortable: false, width: 110, align: 'end' },
      { key: 'name', header: 'Name', width: 240 },
      { key: 'country', header: 'Country', width: 160 },
      { key: 'joined', header: 'Joined', width: 140 },
      { key: 'score', header: 'Score', width: 120, align: 'end' },
    ],
  }}
/>
```

## A group the server counts, and children it fetches when you open one

Set `grouping: true` on the datasource and **Group By** comes back in the column menu. Every request then carries `groupBy` — the columns being grouped by, outermost first — and `groupKeys`, the group it is inside: `[]` asks for the top level, `['Japan']` for what is inside Japan. Shorter than `groupBy` and the rows wanted are _group_ rows, one per distinct value, with `groupCounts` beside them; the same length and they are that group's own rows. Nothing is fetched until a chevron is pressed, so a million rows under eight groups cost the page eight rows.

A group row is a row, and it brings its own totals

The server answers with rows of the same shape carrying the level's column — a group row for Japan is `{ country: "Japan", score: 61_500_000 }`. A column with an `aggregate` reads its total off that row rather than adding up rows the browser has not got, so the numbers are the server's arithmetic, over the whole group.

A level is its own cache, and closing a group frees it

Each open group holds blocks, a count and a failure of its own, and a group that is shut is disposed of with everything under it. Eviction keeps the chain above a level in play, so scrolling deep inside a group never drops the row it hangs off.

What the grid does not hold, it does not claim

A group whose rows have never been fetched shows no select-all checkbox, and an export writes the rows in hand. A grid that offered either would be promising something only the server can answer.

**Server-side grouping**

```jsx
const dataSource = useMemo(() => ({
  // Without this, Group By is not offered at all: a `getRows` that ignored
  // `groupKeys` would answer a group level with leaf rows and nothing could tell.
  grouping: true,
  async getRows({ startRow, endRow, groupBy, groupKeys, sort, signal }) {
    const res = await fetch('/api/people?' + new URLSearchParams({
      offset: String(startRow),
      limit: String(endRow - startRow),
      groupBy: groupBy.join(','),      // ['country'] — what to GROUP BY
      groupKeys: groupKeys.join(','),  // [] for the groups, ['Japan'] for what is in one
    }), { signal });

    const body = await res.json();

    // A group level answers group rows plus how many leaf rows each holds. A leaf
    // level answers rows, and needs neither — it is the same shape as every other block.
    return { rows: body.items, totalCount: body.total, groupCounts: body.counts };
  },
}), []);

<DataGrid
  def={{
    dataSource,
    columns: [
      { key: 'name', header: 'Name' },
      { key: 'country', header: 'Country' },
      { key: 'score', header: 'Score', aggregate: 'sum', align: 'end' },
    ],
  }}
/>
```

## A tree the server holds, one level per chevron

Put `def.treeData` beside the datasource and the tree stops being something the browser holds at all. Every request then carries `treeKeys` — the row whose children are wanted, named by its key and its ancestors', outermost first, with `[]` for the top — and `hasChildren` is how a row says there is something under it to ask for. It is the same mechanism as server-side grouping: a level per open path, with its own blocks, its own count and the same eviction, which keeps the chain above a level in play. A folder nobody has opened costs the page nothing, however deep it goes.

The path is keys, so the server names a node the way it named it

`treeKeys` is built from `def.rowKey` rather than from the values on screen — the ids the server sent come back to it verbatim. A row three deep asks with all three, so a query can be written against the whole path or against the last segment alone.

A chevron is the server's answer, not a guess

`hasChildren` names a field or works the answer out. Without one no row grows a chevron: a grid that offered one on every row would promise a level that may not exist, and a grid that fetched to find out would defeat the whole thing.

What the grid does not hold, it does not claim

`selection: 'cascade'` is not offered — a tick cannot take rows nobody has fetched — and an export writes the rows in hand, at the depth they are drawn.

**Lazy tree**

```jsx
const dataSource = useMemo(() => ({
  async getRows({ startRow, endRow, treeKeys, sort, signal }) {
    const res = await fetch('/api/files?' + new URLSearchParams({
      offset: String(startRow),
      limit: String(endRow - startRow),
      // [] is the top of the tree, ['app'] what is in that folder,
      // ['app', 'app/m3'] what is in the one under that.
      path: treeKeys.join('/'),
    }), { signal });

    const body = await res.json();

    return { rows: body.items, totalCount: body.total };
  },
}), []);

<DataGrid
  def={{
    dataSource,
    rowKey: 'id',
    // The tree is the server's. `hasChildren` is the one thing a row's own
    // values cannot say, since what is under it has never been in the browser.
    treeData: { hasChildren: 'folder', column: 'name' },
    columns: [
      { key: 'name', header: 'Name' },
      { key: 'size', header: 'Size (KB)', align: 'end' },
      { key: 'modified', header: 'Modified' },
    ],
  }}
/>
```

## A cell you can type into

`column.editable` is the whole opt-in, and `def.onCellEdit` is the one function that both judges an edit and is told about it — answer nothing to accept the value, or a string to refuse it. Which editor a cell opens is read off the value in it: a number gets a numeric field, a boolean a checkbox, everything else a text field, so a column of numbers has a numeric keypad on a phone without being told. `editor` names one of the four (`text`, `number`, `checkbox`, `select`) or configures it, and `EditCell` is a control of your own bound to `cell.draft`.

A press chooses the cell, a double press opens it

A press makes a cell the current one and marks it, wherever the arrows would carry on from — and a **double press opens the editor**, the way a spreadsheet reads one. A cell that cannot be edited takes both as focus and nothing else, and a widget inside a cell keeps its own presses: a double press on a chevron, or on a link in a `Cell` of your own, belongs to the widget rather than the editor.

The keyboard is APG's, and Tab is the one that matters

Enter or F2 opens the editor, Escape throws the draft away, Enter commits and hands the keyboard back to the cell. A printable character opens the editor on that character, replacing the value the way a spreadsheet does — and **Tab commits and opens the next editable cell**, along the row and on into the rows after it, so a row of corrections is one gesture.

A select editor opens its list with the editor

A `select` cell opens its list as soon as the editor does, highlighting the value already in it, so choosing one is the single gesture it looks like — the list is the whole control, and waiting for a second press to show it asks for a gesture nobody meant. Escape closes the list and a second Escape leaves the editor. A printable character is _not_ a value a list can hold, so on a `select` or a `checkbox` it opens the editor on the value already there; `Dropdown` takes the same behaviour as `defaultOpen`.

A validator can be async, and a late answer is dropped

`def.onCellEdit` may return a promise, so a uniqueness check against a server is the same function. The editor waits; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now. A refused value keeps the editor open with the message in a `role="alert"` — in the top layer, since a bubble inside the scroller would be clipped away on the last row.

The grid does not own your data, so it holds the edit

An accepted value is kept as an edit _over_ the rows the grid was given, and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a grid over a `def.dataSource` be edited at all. `onCellEditsChange` reports the whole list, oldest first, which is the stream an undo is built out of, and `clearEdits()` on the grid's ref says your own data has caught up. An edit never re-sorts or re-filters the grid: a row jumping out from under the pointer as it is typed into is not an edit anybody asked for.

**Cell editing**

```jsx
<DataGrid
  data={people}
  def={{
    rowKey: 'id',
    // Both the validation and the notification. Nothing accepts, a string refuses.
    onCellEdit: ({ columnKey, value }) => {
      if (columnKey === 'first_name' && !String(value).trim()) return 'A name is required';
      if (columnKey === 'salary' && Number(value) < 0) return 'A salary cannot be negative';
    },
    columns: [
      { key: 'first_name', header: 'First name', editable: true },
      // No `editor`: the value is a number, so the field is a numeric one.
      { key: 'salary', header: 'Salary', editable: true, align: 'end' },
      { key: 'country', header: 'Country', editable: true, editor: { type: 'select', options: countryOptions } },
    ],
  }}
/>
```

## Tab through it like a spreadsheet

A grid is a composite widget, so APG puts **one** of its cells in the page tab sequence and Tab leaves for whatever comes after it — the arrow keys are how you move inside. That is the default here. A screen built for data entry often wants the other reading, though, the one a spreadsheet has: `def.tabNavigation="cells"` makes Tab walk to the next cell and on into the rows after it, and Shift+Tab walk back.

The cost is the way out

Tab is how a keyboard leaves a widget it has finished with. Spend it on the cells and there is nothing left to leave with — so this grid lets the key through at the first and last cell rather than swallowing it instead of trapping the keyboard in the cells. Weigh it that way round: a grid on a page of content should stay a single tab stop, and a grid that _is_ the screen can afford to keep the key.

It changes Tab and nothing else

Enter and F2 still step into whatever a cell holds, Tab still walks the controls inside that cell and wraps, Escape still hands the keyboard back, and Space still selects the row. In an open editor Tab already committed and opened the next editable cell — that is APG's own rule for a cell whose widget has the keyboard, and it reads the same under either setting.

**Spreadsheet tab mode**

```jsx
<DataGrid
  data={people}
  def={{
    rowKey: 'id',
    rowSelection: true,
    // 'none' is the default: one tab stop, and Tab leaves the grid (APG).
    tabNavigation: 'cells',
    columns: [
      { key: 'first_name', header: 'First name', editable: true },
      { key: 'last_name', header: 'Last name', editable: true },
      { key: 'country', header: 'Country' },
      { key: 'salary', header: 'Salary', align: 'end', editable: true },
    ],
  }}
/>
```

## A block of cells, and Ctrl+C

Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there, and it stays marked once the grid loses focus, because otherwise a copy has nothing to act on. `Ctrl+C` on it copies that cell. `def.rangeSelection` is the rest: drag across cells, or hold Shift with the arrow keys, and the block that is marked is what `Ctrl+C` writes — tab-separated, which is what Excel, Sheets and Numbers paste as columns.

The mark is the model's, not a focus ring

A `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing — and any ring made of focus goes out the moment the grid loses it. The current cell is state here instead: it survives a press, a blur and a scroll, and it is the corner a Shift+arrow grows the block from.

A block is selected; one cell is a cursor

The cells in a block report `aria-selected` and the grid is `aria-multiselectable`. A lone current cell reports neither: it has chosen nothing, and saying "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring — the way a sheet says where typing would land.

A copied figure is the one a file would carry

The clipboard is an export: a column's `exportValue` is used where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it. Ctrl+C rides the browser's own copy event, so text you have selected on the page still belongs to the page.

Marking cells is a drag, so text selection is not

A drag can mark cells or select text, never both, so a grid with `rangeSelection` on gives text selection up — an open editor hands it back for the value being typed. A touch is left alone entirely: that press is how the grid scrolls.

**Range selection and copy**

```jsx
<DataGrid
  data={people}
  def={{
    rowKey: 'id',
    // Drag, or Shift with the arrow keys. Ctrl+C copies the block as TSV.
    rangeSelection: true,
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'country', header: 'Country' },
      { key: 'age', header: 'Age', align: 'end' },
      { key: 'salary', header: 'Salary', align: 'end' },
    ],
  }}
  onRangeChange={(range) => setSummary(range?.values())}
/>
```

## Ctrl+V, one judgement per cell

A block of cells goes back the other way too. `Ctrl+V` fills from the current cell, or the block that is marked where one is, and every cell it covers goes through the same `def.onCellEdit` an editor would — so a paste needs no second validator, no second event and no second way of writing a value. `onPaste` reports the lot once.

The bigger of the block and the clipboard wins, on each axis

A block bigger than the clipboard is tiled with it, the way a spreadsheet fills a column from one cell; a clipboard bigger than the block spills past it. Both stop at the edge of the grid. One rule, and the degenerate case falls out of it: a paste onto the current cell alone starts from a block of one.

A refusal skips its own cell and nothing else

A paste is many independent judgements, and stopping at the first bad value would leave the block half written with no way back. The cells that were refused wear the red ring and report `aria-invalid` — there is no editor open on any of them to show a message in — and `onPaste` carries each one with what it was refused with.

The clipboard carries no types, so the cell says how to read it

A number column parses the text and refuses what is not a number, a checkbox takes `true`/`false` (and `1`/`0`), and a `select` keeps to its own options and holds the option's value rather than its spelling. A value that did not change is not an edit at all, so pasting a column back over itself costs nothing.

A paste inside an open editor belongs to the editor

That one is a value being typed, not a block being filled — so the grid hands it back rather than filling the cells under it. Everything a paste accepts lands in the same edit stream a typed value does, with a `reason` of `paste`.

**Paste into a block**

```jsx
<DataGrid
  data={people}
  def={{
    rowKey: 'id',
    rangeSelection: true,
    columns: [
      { key: 'first_name', header: 'First name', editable: true },
      { key: 'country', header: 'Country', editable: true },
      { key: 'age', header: 'Age', align: 'end', editable: true },
      { key: 'salary', header: 'Salary', align: 'end', editable: true },
    ],
    // The same function a typed value is judged by. Answer a string to refuse the cell.
    onCellEdit: ({ columnKey, value }) =>
      columnKey === 'salary' && Number(value) < 0 ? 'Salary cannot be negative' : undefined,
  }}
  onPaste={({ applied, rejected, skipped }) => setReport({ applied, rejected, skipped })}
/>
```

**Disable Sorting and Resizing**

```jsx
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      { key: 'age', header: 'Age', width: 100, sortable: true }, // Override: sortable
      { key: 'email', header: 'Email', width: 300, resizable: true }, // Override: resizable
      { key: 'country' },
      { key: 'city' },
    ],
    rowHeight: 40,
    visibleRowsCount: 5,
    sortable: false,   // Disable sorting globally
    resizable: false,  // Disable resizing globally
  }}
/>
```

**Context Menu Control**

```jsx
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      { key: 'age', header: 'Age', width: 100, contextMenu: false }, // No context menu
      { key: 'email', header: 'Email', width: 300, contextMenu: { sort: true, pin: false, group: false } },
      { key: 'country' },
      { key: 'city' },
    ],
    rowHeight: 40,
    visibleRowsCount: 5,
    contextMenu: { sort: true, pin: true, group: false }, // Disable grouping globally
  }}
/>
```

**Resizer Style**

```jsx
// 'hover' — resizer appears only when hovering the header cell
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      { key: 'age', header: 'Age', width: 100 },
      { key: 'email', header: 'Email', width: 300 },
      { key: 'country' },
      { key: 'city' },
    ],
    rowHeight: 40,
    visibleRowsCount: 5,
    resizerStyle: 'hover', // 'visible' | 'hover' | 'hidden'
  }}
/>
```

**Resize Mode**

```jsx
// 'smooth' (default): width updates batched to one per animation frame (~60fps, ~1 frame
// behind the cursor). 'instant': width updates synchronously on every pointer move, so the
// column tracks the cursor with no added latency. Drag a column edge and toggle to compare.
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      { key: 'age', header: 'Age', width: 100 },
      { key: 'email', header: 'Email', width: 300 },
      { key: 'country' },
      { key: 'city' },
    ],
    rowHeight: 40,
    visibleRowsCount: 5,
    resizeMode: 'smooth', // or 'instant'
  }}
/>
```

**Header Hover**

```jsx
// 'static' (default): each sortable header cell paints its own inset pill on hover.
// 'sliding': one pill for the whole header that flows from cell to cell, the edge it
// moves towards leading and the other catching up. Move along the headers to compare.
<DataGrid
  data={data}
  def={{
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'last_name', header: 'Last name' },
      { key: 'age', header: 'Age', width: 100 },
      { key: 'email', header: 'Email', width: 300 },
      { key: 'country' },
      { key: 'city' },
    ],
    rowHeight: 40,
    visibleRowsCount: 5,
    headerHover: 'sliding', // or 'static'
  }}
/>
```

**Localization**

```jsx
// Every word the grid writes is in def.localeText — any subset, the rest stays English.
// A value with a number or a name in it is a function, so a language can order its own words.
const de: Partial<DataGridLocaleText> = {
  searchPlaceholder: 'Suchen...',
  filterPlaceholder: 'Filtern...',
  clearFilters: 'Filter zurücksetzen',
  rowCount: (filtered, total) => (filtered === total ? `${total} Zeilen` : `${filtered} von ${total} Zeilen`),
  selectedCount: (selected) => `Ausgewählt: ${selected}`,
  selectAllRows: 'Alle Zeilen auswählen',
  selectRow: (row) => `Zeile ${row} auswählen`,
  sortAscending: 'Aufsteigend sortieren',
  sortDescending: 'Absteigend sortieren',
  columnOptions: (column) => `Spaltenoptionen für ${column}`,
  // …and the rest of DataGridLocaleText
};

<DataGrid data={data} def={{ columns, globalFilter: true, topBar: true, bottomBar: true, rowSelection: true, localeText: de }} />
```

## DataGrid props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `component` | `keyof ComponentsAndVariants` | — | Component name for style resolution. Default: 'datagrid'. Set to a custom name to use a different component style tree. |
| `data` | `TRow[]` | — | The rows. Virtualization means the length is not what is rendered — ten thousand is fine. Optional only because `def.dataSource` replaces it: with one set, this is not read at all. |
| `def`required | `GridDefinition<TRow>` | — | Everything about the grid that is not the rows: the columns, the bars, the title, the row height. |
| `loading` | `boolean` | — | Show the loading sweep over the rows. The grid keeps whatever it is already showing underneath. |
| `onSelectedRowKeysChange` | `ChangeHandler<Key[], DataGridSelectionReason>` | — | Fires with every selected row key and why the selection changed. |
| `onSelectionChange` | `(event: SelectionChangeEvent<TRow>) => void` | — | The older callback, whose event carries the keys *acted on* beside the selection. Both fire. |
| `globalFilterValue` | `string` | — | Controlled global filter value |
| `onGlobalFilterChange` | `ChangeHandler<string, DataGridFilterReason>` | — | Fires with the global filter's new text, and whether it was typed or emptied. |
| `columnFilters` | `ColumnFilters<TRow>` | — | Controlled column filters |
| `onColumnFiltersChange` | `ChangeHandler<ColumnFilters<TRow>, DataGridFilterReason>` | — | Fires with every column filter still set, and whether one was set or cleared. |
| `filters` | `((row: TRow) => boolean)[]` | — | External predicate filters applied before global/column filters. Memoize with useMemo for performance. |
| `expandedRowKeys` | `Key[]` | — | Controlled expanded detail row keys |
| `onExpandedRowKeysChange` | `ChangeHandler<Key[], DataGridExpandReason>` | — | Fires with every expanded detail row key, and whether one was opened or shut. |
| `expandedTreeKeys` | `Key[]` | — | Controlled open rows of a `def.treeData` tree. A separate list from `expandedRowKeys`, which is the detail panels: a row can have both, and they open different things. |
| `onExpandedTreeKeysChange` | `ChangeHandler<Key[], DataGridExpandReason>` | — | Fires with every open tree row's key, and whether one was opened or shut. |
| `page` | `number` | — | Controlled current page (1-indexed). Used with pagination. |
| `pageSize` | `number` | — | Controlled page size. Used with pagination and pageSizeOptions. |
| `onPaginationChange` | `ChangeHandler<DataGridPagination, DataGridChangeReason>` | — | Fires with both pager values and why they changed — the page and the size move together, and a filter or a sort is what sends the grid back to the first page. |
| `onPageChange` | `(page: number, pageSize: number) => void` | — | The older page callback, whose second argument is the page size rather than the reason. Both fire. |
| `onPageSizeChange` | `(pageSize: number) => void` | — | The older page-size callback. Both fire. |
| `onCellEditsChange` | `ChangeHandler<CellEdit<TRow>[], DataGridEditReason>` | — | Fires with every edit the grid has accepted, oldest first — the stream an undo is built out of. It is the whole list rather than the one that changed, so a host keeps no history of its own, and it empties when `clearEdits()` says the host has saved them. |
| `onRangeChange` | `ChangeHandler<CellRange \| undefined, DataGridRangeReason>` | — | Fires with the block of cells that is marked, or `undefined` once the grid has given it up. It reports the single current cell too, which is the degenerate range and what Ctrl+C acts on — so a status bar summing the selection is `range?.values()`, and a `reason` of `'select'` is the press or the arrow key that collapsed it back to one. |
| `onPaste` | `(paste: DataGridPaste<TRow>) => void` | — | Fires once with everything a Ctrl+V did: the cells that were written, the cells `def.onCellEdit` refused and the message each was refused with, and how many the block covered that nothing could be written to. It fires after the values have landed, so `range.values()` reads what the grid now shows. |
| `onSortingChange` | `ChangeHandler<DataGridSort \| undefined, DataGridSortReason>` | — | Fires with the column and direction the grid is sorted by, or `undefined` once the sort is cleared. |
| `onSortChange` | `(columnKey: Key \| undefined, direction: SortDirection \| undefined) => void` | — | The older sort callback, whose two arguments are the column and the direction. Both fire. |
| `onServerStateChange` | `ChangeHandler<ServerState<TRow>, DataGridChangeReason>` | — | Fires on any server-relevant state change with the full snapshot for an API call, and what moved. |

## DataGrid keyboard

| Key | Result |
| --- | --- |
| `Tab` | Enters the grid at one cell, not at every cell. Shift+Tab leaves it. |
| `Right / Left` | One cell along the row. At either end focus stays where it is. |
| `Down / Up` | One row, keeping the column: through a group row or a detail panel with fewer cells, and through a grouped header whose cells cover several columns each. |
| `Home / End` | The first or last cell of the row. |
| `Ctrl + Home / End` | The first or last cell of the whole grid, scrolling there if it has not been rendered yet. |
| `PageDown / PageUp` | A screenful of rows at a time. |
| `Enter / Space` | Sorts, on a sortable column header. On an editable cell, opens its editor. Anywhere else, steps into the cell's own control. |
| `F2` | Steps into the cell's control even on a header, where Enter is spoken for by the sort; on an editable cell it opens the editor, as Enter does. |
| `Escape` | Hands the keyboard back from that control to the cell; from an editor it also throws the draft away. |

### On a tree's own column

| Key | Result |
| --- | --- |
| `Right / Left` | Opens a shut row and shuts an open one; on a row that is already shut, Left steps out to its parent. Both follow the reading order, so they swap in a right-to-left grid, and on every other column they are the ordinary move along the row. |

### On an editable cell

| Key | Result |
| --- | --- |
| `Any printable character` | Opens the editor on that character, replacing the value the way a spreadsheet does. |

### In an editor

| Key | Result |
| --- | --- |
| `Enter` | Commits and hands the keyboard back to the cell. A value `def.onCellEdit` refuses keeps the editor open, with the message in a `role="alert"` and `aria-invalid` on the field. |
| `Tab / Shift+Tab` | Commits and opens the next or previous editable cell, along the row and on into the ones after it. With no next one it leaves the grid, as Tab does. |

### On a column resizer

| Key | Result |
| --- | --- |
| `Tab / F2` | Reaches the resizer of the header cell focus is on. Escape hands the keyboard back to the cell. |
| `Right / Left` | Moves the separator 16px, which widens or narrows the column exactly as dragging it would. |
| `Home / End` | The narrowest the grid allows, or as wide as the grid itself. |

## DataGrid accessibility

Implements [the WAI-ARIA APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/).

- `role="grid"` with `aria-rowcount`/`aria-colcount` over the whole data set and `aria-rowindex`/`aria-colindex` on what is rendered, so virtualization does not renumber the grid.
- With `def.treeData` it is a `role="treegrid"` instead, and its rows carry `aria-level`, `aria-posinset`, `aria-setsize` and — valid only inside a treegrid — `aria-expanded`. The rows under a row are its siblings in the same rowgroup rather than its children, so those four are all a screen reader has to go on.
- One cell is the tab stop, as APG's grid pattern specifies: the grid is entered once and the arrow keys move inside it, rather than every cell being a stop of its own.
- A grid is not named by the rows in it — pass `def.title` and the grid points `aria-labelledby` at it. Everything the grid draws for itself is named after what it acts on ("Select row 4", "Filter Country"), and a selection change is announced through a `role="status"` region.
- Each column resizer is a `role="separator"` — APG's window splitter — carrying the column's width on `aria-valuenow`, so the new width is read out as it changes.

Swept with axe on every release, in 8 states: `DataGrid`, `DataGrid (editing, with a refused value)`, `DataGrid (paginated)`, `DataGrid (virtualized, grouped, selectable)`, `DataGrid (grouped, expanded)`, `DataGrid (tree data)`, `DataGrid (list layout, grouped, selecting)`, `DataGrid (list layout, record view)`. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

## DataGrid style tree

Every part the component draws is a node with a name, so a default can be restyled with `Box.components()` instead of a selector — and a variant is a name too.

`datagrid`variants: list

`datagrid.content`

`datagrid.loader`

`datagrid.loader.track`

`datagrid.loader.track.bar`

`datagrid.error`

`datagrid.error.message`

`datagrid.error.retry`

`datagrid.topBar`

`datagrid.topBar.globalFilter`

`datagrid.topBar.globalFilter.stats`

`datagrid.topBar.columnGroups`

`datagrid.topBar.columnGroups.icon`

`datagrid.topBar.columnGroups.separator`

`datagrid.topBar.columnGroups.item`

`datagrid.topBar.columnGroups.item.icon`

`datagrid.topBar.columnVisibility`

`datagrid.topBar.columnVisibility.badge`variants: empty

`datagrid.topBar.columnVisibility.panel`

`datagrid.topBar.columnVisibility.search`

`datagrid.topBar.columnVisibility.searchIcon`

`datagrid.topBar.columnVisibility.searchInput`

`datagrid.topBar.columnVisibility.toolbar`

`datagrid.topBar.columnVisibility.count`

`datagrid.topBar.columnVisibility.action`

`datagrid.topBar.columnVisibility.list`

`datagrid.topBar.columnVisibility.section`variants: divided

`datagrid.topBar.columnVisibility.group`

`datagrid.topBar.columnVisibility.item`

`datagrid.topBar.columnVisibility.empty`

`datagrid.topBar.export`

`datagrid.topBar.export.button`

`datagrid.filter`

`datagrid.filter.row`

`datagrid.filter.cell`variants: isPinned, isFirstStartPinned, isLastStartPinned, isFirstEndPinned, isLastEndPinned

`datagrid.filter.cell.input`

`datagrid.header`

`datagrid.header.hover`variants: visible, backward, entering

`datagrid.header.cell`variants: isPinned, isFirstStartPinned, isLastStartPinned, isFirstEndPinned, isLastEndPinned, isSortable, hasHoverPill, isPillBeforeResizer, isPillAfterResizer, isRowSelection, isRowNumber, isFirstLeaf, isLastLeaf, isEmptyCell

`datagrid.header.cell.contextMenu`

`datagrid.header.cell.contextMenu.icon`

`datagrid.header.cell.contextMenu.tooltip`variants: closed

`datagrid.header.cell.contextMenu.tooltip.item`

`datagrid.header.cell.contextMenu.tooltip.item.icon`

`datagrid.header.cell.contextMenu.tooltip.item.separator`

`datagrid.header.cell.resizer`

`datagrid.body`

`datagrid.body.cell`variants: isPinned, isFirstStartPinned, isLastStartPinned, isFirstEndPinned, isLastEndPinned, isInRange, isCurrentCell, isEditing, isInvalid, isRowNumber, isRowSelection, isFirstLeaf, isLastLeaf, isEmptyCell, isRowDetail, isExpanded, isExpandedFirstLeaf, isExpandedLastLeaf

`datagrid.body.cell.text`

`datagrid.body.cell.editor`variants: isPending

`datagrid.body.cell.error`

`datagrid.body.cell.placeholder`

`datagrid.body.cell.placeholder.bar`

`datagrid.body.cell.tree`

`datagrid.body.cell.tree.toggle`variants: isExpanded

`datagrid.body.cell.tree.spacer`

`datagrid.body.cell.rowDetail`variants: isExpanded

`datagrid.body.detailRow`

`datagrid.body.detailRow.content`

`datagrid.body.row`

`datagrid.body.groupRow`

`datagrid.body.groupRow.expandButton`

`datagrid.body.groupRow.aggregate`

`datagrid.body.empty`

`datagrid.footer`

`datagrid.footer.cell`

`datagrid.footer.label`

`datagrid.emptyColumns`

`datagrid.bottomBar`

`datagrid.bottomBar.info`

`datagrid.bottomBar.clearFilters`

`datagrid.bottomBar.selection`

`datagrid.bottomBar.action`variants: pressed

`datagrid.bottomBar.pagination`

`datagrid.bottomBar.pagination.button`

`datagrid.bottomBar.pagination.info`

`datagrid.bottomBar.pagination.pageSize`

`datagrid.list`

`datagrid.list.toolbar`

`datagrid.list.toolbar.count`

`datagrid.list.toolbar.search`

`datagrid.list.toolbar.button`variants: active

`datagrid.list.toolbar.chip`

`datagrid.list.toggle`

`datagrid.list.detail`

`datagrid.list.button`

`datagrid.list.selectionBar`

`datagrid.list.sheet`

`datagrid.list.sheet.line`

`datagrid.list.sheet.input`

`datagrid.list.sheet.chip`

`datagrid.list.sheet.button`

`datagrid.list.sheet.primary`

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

