@dashforge/tw 0.8.1-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 (180) hide show
  1. package/CHANGELOG.md +126 -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 +177 -67
  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 +6 -2
  12. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -1
  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 +28 -3
  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 +9 -6
  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 +240 -0
  24. package/src/components/DataGrid/DataGrid.tsx +104 -16
  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 +42 -25
  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 +40 -4
  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 +28 -3
  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
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  useMemo,
3
+ useState,
4
+ useEffect,
3
5
  type ReactNode,
4
6
  type MouseEvent,
5
7
  type PointerEvent as RPointerEvent,
@@ -43,6 +45,7 @@ import {
43
45
  const DEFAULT_LABELS: Required<TableLabels> = {
44
46
  searchPlaceholder: 'Search...',
45
47
  noData: 'No data',
48
+ noResults: 'No matching results',
46
49
  loading: 'Loading…',
47
50
  ariaSortNone: 'Not sorted. Click to sort ascending.',
48
51
  ariaSortAscending: 'Sorted ascending. Click to sort descending.',
@@ -69,8 +72,7 @@ const DEFAULT_LABELS: Required<TableLabels> = {
69
72
  densitySpacious: 'Spacious',
70
73
  columnsButton: 'Columns',
71
74
  columnsTitle: 'Toggle columns',
72
- columnsShowAll: 'Show all',
73
- columnsHideAll: 'Hide all',
75
+ columnsToggleAll: 'Toggle all columns',
74
76
  };
75
77
 
76
78
  /**
@@ -235,7 +237,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
235
237
  defaultValue: defaultColumnWidths,
236
238
  onChange: onColumnWidthsChange,
237
239
  });
238
- const { startResize } = useColumnResize({
240
+ const { startResize, nudge: nudgeColumnWidth } = useColumnResize({
239
241
  widths: columnWidths,
240
242
  onChange: setColumnWidths,
241
243
  });
@@ -255,6 +257,23 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
255
257
  onChange: setColumnOrder,
256
258
  });
257
259
 
260
+ // Column reorder rides on native HTML5 drag-and-drop, whose
261
+ // `dragstart` / `drop` events DO NOT fire for touch input. On a
262
+ // coarse-pointer device the drag would be a dead affordance — so
263
+ // we detect the primary pointer and gate reorder off there. Mouse
264
+ // / trackpad / pen (fine pointer) keep the feature. A pointer-event
265
+ // based reorder that also covers touch is tracked as a follow-up
266
+ // in REFINEMENT-NOTES.md.
267
+ const [coarsePointer, setCoarsePointer] = useState(false);
268
+ useEffect(() => {
269
+ if (typeof window === 'undefined' || !window.matchMedia) return;
270
+ const mq = window.matchMedia('(pointer: coarse)');
271
+ setCoarsePointer(mq.matches);
272
+ const onChange = (e: MediaQueryListEvent) => setCoarsePointer(e.matches);
273
+ mq.addEventListener('change', onChange);
274
+ return () => mq.removeEventListener('change', onChange);
275
+ }, []);
276
+
258
277
  // Apply the active order to the columns list. Cols not in the
259
278
  // order array (e.g. newly-added columns at runtime) are appended
260
279
  // in their original position from `cols`.
@@ -420,8 +439,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
420
439
  labels={{
421
440
  columnsButton: labels.columnsButton,
422
441
  columnsTitle: labels.columnsTitle,
423
- columnsShowAll: labels.columnsShowAll,
424
- columnsHideAll: labels.columnsHideAll,
442
+ columnsToggleAll: labels.columnsToggleAll,
425
443
  }}
426
444
  />
427
445
  )}
@@ -434,7 +452,19 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
434
452
  className={cn(v.scroll(), slotProps?.scroll?.className)}
435
453
  style={height ? { height, maxHeight: height } : undefined}
436
454
  >
437
- <table className={cn(v.table(), slotProps?.table?.className)}>
455
+ {/*
456
+ `aria-rowcount` announces the TRUE total to assistive tech —
457
+ the virtualized DOM only holds the ~visible window, so without
458
+ it a screen reader reports "row N of <window size>" instead of
459
+ "row N of <dataset size>". `+ 1` accounts for the header row.
460
+ Each `<tr>` carries a 1-based `aria-rowindex` (header = 1,
461
+ data rows = absoluteIndex + 2). `-1` when the effective count
462
+ is unknown/zero keeps it a valid (ignored) value.
463
+ */}
464
+ <table
465
+ className={cn(v.table(), slotProps?.table?.className)}
466
+ aria-rowcount={effectiveTotalCount > 0 ? effectiveTotalCount + 1 : -1}
467
+ >
438
468
  {caption != null && (
439
469
  <caption className={cn(!showCaption && 'sr-only', 'py-2 text-sm text-neutral-500')}>
440
470
  {caption}
@@ -447,7 +477,10 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
447
477
  slotProps?.thead?.className,
448
478
  )}
449
479
  >
450
- <tr className={cn(v.headerRow(), slotProps?.headerRow?.className)}>
480
+ <tr
481
+ aria-rowindex={1}
482
+ className={cn(v.headerRow(), slotProps?.headerRow?.className)}
483
+ >
451
484
  {showSelectionColumn && (
452
485
  <th
453
486
  scope="col"
@@ -492,8 +525,11 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
492
525
  enableColumnResize && col.resizable !== false
493
526
  }
494
527
  startResize={startResize}
528
+ nudgeColumnWidth={nudgeColumnWidth}
495
529
  reorderable={
496
- enableColumnReorder && col.reorderable !== false
530
+ enableColumnReorder &&
531
+ col.reorderable !== false &&
532
+ !coarsePointer
497
533
  }
498
534
  reorderHandlers={reorderHandlers}
499
535
  labels={labels}
@@ -537,7 +573,16 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
537
573
  ? renderEmptyState({
538
574
  totalColumnCount,
539
575
  emptyState,
540
- fallback: labels.noData,
576
+ // Distinguish a genuinely empty dataset from one
577
+ // emptied by an active search / filter (works for
578
+ // server-side mode too — a query that returns 0
579
+ // rows still reads as "no matching results").
580
+ fallback:
581
+ rows.length > 0 &&
582
+ (debouncedSearchQuery.trim().length > 0 ||
583
+ filterModel.length > 0)
584
+ ? labels.noResults
585
+ : labels.noData,
541
586
  cellClass: cn(
542
587
  v.cell(),
543
588
  v.emptyState(),
@@ -648,6 +693,13 @@ function HeaderCell<T extends object>(props: {
648
693
  clampMin: number,
649
694
  clampMax: number,
650
695
  ) => (e: RPointerEvent<HTMLElement>) => void;
696
+ nudgeColumnWidth: (
697
+ field: string,
698
+ currentWidth: number,
699
+ delta: number,
700
+ clampMin: number,
701
+ clampMax: number,
702
+ ) => void;
651
703
  reorderable: boolean;
652
704
  reorderHandlers: ColumnReorderHandlers;
653
705
  labels: Required<TableLabels>;
@@ -665,6 +717,7 @@ function HeaderCell<T extends object>(props: {
665
717
  effectiveWidth,
666
718
  resizable,
667
719
  startResize,
720
+ nudgeColumnWidth,
668
721
  reorderable,
669
722
  reorderHandlers,
670
723
  labels,
@@ -720,24 +773,55 @@ function HeaderCell<T extends object>(props: {
720
773
  }
721
774
  : {};
722
775
 
776
+ // Resize bounds — shared by the pointer-drag and keyboard paths.
777
+ const resizeMin = col.minWidth ?? 40;
778
+ const resizeMax = col.maxWidth ?? 1200;
779
+ // Keyboard nudge step: 16px, or 64px with Shift held (coarse).
780
+ const measureWidth = (handle: HTMLElement): number =>
781
+ effectiveWidth ??
782
+ (handle.parentElement as HTMLElement | null)?.getBoundingClientRect()
783
+ .width ??
784
+ 120;
785
+
723
786
  const resizeHandle = resizable ? (
724
787
  <span
725
788
  role="separator"
726
789
  aria-orientation="vertical"
727
790
  aria-label={`Resize ${typeof col.header === 'string' ? col.header : col.field}`}
791
+ // Width bounds announced to assistive tech on the separator.
792
+ aria-valuemin={resizeMin}
793
+ aria-valuemax={resizeMax}
794
+ aria-valuenow={effectiveWidth ?? undefined}
795
+ // Keyboard-operable: focusable + ArrowLeft/Right resize. WCAG 2.1
796
+ // keyboard operability — the drag is no longer the only path.
797
+ tabIndex={disabled ? -1 : 0}
798
+ // D6 guard: the parent `<th>` is `draggable` when reorderable;
799
+ // marking the handle non-draggable means grabbing the handle
800
+ // starts a RESIZE, never a column-reorder drag.
801
+ draggable={false}
728
802
  onPointerDown={(e) => {
729
- const handle = e.currentTarget;
730
- const th = handle.parentElement as HTMLElement | null;
731
- const startWidth =
732
- effectiveWidth ?? th?.getBoundingClientRect().width ?? 120;
733
- const min = col.minWidth ?? 40;
734
- const max = col.maxWidth ?? 1200;
735
- startResize(col.field as string, startWidth, min, max)(e);
803
+ const startWidth = measureWidth(e.currentTarget);
804
+ startResize(col.field as string, startWidth, resizeMin, resizeMax)(e);
805
+ }}
806
+ onKeyDown={(e) => {
807
+ if (disabled) return;
808
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
809
+ e.preventDefault();
810
+ const step = (e.shiftKey ? 64 : 16) * (e.key === 'ArrowLeft' ? -1 : 1);
811
+ const current = measureWidth(e.currentTarget);
812
+ nudgeColumnWidth(
813
+ col.field as string,
814
+ current,
815
+ step,
816
+ resizeMin,
817
+ resizeMax,
818
+ );
736
819
  }}
737
820
  className={cn(
738
821
  'absolute top-0 bottom-0 right-0 w-1.5',
739
822
  'cursor-col-resize select-none touch-none',
740
823
  'hover:bg-primary-300 active:bg-primary-400',
824
+ 'focus:outline-none focus-visible:bg-primary-400',
741
825
  'transition-colors motion-reduce:transition-none',
742
826
  )}
743
827
  />
@@ -952,6 +1036,10 @@ function renderVirtualizedBody<T extends object>(args: {
952
1036
  return (
953
1037
  <tr
954
1038
  key={rowId}
1039
+ // 1-based row index in the FULL dataset (header row is 1,
1040
+ // so data rows start at 2). Lets a screen reader announce
1041
+ // the true position despite virtualization windowing.
1042
+ aria-rowindex={absoluteIdx + 2}
955
1043
  data-selected={isSelected ? 'true' : 'false'}
956
1044
  aria-selected={rowSelection !== 'none' ? isSelected : undefined}
957
1045
  className={classes.row}
@@ -0,0 +1,330 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the per-column filter UI (`ColumnFilterTrigger`).
4
+ *
5
+ * Sprint 6 P4 — the filter UI shipped in 0.8.0-beta with only
6
+ * DataGrid-integration coverage. This file exercises each filter
7
+ * type (text / number / boolean / date) directly, including the
8
+ * apply / clear branches + edge cases.
9
+ */
10
+ import * as React from 'react';
11
+ import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
12
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
13
+ import { ColumnFilterTrigger } from './ColumnFilters.js';
14
+ import type { TableFilterModel } from '../../Table/table.types.js';
15
+
16
+ void React;
17
+ afterEach(() => cleanup());
18
+
19
+ beforeAll(() => {
20
+ if (typeof globalThis.ResizeObserver === 'undefined') {
21
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
22
+ class {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ };
27
+ }
28
+ });
29
+
30
+ const EMPTY: TableFilterModel = [];
31
+
32
+ describe('ColumnFilterTrigger — trigger button', () => {
33
+ it('renders a filter button', () => {
34
+ render(
35
+ <ColumnFilterTrigger
36
+ field="name"
37
+ filterType="text"
38
+ filterModel={EMPTY}
39
+ onFilterChange={() => {}}
40
+ />,
41
+ );
42
+ expect(screen.getByRole('button', { name: 'Filter' })).not.toBeNull();
43
+ });
44
+
45
+ it('shows aria-pressed=false when no filter is active', () => {
46
+ render(
47
+ <ColumnFilterTrigger
48
+ field="name"
49
+ filterType="text"
50
+ filterModel={EMPTY}
51
+ onFilterChange={() => {}}
52
+ />,
53
+ );
54
+ expect(
55
+ screen.getByRole('button', { name: 'Filter' }).getAttribute('aria-pressed'),
56
+ ).toBe('false');
57
+ });
58
+
59
+ it('shows aria-pressed=true when a filter is active on the field', () => {
60
+ render(
61
+ <ColumnFilterTrigger
62
+ field="name"
63
+ filterType="text"
64
+ filterModel={[{ field: 'name', op: 'contains', value: 'x' }]}
65
+ onFilterChange={() => {}}
66
+ />,
67
+ );
68
+ expect(
69
+ screen.getByRole('button', { name: 'Filter' }).getAttribute('aria-pressed'),
70
+ ).toBe('true');
71
+ });
72
+
73
+ it('translates the trigger label via labels', () => {
74
+ render(
75
+ <ColumnFilterTrigger
76
+ field="name"
77
+ filterType="text"
78
+ filterModel={EMPTY}
79
+ onFilterChange={() => {}}
80
+ labels={{ filterColumn: 'Filtra' }}
81
+ />,
82
+ );
83
+ expect(screen.getByRole('button', { name: 'Filtra' })).not.toBeNull();
84
+ });
85
+
86
+ it('is disabled when disabled is set', () => {
87
+ render(
88
+ <ColumnFilterTrigger
89
+ field="name"
90
+ filterType="text"
91
+ filterModel={EMPTY}
92
+ onFilterChange={() => {}}
93
+ disabled
94
+ />,
95
+ );
96
+ expect(
97
+ (screen.getByRole('button', { name: 'Filter' }) as HTMLButtonElement)
98
+ .disabled,
99
+ ).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('ColumnFilterTrigger — text filter', () => {
104
+ it('Apply commits a contains filter', () => {
105
+ const onFilterChange = vi.fn();
106
+ render(
107
+ <ColumnFilterTrigger
108
+ field="name"
109
+ filterType="text"
110
+ filterModel={EMPTY}
111
+ onFilterChange={onFilterChange}
112
+ />,
113
+ );
114
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
115
+ fireEvent.change(screen.getByPlaceholderText('…'), {
116
+ target: { value: 'abc' },
117
+ });
118
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
119
+ expect(onFilterChange).toHaveBeenCalledWith([
120
+ { field: 'name', op: 'contains', value: 'abc' },
121
+ ]);
122
+ });
123
+
124
+ it('Apply with an empty value commits a removal (null → no filter)', () => {
125
+ const onFilterChange = vi.fn();
126
+ render(
127
+ <ColumnFilterTrigger
128
+ field="name"
129
+ filterType="text"
130
+ filterModel={[{ field: 'name', op: 'contains', value: 'old' }]}
131
+ onFilterChange={onFilterChange}
132
+ />,
133
+ );
134
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
135
+ fireEvent.change(screen.getByDisplayValue('old'), {
136
+ target: { value: '' },
137
+ });
138
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
139
+ expect(onFilterChange).toHaveBeenCalledWith([]);
140
+ });
141
+
142
+ it('Clear removes the filter for the field', () => {
143
+ const onFilterChange = vi.fn();
144
+ render(
145
+ <ColumnFilterTrigger
146
+ field="name"
147
+ filterType="text"
148
+ filterModel={[{ field: 'name', op: 'contains', value: 'x' }]}
149
+ onFilterChange={onFilterChange}
150
+ />,
151
+ );
152
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
153
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
154
+ expect(onFilterChange).toHaveBeenCalledWith([]);
155
+ });
156
+
157
+ it('Enter in the text input applies the filter', () => {
158
+ const onFilterChange = vi.fn();
159
+ render(
160
+ <ColumnFilterTrigger
161
+ field="name"
162
+ filterType="text"
163
+ filterModel={EMPTY}
164
+ onFilterChange={onFilterChange}
165
+ />,
166
+ );
167
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
168
+ const input = screen.getByPlaceholderText('…');
169
+ fireEvent.change(input, { target: { value: 'typed' } });
170
+ fireEvent.keyDown(input, { key: 'Enter' });
171
+ expect(onFilterChange).toHaveBeenCalledWith([
172
+ { field: 'name', op: 'contains', value: 'typed' },
173
+ ]);
174
+ });
175
+ });
176
+
177
+ describe('ColumnFilterTrigger — number range filter', () => {
178
+ it('Apply with min + max commits a between filter', () => {
179
+ const onFilterChange = vi.fn();
180
+ render(
181
+ <ColumnFilterTrigger
182
+ field="age"
183
+ filterType="number"
184
+ filterModel={EMPTY}
185
+ onFilterChange={onFilterChange}
186
+ />,
187
+ );
188
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
189
+ const inputs = screen.getAllByRole('spinbutton');
190
+ fireEvent.change(inputs[0]!, { target: { value: '10' } });
191
+ fireEvent.change(inputs[1]!, { target: { value: '50' } });
192
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
193
+ expect(onFilterChange).toHaveBeenCalledWith([
194
+ { field: 'age', op: 'between', value: [10, 50] },
195
+ ]);
196
+ });
197
+
198
+ it('Apply with only a min commits an open-ended range', () => {
199
+ const onFilterChange = vi.fn();
200
+ render(
201
+ <ColumnFilterTrigger
202
+ field="age"
203
+ filterType="number"
204
+ filterModel={EMPTY}
205
+ onFilterChange={onFilterChange}
206
+ />,
207
+ );
208
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
209
+ const inputs = screen.getAllByRole('spinbutton');
210
+ fireEvent.change(inputs[0]!, { target: { value: '18' } });
211
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
212
+ expect(onFilterChange).toHaveBeenCalledWith([
213
+ { field: 'age', op: 'between', value: [18, null] },
214
+ ]);
215
+ });
216
+
217
+ it('Apply with both ends empty removes the filter', () => {
218
+ const onFilterChange = vi.fn();
219
+ render(
220
+ <ColumnFilterTrigger
221
+ field="age"
222
+ filterType="number"
223
+ filterModel={[{ field: 'age', op: 'between', value: [1, 2] }]}
224
+ onFilterChange={onFilterChange}
225
+ />,
226
+ );
227
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
228
+ // The two inputs are seeded from the current value — clear them.
229
+ const inputs = screen.getAllByRole('spinbutton');
230
+ fireEvent.change(inputs[0]!, { target: { value: '' } });
231
+ fireEvent.change(inputs[1]!, { target: { value: '' } });
232
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
233
+ expect(onFilterChange).toHaveBeenCalledWith([]);
234
+ });
235
+ });
236
+
237
+ describe('ColumnFilterTrigger — boolean filter', () => {
238
+ it('Apply with True selected commits an equals=true filter', () => {
239
+ const onFilterChange = vi.fn();
240
+ render(
241
+ <ColumnFilterTrigger
242
+ field="active"
243
+ filterType="boolean"
244
+ filterModel={EMPTY}
245
+ onFilterChange={onFilterChange}
246
+ />,
247
+ );
248
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
249
+ fireEvent.click(screen.getByLabelText('True'));
250
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
251
+ expect(onFilterChange).toHaveBeenCalledWith([
252
+ { field: 'active', op: 'equals', value: true },
253
+ ]);
254
+ });
255
+
256
+ it('Apply with False selected commits an equals=false filter', () => {
257
+ const onFilterChange = vi.fn();
258
+ render(
259
+ <ColumnFilterTrigger
260
+ field="active"
261
+ filterType="boolean"
262
+ filterModel={EMPTY}
263
+ onFilterChange={onFilterChange}
264
+ />,
265
+ );
266
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
267
+ fireEvent.click(screen.getByLabelText('False'));
268
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
269
+ expect(onFilterChange).toHaveBeenCalledWith([
270
+ { field: 'active', op: 'equals', value: false },
271
+ ]);
272
+ });
273
+
274
+ it('Apply with All selected removes the filter', () => {
275
+ const onFilterChange = vi.fn();
276
+ render(
277
+ <ColumnFilterTrigger
278
+ field="active"
279
+ filterType="boolean"
280
+ filterModel={[{ field: 'active', op: 'equals', value: true }]}
281
+ onFilterChange={onFilterChange}
282
+ />,
283
+ );
284
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
285
+ fireEvent.click(screen.getByLabelText('All'));
286
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
287
+ expect(onFilterChange).toHaveBeenCalledWith([]);
288
+ });
289
+ });
290
+
291
+ describe('ColumnFilterTrigger — date range filter', () => {
292
+ it('Apply with from + to commits a between filter (ISO strings)', () => {
293
+ const onFilterChange = vi.fn();
294
+ render(
295
+ <ColumnFilterTrigger
296
+ field="joined"
297
+ filterType="date"
298
+ filterModel={EMPTY}
299
+ onFilterChange={onFilterChange}
300
+ />,
301
+ );
302
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
303
+ const dateInputs = document.querySelectorAll('input[type="date"]');
304
+ fireEvent.change(dateInputs[0]!, { target: { value: '2024-01-01' } });
305
+ fireEvent.change(dateInputs[1]!, { target: { value: '2024-12-31' } });
306
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
307
+ expect(onFilterChange).toHaveBeenCalledWith([
308
+ { field: 'joined', op: 'between', value: ['2024-01-01', '2024-12-31'] },
309
+ ]);
310
+ });
311
+
312
+ it('Apply with both date ends empty removes the filter', () => {
313
+ const onFilterChange = vi.fn();
314
+ render(
315
+ <ColumnFilterTrigger
316
+ field="joined"
317
+ filterType="date"
318
+ filterModel={[
319
+ { field: 'joined', op: 'between', value: ['2024-01-01', null] },
320
+ ]}
321
+ onFilterChange={onFilterChange}
322
+ />,
323
+ );
324
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
325
+ const dateInputs = document.querySelectorAll('input[type="date"]');
326
+ fireEvent.change(dateInputs[0]!, { target: { value: '' } });
327
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
328
+ expect(onFilterChange).toHaveBeenCalledWith([]);
329
+ });
330
+ });
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from 'react';
1
+ import { useState, type ReactNode, type KeyboardEvent } from 'react';
2
2
  import { Popover } from '../../Popover/Popover.js';
3
3
  import { cn } from '../../../utils/cn.js';
4
4
  import type {
@@ -40,6 +40,22 @@ function setFilter(
40
40
  return [...rest, next];
41
41
  }
42
42
 
43
+ /**
44
+ * Shared `onKeyDown` for filter inputs — pressing Enter commits the
45
+ * filter (equivalent to clicking Apply). Without this the user has to
46
+ * leave the keyboard and click the button, which reads as broken for
47
+ * a single-field text filter.
48
+ */
49
+ function onEnterApply(
50
+ e: KeyboardEvent<HTMLInputElement>,
51
+ apply: () => void,
52
+ ): void {
53
+ if (e.key === 'Enter') {
54
+ e.preventDefault();
55
+ apply();
56
+ }
57
+ }
58
+
43
59
  // ───── Filter labels (i18n) ─────
44
60
 
45
61
  export interface ColumnFilterLabels {
@@ -173,18 +189,22 @@ function TextFilter(props: {
173
189
  typeof current?.value === 'string' ? current.value : '',
174
190
  );
175
191
 
192
+ const apply = () =>
193
+ onCommit(value.trim() ? { field, op: 'contains', value } : null);
194
+
176
195
  return (
177
196
  <div className="flex flex-col gap-2 min-w-[200px]">
178
197
  <input
179
198
  type="text"
180
199
  value={value}
181
200
  onChange={(e) => setValue(e.target.value)}
201
+ onKeyDown={(e) => onEnterApply(e, apply)}
182
202
  placeholder="…"
183
203
  autoFocus
184
204
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
185
205
  />
186
206
  <FilterActions
187
- onApply={() => onCommit(value.trim() ? { field, op: 'contains', value } : null)}
207
+ onApply={apply}
188
208
  onClear={() => {
189
209
  setValue('');
190
210
  onCommit(null);
@@ -236,6 +256,7 @@ function NumberRangeFilter(props: {
236
256
  type="number"
237
257
  value={min}
238
258
  onChange={(e) => setMin(e.target.value)}
259
+ onKeyDown={(e) => onEnterApply(e, apply)}
239
260
  autoFocus
240
261
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
241
262
  />
@@ -246,6 +267,7 @@ function NumberRangeFilter(props: {
246
267
  type="number"
247
268
  value={max}
248
269
  onChange={(e) => setMax(e.target.value)}
270
+ onKeyDown={(e) => onEnterApply(e, apply)}
249
271
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
250
272
  />
251
273
  </label>
@@ -357,6 +379,7 @@ function DateRangeFilter(props: {
357
379
  type="date"
358
380
  value={from}
359
381
  onChange={(e) => setFrom(e.target.value)}
382
+ onKeyDown={(e) => onEnterApply(e, apply)}
360
383
  autoFocus
361
384
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
362
385
  />
@@ -367,6 +390,7 @@ function DateRangeFilter(props: {
367
390
  type="date"
368
391
  value={to}
369
392
  onChange={(e) => setTo(e.target.value)}
393
+ onKeyDown={(e) => onEnterApply(e, apply)}
370
394
  className="rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
371
395
  />
372
396
  </label>