@dashforge/tw 0.5.0-beta → 0.7.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 (105) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2504 -51
  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 +171 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +216 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  13. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  14. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  15. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  16. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  17. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  18. package/dist/src/components/Table/Table.d.ts +24 -0
  19. package/dist/src/components/Table/Table.d.ts.map +1 -0
  20. package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
  21. package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
  22. package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
  23. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
  24. package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
  25. package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
  26. package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
  27. package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
  28. package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
  29. package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
  30. package/dist/src/components/Table/cells/index.d.ts +11 -0
  31. package/dist/src/components/Table/cells/index.d.ts.map +1 -0
  32. package/dist/src/components/Table/table.types.d.ts +312 -0
  33. package/dist/src/components/Table/table.types.d.ts.map +1 -0
  34. package/dist/src/components/Table/table.variants.d.ts +285 -0
  35. package/dist/src/components/Table/table.variants.d.ts.map +1 -0
  36. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  37. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  38. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  39. package/dist/src/components/_shared/data/getNestedValue.d.ts +20 -0
  40. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  41. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts +45 -0
  42. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  43. package/dist/src/components/_shared/data/useControllableState.d.ts +23 -0
  44. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useDebouncedValue.d.ts +10 -0
  46. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  47. package/dist/src/components/_shared/data/useTableFilter.d.ts +13 -0
  48. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  49. package/dist/src/components/_shared/data/useTableSearch.d.ts +23 -0
  50. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  51. package/dist/src/components/_shared/data/useTableSelection.d.ts +25 -0
  52. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  53. package/dist/src/components/_shared/data/useTableSort.d.ts +20 -0
  54. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  55. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  56. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  57. package/dist/src/index.d.ts +10 -1
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/package.json +3 -3
  60. package/src/components/Accordion/accordion.variants.ts +5 -3
  61. package/src/components/Box/Box.test.tsx +32 -11
  62. package/src/components/Box/box.variants.ts +23 -4
  63. package/src/components/DataGrid/DataGrid.test.tsx +496 -0
  64. package/src/components/DataGrid/DataGrid.tsx +831 -0
  65. package/src/components/DataGrid/dataGrid.types.ts +189 -0
  66. package/src/components/DataGrid/dataGrid.variants.ts +144 -0
  67. package/src/components/Dialog/dialog.variants.ts +10 -6
  68. package/src/components/Divider/Divider.test.tsx +3 -1
  69. package/src/components/Divider/Divider.tsx +1 -1
  70. package/src/components/Divider/divider.variants.ts +2 -1
  71. package/src/components/Pagination/Pagination.tsx +3 -1
  72. package/src/components/Pagination/pagination.variants.ts +19 -13
  73. package/src/components/Popover/popover.variants.ts +10 -4
  74. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  75. package/src/components/Table/Table.test.tsx +603 -0
  76. package/src/components/Table/Table.tsx +816 -0
  77. package/src/components/Table/cells/RenderButton.tsx +23 -0
  78. package/src/components/Table/cells/RenderChip.tsx +103 -0
  79. package/src/components/Table/cells/RenderText.tsx +37 -0
  80. package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
  81. package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
  82. package/src/components/Table/cells/index.ts +14 -0
  83. package/src/components/Table/table.types.ts +346 -0
  84. package/src/components/Table/table.variants.ts +207 -0
  85. package/src/components/Tabs/tabs.variants.ts +19 -8
  86. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  87. package/src/components/Typography/Typography.test.tsx +3 -1
  88. package/src/components/Typography/typography.variants.ts +6 -2
  89. package/src/components/_shared/data/getNestedValue.test.ts +50 -0
  90. package/src/components/_shared/data/getNestedValue.ts +32 -0
  91. package/src/components/_shared/data/useColumnAutoDetect.test.ts +123 -0
  92. package/src/components/_shared/data/useColumnAutoDetect.ts +100 -0
  93. package/src/components/_shared/data/useControllableState.ts +48 -0
  94. package/src/components/_shared/data/useDebouncedValue.ts +24 -0
  95. package/src/components/_shared/data/useTableFilter.ts +37 -0
  96. package/src/components/_shared/data/useTableSearch.test.ts +115 -0
  97. package/src/components/_shared/data/useTableSearch.ts +66 -0
  98. package/src/components/_shared/data/useTableSelection.test.ts +100 -0
  99. package/src/components/_shared/data/useTableSelection.ts +100 -0
  100. package/src/components/_shared/data/useTableSort.test.ts +150 -0
  101. package/src/components/_shared/data/useTableSort.ts +141 -0
  102. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  103. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  104. package/src/components/_shared/themeIdentity.test.ts +113 -0
  105. package/src/index.ts +59 -1
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getNestedValue } from './getNestedValue.js';
3
+
4
+ describe('getNestedValue', () => {
5
+ it('returns top-level value', () => {
6
+ expect(getNestedValue({ a: 1 }, 'a')).toBe(1);
7
+ });
8
+
9
+ it('returns nested value (1 level)', () => {
10
+ expect(getNestedValue({ user: { name: 'Jane' } }, 'user.name')).toBe('Jane');
11
+ });
12
+
13
+ it('returns nested value (3 levels)', () => {
14
+ expect(
15
+ getNestedValue({ a: { b: { c: { d: 42 } } } }, 'a.b.c.d'),
16
+ ).toBe(42);
17
+ });
18
+
19
+ it('returns undefined when path is empty', () => {
20
+ expect(getNestedValue({ a: 1 }, '')).toBeUndefined();
21
+ });
22
+
23
+ it('returns undefined when intermediate segment is null', () => {
24
+ expect(getNestedValue({ user: null as never }, 'user.name')).toBeUndefined();
25
+ });
26
+
27
+ it('returns undefined when intermediate segment is undefined', () => {
28
+ expect(getNestedValue({} as { a?: { b?: number } }, 'a.b')).toBeUndefined();
29
+ });
30
+
31
+ it('returns undefined when intermediate segment is a primitive', () => {
32
+ expect(getNestedValue({ a: 'hello' }, 'a.b')).toBeUndefined();
33
+ });
34
+
35
+ it('preserves zero value (not coerced to undefined)', () => {
36
+ expect(getNestedValue({ a: { b: 0 } }, 'a.b')).toBe(0);
37
+ });
38
+
39
+ it('preserves false value', () => {
40
+ expect(getNestedValue({ a: { b: false } }, 'a.b')).toBe(false);
41
+ });
42
+
43
+ it('preserves empty string value', () => {
44
+ expect(getNestedValue({ a: { b: '' } }, 'a.b')).toBe('');
45
+ });
46
+
47
+ it('returns the full sub-object when path stops mid-tree', () => {
48
+ expect(getNestedValue({ a: { b: { c: 1 } } }, 'a.b')).toEqual({ c: 1 });
49
+ });
50
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Walk a dotted path through an object and return the value, or
3
+ * `undefined` if any segment is missing / nullish.
4
+ *
5
+ * Bug fix from the MUI-side Table (Sprint 4.1 motivation): the
6
+ * existing wrapper typed `field: NestedKeyOf<T>` but performed a
7
+ * flat `row[col.field]` lookup at runtime — nested keys silently
8
+ * returned `undefined`. This helper makes the runtime match the
9
+ * type contract.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * getNestedValue({ user: { name: 'Jane' } }, 'user.name') // 'Jane'
14
+ * getNestedValue({ user: null }, 'user.name') // undefined
15
+ * getNestedValue({}, 'a.b.c') // undefined
16
+ * getNestedValue({ a: { b: 0 } }, 'a.b') // 0 (NOT undefined)
17
+ * ```
18
+ */
19
+ export function getNestedValue<T extends object>(
20
+ row: T,
21
+ path: string,
22
+ ): unknown {
23
+ if (!path) return undefined;
24
+ const segments = path.split('.');
25
+ let current: unknown = row;
26
+ for (const segment of segments) {
27
+ if (current == null) return undefined;
28
+ if (typeof current !== 'object') return undefined;
29
+ current = (current as Record<string, unknown>)[segment];
30
+ }
31
+ return current;
32
+ }
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { renderHook } from '@testing-library/react';
4
+ import {
5
+ useColumnAutoDetect,
6
+ resolveAlign,
7
+ resolveMonospace,
8
+ resolveTabularNums,
9
+ } from './useColumnAutoDetect.js';
10
+ import type { TableColumn } from '../../Table/table.types.js';
11
+
12
+ interface Row {
13
+ name: string;
14
+ age: number;
15
+ active: boolean;
16
+ joinedAt: Date;
17
+ isoDate: string;
18
+ meta: { count: number };
19
+ unknownField?: never;
20
+ }
21
+
22
+ const cols: TableColumn<Row>[] = [
23
+ { field: 'name', header: 'Name' },
24
+ { field: 'age', header: 'Age' },
25
+ { field: 'active', header: 'Active' },
26
+ { field: 'joinedAt' as never, header: 'JoinedAt' },
27
+ { field: 'isoDate' as never, header: 'ISO Date' },
28
+ { field: 'meta.count' as never, header: 'Count' },
29
+ { field: 'unknownField' as never, header: 'Unknown' },
30
+ ];
31
+
32
+ const rows: Row[] = [
33
+ {
34
+ name: 'Jane', age: 30, active: true,
35
+ joinedAt: new Date('2024-01-15'),
36
+ isoDate: '2024-01-15T00:00:00Z',
37
+ meta: { count: 5 },
38
+ },
39
+ ];
40
+
41
+ describe('useColumnAutoDetect', () => {
42
+ it('infers number / boolean / string / date / nested-number', () => {
43
+ const { result } = renderHook(() => useColumnAutoDetect(rows, cols));
44
+ expect(result.current.get('name')).toBe('string');
45
+ expect(result.current.get('age')).toBe('number');
46
+ expect(result.current.get('active')).toBe('boolean');
47
+ expect(result.current.get('joinedAt')).toBe('date');
48
+ expect(result.current.get('isoDate')).toBe('date');
49
+ expect(result.current.get('meta.count')).toBe('number');
50
+ expect(result.current.get('unknownField')).toBe('unknown');
51
+ });
52
+
53
+ it('returns unknown for empty rows array', () => {
54
+ const { result } = renderHook(() => useColumnAutoDetect([], cols));
55
+ for (const col of cols) {
56
+ expect(result.current.get(col.field as string)).toBe('unknown');
57
+ }
58
+ });
59
+
60
+ it('skips null values to find the first non-null sample', () => {
61
+ const colsAge: TableColumn<{ age: number | null }>[] = [{ field: 'age', header: 'Age' }];
62
+ const sparseRows = [{ age: null }, { age: null }, { age: 42 }];
63
+ const { result } = renderHook(() => useColumnAutoDetect(sparseRows, colsAge));
64
+ expect(result.current.get('age')).toBe('number');
65
+ });
66
+ });
67
+
68
+ describe('resolveAlign', () => {
69
+ it('explicit align wins', () => {
70
+ expect(resolveAlign({ align: 'center' }, 'number')).toBe('center');
71
+ });
72
+ it('number → right', () => {
73
+ expect(resolveAlign({}, 'number')).toBe('right');
74
+ });
75
+ it('boolean → center', () => {
76
+ expect(resolveAlign({}, 'boolean')).toBe('center');
77
+ });
78
+ it('string → left (default)', () => {
79
+ expect(resolveAlign({}, 'string')).toBe('left');
80
+ });
81
+ it('date → left', () => {
82
+ expect(resolveAlign({}, 'date')).toBe('left');
83
+ });
84
+ it('unknown → left', () => {
85
+ expect(resolveAlign({}, 'unknown')).toBe('left');
86
+ });
87
+ });
88
+
89
+ describe('resolveMonospace', () => {
90
+ // Library MUST NOT auto-pick a font family — it would override
91
+ // the consumer's theme font. Only explicit opt-in turns it on.
92
+ it('explicit monospace=true wins', () => {
93
+ expect(resolveMonospace({ monospace: true })).toBe(true);
94
+ });
95
+ it('explicit monospace=false → false', () => {
96
+ expect(resolveMonospace({ monospace: false })).toBe(false);
97
+ });
98
+ it('omitted → false (never auto-applies)', () => {
99
+ expect(resolveMonospace({})).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('resolveTabularNums', () => {
104
+ // tabular-nums is a font-feature-setting (digit alignment) — does
105
+ // NOT change the font family. Safe to auto-apply for number/date.
106
+ it('explicit tabularNums=true wins', () => {
107
+ expect(resolveTabularNums({ tabularNums: true }, 'string')).toBe(true);
108
+ });
109
+ it('explicit tabularNums=false wins (opt-out)', () => {
110
+ expect(resolveTabularNums({ tabularNums: false }, 'number')).toBe(false);
111
+ });
112
+ it('number → true (auto)', () => {
113
+ expect(resolveTabularNums({}, 'number')).toBe(true);
114
+ });
115
+ it('date → true (auto, ISO digits align)', () => {
116
+ expect(resolveTabularNums({}, 'date')).toBe(true);
117
+ });
118
+ it('string / boolean / unknown → false', () => {
119
+ expect(resolveTabularNums({}, 'string')).toBe(false);
120
+ expect(resolveTabularNums({}, 'boolean')).toBe(false);
121
+ expect(resolveTabularNums({}, 'unknown')).toBe(false);
122
+ });
123
+ });
@@ -0,0 +1,100 @@
1
+ import { useMemo } from 'react';
2
+ import { getNestedValue } from './getNestedValue.js';
3
+ import type { TableColumn, TableColumnInferredType } from '../../Table/table.types.js';
4
+
5
+ /**
6
+ * Auto-detects the primitive type of each column by scanning the
7
+ * first non-null value across the supplied rows. Drives smart
8
+ * defaults for `align` and `monospace` on the cell render path.
9
+ *
10
+ * Scans only the first N rows (default 50) for performance — if the
11
+ * dataset's first 50 rows are unrepresentative, the consumer can
12
+ * override per-column via explicit `align` / `monospace` props.
13
+ *
14
+ * Returns `Map<columnField, inferredType>`.
15
+ */
16
+ export function useColumnAutoDetect<T extends object>(
17
+ rows: T[],
18
+ cols: TableColumn<T>[],
19
+ sampleSize = 50,
20
+ ): Map<string, TableColumnInferredType> {
21
+ return useMemo(() => {
22
+ const result = new Map<string, TableColumnInferredType>();
23
+ const sample = rows.slice(0, sampleSize);
24
+ for (const col of cols) {
25
+ const field = col.field as string;
26
+ let inferred: TableColumnInferredType = 'unknown';
27
+ for (const row of sample) {
28
+ const value = getNestedValue(row, field);
29
+ if (value == null) continue;
30
+ inferred = inferType(value);
31
+ break; // first non-null wins
32
+ }
33
+ result.set(field, inferred);
34
+ }
35
+ return result;
36
+ }, [rows, cols, sampleSize]);
37
+ }
38
+
39
+ function inferType(value: unknown): TableColumnInferredType {
40
+ if (typeof value === 'number') return 'number';
41
+ if (typeof value === 'boolean') return 'boolean';
42
+ if (value instanceof Date) return 'date';
43
+ if (typeof value === 'string') {
44
+ // Heuristic: ISO date string (YYYY-MM-DD[T...]) → 'date'
45
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'date';
46
+ return 'string';
47
+ }
48
+ return 'unknown';
49
+ }
50
+
51
+ /**
52
+ * Resolves the effective `align` for a column, honoring explicit
53
+ * `col.align` first and falling back to the inferred type.
54
+ */
55
+ export function resolveAlign(
56
+ col: { align?: 'left' | 'right' | 'center' },
57
+ inferred: TableColumnInferredType | undefined,
58
+ ): 'left' | 'right' | 'center' {
59
+ if (col.align) return col.align;
60
+ switch (inferred) {
61
+ case 'number':
62
+ return 'right';
63
+ case 'boolean':
64
+ return 'center';
65
+ default:
66
+ return 'left';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolves the effective `tabularNums` flag for a column.
72
+ *
73
+ * `tabular-nums` is a `font-variant-numeric` rule that makes every
74
+ * digit occupy the same width — it does NOT change the font family.
75
+ * Auto-applied for `number` and `date` columns so currency values
76
+ * and ISO dates align cleanly. Explicit `col.tabularNums` wins.
77
+ */
78
+ export function resolveTabularNums(
79
+ col: { tabularNums?: boolean },
80
+ inferred: TableColumnInferredType | undefined,
81
+ ): boolean {
82
+ if (col.tabularNums !== undefined) return col.tabularNums;
83
+ return inferred === 'number' || inferred === 'date';
84
+ }
85
+
86
+ /**
87
+ * Resolves the effective `monospace` flag for a column.
88
+ *
89
+ * `font-mono` changes the font FAMILY to a monospace stack, which
90
+ * would override the consumer's theme `font-sans`. The library
91
+ * never auto-applies it — only an explicit `col.monospace: true`
92
+ * opts in. Consumers wanting a custom mono stack configure it
93
+ * through Tailwind's `theme.extend.fontFamily.mono` in their own
94
+ * preset (the dashforgePreset does not own the `fontFamily` axis).
95
+ */
96
+ export function resolveMonospace(
97
+ col: { monospace?: boolean },
98
+ ): boolean {
99
+ return col.monospace === true;
100
+ }
@@ -0,0 +1,48 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Generic "controllable" state hook — switches between controlled
5
+ * (via parent-supplied value + setter) and uncontrolled (internal
6
+ * `useState`).
7
+ *
8
+ * - If `controlledValue !== undefined`, the hook is in **controlled**
9
+ * mode: the internal state is ignored; the returned setter calls
10
+ * `onControlledChange` only.
11
+ * - Otherwise it's in **uncontrolled** mode: internal `useState`,
12
+ * defaulting to `defaultValue`. The optional `onControlledChange`
13
+ * is still fired on every change so the parent can react.
14
+ *
15
+ * The control mode is determined per-render. Mixing modes (passing
16
+ * `controlledValue` undefined sometimes) is a consumer bug — we don't
17
+ * try to "remember" the last mode (React's rules-of-hooks make that
18
+ * unsafe).
19
+ */
20
+ export function useControllableState<T>(args: {
21
+ controlledValue: T | undefined;
22
+ defaultValue: T;
23
+ onChange?: (value: T) => void;
24
+ }): [T, (next: T | ((prev: T) => T)) => void] {
25
+ const { controlledValue, defaultValue, onChange } = args;
26
+ const [uncontrolledValue, setUncontrolledValue] = useState<T>(defaultValue);
27
+ const isControlled = controlledValue !== undefined;
28
+ const value = isControlled ? (controlledValue as T) : uncontrolledValue;
29
+
30
+ // Stable ref to `onChange` so the setter doesn't change identity
31
+ // every render (consumer can pass an inline lambda safely).
32
+ const onChangeRef = useRef(onChange);
33
+ onChangeRef.current = onChange;
34
+
35
+ const setValue = useCallback(
36
+ (next: T | ((prev: T) => T)) => {
37
+ const resolved =
38
+ typeof next === 'function' ? (next as (prev: T) => T)(value) : next;
39
+ if (!isControlled) {
40
+ setUncontrolledValue(resolved);
41
+ }
42
+ onChangeRef.current?.(resolved);
43
+ },
44
+ [isControlled, value],
45
+ );
46
+
47
+ return [value, setValue];
48
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Returns a debounced version of `value` — only updates after `delayMs`
5
+ * of no further changes. Used by the search input so we don't re-filter
6
+ * the entire row set on every keystroke.
7
+ *
8
+ * `delayMs <= 0` short-circuits to the input value (no debounce —
9
+ * useful for tests).
10
+ */
11
+ export function useDebouncedValue<T>(value: T, delayMs: number): T {
12
+ const [debounced, setDebounced] = useState<T>(value);
13
+
14
+ useEffect(() => {
15
+ if (delayMs <= 0) {
16
+ setDebounced(value);
17
+ return;
18
+ }
19
+ const timer = setTimeout(() => setDebounced(value), delayMs);
20
+ return () => clearTimeout(timer);
21
+ }, [value, delayMs]);
22
+
23
+ return debounced;
24
+ }
@@ -0,0 +1,37 @@
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
+ * Filters `rows` by the per-column filter model. v1 only supports
8
+ * the `contains` operator (text substring) — additional ops
9
+ * (`equals`, `in`, `gte`, `lte`, `between`, `before`, `after`)
10
+ * land in v1-bis once the filter UI surface for each shape is
11
+ * fleshed out.
12
+ *
13
+ * AND semantics across columns: a row must match EVERY active
14
+ * filter. Within a single filter, the op decides the predicate.
15
+ */
16
+ export function useTableFilter<T extends object>(
17
+ rows: T[],
18
+ filterModel: TableFilterModel,
19
+ ): T[] {
20
+ return useMemo(() => {
21
+ if (!filterModel || filterModel.length === 0) return rows;
22
+ return rows.filter((row) =>
23
+ filterModel.every((filter) => {
24
+ const value = getNestedValue(row, filter.field);
25
+ const stringValue = stringifyForSearch(value).toLowerCase();
26
+ const needle = filter.value.trim().toLowerCase();
27
+ if (!needle) return true; // empty filter == no-op
28
+ switch (filter.op) {
29
+ case 'contains':
30
+ return stringValue.includes(needle);
31
+ default:
32
+ return true;
33
+ }
34
+ }),
35
+ );
36
+ }, [rows, filterModel]);
37
+ }
@@ -0,0 +1,115 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { renderHook } from '@testing-library/react';
4
+ import { useTableSearch, stringifyForSearch } from './useTableSearch.js';
5
+ import type { TableColumn } from '../../Table/table.types.js';
6
+
7
+ interface User {
8
+ name: string;
9
+ email: string;
10
+ age: number;
11
+ active: boolean;
12
+ joinedAt: Date;
13
+ meta: { city: string };
14
+ }
15
+
16
+ const cols: TableColumn<User>[] = [
17
+ { field: 'name', header: 'Name', searchable: true },
18
+ { field: 'email', header: 'Email', searchable: true },
19
+ { field: 'age', header: 'Age', searchable: true },
20
+ { field: 'active', header: 'Active', searchable: true },
21
+ { field: 'joinedAt' as never, header: 'Joined', searchable: true },
22
+ { field: 'meta.city' as never, header: 'City', searchable: true },
23
+ ];
24
+
25
+ const rows: User[] = [
26
+ {
27
+ name: 'Jane', email: 'jane@example.com', age: 30, active: true,
28
+ joinedAt: new Date('2024-01-15'), meta: { city: 'Roma' },
29
+ },
30
+ {
31
+ name: 'Bob', email: 'bob@example.org', age: 45, active: false,
32
+ joinedAt: new Date('2023-06-20'), meta: { city: 'Milano' },
33
+ },
34
+ ];
35
+
36
+ describe('stringifyForSearch', () => {
37
+ it('handles null/undefined', () => {
38
+ expect(stringifyForSearch(null)).toBe('');
39
+ expect(stringifyForSearch(undefined)).toBe('');
40
+ });
41
+ it('returns strings as-is', () => {
42
+ expect(stringifyForSearch('hello')).toBe('hello');
43
+ });
44
+ it('stringifies numbers', () => {
45
+ expect(stringifyForSearch(42)).toBe('42');
46
+ });
47
+ it('stringifies booleans', () => {
48
+ expect(stringifyForSearch(true)).toBe('true');
49
+ expect(stringifyForSearch(false)).toBe('false');
50
+ });
51
+ it('stringifies Date as ISO', () => {
52
+ expect(stringifyForSearch(new Date('2024-01-15T00:00:00.000Z'))).toBe(
53
+ '2024-01-15T00:00:00.000Z',
54
+ );
55
+ });
56
+ it('stringifies objects via JSON.stringify', () => {
57
+ expect(stringifyForSearch({ a: 1 })).toBe('{"a":1}');
58
+ });
59
+ it('stringifies arrays via join-space of stringified elements', () => {
60
+ expect(stringifyForSearch(['a', 'b', 1])).toBe('a b 1');
61
+ });
62
+ });
63
+
64
+ describe('useTableSearch', () => {
65
+ it('returns all rows when query is empty', () => {
66
+ const { result } = renderHook(() => useTableSearch(rows, cols, ''));
67
+ expect(result.current).toHaveLength(2);
68
+ });
69
+
70
+ it('returns all rows when query is only whitespace', () => {
71
+ const { result } = renderHook(() => useTableSearch(rows, cols, ' '));
72
+ expect(result.current).toHaveLength(2);
73
+ });
74
+
75
+ it('filters by string match (case-insensitive)', () => {
76
+ const { result } = renderHook(() => useTableSearch(rows, cols, 'jane'));
77
+ expect(result.current).toHaveLength(1);
78
+ expect(result.current[0]!.name).toBe('Jane');
79
+ });
80
+
81
+ it('filters by number coerced to string', () => {
82
+ const { result } = renderHook(() => useTableSearch(rows, cols, '45'));
83
+ expect(result.current).toHaveLength(1);
84
+ expect(result.current[0]!.age).toBe(45);
85
+ });
86
+
87
+ it('filters by boolean', () => {
88
+ const { result } = renderHook(() => useTableSearch(rows, cols, 'false'));
89
+ expect(result.current).toHaveLength(1);
90
+ expect(result.current[0]!.active).toBe(false);
91
+ });
92
+
93
+ it('filters by Date (ISO substring)', () => {
94
+ const { result } = renderHook(() => useTableSearch(rows, cols, '2024-01'));
95
+ expect(result.current).toHaveLength(1);
96
+ expect(result.current[0]!.name).toBe('Jane');
97
+ });
98
+
99
+ it('filters by NESTED key', () => {
100
+ const { result } = renderHook(() => useTableSearch(rows, cols, 'roma'));
101
+ expect(result.current).toHaveLength(1);
102
+ expect(result.current[0]!.name).toBe('Jane');
103
+ });
104
+
105
+ it('returns empty when nothing matches', () => {
106
+ const { result } = renderHook(() => useTableSearch(rows, cols, 'xxxxxxx'));
107
+ expect(result.current).toHaveLength(0);
108
+ });
109
+
110
+ it('returns all rows if no column is searchable', () => {
111
+ const noneSearchable: TableColumn<User>[] = cols.map((c) => ({ ...c, searchable: false }));
112
+ const { result } = renderHook(() => useTableSearch(rows, noneSearchable, 'jane'));
113
+ expect(result.current).toHaveLength(2);
114
+ });
115
+ });
@@ -0,0 +1,66 @@
1
+ import { useMemo } from 'react';
2
+ import { getNestedValue } from './getNestedValue.js';
3
+ import type { TableColumn } from '../../Table/table.types.js';
4
+
5
+ /**
6
+ * Stringify any value into a search-comparable form. Handles all
7
+ * the edge cases that bit the MUI-side Table:
8
+ *
9
+ * - `null` / `undefined` → `''`
10
+ * - `string` → as-is
11
+ * - `number` / `bigint` → `String(value)`
12
+ * - `boolean` → `'true'` / `'false'`
13
+ * - `Date` → ISO 8601
14
+ * - `Array` / object → `JSON.stringify` (best effort)
15
+ */
16
+ export function stringifyForSearch(value: unknown): string {
17
+ if (value == null) return '';
18
+ if (typeof value === 'string') return value;
19
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value);
20
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
21
+ if (value instanceof Date) return value.toISOString();
22
+ if (Array.isArray(value)) {
23
+ try {
24
+ return value.map(stringifyForSearch).join(' ');
25
+ } catch {
26
+ return '';
27
+ }
28
+ }
29
+ if (typeof value === 'object') {
30
+ try {
31
+ return JSON.stringify(value);
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+ return String(value);
37
+ }
38
+
39
+ /**
40
+ * Filters `rows` by `query` against the columns flagged
41
+ * `searchable: true`. Empty / whitespace-only query is a no-op
42
+ * (returns the original rows).
43
+ *
44
+ * Case-insensitive substring match. The caller is responsible for
45
+ * debouncing the input (use `useDebouncedValue`).
46
+ */
47
+ export function useTableSearch<T extends object>(
48
+ rows: T[],
49
+ cols: TableColumn<T>[],
50
+ query: string,
51
+ ): T[] {
52
+ return useMemo(() => {
53
+ const trimmed = query.trim();
54
+ if (!trimmed) return rows;
55
+ const needle = trimmed.toLowerCase();
56
+ const searchableCols = cols.filter((c) => c.searchable);
57
+ if (searchableCols.length === 0) return rows;
58
+ return rows.filter((row) =>
59
+ searchableCols.some((col) =>
60
+ stringifyForSearch(getNestedValue(row, col.field as string))
61
+ .toLowerCase()
62
+ .includes(needle),
63
+ ),
64
+ );
65
+ }, [rows, cols, query]);
66
+ }