Skip to content

Commit bbd0004

Browse files
feat(components): add DataTable (RAC + TanStack v8) [spike]
Higher-level data table composed on the LaunchPad RAC Table + TanStack Table v8: declarative columns + data with freeform search, sorting, row selection, column visibility, resize, density, click-row-to-open, and column drag-reorder. Adds cell content helpers (two-line, numeric, delta, status, tag, mono) matching the Figma "Table BETA" system, plus header/border styling from the o11y traces design. Spike (spike/launchpad-datatable) for design review via Chromatic; see spikes/launchpad-datatable/SPEC.md. Keyboard column-reorder is a documented follow-up.
1 parent 83230cf commit bbd0004

9 files changed

Lines changed: 1595 additions & 1 deletion

File tree

packages/components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@launchpad-ui/icons": "workspace:~",
3535
"@launchpad-ui/tokens": "workspace:~",
3636
"@react-aria/live-announcer": "3.4.4",
37+
"@tanstack/react-table": "^8.21.3",
3738
"class-variance-authority": "0.7.0"
3839
},
3940
"devDependencies": {
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import type { ColumnDef, RowData, SortingState, VisibilityState } from '@tanstack/react-table';
2+
import type { Ref } from 'react';
3+
import type { Selection as AriaSelection, Key, SortDescriptor } from 'react-aria-components';
4+
5+
import { Icon } from '@launchpad-ui/icons';
6+
import {
7+
flexRender,
8+
getCoreRowModel,
9+
getFilteredRowModel,
10+
getSortedRowModel,
11+
useReactTable,
12+
} from '@tanstack/react-table';
13+
import { useState } from 'react';
14+
15+
import { Group } from './Group';
16+
import { IconButton } from './IconButton';
17+
import { Input } from './Input';
18+
import { Menu, MenuItem, MenuTrigger } from './Menu';
19+
import { Popover } from './Popover';
20+
import { SearchField } from './SearchField';
21+
import styles from './styles/DataTable.module.css';
22+
import { Cell, Column, ResizableTableContainer, Row, Table, TableBody, TableHeader } from './Table';
23+
24+
// Augment TanStack column meta with the extra hints DataTable needs. `label` gives
25+
// the column-visibility menu a human string when `header` is a render function;
26+
// `isRowHeader` marks which column RAC should treat as the row's header cell.
27+
declare module '@tanstack/react-table' {
28+
// biome-ignore lint/correctness/noUnusedVariables: augmentation must match TanStack's generic signature
29+
interface ColumnMeta<TData extends RowData, TValue> {
30+
label?: string;
31+
isRowHeader?: boolean;
32+
/** Horizontal alignment; `end` right-aligns header + cells (numeric/currency/delta). */
33+
align?: 'start' | 'end';
34+
/** Set false to pin a column (excluded from drag-reorder, e.g. a row-actions column). */
35+
enableReorder?: boolean;
36+
}
37+
}
38+
39+
interface DataTableProps<TData> {
40+
/** Accessible name for the table. */
41+
'aria-label': string;
42+
/** Row data. */
43+
data: TData[];
44+
/** TanStack column definitions (accessorKey/accessorFn, header, cell, enableSorting, enableHiding, meta). */
45+
columns: ColumnDef<TData, unknown>[];
46+
/** Stable row id — used as the RAC row key and for selection. Defaults to the row index. */
47+
getRowId?: (row: TData, index: number) => string;
48+
/** Initial sort (uncontrolled), e.g. `[{ id: 'status', desc: true }]`. */
49+
defaultSorting?: SortingState;
50+
/** Row density, matching the Figma table system. Default `default`. */
51+
density?: 'default' | 'compact' | 'tight';
52+
/** RAC selection mode. Selection is delegated to RAC (checkbox column injected automatically). */
53+
selectionMode?: 'none' | 'single' | 'multiple';
54+
/**
55+
* `toggle` (default) shows a checkbox column; `replace` selects the whole row on click with
56+
* a highlight and no checkbox — the list/detail pattern (click a trace row to open it).
57+
*/
58+
selectionBehavior?: 'toggle' | 'replace';
59+
selectedKeys?: AriaSelection;
60+
onSelectionChange?: (keys: AriaSelection) => void;
61+
/**
62+
* Called when a row is activated (click or Enter) — the click-row-to-open-detail pattern
63+
* (e.g. a traces list opening a trace). Receives the row id. RAC suppresses this when the
64+
* press lands on an interactive cell (checkbox, actions menu).
65+
*/
66+
onRowAction?: (id: string) => void;
67+
/** Show the freeform search field in the toolbar (TanStack global filter). Default true. */
68+
enableGlobalFilter?: boolean;
69+
/** Show the column-visibility menu in the toolbar (TanStack column visibility). Default true. */
70+
enableColumnVisibility?: boolean;
71+
/** Wrap in a RAC ResizableTableContainer so columns can be resized. Default false. */
72+
enableColumnResize?: boolean;
73+
/**
74+
* Allow reordering columns by dragging a grip handle in the header (TanStack `columnOrder`).
75+
* RAC has no native column DnD, so this uses lightweight HTML5 drag — mouse-based; keyboard
76+
* reorder is a follow-up. Pin a column with `meta.enableReorder: false`.
77+
*/
78+
enableColumnReorder?: boolean;
79+
ref?: Ref<HTMLTableElement>;
80+
}
81+
82+
function columnLabel<TData>(columnDef: ColumnDef<TData, unknown>, id: string): string {
83+
if (columnDef.meta?.label) {
84+
return columnDef.meta.label;
85+
}
86+
if (typeof columnDef.header === 'string') {
87+
return columnDef.header;
88+
}
89+
return id;
90+
}
91+
92+
/**
93+
* A data table takes declarative `columns` + `data` and layers the data-heavy interactions
94+
* product surfaces expect — freeform search, sorting, row selection, column visibility, and
95+
* resize — on top of the LaunchPad RAC `Table`.
96+
*
97+
* Division of labor: RAC owns markup, keyboard nav, a11y, selection, sort UI, DnD, and resize;
98+
* TanStack Table v8 is the headless model for filtering, sorting, and column visibility.
99+
*/
100+
const DataTable = <TData,>({
101+
data,
102+
columns,
103+
getRowId,
104+
defaultSorting,
105+
density = 'default',
106+
selectionMode = 'none',
107+
selectionBehavior,
108+
selectedKeys,
109+
onSelectionChange,
110+
onRowAction,
111+
enableGlobalFilter = true,
112+
enableColumnVisibility = true,
113+
enableColumnResize = false,
114+
enableColumnReorder = false,
115+
ref,
116+
...props
117+
}: DataTableProps<TData>) => {
118+
const [sorting, setSorting] = useState<SortingState>(defaultSorting ?? []);
119+
const [globalFilter, setGlobalFilter] = useState('');
120+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
121+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
122+
// Column drag-reorder (HTML5 DnD): the column being dragged and the current drop target.
123+
const [dragColumnId, setDragColumnId] = useState<string | null>(null);
124+
const [dropColumnId, setDropColumnId] = useState<string | null>(null);
125+
126+
const table = useReactTable({
127+
data,
128+
columns,
129+
state: { sorting, globalFilter, columnVisibility, columnOrder },
130+
getRowId,
131+
onSortingChange: setSorting,
132+
onGlobalFilterChange: setGlobalFilter,
133+
onColumnVisibilityChange: setColumnVisibility,
134+
onColumnOrderChange: setColumnOrder,
135+
getCoreRowModel: getCoreRowModel(),
136+
getFilteredRowModel: getFilteredRowModel(),
137+
getSortedRowModel: getSortedRowModel(),
138+
enableSortingRemoval: false,
139+
});
140+
141+
// Move `fromId` to `toId`'s slot in the full leaf-column order and commit it to TanStack,
142+
// which re-derives both header and cell order (RAC then re-renders in lockstep).
143+
const reorderColumn = (fromId: string, toId: string) => {
144+
const order = table.getAllLeafColumns().map((column) => column.id);
145+
const from = order.indexOf(fromId);
146+
const to = order.indexOf(toId);
147+
if (from === -1 || to === -1 || from === to) {
148+
return;
149+
}
150+
order.splice(from, 1);
151+
order.splice(to, 0, fromId);
152+
setColumnOrder(order);
153+
};
154+
155+
// Bridge: derive RAC's single-column SortDescriptor from TanStack sorting state, and
156+
// translate RAC sort changes back into TanStack state so the sort UI is RAC's but the
157+
// actual ordering is computed by TanStack's row model.
158+
const sort = sorting[0];
159+
const sortDescriptor: SortDescriptor | undefined = sort
160+
? { column: sort.id, direction: sort.desc ? 'descending' : 'ascending' }
161+
: undefined;
162+
const onSortChange = (descriptor: SortDescriptor) => {
163+
setSorting([{ id: String(descriptor.column), desc: descriptor.direction === 'descending' }]);
164+
};
165+
166+
const headers = table.getHeaderGroups()[0]?.headers ?? [];
167+
const rows = table.getRowModel().rows;
168+
// RAC memoizes dynamic-collection rows by their stable id, so toggling column
169+
// visibility/order updates the (statically-mapped) header but leaves cached cells
170+
// behind — tripping RAC's "cell count must match column count" invariant. Feeding
171+
// a `dependencies` value that changes with the visible columns invalidates that
172+
// cache in lockstep. It must be a single, constant-size entry: RAC spreads
173+
// `dependencies` into a useMemo deps array, and React requires a stable size.
174+
const visibleColumnsKey = table
175+
.getVisibleLeafColumns()
176+
.map((column) => column.id)
177+
.join('|');
178+
const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide());
179+
const visibleColumnKeys = new Set<Key>(
180+
hideableColumns.filter((column) => column.getIsVisible()).map((column) => column.id),
181+
);
182+
183+
const onColumnSelectionChange = (keys: AriaSelection) => {
184+
const next: VisibilityState = {};
185+
for (const column of hideableColumns) {
186+
next[column.id] = keys === 'all' || keys.has(column.id);
187+
}
188+
setColumnVisibility(next);
189+
};
190+
191+
const tableElement = (
192+
<Table
193+
aria-label={props['aria-label']}
194+
ref={ref}
195+
selectionMode={selectionMode === 'none' ? undefined : selectionMode}
196+
selectionBehavior={selectionBehavior}
197+
selectedKeys={selectedKeys}
198+
onSelectionChange={onSelectionChange}
199+
onRowAction={onRowAction ? (key) => onRowAction(String(key)) : undefined}
200+
sortDescriptor={sortDescriptor}
201+
onSortChange={onSortChange}
202+
>
203+
<TableHeader>
204+
{headers.map((header, index) => {
205+
const { columnDef } = header.column;
206+
const columnId = header.column.id;
207+
const isRowHeader = columnDef.meta?.isRowHeader ?? index === 0;
208+
const reorderable = enableColumnReorder && columnDef.meta?.enableReorder !== false;
209+
const content = flexRender(columnDef.header, header.getContext());
210+
return (
211+
<Column
212+
key={header.id}
213+
id={columnId}
214+
isRowHeader={isRowHeader}
215+
allowsSorting={header.column.getCanSort()}
216+
className={columnDef.meta?.align === 'end' ? styles.alignEnd : undefined}
217+
>
218+
{reorderable ? (
219+
// biome-ignore lint/a11y/noStaticElementInteractions: HTML5 drop target for column reorder; keyboard reorder is a documented follow-up
220+
<span
221+
className={
222+
dropColumnId === columnId
223+
? `${styles.reorderHeader} ${styles.dropTarget}`
224+
: styles.reorderHeader
225+
}
226+
onDragOver={(e) => {
227+
if (dragColumnId && dragColumnId !== columnId) {
228+
e.preventDefault();
229+
setDropColumnId(columnId);
230+
}
231+
}}
232+
onDragLeave={() =>
233+
setDropColumnId((current) => (current === columnId ? null : current))
234+
}
235+
onDrop={(e) => {
236+
e.preventDefault();
237+
if (dragColumnId) {
238+
reorderColumn(dragColumnId, columnId);
239+
}
240+
setDragColumnId(null);
241+
setDropColumnId(null);
242+
}}
243+
>
244+
{/* Grip is the drag origin; stop pointer/click from reaching RAC's sort press. */}
245+
<span
246+
className={styles.grip}
247+
role="button"
248+
tabIndex={-1}
249+
draggable
250+
aria-label="Drag to reorder column"
251+
onPointerDown={(e) => e.stopPropagation()}
252+
onDragStart={(e) => {
253+
setDragColumnId(columnId);
254+
e.dataTransfer.effectAllowed = 'move';
255+
e.dataTransfer.setData('text/plain', columnId);
256+
}}
257+
onDragEnd={() => {
258+
setDragColumnId(null);
259+
setDropColumnId(null);
260+
}}
261+
>
262+
<Icon name="grip-horiz" size="small" />
263+
</span>
264+
{content}
265+
</span>
266+
) : (
267+
content
268+
)}
269+
</Column>
270+
);
271+
})}
272+
</TableHeader>
273+
<TableBody
274+
items={rows}
275+
dependencies={[visibleColumnsKey]}
276+
renderEmptyState={() => 'No results found'}
277+
>
278+
{(row) => (
279+
<Row id={row.id}>
280+
{row.getVisibleCells().map((cell) => (
281+
<Cell
282+
key={cell.id}
283+
className={
284+
cell.column.columnDef.meta?.align === 'end' ? styles.alignEnd : undefined
285+
}
286+
>
287+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
288+
</Cell>
289+
))}
290+
</Row>
291+
)}
292+
</TableBody>
293+
</Table>
294+
);
295+
296+
const showToolbar = enableGlobalFilter || enableColumnVisibility;
297+
298+
return (
299+
<div className={styles.root} data-density={density}>
300+
{showToolbar && (
301+
<div className={styles.toolbar}>
302+
{enableGlobalFilter && (
303+
<SearchField
304+
aria-label="Search"
305+
value={globalFilter}
306+
onChange={setGlobalFilter}
307+
className={styles.search}
308+
>
309+
<Group>
310+
<Icon name="search" size="small" />
311+
<Input placeholder="Search" />
312+
<IconButton
313+
icon="cancel-circle-outline"
314+
aria-label="Clear search"
315+
size="small"
316+
variant="minimal"
317+
/>
318+
</Group>
319+
</SearchField>
320+
)}
321+
{enableColumnVisibility && hideableColumns.length > 0 && (
322+
<span className={styles.columnMenu}>
323+
<MenuTrigger>
324+
<IconButton
325+
icon="more-horiz"
326+
aria-label="Column settings"
327+
variant="minimal"
328+
size="small"
329+
/>
330+
<Popover>
331+
<Menu
332+
selectionMode="multiple"
333+
selectedKeys={visibleColumnKeys}
334+
onSelectionChange={onColumnSelectionChange}
335+
>
336+
{hideableColumns.map((column) => (
337+
<MenuItem key={column.id} id={column.id}>
338+
{columnLabel(column.columnDef, column.id)}
339+
</MenuItem>
340+
))}
341+
</Menu>
342+
</Popover>
343+
</MenuTrigger>
344+
</span>
345+
)}
346+
</div>
347+
)}
348+
{enableColumnResize ? (
349+
<ResizableTableContainer>{tableElement}</ResizableTableContainer>
350+
) : (
351+
tableElement
352+
)}
353+
</div>
354+
);
355+
};
356+
357+
export { DataTable };
358+
export type { DataTableProps };

0 commit comments

Comments
 (0)