@dashforge/tw 0.8.0-beta → 0.9.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 +188 -0
- package/COVERAGE.md +63 -0
- package/LICENSE +21 -0
- package/PERFORMANCE.md +59 -0
- package/REFINEMENT-NOTES.md +50 -0
- package/dist/index.esm.js +235 -160
- package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
- package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -1
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +7 -0
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -1
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts +44 -0
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -0
- package/dist/src/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/src/components/Table/Table.d.ts.map +1 -1
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
- package/dist/src/components/Table/table.types.d.ts +29 -6
- package/dist/src/components/Table/table.types.d.ts.map +1 -1
- package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
- package/eslint.config.mjs +16 -1
- package/package.json +11 -8
- package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +5 -2
- package/src/components/DataGrid/DataGrid.perf.test.tsx +114 -0
- package/src/components/DataGrid/DataGrid.test.tsx +253 -17
- package/src/components/DataGrid/DataGrid.tsx +107 -24
- package/src/components/DataGrid/filters/ColumnFilters.test.tsx +330 -0
- package/src/components/DataGrid/filters/ColumnFilters.tsx +26 -2
- package/src/components/DataGrid/reorder/useColumnReorder.test.ts +95 -3
- package/src/components/DataGrid/resize/useColumnResize.test.ts +144 -0
- package/src/components/DataGrid/resize/useColumnResize.ts +30 -1
- package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +186 -0
- package/src/components/Dialog/Dialog.tsx +9 -0
- package/src/components/Table/Table.perf.test.tsx +81 -0
- package/src/components/Table/Table.test.tsx +72 -1
- package/src/components/Table/Table.tsx +41 -8
- package/src/components/Table/cells/RenderChip.tsx +6 -9
- package/src/components/Table/cells/cells.test.tsx +247 -0
- package/src/components/Table/table.types.ts +29 -6
- package/src/components/Typography/Typography.test.tsx +15 -0
- package/src/components/Typography/typography.variants.ts +14 -1
- package/test-output/vitest/coverage/base.css +224 -0
- package/test-output/vitest/coverage/block-navigation.js +87 -0
- package/test-output/vitest/coverage/clover.xml +2418 -0
- package/test-output/vitest/coverage/coverage-final.json +89 -0
- package/test-output/vitest/coverage/favicon.png +0 -0
- package/test-output/vitest/coverage/index.html +746 -0
- package/test-output/vitest/coverage/prettify.css +1 -0
- package/test-output/vitest/coverage/prettify.js +2 -0
- package/test-output/vitest/coverage/sort-arrow-sprite.png +0 -0
- package/test-output/vitest/coverage/sorter.js +210 -0
- package/test-output/vitest/coverage/src/components/Accordion/Accordion.tsx.html +376 -0
- package/test-output/vitest/coverage/src/components/Accordion/accordion.variants.ts.html +181 -0
- package/test-output/vitest/coverage/src/components/Accordion/index.html +131 -0
- package/test-output/vitest/coverage/src/components/AppShell/AppShell.tsx.html +802 -0
- package/test-output/vitest/coverage/src/components/AppShell/appShell.variants.ts.html +247 -0
- package/test-output/vitest/coverage/src/components/AppShell/index.html +131 -0
- package/test-output/vitest/coverage/src/components/AspectRatio/AspectRatio.tsx.html +304 -0
- package/test-output/vitest/coverage/src/components/AspectRatio/index.html +116 -0
- package/test-output/vitest/coverage/src/components/Autocomplete/Autocomplete.tsx.html +3292 -0
- package/test-output/vitest/coverage/src/components/Autocomplete/autocomplete.variants.ts.html +508 -0
- package/test-output/vitest/coverage/src/components/Autocomplete/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Box/Box.tsx.html +334 -0
- package/test-output/vitest/coverage/src/components/Box/box.variants.ts.html +775 -0
- package/test-output/vitest/coverage/src/components/Box/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Breadcrumbs/Breadcrumbs.tsx.html +694 -0
- package/test-output/vitest/coverage/src/components/Breadcrumbs/breadcrumbs.variants.ts.html +247 -0
- package/test-output/vitest/coverage/src/components/Breadcrumbs/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Button/Button.tsx.html +535 -0
- package/test-output/vitest/coverage/src/components/Button/button.variants.ts.html +562 -0
- package/test-output/vitest/coverage/src/components/Button/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Checkbox/Checkbox.tsx.html +943 -0
- package/test-output/vitest/coverage/src/components/Checkbox/checkbox.variants.ts.html +289 -0
- package/test-output/vitest/coverage/src/components/Checkbox/index.html +131 -0
- package/test-output/vitest/coverage/src/components/ConfirmDialog/ConfirmDialog.tsx.html +841 -0
- package/test-output/vitest/coverage/src/components/ConfirmDialog/confirmDialog.variants.ts.html +313 -0
- package/test-output/vitest/coverage/src/components/ConfirmDialog/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Container/Container.tsx.html +307 -0
- package/test-output/vitest/coverage/src/components/Container/container.variants.ts.html +328 -0
- package/test-output/vitest/coverage/src/components/Container/index.html +131 -0
- package/test-output/vitest/coverage/src/components/DataGrid/DataGrid.tsx.html +3937 -0
- package/test-output/vitest/coverage/src/components/DataGrid/dataGrid.variants.ts.html +541 -0
- package/test-output/vitest/coverage/src/components/DataGrid/filters/ColumnFilters.tsx.html +1453 -0
- package/test-output/vitest/coverage/src/components/DataGrid/filters/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DataGrid/index.html +131 -0
- package/test-output/vitest/coverage/src/components/DataGrid/reorder/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DataGrid/reorder/useColumnReorder.ts.html +517 -0
- package/test-output/vitest/coverage/src/components/DataGrid/resize/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DataGrid/resize/useColumnResize.ts.html +490 -0
- package/test-output/vitest/coverage/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx.html +592 -0
- package/test-output/vitest/coverage/src/components/DataGrid/visibility/index.html +116 -0
- package/test-output/vitest/coverage/src/components/DateTimePicker/DateTimePicker.tsx.html +1042 -0
- package/test-output/vitest/coverage/src/components/DateTimePicker/dateTimePicker.variants.ts.html +337 -0
- package/test-output/vitest/coverage/src/components/DateTimePicker/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Dialog/Dialog.tsx.html +391 -0
- package/test-output/vitest/coverage/src/components/Dialog/dialog.variants.ts.html +283 -0
- package/test-output/vitest/coverage/src/components/Dialog/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Divider/Divider.tsx.html +505 -0
- package/test-output/vitest/coverage/src/components/Divider/divider.variants.ts.html +433 -0
- package/test-output/vitest/coverage/src/components/Divider/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Grid/Grid.tsx.html +529 -0
- package/test-output/vitest/coverage/src/components/Grid/grid.variants.ts.html +484 -0
- package/test-output/vitest/coverage/src/components/Grid/index.html +131 -0
- package/test-output/vitest/coverage/src/components/LeftNav/LeftNav.tsx.html +1075 -0
- package/test-output/vitest/coverage/src/components/LeftNav/index.html +131 -0
- package/test-output/vitest/coverage/src/components/LeftNav/leftNav.variants.ts.html +406 -0
- package/test-output/vitest/coverage/src/components/NumberField/NumberField.tsx.html +1048 -0
- package/test-output/vitest/coverage/src/components/NumberField/index.html +131 -0
- package/test-output/vitest/coverage/src/components/NumberField/numberField.variants.ts.html +367 -0
- package/test-output/vitest/coverage/src/components/OTPField/OTPField.tsx.html +835 -0
- package/test-output/vitest/coverage/src/components/OTPField/index.html +131 -0
- package/test-output/vitest/coverage/src/components/OTPField/otpField.variants.ts.html +301 -0
- package/test-output/vitest/coverage/src/components/Pagination/Pagination.tsx.html +916 -0
- package/test-output/vitest/coverage/src/components/Pagination/index.html +146 -0
- package/test-output/vitest/coverage/src/components/Pagination/pagination.helpers.ts.html +391 -0
- package/test-output/vitest/coverage/src/components/Pagination/pagination.variants.ts.html +436 -0
- package/test-output/vitest/coverage/src/components/Popover/Popover.tsx.html +250 -0
- package/test-output/vitest/coverage/src/components/Popover/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Popover/popover.variants.ts.html +157 -0
- package/test-output/vitest/coverage/src/components/RadioGroup/RadioGroup.tsx.html +934 -0
- package/test-output/vitest/coverage/src/components/RadioGroup/index.html +131 -0
- package/test-output/vitest/coverage/src/components/RadioGroup/radioGroup.variants.ts.html +346 -0
- package/test-output/vitest/coverage/src/components/Skeleton/Skeleton.tsx.html +232 -0
- package/test-output/vitest/coverage/src/components/Skeleton/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Skeleton/skeleton.variants.ts.html +346 -0
- package/test-output/vitest/coverage/src/components/Snackbar/Snackbar.tsx.html +979 -0
- package/test-output/vitest/coverage/src/components/Snackbar/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Snackbar/snackbar.variants.ts.html +307 -0
- package/test-output/vitest/coverage/src/components/Stack/Stack.tsx.html +430 -0
- package/test-output/vitest/coverage/src/components/Stack/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Stack/stack.variants.ts.html +325 -0
- package/test-output/vitest/coverage/src/components/Switch/Switch.tsx.html +559 -0
- package/test-output/vitest/coverage/src/components/Switch/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Switch/switch.variants.ts.html +295 -0
- package/test-output/vitest/coverage/src/components/Table/Table.tsx.html +2677 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderButton.tsx.html +154 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderChip.tsx.html +385 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderText.tsx.html +196 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RenderTwoLine.tsx.html +187 -0
- package/test-output/vitest/coverage/src/components/Table/cells/RowActionsMenu.tsx.html +526 -0
- package/test-output/vitest/coverage/src/components/Table/cells/index.html +191 -0
- package/test-output/vitest/coverage/src/components/Table/cells/index.ts.html +127 -0
- package/test-output/vitest/coverage/src/components/Table/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Table/table.variants.ts.html +706 -0
- package/test-output/vitest/coverage/src/components/Tabs/Tabs.tsx.html +283 -0
- package/test-output/vitest/coverage/src/components/Tabs/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Tabs/tabs.variants.ts.html +292 -0
- package/test-output/vitest/coverage/src/components/TextField/TextField.tsx.html +820 -0
- package/test-output/vitest/coverage/src/components/TextField/index.html +131 -0
- package/test-output/vitest/coverage/src/components/TextField/textField.variants.ts.html +400 -0
- package/test-output/vitest/coverage/src/components/Textarea/Textarea.tsx.html +655 -0
- package/test-output/vitest/coverage/src/components/Textarea/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Textarea/textarea.variants.ts.html +340 -0
- package/test-output/vitest/coverage/src/components/Tooltip/Tooltip.tsx.html +295 -0
- package/test-output/vitest/coverage/src/components/Tooltip/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Tooltip/tooltip.variants.ts.html +169 -0
- package/test-output/vitest/coverage/src/components/TopBar/TopBar.tsx.html +283 -0
- package/test-output/vitest/coverage/src/components/TopBar/index.html +131 -0
- package/test-output/vitest/coverage/src/components/TopBar/topBar.variants.ts.html +199 -0
- package/test-output/vitest/coverage/src/components/Typography/Typography.tsx.html +421 -0
- package/test-output/vitest/coverage/src/components/Typography/index.html +131 -0
- package/test-output/vitest/coverage/src/components/Typography/typography.variants.ts.html +436 -0
- package/test-output/vitest/coverage/src/components/VisuallyHidden/VisuallyHidden.tsx.html +262 -0
- package/test-output/vitest/coverage/src/components/VisuallyHidden/index.html +116 -0
- package/test-output/vitest/coverage/src/components/_shared/data/getNestedValue.ts.html +181 -0
- package/test-output/vitest/coverage/src/components/_shared/data/index.html +236 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useColumnAutoDetect.ts.html +385 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useControllableState.ts.html +229 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useDebouncedValue.ts.html +157 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableFilter.ts.html +439 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableSearch.ts.html +283 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableSelection.ts.html +385 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useTableSort.ts.html +508 -0
- package/test-output/vitest/coverage/src/components/_shared/data/useVirtualizer.ts.html +682 -0
- package/test-output/vitest/coverage/src/components/_shared/index.html +116 -0
- package/test-output/vitest/coverage/src/components/_shared/resolveValidationState.ts.html +265 -0
- package/test-output/vitest/coverage/src/hooks/index.html +116 -0
- package/test-output/vitest/coverage/src/hooks/useAccessState.ts.html +328 -0
- package/test-output/vitest/coverage/src/index.html +116 -0
- package/test-output/vitest/coverage/src/index.ts.html +1153 -0
- package/test-output/vitest/coverage/src/utils/cn.ts.html +172 -0
- package/test-output/vitest/coverage/src/utils/index.html +116 -0
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +0 -33
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +0 -1
- package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +0 -200
|
@@ -35,6 +35,19 @@ export interface ColumnResizeHandlers {
|
|
|
35
35
|
clampMin: number,
|
|
36
36
|
clampMax: number,
|
|
37
37
|
) => (e: RPointerEvent<HTMLElement>) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Commit a single clamped width delta — the keyboard-resize path.
|
|
40
|
+
* Wired to ArrowLeft/ArrowRight on the focused resize handle so the
|
|
41
|
+
* feature is reachable without a pointer (WCAG 2.1 — keyboard
|
|
42
|
+
* operability). Same clamping as the drag path.
|
|
43
|
+
*/
|
|
44
|
+
nudge: (
|
|
45
|
+
field: string,
|
|
46
|
+
currentWidth: number,
|
|
47
|
+
delta: number,
|
|
48
|
+
clampMin: number,
|
|
49
|
+
clampMax: number,
|
|
50
|
+
) => void;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
export function useColumnResize(args: UseColumnResizeArgs): ColumnResizeHandlers {
|
|
@@ -96,7 +109,23 @@ export function useColumnResize(args: UseColumnResizeArgs): ColumnResizeHandlers
|
|
|
96
109
|
[onChange, minWidth, maxWidth],
|
|
97
110
|
);
|
|
98
111
|
|
|
99
|
-
|
|
112
|
+
const nudge = useCallback(
|
|
113
|
+
(
|
|
114
|
+
field: string,
|
|
115
|
+
currentWidth: number,
|
|
116
|
+
delta: number,
|
|
117
|
+
clampMin: number,
|
|
118
|
+
clampMax: number,
|
|
119
|
+
) => {
|
|
120
|
+
const effectiveMin = Math.max(minWidth, clampMin);
|
|
121
|
+
const effectiveMax = Math.min(maxWidth, clampMax);
|
|
122
|
+
const next = clamp(currentWidth + delta, effectiveMin, effectiveMax);
|
|
123
|
+
onChange({ ...widthsRef.current, [field]: next });
|
|
124
|
+
},
|
|
125
|
+
[onChange, minWidth, maxWidth],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { startResize, nudge };
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
function clamp(n: number, min: number, max: number): number {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import { Popover } from '../../Popover/Popover.js';
|
|
3
|
+
import { cn } from '../../../utils/cn.js';
|
|
4
|
+
import type { TableColumn } from '../../Table/table.types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Column visibility menu for DataGrid.
|
|
8
|
+
*
|
|
9
|
+
* Renders a toolbar button (the trigger) that opens a `<Popover>`
|
|
10
|
+
* with one checkbox per `hideable` column. Auto-commits on every
|
|
11
|
+
* toggle — there's no draft / commit step. Outside-click and Esc
|
|
12
|
+
* close the popover (Radix-managed).
|
|
13
|
+
*
|
|
14
|
+
* UX rationale for Popover-over-Dialog:
|
|
15
|
+
* - Lightweight, non-modal: the user can keep referring to the grid
|
|
16
|
+
* while picking columns. A modal Dialog would interrupt the flow.
|
|
17
|
+
* - Auto-commit reads as a "settings panel" rather than a
|
|
18
|
+
* "configuration form" — matches AG-Grid / MUI DataGrid v6 /
|
|
19
|
+
* TanStack examples.
|
|
20
|
+
* - No focus trap on the rest of the page; the user can scroll the
|
|
21
|
+
* grid or click the column resize handles without dismissing the
|
|
22
|
+
* panel first.
|
|
23
|
+
*
|
|
24
|
+
* Columns with `hideable: false` are NEVER shown in the menu —
|
|
25
|
+
* those are considered structurally required (e.g. an ID column).
|
|
26
|
+
*
|
|
27
|
+
* RBAC-hidden columns (`access.onUnauthorized === 'hide'`) are also
|
|
28
|
+
* excluded — they're already removed from `visibleCols` upstream.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface ColumnVisibilityLabels {
|
|
32
|
+
columnsButton?: string;
|
|
33
|
+
columnsTitle?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Accessible label for the tri-state master checkbox that
|
|
36
|
+
* shows/hides every hideable column at once. Not shown as visible
|
|
37
|
+
* text — surfaced to assistive tech via `aria-label`.
|
|
38
|
+
*/
|
|
39
|
+
columnsToggleAll?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_VISIBILITY_LABELS: Required<ColumnVisibilityLabels> = {
|
|
43
|
+
columnsButton: 'Columns',
|
|
44
|
+
columnsTitle: 'Toggle columns',
|
|
45
|
+
columnsToggleAll: 'Toggle all columns',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface ColumnVisibilityTriggerProps<T extends object> {
|
|
49
|
+
cols: TableColumn<T>[];
|
|
50
|
+
hiddenColumns: string[];
|
|
51
|
+
onChange: (next: string[]) => void;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
labels?: ColumnVisibilityLabels;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ColumnVisibilityTrigger<T extends object>(
|
|
57
|
+
props: ColumnVisibilityTriggerProps<T>,
|
|
58
|
+
) {
|
|
59
|
+
const { cols, hiddenColumns, onChange, disabled, labels: labelsProp } = props;
|
|
60
|
+
const labels = { ...DEFAULT_VISIBILITY_LABELS, ...labelsProp };
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Hideable cols are the only ones the user can toggle. `hideable`
|
|
64
|
+
// defaults to `true` — a column is hideable unless explicitly opted
|
|
65
|
+
// out with `hideable: false` (structurally required column).
|
|
66
|
+
const hideableCols = cols.filter((c) => c.hideable !== false);
|
|
67
|
+
|
|
68
|
+
const toggle = (field: string) => {
|
|
69
|
+
onChange(
|
|
70
|
+
hiddenColumns.includes(field)
|
|
71
|
+
? hiddenColumns.filter((f) => f !== field)
|
|
72
|
+
: [...hiddenColumns, field],
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Tri-state master: checked = every hideable column visible,
|
|
77
|
+
// unchecked = every one hidden, indeterminate = mixed.
|
|
78
|
+
const allVisible = hideableCols.every(
|
|
79
|
+
(c) => !hiddenColumns.includes(c.field as string),
|
|
80
|
+
);
|
|
81
|
+
const allHidden =
|
|
82
|
+
hideableCols.length > 0 &&
|
|
83
|
+
hideableCols.every((c) => hiddenColumns.includes(c.field as string));
|
|
84
|
+
const someHidden = !allVisible && !allHidden;
|
|
85
|
+
|
|
86
|
+
// Standard select-all semantics: when everything is visible the
|
|
87
|
+
// master hides all; otherwise it shows all.
|
|
88
|
+
const toggleAll = () => {
|
|
89
|
+
onChange(allVisible ? hideableCols.map((c) => c.field as string) : []);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Popover
|
|
94
|
+
open={open}
|
|
95
|
+
onOpenChange={setOpen}
|
|
96
|
+
side="bottom"
|
|
97
|
+
align="end"
|
|
98
|
+
content={
|
|
99
|
+
<div className="flex flex-col gap-1 min-w-[220px]">
|
|
100
|
+
{/*
|
|
101
|
+
Header row — the tri-state master checkbox + the title.
|
|
102
|
+
The master replaces the old "Show all · Hide all" text
|
|
103
|
+
links: one control, no extra copy, consistent with the
|
|
104
|
+
DataGrid row select-all checkbox. The title is a heading
|
|
105
|
+
(not part of the checkbox label) and stays consumer-
|
|
106
|
+
customizable via `labels.columnsTitle`.
|
|
107
|
+
*/}
|
|
108
|
+
<div className="flex items-center gap-2 px-1 pb-2 mb-1 border-b border-neutral-200">
|
|
109
|
+
<input
|
|
110
|
+
type="checkbox"
|
|
111
|
+
aria-label={labels.columnsToggleAll}
|
|
112
|
+
checked={allVisible}
|
|
113
|
+
ref={(el) => {
|
|
114
|
+
if (el) el.indeterminate = someHidden;
|
|
115
|
+
}}
|
|
116
|
+
onChange={toggleAll}
|
|
117
|
+
className="cursor-pointer"
|
|
118
|
+
/>
|
|
119
|
+
<span className="text-xs font-semibold text-neutral-700 uppercase tracking-wide">
|
|
120
|
+
{labels.columnsTitle}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
<ul className="flex flex-col gap-0.5 max-h-72 overflow-auto">
|
|
124
|
+
{hideableCols.map((col) => {
|
|
125
|
+
const field = col.field as string;
|
|
126
|
+
const hidden = hiddenColumns.includes(field);
|
|
127
|
+
const header =
|
|
128
|
+
typeof col.header === 'function' ? col.header() : col.header;
|
|
129
|
+
return (
|
|
130
|
+
<li key={field}>
|
|
131
|
+
<label className="flex items-center gap-2 cursor-pointer py-1 px-1 rounded hover:bg-neutral-100">
|
|
132
|
+
<input
|
|
133
|
+
type="checkbox"
|
|
134
|
+
checked={!hidden}
|
|
135
|
+
onChange={() => toggle(field)}
|
|
136
|
+
className="cursor-pointer"
|
|
137
|
+
/>
|
|
138
|
+
<span className="text-sm text-neutral-900">{header}</span>
|
|
139
|
+
</label>
|
|
140
|
+
</li>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</ul>
|
|
144
|
+
</div>
|
|
145
|
+
}
|
|
146
|
+
>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
className={cn(
|
|
151
|
+
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md',
|
|
152
|
+
'text-sm text-neutral-700 hover:text-neutral-900',
|
|
153
|
+
'border border-neutral-300 bg-neutral-50 hover:bg-neutral-100',
|
|
154
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
155
|
+
'transition-colors motion-reduce:transition-none',
|
|
156
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<ColumnsIcon />
|
|
160
|
+
<span>{labels.columnsButton}</span>
|
|
161
|
+
</button>
|
|
162
|
+
</Popover>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ───── Icon ─────
|
|
167
|
+
|
|
168
|
+
function ColumnsIcon(): ReactNode {
|
|
169
|
+
return (
|
|
170
|
+
<svg
|
|
171
|
+
width="0.875em"
|
|
172
|
+
height="0.875em"
|
|
173
|
+
viewBox="0 0 16 16"
|
|
174
|
+
fill="none"
|
|
175
|
+
stroke="currentColor"
|
|
176
|
+
strokeWidth="1.5"
|
|
177
|
+
strokeLinecap="round"
|
|
178
|
+
strokeLinejoin="round"
|
|
179
|
+
aria-hidden="true"
|
|
180
|
+
>
|
|
181
|
+
<rect x="2" y="3" width="3.5" height="10" rx="0.5" />
|
|
182
|
+
<rect x="6.25" y="3" width="3.5" height="10" rx="0.5" />
|
|
183
|
+
<rect x="10.5" y="3" width="3.5" height="10" rx="0.5" />
|
|
184
|
+
</svg>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -42,6 +42,15 @@ export function Dialog(props: DialogProps) {
|
|
|
42
42
|
/>
|
|
43
43
|
<RadixDialog.Content
|
|
44
44
|
className={cn(v.content(), sx, slotProps?.content?.className)}
|
|
45
|
+
// When no `description` is supplied we render no
|
|
46
|
+
// `<RadixDialog.Description>`. Radix then logs a dev warning
|
|
47
|
+
// unless the Content explicitly opts out via
|
|
48
|
+
// `aria-describedby={undefined}` — the documented escape
|
|
49
|
+
// hatch for description-less dialogs. Spread it ONLY in that
|
|
50
|
+
// case so a present description still auto-links its id.
|
|
51
|
+
{...(description == null
|
|
52
|
+
? { 'aria-describedby': undefined }
|
|
53
|
+
: {})}
|
|
45
54
|
onPointerDownOutside={(event) => {
|
|
46
55
|
if (disableBackdropClose) event.preventDefault();
|
|
47
56
|
}}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Performance guardrails for `<Table>` (non-virtualized).
|
|
4
|
+
*
|
|
5
|
+
* Table renders EVERY row in the DOM — its design ceiling is ~500
|
|
6
|
+
* rows (past that, the consumer should switch to `<DataGrid>`). These
|
|
7
|
+
* tests pin the mount cost at that ceiling so a regression in the
|
|
8
|
+
* render path (e.g. an accidental O(n²) in the sort / search pipeline)
|
|
9
|
+
* surfaces in CI.
|
|
10
|
+
*
|
|
11
|
+
* Budgets are deliberately generous — they catch order-of-magnitude
|
|
12
|
+
* regressions under a cold workspace-wide test run, not micro-jitter.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
16
|
+
import { render, cleanup } from '@testing-library/react';
|
|
17
|
+
import { Table } from './Table.js';
|
|
18
|
+
import type { TableColumn } from './table.types.js';
|
|
19
|
+
|
|
20
|
+
void React;
|
|
21
|
+
afterEach(() => cleanup());
|
|
22
|
+
|
|
23
|
+
interface Row {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
email: string;
|
|
27
|
+
age: number;
|
|
28
|
+
city: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COLS: TableColumn<Row>[] = [
|
|
32
|
+
{ field: 'name', header: 'Name', sortable: true, searchable: true },
|
|
33
|
+
{ field: 'email', header: 'Email', searchable: true },
|
|
34
|
+
{ field: 'age', header: 'Age', sortable: true },
|
|
35
|
+
{ field: 'city', header: 'City' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function makeRows(n: number): Row[] {
|
|
39
|
+
const cities = ['Roma', 'Milano', 'Berlin'];
|
|
40
|
+
return Array.from({ length: n }).map((_, i) => ({
|
|
41
|
+
id: `r${i}`,
|
|
42
|
+
name: `User ${i}`,
|
|
43
|
+
email: `user${i}@example.com`,
|
|
44
|
+
age: 20 + (i % 50),
|
|
45
|
+
city: cities[i % 3] as string,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('<Table> performance', () => {
|
|
50
|
+
it("mounts 500 rows (Table's row-count ceiling) under 800ms", () => {
|
|
51
|
+
const rows = makeRows(500);
|
|
52
|
+
const t0 = performance.now();
|
|
53
|
+
render(<Table rows={rows} cols={COLS} getRowId={(r) => r.id} />);
|
|
54
|
+
const t1 = performance.now();
|
|
55
|
+
expect(t1 - t0).toBeLessThan(800);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders every row in the DOM (non-virtualized contract)', () => {
|
|
59
|
+
const rows = makeRows(500);
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<Table rows={rows} cols={COLS} getRowId={(r) => r.id} />,
|
|
62
|
+
);
|
|
63
|
+
// Non-virtualized: all 500 data rows are mounted.
|
|
64
|
+
expect(container.querySelectorAll('tbody tr').length).toBe(500);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('applies a default sort over 500 rows within the mount budget', () => {
|
|
68
|
+
const rows = makeRows(500);
|
|
69
|
+
const t0 = performance.now();
|
|
70
|
+
render(
|
|
71
|
+
<Table
|
|
72
|
+
rows={rows}
|
|
73
|
+
cols={COLS}
|
|
74
|
+
getRowId={(r) => r.id}
|
|
75
|
+
defaultSortModel={[{ field: 'name', direction: 'asc' }]}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
const t1 = performance.now();
|
|
79
|
+
expect(t1 - t0).toBeLessThan(800);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { describe, it, expect, afterEach, beforeAll } from 'vitest';
|
|
3
|
+
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
|
|
4
4
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|
5
5
|
import { Table } from './Table.js';
|
|
6
6
|
import type { TableColumn } from './table.types.js';
|
|
@@ -601,3 +601,74 @@ describe('Table — i18n', () => {
|
|
|
601
601
|
expect(screen.queryByLabelText('Seleziona tutte')).not.toBeNull();
|
|
602
602
|
});
|
|
603
603
|
});
|
|
604
|
+
|
|
605
|
+
describe('Table — Sprint 6 P2 refinements', () => {
|
|
606
|
+
// ───── D7: no-results vs no-data empty state ─────
|
|
607
|
+
|
|
608
|
+
it('shows the noData message when the dataset is genuinely empty', () => {
|
|
609
|
+
render(<Table rows={[]} cols={baseCols} getRowId={getRowId} />);
|
|
610
|
+
expect(screen.queryByText('No data')).not.toBeNull();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('shows the noResults message when a search excludes every row', () => {
|
|
614
|
+
render(
|
|
615
|
+
<Table
|
|
616
|
+
rows={baseRows}
|
|
617
|
+
cols={baseCols}
|
|
618
|
+
getRowId={getRowId}
|
|
619
|
+
enableSearch
|
|
620
|
+
searchQuery="zzz-no-match"
|
|
621
|
+
searchDebounceMs={0}
|
|
622
|
+
/>,
|
|
623
|
+
);
|
|
624
|
+
expect(screen.queryByText('No matching results')).not.toBeNull();
|
|
625
|
+
expect(screen.queryByText('No data')).toBeNull();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('shows the noResults message when a filter excludes every row', () => {
|
|
629
|
+
render(
|
|
630
|
+
<Table
|
|
631
|
+
rows={baseRows}
|
|
632
|
+
cols={baseCols}
|
|
633
|
+
getRowId={getRowId}
|
|
634
|
+
filterModel={[{ field: 'name', op: 'contains', value: 'zzz-no-match' }]}
|
|
635
|
+
/>,
|
|
636
|
+
);
|
|
637
|
+
expect(screen.queryByText('No matching results')).not.toBeNull();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// ───── T1: dev-warn when getRowId omitted + identity-sensitive feature ─────
|
|
641
|
+
|
|
642
|
+
it('warns in dev when getRowId is omitted and a sortable column exists', () => {
|
|
643
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
644
|
+
// baseCols has sortable columns; no getRowId passed.
|
|
645
|
+
render(<Table rows={baseRows} cols={baseCols} />);
|
|
646
|
+
expect(warn).toHaveBeenCalledWith(
|
|
647
|
+
expect.stringContaining('`getRowId` is not set'),
|
|
648
|
+
);
|
|
649
|
+
warn.mockRestore();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('does NOT warn when getRowId is provided', () => {
|
|
653
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
654
|
+
render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
|
|
655
|
+
expect(warn).not.toHaveBeenCalledWith(
|
|
656
|
+
expect.stringContaining('`getRowId` is not set'),
|
|
657
|
+
);
|
|
658
|
+
warn.mockRestore();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('does NOT warn when getRowId is omitted but no identity-sensitive feature is active', () => {
|
|
662
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
663
|
+
// Plain columns: not sortable, not searchable, no selection/expansion.
|
|
664
|
+
const plainCols: TableColumn<User>[] = [
|
|
665
|
+
{ field: 'name', header: 'Name' },
|
|
666
|
+
{ field: 'email', header: 'Email' },
|
|
667
|
+
];
|
|
668
|
+
render(<Table rows={baseRows} cols={plainCols} />);
|
|
669
|
+
expect(warn).not.toHaveBeenCalledWith(
|
|
670
|
+
expect.stringContaining('`getRowId` is not set'),
|
|
671
|
+
);
|
|
672
|
+
warn.mockRestore();
|
|
673
|
+
});
|
|
674
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, type ReactNode, type MouseEvent } from 'react';
|
|
1
|
+
import { useMemo, useEffect, type ReactNode, type MouseEvent } from 'react';
|
|
2
2
|
import { cn } from '../../utils/cn.js';
|
|
3
3
|
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
4
4
|
import { Skeleton } from '../Skeleton/Skeleton.js';
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
const DEFAULT_LABELS: Required<TableLabels> = {
|
|
28
28
|
searchPlaceholder: 'Search...',
|
|
29
29
|
noData: 'No data',
|
|
30
|
+
noResults: 'No matching results',
|
|
30
31
|
loading: 'Loading…',
|
|
31
32
|
ariaSortNone: 'Not sorted. Click to sort ascending.',
|
|
32
33
|
ariaSortAscending: 'Sorted ascending. Click to sort descending.',
|
|
@@ -52,12 +53,8 @@ const DEFAULT_LABELS: Required<TableLabels> = {
|
|
|
52
53
|
densityComfortable: 'Comfortable',
|
|
53
54
|
densitySpacious: 'Spacious',
|
|
54
55
|
columnsButton: 'Columns',
|
|
55
|
-
columnsTitle: '
|
|
56
|
-
|
|
57
|
-
'Show or hide columns. Required columns cannot be toggled.',
|
|
58
|
-
columnsShowAll: 'Show all',
|
|
59
|
-
columnsHideAll: 'Hide all',
|
|
60
|
-
columnsDone: 'Done',
|
|
56
|
+
columnsTitle: 'Toggle columns',
|
|
57
|
+
columnsToggleAll: 'Toggle all columns',
|
|
61
58
|
};
|
|
62
59
|
|
|
63
60
|
const DEFAULT_GET_ROW_ID = <T extends object>(_row: T, index: number) => String(index);
|
|
@@ -135,6 +132,31 @@ export function Table<T extends object>(props: TableProps<T>) {
|
|
|
135
132
|
const labels = { ...DEFAULT_LABELS, ...labelsProp };
|
|
136
133
|
const tableAccessState = useAccessState(tableAccess);
|
|
137
134
|
|
|
135
|
+
// Dev-only guard: `getRowId` is optional (positional-index fallback)
|
|
136
|
+
// but that fallback breaks row identity once rows reorder (sort) or
|
|
137
|
+
// change presence (search / filter) — selection + expansion state
|
|
138
|
+
// then jump to the wrong rows. Warn when it's omitted AND a feature
|
|
139
|
+
// that relies on stable identity is active. Stripped in production.
|
|
140
|
+
const getRowIdProvided = props.getRowId !== undefined;
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
143
|
+
if (getRowIdProvided) return;
|
|
144
|
+
const identityMatters =
|
|
145
|
+
rowSelection !== 'none' ||
|
|
146
|
+
expandable != null ||
|
|
147
|
+
enableSearch ||
|
|
148
|
+
cols.some((c) => Boolean(c.sortable));
|
|
149
|
+
if (identityMatters) {
|
|
150
|
+
console.warn(
|
|
151
|
+
'[Dashforge Table] `getRowId` is not set — falling back to the ' +
|
|
152
|
+
'positional row index. With sort / search / selection / ' +
|
|
153
|
+
'expandable rows active, the index-based key breaks row ' +
|
|
154
|
+
'identity when rows reorder. Pass a stable resolver, e.g. ' +
|
|
155
|
+
'`getRowId={(row) => row.id}`.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}, [getRowIdProvided, rowSelection, expandable, enableSearch, cols]);
|
|
159
|
+
|
|
138
160
|
// ───── Controllable state machinery ─────
|
|
139
161
|
|
|
140
162
|
const [sortModel, setSortModel] = useControllableState<TableSortModel>({
|
|
@@ -344,7 +366,14 @@ export function Table<T extends object>(props: TableProps<T>) {
|
|
|
344
366
|
? renderEmptyState({
|
|
345
367
|
totalColumnCount,
|
|
346
368
|
emptyState,
|
|
347
|
-
|
|
369
|
+
// Distinguish a genuinely empty dataset from one
|
|
370
|
+
// emptied by an active search / filter.
|
|
371
|
+
fallback:
|
|
372
|
+
rows.length > 0 &&
|
|
373
|
+
(debouncedSearchQuery.trim().length > 0 ||
|
|
374
|
+
filterModel.length > 0)
|
|
375
|
+
? labels.noResults
|
|
376
|
+
: labels.noData,
|
|
348
377
|
cellClass: cn(v.cell(), v.emptyState(), slotProps?.emptyState?.className),
|
|
349
378
|
})
|
|
350
379
|
: sortedRows.map((row, idx) => {
|
|
@@ -736,6 +765,10 @@ function renderLoadingRows<T extends object>(args: {
|
|
|
736
765
|
cellClass: string;
|
|
737
766
|
}): ReactNode {
|
|
738
767
|
const { count, visibleCols, showSelectionColumn, showExpandColumn, showRowActionsColumn, rowClass, cellClass } = args;
|
|
768
|
+
// No explicit row height here (unlike DataGrid's loading rows, which
|
|
769
|
+
// pin `rowHeight` for the virtualization math): Table is auto-height
|
|
770
|
+
// by design. Skeleton rows inherit the same density padding via
|
|
771
|
+
// `cellClass`, so they line up with real rows without a fixed height.
|
|
739
772
|
return Array.from({ length: count }).map((_, i) => (
|
|
740
773
|
<tr key={`loading-${i}`} className={rowClass} aria-busy="true">
|
|
741
774
|
{showSelectionColumn && <td className={cellClass}><Skeleton variant="rectangle" width="16px" height="16px" /></td>}
|
|
@@ -53,15 +53,12 @@ const chipVariants = tv({
|
|
|
53
53
|
{ variant: 'soft', color: 'danger', class: 'bg-danger-100 dark:bg-danger-950 text-danger-700 dark:text-danger-300' },
|
|
54
54
|
{ variant: 'soft', color: 'info', class: 'bg-info-100 dark:bg-info-950 text-info-700 dark:text-info-300' },
|
|
55
55
|
{ variant: 'soft', color: 'secondary', class: 'bg-secondary-100 dark:bg-secondary-950 text-secondary-700 dark:text-secondary-300' },
|
|
56
|
-
// solid = full bg +
|
|
57
|
-
// solid neutral: bg
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// auto-inverts: light text in light mode (against dark bg) is
|
|
63
|
-
// what we want; in dark mode `bg-neutral-700` becomes light AND
|
|
64
|
-
// `text-neutral-50` becomes dark — contrast preserved.
|
|
56
|
+
// solid = full bg + contrasting text.
|
|
57
|
+
// solid neutral: both `bg-neutral-700` and `text-neutral-50` sit
|
|
58
|
+
// on the neutral palette, so they auto-invert together via the
|
|
59
|
+
// CSS-var swap — bg/text contrast is preserved in BOTH modes.
|
|
60
|
+
// (`text-white` is deliberately NOT used here: white does not
|
|
61
|
+
// auto-invert, which would flip the contrast in dark mode.)
|
|
65
62
|
{ variant: 'solid', color: 'neutral', class: 'bg-neutral-700 text-neutral-50' },
|
|
66
63
|
{ variant: 'solid', color: 'primary', class: 'bg-primary-500 text-white' },
|
|
67
64
|
{ variant: 'solid', color: 'success', class: 'bg-success-500 text-white' },
|