@dashforge/tw 0.5.0-beta → 0.7.0-beta

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 (105) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2504 -51
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +171 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +216 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  13. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  14. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  15. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  16. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  17. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  18. package/dist/src/components/Table/Table.d.ts +24 -0
  19. package/dist/src/components/Table/Table.d.ts.map +1 -0
  20. package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
  21. package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
  22. package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
  23. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
  24. package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
  25. package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
  26. package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
  27. package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
  28. package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
  29. package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
  30. package/dist/src/components/Table/cells/index.d.ts +11 -0
  31. package/dist/src/components/Table/cells/index.d.ts.map +1 -0
  32. package/dist/src/components/Table/table.types.d.ts +312 -0
  33. package/dist/src/components/Table/table.types.d.ts.map +1 -0
  34. package/dist/src/components/Table/table.variants.d.ts +285 -0
  35. package/dist/src/components/Table/table.variants.d.ts.map +1 -0
  36. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  37. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  38. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  39. package/dist/src/components/_shared/data/getNestedValue.d.ts +20 -0
  40. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  41. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts +45 -0
  42. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  43. package/dist/src/components/_shared/data/useControllableState.d.ts +23 -0
  44. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useDebouncedValue.d.ts +10 -0
  46. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  47. package/dist/src/components/_shared/data/useTableFilter.d.ts +13 -0
  48. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  49. package/dist/src/components/_shared/data/useTableSearch.d.ts +23 -0
  50. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  51. package/dist/src/components/_shared/data/useTableSelection.d.ts +25 -0
  52. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  53. package/dist/src/components/_shared/data/useTableSort.d.ts +20 -0
  54. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  55. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  56. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  57. package/dist/src/index.d.ts +10 -1
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/package.json +3 -3
  60. package/src/components/Accordion/accordion.variants.ts +5 -3
  61. package/src/components/Box/Box.test.tsx +32 -11
  62. package/src/components/Box/box.variants.ts +23 -4
  63. package/src/components/DataGrid/DataGrid.test.tsx +496 -0
  64. package/src/components/DataGrid/DataGrid.tsx +831 -0
  65. package/src/components/DataGrid/dataGrid.types.ts +189 -0
  66. package/src/components/DataGrid/dataGrid.variants.ts +144 -0
  67. package/src/components/Dialog/dialog.variants.ts +10 -6
  68. package/src/components/Divider/Divider.test.tsx +3 -1
  69. package/src/components/Divider/Divider.tsx +1 -1
  70. package/src/components/Divider/divider.variants.ts +2 -1
  71. package/src/components/Pagination/Pagination.tsx +3 -1
  72. package/src/components/Pagination/pagination.variants.ts +19 -13
  73. package/src/components/Popover/popover.variants.ts +10 -4
  74. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  75. package/src/components/Table/Table.test.tsx +603 -0
  76. package/src/components/Table/Table.tsx +816 -0
  77. package/src/components/Table/cells/RenderButton.tsx +23 -0
  78. package/src/components/Table/cells/RenderChip.tsx +103 -0
  79. package/src/components/Table/cells/RenderText.tsx +37 -0
  80. package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
  81. package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
  82. package/src/components/Table/cells/index.ts +14 -0
  83. package/src/components/Table/table.types.ts +346 -0
  84. package/src/components/Table/table.variants.ts +207 -0
  85. package/src/components/Tabs/tabs.variants.ts +19 -8
  86. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  87. package/src/components/Typography/Typography.test.tsx +3 -1
  88. package/src/components/Typography/typography.variants.ts +6 -2
  89. package/src/components/_shared/data/getNestedValue.test.ts +50 -0
  90. package/src/components/_shared/data/getNestedValue.ts +32 -0
  91. package/src/components/_shared/data/useColumnAutoDetect.test.ts +123 -0
  92. package/src/components/_shared/data/useColumnAutoDetect.ts +100 -0
  93. package/src/components/_shared/data/useControllableState.ts +48 -0
  94. package/src/components/_shared/data/useDebouncedValue.ts +24 -0
  95. package/src/components/_shared/data/useTableFilter.ts +37 -0
  96. package/src/components/_shared/data/useTableSearch.test.ts +115 -0
  97. package/src/components/_shared/data/useTableSearch.ts +66 -0
  98. package/src/components/_shared/data/useTableSelection.test.ts +100 -0
  99. package/src/components/_shared/data/useTableSelection.ts +100 -0
  100. package/src/components/_shared/data/useTableSort.test.ts +150 -0
  101. package/src/components/_shared/data/useTableSort.ts +141 -0
  102. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  103. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  104. package/src/components/_shared/themeIdentity.test.ts +113 -0
  105. package/src/index.ts +59 -1
@@ -0,0 +1,816 @@
1
+ import { useMemo, type ReactNode, type MouseEvent } from 'react';
2
+ import { cn } from '../../utils/cn.js';
3
+ import { useAccessState } from '../../hooks/useAccessState.js';
4
+ import { Skeleton } from '../Skeleton/Skeleton.js';
5
+ import { tableVariants } from './table.variants.js';
6
+ import { useControllableState } from '../_shared/data/useControllableState.js';
7
+ import { useDebouncedValue } from '../_shared/data/useDebouncedValue.js';
8
+ import { useTableSearch } from '../_shared/data/useTableSearch.js';
9
+ import { useTableFilter } from '../_shared/data/useTableFilter.js';
10
+ import { useTableSort, cycleSortFor } from '../_shared/data/useTableSort.js';
11
+ import { useTableSelection } from '../_shared/data/useTableSelection.js';
12
+ import { getNestedValue } from '../_shared/data/getNestedValue.js';
13
+ import {
14
+ useColumnAutoDetect,
15
+ resolveAlign,
16
+ resolveMonospace,
17
+ resolveTabularNums,
18
+ } from '../_shared/data/useColumnAutoDetect.js';
19
+ import type {
20
+ TableProps,
21
+ TableColumn,
22
+ TableSortModel,
23
+ TableFilterModel,
24
+ TableLabels,
25
+ } from './table.types.js';
26
+
27
+ const DEFAULT_LABELS: Required<TableLabels> = {
28
+ searchPlaceholder: 'Search...',
29
+ noData: 'No data',
30
+ loading: 'Loading…',
31
+ ariaSortNone: 'Not sorted. Click to sort ascending.',
32
+ ariaSortAscending: 'Sorted ascending. Click to sort descending.',
33
+ ariaSortDescending: 'Sorted descending. Click to clear sort.',
34
+ ariaSelectRow: 'Select row',
35
+ ariaSelectAllRows: 'Select all rows',
36
+ ariaExpandRow: 'Expand row',
37
+ ariaCollapseRow: 'Collapse row',
38
+ selectedCount: '{count} selected',
39
+ clearSelection: 'Clear selection',
40
+ filterColumn: 'Filter',
41
+ filterApply: 'Apply',
42
+ filterClear: 'Clear',
43
+ density: 'Density',
44
+ densityCompact: 'Compact',
45
+ densityComfortable: 'Comfortable',
46
+ densitySpacious: 'Spacious',
47
+ };
48
+
49
+ const DEFAULT_GET_ROW_ID = <T extends object>(_row: T, index: number) => String(index);
50
+
51
+ /**
52
+ * Dashforge TW `<Table>` — declarative-first, market-grounded table.
53
+ *
54
+ * Design references:
55
+ * - Visual: Stripe (clean line dividers, hover row actions, sticky header)
56
+ * - Density: Pencil & Paper UX research (compact / comfortable / spacious)
57
+ * - A11Y: W3C WAI Table tutorial (semantic `<table>`, scope, aria-sort)
58
+ * - Column model: extended from Dashforge MUI Table existing API
59
+ *
60
+ * Smart defaults: column types auto-detected → numbers right-aligned
61
+ * + tabular-nums, dates monospace, booleans center-aligned. Override
62
+ * via explicit `align` / `monospace` per column.
63
+ *
64
+ * State (sort / search / filter / selection / expansion) is internal
65
+ * by default; pass the controlled prop + matching change handler to
66
+ * lift state up — same pattern as `<TextField>` value.
67
+ *
68
+ * i18n: pass `header: t('users.name')` for column headers; pass
69
+ * `labels={{ searchPlaceholder: t('...'), ... }}` for internal default
70
+ * strings.
71
+ */
72
+ export function Table<T extends object>(props: TableProps<T>) {
73
+ const {
74
+ rows,
75
+ cols,
76
+ getRowId = DEFAULT_GET_ROW_ID,
77
+
78
+ // State props (controllable)
79
+ sortModel: sortModelProp,
80
+ onSortChange,
81
+ defaultSortModel,
82
+
83
+ enableSearch = false,
84
+ searchQuery: searchQueryProp,
85
+ onSearchQueryChange,
86
+ searchPlaceholder,
87
+ searchDebounceMs = 200,
88
+
89
+ filterModel: filterModelProp,
90
+ onFilterChange,
91
+
92
+ rowSelection = 'none',
93
+ selectedRowIds: selectedRowIdsProp,
94
+ onSelectionChange,
95
+ bulkActions,
96
+
97
+ expandable,
98
+
99
+ rowActions,
100
+
101
+ loading = false,
102
+ loadingRowCount = 5,
103
+ emptyState,
104
+
105
+ stickyHeader = true,
106
+ caption,
107
+ showCaption = false,
108
+
109
+ access: tableAccess,
110
+
111
+ labels: labelsProp,
112
+
113
+ variant,
114
+ size,
115
+ density,
116
+
117
+ sx,
118
+ slotProps,
119
+ } = props;
120
+
121
+ const labels = { ...DEFAULT_LABELS, ...labelsProp };
122
+ const tableAccessState = useAccessState(tableAccess);
123
+
124
+ // ───── Controllable state machinery ─────
125
+
126
+ const [sortModel, setSortModel] = useControllableState<TableSortModel>({
127
+ controlledValue: sortModelProp,
128
+ defaultValue: defaultSortModel ?? [],
129
+ onChange: onSortChange,
130
+ });
131
+ const [searchQuery, setSearchQuery] = useControllableState<string>({
132
+ controlledValue: searchQueryProp,
133
+ defaultValue: '',
134
+ onChange: onSearchQueryChange,
135
+ });
136
+ // Filter UI (per-column quick filters) is scoped for v1-bis; for now
137
+ // the model is read-only inside the component — the consumer wires it
138
+ // up via `filterModelProp` + `onFilterChange` from their own state.
139
+ // Until the in-table filter UI ships, only the controlled mode is
140
+ // meaningful, so we don't need an internal setter.
141
+ void onFilterChange;
142
+ const filterModel: TableFilterModel = filterModelProp ?? [];
143
+ const [selectedRowIds, setSelectedRowIds] = useControllableState<string[]>({
144
+ controlledValue: selectedRowIdsProp,
145
+ defaultValue: [],
146
+ onChange: onSelectionChange,
147
+ });
148
+ const [expandedRowIds, setExpandedRowIds] = useControllableState<string[]>({
149
+ controlledValue: expandable?.expandedRowIds,
150
+ defaultValue: [],
151
+ onChange: expandable?.onExpandChange,
152
+ });
153
+
154
+ // ───── RBAC: filter out hidden columns ─────
155
+
156
+ const visibleCols = useMemo(
157
+ () =>
158
+ cols.filter(
159
+ (col) => !col.access || col.access.onUnauthorized !== 'hide',
160
+ ),
161
+ [cols],
162
+ );
163
+
164
+ // ───── Smart-default column types ─────
165
+
166
+ const columnTypes = useColumnAutoDetect(rows, visibleCols);
167
+
168
+ // ───── Pipeline: search → filter → sort ─────
169
+
170
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, searchDebounceMs);
171
+ const searchedRows = useTableSearch(rows, visibleCols, debouncedSearchQuery);
172
+ const filteredRows = useTableFilter(searchedRows, filterModel);
173
+ const sortedRows = useTableSort(filteredRows, visibleCols, sortModel);
174
+
175
+ // ───── Selection helpers ─────
176
+
177
+ const visibleRowIds = useMemo(
178
+ () => sortedRows.map((row, idx) => getRowId(row, idx)),
179
+ [sortedRows, getRowId],
180
+ );
181
+
182
+ const selection = useTableSelection(rowSelection, selectedRowIds, setSelectedRowIds);
183
+
184
+ const selectedRows = useMemo(
185
+ () => sortedRows.filter((row, idx) => selection.isSelected(getRowId(row, idx))),
186
+ [sortedRows, selection, getRowId],
187
+ );
188
+
189
+ // ───── Variant recipe ─────
190
+
191
+ const v = tableVariants({ variant, size, density, stickyHeader });
192
+
193
+ // ───── Render ─────
194
+
195
+ if (!tableAccessState.visible) return null;
196
+
197
+ const isTableInteractive = !tableAccessState.disabled;
198
+ const showSelectionColumn = rowSelection !== 'none';
199
+ const showExpandColumn = expandable != null;
200
+ const showRowActionsColumn = rowActions != null;
201
+
202
+ const totalColumnCount =
203
+ visibleCols.length +
204
+ (showSelectionColumn ? 1 : 0) +
205
+ (showExpandColumn ? 1 : 0) +
206
+ (showRowActionsColumn ? 1 : 0);
207
+
208
+ return (
209
+ <div
210
+ className={cn(v.root(), sx, slotProps?.root?.className)}
211
+ data-disabled={!isTableInteractive ? 'true' : undefined}
212
+ >
213
+ {/* ───── Toolbar (search) ───── */}
214
+ {enableSearch && (
215
+ <div className={cn(v.toolbar(), slotProps?.toolbar?.className)}>
216
+ <label className={cn(v.search(), slotProps?.search?.className)}>
217
+ <span className="sr-only">{labels.searchPlaceholder}</span>
218
+ <SearchIcon className="ml-2 shrink-0 text-neutral-500" />
219
+ <input
220
+ type="text"
221
+ value={searchQuery}
222
+ onChange={(e) => setSearchQuery(e.target.value)}
223
+ placeholder={searchPlaceholder ?? labels.searchPlaceholder}
224
+ disabled={!isTableInteractive}
225
+ className={cn(
226
+ 'flex-1 bg-transparent border-0 outline-none',
227
+ 'px-2 py-1.5',
228
+ // placeholder color auto-inverts via the CSS-var preset;
229
+ // no `dark:` variant needed on neutral palette.
230
+ 'placeholder:text-neutral-400',
231
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
232
+ )}
233
+ />
234
+ </label>
235
+ </div>
236
+ )}
237
+
238
+ {/* ───── Scroll wrapper ───── */}
239
+ <div className={cn(v.scroll(), slotProps?.scroll?.className)}>
240
+ <table className={cn(v.table(), slotProps?.table?.className)}>
241
+ {caption != null && (
242
+ <caption className={cn(!showCaption && 'sr-only', 'py-2 text-sm text-neutral-500')}>
243
+ {caption}
244
+ </caption>
245
+ )}
246
+
247
+ {/* ───── Header ───── */}
248
+ <thead className={cn(v.thead(), slotProps?.thead?.className)}>
249
+ <tr className={cn(v.headerRow(), slotProps?.headerRow?.className)}>
250
+ {showSelectionColumn && (
251
+ <th
252
+ scope="col"
253
+ className={cn(
254
+ v.headerCell(),
255
+ v.selectionCell(),
256
+ slotProps?.headerCell?.className,
257
+ slotProps?.selectionCell?.className,
258
+ )}
259
+ >
260
+ {rowSelection === 'multiple' && (
261
+ <input
262
+ type="checkbox"
263
+ aria-label={labels.ariaSelectAllRows}
264
+ checked={selection.isAllSelected(visibleRowIds)}
265
+ ref={(el) => {
266
+ if (el) el.indeterminate = selection.isIndeterminate(visibleRowIds);
267
+ }}
268
+ onChange={() => selection.toggleAll(visibleRowIds)}
269
+ disabled={!isTableInteractive}
270
+ className="cursor-pointer"
271
+ />
272
+ )}
273
+ </th>
274
+ )}
275
+
276
+ {showExpandColumn && (
277
+ <th
278
+ scope="col"
279
+ aria-hidden="true"
280
+ className={cn(
281
+ v.headerCell(),
282
+ v.expandToggleCell(),
283
+ slotProps?.headerCell?.className,
284
+ slotProps?.expandToggleCell?.className,
285
+ )}
286
+ />
287
+ )}
288
+
289
+ {visibleCols.map((col) => (
290
+ <HeaderCell
291
+ key={col.field as string}
292
+ col={col}
293
+ sortModel={sortModel}
294
+ onSortClick={(e) => handleSortClick(col, e, sortModel, setSortModel)}
295
+ inferredType={columnTypes.get(col.field as string)}
296
+ labels={labels}
297
+ className={cn(v.headerCell(), slotProps?.headerCell?.className)}
298
+ buttonClassName={v.headerCellButton()}
299
+ disabled={!isTableInteractive}
300
+ />
301
+ ))}
302
+
303
+ {showRowActionsColumn && (
304
+ <th
305
+ scope="col"
306
+ aria-hidden="true"
307
+ className={cn(
308
+ v.headerCell(),
309
+ 'w-12',
310
+ slotProps?.headerCell?.className,
311
+ )}
312
+ />
313
+ )}
314
+ </tr>
315
+ </thead>
316
+
317
+ {/* ───── Body ───── */}
318
+ <tbody className={cn(v.tbody(), slotProps?.tbody?.className)}>
319
+ {loading
320
+ ? renderLoadingRows({
321
+ count: loadingRowCount,
322
+ visibleCols,
323
+ showSelectionColumn,
324
+ showExpandColumn,
325
+ showRowActionsColumn,
326
+ rowClass: cn(v.row(), slotProps?.row?.className),
327
+ cellClass: cn(v.cell(), slotProps?.cell?.className),
328
+ })
329
+ : sortedRows.length === 0
330
+ ? renderEmptyState({
331
+ totalColumnCount,
332
+ emptyState,
333
+ fallback: labels.noData,
334
+ cellClass: cn(v.cell(), v.emptyState(), slotProps?.emptyState?.className),
335
+ })
336
+ : sortedRows.map((row, idx) => {
337
+ const rowId = getRowId(row, idx);
338
+ const isSelected = selection.isSelected(rowId);
339
+ const isExpanded = expandedRowIds.includes(rowId);
340
+ return (
341
+ <Row
342
+ key={rowId}
343
+ row={row}
344
+ rowId={rowId}
345
+ rowIndex={idx}
346
+ isSelected={isSelected}
347
+ isExpanded={isExpanded}
348
+ visibleCols={visibleCols}
349
+ columnTypes={columnTypes}
350
+ rowSelection={rowSelection}
351
+ showSelectionColumn={showSelectionColumn}
352
+ showExpandColumn={showExpandColumn}
353
+ showRowActionsColumn={showRowActionsColumn}
354
+ expandable={expandable}
355
+ rowActions={rowActions}
356
+ labels={labels}
357
+ toggleRow={selection.toggleRow}
358
+ toggleExpand={(id) =>
359
+ setExpandedRowIds((prev) =>
360
+ prev.includes(id)
361
+ ? prev.filter((p) => p !== id)
362
+ : [...prev, id],
363
+ )
364
+ }
365
+ isInteractive={isTableInteractive}
366
+ rowClass={cn(v.row(), slotProps?.row?.className)}
367
+ cellClass={cn(v.cell(), slotProps?.cell?.className)}
368
+ selectionCellClass={cn(
369
+ v.cell(),
370
+ v.selectionCell(),
371
+ slotProps?.cell?.className,
372
+ slotProps?.selectionCell?.className,
373
+ )}
374
+ expandToggleCellClass={cn(
375
+ v.cell(),
376
+ v.expandToggleCell(),
377
+ slotProps?.cell?.className,
378
+ slotProps?.expandToggleCell?.className,
379
+ )}
380
+ rowActionsCellClass={cn(
381
+ v.cell(),
382
+ v.rowActionsCell(),
383
+ slotProps?.cell?.className,
384
+ slotProps?.rowActionsCell?.className,
385
+ )}
386
+ expandedRowClass={cn(v.expandedRow(), slotProps?.expandedRow?.className)}
387
+ totalColumnCount={totalColumnCount}
388
+ />
389
+ );
390
+ })}
391
+ </tbody>
392
+ </table>
393
+ </div>
394
+
395
+ {/* ───── Bulk action footer (sticky) ───── */}
396
+ {bulkActions && selectedRows.length > 0 && (
397
+ <div
398
+ className={cn(v.bulkActionFooter(), slotProps?.bulkActionFooter?.className)}
399
+ role="region"
400
+ aria-label={labels.selectedCount.replace('{count}', String(selectedRows.length))}
401
+ >
402
+ <span className="text-sm text-neutral-700">
403
+ {labels.selectedCount.replace('{count}', String(selectedRows.length))}
404
+ </span>
405
+ <div className="flex items-center gap-2">
406
+ {bulkActions(selectedRows)}
407
+ <button
408
+ type="button"
409
+ onClick={selection.clearSelection}
410
+ className={cn(
411
+ // text-neutral-500 → text-neutral-900 on hover; both auto-invert.
412
+ 'text-sm text-neutral-500 hover:text-neutral-900',
413
+ 'underline-offset-2 hover:underline',
414
+ 'focus:outline-none focus-visible:underline',
415
+ )}
416
+ >
417
+ {labels.clearSelection}
418
+ </button>
419
+ </div>
420
+ </div>
421
+ )}
422
+ </div>
423
+ );
424
+ }
425
+
426
+ // ───── Header cell (with sort button) ─────
427
+
428
+ function HeaderCell<T extends object>(props: {
429
+ col: TableColumn<T>;
430
+ sortModel: TableSortModel;
431
+ onSortClick: (e: MouseEvent<HTMLButtonElement>) => void;
432
+ inferredType: string | undefined;
433
+ labels: Required<TableLabels>;
434
+ className: string;
435
+ buttonClassName: string;
436
+ disabled: boolean;
437
+ }) {
438
+ const { col, sortModel, onSortClick, inferredType, labels, className, buttonClassName, disabled } = props;
439
+
440
+ const align = resolveAlign(col, inferredType as never);
441
+ const alignClass =
442
+ align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
443
+
444
+ const sortEntry = sortModel.find((s) => s.field === col.field);
445
+ const ariaSort: 'ascending' | 'descending' | 'none' =
446
+ sortEntry?.direction === 'asc'
447
+ ? 'ascending'
448
+ : sortEntry?.direction === 'desc'
449
+ ? 'descending'
450
+ : 'none';
451
+
452
+ const headerContent =
453
+ typeof col.header === 'function' ? col.header() : col.header;
454
+
455
+ const cellStyle = {
456
+ width: col.width != null ? `${col.width}px` : undefined,
457
+ minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
458
+ flex: col.flex,
459
+ };
460
+
461
+ if (col.sortable) {
462
+ return (
463
+ <th
464
+ scope="col"
465
+ aria-sort={ariaSort}
466
+ className={cn(className, alignClass)}
467
+ style={cellStyle}
468
+ >
469
+ <button
470
+ type="button"
471
+ onClick={onSortClick}
472
+ disabled={disabled}
473
+ aria-label={
474
+ ariaSort === 'ascending'
475
+ ? labels.ariaSortAscending
476
+ : ariaSort === 'descending'
477
+ ? labels.ariaSortDescending
478
+ : labels.ariaSortNone
479
+ }
480
+ className={cn(
481
+ buttonClassName,
482
+ align === 'right' && 'flex-row-reverse',
483
+ 'disabled:cursor-not-allowed disabled:opacity-50',
484
+ )}
485
+ >
486
+ <span>{headerContent}</span>
487
+ <SortIcon direction={sortEntry?.direction} />
488
+ </button>
489
+ </th>
490
+ );
491
+ }
492
+
493
+ return (
494
+ <th
495
+ scope="col"
496
+ className={cn(className, alignClass)}
497
+ style={cellStyle}
498
+ >
499
+ {headerContent}
500
+ </th>
501
+ );
502
+ }
503
+
504
+ // ───── Data row ─────
505
+
506
+ function Row<T extends object>(props: {
507
+ row: T;
508
+ rowId: string;
509
+ rowIndex: number;
510
+ isSelected: boolean;
511
+ isExpanded: boolean;
512
+ visibleCols: TableColumn<T>[];
513
+ columnTypes: Map<string, string>;
514
+ rowSelection: 'none' | 'single' | 'multiple';
515
+ showSelectionColumn: boolean;
516
+ showExpandColumn: boolean;
517
+ showRowActionsColumn: boolean;
518
+ expandable: TableProps<T>['expandable'];
519
+ rowActions: TableProps<T>['rowActions'];
520
+ labels: Required<TableLabels>;
521
+ toggleRow: (rowId: string) => void;
522
+ toggleExpand: (rowId: string) => void;
523
+ isInteractive: boolean;
524
+ rowClass: string;
525
+ cellClass: string;
526
+ selectionCellClass: string;
527
+ expandToggleCellClass: string;
528
+ rowActionsCellClass: string;
529
+ expandedRowClass: string;
530
+ totalColumnCount: number;
531
+ }) {
532
+ const {
533
+ row,
534
+ rowId,
535
+ rowIndex,
536
+ isSelected,
537
+ isExpanded,
538
+ visibleCols,
539
+ columnTypes,
540
+ rowSelection,
541
+ showSelectionColumn,
542
+ showExpandColumn,
543
+ showRowActionsColumn,
544
+ expandable,
545
+ rowActions,
546
+ labels,
547
+ toggleRow,
548
+ toggleExpand,
549
+ isInteractive,
550
+ rowClass,
551
+ cellClass,
552
+ selectionCellClass,
553
+ expandToggleCellClass,
554
+ rowActionsCellClass,
555
+ expandedRowClass,
556
+ totalColumnCount,
557
+ } = props;
558
+
559
+ return (
560
+ <>
561
+ <tr
562
+ data-selected={isSelected ? 'true' : 'false'}
563
+ aria-selected={rowSelection !== 'none' ? isSelected : undefined}
564
+ className={rowClass}
565
+ >
566
+ {showSelectionColumn && (
567
+ <td className={selectionCellClass}>
568
+ <input
569
+ type="checkbox"
570
+ aria-label={labels.ariaSelectRow}
571
+ checked={isSelected}
572
+ onChange={() => toggleRow(rowId)}
573
+ disabled={!isInteractive}
574
+ className="cursor-pointer"
575
+ />
576
+ </td>
577
+ )}
578
+
579
+ {showExpandColumn && (
580
+ <td className={expandToggleCellClass}>
581
+ <button
582
+ type="button"
583
+ onClick={() => toggleExpand(rowId)}
584
+ aria-label={isExpanded ? labels.ariaCollapseRow : labels.ariaExpandRow}
585
+ aria-expanded={isExpanded}
586
+ disabled={!isInteractive}
587
+ className={cn(
588
+ 'inline-flex items-center justify-center h-6 w-6 rounded',
589
+ // text-neutral-500 + hover bump; both auto-invert via CSS-var preset.
590
+ 'text-neutral-500 hover:text-neutral-900',
591
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
592
+ 'transition-transform motion-reduce:transition-none',
593
+ isExpanded && 'rotate-90',
594
+ )}
595
+ >
596
+ <ChevronRightIcon />
597
+ </button>
598
+ </td>
599
+ )}
600
+
601
+ {visibleCols.map((col) => (
602
+ <DataCell
603
+ key={col.field as string}
604
+ col={col}
605
+ row={row}
606
+ rowIndex={rowIndex}
607
+ isSelected={isSelected}
608
+ isExpanded={isExpanded}
609
+ inferredType={columnTypes.get(col.field as string)}
610
+ cellClass={cellClass}
611
+ />
612
+ ))}
613
+
614
+ {showRowActionsColumn && rowActions && (
615
+ <td className={rowActionsCellClass}>
616
+ {rowActions(row)}
617
+ </td>
618
+ )}
619
+ </tr>
620
+
621
+ {isExpanded && expandable && (
622
+ <tr className={expandedRowClass}>
623
+ <td colSpan={totalColumnCount} className="p-3">
624
+ {expandable.render(row)}
625
+ </td>
626
+ </tr>
627
+ )}
628
+ </>
629
+ );
630
+ }
631
+
632
+ // ───── Data cell ─────
633
+
634
+ function DataCell<T extends object>(props: {
635
+ col: TableColumn<T>;
636
+ row: T;
637
+ rowIndex: number;
638
+ isSelected: boolean;
639
+ isExpanded: boolean;
640
+ inferredType: string | undefined;
641
+ cellClass: string;
642
+ }) {
643
+ const { col, row, rowIndex, isSelected, isExpanded, inferredType, cellClass } = props;
644
+ const value = getNestedValue(row, col.field as string);
645
+ const align = resolveAlign(col, inferredType as never);
646
+ // tabularNums = digit alignment via font-variant-numeric — does NOT
647
+ // change the font family. Auto-true for number / date columns.
648
+ const tabularNums = resolveTabularNums(col, inferredType as never);
649
+ // monospace = font-family override (font-mono). Library never
650
+ // auto-picks a font family — only explicit consumer opt-in. The
651
+ // consumer's theme font-sans is preserved by default.
652
+ const monospace = resolveMonospace(col);
653
+ const alignClass =
654
+ align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
655
+
656
+ // Per-column RBAC state for the cellRenderer context
657
+ const columnAccessState = col.access
658
+ ? {
659
+ hidden: col.access.onUnauthorized === 'hide',
660
+ disabled: col.access.onUnauthorized === 'disable',
661
+ readonly: col.access.onUnauthorized === 'readonly',
662
+ }
663
+ : null;
664
+
665
+ const content = col.cellRenderer
666
+ ? col.cellRenderer({
667
+ row,
668
+ value,
669
+ rowIndex,
670
+ isSelected,
671
+ isExpanded,
672
+ access: columnAccessState,
673
+ })
674
+ : renderDefaultCell(value);
675
+
676
+ return (
677
+ <td
678
+ className={cn(
679
+ cellClass,
680
+ alignClass,
681
+ tabularNums && 'tabular-nums',
682
+ monospace && 'font-mono',
683
+ )}
684
+ style={{
685
+ width: col.width != null ? `${col.width}px` : undefined,
686
+ minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
687
+ }}
688
+ >
689
+ {content}
690
+ </td>
691
+ );
692
+ }
693
+
694
+ // ───── Helpers ─────
695
+
696
+ function renderDefaultCell(value: unknown): ReactNode {
697
+ if (value == null) return null;
698
+ if (typeof value === 'boolean') return value ? '✓' : '✗';
699
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
700
+ if (typeof value === 'object') return JSON.stringify(value);
701
+ return value as ReactNode;
702
+ }
703
+
704
+ function handleSortClick<T extends object>(
705
+ col: TableColumn<T>,
706
+ event: MouseEvent<HTMLButtonElement>,
707
+ current: TableSortModel,
708
+ setSortModel: (next: TableSortModel | ((prev: TableSortModel) => TableSortModel)) => void,
709
+ ) {
710
+ if (!col.sortable) return;
711
+ const multi = event.shiftKey;
712
+ setSortModel((prev) => cycleSortFor(prev, col.field as string, multi));
713
+ }
714
+
715
+ function renderLoadingRows<T extends object>(args: {
716
+ count: number;
717
+ visibleCols: TableColumn<T>[];
718
+ showSelectionColumn: boolean;
719
+ showExpandColumn: boolean;
720
+ showRowActionsColumn: boolean;
721
+ rowClass: string;
722
+ cellClass: string;
723
+ }): ReactNode {
724
+ const { count, visibleCols, showSelectionColumn, showExpandColumn, showRowActionsColumn, rowClass, cellClass } = args;
725
+ return Array.from({ length: count }).map((_, i) => (
726
+ <tr key={`loading-${i}`} className={rowClass} aria-busy="true">
727
+ {showSelectionColumn && <td className={cellClass}><Skeleton variant="rectangle" width="16px" height="16px" /></td>}
728
+ {showExpandColumn && <td className={cellClass}><Skeleton variant="rectangle" width="16px" height="16px" /></td>}
729
+ {visibleCols.map((col) => (
730
+ <td key={col.field as string} className={cellClass}>
731
+ <Skeleton variant="text" width="80%" />
732
+ </td>
733
+ ))}
734
+ {showRowActionsColumn && <td className={cellClass}><Skeleton variant="rectangle" width="24px" height="24px" /></td>}
735
+ </tr>
736
+ ));
737
+ }
738
+
739
+ function renderEmptyState(args: {
740
+ totalColumnCount: number;
741
+ emptyState: ReactNode;
742
+ fallback: string;
743
+ cellClass: string;
744
+ }): ReactNode {
745
+ return (
746
+ <tr>
747
+ <td colSpan={args.totalColumnCount} className={args.cellClass}>
748
+ {args.emptyState ?? args.fallback}
749
+ </td>
750
+ </tr>
751
+ );
752
+ }
753
+
754
+ // ───── Icons ─────
755
+
756
+ function SortIcon(props: { direction?: 'asc' | 'desc' }) {
757
+ return (
758
+ <svg
759
+ width="0.75em"
760
+ height="0.75em"
761
+ viewBox="0 0 16 16"
762
+ fill="currentColor"
763
+ aria-hidden="true"
764
+ className="shrink-0 opacity-60 group-hover:opacity-100"
765
+ >
766
+ {props.direction === 'asc' ? (
767
+ <path d="M8 4l4 6H4z" />
768
+ ) : props.direction === 'desc' ? (
769
+ <path d="M8 12L4 6h8z" />
770
+ ) : (
771
+ <>
772
+ <path d="M8 3l3 4H5z" opacity="0.5" />
773
+ <path d="M8 13l-3-4h6z" opacity="0.5" />
774
+ </>
775
+ )}
776
+ </svg>
777
+ );
778
+ }
779
+
780
+ function ChevronRightIcon() {
781
+ return (
782
+ <svg
783
+ width="0.875em"
784
+ height="0.875em"
785
+ viewBox="0 0 16 16"
786
+ fill="none"
787
+ stroke="currentColor"
788
+ strokeWidth="1.5"
789
+ strokeLinecap="round"
790
+ strokeLinejoin="round"
791
+ aria-hidden="true"
792
+ >
793
+ <path d="M6 4l4 4-4 4" />
794
+ </svg>
795
+ );
796
+ }
797
+
798
+ function SearchIcon(props: { className?: string }) {
799
+ return (
800
+ <svg
801
+ width="0.875em"
802
+ height="0.875em"
803
+ viewBox="0 0 16 16"
804
+ fill="none"
805
+ stroke="currentColor"
806
+ strokeWidth="1.5"
807
+ strokeLinecap="round"
808
+ strokeLinejoin="round"
809
+ aria-hidden="true"
810
+ className={props.className}
811
+ >
812
+ <circle cx="7" cy="7" r="4.5" />
813
+ <path d="M11 11l3 3" />
814
+ </svg>
815
+ );
816
+ }