@dashforge/tw 0.4.0-beta → 0.6.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.
- package/CHANGELOG.md +391 -0
- package/dist/index.esm.js +2113 -2
- package/dist/src/components/Pagination/Pagination.d.ts +32 -0
- package/dist/src/components/Pagination/Pagination.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.helpers.d.ts +23 -0
- package/dist/src/components/Pagination/pagination.helpers.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.types.d.ts +120 -0
- package/dist/src/components/Pagination/pagination.types.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.variants.d.ts +150 -0
- package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -0
- package/dist/src/components/Skeleton/Skeleton.d.ts +23 -0
- package/dist/src/components/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/src/components/Skeleton/skeleton.types.d.ts +56 -0
- package/dist/src/components/Skeleton/skeleton.types.d.ts.map +1 -0
- package/dist/src/components/Skeleton/skeleton.variants.d.ts +110 -0
- package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -0
- package/dist/src/components/Table/Table.d.ts +24 -0
- package/dist/src/components/Table/Table.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/getNestedValue.d.ts +20 -0
- package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts +45 -0
- package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useControllableState.d.ts +23 -0
- package/dist/src/components/Table/_internal/useControllableState.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useDebouncedValue.d.ts +10 -0
- package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableFilter.d.ts +13 -0
- package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSearch.d.ts +23 -0
- package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSelection.d.ts +25 -0
- package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSort.d.ts +20 -0
- package/dist/src/components/Table/_internal/useTableSort.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
- package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
- package/dist/src/components/Table/cells/index.d.ts +11 -0
- package/dist/src/components/Table/cells/index.d.ts.map +1 -0
- package/dist/src/components/Table/table.types.d.ts +304 -0
- package/dist/src/components/Table/table.types.d.ts.map +1 -0
- package/dist/src/components/Table/table.variants.d.ts +285 -0
- package/dist/src/components/Table/table.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +13 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Pagination/Pagination.test.tsx +314 -0
- package/src/components/Pagination/Pagination.tsx +275 -0
- package/src/components/Pagination/pagination.helpers.ts +102 -0
- package/src/components/Pagination/pagination.types.ts +117 -0
- package/src/components/Pagination/pagination.variants.ts +111 -0
- package/src/components/Skeleton/Skeleton.test.tsx +127 -0
- package/src/components/Skeleton/Skeleton.tsx +49 -0
- package/src/components/Skeleton/skeleton.types.ts +59 -0
- package/src/components/Skeleton/skeleton.variants.ts +86 -0
- package/src/components/Table/Table.test.tsx +603 -0
- package/src/components/Table/Table.tsx +816 -0
- package/src/components/Table/_internal/getNestedValue.test.ts +50 -0
- package/src/components/Table/_internal/getNestedValue.ts +32 -0
- package/src/components/Table/_internal/themeIdentity.test.ts +84 -0
- package/src/components/Table/_internal/useColumnAutoDetect.test.ts +123 -0
- package/src/components/Table/_internal/useColumnAutoDetect.ts +100 -0
- package/src/components/Table/_internal/useControllableState.ts +48 -0
- package/src/components/Table/_internal/useDebouncedValue.ts +24 -0
- package/src/components/Table/_internal/useTableFilter.ts +37 -0
- package/src/components/Table/_internal/useTableSearch.test.ts +115 -0
- package/src/components/Table/_internal/useTableSearch.ts +66 -0
- package/src/components/Table/_internal/useTableSelection.test.ts +100 -0
- package/src/components/Table/_internal/useTableSelection.ts +100 -0
- package/src/components/Table/_internal/useTableSort.test.ts +150 -0
- package/src/components/Table/_internal/useTableSort.ts +141 -0
- package/src/components/Table/cells/RenderButton.tsx +23 -0
- package/src/components/Table/cells/RenderChip.tsx +103 -0
- package/src/components/Table/cells/RenderText.tsx +37 -0
- package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
- package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
- package/src/components/Table/cells/index.ts +14 -0
- package/src/components/Table/table.types.ts +337 -0
- package/src/components/Table/table.variants.ts +207 -0
- package/src/index.ts +60 -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 './_internal/useControllableState.js';
|
|
7
|
+
import { useDebouncedValue } from './_internal/useDebouncedValue.js';
|
|
8
|
+
import { useTableSearch } from './_internal/useTableSearch.js';
|
|
9
|
+
import { useTableFilter } from './_internal/useTableFilter.js';
|
|
10
|
+
import { useTableSort, cycleSortFor } from './_internal/useTableSort.js';
|
|
11
|
+
import { useTableSelection } from './_internal/useTableSelection.js';
|
|
12
|
+
import { getNestedValue } from './_internal/getNestedValue.js';
|
|
13
|
+
import {
|
|
14
|
+
useColumnAutoDetect,
|
|
15
|
+
resolveAlign,
|
|
16
|
+
resolveMonospace,
|
|
17
|
+
resolveTabularNums,
|
|
18
|
+
} from './_internal/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
|
+
}
|