noecosystem-design 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/apps/storybook/stories/catalog/app-frame.stories.tsx +43 -0
  3. package/apps/storybook/stories/catalog/button.stories.tsx +1 -0
  4. package/apps/storybook/stories/catalog/checkbox.stories.tsx +7 -2
  5. package/apps/storybook/stories/catalog/data-table.stories.tsx +226 -3
  6. package/apps/storybook/stories/catalog/sidebar-navigation.stories.tsx +22 -0
  7. package/docs/design-system/brand-theme.md +3 -2
  8. package/docs/design-system/components.md +45 -0
  9. package/docs/design-system/inventory.md +1 -0
  10. package/docs/design-system/surface-policy.md +11 -8
  11. package/docs/design-system/visual-qa.md +6 -0
  12. package/package.json +1 -1
  13. package/packages/data-ui/package.json +6 -1
  14. package/packages/data-ui/src/data-table.tsx +2 -99
  15. package/packages/data-ui/src/pagination.tsx +2 -2
  16. package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
  17. package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
  18. package/packages/data-ui/src/table/contracts.ts +133 -0
  19. package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
  20. package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
  21. package/packages/data-ui/src/table/data-table.tsx +524 -0
  22. package/packages/design-tokens/src/tokens.css +16 -10
  23. package/packages/design-tokens/src/tokens.json +12 -8
  24. package/packages/design-tokens/src/tokens.mjs +12 -8
  25. package/packages/registry/catalog-index.json +2 -2
  26. package/packages/registry/manifest.json +60 -18
  27. package/packages/registry/r/activity-feed.json +1 -1
  28. package/packages/registry/r/agent-composer.json +7 -1
  29. package/packages/registry/r/agent-message.json +1 -1
  30. package/packages/registry/r/agent-plan.json +1 -1
  31. package/packages/registry/r/agent-workspace.json +1 -1
  32. package/packages/registry/r/app-frame.json +12 -4
  33. package/packages/registry/r/approval-request.json +7 -1
  34. package/packages/registry/r/button.json +1 -1
  35. package/packages/registry/r/calendar.json +6 -5
  36. package/packages/registry/r/chart-frame.json +1 -1
  37. package/packages/registry/r/chart.json +1 -1
  38. package/packages/registry/r/dashboard-01.json +22 -2
  39. package/packages/registry/r/dashboard-02.json +22 -2
  40. package/packages/registry/r/dashboard-shell.json +1 -1
  41. package/packages/registry/r/data-table.json +22 -2
  42. package/packages/registry/r/date-picker.json +6 -5
  43. package/packages/registry/r/filter-bar.json +1 -1
  44. package/packages/registry/r/header-navigation.json +3 -3
  45. package/packages/registry/r/inline-cta.json +6 -6
  46. package/packages/registry/r/metric-card.json +1 -1
  47. package/packages/registry/r/pagination.json +7 -5
  48. package/packages/registry/r/run-inspector.json +1 -1
  49. package/packages/registry/r/sidebar-navigation.json +8 -3
  50. package/packages/registry/r/tool-call.json +1 -1
  51. package/packages/registry/r/workflow-canvas.json +1 -1
  52. package/packages/registry/r/workflow-node.json +1 -1
  53. package/packages/registry/registry.json +12 -3
  54. package/packages/registry/search-index.json +23 -8
  55. package/packages/ui/package.json +5 -1
  56. package/packages/ui/src/app-frame.browser.test.tsx +47 -0
  57. package/packages/ui/src/app-frame.tsx +33 -6
  58. package/packages/ui/src/button.browser.test.tsx +8 -0
  59. package/packages/ui/src/button.tsx +1 -1
  60. package/packages/ui/src/checkbox.tsx +1 -1
  61. package/packages/ui/src/header-navigation.tsx +1 -1
  62. package/packages/ui/src/sidebar-navigation.tsx +13 -2
  63. package/scripts/generate-manifest.mjs +71 -0
  64. package/tests/tokens.test.mjs +7 -13
  65. package/vitest.config.ts +5 -0
@@ -0,0 +1,180 @@
1
+ import { useState } from 'react';
2
+ import { expect, test, vi } from 'vitest';
3
+ import { render } from 'vitest-browser-react';
4
+
5
+ import type { DataColumn } from './contracts';
6
+ import { DataTable } from './data-table';
7
+
8
+ type Row = { id: string; service: string; owner: string };
9
+
10
+ const columns: DataColumn<Row>[] = [
11
+ { key: 'service', header: 'Service', technical: true },
12
+ { key: 'owner', header: 'Owner' },
13
+ ];
14
+
15
+ const rows: Row[] = [
16
+ { id: '1', service: 'catalog-api', owner: 'Platform' },
17
+ { id: '2', service: 'agent-runner', owner: 'AI Platform' },
18
+ { id: '3', service: 'registry-sync', owner: 'Design Systems' },
19
+ ];
20
+
21
+ interface ScreenLike {
22
+ getByRole(
23
+ role: 'table' | 'button' | 'checkbox' | 'columnheader',
24
+ options?: { name?: string },
25
+ ): {
26
+ element(): Element;
27
+ click(): Promise<void>;
28
+ };
29
+ getByText(text: string): { element(): Element };
30
+ }
31
+
32
+ const bodyRows = (screen: ScreenLike) =>
33
+ (screen.getByRole('table').element() as HTMLTableElement).querySelectorAll('tbody tr');
34
+
35
+ test('renders a semantic table with logical alignment metadata', async () => {
36
+ const screen = await render(
37
+ <DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />,
38
+ );
39
+
40
+ const table = screen.getByRole('table').element() as HTMLTableElement;
41
+ expect(table.querySelector('caption')?.textContent).toBe('Service health');
42
+ const header = screen
43
+ .getByRole('columnheader', { name: 'Service' })
44
+ .element() as HTMLTableCellElement;
45
+ expect(header.getAttribute('data-align')).toBe('start');
46
+ expect(header.getAttribute('aria-sort')).toBe('none');
47
+ expect(bodyRows(screen)).toHaveLength(3);
48
+ });
49
+
50
+ test('sorting cycles ascending, descending, then unsorted with aria-sort state', async () => {
51
+ const onSortingChange = vi.fn();
52
+ const screen = await render(
53
+ <DataTable
54
+ columns={columns}
55
+ rows={rows}
56
+ caption="Service health"
57
+ empty="No services yet."
58
+ onSortingChange={onSortingChange}
59
+ />,
60
+ );
61
+
62
+ const header = () =>
63
+ screen.getByRole('columnheader', { name: 'Service' }).element() as HTMLTableCellElement;
64
+ const firstRowService = () =>
65
+ (bodyRows(screen)[0] as HTMLTableRowElement).querySelector('td')?.textContent;
66
+
67
+ await screen.getByRole('button', { name: 'Service' }).click();
68
+ expect(header().getAttribute('aria-sort')).toBe('ascending');
69
+ expect(firstRowService()).toBe('agent-runner');
70
+ expect(onSortingChange).toHaveBeenLastCalledWith([{ field: 'service', direction: 'asc' }]);
71
+
72
+ await screen.getByRole('button', { name: 'Service' }).click();
73
+ expect(header().getAttribute('aria-sort')).toBe('descending');
74
+ expect(firstRowService()).toBe('registry-sync');
75
+ expect(onSortingChange).toHaveBeenLastCalledWith([{ field: 'service', direction: 'desc' }]);
76
+
77
+ await screen.getByRole('button', { name: 'Service' }).click();
78
+ expect(header().getAttribute('aria-sort')).toBe('none');
79
+ expect(firstRowService()).toBe('catalog-api');
80
+ });
81
+
82
+ test('selection keeps stable row ids and reflects indeterminate state', async () => {
83
+ const onSelectionChange = vi.fn();
84
+ const screen = await render(
85
+ <DataTable
86
+ columns={columns}
87
+ rows={rows}
88
+ caption="Service health"
89
+ empty="No services yet."
90
+ defaultSelection={['2']}
91
+ onSelectionChange={onSelectionChange}
92
+ selectAllLabel="Select all rows"
93
+ rowSelectionLabel={(row: Row) => `Select ${row.service}`}
94
+ selectedCountLabel={(count: number) => `${count} selected`}
95
+ />,
96
+ );
97
+
98
+ await screen.getByRole('checkbox', { name: 'Select catalog-api' }).click();
99
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['1', '2']);
100
+ expect(
101
+ screen.getByRole('checkbox', { name: 'Select all rows' }).element() as HTMLInputElement,
102
+ ).toBePartiallyChecked();
103
+
104
+ await screen.getByRole('checkbox', { name: 'Select all rows' }).click();
105
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['1', '2', '3']);
106
+ expect(screen.getByText('3 selected').element()).toBeTruthy();
107
+ });
108
+
109
+ test('client-side pagination slices rows and reports status', async () => {
110
+ function PaginationHost() {
111
+ const [page, setPage] = useState({ index: 0, size: 2 });
112
+ return (
113
+ <DataTable
114
+ columns={columns}
115
+ rows={rows}
116
+ caption="Service health"
117
+ empty="No services yet."
118
+ page={page}
119
+ onPageChange={setPage}
120
+ previousPageLabel="Previous page"
121
+ nextPageLabel="Next page"
122
+ />
123
+ );
124
+ }
125
+ const screen = await render(<PaginationHost />);
126
+
127
+ expect(bodyRows(screen)).toHaveLength(2);
128
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
129
+ expect(screen.getByText('1–2 / 3').element()).toBeTruthy();
130
+
131
+ await screen.getByRole('button', { name: 'Next page' }).click();
132
+ expect(bodyRows(screen)).toHaveLength(1);
133
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
134
+ expect(screen.getByText('3–3 / 3').element()).toBeTruthy();
135
+ });
136
+
137
+ test('renders logical alignment inside an RTL container without mirroring hacks', async () => {
138
+ const screen = await render(
139
+ <div dir="rtl">
140
+ <DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
141
+ </div>,
142
+ );
143
+
144
+ const header = screen
145
+ .getByRole('columnheader', { name: 'Owner' })
146
+ .element() as HTMLTableCellElement;
147
+ expect(header.getAttribute('data-align')).toBe('start');
148
+ expect(header.className).toContain('text-start');
149
+ const technicalCell = screen
150
+ .getByRole('cell', { name: 'catalog-api' })
151
+ .element() as HTMLTableCellElement;
152
+ expect(technicalCell.querySelector('[dir="ltr"]')).toBeTruthy();
153
+ });
154
+
155
+ test('loading renders skeleton rows instead of data', async () => {
156
+ const screen = await render(
157
+ <DataTable
158
+ columns={columns}
159
+ rows={rows}
160
+ caption="Service health"
161
+ empty="No services yet."
162
+ loading
163
+ loadingRows={2}
164
+ />,
165
+ );
166
+
167
+ expect(
168
+ (screen.getByRole('table').element() as HTMLTableElement).querySelectorAll(
169
+ '[data-slot="data-table-loading-row"]',
170
+ ),
171
+ ).toHaveLength(2);
172
+ });
173
+
174
+ test('empty model renders the empty affordance', async () => {
175
+ const screen = await render(
176
+ <DataTable columns={columns} rows={[]} caption="Service health" empty="No services yet." />,
177
+ );
178
+
179
+ expect(screen.getByText('No services yet.').element()).toBeTruthy();
180
+ });
@@ -0,0 +1,524 @@
1
+ import { useId, useMemo, useState } from 'react';
2
+ import type * as React from 'react';
3
+ import { ArrowDown2, ArrowUp2 } from '@noe/icons';
4
+ import { TechnicalValue } from '@noe/ui/bidi';
5
+ import { Button } from '@noe/ui/button';
6
+ import { Checkbox } from '@noe/ui/checkbox';
7
+ import { cn } from '@noe/ui/lib/cn';
8
+ import { Skeleton } from '@noe/ui/skeleton';
9
+ import { functionalUpdate, useTable } from '@tanstack/react-table';
10
+ import type {
11
+ ColumnFiltersState,
12
+ OnChangeFn,
13
+ RowSelectionState,
14
+ SortingState,
15
+ } from '@tanstack/react-table';
16
+
17
+ import { Pagination } from '../pagination';
18
+ import {
19
+ fromTanStackFilters,
20
+ fromTanStackSelection,
21
+ fromTanStackSorting,
22
+ noeTableFeatures,
23
+ toTanStackColumns,
24
+ toTanStackFilters,
25
+ toTanStackSelection,
26
+ toTanStackSorting,
27
+ } from './adapters/tanstack';
28
+ import type { DataColumn, DataSort, FilterSpec, PageSpec, RowId, SelectionMode } from './contracts';
29
+
30
+ export type {
31
+ DataColumn,
32
+ DataColumnSortFn,
33
+ DataQuery,
34
+ DataQueryInput,
35
+ DataSort,
36
+ DataSortDirection,
37
+ FilterOperator,
38
+ FilterSpec,
39
+ PageSpec,
40
+ RowId,
41
+ SelectionMode,
42
+ SelectionState,
43
+ TableStateAdapter,
44
+ } from './contracts';
45
+ export { createMemoryTableStateAdapter, toDataQuery } from './contracts';
46
+
47
+ export interface DataTablePaginationStatusInfo {
48
+ /** One-based index of the first row on the page. */
49
+ startIndex: number;
50
+ /** One-based index of the last row on the page. */
51
+ endIndex: number;
52
+ /** Total row count across pages. */
53
+ total: number;
54
+ }
55
+
56
+ export interface DataTableSelectionInfo {
57
+ selectedIds: RowId[];
58
+ }
59
+
60
+ type DataTableFeatureProps<Row extends { id: string }> = {
61
+ /** Controlled NOE sorting state. */
62
+ sorting?: readonly DataSort[];
63
+ /** Initial sorting state for uncontrolled usage. */
64
+ defaultSorting?: readonly DataSort[];
65
+ onSortingChange?: (sorting: DataSort[]) => void;
66
+ /** Server owns row order; the table only reflects sorting state. */
67
+ manualSorting?: boolean;
68
+ } & (
69
+ | {
70
+ filters?: undefined;
71
+ defaultFilters?: undefined;
72
+ onFiltersChange?: undefined;
73
+ manualFiltering?: undefined;
74
+ }
75
+ | {
76
+ /** Controlled NOE filter state. */
77
+ filters: readonly FilterSpec[];
78
+ defaultFilters?: undefined;
79
+ onFiltersChange: (filters: FilterSpec[]) => void;
80
+ manualFiltering?: boolean;
81
+ }
82
+ | {
83
+ filters?: undefined;
84
+ /** Initial filter state for uncontrolled usage. */
85
+ defaultFilters?: readonly FilterSpec[];
86
+ onFiltersChange?: (filters: FilterSpec[]) => void;
87
+ manualFiltering?: boolean;
88
+ }
89
+ ) &
90
+ (
91
+ | {
92
+ page?: undefined;
93
+ onPageChange?: undefined;
94
+ previousPageLabel?: undefined;
95
+ nextPageLabel?: undefined;
96
+ paginationLabel?: undefined;
97
+ paginationStatus?: undefined;
98
+ totalRowCount?: undefined;
99
+ manualPagination?: undefined;
100
+ }
101
+ | {
102
+ /** Controlled zero-based page state. */
103
+ page: PageSpec;
104
+ onPageChange: (page: PageSpec) => void;
105
+ /** Accessible names for the icon-only pagination controls. */
106
+ previousPageLabel: string;
107
+ nextPageLabel: string;
108
+ paginationLabel?: string;
109
+ paginationStatus?: (info: DataTablePaginationStatusInfo) => React.ReactNode;
110
+ /** Server-side total; required for accurate status in manual mode. */
111
+ totalRowCount?: number;
112
+ /** Server owns row slicing; `rows` holds exactly the current page. */
113
+ manualPagination?: boolean;
114
+ }
115
+ ) &
116
+ (
117
+ | {
118
+ selection?: undefined;
119
+ defaultSelection?: undefined;
120
+ onSelectionChange?: undefined;
121
+ selectionMode?: undefined;
122
+ selectAllLabel?: undefined;
123
+ rowSelectionLabel?: undefined;
124
+ selectedCountLabel?: undefined;
125
+ renderSelectionActions?: undefined;
126
+ }
127
+ | {
128
+ /** Controlled selected row ids. */
129
+ selection?: readonly RowId[];
130
+ /** Initial selection for uncontrolled usage. */
131
+ defaultSelection?: readonly RowId[];
132
+ onSelectionChange?: (ids: RowId[]) => void;
133
+ selectionMode?: SelectionMode;
134
+ /** Accessible name for the select-all header control. */
135
+ selectAllLabel: string;
136
+ /** Accessible name factory for row selection controls. */
137
+ rowSelectionLabel?: (row: Row) => string;
138
+ selectedCountLabel?: (count: number) => React.ReactNode;
139
+ renderSelectionActions?: (info: DataTableSelectionInfo) => React.ReactNode;
140
+ }
141
+ );
142
+
143
+ export type DataTableProps<Row extends { id: string }> = {
144
+ columns: DataColumn<Row>[];
145
+ rows: Row[];
146
+ caption: string;
147
+ empty: React.ReactNode;
148
+ className?: string;
149
+ loading?: boolean;
150
+ /** Skeleton row count while `loading` is true. Default: 5. */
151
+ loadingRows?: number;
152
+ /** Visual rhythm: `regular` (default) or `compact` for dense workspaces. */
153
+ density?: 'compact' | 'regular';
154
+ } & DataTableFeatureProps<Row>;
155
+
156
+ /** Pagination state that keeps every row visible when paging is disabled. */
157
+ const ALL_ROWS_PAGINATION = { pageIndex: 0, pageSize: Number.MAX_SAFE_INTEGER };
158
+
159
+ export function DataTable<Row extends { id: string }>({
160
+ columns,
161
+ rows,
162
+ caption,
163
+ empty,
164
+ className,
165
+ loading = false,
166
+ loadingRows = 5,
167
+ density = 'regular',
168
+ sorting,
169
+ defaultSorting,
170
+ onSortingChange,
171
+ manualSorting,
172
+ filters,
173
+ defaultFilters,
174
+ onFiltersChange,
175
+ manualFiltering,
176
+ page,
177
+ onPageChange,
178
+ previousPageLabel,
179
+ nextPageLabel,
180
+ paginationLabel,
181
+ paginationStatus,
182
+ totalRowCount,
183
+ manualPagination,
184
+ selection,
185
+ defaultSelection,
186
+ onSelectionChange,
187
+ selectionMode = 'multiple',
188
+ selectAllLabel,
189
+ rowSelectionLabel,
190
+ selectedCountLabel,
191
+ renderSelectionActions,
192
+ }: DataTableProps<Row>) {
193
+ const captionId = useId();
194
+ const selectionEnabled = selectAllLabel !== undefined;
195
+ const paginationEnabled = page !== undefined;
196
+
197
+ const [internalSorting, setInternalSorting] = useState<SortingState>(() =>
198
+ toTanStackSorting(defaultSorting ?? []),
199
+ );
200
+ const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>(() =>
201
+ toTanStackFilters(defaultFilters ?? []),
202
+ );
203
+ const [internalSelection, setInternalSelection] = useState<RowSelectionState>(() =>
204
+ toTanStackSelection(defaultSelection ?? []),
205
+ );
206
+
207
+ const controlledSorting = useMemo(
208
+ () => (sorting !== undefined ? toTanStackSorting(sorting) : undefined),
209
+ [sorting],
210
+ );
211
+ const controlledFilters = useMemo(
212
+ () => (filters !== undefined ? toTanStackFilters(filters) : undefined),
213
+ [filters],
214
+ );
215
+ const controlledSelection = useMemo(
216
+ () => (selection !== undefined ? toTanStackSelection(selection) : undefined),
217
+ [selection],
218
+ );
219
+
220
+ const sortingState = controlledSorting ?? internalSorting;
221
+ const filtersState = controlledFilters ?? internalFilters;
222
+ const selectionState = controlledSelection ?? internalSelection;
223
+ const paginationState = useMemo(
224
+ () =>
225
+ page !== undefined ? { pageIndex: page.index, pageSize: page.size } : ALL_ROWS_PAGINATION,
226
+ [page],
227
+ );
228
+
229
+ const tanstackColumns = useMemo(() => toTanStackColumns(columns), [columns]);
230
+
231
+ const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
232
+ const next = functionalUpdate(updater, sortingState);
233
+ setInternalSorting(next);
234
+ onSortingChange?.(fromTanStackSorting(next));
235
+ };
236
+
237
+ const handleFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
238
+ const next = functionalUpdate(updater, filtersState);
239
+ setInternalFilters(next);
240
+ onFiltersChange?.(fromTanStackFilters(next));
241
+ };
242
+
243
+ const handleSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {
244
+ const next = functionalUpdate(updater, selectionState);
245
+ setInternalSelection(next);
246
+ onSelectionChange?.(fromTanStackSelection(next));
247
+ };
248
+
249
+ const table = useTable({
250
+ features: noeTableFeatures,
251
+ columns: tanstackColumns,
252
+ data: rows,
253
+ getRowId: (row: Row) => row.id,
254
+ state: {
255
+ sorting: sortingState,
256
+ columnFilters: filtersState,
257
+ rowSelection: selectionState,
258
+ pagination: paginationState,
259
+ },
260
+ onSortingChange: handleSortingChange,
261
+ onColumnFiltersChange: handleFiltersChange,
262
+ onRowSelectionChange: handleSelectionChange,
263
+ enableMultiSort: false,
264
+ autoResetPageIndex: false,
265
+ ...(manualSorting !== undefined ? { manualSorting } : {}),
266
+ ...(manualFiltering !== undefined ? { manualFiltering } : {}),
267
+ ...(manualPagination !== undefined ? { manualPagination } : {}),
268
+ ...(totalRowCount !== undefined ? { rowCount: totalRowCount } : {}),
269
+ });
270
+
271
+ const modelRows = table.getRowModel().rows;
272
+ const selectedIds =
273
+ selection !== undefined ? [...selection] : fromTanStackSelection(selectionState);
274
+
275
+ const toggleRowSelection = (rowId: RowId) => {
276
+ const isSelected = selectedIds.includes(rowId);
277
+ if (selectionMode === 'single') {
278
+ handleSelectionChange(isSelected ? {} : toTanStackSelection([rowId]));
279
+ return;
280
+ }
281
+ handleSelectionChange(
282
+ toTanStackSelection(
283
+ isSelected ? selectedIds.filter((id) => id !== rowId) : [...selectedIds, rowId],
284
+ ),
285
+ );
286
+ };
287
+
288
+ const allRowsSelected = table.getIsAllRowsSelected();
289
+ const someRowsSelected = table.getIsSomeRowsSelected();
290
+
291
+ const pageSize = page?.size ?? ALL_ROWS_PAGINATION.pageSize;
292
+ const pageIndex = page?.index ?? 0;
293
+ const totalRows =
294
+ manualPagination === true
295
+ ? (totalRowCount ?? pageIndex * pageSize + modelRows.length)
296
+ : table.getSortedRowModel().rows.length;
297
+ const canPreviousPage = paginationEnabled && pageIndex > 0;
298
+ const canNextPage = paginationEnabled
299
+ ? manualPagination === true
300
+ ? totalRowCount !== undefined
301
+ ? (pageIndex + 1) * pageSize < totalRowCount
302
+ : modelRows.length === pageSize
303
+ : pageIndex + 1 < table.getPageCount()
304
+ : false;
305
+ const paginationInfo: DataTablePaginationStatusInfo | undefined = paginationEnabled
306
+ ? {
307
+ startIndex: totalRows === 0 ? 0 : pageIndex * pageSize + 1,
308
+ endIndex: Math.min(totalRows, (pageIndex + 1) * pageSize),
309
+ total: totalRows,
310
+ }
311
+ : undefined;
312
+
313
+ const cellPadding = density === 'compact' ? 'px-3 py-1.5' : 'px-4 py-2.5';
314
+
315
+ if (!loading && modelRows.length === 0) {
316
+ return (
317
+ <div
318
+ className={cn(
319
+ 'flex min-h-32 items-center justify-center rounded-lg border border-dashed border-border bg-surface-sunken/30 p-6 text-center text-sm text-muted-foreground',
320
+ className,
321
+ )}
322
+ data-slot="data-table-empty"
323
+ >
324
+ {empty}
325
+ </div>
326
+ );
327
+ }
328
+
329
+ const showSelectionBar =
330
+ selectionEnabled &&
331
+ selectedIds.length > 0 &&
332
+ (selectedCountLabel !== undefined || renderSelectionActions !== undefined);
333
+
334
+ return (
335
+ <div className={cn('flex flex-col gap-3', className)} data-slot="data-table-root">
336
+ {showSelectionBar ? (
337
+ <div
338
+ className="flex items-center gap-3 rounded-md border border-border bg-surface-sunken/40 px-4 py-2.5 text-sm"
339
+ data-slot="data-table-selection-bar"
340
+ >
341
+ {selectedCountLabel ? (
342
+ <span className="tabular-nums text-muted-foreground">
343
+ {selectedCountLabel(selectedIds.length)}
344
+ </span>
345
+ ) : null}
346
+ {renderSelectionActions ? (
347
+ <div className="ms-auto flex items-center gap-2">
348
+ {renderSelectionActions({ selectedIds })}
349
+ </div>
350
+ ) : null}
351
+ </div>
352
+ ) : null}
353
+ {/* WCAG 2.1: keyboard-accessible scrollable container for overflow tables. */}
354
+ <section
355
+ aria-labelledby={captionId}
356
+ className="overflow-x-auto rounded-lg border border-border"
357
+ data-slot="data-table-scroll"
358
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable region focus pattern
359
+ tabIndex={0}
360
+ >
361
+ <table
362
+ className="w-full min-w-full border-collapse text-sm"
363
+ data-density={density}
364
+ data-slot="data-table"
365
+ >
366
+ <caption className="sr-only" id={captionId}>
367
+ {caption}
368
+ </caption>
369
+ <thead>
370
+ <tr className="border-b border-border bg-surface-sunken/60">
371
+ {selectionEnabled && selectionMode === 'multiple' ? (
372
+ <th className={cn('w-10', cellPadding)} data-slot="data-table-select-header">
373
+ <Checkbox
374
+ aria-label={selectAllLabel}
375
+ checked={allRowsSelected}
376
+ onChange={() => table.toggleAllRowsSelected(!allRowsSelected)}
377
+ ref={(element) => {
378
+ if (element) element.indeterminate = someRowsSelected && !allRowsSelected;
379
+ }}
380
+ />
381
+ </th>
382
+ ) : null}
383
+ {columns.map((column) => {
384
+ const tanstackColumn = table.getColumn(column.key);
385
+ const canSort = tanstackColumn?.getCanSort() ?? false;
386
+ const sorted = tanstackColumn?.getIsSorted() ?? false;
387
+ return (
388
+ <th
389
+ aria-sort={
390
+ sorted === 'asc'
391
+ ? 'ascending'
392
+ : sorted === 'desc'
393
+ ? 'descending'
394
+ : canSort
395
+ ? 'none'
396
+ : undefined
397
+ }
398
+ className={cn(
399
+ 'whitespace-nowrap text-xs font-medium text-muted-foreground',
400
+ cellPadding,
401
+ column.align === 'end' ? 'text-end' : 'text-start',
402
+ )}
403
+ data-align={column.align ?? 'start'}
404
+ key={column.key}
405
+ scope="col"
406
+ >
407
+ {canSort && tanstackColumn ? (
408
+ <Button
409
+ className="-ms-2 text-muted-foreground hover:text-foreground"
410
+ data-slot="data-table-sort-button"
411
+ onClick={(event) => tanstackColumn.getToggleSortingHandler()?.(event)}
412
+ size="sm"
413
+ variant="ghost"
414
+ >
415
+ {column.header}
416
+ {sorted === 'desc' ? (
417
+ <ArrowDown2 className="size-3.5" />
418
+ ) : (
419
+ <ArrowUp2 className={cn('size-3.5', sorted === false && 'opacity-40')} />
420
+ )}
421
+ </Button>
422
+ ) : (
423
+ column.header
424
+ )}
425
+ </th>
426
+ );
427
+ })}
428
+ </tr>
429
+ </thead>
430
+ <tbody>
431
+ {loading
432
+ ? Array.from(
433
+ { length: loadingRows },
434
+ (_, index) => `data-table-loading-${index}`,
435
+ ).map((rowKey) => (
436
+ <tr
437
+ className="border-b border-border last:border-b-0"
438
+ data-slot="data-table-loading-row"
439
+ key={rowKey}
440
+ >
441
+ {selectionEnabled ? (
442
+ <td className={cellPadding}>
443
+ <Skeleton className="size-4" />
444
+ </td>
445
+ ) : null}
446
+ {columns.map((column) => (
447
+ <td className={cellPadding} key={column.key}>
448
+ <Skeleton className="h-4 w-full max-w-32" />
449
+ </td>
450
+ ))}
451
+ </tr>
452
+ ))
453
+ : modelRows.map((tanstackRow) => {
454
+ const row = tanstackRow.original;
455
+ const isSelected = tanstackRow.getIsSelected();
456
+ return (
457
+ <tr
458
+ className="border-b border-border last:border-b-0 hover:bg-muted/50"
459
+ data-selected={isSelected || undefined}
460
+ key={tanstackRow.id}
461
+ >
462
+ {selectionEnabled ? (
463
+ <td className={cellPadding} data-slot="data-table-select-cell">
464
+ <Checkbox
465
+ aria-label={rowSelectionLabel?.(row)}
466
+ checked={isSelected}
467
+ onChange={() => toggleRowSelection(tanstackRow.id)}
468
+ />
469
+ </td>
470
+ ) : null}
471
+ {columns.map((column) => {
472
+ const content = column.render
473
+ ? column.render(row)
474
+ : String(row[column.key] ?? '');
475
+ const value = column.technical ? (
476
+ <TechnicalValue>{content}</TechnicalValue>
477
+ ) : (
478
+ content
479
+ );
480
+ return (
481
+ <td
482
+ className={cn(
483
+ 'whitespace-nowrap text-foreground',
484
+ cellPadding,
485
+ column.align === 'end' ? 'text-end' : 'text-start',
486
+ )}
487
+ data-align={column.align ?? 'start'}
488
+ key={column.key}
489
+ >
490
+ {value}
491
+ </td>
492
+ );
493
+ })}
494
+ </tr>
495
+ );
496
+ })}
497
+ </tbody>
498
+ </table>
499
+ </section>
500
+ {paginationEnabled && paginationInfo ? (
501
+ <Pagination
502
+ canNext={canNextPage}
503
+ canPrevious={canPreviousPage}
504
+ label={paginationLabel ?? caption}
505
+ nextLabel={nextPageLabel}
506
+ onNext={
507
+ canNextPage ? () => onPageChange({ index: pageIndex + 1, size: pageSize }) : undefined
508
+ }
509
+ onPrevious={
510
+ canPreviousPage
511
+ ? () => onPageChange({ index: pageIndex - 1, size: pageSize })
512
+ : undefined
513
+ }
514
+ previousLabel={previousPageLabel}
515
+ status={
516
+ paginationStatus
517
+ ? paginationStatus(paginationInfo)
518
+ : `${paginationInfo.startIndex}–${paginationInfo.endIndex} / ${paginationInfo.total}`
519
+ }
520
+ />
521
+ ) : null}
522
+ </div>
523
+ );
524
+ }