@dashforge/tw 0.5.0-beta → 0.6.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.
- package/CHANGELOG.md +268 -0
- package/dist/index.esm.js +1553 -4
- package/dist/src/components/Table/Table.d.ts +24 -0
- package/dist/src/components/Table/Table.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/getNestedValue.d.ts +20 -0
- package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts +45 -0
- package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useControllableState.d.ts +23 -0
- package/dist/src/components/Table/_internal/useControllableState.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useDebouncedValue.d.ts +10 -0
- package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableFilter.d.ts +13 -0
- package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSearch.d.ts +23 -0
- package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSelection.d.ts +25 -0
- package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSort.d.ts +20 -0
- package/dist/src/components/Table/_internal/useTableSort.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
- package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
- package/dist/src/components/Table/cells/index.d.ts +11 -0
- package/dist/src/components/Table/cells/index.d.ts.map +1 -0
- package/dist/src/components/Table/table.types.d.ts +304 -0
- package/dist/src/components/Table/table.types.d.ts.map +1 -0
- package/dist/src/components/Table/table.variants.d.ts +285 -0
- package/dist/src/components/Table/table.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Table/Table.test.tsx +603 -0
- package/src/components/Table/Table.tsx +816 -0
- package/src/components/Table/_internal/getNestedValue.test.ts +50 -0
- package/src/components/Table/_internal/getNestedValue.ts +32 -0
- package/src/components/Table/_internal/themeIdentity.test.ts +84 -0
- package/src/components/Table/_internal/useColumnAutoDetect.test.ts +123 -0
- package/src/components/Table/_internal/useColumnAutoDetect.ts +100 -0
- package/src/components/Table/_internal/useControllableState.ts +48 -0
- package/src/components/Table/_internal/useDebouncedValue.ts +24 -0
- package/src/components/Table/_internal/useTableFilter.ts +37 -0
- package/src/components/Table/_internal/useTableSearch.test.ts +115 -0
- package/src/components/Table/_internal/useTableSearch.ts +66 -0
- package/src/components/Table/_internal/useTableSelection.test.ts +100 -0
- package/src/components/Table/_internal/useTableSelection.ts +100 -0
- package/src/components/Table/_internal/useTableSort.test.ts +150 -0
- package/src/components/Table/_internal/useTableSort.ts +141 -0
- package/src/components/Table/cells/RenderButton.tsx +23 -0
- package/src/components/Table/cells/RenderChip.tsx +103 -0
- package/src/components/Table/cells/RenderText.tsx +37 -0
- package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
- package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
- package/src/components/Table/cells/index.ts +14 -0
- package/src/components/Table/table.types.ts +337 -0
- package/src/components/Table/table.variants.ts +207 -0
- package/src/index.ts +43 -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,84 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Theme-identity regression test.
|
|
7
|
+
*
|
|
8
|
+
* Enforces the Dashforge rule (memory: `feedback_dashforge_preset_is_identity`):
|
|
9
|
+
* the `@dashforge/tw-theme` default preset auto-inverts the neutral
|
|
10
|
+
* palette via a CSS-variable swap. Adding `dark:` Tailwind variants
|
|
11
|
+
* on `neutral-*` classes creates a DOUBLE inversion that breaks
|
|
12
|
+
* dark mode (both classes resolve to the dark value).
|
|
13
|
+
*
|
|
14
|
+
* This test scans Table source files for the anti-pattern and fails
|
|
15
|
+
* if any are introduced. Color palettes (`primary`, `secondary`,
|
|
16
|
+
* `success`, `warning`, `danger`, `info`) are EXEMPT — they do not
|
|
17
|
+
* auto-invert, so `dark:` variants on them are a legitimate design
|
|
18
|
+
* choice (e.g. RenderChip color rows).
|
|
19
|
+
*/
|
|
20
|
+
const TABLE_DIR = join(__dirname, '..');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Match `dark:<modifier>?:bg-neutral-X` / `text-neutral-X` /
|
|
24
|
+
* `border-neutral-X` / `placeholder:text-neutral-X` / `ring-neutral-X` etc.
|
|
25
|
+
*
|
|
26
|
+
* The `(?:[a-z-]+:)*` chunk eats arbitrary intermediate variants
|
|
27
|
+
* (e.g. `dark:hover:bg-neutral-...`, `dark:placeholder:text-neutral-...`).
|
|
28
|
+
*/
|
|
29
|
+
const NEUTRAL_DARK_RE =
|
|
30
|
+
/dark:(?:[a-z-]+:)*(?:bg|text|border|ring|placeholder:text|focus-visible:bg|hover:bg|hover:text|focus:bg)?-?neutral-\d+/;
|
|
31
|
+
|
|
32
|
+
function walkSourceFiles(dir: string): string[] {
|
|
33
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const full = join(dir, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
out.push(...walkSourceFiles(full));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!entry.isFile()) continue;
|
|
42
|
+
if (entry.name.endsWith('.test.ts')) continue;
|
|
43
|
+
if (entry.name.endsWith('.test.tsx')) continue;
|
|
44
|
+
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
|
|
45
|
+
out.push(full);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('Table — theme identity rule (no `dark:` on neutral palette)', () => {
|
|
51
|
+
const files = walkSourceFiles(TABLE_DIR).filter(
|
|
52
|
+
// The test file itself contains the regex example — exclude.
|
|
53
|
+
(f) => !f.endsWith('themeIdentity.test.ts'),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
it('finds at least the expected source files', () => {
|
|
57
|
+
expect(files.length).toBeGreaterThan(5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const rel = file.replace(TABLE_DIR + '/', '');
|
|
62
|
+
it(`${rel} — contains no \`dark:*-neutral-N\` classes`, () => {
|
|
63
|
+
const source = readFileSync(file, 'utf-8');
|
|
64
|
+
// Strip line comments to avoid false positives in docstrings
|
|
65
|
+
const stripped = source
|
|
66
|
+
.replace(/\/\/.*$/gm, '')
|
|
67
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
68
|
+
const match = stripped.match(NEUTRAL_DARK_RE);
|
|
69
|
+
if (match) {
|
|
70
|
+
// Surface the offending excerpt to make the failure actionable.
|
|
71
|
+
const idx = stripped.indexOf(match[0]);
|
|
72
|
+
const context = stripped.slice(Math.max(0, idx - 60), idx + match[0].length + 60);
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Found \`dark:\`-on-neutral anti-pattern in ${rel}:\n ...${context}...\n` +
|
|
75
|
+
`Rule: dashforgePreset() auto-inverts neutral palette via CSS-var swap. ` +
|
|
76
|
+
`Adding \`dark:bg-neutral-N\` (etc.) creates double inversion and breaks dark mode.\n` +
|
|
77
|
+
`Fix: drop the \`dark:\` variant — the same class works in both modes.\n` +
|
|
78
|
+
`See memory: \`feedback_dashforge_preset_is_identity\`.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
expect(match).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
@@ -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.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.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.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.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.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
|
+
}
|