compote-ui 0.79.0 → 0.79.2
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/dist/components/data-table-v9/create-table.svelte.js +12 -1
- package/dist/components/data-table-v9/filter-revisions.svelte.d.ts +17 -0
- package/dist/components/data-table-v9/filter-revisions.svelte.js +42 -0
- package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte +77 -51
- package/dist/components/number-input/number-input.svelte +2 -6
- package/dist/utils/debounced-filter-edit.svelte.d.ts +16 -0
- package/dist/utils/debounced-filter-edit.svelte.js +56 -0
- package/dist/utils/number-input-value.d.ts +10 -0
- package/dist/utils/number-input-value.js +15 -0
- package/dist/utils/range-filter.d.ts +19 -0
- package/dist/utils/range-filter.js +23 -0
- package/dist/utils/select-filter.d.ts +4 -1
- package/dist/utils/select-filter.js +6 -3
- package/package.json +1 -1
- package/skills/component-usage/SKILL.md +1 -1
- package/skills/component-usage/references/forms-and-inputs.md +3 -0
- package/skills/data-table/SKILL.md +1 -1
- package/skills/data-table/references/table-state-and-reactivity.md +15 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createTable as createSvelteTable } from '@tanstack/svelte-table';
|
|
1
|
+
import { createTable as createSvelteTable, functionalUpdate } from '@tanstack/svelte-table';
|
|
2
2
|
import { useLocaleContext } from '@ark-ui/svelte/locale';
|
|
3
3
|
import { onDestroy } from 'svelte';
|
|
4
4
|
import { renderComponent, renderSnippet } from '@tanstack/svelte-table';
|
|
5
5
|
import { dataTableFeatures } from './features';
|
|
6
6
|
import { TYPE_NUMBER_FORMAT_DEFAULTS } from './data-table-utils';
|
|
7
|
+
import { changedFilterIds, FilterRevisions, registerFilterRevisions } from './filter-revisions.svelte';
|
|
7
8
|
import { formatSelectValue, matchesSelectFilter, selectCellValues } from '../../utils/select-filter';
|
|
8
9
|
const oneOfFilterFn = (row, columnId, filterValue) => {
|
|
9
10
|
return matchesSelectFilter(row.getValue(columnId), filterValue);
|
|
@@ -20,6 +21,7 @@ export function createTable(options) {
|
|
|
20
21
|
// memoization isn't invalidated on every access. Pass `columns` through a
|
|
21
22
|
// getter (like `data`) to make adding/removing/reordering columns reactive.
|
|
22
23
|
const columnDefs = $derived(createColumns(options.columns, localeCtx));
|
|
24
|
+
const filterRevisions = new FilterRevisions();
|
|
23
25
|
const table = createSvelteTable({
|
|
24
26
|
features: dataTableFeatures,
|
|
25
27
|
get data() {
|
|
@@ -48,6 +50,14 @@ export function createTable(options) {
|
|
|
48
50
|
.flatRows[0]?.getAllCellsByColumnId()[column.id]?.getValue();
|
|
49
51
|
return value == null || typeof value === 'string' || typeof value === 'number';
|
|
50
52
|
}),
|
|
53
|
+
// Same write as TanStack's default handler, plus a per-column revision bump so
|
|
54
|
+
// ColumnFilter can tell a filter changed even when it ends on its old value.
|
|
55
|
+
onColumnFiltersChange: ((updater) => {
|
|
56
|
+
const atom = table.baseAtoms.columnFilters;
|
|
57
|
+
const prev = atom.get();
|
|
58
|
+
atom.set((old) => functionalUpdate(updater, old));
|
|
59
|
+
filterRevisions.bump(changedFilterIds(prev, atom.get()));
|
|
60
|
+
}),
|
|
51
61
|
debugTable: options.debugTable,
|
|
52
62
|
initialState: {
|
|
53
63
|
columnVisibility: initialColumnVisibility,
|
|
@@ -79,6 +89,7 @@ export function createTable(options) {
|
|
|
79
89
|
});
|
|
80
90
|
onDestroy(() => subscription.unsubscribe());
|
|
81
91
|
}
|
|
92
|
+
registerFilterRevisions(table, filterRevisions);
|
|
82
93
|
return table;
|
|
83
94
|
}
|
|
84
95
|
function createColumnVisibility(columns) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ColumnFiltersState } from '@tanstack/svelte-table';
|
|
2
|
+
/**
|
|
3
|
+
* A per-column counter that goes up on every change to that column's filter value. Unlike
|
|
4
|
+
* the value itself it never returns to an earlier state, so a filter cleared and then set
|
|
5
|
+
* back to the same value still reads as changed (the ABA case a value comparison misses).
|
|
6
|
+
*/
|
|
7
|
+
export declare class FilterRevisions {
|
|
8
|
+
#private;
|
|
9
|
+
get(columnId: string): number;
|
|
10
|
+
bump(columnIds: string[]): void;
|
|
11
|
+
}
|
|
12
|
+
/** Ids of the columns whose filter value differs (by reference) between two filter states. */
|
|
13
|
+
export declare function changedFilterIds(prev: ColumnFiltersState, next: ColumnFiltersState): string[];
|
|
14
|
+
/** Links a table to its filter revisions (done by `createTable`). */
|
|
15
|
+
export declare function registerFilterRevisions(table: object, revisions: FilterRevisions): void;
|
|
16
|
+
/** The filter revisions of a table made by `createTable`; undefined for any other table. */
|
|
17
|
+
export declare function getFilterRevisions(table: object): FilterRevisions | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-column counter that goes up on every change to that column's filter value. Unlike
|
|
3
|
+
* the value itself it never returns to an earlier state, so a filter cleared and then set
|
|
4
|
+
* back to the same value still reads as changed (the ABA case a value comparison misses).
|
|
5
|
+
*/
|
|
6
|
+
export class FilterRevisions {
|
|
7
|
+
// Raw record replaced on change: reads in templates/deriveds stay reactive.
|
|
8
|
+
#revisions = $state.raw({});
|
|
9
|
+
get(columnId) {
|
|
10
|
+
return this.#revisions[columnId] ?? 0;
|
|
11
|
+
}
|
|
12
|
+
bump(columnIds) {
|
|
13
|
+
if (columnIds.length === 0)
|
|
14
|
+
return;
|
|
15
|
+
const next = { ...this.#revisions };
|
|
16
|
+
for (const id of columnIds)
|
|
17
|
+
next[id] = (next[id] ?? 0) + 1;
|
|
18
|
+
this.#revisions = next;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Ids of the columns whose filter value differs (by reference) between two filter states. */
|
|
22
|
+
export function changedFilterIds(prev, next) {
|
|
23
|
+
if (prev === next)
|
|
24
|
+
return [];
|
|
25
|
+
const before = Object.create(null);
|
|
26
|
+
const after = Object.create(null);
|
|
27
|
+
for (const filter of prev)
|
|
28
|
+
before[filter.id] = filter.value;
|
|
29
|
+
for (const filter of next)
|
|
30
|
+
after[filter.id] = filter.value;
|
|
31
|
+
const ids = Object.keys({ ...before, ...after });
|
|
32
|
+
return ids.filter((id) => !Object.is(before[id], after[id]));
|
|
33
|
+
}
|
|
34
|
+
const registry = new WeakMap();
|
|
35
|
+
/** Links a table to its filter revisions (done by `createTable`). */
|
|
36
|
+
export function registerFilterRevisions(table, revisions) {
|
|
37
|
+
registry.set(table, revisions);
|
|
38
|
+
}
|
|
39
|
+
/** The filter revisions of a table made by `createTable`; undefined for any other table. */
|
|
40
|
+
export function getFilterRevisions(table) {
|
|
41
|
+
return registry.get(table);
|
|
42
|
+
}
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
type SelectFilterOption,
|
|
19
19
|
type SelectFilterValue
|
|
20
20
|
} from '../../../utils/select-filter';
|
|
21
|
+
import { mergeRangeFilter, rangeBounds, type PendingRange } from '../../../utils/range-filter';
|
|
22
|
+
import { DebouncedFilterEdits } from '../../../utils/debounced-filter-edit.svelte';
|
|
23
|
+
import { getFilterRevisions } from '../filter-revisions.svelte';
|
|
21
24
|
|
|
22
25
|
type Props = {
|
|
23
26
|
table: DataTableInstance<T>;
|
|
@@ -30,11 +33,23 @@
|
|
|
30
33
|
// Popover and tooltip share one trigger element, so both must agree on its id.
|
|
31
34
|
const triggerId = $props.id();
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
// Debounced edits not committed yet. A control shows its pending edit if there is one,
|
|
37
|
+
// otherwise the column's live filter value — so a filter changed from outside (the app
|
|
38
|
+
// calling setFilterValue / setColumnFilters / resetColumnFilters) never leaves a stale
|
|
39
|
+
// value in the inputs, an edit pending during that change is dropped rather than
|
|
40
|
+
// re-applied, and committing one range bound never restores the other's old value.
|
|
41
|
+
const textEdits = new DebouncedFilterEdits<string>();
|
|
42
|
+
const rangeEdits = new DebouncedFilterEdits<PendingRange>();
|
|
43
|
+
|
|
44
|
+
// What an edit is based on: the column's filter revision, which only ever goes up, so a
|
|
45
|
+
// filter cleared and set back to its old value still makes the edit stale. Tables not
|
|
46
|
+
// made by compote's createTable have no revisions; fall back to the filter value.
|
|
47
|
+
const filterRevisions = $derived(getFilterRevisions(table));
|
|
48
|
+
function editBase(column: Column<DataTableFeatures, T, unknown>): unknown {
|
|
49
|
+
return filterRevisions ? filterRevisions.get(column.id) : column.getFilterValue();
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
let localSelectSearch: Record<string, string> = $state({});
|
|
37
|
-
const timers: Record<string, ReturnType<typeof setTimeout>> = {};
|
|
38
53
|
|
|
39
54
|
const columnFilters = $derived.by(() => table.atoms.columnFilters.get());
|
|
40
55
|
const columnVisibility = $derived.by(() => table.atoms.columnVisibility.get());
|
|
@@ -73,7 +88,8 @@
|
|
|
73
88
|
});
|
|
74
89
|
|
|
75
90
|
onDestroy(() => {
|
|
76
|
-
|
|
91
|
+
textEdits.cancelAll();
|
|
92
|
+
rangeEdits.cancelAll();
|
|
77
93
|
});
|
|
78
94
|
|
|
79
95
|
function getColumnType(column: Column<DataTableFeatures, T, unknown>): string | undefined {
|
|
@@ -93,22 +109,15 @@
|
|
|
93
109
|
|
|
94
110
|
function removeFilter(column: Column<DataTableFeatures, T, unknown>) {
|
|
95
111
|
manualFilterIds = manualFilterIds.filter((id) => id !== column.id);
|
|
112
|
+
textEdits.cancel(column.id);
|
|
113
|
+
rangeEdits.cancel(column.id);
|
|
96
114
|
column.setFilterValue(undefined);
|
|
97
|
-
delete localText[column.id];
|
|
98
|
-
delete localNumMin[column.id];
|
|
99
|
-
delete localNumMax[column.id];
|
|
100
115
|
delete localSelectSearch[column.id];
|
|
101
|
-
clearTimeout(timers[column.id]);
|
|
102
|
-
clearTimeout(timers[`${column.id}_min`]);
|
|
103
|
-
clearTimeout(timers[`${column.id}_max`]);
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
function clearFilters() {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
localText = {};
|
|
110
|
-
localNumMin = {};
|
|
111
|
-
localNumMax = {};
|
|
119
|
+
textEdits.cancelAll();
|
|
120
|
+
rangeEdits.cancelAll();
|
|
112
121
|
localSelectSearch = {};
|
|
113
122
|
manualFilterIds = [];
|
|
114
123
|
showColumnPicker = false;
|
|
@@ -116,12 +125,39 @@
|
|
|
116
125
|
table.resetColumnFilters();
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
function getTextValue(column: Column<DataTableFeatures, T, unknown>): string {
|
|
129
|
+
const value = column.getFilterValue();
|
|
130
|
+
return textEdits.get(column.id, editBase(column)) ?? (typeof value === 'string' ? value : '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether `column` still has a filter card. A card torn down by an outside change can
|
|
135
|
+
* still emit — Ark's NumberInput commits the typed number on the blur its unmount
|
|
136
|
+
* triggers — and that must not write back a filter the app just cleared.
|
|
137
|
+
*/
|
|
138
|
+
function isCardActive(column: Column<DataTableFeatures, T, unknown>): boolean {
|
|
139
|
+
return activeFilterIds.includes(column.id);
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
function handleTextInput(column: Column<DataTableFeatures, T, unknown>, value: string) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
column
|
|
124
|
-
|
|
143
|
+
if (!isCardActive(column)) return;
|
|
144
|
+
textEdits.edit(
|
|
145
|
+
column.id,
|
|
146
|
+
editBase(column),
|
|
147
|
+
() => value,
|
|
148
|
+
() => editBase(column),
|
|
149
|
+
(text) => column.setFilterValue(text || undefined)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getRangeBound(
|
|
154
|
+
column: Column<DataTableFeatures, T, unknown>,
|
|
155
|
+
which: 'min' | 'max'
|
|
156
|
+
): number | null {
|
|
157
|
+
const value = column.getFilterValue();
|
|
158
|
+
const pending = rangeEdits.get(column.id, editBase(column));
|
|
159
|
+
if (pending && which in pending) return pending[which] ?? null;
|
|
160
|
+
return rangeBounds(value)[which === 'min' ? 0 : 1] ?? null;
|
|
125
161
|
}
|
|
126
162
|
|
|
127
163
|
function handleNumericInput(
|
|
@@ -129,19 +165,14 @@
|
|
|
129
165
|
which: 'min' | 'max',
|
|
130
166
|
value: number | null
|
|
131
167
|
) {
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
timers[`${column.id}_${which}`] = setTimeout(() => {
|
|
141
|
-
const min = localNumMin[column.id];
|
|
142
|
-
const max = localNumMax[column.id];
|
|
143
|
-
column.setFilterValue(min === undefined && max === undefined ? undefined : [min, max]);
|
|
144
|
-
}, 300);
|
|
168
|
+
if (!isCardActive(column)) return;
|
|
169
|
+
rangeEdits.edit(
|
|
170
|
+
column.id,
|
|
171
|
+
editBase(column),
|
|
172
|
+
(pending) => ({ ...pending, [which]: value }),
|
|
173
|
+
() => editBase(column),
|
|
174
|
+
(pending) => column.setFilterValue(mergeRangeFilter(column.getFilterValue(), pending))
|
|
175
|
+
);
|
|
145
176
|
}
|
|
146
177
|
|
|
147
178
|
function getSelectValues(column: Column<DataTableFeatures, T, unknown>): SelectFilterValue[] {
|
|
@@ -246,32 +277,26 @@
|
|
|
246
277
|
<NumberInput
|
|
247
278
|
layout="horizontal"
|
|
248
279
|
label="From"
|
|
249
|
-
value={
|
|
280
|
+
bind:value={
|
|
281
|
+
() => getRangeBound(column, 'min'),
|
|
282
|
+
(bound) => handleNumericInput(column, 'min', bound ?? null)
|
|
283
|
+
}
|
|
250
284
|
min={facetMin}
|
|
251
285
|
max={facetMax}
|
|
252
286
|
formatOptions={colFormatOptions}
|
|
253
|
-
onValueChange={({ valueAsNumber }) =>
|
|
254
|
-
handleNumericInput(
|
|
255
|
-
column,
|
|
256
|
-
'min',
|
|
257
|
-
isNaN(valueAsNumber) ? null : valueAsNumber
|
|
258
|
-
)}
|
|
259
287
|
/>
|
|
260
288
|
</div>
|
|
261
289
|
<div class="min-w-0 flex-1">
|
|
262
290
|
<NumberInput
|
|
263
291
|
layout="horizontal"
|
|
264
292
|
label="To"
|
|
265
|
-
value={
|
|
293
|
+
bind:value={
|
|
294
|
+
() => getRangeBound(column, 'max'),
|
|
295
|
+
(bound) => handleNumericInput(column, 'max', bound ?? null)
|
|
296
|
+
}
|
|
266
297
|
min={facetMin}
|
|
267
298
|
max={facetMax}
|
|
268
299
|
formatOptions={colFormatOptions}
|
|
269
|
-
onValueChange={({ valueAsNumber }) =>
|
|
270
|
-
handleNumericInput(
|
|
271
|
-
column,
|
|
272
|
-
'max',
|
|
273
|
-
isNaN(valueAsNumber) ? null : valueAsNumber
|
|
274
|
-
)}
|
|
275
300
|
/>
|
|
276
301
|
</div>
|
|
277
302
|
</div>
|
|
@@ -372,9 +397,10 @@
|
|
|
372
397
|
<Field.Root hideMessageLine>
|
|
373
398
|
<Field.Input
|
|
374
399
|
placeholder="Search..."
|
|
375
|
-
value={
|
|
376
|
-
|
|
377
|
-
handleTextInput(column, (
|
|
400
|
+
bind:value={
|
|
401
|
+
() => getTextValue(column),
|
|
402
|
+
(text) => handleTextInput(column, String(text ?? ''))
|
|
403
|
+
}
|
|
378
404
|
/>
|
|
379
405
|
</Field.Root>
|
|
380
406
|
{/if}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { NumberInputProps } from './types';
|
|
5
5
|
import { PhCaretDown, PhCaretUp } from '../../icons';
|
|
6
6
|
import { useLocaleContext } from '@ark-ui/svelte/locale';
|
|
7
|
+
import { toNumberInputValue } from '../../utils/number-input-value';
|
|
7
8
|
|
|
8
9
|
let {
|
|
9
10
|
value = $bindable(),
|
|
@@ -28,12 +29,7 @@
|
|
|
28
29
|
{...restProps}
|
|
29
30
|
allowMouseWheel
|
|
30
31
|
locale={locale().locale}
|
|
31
|
-
value={value
|
|
32
|
-
? new Intl.NumberFormat(locale().locale, {
|
|
33
|
-
useGrouping: false,
|
|
34
|
-
maximumFractionDigits: 20
|
|
35
|
-
}).format(value)
|
|
36
|
-
: undefined}
|
|
32
|
+
value={toNumberInputValue(value, locale().locale, restProps.defaultValue !== undefined)}
|
|
37
33
|
readOnly={readonly}
|
|
38
34
|
onValueChange={(valueChangeDetails) => {
|
|
39
35
|
onValueChange?.(valueChangeDetails);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare class DebouncedFilterEdits<T> {
|
|
2
|
+
#private;
|
|
3
|
+
constructor(delay?: number);
|
|
4
|
+
/** The pending edit for `id`, or undefined when there is none or it went stale. */
|
|
5
|
+
get(id: string, current: unknown): T | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Records an edit against the filter's `current` value: `update` receives the live
|
|
8
|
+
* pending edit (undefined when there is none or it went stale) and returns the new one.
|
|
9
|
+
* After the delay, `commit` runs with it — unless `read()` shows the filter changed.
|
|
10
|
+
*/
|
|
11
|
+
edit(id: string, current: unknown, update: (pending: T | undefined) => T, read: () => unknown, commit: (value: T) => void): void;
|
|
12
|
+
/** Drops the pending edit for `id` without committing it. */
|
|
13
|
+
cancel(id: string): void;
|
|
14
|
+
/** Drops every pending edit without committing. */
|
|
15
|
+
cancelAll(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export class DebouncedFilterEdits {
|
|
2
|
+
// Raw, not deep: `base` must stay the exact filter value it is compared against — a deep
|
|
3
|
+
// $state would proxy an array base (a range) and `Object.is` would never match it again.
|
|
4
|
+
#pending = $state.raw({});
|
|
5
|
+
// Timer handles are bookkeeping, never rendered — a reactive map would only add overhead.
|
|
6
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
7
|
+
#timers = new Map();
|
|
8
|
+
#delay;
|
|
9
|
+
constructor(delay = 300) {
|
|
10
|
+
this.#delay = delay;
|
|
11
|
+
}
|
|
12
|
+
/** The pending edit for `id`, or undefined when there is none or it went stale. */
|
|
13
|
+
get(id, current) {
|
|
14
|
+
const pending = this.#pending[id];
|
|
15
|
+
return pending && Object.is(pending.base, current) ? pending.value : undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Records an edit against the filter's `current` value: `update` receives the live
|
|
19
|
+
* pending edit (undefined when there is none or it went stale) and returns the new one.
|
|
20
|
+
* After the delay, `commit` runs with it — unless `read()` shows the filter changed.
|
|
21
|
+
*/
|
|
22
|
+
edit(id, current, update, read, commit) {
|
|
23
|
+
this.#pending = {
|
|
24
|
+
...this.#pending,
|
|
25
|
+
[id]: { value: update(this.get(id, current)), base: current }
|
|
26
|
+
};
|
|
27
|
+
clearTimeout(this.#timers.get(id));
|
|
28
|
+
this.#timers.set(id, setTimeout(() => {
|
|
29
|
+
this.#timers.delete(id);
|
|
30
|
+
const pending = this.#pending[id];
|
|
31
|
+
this.#remove(id);
|
|
32
|
+
if (pending && Object.is(read(), pending.base))
|
|
33
|
+
commit(pending.value);
|
|
34
|
+
}, this.#delay));
|
|
35
|
+
}
|
|
36
|
+
/** Drops the pending edit for `id` without committing it. */
|
|
37
|
+
cancel(id) {
|
|
38
|
+
clearTimeout(this.#timers.get(id));
|
|
39
|
+
this.#timers.delete(id);
|
|
40
|
+
this.#remove(id);
|
|
41
|
+
}
|
|
42
|
+
/** Drops every pending edit without committing. */
|
|
43
|
+
cancelAll() {
|
|
44
|
+
for (const timer of this.#timers.values())
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
this.#timers.clear();
|
|
47
|
+
this.#pending = {};
|
|
48
|
+
}
|
|
49
|
+
#remove(id) {
|
|
50
|
+
if (!(id in this.#pending))
|
|
51
|
+
return;
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- dropped from the record
|
|
53
|
+
const { [id]: _removed, ...rest } = this.#pending;
|
|
54
|
+
this.#pending = rest;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The string value NumberInput hands to Ark's NumberInput.Root.
|
|
3
|
+
*
|
|
4
|
+
* An empty value (`null`, `undefined` or NaN) becomes `''`, a controlled empty value. Passing
|
|
5
|
+
* `undefined` instead would switch Ark to its internal state, which keeps showing the last
|
|
6
|
+
* number — so clearing a bound from outside (e.g. a filter going from [10, 100] to
|
|
7
|
+
* [undefined, 100]) would leave the 10 on screen. The one exception: an `undefined` value
|
|
8
|
+
* with a `defaultValue` set stays `undefined`, so uncontrolled use keeps working.
|
|
9
|
+
*/
|
|
10
|
+
export declare function toNumberInputValue(value: number | null | undefined, locale: string, hasDefaultValue?: boolean): string | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The string value NumberInput hands to Ark's NumberInput.Root.
|
|
3
|
+
*
|
|
4
|
+
* An empty value (`null`, `undefined` or NaN) becomes `''`, a controlled empty value. Passing
|
|
5
|
+
* `undefined` instead would switch Ark to its internal state, which keeps showing the last
|
|
6
|
+
* number — so clearing a bound from outside (e.g. a filter going from [10, 100] to
|
|
7
|
+
* [undefined, 100]) would leave the 10 on screen. The one exception: an `undefined` value
|
|
8
|
+
* with a `defaultValue` set stays `undefined`, so uncontrolled use keeps working.
|
|
9
|
+
*/
|
|
10
|
+
export function toNumberInputValue(value, locale, hasDefaultValue = false) {
|
|
11
|
+
if (value == null || Number.isNaN(value)) {
|
|
12
|
+
return value === undefined && hasDefaultValue ? undefined : '';
|
|
13
|
+
}
|
|
14
|
+
return new Intl.NumberFormat(locale, { useGrouping: false, maximumFractionDigits: 20 }).format(value);
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value handling for numeric range filters (`inNumberRange`): the filter value is a
|
|
3
|
+
* `[min, max]` tuple where either bound may be `undefined`.
|
|
4
|
+
*/
|
|
5
|
+
export type RangeFilterValue = [number | undefined, number | undefined];
|
|
6
|
+
/** Bounds edited in the UI but not committed yet; `null` means the bound was cleared. */
|
|
7
|
+
export type PendingRange = {
|
|
8
|
+
min?: number | null;
|
|
9
|
+
max?: number | null;
|
|
10
|
+
};
|
|
11
|
+
/** The `[min, max]` bounds of a range filter value, or both undefined when there is none. */
|
|
12
|
+
export declare function rangeBounds(filterValue: unknown): RangeFilterValue;
|
|
13
|
+
/**
|
|
14
|
+
* The filter value after committing `pending` edits onto the current filter value. A bound
|
|
15
|
+
* that wasn't edited keeps its current (live) value, so a stale bound can't come back after
|
|
16
|
+
* the filter was changed elsewhere. Returns `undefined` when both bounds are empty, which
|
|
17
|
+
* removes the filter.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mergeRangeFilter(current: unknown, pending: PendingRange): RangeFilterValue | undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value handling for numeric range filters (`inNumberRange`): the filter value is a
|
|
3
|
+
* `[min, max]` tuple where either bound may be `undefined`.
|
|
4
|
+
*/
|
|
5
|
+
/** The `[min, max]` bounds of a range filter value, or both undefined when there is none. */
|
|
6
|
+
export function rangeBounds(filterValue) {
|
|
7
|
+
if (!Array.isArray(filterValue))
|
|
8
|
+
return [undefined, undefined];
|
|
9
|
+
const [min, max] = filterValue;
|
|
10
|
+
return [typeof min === 'number' ? min : undefined, typeof max === 'number' ? max : undefined];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The filter value after committing `pending` edits onto the current filter value. A bound
|
|
14
|
+
* that wasn't edited keeps its current (live) value, so a stale bound can't come back after
|
|
15
|
+
* the filter was changed elsewhere. Returns `undefined` when both bounds are empty, which
|
|
16
|
+
* removes the filter.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeRangeFilter(current, pending) {
|
|
19
|
+
const [currentMin, currentMax] = rangeBounds(current);
|
|
20
|
+
const min = 'min' in pending ? (pending.min ?? undefined) : currentMin;
|
|
21
|
+
const max = 'max' in pending ? (pending.max ?? undefined) : currentMax;
|
|
22
|
+
return min === undefined && max === undefined ? undefined : [min, max];
|
|
23
|
+
}
|
|
@@ -30,5 +30,8 @@ export type SelectFilterOption = {
|
|
|
30
30
|
* option (labelled `emptyLabel`) last.
|
|
31
31
|
*/
|
|
32
32
|
export declare function selectFilterOptions(facets: Map<unknown, number>, emptyLabel?: string): SelectFilterOption[];
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Cell text for a select value: the same distinct items the Funnel lists, joined with `, `;
|
|
35
|
+
* undefined when empty.
|
|
36
|
+
*/
|
|
34
37
|
export declare function formatSelectValue(value: unknown): string | number | boolean | undefined;
|
|
@@ -68,11 +68,14 @@ export function selectFilterOptions(facets, emptyLabel = '(empty)') {
|
|
|
68
68
|
}
|
|
69
69
|
return options;
|
|
70
70
|
}
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Cell text for a select value: the same distinct items the Funnel lists, joined with `, `;
|
|
73
|
+
* undefined when empty.
|
|
74
|
+
*/
|
|
72
75
|
export function formatSelectValue(value) {
|
|
73
76
|
if (Array.isArray(value)) {
|
|
74
|
-
const items = value
|
|
75
|
-
return items
|
|
77
|
+
const items = selectCellValues(value);
|
|
78
|
+
return items[0] === EMPTY_SELECT_VALUE ? undefined : items.join(', ');
|
|
76
79
|
}
|
|
77
80
|
return isEmpty(value) ? undefined : value;
|
|
78
81
|
}
|
package/package.json
CHANGED
|
@@ -172,6 +172,9 @@ If selection must be restricted to a fixed set (pick-only, no free text), use `C
|
|
|
172
172
|
Props: `value?: number | null` (bindable), `label?`, `readonly?`, `layout?: 'vertical' | 'horizontal'`,
|
|
173
173
|
plus Ark UI NumberInput props (`min`, `max`, `step`, `formatOptions`, `locale`).
|
|
174
174
|
|
|
175
|
+
`value` is controlled: setting it to `null` from outside clears the input. Use `defaultValue`
|
|
176
|
+
(a string) only for uncontrolled use, without passing `value`.
|
|
177
|
+
|
|
175
178
|
## PasswordInput
|
|
176
179
|
|
|
177
180
|
Password field with a built-in show/hide toggle. The underlying `<input>` is a real DOM input —
|
|
@@ -28,6 +28,21 @@ const sorting = $derived(table.store.state.sorting);
|
|
|
28
28
|
const filters = $derived(table.store.state.columnFilters);
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
Change filters from app code with the table API — `column.setFilterValue(...)`,
|
|
32
|
+
`table.setColumnFilters(...)`, `table.resetColumnFilters()`. `DataTable.ColumnFilter` shows the
|
|
33
|
+
live filter value in its inputs (it only holds edits still inside the 300ms debounce), so an
|
|
34
|
+
external change clears or updates the inputs, and editing one range bound keeps the other
|
|
35
|
+
bound's current value. An edit still inside the debounce is dropped when its column's filter
|
|
36
|
+
changes from outside — even if the filter ends on its old value again (e.g. reset, then saved
|
|
37
|
+
filters restored), since `createTable` keeps a per-column filter revision that only goes up. A
|
|
38
|
+
reset that finds the filter already empty changes nothing (TanStack keeps the state reference),
|
|
39
|
+
so an edit typed into that empty filter still commits.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// e.g. drop every filter on columns that belong to the previous category
|
|
43
|
+
table.setColumnFilters((filters) => filters.filter((f) => !f.id.startsWith('attr_')));
|
|
44
|
+
```
|
|
45
|
+
|
|
31
46
|
Persist column visibility with the observer callback:
|
|
32
47
|
|
|
33
48
|
```ts
|