@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.
Files changed (183) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/COVERAGE.md +63 -0
  3. package/LICENSE +21 -0
  4. package/PERFORMANCE.md +59 -0
  5. package/REFINEMENT-NOTES.md +50 -0
  6. package/dist/index.esm.js +235 -160
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
  8. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -1
  9. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +7 -0
  10. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -1
  11. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts +44 -0
  12. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -0
  13. package/dist/src/components/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/src/components/Table/Table.d.ts.map +1 -1
  15. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
  16. package/dist/src/components/Table/table.types.d.ts +29 -6
  17. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  18. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  19. package/eslint.config.mjs +16 -1
  20. package/package.json +11 -8
  21. package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +5 -2
  22. package/src/components/DataGrid/DataGrid.perf.test.tsx +114 -0
  23. package/src/components/DataGrid/DataGrid.test.tsx +253 -17
  24. package/src/components/DataGrid/DataGrid.tsx +107 -24
  25. package/src/components/DataGrid/filters/ColumnFilters.test.tsx +330 -0
  26. package/src/components/DataGrid/filters/ColumnFilters.tsx +26 -2
  27. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +95 -3
  28. package/src/components/DataGrid/resize/useColumnResize.test.ts +144 -0
  29. package/src/components/DataGrid/resize/useColumnResize.ts +30 -1
  30. package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +186 -0
  31. package/src/components/Dialog/Dialog.tsx +9 -0
  32. package/src/components/Table/Table.perf.test.tsx +81 -0
  33. package/src/components/Table/Table.test.tsx +72 -1
  34. package/src/components/Table/Table.tsx +41 -8
  35. package/src/components/Table/cells/RenderChip.tsx +6 -9
  36. package/src/components/Table/cells/cells.test.tsx +247 -0
  37. package/src/components/Table/table.types.ts +29 -6
  38. package/src/components/Typography/Typography.test.tsx +15 -0
  39. package/src/components/Typography/typography.variants.ts +14 -1
  40. package/test-output/vitest/coverage/base.css +224 -0
  41. package/test-output/vitest/coverage/block-navigation.js +87 -0
  42. package/test-output/vitest/coverage/clover.xml +2418 -0
  43. package/test-output/vitest/coverage/coverage-final.json +89 -0
  44. package/test-output/vitest/coverage/favicon.png +0 -0
  45. package/test-output/vitest/coverage/index.html +746 -0
  46. package/test-output/vitest/coverage/prettify.css +1 -0
  47. package/test-output/vitest/coverage/prettify.js +2 -0
  48. package/test-output/vitest/coverage/sort-arrow-sprite.png +0 -0
  49. package/test-output/vitest/coverage/sorter.js +210 -0
  50. package/test-output/vitest/coverage/src/components/Accordion/Accordion.tsx.html +376 -0
  51. package/test-output/vitest/coverage/src/components/Accordion/accordion.variants.ts.html +181 -0
  52. package/test-output/vitest/coverage/src/components/Accordion/index.html +131 -0
  53. package/test-output/vitest/coverage/src/components/AppShell/AppShell.tsx.html +802 -0
  54. package/test-output/vitest/coverage/src/components/AppShell/appShell.variants.ts.html +247 -0
  55. package/test-output/vitest/coverage/src/components/AppShell/index.html +131 -0
  56. package/test-output/vitest/coverage/src/components/AspectRatio/AspectRatio.tsx.html +304 -0
  57. package/test-output/vitest/coverage/src/components/AspectRatio/index.html +116 -0
  58. package/test-output/vitest/coverage/src/components/Autocomplete/Autocomplete.tsx.html +3292 -0
  59. package/test-output/vitest/coverage/src/components/Autocomplete/autocomplete.variants.ts.html +508 -0
  60. package/test-output/vitest/coverage/src/components/Autocomplete/index.html +131 -0
  61. package/test-output/vitest/coverage/src/components/Box/Box.tsx.html +334 -0
  62. package/test-output/vitest/coverage/src/components/Box/box.variants.ts.html +775 -0
  63. package/test-output/vitest/coverage/src/components/Box/index.html +131 -0
  64. package/test-output/vitest/coverage/src/components/Breadcrumbs/Breadcrumbs.tsx.html +694 -0
  65. package/test-output/vitest/coverage/src/components/Breadcrumbs/breadcrumbs.variants.ts.html +247 -0
  66. package/test-output/vitest/coverage/src/components/Breadcrumbs/index.html +131 -0
  67. package/test-output/vitest/coverage/src/components/Button/Button.tsx.html +535 -0
  68. package/test-output/vitest/coverage/src/components/Button/button.variants.ts.html +562 -0
  69. package/test-output/vitest/coverage/src/components/Button/index.html +131 -0
  70. package/test-output/vitest/coverage/src/components/Checkbox/Checkbox.tsx.html +943 -0
  71. package/test-output/vitest/coverage/src/components/Checkbox/checkbox.variants.ts.html +289 -0
  72. package/test-output/vitest/coverage/src/components/Checkbox/index.html +131 -0
  73. package/test-output/vitest/coverage/src/components/ConfirmDialog/ConfirmDialog.tsx.html +841 -0
  74. package/test-output/vitest/coverage/src/components/ConfirmDialog/confirmDialog.variants.ts.html +313 -0
  75. package/test-output/vitest/coverage/src/components/ConfirmDialog/index.html +131 -0
  76. package/test-output/vitest/coverage/src/components/Container/Container.tsx.html +307 -0
  77. package/test-output/vitest/coverage/src/components/Container/container.variants.ts.html +328 -0
  78. package/test-output/vitest/coverage/src/components/Container/index.html +131 -0
  79. package/test-output/vitest/coverage/src/components/DataGrid/DataGrid.tsx.html +3937 -0
  80. package/test-output/vitest/coverage/src/components/DataGrid/dataGrid.variants.ts.html +541 -0
  81. package/test-output/vitest/coverage/src/components/DataGrid/filters/ColumnFilters.tsx.html +1453 -0
  82. package/test-output/vitest/coverage/src/components/DataGrid/filters/index.html +116 -0
  83. package/test-output/vitest/coverage/src/components/DataGrid/index.html +131 -0
  84. package/test-output/vitest/coverage/src/components/DataGrid/reorder/index.html +116 -0
  85. package/test-output/vitest/coverage/src/components/DataGrid/reorder/useColumnReorder.ts.html +517 -0
  86. package/test-output/vitest/coverage/src/components/DataGrid/resize/index.html +116 -0
  87. package/test-output/vitest/coverage/src/components/DataGrid/resize/useColumnResize.ts.html +490 -0
  88. package/test-output/vitest/coverage/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx.html +592 -0
  89. package/test-output/vitest/coverage/src/components/DataGrid/visibility/index.html +116 -0
  90. package/test-output/vitest/coverage/src/components/DateTimePicker/DateTimePicker.tsx.html +1042 -0
  91. package/test-output/vitest/coverage/src/components/DateTimePicker/dateTimePicker.variants.ts.html +337 -0
  92. package/test-output/vitest/coverage/src/components/DateTimePicker/index.html +131 -0
  93. package/test-output/vitest/coverage/src/components/Dialog/Dialog.tsx.html +391 -0
  94. package/test-output/vitest/coverage/src/components/Dialog/dialog.variants.ts.html +283 -0
  95. package/test-output/vitest/coverage/src/components/Dialog/index.html +131 -0
  96. package/test-output/vitest/coverage/src/components/Divider/Divider.tsx.html +505 -0
  97. package/test-output/vitest/coverage/src/components/Divider/divider.variants.ts.html +433 -0
  98. package/test-output/vitest/coverage/src/components/Divider/index.html +131 -0
  99. package/test-output/vitest/coverage/src/components/Grid/Grid.tsx.html +529 -0
  100. package/test-output/vitest/coverage/src/components/Grid/grid.variants.ts.html +484 -0
  101. package/test-output/vitest/coverage/src/components/Grid/index.html +131 -0
  102. package/test-output/vitest/coverage/src/components/LeftNav/LeftNav.tsx.html +1075 -0
  103. package/test-output/vitest/coverage/src/components/LeftNav/index.html +131 -0
  104. package/test-output/vitest/coverage/src/components/LeftNav/leftNav.variants.ts.html +406 -0
  105. package/test-output/vitest/coverage/src/components/NumberField/NumberField.tsx.html +1048 -0
  106. package/test-output/vitest/coverage/src/components/NumberField/index.html +131 -0
  107. package/test-output/vitest/coverage/src/components/NumberField/numberField.variants.ts.html +367 -0
  108. package/test-output/vitest/coverage/src/components/OTPField/OTPField.tsx.html +835 -0
  109. package/test-output/vitest/coverage/src/components/OTPField/index.html +131 -0
  110. package/test-output/vitest/coverage/src/components/OTPField/otpField.variants.ts.html +301 -0
  111. package/test-output/vitest/coverage/src/components/Pagination/Pagination.tsx.html +916 -0
  112. package/test-output/vitest/coverage/src/components/Pagination/index.html +146 -0
  113. package/test-output/vitest/coverage/src/components/Pagination/pagination.helpers.ts.html +391 -0
  114. package/test-output/vitest/coverage/src/components/Pagination/pagination.variants.ts.html +436 -0
  115. package/test-output/vitest/coverage/src/components/Popover/Popover.tsx.html +250 -0
  116. package/test-output/vitest/coverage/src/components/Popover/index.html +131 -0
  117. package/test-output/vitest/coverage/src/components/Popover/popover.variants.ts.html +157 -0
  118. package/test-output/vitest/coverage/src/components/RadioGroup/RadioGroup.tsx.html +934 -0
  119. package/test-output/vitest/coverage/src/components/RadioGroup/index.html +131 -0
  120. package/test-output/vitest/coverage/src/components/RadioGroup/radioGroup.variants.ts.html +346 -0
  121. package/test-output/vitest/coverage/src/components/Skeleton/Skeleton.tsx.html +232 -0
  122. package/test-output/vitest/coverage/src/components/Skeleton/index.html +131 -0
  123. package/test-output/vitest/coverage/src/components/Skeleton/skeleton.variants.ts.html +346 -0
  124. package/test-output/vitest/coverage/src/components/Snackbar/Snackbar.tsx.html +979 -0
  125. package/test-output/vitest/coverage/src/components/Snackbar/index.html +131 -0
  126. package/test-output/vitest/coverage/src/components/Snackbar/snackbar.variants.ts.html +307 -0
  127. package/test-output/vitest/coverage/src/components/Stack/Stack.tsx.html +430 -0
  128. package/test-output/vitest/coverage/src/components/Stack/index.html +131 -0
  129. package/test-output/vitest/coverage/src/components/Stack/stack.variants.ts.html +325 -0
  130. package/test-output/vitest/coverage/src/components/Switch/Switch.tsx.html +559 -0
  131. package/test-output/vitest/coverage/src/components/Switch/index.html +131 -0
  132. package/test-output/vitest/coverage/src/components/Switch/switch.variants.ts.html +295 -0
  133. package/test-output/vitest/coverage/src/components/Table/Table.tsx.html +2677 -0
  134. package/test-output/vitest/coverage/src/components/Table/cells/RenderButton.tsx.html +154 -0
  135. package/test-output/vitest/coverage/src/components/Table/cells/RenderChip.tsx.html +385 -0
  136. package/test-output/vitest/coverage/src/components/Table/cells/RenderText.tsx.html +196 -0
  137. package/test-output/vitest/coverage/src/components/Table/cells/RenderTwoLine.tsx.html +187 -0
  138. package/test-output/vitest/coverage/src/components/Table/cells/RowActionsMenu.tsx.html +526 -0
  139. package/test-output/vitest/coverage/src/components/Table/cells/index.html +191 -0
  140. package/test-output/vitest/coverage/src/components/Table/cells/index.ts.html +127 -0
  141. package/test-output/vitest/coverage/src/components/Table/index.html +131 -0
  142. package/test-output/vitest/coverage/src/components/Table/table.variants.ts.html +706 -0
  143. package/test-output/vitest/coverage/src/components/Tabs/Tabs.tsx.html +283 -0
  144. package/test-output/vitest/coverage/src/components/Tabs/index.html +131 -0
  145. package/test-output/vitest/coverage/src/components/Tabs/tabs.variants.ts.html +292 -0
  146. package/test-output/vitest/coverage/src/components/TextField/TextField.tsx.html +820 -0
  147. package/test-output/vitest/coverage/src/components/TextField/index.html +131 -0
  148. package/test-output/vitest/coverage/src/components/TextField/textField.variants.ts.html +400 -0
  149. package/test-output/vitest/coverage/src/components/Textarea/Textarea.tsx.html +655 -0
  150. package/test-output/vitest/coverage/src/components/Textarea/index.html +131 -0
  151. package/test-output/vitest/coverage/src/components/Textarea/textarea.variants.ts.html +340 -0
  152. package/test-output/vitest/coverage/src/components/Tooltip/Tooltip.tsx.html +295 -0
  153. package/test-output/vitest/coverage/src/components/Tooltip/index.html +131 -0
  154. package/test-output/vitest/coverage/src/components/Tooltip/tooltip.variants.ts.html +169 -0
  155. package/test-output/vitest/coverage/src/components/TopBar/TopBar.tsx.html +283 -0
  156. package/test-output/vitest/coverage/src/components/TopBar/index.html +131 -0
  157. package/test-output/vitest/coverage/src/components/TopBar/topBar.variants.ts.html +199 -0
  158. package/test-output/vitest/coverage/src/components/Typography/Typography.tsx.html +421 -0
  159. package/test-output/vitest/coverage/src/components/Typography/index.html +131 -0
  160. package/test-output/vitest/coverage/src/components/Typography/typography.variants.ts.html +436 -0
  161. package/test-output/vitest/coverage/src/components/VisuallyHidden/VisuallyHidden.tsx.html +262 -0
  162. package/test-output/vitest/coverage/src/components/VisuallyHidden/index.html +116 -0
  163. package/test-output/vitest/coverage/src/components/_shared/data/getNestedValue.ts.html +181 -0
  164. package/test-output/vitest/coverage/src/components/_shared/data/index.html +236 -0
  165. package/test-output/vitest/coverage/src/components/_shared/data/useColumnAutoDetect.ts.html +385 -0
  166. package/test-output/vitest/coverage/src/components/_shared/data/useControllableState.ts.html +229 -0
  167. package/test-output/vitest/coverage/src/components/_shared/data/useDebouncedValue.ts.html +157 -0
  168. package/test-output/vitest/coverage/src/components/_shared/data/useTableFilter.ts.html +439 -0
  169. package/test-output/vitest/coverage/src/components/_shared/data/useTableSearch.ts.html +283 -0
  170. package/test-output/vitest/coverage/src/components/_shared/data/useTableSelection.ts.html +385 -0
  171. package/test-output/vitest/coverage/src/components/_shared/data/useTableSort.ts.html +508 -0
  172. package/test-output/vitest/coverage/src/components/_shared/data/useVirtualizer.ts.html +682 -0
  173. package/test-output/vitest/coverage/src/components/_shared/index.html +116 -0
  174. package/test-output/vitest/coverage/src/components/_shared/resolveValidationState.ts.html +265 -0
  175. package/test-output/vitest/coverage/src/hooks/index.html +116 -0
  176. package/test-output/vitest/coverage/src/hooks/useAccessState.ts.html +328 -0
  177. package/test-output/vitest/coverage/src/index.html +116 -0
  178. package/test-output/vitest/coverage/src/index.ts.html +1153 -0
  179. package/test-output/vitest/coverage/src/utils/cn.ts.html +172 -0
  180. package/test-output/vitest/coverage/src/utils/index.html +116 -0
  181. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +0 -33
  182. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +0 -1
  183. 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
- return { startResize };
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: 'Manage columns',
56
- columnsDescription:
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
- fallback: labels.noData,
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 + light text
57
- // solid neutral: bg + text both anchored to neutral palette
58
- // (auto-inverts). `text-white` would not invert and would break
59
- // contrast in light mode against a `bg-neutral-700` dark bg, so
60
- // we stick to `text-neutral-50` (light text in light mode, dark
61
- // in dark — wait, that's wrong too). Actually `text-neutral-50`
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' },