@dashforge/tw 0.7.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 (37) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/dist/index.esm.js +1092 -74
  3. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
  4. package/dist/src/components/DataGrid/dataGrid.types.d.ts +43 -0
  5. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +6 -0
  7. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -1
  8. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
  9. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
  11. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
  12. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
  13. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
  14. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
  15. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
  16. package/dist/src/components/Table/Table.d.ts.map +1 -1
  17. package/dist/src/components/Table/table.types.d.ts +69 -5
  18. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  19. package/dist/src/components/_shared/data/useTableFilter.d.ts +30 -7
  20. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -1
  21. package/dist/src/index.d.ts +1 -1
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/components/DataGrid/DataGrid.test.tsx +422 -1
  25. package/src/components/DataGrid/DataGrid.tsx +421 -53
  26. package/src/components/DataGrid/dataGrid.types.ts +50 -1
  27. package/src/components/DataGrid/dataGrid.variants.ts +9 -1
  28. package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
  29. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
  30. package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
  31. package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
  32. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
  33. package/src/components/Table/Table.tsx +14 -0
  34. package/src/components/Table/table.types.ts +74 -5
  35. package/src/components/_shared/data/useTableFilter.test.ts +166 -0
  36. package/src/components/_shared/data/useTableFilter.ts +97 -16
  37. package/src/index.ts +2 -0
@@ -0,0 +1,106 @@
1
+ import { useCallback, useRef, type PointerEvent as RPointerEvent } from 'react';
2
+
3
+ /**
4
+ * Native column-resize behavior for DataGrid (and Table later if
5
+ * promoted to `_shared`).
6
+ *
7
+ * The returned `startResize` is wired to a `pointerdown` handler on a
8
+ * thin grabber positioned at the right edge of a `<th>`. We capture
9
+ * the pointer on the target element so `pointermove` / `pointerup`
10
+ * keep flowing even if the cursor leaves the grabber.
11
+ *
12
+ * Why native pointer events vs a drag library:
13
+ * - Zero new runtime deps (sprint constraint)
14
+ * - Pointer events normalize mouse/touch/pen out of the box
15
+ * - Pointer capture means we don't have to track the global
16
+ * `mousemove` / `mouseup` — the browser routes events to our
17
+ * listener until release.
18
+ */
19
+
20
+ export interface UseColumnResizeArgs {
21
+ /** Current column widths (Record<field, px>). */
22
+ widths: Record<string, number>;
23
+ /** Commit a new width map. */
24
+ onChange: (next: Record<string, number>) => void;
25
+ /** Hard floor for any column (px). @default 40 */
26
+ minWidth?: number;
27
+ /** Hard ceiling for any column (px). @default 1200 */
28
+ maxWidth?: number;
29
+ }
30
+
31
+ export interface ColumnResizeHandlers {
32
+ startResize: (
33
+ field: string,
34
+ startWidth: number,
35
+ clampMin: number,
36
+ clampMax: number,
37
+ ) => (e: RPointerEvent<HTMLElement>) => void;
38
+ }
39
+
40
+ export function useColumnResize(args: UseColumnResizeArgs): ColumnResizeHandlers {
41
+ const { widths, onChange, minWidth = 40, maxWidth = 1200 } = args;
42
+
43
+ // We need the LATEST widths inside the pointermove closure. Stick
44
+ // them in a ref so the closure captured at pointerdown stays valid
45
+ // for the duration of the drag.
46
+ const widthsRef = useRef(widths);
47
+ widthsRef.current = widths;
48
+
49
+ const startResize = useCallback(
50
+ (
51
+ field: string,
52
+ startWidth: number,
53
+ clampMin: number,
54
+ clampMax: number,
55
+ ) => {
56
+ return (e: RPointerEvent<HTMLElement>) => {
57
+ // Only respond to the primary mouse button (or touch / pen).
58
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
59
+
60
+ e.preventDefault();
61
+ e.stopPropagation();
62
+
63
+ const startX = e.clientX;
64
+ const target = e.currentTarget;
65
+ try {
66
+ target.setPointerCapture(e.pointerId);
67
+ } catch {
68
+ // jsdom or older browsers — fine to fall back to global listeners.
69
+ }
70
+
71
+ const effectiveMin = Math.max(minWidth, clampMin);
72
+ const effectiveMax = Math.min(maxWidth, clampMax);
73
+
74
+ const handleMove = (ev: PointerEvent) => {
75
+ const delta = ev.clientX - startX;
76
+ const next = clamp(startWidth + delta, effectiveMin, effectiveMax);
77
+ onChange({ ...widthsRef.current, [field]: next });
78
+ };
79
+
80
+ const handleEnd = (ev: PointerEvent) => {
81
+ try {
82
+ target.releasePointerCapture(ev.pointerId);
83
+ } catch {
84
+ /* noop */
85
+ }
86
+ target.removeEventListener('pointermove', handleMove);
87
+ target.removeEventListener('pointerup', handleEnd);
88
+ target.removeEventListener('pointercancel', handleEnd);
89
+ };
90
+
91
+ target.addEventListener('pointermove', handleMove);
92
+ target.addEventListener('pointerup', handleEnd);
93
+ target.addEventListener('pointercancel', handleEnd);
94
+ };
95
+ },
96
+ [onChange, minWidth, maxWidth],
97
+ );
98
+
99
+ return { startResize };
100
+ }
101
+
102
+ function clamp(n: number, min: number, max: number): number {
103
+ if (n < min) return min;
104
+ if (n > max) return max;
105
+ return n;
106
+ }
@@ -0,0 +1,200 @@
1
+ import { useState, useEffect, type ReactNode } from 'react';
2
+ import { Dialog } from '../../Dialog/Dialog.js';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import type { TableColumn } from '../../Table/table.types.js';
5
+
6
+ /**
7
+ * Column visibility dialog for DataGrid.
8
+ *
9
+ * Renders a toolbar button (the trigger) that opens a `<Dialog>` with
10
+ * one checkbox per `hideable` column. The consumer can:
11
+ * - Show/hide individual columns
12
+ * - "Show all" / "Hide all" shortcuts
13
+ * - Persist visibility via the controllable `hiddenColumns` model
14
+ *
15
+ * Columns with `hideable: false` are NEVER shown in the dialog —
16
+ * those are considered structurally required (e.g. an ID column).
17
+ *
18
+ * RBAC-hidden columns (`access.onUnauthorized === 'hide'`) are also
19
+ * excluded — they're already removed from `visibleCols` upstream.
20
+ */
21
+
22
+ export interface ColumnVisibilityLabels {
23
+ columnsButton?: string;
24
+ columnsTitle?: string;
25
+ columnsDescription?: string;
26
+ columnsShowAll?: string;
27
+ columnsHideAll?: string;
28
+ columnsDone?: string;
29
+ }
30
+
31
+ const DEFAULT_VISIBILITY_LABELS: Required<ColumnVisibilityLabels> = {
32
+ columnsButton: 'Columns',
33
+ columnsTitle: 'Manage columns',
34
+ columnsDescription: 'Show or hide columns. Required columns cannot be toggled.',
35
+ columnsShowAll: 'Show all',
36
+ columnsHideAll: 'Hide all',
37
+ columnsDone: 'Done',
38
+ };
39
+
40
+ export interface ColumnVisibilityTriggerProps<T extends object> {
41
+ cols: TableColumn<T>[];
42
+ hiddenColumns: string[];
43
+ onChange: (next: string[]) => void;
44
+ disabled?: boolean;
45
+ labels?: ColumnVisibilityLabels;
46
+ }
47
+
48
+ export function ColumnVisibilityTrigger<T extends object>(
49
+ props: ColumnVisibilityTriggerProps<T>,
50
+ ) {
51
+ const { cols, hiddenColumns, onChange, disabled, labels: labelsProp } = props;
52
+ const labels = { ...DEFAULT_VISIBILITY_LABELS, ...labelsProp };
53
+ const [open, setOpen] = useState(false);
54
+
55
+ // Hideable cols are the only ones the user can toggle. `hideable`
56
+ // defaults to `true` — a column is hideable unless explicitly opted
57
+ // out with `hideable: false` (structurally required column).
58
+ const hideableCols = cols.filter((c) => c.hideable !== false);
59
+
60
+ return (
61
+ <>
62
+ <button
63
+ type="button"
64
+ onClick={() => setOpen(true)}
65
+ disabled={disabled}
66
+ className={cn(
67
+ 'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md',
68
+ 'text-sm text-neutral-700 hover:text-neutral-900',
69
+ 'border border-neutral-300 bg-neutral-50 hover:bg-neutral-100',
70
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
71
+ 'transition-colors motion-reduce:transition-none',
72
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
73
+ )}
74
+ >
75
+ <ColumnsIcon />
76
+ <span>{labels.columnsButton}</span>
77
+ </button>
78
+ <Dialog
79
+ open={open}
80
+ onOpenChange={setOpen}
81
+ title={labels.columnsTitle}
82
+ description={labels.columnsDescription}
83
+ size="sm"
84
+ >
85
+ <ColumnVisibilityBody
86
+ cols={hideableCols}
87
+ hiddenColumns={hiddenColumns}
88
+ onChange={onChange}
89
+ onDone={() => setOpen(false)}
90
+ labels={labels}
91
+ />
92
+ </Dialog>
93
+ </>
94
+ );
95
+ }
96
+
97
+ // ───── Dialog body ─────
98
+
99
+ function ColumnVisibilityBody<T extends object>(props: {
100
+ cols: TableColumn<T>[];
101
+ hiddenColumns: string[];
102
+ onChange: (next: string[]) => void;
103
+ onDone: () => void;
104
+ labels: Required<ColumnVisibilityLabels>;
105
+ }) {
106
+ const { cols, hiddenColumns, onChange, onDone, labels } = props;
107
+ // Local draft so the user can stage changes; commit on Done.
108
+ const [draft, setDraft] = useState<string[]>(hiddenColumns);
109
+
110
+ // Re-sync when the dialog re-opens with a different external state.
111
+ useEffect(() => {
112
+ setDraft(hiddenColumns);
113
+ }, [hiddenColumns]);
114
+
115
+ const toggle = (field: string) => {
116
+ setDraft((prev) =>
117
+ prev.includes(field) ? prev.filter((f) => f !== field) : [...prev, field],
118
+ );
119
+ };
120
+
121
+ const showAll = () => setDraft([]);
122
+ const hideAll = () => setDraft(cols.map((c) => c.field as string));
123
+
124
+ return (
125
+ <div className="flex flex-col gap-3">
126
+ <div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
127
+ <button
128
+ type="button"
129
+ onClick={showAll}
130
+ className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
131
+ >
132
+ {labels.columnsShowAll}
133
+ </button>
134
+ <span className="text-neutral-300">·</span>
135
+ <button
136
+ type="button"
137
+ onClick={hideAll}
138
+ className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
139
+ >
140
+ {labels.columnsHideAll}
141
+ </button>
142
+ </div>
143
+ <ul className="flex flex-col gap-1.5 max-h-72 overflow-auto">
144
+ {cols.map((col) => {
145
+ const field = col.field as string;
146
+ const hidden = draft.includes(field);
147
+ const header =
148
+ typeof col.header === 'function' ? col.header() : col.header;
149
+ return (
150
+ <li key={field}>
151
+ <label className="flex items-center gap-2 cursor-pointer py-1 px-1 rounded hover:bg-neutral-100">
152
+ <input
153
+ type="checkbox"
154
+ checked={!hidden}
155
+ onChange={() => toggle(field)}
156
+ className="cursor-pointer"
157
+ />
158
+ <span className="text-sm text-neutral-900">{header}</span>
159
+ </label>
160
+ </li>
161
+ );
162
+ })}
163
+ </ul>
164
+ <div className="flex justify-end gap-2 pt-2 border-t border-neutral-200">
165
+ <button
166
+ type="button"
167
+ onClick={() => {
168
+ onChange(draft);
169
+ onDone();
170
+ }}
171
+ className="text-sm bg-primary-500 text-white rounded-md hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 px-3 py-1.5"
172
+ >
173
+ {labels.columnsDone}
174
+ </button>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ // ───── Icon ─────
181
+
182
+ function ColumnsIcon(): ReactNode {
183
+ return (
184
+ <svg
185
+ width="0.875em"
186
+ height="0.875em"
187
+ viewBox="0 0 16 16"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ strokeWidth="1.5"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ aria-hidden="true"
194
+ >
195
+ <rect x="2" y="3" width="3.5" height="10" rx="0.5" />
196
+ <rect x="6.25" y="3" width="3.5" height="10" rx="0.5" />
197
+ <rect x="10.5" y="3" width="3.5" height="10" rx="0.5" />
198
+ </svg>
199
+ );
200
+ }
@@ -40,10 +40,24 @@ const DEFAULT_LABELS: Required<TableLabels> = {
40
40
  filterColumn: 'Filter',
41
41
  filterApply: 'Apply',
42
42
  filterClear: 'Clear',
43
+ filterMin: 'Min',
44
+ filterMax: 'Max',
45
+ filterFrom: 'From',
46
+ filterTo: 'To',
47
+ filterAll: 'All',
48
+ filterTrue: 'True',
49
+ filterFalse: 'False',
43
50
  density: 'Density',
44
51
  densityCompact: 'Compact',
45
52
  densityComfortable: 'Comfortable',
46
53
  densitySpacious: 'Spacious',
54
+ columnsButton: 'Columns',
55
+ columnsTitle: 'Manage columns',
56
+ columnsDescription:
57
+ 'Show or hide columns. Required columns cannot be toggled.',
58
+ columnsShowAll: 'Show all',
59
+ columnsHideAll: 'Hide all',
60
+ columnsDone: 'Done',
47
61
  };
48
62
 
49
63
  const DEFAULT_GET_ROW_ID = <T extends object>(_row: T, index: number) => String(index);
@@ -131,11 +131,20 @@ export interface TableColumn<T extends object> {
131
131
  searchable?: boolean;
132
132
 
133
133
  /**
134
- * Enable per-column quick filter (text-based in v1).
134
+ * Enable per-column quick filter UI in the column header.
135
135
  * @default false
136
136
  */
137
137
  filterable?: boolean;
138
138
 
139
+ /**
140
+ * Override the filter UI type. By default the type is auto-detected
141
+ * from `useColumnAutoDetect` (number → range, date → range,
142
+ * boolean → select, anything else → text contains). Force a
143
+ * specific UI when the autodetect picks the wrong one — e.g. an
144
+ * ID column stored as a number that should filter by text contains.
145
+ */
146
+ filterType?: TableFilterType;
147
+
139
148
  /**
140
149
  * Custom cell renderer. Receives the full row + the resolved
141
150
  * (potentially nested) value, the row index, selection state,
@@ -160,9 +169,32 @@ export interface TableColumn<T extends object> {
160
169
  */
161
170
  sticky?: 'left' | 'right';
162
171
 
163
- /** Reserved for v1-bis (column-visibility dialog). */
172
+ /**
173
+ * Allow user to hide this column from the column-visibility dialog.
174
+ * Pass `false` for structurally required columns (e.g. an ID column).
175
+ * @default true
176
+ */
164
177
  hideable?: boolean;
178
+ /** Hidden on first render (the user can re-show via the dialog). */
165
179
  defaultHidden?: boolean;
180
+
181
+ /**
182
+ * Allow user to resize this column by dragging the right edge of
183
+ * its header. Pass `false` to opt out per-column. The grid-level
184
+ * `enableColumnResize` flag must also be on (default `true`).
185
+ * @default true
186
+ */
187
+ resizable?: boolean;
188
+ /** Maximum width (px) honored by the resize drag. */
189
+ maxWidth?: number;
190
+
191
+ /**
192
+ * Allow user to reorder this column by dragging its header. Pass
193
+ * `false` to pin its position. The grid-level `enableColumnReorder`
194
+ * flag must also be on (default `true`).
195
+ * @default true
196
+ */
197
+ reorderable?: boolean;
166
198
  }
167
199
 
168
200
  // ───── State models ─────
@@ -181,15 +213,36 @@ export interface TableSortItem {
181
213
  */
182
214
  export type TableSortModel = TableSortItem[];
183
215
 
216
+ /**
217
+ * Filter operator. The value shape depends on the operator:
218
+ * - `contains` → `value: string` (text substring match)
219
+ * - `equals` → `value: unknown` (exact match, used for booleans)
220
+ * - `between` → `value: [min, max]` where each end can be `null`
221
+ * for an open range. Used for number ranges (`value: [10, 50]`)
222
+ * and date ranges (`value: ['2024-01-01', '2024-12-31']` as ISO).
223
+ */
224
+ export type TableFilterOperator = 'contains' | 'equals' | 'between';
225
+
184
226
  export interface TableFilterItem {
185
227
  field: string;
186
- /** Text substring match. Future ops: `equals`, `in`, `gte`, `lte`, `between`. */
187
- op: 'contains';
188
- value: string;
228
+ op: TableFilterOperator;
229
+ value: unknown;
189
230
  }
190
231
 
191
232
  export type TableFilterModel = TableFilterItem[];
192
233
 
234
+ /**
235
+ * Column-level filter type hint. Determines which filter UI to
236
+ * render in the column header Popover. Auto-detected from
237
+ * `useColumnAutoDetect` when omitted:
238
+ *
239
+ * - `number` → number range filter
240
+ * - `date` → date range filter
241
+ * - `boolean` → true/false/all select
242
+ * - `string` (or anything else) → text contains
243
+ */
244
+ export type TableFilterType = 'text' | 'number' | 'boolean' | 'date';
245
+
193
246
  export type TableRowSelectionMode = 'none' | 'single' | 'multiple';
194
247
 
195
248
  // ───── i18n labels ─────
@@ -221,11 +274,27 @@ export interface TableLabels {
221
274
  filterColumn?: string;
222
275
  filterApply?: string;
223
276
  filterClear?: string;
277
+ /** Number / date range filter inputs. */
278
+ filterMin?: string;
279
+ filterMax?: string;
280
+ filterFrom?: string;
281
+ filterTo?: string;
282
+ /** Boolean filter radio options. */
283
+ filterAll?: string;
284
+ filterTrue?: string;
285
+ filterFalse?: string;
224
286
  /** Density toggle. */
225
287
  density?: string;
226
288
  densityCompact?: string;
227
289
  densityComfortable?: string;
228
290
  densitySpacious?: string;
291
+ /** Column visibility dialog. */
292
+ columnsButton?: string;
293
+ columnsTitle?: string;
294
+ columnsDescription?: string;
295
+ columnsShowAll?: string;
296
+ columnsHideAll?: string;
297
+ columnsDone?: string;
229
298
  }
230
299
 
231
300
  // ───── Slot props ─────
@@ -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
+ });