@dashforge/tw 0.8.1-beta → 0.9.1-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 (204) hide show
  1. package/CHANGELOG.md +155 -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 +806 -134
  7. package/dist/src/components/Calendar/Calendar.d.ts +14 -0
  8. package/dist/src/components/Calendar/Calendar.d.ts.map +1 -0
  9. package/dist/src/components/Calendar/calendar.types.d.ts +72 -0
  10. package/dist/src/components/Calendar/calendar.types.d.ts.map +1 -0
  11. package/dist/src/components/Calendar/calendar.variants.d.ts +99 -0
  12. package/dist/src/components/Calendar/calendar.variants.d.ts.map +1 -0
  13. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
  14. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -1
  15. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +7 -0
  16. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -1
  17. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts +6 -2
  18. package/dist/src/components/DataGrid/visibility/ColumnVisibilityMenu.d.ts.map +1 -1
  19. package/dist/src/components/DatePicker/DatePicker.d.ts +14 -0
  20. package/dist/src/components/DatePicker/DatePicker.d.ts.map +1 -0
  21. package/dist/src/components/DatePicker/datePicker.types.d.ts +88 -0
  22. package/dist/src/components/DatePicker/datePicker.types.d.ts.map +1 -0
  23. package/dist/src/components/DatePicker/datePicker.variants.d.ts +97 -0
  24. package/dist/src/components/DatePicker/datePicker.variants.d.ts.map +1 -0
  25. package/dist/src/components/Dialog/Dialog.d.ts.map +1 -1
  26. package/dist/src/components/Table/Table.d.ts.map +1 -1
  27. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
  28. package/dist/src/components/Table/table.types.d.ts +28 -3
  29. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  30. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  31. package/dist/src/index.d.ts +6 -0
  32. package/dist/src/index.d.ts.map +1 -1
  33. package/eslint.config.mjs +16 -1
  34. package/package.json +10 -6
  35. package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +5 -2
  36. package/src/components/Calendar/Calendar.test.tsx +75 -0
  37. package/src/components/Calendar/Calendar.tsx +264 -0
  38. package/src/components/Calendar/calendar.types.ts +65 -0
  39. package/src/components/Calendar/calendar.variants.ts +64 -0
  40. package/src/components/DataGrid/DataGrid.perf.test.tsx +114 -0
  41. package/src/components/DataGrid/DataGrid.test.tsx +240 -0
  42. package/src/components/DataGrid/DataGrid.tsx +104 -16
  43. package/src/components/DataGrid/filters/ColumnFilters.test.tsx +330 -0
  44. package/src/components/DataGrid/filters/ColumnFilters.tsx +26 -2
  45. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +95 -3
  46. package/src/components/DataGrid/resize/useColumnResize.test.ts +144 -0
  47. package/src/components/DataGrid/resize/useColumnResize.ts +30 -1
  48. package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +42 -25
  49. package/src/components/DatePicker/DatePicker.test.tsx +76 -0
  50. package/src/components/DatePicker/DatePicker.tsx +305 -0
  51. package/src/components/DatePicker/datePicker.types.ts +77 -0
  52. package/src/components/DatePicker/datePicker.variants.ts +50 -0
  53. package/src/components/Dialog/Dialog.tsx +9 -0
  54. package/src/components/Table/Table.perf.test.tsx +81 -0
  55. package/src/components/Table/Table.test.tsx +72 -1
  56. package/src/components/Table/Table.tsx +40 -4
  57. package/src/components/Table/cells/RenderChip.tsx +6 -9
  58. package/src/components/Table/cells/cells.test.tsx +247 -0
  59. package/src/components/Table/table.types.ts +28 -3
  60. package/src/components/Typography/Typography.test.tsx +15 -0
  61. package/src/components/Typography/typography.variants.ts +14 -1
  62. package/src/index.ts +21 -1
  63. package/test-output/vitest/coverage/base.css +224 -0
  64. package/test-output/vitest/coverage/block-navigation.js +87 -0
  65. package/test-output/vitest/coverage/clover.xml +2418 -0
  66. package/test-output/vitest/coverage/coverage-final.json +89 -0
  67. package/test-output/vitest/coverage/favicon.png +0 -0
  68. package/test-output/vitest/coverage/index.html +746 -0
  69. package/test-output/vitest/coverage/prettify.css +1 -0
  70. package/test-output/vitest/coverage/prettify.js +2 -0
  71. package/test-output/vitest/coverage/sort-arrow-sprite.png +0 -0
  72. package/test-output/vitest/coverage/sorter.js +210 -0
  73. package/test-output/vitest/coverage/src/components/Accordion/Accordion.tsx.html +376 -0
  74. package/test-output/vitest/coverage/src/components/Accordion/accordion.variants.ts.html +181 -0
  75. package/test-output/vitest/coverage/src/components/Accordion/index.html +131 -0
  76. package/test-output/vitest/coverage/src/components/AppShell/AppShell.tsx.html +802 -0
  77. package/test-output/vitest/coverage/src/components/AppShell/appShell.variants.ts.html +247 -0
  78. package/test-output/vitest/coverage/src/components/AppShell/index.html +131 -0
  79. package/test-output/vitest/coverage/src/components/AspectRatio/AspectRatio.tsx.html +304 -0
  80. package/test-output/vitest/coverage/src/components/AspectRatio/index.html +116 -0
  81. package/test-output/vitest/coverage/src/components/Autocomplete/Autocomplete.tsx.html +3292 -0
  82. package/test-output/vitest/coverage/src/components/Autocomplete/autocomplete.variants.ts.html +508 -0
  83. package/test-output/vitest/coverage/src/components/Autocomplete/index.html +131 -0
  84. package/test-output/vitest/coverage/src/components/Box/Box.tsx.html +334 -0
  85. package/test-output/vitest/coverage/src/components/Box/box.variants.ts.html +775 -0
  86. package/test-output/vitest/coverage/src/components/Box/index.html +131 -0
  87. package/test-output/vitest/coverage/src/components/Breadcrumbs/Breadcrumbs.tsx.html +694 -0
  88. package/test-output/vitest/coverage/src/components/Breadcrumbs/breadcrumbs.variants.ts.html +247 -0
  89. package/test-output/vitest/coverage/src/components/Breadcrumbs/index.html +131 -0
  90. package/test-output/vitest/coverage/src/components/Button/Button.tsx.html +535 -0
  91. package/test-output/vitest/coverage/src/components/Button/button.variants.ts.html +562 -0
  92. package/test-output/vitest/coverage/src/components/Button/index.html +131 -0
  93. package/test-output/vitest/coverage/src/components/Checkbox/Checkbox.tsx.html +943 -0
  94. package/test-output/vitest/coverage/src/components/Checkbox/checkbox.variants.ts.html +289 -0
  95. package/test-output/vitest/coverage/src/components/Checkbox/index.html +131 -0
  96. package/test-output/vitest/coverage/src/components/ConfirmDialog/ConfirmDialog.tsx.html +841 -0
  97. package/test-output/vitest/coverage/src/components/ConfirmDialog/confirmDialog.variants.ts.html +313 -0
  98. package/test-output/vitest/coverage/src/components/ConfirmDialog/index.html +131 -0
  99. package/test-output/vitest/coverage/src/components/Container/Container.tsx.html +307 -0
  100. package/test-output/vitest/coverage/src/components/Container/container.variants.ts.html +328 -0
  101. package/test-output/vitest/coverage/src/components/Container/index.html +131 -0
  102. package/test-output/vitest/coverage/src/components/DataGrid/DataGrid.tsx.html +3937 -0
  103. package/test-output/vitest/coverage/src/components/DataGrid/dataGrid.variants.ts.html +541 -0
  104. package/test-output/vitest/coverage/src/components/DataGrid/filters/ColumnFilters.tsx.html +1453 -0
  105. package/test-output/vitest/coverage/src/components/DataGrid/filters/index.html +116 -0
  106. package/test-output/vitest/coverage/src/components/DataGrid/index.html +131 -0
  107. package/test-output/vitest/coverage/src/components/DataGrid/reorder/index.html +116 -0
  108. package/test-output/vitest/coverage/src/components/DataGrid/reorder/useColumnReorder.ts.html +517 -0
  109. package/test-output/vitest/coverage/src/components/DataGrid/resize/index.html +116 -0
  110. package/test-output/vitest/coverage/src/components/DataGrid/resize/useColumnResize.ts.html +490 -0
  111. package/test-output/vitest/coverage/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx.html +592 -0
  112. package/test-output/vitest/coverage/src/components/DataGrid/visibility/index.html +116 -0
  113. package/test-output/vitest/coverage/src/components/DateTimePicker/DateTimePicker.tsx.html +1042 -0
  114. package/test-output/vitest/coverage/src/components/DateTimePicker/dateTimePicker.variants.ts.html +337 -0
  115. package/test-output/vitest/coverage/src/components/DateTimePicker/index.html +131 -0
  116. package/test-output/vitest/coverage/src/components/Dialog/Dialog.tsx.html +391 -0
  117. package/test-output/vitest/coverage/src/components/Dialog/dialog.variants.ts.html +283 -0
  118. package/test-output/vitest/coverage/src/components/Dialog/index.html +131 -0
  119. package/test-output/vitest/coverage/src/components/Divider/Divider.tsx.html +505 -0
  120. package/test-output/vitest/coverage/src/components/Divider/divider.variants.ts.html +433 -0
  121. package/test-output/vitest/coverage/src/components/Divider/index.html +131 -0
  122. package/test-output/vitest/coverage/src/components/Grid/Grid.tsx.html +529 -0
  123. package/test-output/vitest/coverage/src/components/Grid/grid.variants.ts.html +484 -0
  124. package/test-output/vitest/coverage/src/components/Grid/index.html +131 -0
  125. package/test-output/vitest/coverage/src/components/LeftNav/LeftNav.tsx.html +1075 -0
  126. package/test-output/vitest/coverage/src/components/LeftNav/index.html +131 -0
  127. package/test-output/vitest/coverage/src/components/LeftNav/leftNav.variants.ts.html +406 -0
  128. package/test-output/vitest/coverage/src/components/NumberField/NumberField.tsx.html +1048 -0
  129. package/test-output/vitest/coverage/src/components/NumberField/index.html +131 -0
  130. package/test-output/vitest/coverage/src/components/NumberField/numberField.variants.ts.html +367 -0
  131. package/test-output/vitest/coverage/src/components/OTPField/OTPField.tsx.html +835 -0
  132. package/test-output/vitest/coverage/src/components/OTPField/index.html +131 -0
  133. package/test-output/vitest/coverage/src/components/OTPField/otpField.variants.ts.html +301 -0
  134. package/test-output/vitest/coverage/src/components/Pagination/Pagination.tsx.html +916 -0
  135. package/test-output/vitest/coverage/src/components/Pagination/index.html +146 -0
  136. package/test-output/vitest/coverage/src/components/Pagination/pagination.helpers.ts.html +391 -0
  137. package/test-output/vitest/coverage/src/components/Pagination/pagination.variants.ts.html +436 -0
  138. package/test-output/vitest/coverage/src/components/Popover/Popover.tsx.html +250 -0
  139. package/test-output/vitest/coverage/src/components/Popover/index.html +131 -0
  140. package/test-output/vitest/coverage/src/components/Popover/popover.variants.ts.html +157 -0
  141. package/test-output/vitest/coverage/src/components/RadioGroup/RadioGroup.tsx.html +934 -0
  142. package/test-output/vitest/coverage/src/components/RadioGroup/index.html +131 -0
  143. package/test-output/vitest/coverage/src/components/RadioGroup/radioGroup.variants.ts.html +346 -0
  144. package/test-output/vitest/coverage/src/components/Skeleton/Skeleton.tsx.html +232 -0
  145. package/test-output/vitest/coverage/src/components/Skeleton/index.html +131 -0
  146. package/test-output/vitest/coverage/src/components/Skeleton/skeleton.variants.ts.html +346 -0
  147. package/test-output/vitest/coverage/src/components/Snackbar/Snackbar.tsx.html +979 -0
  148. package/test-output/vitest/coverage/src/components/Snackbar/index.html +131 -0
  149. package/test-output/vitest/coverage/src/components/Snackbar/snackbar.variants.ts.html +307 -0
  150. package/test-output/vitest/coverage/src/components/Stack/Stack.tsx.html +430 -0
  151. package/test-output/vitest/coverage/src/components/Stack/index.html +131 -0
  152. package/test-output/vitest/coverage/src/components/Stack/stack.variants.ts.html +325 -0
  153. package/test-output/vitest/coverage/src/components/Switch/Switch.tsx.html +559 -0
  154. package/test-output/vitest/coverage/src/components/Switch/index.html +131 -0
  155. package/test-output/vitest/coverage/src/components/Switch/switch.variants.ts.html +295 -0
  156. package/test-output/vitest/coverage/src/components/Table/Table.tsx.html +2677 -0
  157. package/test-output/vitest/coverage/src/components/Table/cells/RenderButton.tsx.html +154 -0
  158. package/test-output/vitest/coverage/src/components/Table/cells/RenderChip.tsx.html +385 -0
  159. package/test-output/vitest/coverage/src/components/Table/cells/RenderText.tsx.html +196 -0
  160. package/test-output/vitest/coverage/src/components/Table/cells/RenderTwoLine.tsx.html +187 -0
  161. package/test-output/vitest/coverage/src/components/Table/cells/RowActionsMenu.tsx.html +526 -0
  162. package/test-output/vitest/coverage/src/components/Table/cells/index.html +191 -0
  163. package/test-output/vitest/coverage/src/components/Table/cells/index.ts.html +127 -0
  164. package/test-output/vitest/coverage/src/components/Table/index.html +131 -0
  165. package/test-output/vitest/coverage/src/components/Table/table.variants.ts.html +706 -0
  166. package/test-output/vitest/coverage/src/components/Tabs/Tabs.tsx.html +283 -0
  167. package/test-output/vitest/coverage/src/components/Tabs/index.html +131 -0
  168. package/test-output/vitest/coverage/src/components/Tabs/tabs.variants.ts.html +292 -0
  169. package/test-output/vitest/coverage/src/components/TextField/TextField.tsx.html +820 -0
  170. package/test-output/vitest/coverage/src/components/TextField/index.html +131 -0
  171. package/test-output/vitest/coverage/src/components/TextField/textField.variants.ts.html +400 -0
  172. package/test-output/vitest/coverage/src/components/Textarea/Textarea.tsx.html +655 -0
  173. package/test-output/vitest/coverage/src/components/Textarea/index.html +131 -0
  174. package/test-output/vitest/coverage/src/components/Textarea/textarea.variants.ts.html +340 -0
  175. package/test-output/vitest/coverage/src/components/Tooltip/Tooltip.tsx.html +295 -0
  176. package/test-output/vitest/coverage/src/components/Tooltip/index.html +131 -0
  177. package/test-output/vitest/coverage/src/components/Tooltip/tooltip.variants.ts.html +169 -0
  178. package/test-output/vitest/coverage/src/components/TopBar/TopBar.tsx.html +283 -0
  179. package/test-output/vitest/coverage/src/components/TopBar/index.html +131 -0
  180. package/test-output/vitest/coverage/src/components/TopBar/topBar.variants.ts.html +199 -0
  181. package/test-output/vitest/coverage/src/components/Typography/Typography.tsx.html +421 -0
  182. package/test-output/vitest/coverage/src/components/Typography/index.html +131 -0
  183. package/test-output/vitest/coverage/src/components/Typography/typography.variants.ts.html +436 -0
  184. package/test-output/vitest/coverage/src/components/VisuallyHidden/VisuallyHidden.tsx.html +262 -0
  185. package/test-output/vitest/coverage/src/components/VisuallyHidden/index.html +116 -0
  186. package/test-output/vitest/coverage/src/components/_shared/data/getNestedValue.ts.html +181 -0
  187. package/test-output/vitest/coverage/src/components/_shared/data/index.html +236 -0
  188. package/test-output/vitest/coverage/src/components/_shared/data/useColumnAutoDetect.ts.html +385 -0
  189. package/test-output/vitest/coverage/src/components/_shared/data/useControllableState.ts.html +229 -0
  190. package/test-output/vitest/coverage/src/components/_shared/data/useDebouncedValue.ts.html +157 -0
  191. package/test-output/vitest/coverage/src/components/_shared/data/useTableFilter.ts.html +439 -0
  192. package/test-output/vitest/coverage/src/components/_shared/data/useTableSearch.ts.html +283 -0
  193. package/test-output/vitest/coverage/src/components/_shared/data/useTableSelection.ts.html +385 -0
  194. package/test-output/vitest/coverage/src/components/_shared/data/useTableSort.ts.html +508 -0
  195. package/test-output/vitest/coverage/src/components/_shared/data/useVirtualizer.ts.html +682 -0
  196. package/test-output/vitest/coverage/src/components/_shared/index.html +116 -0
  197. package/test-output/vitest/coverage/src/components/_shared/resolveValidationState.ts.html +265 -0
  198. package/test-output/vitest/coverage/src/hooks/index.html +116 -0
  199. package/test-output/vitest/coverage/src/hooks/useAccessState.ts.html +328 -0
  200. package/test-output/vitest/coverage/src/index.html +116 -0
  201. package/test-output/vitest/coverage/src/index.ts.html +1153 -0
  202. package/test-output/vitest/coverage/src/utils/cn.ts.html +172 -0
  203. package/test-output/vitest/coverage/src/utils/index.html +116 -0
  204. package/tsconfig.lib.json +7 -4
package/eslint.config.mjs CHANGED
@@ -23,17 +23,32 @@ export default [
23
23
  '@nx/dependency-checks': [
24
24
  'error',
25
25
  {
26
+ // Non-shipped files: their imports must NOT count toward the
27
+ // published `dependencies` set. Spec/test files import the
28
+ // test runner; the build/test config files import build
29
+ // tooling — all of which belong in `devDependencies`.
26
30
  ignoredFiles: [
27
31
  '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
28
32
  '{projectRoot}/rollup.config.{js,ts,mjs,mts,cjs,cts}',
29
33
  '{projectRoot}/vite.config.{js,ts,mjs,mts}',
34
+ '{projectRoot}/vitest.config.{js,ts,mjs,mts}',
35
+ '{projectRoot}/**/*.spec.{ts,tsx}',
36
+ '{projectRoot}/**/*.test.{ts,tsx}',
30
37
  ],
31
38
  // tw-tokens (CSS vars + Tailwind preset) and tw-theme
32
39
  // (Provider that injects the runtime vars) are CONSUMER-FACING
33
40
  // peer dependencies: the runtime requires them, but @dashforge/tw
34
41
  // itself never imports them. Declaring them as peers documents
35
42
  // the requirement without triggering the "not used" check.
36
- ignoredDependencies: ['@dashforge/tw-tokens', '@dashforge/tw-theme'],
43
+ // `tailwindcss` is the same case — a build-time peer (the
44
+ // consumer's Tailwind compiles the class strings + reads the
45
+ // `darkMode: 'selector'` config from `dashforgePreset()`); it
46
+ // is never an `import` site in this package's code.
47
+ ignoredDependencies: [
48
+ '@dashforge/tw-tokens',
49
+ '@dashforge/tw-theme',
50
+ 'tailwindcss',
51
+ ],
37
52
  },
38
53
  ],
39
54
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dashforge/tw",
3
- "version": "0.8.1-beta",
3
+ "version": "0.9.1-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.esm.js",
@@ -25,18 +25,22 @@
25
25
  "@radix-ui/react-switch": "^1.1.0",
26
26
  "@radix-ui/react-tabs": "^1.1.0",
27
27
  "@radix-ui/react-tooltip": "^1.1.0",
28
- "@vitejs/plugin-react": "*",
29
28
  "clsx": "^2.1.0",
30
- "tailwind-merge": "^2.5.0",
29
+ "tailwind-merge": "^3.0.0",
31
30
  "tailwind-variants": "^3.1.1",
32
- "vitest": "*",
31
+ "@dashforge/calendar-core": "0.1.0-beta",
33
32
  "@dashforge/forms": "0.2.3-beta",
34
33
  "@dashforge/rbac": "0.2.3-beta",
35
34
  "@dashforge/ui-core": "0.2.3-beta"
36
35
  },
37
36
  "peerDependencies": {
38
37
  "react": "^18.0.0 || ^19.0.0",
39
- "@dashforge/tw-theme": "0.1.0-beta",
40
- "@dashforge/tw-tokens": "0.1.0-beta"
38
+ "tailwindcss": ">=3.4.1",
39
+ "@dashforge/tw-theme": "0.2.0-beta",
40
+ "@dashforge/tw-tokens": "0.2.0-beta"
41
+ },
42
+ "devDependencies": {
43
+ "@vitejs/plugin-react": "*",
44
+ "vitest": "*"
41
45
  }
42
46
  }
@@ -39,12 +39,15 @@ function withRenderCounter<P>(
39
39
  }
40
40
 
41
41
  describe('<Breadcrumbs> performance', () => {
42
- it('mounts a 50-crumb trail under 50ms', () => {
42
+ it('mounts a 50-crumb trail under 200ms', () => {
43
43
  const trail = makeTrail(50);
44
44
  const t0 = performance.now();
45
45
  render(<Breadcrumbs items={trail} />);
46
46
  const t1 = performance.now();
47
- expect(t1 - t0).toBeLessThan(50);
47
+ // Generous bound — matches the rest of the perf suite. The old
48
+ // 50ms budget was flaky under a parallel full-suite run (cold
49
+ // start jitter), not a real regression signal.
50
+ expect(t1 - t0).toBeLessThan(200);
48
51
  });
49
52
 
50
53
  it('mounts a 1000-crumb (collapsed) trail under 100ms', () => {
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+
5
+ import { Calendar } from './Calendar.js';
6
+
7
+ describe('Calendar', () => {
8
+ it('renders a labelled month grid', () => {
9
+ render(<Calendar today="2026-05-20" />);
10
+ expect(screen.queryByRole('grid', { name: 'Calendar' })).not.toBeNull();
11
+ expect(screen.queryByText('May 2026')).not.toBeNull();
12
+ expect(screen.getAllByRole('columnheader')).toHaveLength(7);
13
+ });
14
+
15
+ it('renders a fixed 42-cell grid', () => {
16
+ render(<Calendar today="2026-05-20" />);
17
+ expect(screen.getAllByRole('gridcell')).toHaveLength(42);
18
+ });
19
+
20
+ it('fires onChange when a day is clicked', () => {
21
+ const onChange = vi.fn();
22
+ render(<Calendar today="2026-05-20" onChange={onChange} />);
23
+ fireEvent.click(screen.getByRole('button', { name: /May 15, 2026/ }));
24
+ expect(onChange).toHaveBeenCalledWith('2026-05-15');
25
+ });
26
+
27
+ it('marks the controlled selected day as pressed', () => {
28
+ render(<Calendar today="2026-05-20" value="2026-05-10" />);
29
+ expect(
30
+ screen
31
+ .getByRole('button', { name: /May 10, 2026/ })
32
+ .getAttribute('aria-pressed'),
33
+ ).toBe('true');
34
+ });
35
+
36
+ it('navigates to the next and previous month', () => {
37
+ render(<Calendar today="2026-05-20" />);
38
+ fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
39
+ expect(screen.queryByText('June 2026')).not.toBeNull();
40
+ fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
41
+ expect(screen.queryByText('May 2026')).not.toBeNull();
42
+ });
43
+
44
+ it('moves roving focus with the arrow keys', () => {
45
+ render(<Calendar today="2026-05-20" />);
46
+ const grid = screen.getByRole('grid');
47
+ expect(
48
+ screen
49
+ .getByRole('button', { name: /May 20, 2026/ })
50
+ .getAttribute('tabindex'),
51
+ ).toBe('0');
52
+ fireEvent.keyDown(grid, { key: 'ArrowRight' });
53
+ expect(
54
+ screen
55
+ .getByRole('button', { name: /May 21, 2026/ })
56
+ .getAttribute('tabindex'),
57
+ ).toBe('0');
58
+ expect(
59
+ screen
60
+ .getByRole('button', { name: /May 20, 2026/ })
61
+ .getAttribute('tabindex'),
62
+ ).toBe('-1');
63
+ });
64
+
65
+ it('does not select a disabled date', () => {
66
+ const onChange = vi.fn();
67
+ render(
68
+ <Calendar today="2026-05-20" minDate="2026-05-15" onChange={onChange} />,
69
+ );
70
+ const blocked = screen.getByRole('button', { name: /May 10, 2026/ });
71
+ expect(blocked.getAttribute('aria-disabled')).toBe('true');
72
+ fireEvent.click(blocked);
73
+ expect(onChange).not.toHaveBeenCalled();
74
+ });
75
+ });
@@ -0,0 +1,264 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { KeyboardEvent } from 'react';
3
+ import {
4
+ formatLongDate,
5
+ resolveCalendarKey,
6
+ useCalendar,
7
+ } from '@dashforge/calendar-core';
8
+ import type { ISODate } from '@dashforge/calendar-core';
9
+ import { cn } from '../../utils/cn.js';
10
+ import { calendarDayVariants, calendarVariants } from './calendar.variants.js';
11
+ import type { CalendarProps } from './calendar.types.js';
12
+
13
+ // Inline 16×16 stroke chevrons — no icon dependency (tw convention).
14
+ function ChevronLeftIcon() {
15
+ return (
16
+ <svg
17
+ width="1em"
18
+ height="1em"
19
+ viewBox="0 0 16 16"
20
+ fill="none"
21
+ aria-hidden="true"
22
+ >
23
+ <path
24
+ d="M10 4 6 8l4 4"
25
+ stroke="currentColor"
26
+ strokeWidth="1.5"
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ />
30
+ </svg>
31
+ );
32
+ }
33
+
34
+ function ChevronRightIcon() {
35
+ return (
36
+ <svg
37
+ width="1em"
38
+ height="1em"
39
+ viewBox="0 0 16 16"
40
+ fill="none"
41
+ aria-hidden="true"
42
+ >
43
+ <path
44
+ d="m6 4 4 4-4 4"
45
+ stroke="currentColor"
46
+ strokeWidth="1.5"
47
+ strokeLinecap="round"
48
+ strokeLinejoin="round"
49
+ />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * `<Calendar>` — a standalone, inline month-grid date picker.
56
+ *
57
+ * The Tailwind presentation layer over the headless `useCalendar` engine
58
+ * from `@dashforge/calendar-core` (the same engine the MUI `@dashforge/ui`
59
+ * calendar consumes — only the skin differs). Implements the WCAG grid
60
+ * pattern with roving tab-index focus.
61
+ *
62
+ * It is a primitive: no form-bridge / RBAC integration — use `<DatePicker>`
63
+ * for a form-bound date field.
64
+ */
65
+ export function Calendar(props: CalendarProps) {
66
+ const {
67
+ value,
68
+ defaultValue,
69
+ onChange,
70
+ month,
71
+ year,
72
+ defaultMonth,
73
+ defaultYear,
74
+ onMonthChange,
75
+ minDate,
76
+ maxDate,
77
+ disabledDates,
78
+ isDateDisabled,
79
+ weekStartDay,
80
+ locale,
81
+ today,
82
+ disabled = false,
83
+ autoFocus = false,
84
+ sx,
85
+ slotProps,
86
+ testId,
87
+ 'aria-label': ariaLabel = 'Calendar',
88
+ } = props;
89
+
90
+ const isValueControlled = value !== undefined;
91
+ const [internalValue, setInternalValue] = useState<ISODate | null>(
92
+ defaultValue ?? null,
93
+ );
94
+ const selectedValue: ISODate | null = isValueControlled
95
+ ? value
96
+ : internalValue;
97
+
98
+ const handleSelect = useCallback(
99
+ (iso: ISODate) => {
100
+ if (!isValueControlled) {
101
+ setInternalValue(iso);
102
+ }
103
+ onChange?.(iso);
104
+ },
105
+ [isValueControlled, onChange],
106
+ );
107
+
108
+ const calendar = useCalendar({
109
+ selected: selectedValue,
110
+ month,
111
+ year,
112
+ defaultMonth,
113
+ defaultYear,
114
+ onMonthChange,
115
+ minDate,
116
+ maxDate,
117
+ disabledDates,
118
+ isDateDisabled,
119
+ weekStartDay,
120
+ locale,
121
+ today,
122
+ onSelect: handleSelect,
123
+ });
124
+
125
+ // Roving focus: the active day cell holds DOM focus; it is moved only
126
+ // after a keyboard interaction (or on mount when `autoFocus`). `focus`
127
+ // uses `preventScroll` so opening the calendar inside a popup never
128
+ // scroll-jumps the page before the popup is positioned.
129
+ const focusedCellRef = useRef<HTMLButtonElement | null>(null);
130
+ const shouldFocusRef = useRef(autoFocus);
131
+
132
+ useEffect(() => {
133
+ if (shouldFocusRef.current && focusedCellRef.current) {
134
+ focusedCellRef.current.focus({ preventScroll: true });
135
+ shouldFocusRef.current = false;
136
+ }
137
+ }, [calendar.focusedDate]);
138
+
139
+ const handleKeyDown = useCallback(
140
+ (event: KeyboardEvent<HTMLDivElement>) => {
141
+ if (disabled) {
142
+ return;
143
+ }
144
+ const action = resolveCalendarKey(event.key);
145
+ if (action === null) {
146
+ return;
147
+ }
148
+ event.preventDefault();
149
+ shouldFocusRef.current = true;
150
+ switch (action) {
151
+ case 'select':
152
+ calendar.selectDate(calendar.focusedDate);
153
+ break;
154
+ case 'monthForward':
155
+ calendar.goToNextMonth();
156
+ break;
157
+ case 'monthBackward':
158
+ calendar.goToPreviousMonth();
159
+ break;
160
+ default:
161
+ calendar.navigate(action);
162
+ }
163
+ },
164
+ [disabled, calendar],
165
+ );
166
+
167
+ const resolvedLocale = locale ?? 'en-US';
168
+ const v = calendarVariants({ disabled });
169
+
170
+ return (
171
+ <div
172
+ data-testid={testId}
173
+ className={cn(v.root(), sx, slotProps?.root?.className)}
174
+ >
175
+ {/* Header: month navigation + label */}
176
+ <div className={cn(v.header(), slotProps?.header?.className)}>
177
+ <button
178
+ type="button"
179
+ className={v.navButton()}
180
+ disabled={disabled}
181
+ aria-label="Previous month"
182
+ onClick={() => {
183
+ calendar.goToPreviousMonth();
184
+ }}
185
+ >
186
+ <ChevronLeftIcon />
187
+ </button>
188
+ <span aria-live="polite" className={v.monthLabel()}>
189
+ {calendar.monthLabel}
190
+ </span>
191
+ <button
192
+ type="button"
193
+ className={v.navButton()}
194
+ disabled={disabled}
195
+ aria-label="Next month"
196
+ onClick={() => {
197
+ calendar.goToNextMonth();
198
+ }}
199
+ >
200
+ <ChevronRightIcon />
201
+ </button>
202
+ </div>
203
+
204
+ {/* Grid */}
205
+ <div
206
+ role="grid"
207
+ aria-label={ariaLabel}
208
+ onKeyDown={handleKeyDown}
209
+ className={cn(v.grid(), slotProps?.grid?.className)}
210
+ >
211
+ <div role="row" className={v.weekdayRow()}>
212
+ {calendar.weekdayNames.map((dayName, index) => (
213
+ <span
214
+ key={`weekday-${String(index)}`}
215
+ role="columnheader"
216
+ className={v.weekday()}
217
+ >
218
+ {dayName}
219
+ </span>
220
+ ))}
221
+ </div>
222
+
223
+ {calendar.weeks.map((week) => (
224
+ <div key={week.key} role="row" className={v.weekRow()}>
225
+ {week.days.map((day) => {
226
+ const isActiveCell = day.iso === calendar.focusedDate;
227
+ return (
228
+ <span key={day.iso} role="gridcell" className="flex">
229
+ <button
230
+ type="button"
231
+ ref={isActiveCell ? focusedCellRef : null}
232
+ tabIndex={isActiveCell ? 0 : -1}
233
+ disabled={disabled}
234
+ aria-label={formatLongDate(
235
+ { year: day.year, month: day.month, day: day.day },
236
+ resolvedLocale,
237
+ )}
238
+ aria-current={day.isToday ? 'date' : undefined}
239
+ aria-pressed={day.isSelected}
240
+ aria-disabled={day.isDisabled}
241
+ onClick={() => {
242
+ calendar.selectDate(day.iso);
243
+ }}
244
+ className={cn(
245
+ calendarDayVariants({
246
+ siblingMonth: day.isSiblingMonth,
247
+ today: day.isToday,
248
+ selected: day.isSelected,
249
+ disabled: day.isDisabled,
250
+ }),
251
+ slotProps?.day?.className,
252
+ )}
253
+ >
254
+ {day.day}
255
+ </button>
256
+ </span>
257
+ );
258
+ })}
259
+ </div>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -0,0 +1,65 @@
1
+ import type { ISODate, WeekDay } from '@dashforge/calendar-core';
2
+
3
+ /** Per-slot `className` overrides for `<Calendar>`. */
4
+ export interface CalendarSlotProps {
5
+ root?: { className?: string };
6
+ header?: { className?: string };
7
+ grid?: { className?: string };
8
+ /** Applied to every day-cell button. */
9
+ day?: { className?: string };
10
+ }
11
+
12
+ /**
13
+ * Props for the `<Calendar>` primitive — a standalone, inline month grid.
14
+ *
15
+ * `Calendar` is NOT a form field (no bridge / RBAC integration). It is the
16
+ * low-level building block used inside `<DatePicker>`. For a form-bound date
17
+ * input use `<DatePicker>`.
18
+ *
19
+ * Dates are ISO `YYYY-MM-DD` strings throughout. The headless date engine is
20
+ * `@dashforge/calendar-core` — shared with the MUI `@dashforge/ui` calendar.
21
+ */
22
+ export interface CalendarProps {
23
+ /** Selected date (controlled). */
24
+ value?: ISODate | null;
25
+ /** Initial selected date (uncontrolled). */
26
+ defaultValue?: ISODate | null;
27
+ /** Fired when a day is chosen. */
28
+ onChange?: (value: ISODate) => void;
29
+ /** Controlled displayed month (1-indexed). Pair with `year`. */
30
+ month?: number;
31
+ /** Controlled displayed year. Pair with `month`. */
32
+ year?: number;
33
+ /** Initial displayed month (1-indexed, uncontrolled). */
34
+ defaultMonth?: number;
35
+ /** Initial displayed year (uncontrolled). */
36
+ defaultYear?: number;
37
+ /** Fired when the displayed month changes. */
38
+ onMonthChange?: (month: number, year: number) => void;
39
+ /** Earliest selectable date (inclusive). */
40
+ minDate?: ISODate;
41
+ /** Latest selectable date (inclusive). */
42
+ maxDate?: ISODate;
43
+ /** Explicit list of disabled dates. */
44
+ disabledDates?: readonly ISODate[];
45
+ /** Predicate marking arbitrary dates disabled. */
46
+ isDateDisabled?: (date: ISODate) => boolean;
47
+ /** Weekday of the grid's first column (`0` = Sunday). Default `0`. */
48
+ weekStartDay?: WeekDay;
49
+ /** BCP-47 locale for month/weekday names. Default `"en-US"`. */
50
+ locale?: string;
51
+ /** Overrides "today" (ISO `YYYY-MM-DD`), mainly for testing. */
52
+ today?: ISODate;
53
+ /** Disables the whole calendar. */
54
+ disabled?: boolean;
55
+ /** Moves DOM focus to the active day cell on mount. */
56
+ autoFocus?: boolean;
57
+ /** Accessible label for the grid. Default `"Calendar"`. */
58
+ 'aria-label'?: string;
59
+ /** Root-level Tailwind class override. */
60
+ sx?: string;
61
+ /** Per-slot `className` overrides. */
62
+ slotProps?: CalendarSlotProps;
63
+ /** Test id applied to the root element. */
64
+ testId?: string;
65
+ }
@@ -0,0 +1,64 @@
1
+ import { tv } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Structural slots for `<Calendar>`.
5
+ *
6
+ * Theme identity: every class is a bare neutral / brand token-backed
7
+ * Tailwind class. The `dashforgePreset()` CSS-variable swap auto-inverts
8
+ * the neutral palette, so there are NO `dark:` variants here (a `dark:` on
9
+ * a neutral class would double-invert — see `_shared/themeIdentity.test.ts`).
10
+ */
11
+ export const calendarVariants = tv({
12
+ slots: {
13
+ root: 'inline-flex w-fit flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 select-none',
14
+ header: 'flex items-center justify-between gap-1',
15
+ navButton:
16
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[1rem] text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:pointer-events-none disabled:opacity-40',
17
+ monthLabel: 'flex-1 text-center text-sm font-semibold text-neutral-900',
18
+ grid: 'flex flex-col gap-1 focus:outline-none',
19
+ weekdayRow: 'grid grid-cols-7 gap-1',
20
+ weekday:
21
+ 'flex h-8 items-center justify-center text-xs font-semibold text-neutral-500',
22
+ weekRow: 'grid grid-cols-7 gap-1',
23
+ },
24
+ variants: {
25
+ disabled: {
26
+ true: { root: 'opacity-60' },
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ disabled: false,
31
+ },
32
+ });
33
+
34
+ /**
35
+ * Per-day-cell recipe. Each of the 42 grid cells is rendered with its own
36
+ * state (`siblingMonth` / `today` / `selected` / `disabled`).
37
+ */
38
+ export const calendarDayVariants = tv({
39
+ base: 'flex h-9 w-9 items-center justify-center rounded-md text-sm text-neutral-900 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500',
40
+ variants: {
41
+ siblingMonth: {
42
+ true: 'text-neutral-400',
43
+ },
44
+ today: {
45
+ true: 'font-bold ring-1 ring-inset ring-primary-400',
46
+ },
47
+ selected: {
48
+ true: 'bg-primary-600 text-white hover:bg-primary-600',
49
+ },
50
+ disabled: {
51
+ true: 'cursor-default text-neutral-300 opacity-60 hover:bg-transparent',
52
+ },
53
+ },
54
+ compoundVariants: [
55
+ // A selected cell drops the "today" ring — the solid fill is enough.
56
+ { selected: true, today: true, class: 'ring-0' },
57
+ ],
58
+ defaultVariants: {
59
+ siblingMonth: false,
60
+ today: false,
61
+ selected: false,
62
+ disabled: false,
63
+ },
64
+ });
@@ -0,0 +1,114 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Performance guardrails for `<DataGrid>` (virtualized).
4
+ *
5
+ * The whole point of DataGrid is O(window) rendering: mounting 10k or
6
+ * 100k rows must cost roughly the same as mounting a few hundred,
7
+ * because only the visible window (+ overscan + 2 spacer `<tr>`) is
8
+ * ever in the DOM. These tests pin that invariant — a regression that
9
+ * accidentally mounts the full dataset (e.g. a broken `renderedRows`
10
+ * slice) blows the DOM-node-count assertion immediately.
11
+ *
12
+ * Budgets are deliberately generous — order-of-magnitude regressions,
13
+ * not micro-jitter.
14
+ */
15
+ import * as React from 'react';
16
+ import { describe, it, expect, afterEach, beforeAll } from 'vitest';
17
+ import { render, cleanup } from '@testing-library/react';
18
+ import { DataGrid } from './DataGrid.js';
19
+ import type { TableColumn } from '../Table/table.types.js';
20
+
21
+ void React;
22
+ afterEach(() => cleanup());
23
+
24
+ beforeAll(() => {
25
+ // jsdom ships neither ResizeObserver nor IntersectionObserver — the
26
+ // virtualizer + Radix popovers need them.
27
+ if (typeof globalThis.ResizeObserver === 'undefined') {
28
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
29
+ class {
30
+ observe() {}
31
+ unobserve() {}
32
+ disconnect() {}
33
+ };
34
+ }
35
+ });
36
+
37
+ interface Row {
38
+ id: string;
39
+ name: string;
40
+ email: string;
41
+ age: number;
42
+ }
43
+
44
+ const COLS: TableColumn<Row>[] = [
45
+ { field: 'name', header: 'Name', sortable: true },
46
+ { field: 'email', header: 'Email' },
47
+ { field: 'age', header: 'Age', sortable: true },
48
+ ];
49
+
50
+ function makeRows(n: number): Row[] {
51
+ return Array.from({ length: n }).map((_, i) => ({
52
+ id: `r${i}`,
53
+ name: `User ${i}`,
54
+ email: `user${i}@example.com`,
55
+ age: 20 + (i % 50),
56
+ }));
57
+ }
58
+
59
+ describe('<DataGrid> virtualization performance', () => {
60
+ it('mounts 10 000 rows under 500ms (render cost is O(window))', () => {
61
+ const rows = makeRows(10_000);
62
+ const t0 = performance.now();
63
+ render(
64
+ <DataGrid
65
+ rows={rows}
66
+ cols={COLS}
67
+ getRowId={(r) => r.id}
68
+ rowHeight={40}
69
+ height="400px"
70
+ />,
71
+ );
72
+ const t1 = performance.now();
73
+ expect(t1 - t0).toBeLessThan(500);
74
+ });
75
+
76
+ it('mounts only the visible window in the DOM, NOT the full dataset', () => {
77
+ const rows = makeRows(10_000);
78
+ const { container } = render(
79
+ <DataGrid
80
+ rows={rows}
81
+ cols={COLS}
82
+ getRowId={(r) => r.id}
83
+ rowHeight={40}
84
+ height="400px"
85
+ />,
86
+ );
87
+ // Virtualized: visible window + overscan + up to 2 spacer rows —
88
+ // a few dozen `<tr>`, far below the 10 000 total.
89
+ const trCount = container.querySelectorAll('tbody tr').length;
90
+ expect(trCount).toBeGreaterThan(0);
91
+ expect(trCount).toBeLessThan(100);
92
+ });
93
+
94
+ it('mounts 100 000 rows within the same generous budget (virtualized)', () => {
95
+ const rows = makeRows(100_000);
96
+ const t0 = performance.now();
97
+ const { container } = render(
98
+ <DataGrid
99
+ rows={rows}
100
+ cols={COLS}
101
+ getRowId={(r) => r.id}
102
+ rowHeight={40}
103
+ height="400px"
104
+ />,
105
+ );
106
+ const t1 = performance.now();
107
+ // 10× the rows — the RENDER stays windowed, so the cost must not
108
+ // scale with the dataset. Generous absolute bound (not a ratio,
109
+ // to stay non-flaky under CI jitter).
110
+ expect(t1 - t0).toBeLessThan(800);
111
+ // And the DOM still holds only the window.
112
+ expect(container.querySelectorAll('tbody tr').length).toBeLessThan(100);
113
+ });
114
+ });