Column configuration
Show, hide, reorder and resize a table's columns
no hidden columns
Usage
import {
ColumnMenu,
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
TableSortHeader,
TableViewport,
columnName,
useColumns,
useTable,
} from "@gpuixui/data";
const columns = useColumns({ columns: FILE_COLUMNS });
const table = useTable({
rows,
keyOf: (row) => row.id,
columns: FILE_COLUMNS,
hiddenColumns: columns.hidden,
});
<ColumnMenu
state={columns.state}
onChange={columns.apply}
onReset={columns.reset}
testId="file-columns"
/>;
<Table height={320} aria-label="Files">
<TableViewport>
<TableHeader>
<TableRow>
{columns.visible.map((column) => (
<TableSortHeader
key={column.id}
field={column.id}
label={column.label}
width={column.width}
sort={table.sort}
onSortPress={table.pressSort}
resize={{
minWidth: column.minWidth,
maxWidth: column.maxWidth,
// One column's words, read the way its header reads them.
label: `Resize the ${columnName(column.label, column.id).toLowerCase()} column`,
onResize: (width) => columns.resize(column.id, width),
}}
/>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.rows.map((row) => (
<TableRow key={row.id}>
{columns.visible.map((column) => (
<TableCell key={column.id} width={column.width}>
{column.valueOf(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</TableViewport>
</Table>;The drag
The divider is a separator tab stop inside the header cell it ends, not a
spacer column: its 6px strip paints, which is what lets GPUI's hit test stop on
it. Four rules are the ones a window measured (pointer-capture,
column-resize-lifecycle):
- The press arms pointer capture. The strip subscribes to both
onMouseDownandonMouseMove; a move-only strip hears one move of eight and never resizes. - The press and its moves land before React commits, so the width a gesture started from lives in a ref and is read again at the next press — a second drag measures from the width the first one ended at.
- Only the primary button arms it. The runtime delivers
mouseDownfor all three buttons, so a right press (the context-menu gesture) or a middle one would otherwise start a resize nobody asked for. - A move that holds no primary button ends the gesture. The runtime reports the button a move holds, and a window the pointer left never hears the release. Without that rule the strip stays armed and the reader's next hover over it resizes the column by however far the pointer travelled; a press after it starts a fresh gesture from the width on screen. The dock package's divider follows the same rule.
The arrows step the focused divider by step (16px), and Home/End name the
column's own bounds; every route clamps, because a reader can hold an arrow
against a bound exactly as a pointer can drag past it. One number drives the
announcement, the drag and the arrows: the width read through the column's own
bounds. A width the strip cannot measure (NaN) is the floor, and a width past
the bounds is the bound — so a consumer that does not clamp still resizes by a
step instead of landing back on the bound the divider already announced.
Saving and restoring
A reader's column set is worth remembering, and a saved set is data: the
release that reads it may have added, renamed or dropped a column since, and a
store writes null for a field it never set. Two functions are the boundary —
columnConfig saves, restoreColumns reads:
import { columnConfig, restoreColumns, useColumns } from "@gpuixui/data";
// Save what the reader configured (order, hidden ids and widths).
const saved = columnConfig(columns.state);
// Restore it at mount; the hook reads the configuration once.
const columns = useColumns({ columns: FILE_COLUMNS, stored });
// A controlled grid can read the same data without the hook.
const state = restoreColumns(FILE_COLUMNS, saved);restoreColumns applies the saved order, hidden ids and widths to
the definitions the current release ships: a saved id this release dropped is
skipped, and a column the release added follows the saved ones in its
definition's place. Every label, accessor and bound comes from the current
definition, never from the store. A saved width is clamped to the column's own
bounds, and a width that is not a finite number reads as the definition's; a
hidden id that no longer exists hides nothing, and the last visible column
cannot be hidden. A stored value that is not a configuration reads as the
shipped columns, so a corrupt or missing record opens the table rather than
taking it down.
API
ColumnMenu
| Prop | Type | Default | Consequence |
|---|---|---|---|
state | ColumnState | — | the configured columns, in paint order |
onChange | (next: ColumnState) => void | — | receives every complete next state |
onReset | () => void | — | restores the definitions; omit to hide the row |
label | string | "Columns" | the trigger's label |
contentLabel | string | the trigger's label | the menu surface's accessible name |
disabled | boolean | — | refuses the trigger |
testId | string | — | derives -toggle-<id>, -move-<id>, -move-<id>-left/-right and -reset |
A blank label or contentLabel reads as the documented default rather than
publishing an unnamed trigger or surface: a default only covers an absent
prop, and a locale table with a missing row answers "". The trigger and the
menu both announce Columns then (src/behaviour/surface-a11y.test.tsx).
A column's label is content the same way: a saved configuration can restore
a column with no title and a locale table can answer "". Every row the menu
paints for that column reads it through columnName, so a blank label reads as
the column's id — the visibility row is named name, not left unnamed, and
the submenu says Move name rather than Move and stops.
(src/behaviour/surface-a11y.test.tsx mounts a blank and a whitespace label.)
TableColumnDivider
| Prop | Type | Default | Consequence |
|---|---|---|---|
width | number | — | the column's current width; one outside the bounds reads as the bound, and an unreadable one as the floor |
minWidth | number | 80 | the narrowest a drag may reach |
maxWidth | number | 640 | the widest a drag may reach |
onResize | (width: number) => void | — | receives every clamped width |
label | string | "Resize column" | the divider's accessible name; a blank one reads as the default |
step | number | 16 | pixels one arrow press moves it |
className | string | — | joined after the recipe |
testId | string | — | the divider's locator |
A blank label reads as Resize column rather than publishing a nameless
control: the divider is a focusable separator, so a locale table with a
missing row — or a saved label that answers "" — used to leave the one control
that resizes a column unnamed while its width stayed announced. A name that
should say which column is composed from the column's own words, the way the
header composes it (columnName); src/behaviour/surface-a11y.test.tsx mounts
an empty and a whitespace label and reads both names off the tree.
useColumns options
| Prop | Type | Default | Consequence |
|---|---|---|---|
columns | ReadonlyArray<ColumnDef> | — | the definitions, in paint order |
stored | unknown | — | a configuration columnConfig saved; read once at mount through restoreColumns |
useColumns
| Field | Type | Consequence |
|---|---|---|
state | ColumnState | the configured columns |
visible | ReadonlyArray<Column> | the one list the header and rows paint |
hidden | ReadonlyArray<string> | the hidden ids, for useTable's filter |
contentWidth | number | the sum of the visible tracks |
widthOf / offsetOf / isVisible | (id) => … | per-column reads |
apply | (next: ColumnState) => void | stores a state a menu produced |
setVisible / toggle / move / resize / reset | functions | the reducers, applied |
Edit this page on GitHubpackages/data/docs/column-configuration.mdx