@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
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Performance guardrails for `<Table>` (non-virtualized).
4
+ *
5
+ * Table renders EVERY row in the DOM — its design ceiling is ~500
6
+ * rows (past that, the consumer should switch to `<DataGrid>`). These
7
+ * tests pin the mount cost at that ceiling so a regression in the
8
+ * render path (e.g. an accidental O(n²) in the sort / search pipeline)
9
+ * surfaces in CI.
10
+ *
11
+ * Budgets are deliberately generous — they catch order-of-magnitude
12
+ * regressions under a cold workspace-wide test run, not micro-jitter.
13
+ */
14
+ import * as React from 'react';
15
+ import { describe, it, expect, afterEach } from 'vitest';
16
+ import { render, cleanup } from '@testing-library/react';
17
+ import { Table } from './Table.js';
18
+ import type { TableColumn } from './table.types.js';
19
+
20
+ void React;
21
+ afterEach(() => cleanup());
22
+
23
+ interface Row {
24
+ id: string;
25
+ name: string;
26
+ email: string;
27
+ age: number;
28
+ city: string;
29
+ }
30
+
31
+ const COLS: TableColumn<Row>[] = [
32
+ { field: 'name', header: 'Name', sortable: true, searchable: true },
33
+ { field: 'email', header: 'Email', searchable: true },
34
+ { field: 'age', header: 'Age', sortable: true },
35
+ { field: 'city', header: 'City' },
36
+ ];
37
+
38
+ function makeRows(n: number): Row[] {
39
+ const cities = ['Roma', 'Milano', 'Berlin'];
40
+ return Array.from({ length: n }).map((_, i) => ({
41
+ id: `r${i}`,
42
+ name: `User ${i}`,
43
+ email: `user${i}@example.com`,
44
+ age: 20 + (i % 50),
45
+ city: cities[i % 3] as string,
46
+ }));
47
+ }
48
+
49
+ describe('<Table> performance', () => {
50
+ it("mounts 500 rows (Table's row-count ceiling) under 800ms", () => {
51
+ const rows = makeRows(500);
52
+ const t0 = performance.now();
53
+ render(<Table rows={rows} cols={COLS} getRowId={(r) => r.id} />);
54
+ const t1 = performance.now();
55
+ expect(t1 - t0).toBeLessThan(800);
56
+ });
57
+
58
+ it('renders every row in the DOM (non-virtualized contract)', () => {
59
+ const rows = makeRows(500);
60
+ const { container } = render(
61
+ <Table rows={rows} cols={COLS} getRowId={(r) => r.id} />,
62
+ );
63
+ // Non-virtualized: all 500 data rows are mounted.
64
+ expect(container.querySelectorAll('tbody tr').length).toBe(500);
65
+ });
66
+
67
+ it('applies a default sort over 500 rows within the mount budget', () => {
68
+ const rows = makeRows(500);
69
+ const t0 = performance.now();
70
+ render(
71
+ <Table
72
+ rows={rows}
73
+ cols={COLS}
74
+ getRowId={(r) => r.id}
75
+ defaultSortModel={[{ field: 'name', direction: 'asc' }]}
76
+ />,
77
+ );
78
+ const t1 = performance.now();
79
+ expect(t1 - t0).toBeLessThan(800);
80
+ });
81
+ });
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import * as React from 'react';
3
- import { describe, it, expect, afterEach, beforeAll } from 'vitest';
3
+ import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
4
4
  import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5
5
  import { Table } from './Table.js';
6
6
  import type { TableColumn } from './table.types.js';
@@ -601,3 +601,74 @@ describe('Table — i18n', () => {
601
601
  expect(screen.queryByLabelText('Seleziona tutte')).not.toBeNull();
602
602
  });
603
603
  });
604
+
605
+ describe('Table — Sprint 6 P2 refinements', () => {
606
+ // ───── D7: no-results vs no-data empty state ─────
607
+
608
+ it('shows the noData message when the dataset is genuinely empty', () => {
609
+ render(<Table rows={[]} cols={baseCols} getRowId={getRowId} />);
610
+ expect(screen.queryByText('No data')).not.toBeNull();
611
+ });
612
+
613
+ it('shows the noResults message when a search excludes every row', () => {
614
+ render(
615
+ <Table
616
+ rows={baseRows}
617
+ cols={baseCols}
618
+ getRowId={getRowId}
619
+ enableSearch
620
+ searchQuery="zzz-no-match"
621
+ searchDebounceMs={0}
622
+ />,
623
+ );
624
+ expect(screen.queryByText('No matching results')).not.toBeNull();
625
+ expect(screen.queryByText('No data')).toBeNull();
626
+ });
627
+
628
+ it('shows the noResults message when a filter excludes every row', () => {
629
+ render(
630
+ <Table
631
+ rows={baseRows}
632
+ cols={baseCols}
633
+ getRowId={getRowId}
634
+ filterModel={[{ field: 'name', op: 'contains', value: 'zzz-no-match' }]}
635
+ />,
636
+ );
637
+ expect(screen.queryByText('No matching results')).not.toBeNull();
638
+ });
639
+
640
+ // ───── T1: dev-warn when getRowId omitted + identity-sensitive feature ─────
641
+
642
+ it('warns in dev when getRowId is omitted and a sortable column exists', () => {
643
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
644
+ // baseCols has sortable columns; no getRowId passed.
645
+ render(<Table rows={baseRows} cols={baseCols} />);
646
+ expect(warn).toHaveBeenCalledWith(
647
+ expect.stringContaining('`getRowId` is not set'),
648
+ );
649
+ warn.mockRestore();
650
+ });
651
+
652
+ it('does NOT warn when getRowId is provided', () => {
653
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
654
+ render(<Table rows={baseRows} cols={baseCols} getRowId={getRowId} />);
655
+ expect(warn).not.toHaveBeenCalledWith(
656
+ expect.stringContaining('`getRowId` is not set'),
657
+ );
658
+ warn.mockRestore();
659
+ });
660
+
661
+ it('does NOT warn when getRowId is omitted but no identity-sensitive feature is active', () => {
662
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
663
+ // Plain columns: not sortable, not searchable, no selection/expansion.
664
+ const plainCols: TableColumn<User>[] = [
665
+ { field: 'name', header: 'Name' },
666
+ { field: 'email', header: 'Email' },
667
+ ];
668
+ render(<Table rows={baseRows} cols={plainCols} />);
669
+ expect(warn).not.toHaveBeenCalledWith(
670
+ expect.stringContaining('`getRowId` is not set'),
671
+ );
672
+ warn.mockRestore();
673
+ });
674
+ });
@@ -1,4 +1,4 @@
1
- import { useMemo, type ReactNode, type MouseEvent } from 'react';
1
+ import { useMemo, useEffect, type ReactNode, type MouseEvent } from 'react';
2
2
  import { cn } from '../../utils/cn.js';
3
3
  import { useAccessState } from '../../hooks/useAccessState.js';
4
4
  import { Skeleton } from '../Skeleton/Skeleton.js';
@@ -27,6 +27,7 @@ import type {
27
27
  const DEFAULT_LABELS: Required<TableLabels> = {
28
28
  searchPlaceholder: 'Search...',
29
29
  noData: 'No data',
30
+ noResults: 'No matching results',
30
31
  loading: 'Loading…',
31
32
  ariaSortNone: 'Not sorted. Click to sort ascending.',
32
33
  ariaSortAscending: 'Sorted ascending. Click to sort descending.',
@@ -53,8 +54,7 @@ const DEFAULT_LABELS: Required<TableLabels> = {
53
54
  densitySpacious: 'Spacious',
54
55
  columnsButton: 'Columns',
55
56
  columnsTitle: 'Toggle columns',
56
- columnsShowAll: 'Show all',
57
- columnsHideAll: 'Hide all',
57
+ columnsToggleAll: 'Toggle all columns',
58
58
  };
59
59
 
60
60
  const DEFAULT_GET_ROW_ID = <T extends object>(_row: T, index: number) => String(index);
@@ -132,6 +132,31 @@ export function Table<T extends object>(props: TableProps<T>) {
132
132
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
133
133
  const tableAccessState = useAccessState(tableAccess);
134
134
 
135
+ // Dev-only guard: `getRowId` is optional (positional-index fallback)
136
+ // but that fallback breaks row identity once rows reorder (sort) or
137
+ // change presence (search / filter) — selection + expansion state
138
+ // then jump to the wrong rows. Warn when it's omitted AND a feature
139
+ // that relies on stable identity is active. Stripped in production.
140
+ const getRowIdProvided = props.getRowId !== undefined;
141
+ useEffect(() => {
142
+ if (process.env.NODE_ENV === 'production') return;
143
+ if (getRowIdProvided) return;
144
+ const identityMatters =
145
+ rowSelection !== 'none' ||
146
+ expandable != null ||
147
+ enableSearch ||
148
+ cols.some((c) => Boolean(c.sortable));
149
+ if (identityMatters) {
150
+ console.warn(
151
+ '[Dashforge Table] `getRowId` is not set — falling back to the ' +
152
+ 'positional row index. With sort / search / selection / ' +
153
+ 'expandable rows active, the index-based key breaks row ' +
154
+ 'identity when rows reorder. Pass a stable resolver, e.g. ' +
155
+ '`getRowId={(row) => row.id}`.',
156
+ );
157
+ }
158
+ }, [getRowIdProvided, rowSelection, expandable, enableSearch, cols]);
159
+
135
160
  // ───── Controllable state machinery ─────
136
161
 
137
162
  const [sortModel, setSortModel] = useControllableState<TableSortModel>({
@@ -341,7 +366,14 @@ export function Table<T extends object>(props: TableProps<T>) {
341
366
  ? renderEmptyState({
342
367
  totalColumnCount,
343
368
  emptyState,
344
- fallback: labels.noData,
369
+ // Distinguish a genuinely empty dataset from one
370
+ // emptied by an active search / filter.
371
+ fallback:
372
+ rows.length > 0 &&
373
+ (debouncedSearchQuery.trim().length > 0 ||
374
+ filterModel.length > 0)
375
+ ? labels.noResults
376
+ : labels.noData,
345
377
  cellClass: cn(v.cell(), v.emptyState(), slotProps?.emptyState?.className),
346
378
  })
347
379
  : sortedRows.map((row, idx) => {
@@ -733,6 +765,10 @@ function renderLoadingRows<T extends object>(args: {
733
765
  cellClass: string;
734
766
  }): ReactNode {
735
767
  const { count, visibleCols, showSelectionColumn, showExpandColumn, showRowActionsColumn, rowClass, cellClass } = args;
768
+ // No explicit row height here (unlike DataGrid's loading rows, which
769
+ // pin `rowHeight` for the virtualization math): Table is auto-height
770
+ // by design. Skeleton rows inherit the same density padding via
771
+ // `cellClass`, so they line up with real rows without a fixed height.
736
772
  return Array.from({ length: count }).map((_, i) => (
737
773
  <tr key={`loading-${i}`} className={rowClass} aria-busy="true">
738
774
  {showSelectionColumn && <td className={cellClass}><Skeleton variant="rectangle" width="16px" height="16px" /></td>}
@@ -53,15 +53,12 @@ const chipVariants = tv({
53
53
  { variant: 'soft', color: 'danger', class: 'bg-danger-100 dark:bg-danger-950 text-danger-700 dark:text-danger-300' },
54
54
  { variant: 'soft', color: 'info', class: 'bg-info-100 dark:bg-info-950 text-info-700 dark:text-info-300' },
55
55
  { variant: 'soft', color: 'secondary', class: 'bg-secondary-100 dark:bg-secondary-950 text-secondary-700 dark:text-secondary-300' },
56
- // solid = full bg + light text
57
- // solid neutral: bg + text both anchored to neutral palette
58
- // (auto-inverts). `text-white` would not invert and would break
59
- // contrast in light mode against a `bg-neutral-700` dark bg, so
60
- // we stick to `text-neutral-50` (light text in light mode, dark
61
- // in dark — wait, that's wrong too). Actually `text-neutral-50`
62
- // auto-inverts: light text in light mode (against dark bg) is
63
- // what we want; in dark mode `bg-neutral-700` becomes light AND
64
- // `text-neutral-50` becomes dark — contrast preserved.
56
+ // solid = full bg + contrasting text.
57
+ // solid neutral: both `bg-neutral-700` and `text-neutral-50` sit
58
+ // on the neutral palette, so they auto-invert together via the
59
+ // CSS-var swap — bg/text contrast is preserved in BOTH modes.
60
+ // (`text-white` is deliberately NOT used here: white does not
61
+ // auto-invert, which would flip the contrast in dark mode.)
65
62
  { variant: 'solid', color: 'neutral', class: 'bg-neutral-700 text-neutral-50' },
66
63
  { variant: 'solid', color: 'primary', class: 'bg-primary-500 text-white' },
67
64
  { variant: 'solid', color: 'success', class: 'bg-success-500 text-white' },
@@ -0,0 +1,247 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the Table cell renderer library — `RenderText`,
4
+ * `RenderTwoLine`, `RenderChip`, `RenderButton`, `RowActionsMenu`.
5
+ *
6
+ * Sprint 6 P4 — these shipped in Sprint 4.1 with only integration
7
+ * coverage (via Table/DataGrid tests). This file exercises them
8
+ * directly, including the RBAC + edge-case branches.
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 { RenderText } from './RenderText.js';
14
+ import { RenderTwoLine } from './RenderTwoLine.js';
15
+ import { RenderChip } from './RenderChip.js';
16
+ import { RenderButton } from './RenderButton.js';
17
+ import { RowActionsMenu } from './RowActionsMenu.js';
18
+
19
+ void React;
20
+ afterEach(() => cleanup());
21
+
22
+ beforeAll(() => {
23
+ if (typeof globalThis.ResizeObserver === 'undefined') {
24
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
25
+ class {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ };
30
+ }
31
+ });
32
+
33
+ describe('RenderText', () => {
34
+ it('renders the value in a span', () => {
35
+ render(<RenderText value="hello" />);
36
+ expect(screen.getByText('hello').tagName).toBe('SPAN');
37
+ });
38
+
39
+ it('applies the truncate class when truncate is set', () => {
40
+ render(<RenderText value="long text" truncate />);
41
+ expect(screen.getByText('long text').className).toContain('truncate');
42
+ });
43
+
44
+ it('applies the muted neutral color when muted is set', () => {
45
+ render(<RenderText value="dim" muted />);
46
+ expect(screen.getByText('dim').className).toContain('text-neutral-500');
47
+ });
48
+
49
+ it('merges a custom className', () => {
50
+ render(<RenderText value="x" className="custom-cls" />);
51
+ expect(screen.getByText('x').className).toContain('custom-cls');
52
+ });
53
+
54
+ it('renders nothing extra when no flags are set', () => {
55
+ render(<RenderText value="plain" />);
56
+ const el = screen.getByText('plain');
57
+ expect(el.className).not.toContain('truncate');
58
+ expect(el.className).not.toContain('text-neutral-500');
59
+ });
60
+ });
61
+
62
+ describe('RenderTwoLine', () => {
63
+ it('renders both primary and secondary text', () => {
64
+ render(<RenderTwoLine primary="Jane Doe" secondary="jane@example.com" />);
65
+ expect(screen.getByText('Jane Doe')).not.toBeNull();
66
+ expect(screen.getByText('jane@example.com')).not.toBeNull();
67
+ });
68
+
69
+ it('primary is font-medium, secondary is muted + smaller', () => {
70
+ render(<RenderTwoLine primary="P" secondary="S" />);
71
+ expect(screen.getByText('P').className).toContain('font-medium');
72
+ expect(screen.getByText('S').className).toContain('text-neutral-500');
73
+ });
74
+
75
+ it('merges a custom className on the wrapper', () => {
76
+ const { container } = render(
77
+ <RenderTwoLine primary="P" secondary="S" className="twoline-cls" />,
78
+ );
79
+ expect(container.firstElementChild?.className).toContain('twoline-cls');
80
+ });
81
+ });
82
+
83
+ describe('RenderChip', () => {
84
+ it('renders children', () => {
85
+ render(<RenderChip>active</RenderChip>);
86
+ expect(screen.getByText('active')).not.toBeNull();
87
+ });
88
+
89
+ it('defaults to soft + neutral + sm', () => {
90
+ render(<RenderChip>x</RenderChip>);
91
+ // soft neutral → bg-neutral-100
92
+ expect(screen.getByText('x').className).toContain('bg-neutral-100');
93
+ });
94
+
95
+ it.each(['primary', 'success', 'warning', 'danger', 'info', 'secondary'] as const)(
96
+ 'renders soft variant for the %s intent',
97
+ (color) => {
98
+ render(<RenderChip color={color}>{color}</RenderChip>);
99
+ expect(screen.getByText(color).className).toContain(`bg-${color}-100`);
100
+ },
101
+ );
102
+
103
+ it('renders the solid variant', () => {
104
+ render(
105
+ <RenderChip variant="solid" color="primary">
106
+ solid
107
+ </RenderChip>,
108
+ );
109
+ expect(screen.getByText('solid').className).toContain('bg-primary-500');
110
+ });
111
+
112
+ it('renders the outline variant', () => {
113
+ render(
114
+ <RenderChip variant="outline" color="danger">
115
+ outline
116
+ </RenderChip>,
117
+ );
118
+ expect(screen.getByText('outline').className).toContain('border-danger-500');
119
+ });
120
+
121
+ it('renders the md size', () => {
122
+ render(
123
+ <RenderChip size="md">md</RenderChip>,
124
+ );
125
+ expect(screen.getByText('md').className).toContain('h-6');
126
+ });
127
+
128
+ it('merges a custom className', () => {
129
+ render(<RenderChip className="chip-cls">x</RenderChip>);
130
+ expect(screen.getByText('x').className).toContain('chip-cls');
131
+ });
132
+ });
133
+
134
+ describe('RenderButton', () => {
135
+ it('renders the label inside a button', () => {
136
+ render(<RenderButton label="Edit" />);
137
+ expect(screen.getByRole('button', { name: 'Edit' })).not.toBeNull();
138
+ });
139
+
140
+ it('fires onClick when clicked', () => {
141
+ const onClick = vi.fn();
142
+ render(<RenderButton label="Go" onClick={onClick} />);
143
+ fireEvent.click(screen.getByRole('button', { name: 'Go' }));
144
+ expect(onClick).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('honors the disabled prop', () => {
148
+ const onClick = vi.fn();
149
+ render(<RenderButton label="Nope" onClick={onClick} disabled />);
150
+ const btn = screen.getByRole('button', { name: 'Nope' }) as HTMLButtonElement;
151
+ expect(btn.disabled).toBe(true);
152
+ fireEvent.click(btn);
153
+ expect(onClick).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe('RowActionsMenu', () => {
158
+ interface Row {
159
+ id: string;
160
+ }
161
+ const row: Row = { id: 'r1' };
162
+
163
+ it('renders the default 3-dot trigger', () => {
164
+ render(
165
+ <RowActionsMenu
166
+ row={row}
167
+ actions={[{ label: 'Edit', onClick: () => {} }]}
168
+ />,
169
+ );
170
+ expect(screen.getByRole('button', { name: 'Row actions' })).not.toBeNull();
171
+ });
172
+
173
+ it('returns null when there are no visible actions', () => {
174
+ const { container } = render(
175
+ <RowActionsMenu row={row} actions={[]} />,
176
+ );
177
+ expect(container.innerHTML).toBe('');
178
+ });
179
+
180
+ it('opens the menu and renders the action items', () => {
181
+ render(
182
+ <RowActionsMenu
183
+ row={row}
184
+ actions={[
185
+ { label: 'Edit', onClick: () => {} },
186
+ { label: 'Delete', onClick: () => {}, color: 'danger' },
187
+ ]}
188
+ />,
189
+ );
190
+ fireEvent.click(screen.getByRole('button', { name: 'Row actions' }));
191
+ expect(screen.getByRole('menuitem', { name: 'Edit' })).not.toBeNull();
192
+ expect(screen.getByRole('menuitem', { name: 'Delete' })).not.toBeNull();
193
+ });
194
+
195
+ it('invokes the action onClick with the row', () => {
196
+ const onClick = vi.fn();
197
+ render(
198
+ <RowActionsMenu row={row} actions={[{ label: 'Edit', onClick }]} />,
199
+ );
200
+ fireEvent.click(screen.getByRole('button', { name: 'Row actions' }));
201
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Edit' }));
202
+ expect(onClick).toHaveBeenCalledWith(row);
203
+ });
204
+
205
+ it('filters out actions whose RBAC says hide', () => {
206
+ render(
207
+ <RowActionsMenu
208
+ row={row}
209
+ actions={[
210
+ { label: 'Edit', onClick: () => {} },
211
+ {
212
+ label: 'SecretDelete',
213
+ onClick: () => {},
214
+ access: { onUnauthorized: 'hide' },
215
+ },
216
+ ]}
217
+ />,
218
+ );
219
+ fireEvent.click(screen.getByRole('button', { name: 'Row actions' }));
220
+ expect(screen.queryByRole('menuitem', { name: 'Edit' })).not.toBeNull();
221
+ expect(screen.queryByRole('menuitem', { name: 'SecretDelete' })).toBeNull();
222
+ });
223
+
224
+ it('renders a disabled action item when action.disabled is set', () => {
225
+ render(
226
+ <RowActionsMenu
227
+ row={row}
228
+ actions={[{ label: 'Edit', onClick: () => {}, disabled: true }]}
229
+ />,
230
+ );
231
+ fireEvent.click(screen.getByRole('button', { name: 'Row actions' }));
232
+ const item = screen.getByRole('menuitem', { name: 'Edit' }) as HTMLButtonElement;
233
+ expect(item.disabled).toBe(true);
234
+ });
235
+
236
+ it('uses a custom trigger when provided', () => {
237
+ render(
238
+ <RowActionsMenu
239
+ row={row}
240
+ actions={[{ label: 'Edit', onClick: () => {} }]}
241
+ trigger={<button type="button">custom-trigger</button>}
242
+ />,
243
+ );
244
+ expect(screen.getByRole('button', { name: 'custom-trigger' })).not.toBeNull();
245
+ expect(screen.queryByRole('button', { name: 'Row actions' })).toBeNull();
246
+ });
247
+ });
@@ -258,7 +258,14 @@ export type TableRowSelectionMode = 'none' | 'single' | 'multiple';
258
258
  */
259
259
  export interface TableLabels {
260
260
  searchPlaceholder?: string;
261
+ /** Shown when the dataset itself is empty (0 input rows). */
261
262
  noData?: string;
263
+ /**
264
+ * Shown when the dataset has rows but an active search / filter
265
+ * excluded them all. Distinct from `noData` so the user can tell
266
+ * "nothing here" from "nothing matches your query".
267
+ */
268
+ noResults?: string;
262
269
  loading?: string;
263
270
  ariaSortNone?: string;
264
271
  ariaSortAscending?: string;
@@ -291,8 +298,8 @@ export interface TableLabels {
291
298
  /** Column visibility menu (Popover-based, auto-commit). */
292
299
  columnsButton?: string;
293
300
  columnsTitle?: string;
294
- columnsShowAll?: string;
295
- columnsHideAll?: string;
301
+ /** Aria-label for the tri-state show/hide-all master checkbox. */
302
+ columnsToggleAll?: string;
296
303
  }
297
304
 
298
305
  // ───── Slot props ─────
@@ -353,7 +360,25 @@ export interface TableProps<T extends object>
353
360
  // ───── Data ─────
354
361
  rows: T[];
355
362
  cols: TableColumn<T>[];
356
- /** Stable row id resolver. Default: positional index. */
363
+ /**
364
+ * Stable row-id resolver — the React key + the identity used by
365
+ * selection and row-expansion state.
366
+ *
367
+ * **Strongly recommended whenever sort / search / selection /
368
+ * expandable rows are used.** The default falls back to the
369
+ * positional index (`String(index)`); once rows reorder (sort) or
370
+ * change presence (search / filter), index `2` points at a
371
+ * *different* row, so selection and expansion state silently jump
372
+ * to the wrong rows. Pass a value derived from the data itself:
373
+ *
374
+ * ```tsx
375
+ * <Table getRowId={(row) => row.id} … />
376
+ * ```
377
+ *
378
+ * Dashforge emits a `console.warn` in development when `getRowId`
379
+ * is omitted and one of those features is active. (`<DataGrid>`
380
+ * makes this prop required for the same reason.)
381
+ */
357
382
  getRowId?: (row: T, index: number) => string;
358
383
 
359
384
  // ───── Sort ─────
@@ -84,6 +84,21 @@ describe('<Typography>', () => {
84
84
  const { container } = render(<Typography color="inherit">x</Typography>);
85
85
  expect(container.firstElementChild?.className).toContain('text-inherit');
86
86
  });
87
+
88
+ it('defaults to color="neutral" (text-neutral-900, NOT text-inherit)', () => {
89
+ // Sprint 6 P5 fix — a bare <Typography> must emit the
90
+ // auto-inverting neutral token, not inherit the consumer's
91
+ // arbitrary root colour (which broke dark-mode consistency).
92
+ const { container } = render(<Typography>x</Typography>);
93
+ const cls = container.firstElementChild?.className ?? '';
94
+ expect(cls).toContain('text-neutral-900');
95
+ expect(cls).not.toContain('text-inherit');
96
+ });
97
+
98
+ it('color="muted" emits text-neutral-600', () => {
99
+ const { container } = render(<Typography color="muted">x</Typography>);
100
+ expect(container.firstElementChild?.className).toContain('text-neutral-600');
101
+ });
87
102
  });
88
103
 
89
104
  // ─── Override semantics ─────────────────────────────────────────────
@@ -55,6 +55,15 @@ export const typographyVariants = tv({
55
55
  * paints white text).
56
56
  */
57
57
  color: {
58
+ // `inherit` is the explicit escape hatch — use it on a
59
+ // `<Typography>` inside a coloured surface (e.g.
60
+ // `<Box variant="solid" color="primary">` paints white text)
61
+ // so the text picks up the surface colour. It is NO LONGER the
62
+ // default: a bare `<Typography>` would otherwise inherit the
63
+ // consuming app's arbitrary root colour (often OS-`prefers-
64
+ // color-scheme` driven), which diverges from the Dashforge
65
+ // `data-dash-tw-theme` mode and breaks colour consistency in
66
+ // dark mode. The default is now `neutral` — see `defaultVariants`.
58
67
  inherit: 'text-inherit',
59
68
  primary: 'text-primary-700 dark:text-primary-400',
60
69
  secondary: 'text-secondary-700 dark:text-secondary-400',
@@ -109,7 +118,11 @@ export const typographyVariants = tv({
109
118
 
110
119
  defaultVariants: {
111
120
  variant: 'body1',
112
- color: 'inherit',
121
+ // `neutral` (not `inherit`): a bare `<Typography>` emits
122
+ // `text-neutral-900`, which auto-inverts via the dashforgePreset()
123
+ // CSS-var swap and follows the Dashforge mode. Pass
124
+ // `color="inherit"` explicitly inside a coloured surface.
125
+ color: 'neutral',
113
126
  align: 'left',
114
127
  },
115
128
  });
package/src/index.ts CHANGED
@@ -108,6 +108,26 @@ export type {
108
108
  } from './components/DateTimePicker/dateTimePicker.types.js';
109
109
  export { dateTimePickerVariants } from './components/DateTimePicker/dateTimePicker.variants.js';
110
110
 
111
+ // Custom calendar suite — Calendar primitive + bridge-integrated DatePicker,
112
+ // both built on the shared headless `@dashforge/calendar-core` engine.
113
+
114
+ export { Calendar } from './components/Calendar/Calendar.js';
115
+ export type {
116
+ CalendarProps,
117
+ CalendarSlotProps,
118
+ } from './components/Calendar/calendar.types.js';
119
+ export {
120
+ calendarVariants,
121
+ calendarDayVariants,
122
+ } from './components/Calendar/calendar.variants.js';
123
+
124
+ export { DatePicker } from './components/DatePicker/DatePicker.js';
125
+ export type {
126
+ DatePickerProps,
127
+ DatePickerSlotProps,
128
+ } from './components/DatePicker/datePicker.types.js';
129
+ export { datePickerVariants } from './components/DatePicker/datePicker.variants.js';
130
+
111
131
  // F6 layout: router-agnostic navigation primitives.
112
132
 
113
133
  export { Breadcrumbs } from './components/Breadcrumbs/Breadcrumbs.js';
@@ -353,4 +373,4 @@ export type { VariantProps } from 'tailwind-variants';
353
373
  /**
354
374
  * Package version (synced with `package.json` at publish time).
355
375
  */
356
- export const VERSION = '0.7.0-beta';
376
+ export const VERSION = '0.9.1-beta';