@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.
- package/CHANGELOG.md +193 -0
- package/dist/index.esm.js +1092 -74
- package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -1
- package/dist/src/components/DataGrid/dataGrid.types.d.ts +43 -0
- package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -1
- package/dist/src/components/DataGrid/dataGrid.variants.d.ts +6 -0
- package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -1
- package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
- package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
- package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
- package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
- package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
- package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
- package/dist/src/components/Table/Table.d.ts.map +1 -1
- package/dist/src/components/Table/table.types.d.ts +69 -5
- package/dist/src/components/Table/table.types.d.ts.map +1 -1
- package/dist/src/components/_shared/data/useTableFilter.d.ts +30 -7
- package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/DataGrid/DataGrid.test.tsx +422 -1
- package/src/components/DataGrid/DataGrid.tsx +421 -53
- package/src/components/DataGrid/dataGrid.types.ts +50 -1
- package/src/components/DataGrid/dataGrid.variants.ts +9 -1
- package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
- package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
- package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
- package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
- package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
- package/src/components/Table/Table.tsx +14 -0
- package/src/components/Table/table.types.ts +74 -5
- package/src/components/_shared/data/useTableFilter.test.ts +166 -0
- package/src/components/_shared/data/useTableFilter.ts +97 -16
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
187
|
-
|
|
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
|
+
});
|