@dashforge/tw 0.6.0-beta → 0.8.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 (107) hide show
  1. package/CHANGELOG.md +451 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2009 -87
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +214 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +222 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
  13. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
  14. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
  15. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
  16. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
  17. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
  18. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
  19. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
  20. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  21. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  22. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  23. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  24. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  25. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  26. package/dist/src/components/Table/Table.d.ts.map +1 -1
  27. package/dist/src/components/Table/table.types.d.ts +77 -5
  28. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  29. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  30. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  31. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  32. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  33. package/dist/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.d.ts +1 -1
  34. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  35. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  36. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  37. package/dist/src/components/_shared/data/useTableFilter.d.ts +36 -0
  38. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  39. package/dist/src/components/{Table/_internal → _shared/data}/useTableSearch.d.ts +1 -1
  40. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  41. package/dist/src/components/{Table/_internal → _shared/data}/useTableSelection.d.ts +1 -1
  42. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  43. package/dist/src/components/{Table/_internal → _shared/data}/useTableSort.d.ts +1 -1
  44. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  46. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  47. package/dist/src/index.d.ts +6 -3
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/components/Accordion/accordion.variants.ts +5 -3
  51. package/src/components/Box/Box.test.tsx +32 -11
  52. package/src/components/Box/box.variants.ts +23 -4
  53. package/src/components/DataGrid/DataGrid.test.tsx +917 -0
  54. package/src/components/DataGrid/DataGrid.tsx +1199 -0
  55. package/src/components/DataGrid/dataGrid.types.ts +238 -0
  56. package/src/components/DataGrid/dataGrid.variants.ts +152 -0
  57. package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
  58. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
  59. package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
  60. package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
  61. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
  62. package/src/components/Dialog/dialog.variants.ts +10 -6
  63. package/src/components/Divider/Divider.test.tsx +3 -1
  64. package/src/components/Divider/Divider.tsx +1 -1
  65. package/src/components/Divider/divider.variants.ts +2 -1
  66. package/src/components/Pagination/Pagination.tsx +3 -1
  67. package/src/components/Pagination/pagination.variants.ts +19 -13
  68. package/src/components/Popover/popover.variants.ts +10 -4
  69. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  70. package/src/components/Table/Table.tsx +22 -8
  71. package/src/components/Table/table.types.ts +83 -5
  72. package/src/components/Tabs/tabs.variants.ts +19 -8
  73. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  74. package/src/components/Typography/Typography.test.tsx +3 -1
  75. package/src/components/Typography/typography.variants.ts +6 -2
  76. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.test.ts +1 -1
  77. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.ts +1 -1
  78. package/src/components/_shared/data/useTableFilter.test.ts +166 -0
  79. package/src/components/_shared/data/useTableFilter.ts +118 -0
  80. package/src/components/{Table/_internal → _shared/data}/useTableSearch.test.ts +1 -1
  81. package/src/components/{Table/_internal → _shared/data}/useTableSearch.ts +1 -1
  82. package/src/components/{Table/_internal → _shared/data}/useTableSelection.ts +1 -1
  83. package/src/components/{Table/_internal → _shared/data}/useTableSort.test.ts +1 -1
  84. package/src/components/{Table/_internal → _shared/data}/useTableSort.ts +1 -1
  85. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  86. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  87. package/src/components/_shared/themeIdentity.test.ts +113 -0
  88. package/src/index.ts +20 -2
  89. package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +0 -1
  90. package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +0 -1
  91. package/dist/src/components/Table/_internal/useControllableState.d.ts.map +0 -1
  92. package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +0 -1
  93. package/dist/src/components/Table/_internal/useTableFilter.d.ts +0 -13
  94. package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +0 -1
  95. package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +0 -1
  96. package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +0 -1
  97. package/dist/src/components/Table/_internal/useTableSort.d.ts.map +0 -1
  98. package/src/components/Table/_internal/themeIdentity.test.ts +0 -84
  99. package/src/components/Table/_internal/useTableFilter.ts +0 -37
  100. /package/dist/src/components/{Table/_internal → _shared/data}/getNestedValue.d.ts +0 -0
  101. /package/dist/src/components/{Table/_internal → _shared/data}/useControllableState.d.ts +0 -0
  102. /package/dist/src/components/{Table/_internal → _shared/data}/useDebouncedValue.d.ts +0 -0
  103. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.test.ts +0 -0
  104. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.ts +0 -0
  105. /package/src/components/{Table/_internal → _shared/data}/useControllableState.ts +0 -0
  106. /package/src/components/{Table/_internal → _shared/data}/useDebouncedValue.ts +0 -0
  107. /package/src/components/{Table/_internal → _shared/data}/useTableSelection.test.ts +0 -0
@@ -19,21 +19,32 @@ export const tabsVariants = tv({
19
19
  variants: {
20
20
  variant: {
21
21
  underline: {
22
- list: 'border-b border-neutral-200 dark:border-neutral-800',
22
+ // Neutral palette auto-inverts via CSS-var swap.
23
+ list: 'border-b border-neutral-200',
23
24
  trigger: [
24
- 'px-3 py-2 border-b-2 border-transparent text-neutral-600 dark:text-neutral-400',
25
- 'hover:text-neutral-900 dark:hover:text-neutral-50',
25
+ 'px-3 py-2 border-b-2 border-transparent text-neutral-600',
26
+ 'hover:text-neutral-900',
27
+ // Primary palette does NOT auto-invert — `dark:` shift is
28
+ // an intentional tone refinement (kept).
26
29
  'data-[state=active]:border-primary-500 data-[state=active]:text-primary-700',
27
30
  'dark:data-[state=active]:text-primary-400',
28
31
  ],
29
32
  },
30
33
  pill: {
31
- list: 'rounded-lg bg-neutral-100 dark:bg-neutral-800 p-1',
34
+ // Pill list bg uses the elevation tier — auto-inverts.
35
+ list: 'rounded-lg bg-neutral-100 p-1',
32
36
  trigger: [
33
- 'px-3 py-1.5 rounded-md text-neutral-600 dark:text-neutral-400',
34
- 'hover:text-neutral-900 dark:hover:text-neutral-50',
35
- 'data-[state=active]:bg-white dark:data-[state=active]:bg-neutral-900',
36
- 'data-[state=active]:text-neutral-900 dark:data-[state=active]:text-neutral-50',
37
+ 'px-3 py-1.5 rounded-md text-neutral-600',
38
+ 'hover:text-neutral-900',
39
+ // Active pill is one tier above the list bg
40
+ // (`bg-neutral-100`-inverted = #171717 in dark).
41
+ // `bg-white` is hardcoded so a `dark:` is required —
42
+ // target `dark:bg-neutral-200` (= #262626 in dark) sits
43
+ // ONE TIER above the list bg, preserving the elevation
44
+ // semantic from light mode (where `bg-white` sits above
45
+ // `bg-neutral-100` = #f5f5f5).
46
+ 'data-[state=active]:bg-white dark:data-[state=active]:bg-neutral-200',
47
+ 'data-[state=active]:text-neutral-900',
37
48
  'data-[state=active]:shadow-sm',
38
49
  ],
39
50
  },
@@ -4,14 +4,24 @@ export const tooltipVariants = tv({
4
4
  slots: {
5
5
  content: [
6
6
  'z-50 max-w-xs rounded-md px-3 py-1.5 text-xs font-medium',
7
+ // Auto-invert via the dashforgePreset() CSS-var swap:
8
+ // - Light mode: `bg-neutral-900` = #171717 (dark surface) +
9
+ // `text-neutral-50` = #fafafa (light text) — DARK tooltip
10
+ // on the light page (high contrast).
11
+ // - Dark mode: `bg-neutral-900` flips to #f5f5f5 (light) +
12
+ // `text-neutral-50` flips to #0a0a0a (dark text) — LIGHT
13
+ // tooltip on the dark page (high contrast against page).
14
+ // Adding `dark:bg-neutral-50 dark:text-neutral-900` (the old
15
+ // pattern) would force the dark-mode tooltip back to a dark
16
+ // surface — invisible against the dark page.
7
17
  'bg-neutral-900 text-neutral-50',
8
- 'dark:bg-neutral-50 dark:text-neutral-900',
9
18
  'shadow-md',
10
19
  'data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95',
11
20
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
12
21
  'motion-reduce:transition-none motion-reduce:duration-0',
13
22
  ],
14
- arrow: 'fill-neutral-900 dark:fill-neutral-50',
23
+ // Arrow tracks the content bg — auto-inverts the same way.
24
+ arrow: 'fill-neutral-900',
15
25
  },
16
26
  });
17
27
 
@@ -219,8 +219,10 @@ describe('<Typography>', () => {
219
219
  const { container } = render(<Typography color={color}>x</Typography>);
220
220
  const cls = container.firstElementChild?.className ?? '';
221
221
  if (color === 'muted') {
222
+ // Sprint 4.3 identity sweep: neutral palette auto-inverts via
223
+ // CSS-var swap — no `dark:` variant needed.
222
224
  expect(cls).toContain('text-neutral-600');
223
- expect(cls).toContain('dark:text-neutral-400');
225
+ expect(cls).not.toContain('dark:text-neutral-');
224
226
  } else {
225
227
  expect(cls).toContain(`text-${color}-700`);
226
228
  expect(cls).toContain(`dark:text-${color}-400`);
@@ -62,8 +62,12 @@ export const typographyVariants = tv({
62
62
  warning: 'text-warning-700 dark:text-warning-400',
63
63
  danger: 'text-danger-700 dark:text-danger-400',
64
64
  info: 'text-info-700 dark:text-info-400',
65
- neutral: 'text-neutral-900 dark:text-neutral-100',
66
- muted: 'text-neutral-600 dark:text-neutral-400',
65
+ // Neutral palette auto-inverts via the dashforgePreset() CSS-var
66
+ // swap — no `dark:` variants. `text-neutral-900` resolves to
67
+ // dark text in light mode and light text in dark mode; same
68
+ // applies to `text-neutral-600` (muted).
69
+ neutral: 'text-neutral-900',
70
+ muted: 'text-neutral-600',
67
71
  },
68
72
 
69
73
  /*
@@ -7,7 +7,7 @@ import {
7
7
  resolveMonospace,
8
8
  resolveTabularNums,
9
9
  } from './useColumnAutoDetect.js';
10
- import type { TableColumn } from '../table.types.js';
10
+ import type { TableColumn } from '../../Table/table.types.js';
11
11
 
12
12
  interface Row {
13
13
  name: string;
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import { getNestedValue } from './getNestedValue.js';
3
- import type { TableColumn, TableColumnInferredType } from '../table.types.js';
3
+ import type { TableColumn, TableColumnInferredType } from '../../Table/table.types.js';
4
4
 
5
5
  /**
6
6
  * Auto-detects the primitive type of each column by scanning the
@@ -0,0 +1,166 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest';
3
+ import { passesFilter, toNumber, toDateMs } from './useTableFilter.js';
4
+
5
+ describe('useTableFilter — toNumber', () => {
6
+ it('returns null for null and undefined', () => {
7
+ expect(toNumber(null)).toBeNull();
8
+ expect(toNumber(undefined)).toBeNull();
9
+ });
10
+
11
+ it('returns the value for finite numbers', () => {
12
+ expect(toNumber(42)).toBe(42);
13
+ expect(toNumber(0)).toBe(0);
14
+ expect(toNumber(-3.14)).toBe(-3.14);
15
+ });
16
+
17
+ it('returns null for non-finite numbers', () => {
18
+ expect(toNumber(Number.NaN)).toBeNull();
19
+ expect(toNumber(Number.POSITIVE_INFINITY)).toBeNull();
20
+ });
21
+
22
+ it('coerces numeric strings', () => {
23
+ expect(toNumber('42')).toBe(42);
24
+ expect(toNumber('0.5')).toBe(0.5);
25
+ });
26
+
27
+ it('returns null for non-numeric strings', () => {
28
+ expect(toNumber('abc')).toBeNull();
29
+ });
30
+
31
+ // JS quirk: Number('') === 0 (finite). Empty-string is treated as 0
32
+ // by toNumber. Callers that need open-range semantics (e.g. the
33
+ // NumberRangeFilter UI) explicitly null-out empty inputs before
34
+ // passing them through.
35
+ it('returns 0 for empty string (JS Number("") === 0)', () => {
36
+ expect(toNumber('')).toBe(0);
37
+ });
38
+
39
+ it('coerces bigint to number', () => {
40
+ expect(toNumber(10n)).toBe(10);
41
+ });
42
+
43
+ it('returns null for objects/arrays/booleans', () => {
44
+ expect(toNumber({})).toBeNull();
45
+ expect(toNumber([])).toBeNull();
46
+ expect(toNumber(true)).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe('useTableFilter — toDateMs', () => {
51
+ it('returns null for null and undefined', () => {
52
+ expect(toDateMs(null)).toBeNull();
53
+ expect(toDateMs(undefined)).toBeNull();
54
+ });
55
+
56
+ it('returns epoch ms for Date instances', () => {
57
+ const d = new Date('2026-05-19T00:00:00Z');
58
+ expect(toDateMs(d)).toBe(d.getTime());
59
+ });
60
+
61
+ it('parses ISO strings', () => {
62
+ const ms = toDateMs('2026-05-19');
63
+ expect(ms).not.toBeNull();
64
+ expect(new Date(ms!).toISOString()).toContain('2026-05-19');
65
+ });
66
+
67
+ it('returns null for invalid date strings', () => {
68
+ expect(toDateMs('not-a-date')).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe('useTableFilter — passesFilter (contains)', () => {
73
+ it('returns true for empty needle (no-op filter)', () => {
74
+ expect(passesFilter('Hello', 'contains', '')).toBe(true);
75
+ expect(passesFilter('Hello', 'contains', ' ')).toBe(true);
76
+ });
77
+
78
+ it('matches substring case-insensitively', () => {
79
+ expect(passesFilter('Hello World', 'contains', 'hello')).toBe(true);
80
+ expect(passesFilter('Hello World', 'contains', 'WORLD')).toBe(true);
81
+ expect(passesFilter('Hello World', 'contains', 'xyz')).toBe(false);
82
+ });
83
+
84
+ it('stringifies non-string row values for searching', () => {
85
+ expect(passesFilter(42, 'contains', '42')).toBe(true);
86
+ expect(passesFilter(true, 'contains', 'true')).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe('useTableFilter — passesFilter (equals)', () => {
91
+ it('returns true when filter value is null (no-op)', () => {
92
+ expect(passesFilter('anything', 'equals', null)).toBe(true);
93
+ });
94
+
95
+ it('does strict equality for booleans', () => {
96
+ expect(passesFilter(true, 'equals', true)).toBe(true);
97
+ expect(passesFilter(false, 'equals', false)).toBe(true);
98
+ expect(passesFilter(true, 'equals', false)).toBe(false);
99
+ });
100
+
101
+ it('does strict equality for primitives', () => {
102
+ expect(passesFilter('a', 'equals', 'a')).toBe(true);
103
+ expect(passesFilter('a', 'equals', 'b')).toBe(false);
104
+ expect(passesFilter(1, 'equals', 1)).toBe(true);
105
+ });
106
+ });
107
+
108
+ describe('useTableFilter — passesFilter (between, number range)', () => {
109
+ it('returns true when range is fully open', () => {
110
+ expect(passesFilter(42, 'between', [null, null])).toBe(true);
111
+ });
112
+
113
+ it('respects min bound', () => {
114
+ expect(passesFilter(5, 'between', [10, null])).toBe(false);
115
+ expect(passesFilter(10, 'between', [10, null])).toBe(true);
116
+ expect(passesFilter(15, 'between', [10, null])).toBe(true);
117
+ });
118
+
119
+ it('respects max bound', () => {
120
+ expect(passesFilter(5, 'between', [null, 10])).toBe(true);
121
+ expect(passesFilter(10, 'between', [null, 10])).toBe(true);
122
+ expect(passesFilter(15, 'between', [null, 10])).toBe(false);
123
+ });
124
+
125
+ it('respects both bounds inclusively', () => {
126
+ expect(passesFilter(7, 'between', [5, 10])).toBe(true);
127
+ expect(passesFilter(5, 'between', [5, 10])).toBe(true);
128
+ expect(passesFilter(10, 'between', [5, 10])).toBe(true);
129
+ expect(passesFilter(4, 'between', [5, 10])).toBe(false);
130
+ expect(passesFilter(11, 'between', [5, 10])).toBe(false);
131
+ });
132
+
133
+ it('coerces string row values to number', () => {
134
+ expect(passesFilter('7', 'between', [5, 10])).toBe(true);
135
+ });
136
+
137
+ it('returns true for malformed filter value (defensive)', () => {
138
+ expect(passesFilter(42, 'between', 'not a tuple')).toBe(true);
139
+ expect(passesFilter(42, 'between', [1])).toBe(true);
140
+ });
141
+ });
142
+
143
+ describe('useTableFilter — passesFilter (between, date range)', () => {
144
+ it('respects ISO date range bounds', () => {
145
+ const row = '2026-05-19';
146
+ expect(passesFilter(row, 'between', ['2026-01-01', '2026-12-31'])).toBe(true);
147
+ expect(passesFilter(row, 'between', ['2026-06-01', '2026-12-31'])).toBe(false);
148
+ expect(passesFilter(row, 'between', ['2026-01-01', '2026-05-01'])).toBe(false);
149
+ });
150
+
151
+ it('accepts Date instances as row values', () => {
152
+ const row = new Date('2026-05-19T00:00:00Z');
153
+ expect(passesFilter(row, 'between', ['2026-01-01', '2026-12-31'])).toBe(true);
154
+ });
155
+
156
+ it('treats null bounds as open ended', () => {
157
+ expect(passesFilter('2026-05-19', 'between', [null, '2026-12-31'])).toBe(true);
158
+ expect(passesFilter('2026-05-19', 'between', ['2026-01-01', null])).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe('useTableFilter — passesFilter (unknown operator)', () => {
163
+ it('returns true for unrecognized operator (defensive no-op)', () => {
164
+ expect(passesFilter('anything', 'unknownOp', 'anything')).toBe(true);
165
+ });
166
+ });
@@ -0,0 +1,118 @@
1
+ import { useMemo } from 'react';
2
+ import { getNestedValue } from './getNestedValue.js';
3
+ import { stringifyForSearch } from './useTableSearch.js';
4
+ import type { TableFilterModel } from '../../Table/table.types.js';
5
+
6
+ /**
7
+ * Coerce a value to a number for range comparison. Returns
8
+ * `null` if not coercible.
9
+ */
10
+ function toNumber(value: unknown): number | null {
11
+ if (value == null) return null;
12
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
13
+ if (typeof value === 'bigint') return Number(value);
14
+ if (typeof value === 'string') {
15
+ const n = Number(value);
16
+ return Number.isFinite(n) ? n : null;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * Coerce a value to a Date's epoch ms for date-range comparison.
23
+ * Accepts `Date` instances + ISO 8601 strings. Returns `null` if
24
+ * not coercible.
25
+ */
26
+ function toDateMs(value: unknown): number | null {
27
+ if (value == null) return null;
28
+ if (value instanceof Date) return value.getTime();
29
+ if (typeof value === 'string') {
30
+ const d = new Date(value);
31
+ const t = d.getTime();
32
+ return Number.isNaN(t) ? null : t;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Test one row × one filter. Returns `true` if the row passes the
39
+ * filter. The operator decides the predicate:
40
+ *
41
+ * - `contains` → substring match on stringified value (text filter)
42
+ * - `equals` → strict equality (boolean filter mostly)
43
+ * - `between` → `value: [min, max]` tuple. Each end can be `null`
44
+ * for an open range. Used by number range + date range filters.
45
+ *
46
+ * Empty filter is a no-op (passes every row).
47
+ */
48
+ function passesFilter(rowValue: unknown, op: string, filterValue: unknown): boolean {
49
+ switch (op) {
50
+ case 'contains': {
51
+ const needle =
52
+ typeof filterValue === 'string' ? filterValue.trim().toLowerCase() : '';
53
+ if (!needle) return true;
54
+ return stringifyForSearch(rowValue).toLowerCase().includes(needle);
55
+ }
56
+ case 'equals': {
57
+ if (filterValue == null) return true;
58
+ return rowValue === filterValue;
59
+ }
60
+ case 'between': {
61
+ if (!Array.isArray(filterValue) || filterValue.length !== 2) return true;
62
+ const [min, max] = filterValue as [unknown, unknown];
63
+ if (min == null && max == null) return true;
64
+
65
+ // Try number range first.
66
+ const rowNum = toNumber(rowValue);
67
+ const minNum = min == null ? null : toNumber(min);
68
+ const maxNum = max == null ? null : toNumber(max);
69
+ if (rowNum != null && (minNum != null || maxNum != null)) {
70
+ if (minNum != null && rowNum < minNum) return false;
71
+ if (maxNum != null && rowNum > maxNum) return false;
72
+ return true;
73
+ }
74
+
75
+ // Fall through to date range.
76
+ const rowMs = toDateMs(rowValue);
77
+ const minMs = min == null ? null : toDateMs(min);
78
+ const maxMs = max == null ? null : toDateMs(max);
79
+ if (rowMs != null && (minMs != null || maxMs != null)) {
80
+ if (minMs != null && rowMs < minMs) return false;
81
+ if (maxMs != null && rowMs > maxMs) return false;
82
+ return true;
83
+ }
84
+
85
+ // If neither coercion worked, let the row pass (defensive).
86
+ return true;
87
+ }
88
+ default:
89
+ return true;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Filters `rows` by the per-column filter model. AND semantics
95
+ * across columns — a row must pass EVERY active filter.
96
+ *
97
+ * Each filter item specifies an `op` (`'contains'` / `'equals'` /
98
+ * `'between'`) and a `value` whose shape matches the operator
99
+ * (string for contains; primitive for equals; `[min, max]` tuple
100
+ * for between).
101
+ */
102
+ export function useTableFilter<T extends object>(
103
+ rows: T[],
104
+ filterModel: TableFilterModel,
105
+ ): T[] {
106
+ return useMemo(() => {
107
+ if (!filterModel || filterModel.length === 0) return rows;
108
+ return rows.filter((row) =>
109
+ filterModel.every((filter) => {
110
+ const value = getNestedValue(row, filter.field);
111
+ return passesFilter(value, filter.op, filter.value);
112
+ }),
113
+ );
114
+ }, [rows, filterModel]);
115
+ }
116
+
117
+ // Exposed for testing.
118
+ export { passesFilter, toNumber, toDateMs };
@@ -2,7 +2,7 @@
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import { renderHook } from '@testing-library/react';
4
4
  import { useTableSearch, stringifyForSearch } from './useTableSearch.js';
5
- import type { TableColumn } from '../table.types.js';
5
+ import type { TableColumn } from '../../Table/table.types.js';
6
6
 
7
7
  interface User {
8
8
  name: string;
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import { getNestedValue } from './getNestedValue.js';
3
- import type { TableColumn } from '../table.types.js';
3
+ import type { TableColumn } from '../../Table/table.types.js';
4
4
 
5
5
  /**
6
6
  * Stringify any value into a search-comparable form. Handles all
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo } from 'react';
2
- import type { TableRowSelectionMode } from '../table.types.js';
2
+ import type { TableRowSelectionMode } from '../../Table/table.types.js';
3
3
 
4
4
  export interface UseTableSelectionResult {
5
5
  selectedRowIds: string[];
@@ -2,7 +2,7 @@
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import { renderHook } from '@testing-library/react';
4
4
  import { useTableSort, cycleSortFor } from './useTableSort.js';
5
- import type { TableColumn, TableSortModel } from '../table.types.js';
5
+ import type { TableColumn, TableSortModel } from '../../Table/table.types.js';
6
6
 
7
7
  interface Row {
8
8
  id: string;
@@ -4,7 +4,7 @@ import type {
4
4
  TableColumn,
5
5
  TableSortModel,
6
6
  TableSortDirection,
7
- } from '../table.types.js';
7
+ } from '../../Table/table.types.js';
8
8
 
9
9
  /**
10
10
  * Default comparator. Handles the common primitive types in a
@@ -0,0 +1,158 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { computeWindow } from './useVirtualizer.js';
4
+
5
+ describe('computeWindow', () => {
6
+ it('returns empty window when totalCount is 0', () => {
7
+ const w = computeWindow({
8
+ totalCount: 0,
9
+ rowHeight: 48,
10
+ viewportHeight: 600,
11
+ scrollTop: 0,
12
+ });
13
+ expect(w.endIndex).toBeLessThan(w.startIndex);
14
+ expect(w.paddingTop).toBe(0);
15
+ expect(w.paddingBottom).toBe(0);
16
+ });
17
+
18
+ it('returns first window when scrollTop=0 (with overscan)', () => {
19
+ const w = computeWindow({
20
+ totalCount: 1000,
21
+ rowHeight: 48,
22
+ viewportHeight: 600,
23
+ scrollTop: 0,
24
+ overscan: 5,
25
+ });
26
+ // viewport fits 600 / 48 = 12.5 → 13 rows + 5 overscan top + 5 overscan bottom
27
+ expect(w.startIndex).toBe(0);
28
+ // 13 visible + 5 overscan_bottom = end at 17 ish
29
+ expect(w.endIndex).toBeGreaterThanOrEqual(12);
30
+ expect(w.paddingTop).toBe(0);
31
+ expect(w.paddingBottom).toBeGreaterThan(0);
32
+ });
33
+
34
+ it('scrolls forward — start index advances with scrollTop', () => {
35
+ const w = computeWindow({
36
+ totalCount: 1000,
37
+ rowHeight: 48,
38
+ viewportHeight: 600,
39
+ scrollTop: 480, // 10 rows down
40
+ overscan: 0,
41
+ });
42
+ expect(w.startIndex).toBe(10);
43
+ expect(w.paddingTop).toBe(480);
44
+ });
45
+
46
+ it('overscan extends start index upward', () => {
47
+ const w = computeWindow({
48
+ totalCount: 1000,
49
+ rowHeight: 48,
50
+ viewportHeight: 600,
51
+ scrollTop: 480,
52
+ overscan: 5,
53
+ });
54
+ expect(w.startIndex).toBe(5);
55
+ expect(w.paddingTop).toBe(5 * 48);
56
+ });
57
+
58
+ it('overscan extends end index downward', () => {
59
+ const w = computeWindow({
60
+ totalCount: 1000,
61
+ rowHeight: 48,
62
+ viewportHeight: 600,
63
+ scrollTop: 0,
64
+ overscan: 5,
65
+ });
66
+ // raw end was ceil(600/48) = 13. with overscan: 13 + 5 = 18.
67
+ expect(w.endIndex).toBe(18);
68
+ });
69
+
70
+ it('clamps start at 0 when overscan would go negative', () => {
71
+ const w = computeWindow({
72
+ totalCount: 1000,
73
+ rowHeight: 48,
74
+ viewportHeight: 600,
75
+ scrollTop: 0,
76
+ overscan: 10,
77
+ });
78
+ expect(w.startIndex).toBe(0);
79
+ expect(w.paddingTop).toBe(0);
80
+ });
81
+
82
+ it('clamps end at totalCount-1 when overscan would exceed', () => {
83
+ // scroll near the end
84
+ const w = computeWindow({
85
+ totalCount: 20,
86
+ rowHeight: 48,
87
+ viewportHeight: 600,
88
+ scrollTop: 480, // would try to show rows 10-22
89
+ overscan: 5,
90
+ });
91
+ expect(w.endIndex).toBe(19); // clamped
92
+ expect(w.paddingBottom).toBe(0);
93
+ });
94
+
95
+ it('paddingTop + visible rows + paddingBottom = total scrollable height', () => {
96
+ const totalCount = 1000;
97
+ const rowHeight = 48;
98
+ const w = computeWindow({
99
+ totalCount,
100
+ rowHeight,
101
+ viewportHeight: 600,
102
+ scrollTop: 5000,
103
+ overscan: 3,
104
+ });
105
+ const visibleRowCount = w.endIndex - w.startIndex + 1;
106
+ const totalRendered = w.paddingTop + visibleRowCount * rowHeight + w.paddingBottom;
107
+ const totalExpected = totalCount * rowHeight;
108
+ expect(totalRendered).toBe(totalExpected);
109
+ });
110
+
111
+ it('handles small dataset (everything fits in viewport)', () => {
112
+ const w = computeWindow({
113
+ totalCount: 5,
114
+ rowHeight: 48,
115
+ viewportHeight: 600,
116
+ scrollTop: 0,
117
+ overscan: 5,
118
+ });
119
+ expect(w.startIndex).toBe(0);
120
+ expect(w.endIndex).toBe(4); // clamped to totalCount - 1
121
+ expect(w.paddingTop).toBe(0);
122
+ expect(w.paddingBottom).toBe(0);
123
+ });
124
+
125
+ it('returns empty window when rowHeight = 0 (defensive)', () => {
126
+ const w = computeWindow({
127
+ totalCount: 1000,
128
+ rowHeight: 0,
129
+ viewportHeight: 600,
130
+ scrollTop: 100,
131
+ });
132
+ expect(w.endIndex).toBeLessThan(w.startIndex);
133
+ });
134
+
135
+ it('returns empty window when viewportHeight = 0', () => {
136
+ const w = computeWindow({
137
+ totalCount: 1000,
138
+ rowHeight: 48,
139
+ viewportHeight: 0,
140
+ scrollTop: 100,
141
+ });
142
+ expect(w.endIndex).toBeLessThan(w.startIndex);
143
+ });
144
+
145
+ it('supports very large dataset (1 million rows)', () => {
146
+ const w = computeWindow({
147
+ totalCount: 1_000_000,
148
+ rowHeight: 48,
149
+ viewportHeight: 600,
150
+ scrollTop: 24_000_000, // ~middle
151
+ overscan: 5,
152
+ });
153
+ expect(w.startIndex).toBe(499_995);
154
+ expect(w.endIndex).toBeGreaterThan(w.startIndex);
155
+ expect(w.paddingTop).toBe(499_995 * 48);
156
+ expect(w.paddingBottom).toBeGreaterThan(0);
157
+ });
158
+ });