@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
@@ -667,11 +667,11 @@ describe('DataGrid — column visibility', () => {
667
667
  expect(screen.queryByRole('button', { name: 'Columns' })).toBeNull();
668
668
  });
669
669
 
670
- it('opens dialog with one checkbox per hideable column', () => {
670
+ it('opens a popover menu with one checkbox per hideable column', () => {
671
671
  const cols: TableColumn<User>[] = [
672
672
  { field: 'name', header: 'Name' },
673
673
  { field: 'email', header: 'Email' },
674
- // hideable: false → not shown in the dialog (structurally required)
674
+ // hideable: false → not shown in the menu (structurally required)
675
675
  { field: 'age', header: 'Age', hideable: false },
676
676
  ];
677
677
  render(
@@ -684,16 +684,13 @@ describe('DataGrid — column visibility', () => {
684
684
  />,
685
685
  );
686
686
  fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
687
+ // Radix Popover content has role="dialog" too.
687
688
  expect(screen.queryByRole('dialog')).not.toBeNull();
688
- // The dialog should expose checkboxes for Name + Email (not Age).
689
- const labels = screen.getAllByText(/Name|Email|Age/);
690
- // Name + Email appear in BOTH the table header and the dialog; Age
691
- // appears only in the header (hideable=false → excluded from dialog).
692
- const dialog = screen.getByRole('dialog');
693
- expect(dialog.textContent).toContain('Name');
694
- expect(dialog.textContent).toContain('Email');
695
- expect(dialog.textContent).not.toContain('Age');
696
- void labels;
689
+ // The menu should expose checkboxes for Name + Email (not Age).
690
+ const menu = screen.getByRole('dialog');
691
+ expect(menu.textContent).toContain('Name');
692
+ expect(menu.textContent).toContain('Email');
693
+ expect(menu.textContent).not.toContain('Age');
697
694
  });
698
695
 
699
696
  it('respects col.defaultHidden on first render', () => {
@@ -716,7 +713,7 @@ describe('DataGrid — column visibility', () => {
716
713
  expect(screen.queryByText('Name')).not.toBeNull();
717
714
  });
718
715
 
719
- it('toggling a column hides it from the table after Done', () => {
716
+ it('toggling a column auto-commits via onHiddenColumnsChange (no Done button)', () => {
720
717
  const onHiddenColumnsChange = vi.fn();
721
718
  render(
722
719
  <DataGrid
@@ -729,18 +726,17 @@ describe('DataGrid — column visibility', () => {
729
726
  />,
730
727
  );
731
728
  fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
732
- // Uncheck "Email" inside the dialog
733
- const dialog = screen.getByRole('dialog');
729
+ // Uncheck "Email" inside the popover menu
730
+ const menu = screen.getByRole('dialog');
734
731
  const emailCheckbox = Array.from(
735
- dialog.querySelectorAll('input[type="checkbox"]'),
732
+ menu.querySelectorAll('input[type="checkbox"]'),
736
733
  ).find((el) => {
737
734
  const li = el.closest('label');
738
735
  return li?.textContent?.includes('Email');
739
736
  }) as HTMLInputElement | undefined;
740
737
  expect(emailCheckbox).toBeDefined();
741
738
  fireEvent.click(emailCheckbox!);
742
- // Commit via Done
743
- fireEvent.click(screen.getByRole('button', { name: 'Done' }));
739
+ // Auto-commit on toggle — no Done button anymore
744
740
  expect(onHiddenColumnsChange).toHaveBeenCalledWith(['email']);
745
741
  });
746
742
 
@@ -758,6 +754,80 @@ describe('DataGrid — column visibility', () => {
758
754
  expect(screen.queryByText('Email')).toBeNull();
759
755
  expect(screen.queryByText('Name')).not.toBeNull();
760
756
  });
757
+
758
+ // ───── Sprint 6 P5 — tri-state master checkbox ─────
759
+
760
+ it('renders a master toggle-all checkbox in the visibility menu', () => {
761
+ render(
762
+ <DataGrid
763
+ rows={generateUsers(5)}
764
+ cols={baseCols}
765
+ getRowId={getRowId}
766
+ rowHeight={48}
767
+ height="400px"
768
+ />,
769
+ );
770
+ fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
771
+ expect(
772
+ screen.queryByLabelText('Toggle all columns'),
773
+ ).not.toBeNull();
774
+ });
775
+
776
+ it('master checkbox is checked when every column is visible', () => {
777
+ render(
778
+ <DataGrid
779
+ rows={generateUsers(5)}
780
+ cols={baseCols}
781
+ getRowId={getRowId}
782
+ rowHeight={48}
783
+ height="400px"
784
+ />,
785
+ );
786
+ fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
787
+ const master = screen.getByLabelText('Toggle all columns') as HTMLInputElement;
788
+ expect(master.checked).toBe(true);
789
+ });
790
+
791
+ it('clicking the master when all visible hides every hideable column', () => {
792
+ const onHiddenColumnsChange = vi.fn();
793
+ render(
794
+ <DataGrid
795
+ rows={generateUsers(5)}
796
+ cols={baseCols}
797
+ getRowId={getRowId}
798
+ rowHeight={48}
799
+ height="400px"
800
+ onHiddenColumnsChange={onHiddenColumnsChange}
801
+ />,
802
+ );
803
+ fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
804
+ fireEvent.click(screen.getByLabelText('Toggle all columns'));
805
+ // baseCols = name / email / age / meta.city — all hideable.
806
+ expect(onHiddenColumnsChange).toHaveBeenCalledWith([
807
+ 'name',
808
+ 'email',
809
+ 'age',
810
+ 'meta.city',
811
+ ]);
812
+ });
813
+
814
+ it('clicking the master when some are hidden shows all', () => {
815
+ const onHiddenColumnsChange = vi.fn();
816
+ render(
817
+ <DataGrid
818
+ rows={generateUsers(5)}
819
+ cols={baseCols}
820
+ getRowId={getRowId}
821
+ rowHeight={48}
822
+ height="400px"
823
+ hiddenColumns={['email']}
824
+ onHiddenColumnsChange={onHiddenColumnsChange}
825
+ />,
826
+ );
827
+ fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
828
+ fireEvent.click(screen.getByLabelText('Toggle all columns'));
829
+ expect(onHiddenColumnsChange).toHaveBeenCalledWith([]);
830
+ });
761
831
  });
762
832
 
763
833
  describe('DataGrid — column resize', () => {
@@ -915,3 +985,169 @@ describe('DataGrid — column reorder', () => {
915
985
  expect(draggableThs[0]?.textContent).toContain('Email');
916
986
  });
917
987
  });
988
+
989
+ describe('DataGrid — Sprint 6 P2 refinements', () => {
990
+ // ───── D1: aria-rowcount / aria-rowindex ─────
991
+
992
+ it('sets aria-rowcount on the table reflecting the full dataset + header', () => {
993
+ const { container } = render(
994
+ <DataGrid
995
+ rows={generateUsers(500)}
996
+ cols={baseCols}
997
+ getRowId={getRowId}
998
+ rowHeight={48}
999
+ height="400px"
1000
+ />,
1001
+ );
1002
+ const table = container.querySelector('table');
1003
+ // 500 data rows + 1 header row
1004
+ expect(table?.getAttribute('aria-rowcount')).toBe('501');
1005
+ });
1006
+
1007
+ it('header row has aria-rowindex=1', () => {
1008
+ const { container } = render(
1009
+ <DataGrid
1010
+ rows={generateUsers(20)}
1011
+ cols={baseCols}
1012
+ getRowId={getRowId}
1013
+ rowHeight={48}
1014
+ height="400px"
1015
+ />,
1016
+ );
1017
+ expect(
1018
+ container.querySelector('thead tr')?.getAttribute('aria-rowindex'),
1019
+ ).toBe('1');
1020
+ });
1021
+
1022
+ it('data rows carry a 1-based aria-rowindex offset past the header', () => {
1023
+ const { container } = render(
1024
+ <DataGrid
1025
+ rows={generateUsers(20)}
1026
+ cols={baseCols}
1027
+ getRowId={getRowId}
1028
+ rowHeight={48}
1029
+ height="400px"
1030
+ />,
1031
+ );
1032
+ // absolute index 0 → aria-rowindex 2 (header occupies index 1)
1033
+ const firstDataRow = container.querySelector('tbody tr[data-selected]');
1034
+ expect(firstDataRow?.getAttribute('aria-rowindex')).toBe('2');
1035
+ });
1036
+
1037
+ // ───── D6: resize handle is not draggable + keyboard-focusable ─────
1038
+
1039
+ it('resize handle is keyboard-focusable and explicitly not draggable', () => {
1040
+ render(
1041
+ <DataGrid
1042
+ rows={generateUsers(5)}
1043
+ cols={baseCols}
1044
+ getRowId={getRowId}
1045
+ rowHeight={48}
1046
+ height="400px"
1047
+ />,
1048
+ );
1049
+ const handle = screen.getAllByRole('separator')[0] as HTMLElement;
1050
+ expect(handle.getAttribute('tabindex')).toBe('0');
1051
+ expect(handle.getAttribute('draggable')).toBe('false');
1052
+ });
1053
+
1054
+ // ───── D3: keyboard column resize ─────
1055
+
1056
+ it('ArrowRight on the resize handle widens the column by 16px', () => {
1057
+ const onColumnWidthsChange = vi.fn();
1058
+ const cols: TableColumn<User>[] = [
1059
+ { field: 'name', header: 'Name', width: 120 },
1060
+ ];
1061
+ render(
1062
+ <DataGrid
1063
+ rows={generateUsers(5)}
1064
+ cols={cols}
1065
+ getRowId={getRowId}
1066
+ rowHeight={48}
1067
+ height="400px"
1068
+ columnWidths={{ name: 120 }}
1069
+ onColumnWidthsChange={onColumnWidthsChange}
1070
+ />,
1071
+ );
1072
+ const handle = screen.getByRole('separator');
1073
+ fireEvent.keyDown(handle, { key: 'ArrowRight' });
1074
+ expect(onColumnWidthsChange).toHaveBeenCalledWith({ name: 136 });
1075
+ });
1076
+
1077
+ it('Shift+ArrowLeft on the resize handle narrows the column by 64px', () => {
1078
+ const onColumnWidthsChange = vi.fn();
1079
+ const cols: TableColumn<User>[] = [
1080
+ { field: 'name', header: 'Name', width: 200 },
1081
+ ];
1082
+ render(
1083
+ <DataGrid
1084
+ rows={generateUsers(5)}
1085
+ cols={cols}
1086
+ getRowId={getRowId}
1087
+ rowHeight={48}
1088
+ height="400px"
1089
+ columnWidths={{ name: 200 }}
1090
+ onColumnWidthsChange={onColumnWidthsChange}
1091
+ />,
1092
+ );
1093
+ const handle = screen.getByRole('separator');
1094
+ fireEvent.keyDown(handle, { key: 'ArrowLeft', shiftKey: true });
1095
+ expect(onColumnWidthsChange).toHaveBeenCalledWith({ name: 136 });
1096
+ });
1097
+
1098
+ // ───── D7: no-results vs no-data empty state ─────
1099
+
1100
+ it('shows the noData message when the dataset is genuinely empty', () => {
1101
+ render(
1102
+ <DataGrid
1103
+ rows={[]}
1104
+ cols={baseCols}
1105
+ getRowId={getRowId}
1106
+ rowHeight={48}
1107
+ height="400px"
1108
+ />,
1109
+ );
1110
+ expect(screen.queryByText('No data')).not.toBeNull();
1111
+ });
1112
+
1113
+ it('shows the noResults message when a filter excludes every row', () => {
1114
+ render(
1115
+ <DataGrid
1116
+ rows={generateUsers(10)}
1117
+ cols={baseCols}
1118
+ getRowId={getRowId}
1119
+ rowHeight={48}
1120
+ height="400px"
1121
+ filterModel={[{ field: 'name', op: 'contains', value: 'zzz-no-match' }]}
1122
+ />,
1123
+ );
1124
+ expect(screen.queryByText('No matching results')).not.toBeNull();
1125
+ expect(screen.queryByText('No data')).toBeNull();
1126
+ });
1127
+
1128
+ // ───── D4: Enter applies the filter ─────
1129
+
1130
+ it('pressing Enter in the filter text input applies the filter', () => {
1131
+ const onFilterChange = vi.fn();
1132
+ const cols: TableColumn<User>[] = [
1133
+ { field: 'name', header: 'Name', filterable: true },
1134
+ ];
1135
+ render(
1136
+ <DataGrid
1137
+ rows={generateUsers(5)}
1138
+ cols={cols}
1139
+ getRowId={getRowId}
1140
+ rowHeight={48}
1141
+ height="400px"
1142
+ onFilterChange={onFilterChange}
1143
+ />,
1144
+ );
1145
+ fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
1146
+ const input = screen.getByPlaceholderText('…') as HTMLInputElement;
1147
+ fireEvent.change(input, { target: { value: 'User 3' } });
1148
+ fireEvent.keyDown(input, { key: 'Enter' });
1149
+ expect(onFilterChange).toHaveBeenCalledWith([
1150
+ { field: 'name', op: 'contains', value: 'User 3' },
1151
+ ]);
1152
+ });
1153
+ });
@@ -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,
@@ -33,7 +35,7 @@ import type {
33
35
  } from '../Table/table.types.js';
34
36
  import type { DataGridProps } from './dataGrid.types.js';
35
37
  import { ColumnFilterTrigger } from './filters/ColumnFilters.js';
36
- import { ColumnVisibilityTrigger } from './visibility/ColumnVisibilityDialog.js';
38
+ import { ColumnVisibilityTrigger } from './visibility/ColumnVisibilityMenu.js';
37
39
  import { useColumnResize } from './resize/useColumnResize.js';
38
40
  import {
39
41
  useColumnReorder,
@@ -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.',
@@ -68,12 +71,8 @@ const DEFAULT_LABELS: Required<TableLabels> = {
68
71
  densityComfortable: 'Comfortable',
69
72
  densitySpacious: 'Spacious',
70
73
  columnsButton: 'Columns',
71
- columnsTitle: 'Manage columns',
72
- columnsDescription:
73
- 'Show or hide columns. Required columns cannot be toggled.',
74
- columnsShowAll: 'Show all',
75
- columnsHideAll: 'Hide all',
76
- columnsDone: 'Done',
74
+ columnsTitle: 'Toggle columns',
75
+ columnsToggleAll: 'Toggle all columns',
77
76
  };
78
77
 
79
78
  /**
@@ -238,7 +237,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
238
237
  defaultValue: defaultColumnWidths,
239
238
  onChange: onColumnWidthsChange,
240
239
  });
241
- const { startResize } = useColumnResize({
240
+ const { startResize, nudge: nudgeColumnWidth } = useColumnResize({
242
241
  widths: columnWidths,
243
242
  onChange: setColumnWidths,
244
243
  });
@@ -258,6 +257,23 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
258
257
  onChange: setColumnOrder,
259
258
  });
260
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
+
261
277
  // Apply the active order to the columns list. Cols not in the
262
278
  // order array (e.g. newly-added columns at runtime) are appended
263
279
  // in their original position from `cols`.
@@ -414,7 +430,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
414
430
  <ColumnVisibilityTrigger
415
431
  cols={cols.filter(
416
432
  (c) =>
417
- // RBAC-hidden columns are never offered in the dialog.
433
+ // RBAC-hidden columns are never offered in the menu.
418
434
  !c.access || c.access.onUnauthorized !== 'hide',
419
435
  )}
420
436
  hiddenColumns={hiddenColumns}
@@ -423,10 +439,7 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
423
439
  labels={{
424
440
  columnsButton: labels.columnsButton,
425
441
  columnsTitle: labels.columnsTitle,
426
- columnsDescription: labels.columnsDescription,
427
- columnsShowAll: labels.columnsShowAll,
428
- columnsHideAll: labels.columnsHideAll,
429
- columnsDone: labels.columnsDone,
442
+ columnsToggleAll: labels.columnsToggleAll,
430
443
  }}
431
444
  />
432
445
  )}
@@ -439,7 +452,19 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
439
452
  className={cn(v.scroll(), slotProps?.scroll?.className)}
440
453
  style={height ? { height, maxHeight: height } : undefined}
441
454
  >
442
- <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
+ >
443
468
  {caption != null && (
444
469
  <caption className={cn(!showCaption && 'sr-only', 'py-2 text-sm text-neutral-500')}>
445
470
  {caption}
@@ -452,7 +477,10 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
452
477
  slotProps?.thead?.className,
453
478
  )}
454
479
  >
455
- <tr className={cn(v.headerRow(), slotProps?.headerRow?.className)}>
480
+ <tr
481
+ aria-rowindex={1}
482
+ className={cn(v.headerRow(), slotProps?.headerRow?.className)}
483
+ >
456
484
  {showSelectionColumn && (
457
485
  <th
458
486
  scope="col"
@@ -497,8 +525,11 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
497
525
  enableColumnResize && col.resizable !== false
498
526
  }
499
527
  startResize={startResize}
528
+ nudgeColumnWidth={nudgeColumnWidth}
500
529
  reorderable={
501
- enableColumnReorder && col.reorderable !== false
530
+ enableColumnReorder &&
531
+ col.reorderable !== false &&
532
+ !coarsePointer
502
533
  }
503
534
  reorderHandlers={reorderHandlers}
504
535
  labels={labels}
@@ -542,7 +573,16 @@ export function DataGrid<T extends object>(props: DataGridProps<T>) {
542
573
  ? renderEmptyState({
543
574
  totalColumnCount,
544
575
  emptyState,
545
- 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,
546
586
  cellClass: cn(
547
587
  v.cell(),
548
588
  v.emptyState(),
@@ -653,6 +693,13 @@ function HeaderCell<T extends object>(props: {
653
693
  clampMin: number,
654
694
  clampMax: number,
655
695
  ) => (e: RPointerEvent<HTMLElement>) => void;
696
+ nudgeColumnWidth: (
697
+ field: string,
698
+ currentWidth: number,
699
+ delta: number,
700
+ clampMin: number,
701
+ clampMax: number,
702
+ ) => void;
656
703
  reorderable: boolean;
657
704
  reorderHandlers: ColumnReorderHandlers;
658
705
  labels: Required<TableLabels>;
@@ -670,6 +717,7 @@ function HeaderCell<T extends object>(props: {
670
717
  effectiveWidth,
671
718
  resizable,
672
719
  startResize,
720
+ nudgeColumnWidth,
673
721
  reorderable,
674
722
  reorderHandlers,
675
723
  labels,
@@ -725,24 +773,55 @@ function HeaderCell<T extends object>(props: {
725
773
  }
726
774
  : {};
727
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
+
728
786
  const resizeHandle = resizable ? (
729
787
  <span
730
788
  role="separator"
731
789
  aria-orientation="vertical"
732
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}
733
802
  onPointerDown={(e) => {
734
- const handle = e.currentTarget;
735
- const th = handle.parentElement as HTMLElement | null;
736
- const startWidth =
737
- effectiveWidth ?? th?.getBoundingClientRect().width ?? 120;
738
- const min = col.minWidth ?? 40;
739
- const max = col.maxWidth ?? 1200;
740
- 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
+ );
741
819
  }}
742
820
  className={cn(
743
821
  'absolute top-0 bottom-0 right-0 w-1.5',
744
822
  'cursor-col-resize select-none touch-none',
745
823
  'hover:bg-primary-300 active:bg-primary-400',
824
+ 'focus:outline-none focus-visible:bg-primary-400',
746
825
  'transition-colors motion-reduce:transition-none',
747
826
  )}
748
827
  />
@@ -957,6 +1036,10 @@ function renderVirtualizedBody<T extends object>(args: {
957
1036
  return (
958
1037
  <tr
959
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}
960
1043
  data-selected={isSelected ? 'true' : 'false'}
961
1044
  aria-selected={rowSelection !== 'none' ? isSelected : undefined}
962
1045
  className={classes.row}