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.
@@ -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
- let localText: Record<string, string> = $state({});
34
- let localNumMin: Record<string, number> = $state({});
35
- let localNumMax: Record<string, number> = $state({});
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
- Object.values(timers).forEach(clearTimeout);
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
- Object.values(timers).forEach(clearTimeout);
108
- for (const key of Object.keys(timers)) delete timers[key];
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
- localText[column.id] = value;
121
- clearTimeout(timers[column.id]);
122
- timers[column.id] = setTimeout(() => {
123
- column.setFilterValue(value || undefined);
124
- }, 300);
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 (value === null) {
133
- if (which === 'min') delete localNumMin[column.id];
134
- else delete localNumMax[column.id];
135
- } else {
136
- if (which === 'min') localNumMin[column.id] = value;
137
- else localNumMax[column.id] = value;
138
- }
139
- clearTimeout(timers[`${column.id}_${which}`]);
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={localNumMin[column.id] ?? null}
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={localNumMax[column.id] ?? null}
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={localText[column.id] ?? ''}
376
- oninput={(e: Event) =>
377
- handleTextInput(column, (e.currentTarget as HTMLInputElement).value)}
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 != null
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
- /** Cell text for a select value: array items joined with `, `; undefined when empty. */
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
- /** Cell text for a select value: array items joined with `, `; undefined when empty. */
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.filter((item) => !isEmpty(item));
75
- return items.length ? items.join(', ') : undefined;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compote-ui",
3
- "version": "0.79.0",
3
+ "version": "0.79.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,7 +8,7 @@ description: >
8
8
  metadata:
9
9
  type: core
10
10
  library: compote-ui
11
- library_version: '0.74.2'
11
+ library_version: '0.79.2'
12
12
  sources:
13
13
  - src/lib/index.ts
14
14
  - src/lib/utils/collections.ts
@@ -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 —
@@ -8,7 +8,7 @@ description: >
8
8
  metadata:
9
9
  type: composition
10
10
  library: compote-ui
11
- library_version: '0.79.0'
11
+ library_version: '0.79.2'
12
12
  requires:
13
13
  - component-usage
14
14
  - theming
@@ -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