compote-ui 0.79.1 → 0.80.0

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 (69) hide show
  1. package/dist/components/data-table-v9/column-filter-editing.svelte.d.ts +41 -0
  2. package/dist/components/data-table-v9/column-filter-editing.svelte.js +88 -0
  3. package/dist/components/data-table-v9/column-filter-kind.d.ts +10 -0
  4. package/dist/components/data-table-v9/column-filter-kind.js +25 -0
  5. package/dist/components/data-table-v9/create-table.svelte.js +49 -26
  6. package/dist/components/data-table-v9/data-table-head.svelte +230 -201
  7. package/dist/components/data-table-v9/data-table-head.svelte.d.ts +2 -0
  8. package/dist/components/data-table-v9/data-table-utils.d.ts +7 -0
  9. package/dist/components/data-table-v9/data-table-utils.js +4 -0
  10. package/dist/components/data-table-v9/data-table.svelte +154 -151
  11. package/dist/components/data-table-v9/data-table.svelte.d.ts +2 -0
  12. package/dist/components/data-table-v9/date-cell.d.ts +13 -0
  13. package/dist/components/data-table-v9/date-cell.js +38 -0
  14. package/dist/components/data-table-v9/filter-editors/boolean-filter-editor.svelte +45 -0
  15. package/dist/components/data-table-v9/filter-editors/boolean-filter-editor.svelte.d.ts +7 -0
  16. package/dist/components/data-table-v9/filter-editors/column-filter-editor.svelte +33 -0
  17. package/dist/components/data-table-v9/filter-editors/column-filter-editor.svelte.d.ts +30 -0
  18. package/dist/components/data-table-v9/filter-editors/date-range-filter-editor.svelte +52 -0
  19. package/dist/components/data-table-v9/filter-editors/date-range-filter-editor.svelte.d.ts +28 -0
  20. package/dist/components/data-table-v9/filter-editors/range-filter-editor.svelte +51 -0
  21. package/dist/components/data-table-v9/filter-editors/range-filter-editor.svelte.d.ts +30 -0
  22. package/dist/components/data-table-v9/filter-editors/select-filter-editor.svelte +59 -0
  23. package/dist/components/data-table-v9/filter-editors/select-filter-editor.svelte.d.ts +28 -0
  24. package/dist/components/data-table-v9/filter-editors/text-filter-editor.svelte +20 -0
  25. package/dist/components/data-table-v9/filter-editors/text-filter-editor.svelte.d.ts +8 -0
  26. package/dist/components/data-table-v9/filter-revisions.svelte.d.ts +17 -0
  27. package/dist/components/data-table-v9/filter-revisions.svelte.js +42 -0
  28. package/dist/components/data-table-v9/header/column-header-filter.svelte +66 -0
  29. package/dist/components/data-table-v9/header/column-header-filter.svelte.d.ts +30 -0
  30. package/dist/components/data-table-v9/table-view-state.svelte.d.ts +1 -0
  31. package/dist/components/data-table-v9/table-view-state.svelte.js +3 -0
  32. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte +203 -433
  33. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte.d.ts +1 -1
  34. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte +82 -79
  35. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte.d.ts +2 -0
  36. package/dist/components/dialog/alert-dialog.svelte +73 -71
  37. package/dist/components/dialog/dialog-root.svelte +45 -43
  38. package/dist/components/drawer/drawer-content.svelte +213 -211
  39. package/dist/components/number-input/number-input.svelte +2 -6
  40. package/dist/components/popover/popover-content.svelte +97 -71
  41. package/dist/components/popover/popover-content.svelte.d.ts +2 -1
  42. package/dist/components/popover/popover-description.svelte +19 -15
  43. package/dist/components/popover/popover-labels.svelte.d.ts +18 -0
  44. package/dist/components/popover/popover-labels.svelte.js +35 -0
  45. package/dist/components/popover/popover-root.svelte +27 -24
  46. package/dist/components/popover/popover-title.svelte +19 -15
  47. package/dist/icons/PhFunnelFill.svelte +19 -0
  48. package/dist/icons/PhFunnelFill.svelte.d.ts +5 -0
  49. package/dist/icons/index.d.ts +1 -0
  50. package/dist/icons/index.js +1 -0
  51. package/dist/utils/date-filter.d.ts +31 -0
  52. package/dist/utils/date-filter.js +58 -0
  53. package/dist/utils/date.d.ts +19 -1
  54. package/dist/utils/date.js +48 -1
  55. package/dist/utils/debounced-filter-edit.svelte.d.ts +16 -0
  56. package/dist/utils/debounced-filter-edit.svelte.js +56 -0
  57. package/dist/utils/number-input-value.d.ts +10 -0
  58. package/dist/utils/number-input-value.js +15 -0
  59. package/dist/utils/range-filter.d.ts +19 -0
  60. package/dist/utils/range-filter.js +23 -0
  61. package/dist/utils/release-focus-on-exit.d.ts +22 -0
  62. package/dist/utils/release-focus-on-exit.js +26 -0
  63. package/package.json +1 -1
  64. package/skills/component-usage/SKILL.md +1 -1
  65. package/skills/component-usage/references/forms-and-inputs.md +3 -0
  66. package/skills/component-usage/references/overlays-and-floating-ui.md +278 -273
  67. package/skills/data-table/SKILL.md +314 -283
  68. package/skills/data-table/references/column-options.md +175 -150
  69. package/skills/data-table/references/table-state-and-reactivity.md +65 -45
@@ -0,0 +1,41 @@
1
+ /** The slice of a TanStack column the filter editors read and write. */
2
+ export type FilterableColumn = {
3
+ id: string;
4
+ getFilterValue(): unknown;
5
+ setFilterValue(value: unknown): void;
6
+ };
7
+ export type ColumnFilterEditingOptions = {
8
+ /** The table the edited columns belong to; read on every access so it may change. */
9
+ table: () => object;
10
+ /**
11
+ * Whether `columnId` still has an editor. An editor torn down by an outside change can
12
+ * still emit — Ark's NumberInput commits the typed number on the blur its unmount
13
+ * triggers — and that must not write back a filter the app just cleared.
14
+ */
15
+ isActive?: (columnId: string) => boolean;
16
+ };
17
+ /** Drops every pending filter edit on `table` without committing, whichever host made it. */
18
+ export declare function cancelPendingFilterEdits(table: object): void;
19
+ /**
20
+ * One host's access to a table's pending filter edits (see PendingFilterEdits). The edits
21
+ * live with the table, not with the host or its editors, so an edit still commits after its
22
+ * editor unmounts with a closing popover.
23
+ *
24
+ * A control shows its pending edit if there is one, otherwise the column's live filter
25
+ * value — so a filter changed from outside (the app calling setFilterValue /
26
+ * setColumnFilters / resetColumnFilters) never leaves a stale value in the inputs, an edit
27
+ * pending during that change is dropped rather than re-applied, and committing one range
28
+ * bound never restores the other's old value.
29
+ */
30
+ export declare class ColumnFilterEditing {
31
+ #private;
32
+ constructor(options: ColumnFilterEditingOptions);
33
+ textValue(column: FilterableColumn): string;
34
+ editText(column: FilterableColumn, value: string): void;
35
+ rangeBound(column: FilterableColumn, which: 'min' | 'max'): number | null;
36
+ editRange(column: FilterableColumn, which: 'min' | 'max', value: number | null): void;
37
+ /** Drops the pending edits for one column without committing them. */
38
+ cancel(columnId: string): void;
39
+ /** Drops every pending edit on the table without committing — including other hosts'. */
40
+ cancelAll(): void;
41
+ }
@@ -0,0 +1,88 @@
1
+ import { DebouncedFilterEdits } from '../../utils/debounced-filter-edit.svelte';
2
+ import { mergeRangeFilter, rangeBounds } from '../../utils/range-filter';
3
+ import { getFilterRevisions } from './filter-revisions.svelte';
4
+ /**
5
+ * The debounced edits pending on one table's filters. Shared by every component that edits
6
+ * them (the toolbar filter, each header filter), so the latest edit to a column wins no
7
+ * matter where it was made, and cancelling reaches edits made from any of them.
8
+ */
9
+ class PendingFilterEdits {
10
+ text = new DebouncedFilterEdits();
11
+ range = new DebouncedFilterEdits();
12
+ }
13
+ const registry = new WeakMap();
14
+ function pendingEdits(table) {
15
+ let edits = registry.get(table);
16
+ if (!edits) {
17
+ edits = new PendingFilterEdits();
18
+ registry.set(table, edits);
19
+ }
20
+ return edits;
21
+ }
22
+ /** Drops every pending filter edit on `table` without committing, whichever host made it. */
23
+ export function cancelPendingFilterEdits(table) {
24
+ const edits = registry.get(table);
25
+ edits?.text.cancelAll();
26
+ edits?.range.cancelAll();
27
+ }
28
+ /**
29
+ * One host's access to a table's pending filter edits (see PendingFilterEdits). The edits
30
+ * live with the table, not with the host or its editors, so an edit still commits after its
31
+ * editor unmounts with a closing popover.
32
+ *
33
+ * A control shows its pending edit if there is one, otherwise the column's live filter
34
+ * value — so a filter changed from outside (the app calling setFilterValue /
35
+ * setColumnFilters / resetColumnFilters) never leaves a stale value in the inputs, an edit
36
+ * pending during that change is dropped rather than re-applied, and committing one range
37
+ * bound never restores the other's old value.
38
+ */
39
+ export class ColumnFilterEditing {
40
+ #table;
41
+ #isActive;
42
+ constructor(options) {
43
+ this.#table = options.table;
44
+ this.#isActive = options.isActive ?? (() => true);
45
+ }
46
+ get #edits() {
47
+ return pendingEdits(this.#table());
48
+ }
49
+ /**
50
+ * What an edit is based on: the column's filter revision, which only ever goes up, so a
51
+ * filter cleared and set back to its old value still makes the edit stale. Tables not
52
+ * made by compote's createTable have no revisions; fall back to the filter value.
53
+ */
54
+ #base(column) {
55
+ const revisions = getFilterRevisions(this.#table());
56
+ return revisions ? revisions.get(column.id) : column.getFilterValue();
57
+ }
58
+ textValue(column) {
59
+ const value = column.getFilterValue();
60
+ return (this.#edits.text.get(column.id, this.#base(column)) ??
61
+ (typeof value === 'string' ? value : ''));
62
+ }
63
+ editText(column, value) {
64
+ if (!this.#isActive(column.id))
65
+ return;
66
+ this.#edits.text.edit(column.id, this.#base(column), () => value, () => this.#base(column), (text) => column.setFilterValue(text || undefined));
67
+ }
68
+ rangeBound(column, which) {
69
+ const pending = this.#edits.range.get(column.id, this.#base(column));
70
+ if (pending && which in pending)
71
+ return pending[which] ?? null;
72
+ return rangeBounds(column.getFilterValue())[which === 'min' ? 0 : 1] ?? null;
73
+ }
74
+ editRange(column, which, value) {
75
+ if (!this.#isActive(column.id))
76
+ return;
77
+ this.#edits.range.edit(column.id, this.#base(column), (pending) => ({ ...pending, [which]: value }), () => this.#base(column), (pending) => column.setFilterValue(mergeRangeFilter(column.getFilterValue(), pending)));
78
+ }
79
+ /** Drops the pending edits for one column without committing them. */
80
+ cancel(columnId) {
81
+ this.#edits.text.cancel(columnId);
82
+ this.#edits.range.cancel(columnId);
83
+ }
84
+ /** Drops every pending edit on the table without committing — including other hosts'. */
85
+ cancelAll() {
86
+ cancelPendingFilterEdits(this.#table());
87
+ }
88
+ }
@@ -0,0 +1,10 @@
1
+ import type { DataTableColumnType } from './types';
2
+ /** Which filter editor (and default filterFn) a column type uses. */
3
+ export type ColumnFilterKind = 'text' | 'range' | 'boolean' | 'select' | 'date';
4
+ /**
5
+ * The filter kind for a column type. The single mapping both the filter editors and the
6
+ * default filterFn follow, so a column never shows an editor its filterFn can't evaluate.
7
+ * `action` columns carry no value and get no filter. `time` stays a text filter until it has
8
+ * its own contract (a time-of-day range has to decide how to cross midnight).
9
+ */
10
+ export declare function filterKindFor(type: DataTableColumnType | undefined): ColumnFilterKind | null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The filter kind for a column type. The single mapping both the filter editors and the
3
+ * default filterFn follow, so a column never shows an editor its filterFn can't evaluate.
4
+ * `action` columns carry no value and get no filter. `time` stays a text filter until it has
5
+ * its own contract (a time-of-day range has to decide how to cross midnight).
6
+ */
7
+ export function filterKindFor(type) {
8
+ switch (type) {
9
+ case 'number':
10
+ case 'currency':
11
+ case 'percent':
12
+ return 'range';
13
+ case 'boolean':
14
+ return 'boolean';
15
+ case 'select':
16
+ return 'select';
17
+ case 'date':
18
+ case 'date-time':
19
+ return 'date';
20
+ case 'action':
21
+ return null;
22
+ default:
23
+ return 'text';
24
+ }
25
+ }
@@ -1,9 +1,14 @@
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 { filterKindFor } from './column-filter-kind';
8
+ import { dateCellTimeZone, formatDateCell } from './date-cell';
9
+ import { isEmptyDateRange, matchesDateRange, resolveDateRange } from '../../utils/date-filter';
10
+ import { cancelPendingFilterEdits } from './column-filter-editing.svelte';
11
+ import { changedFilterIds, FilterRevisions, registerFilterRevisions } from './filter-revisions.svelte';
7
12
  import { formatSelectValue, matchesSelectFilter, selectCellValues } from '../../utils/select-filter';
8
13
  const oneOfFilterFn = (row, columnId, filterValue) => {
9
14
  return matchesSelectFilter(row.getValue(columnId), filterValue);
@@ -20,6 +25,7 @@ export function createTable(options) {
20
25
  // memoization isn't invalidated on every access. Pass `columns` through a
21
26
  // getter (like `data`) to make adding/removing/reordering columns reactive.
22
27
  const columnDefs = $derived(createColumns(options.columns, localeCtx));
28
+ const filterRevisions = new FilterRevisions();
23
29
  const table = createSvelteTable({
24
30
  features: dataTableFeatures,
25
31
  get data() {
@@ -48,6 +54,14 @@ export function createTable(options) {
48
54
  .flatRows[0]?.getAllCellsByColumnId()[column.id]?.getValue();
49
55
  return value == null || typeof value === 'string' || typeof value === 'number';
50
56
  }),
57
+ // Same write as TanStack's default handler, plus a per-column revision bump so
58
+ // ColumnFilter can tell a filter changed even when it ends on its old value.
59
+ onColumnFiltersChange: ((updater) => {
60
+ const atom = table.baseAtoms.columnFilters;
61
+ const prev = atom.get();
62
+ atom.set((old) => functionalUpdate(updater, old));
63
+ filterRevisions.bump(changedFilterIds(prev, atom.get()));
64
+ }),
51
65
  debugTable: options.debugTable,
52
66
  initialState: {
53
67
  columnVisibility: initialColumnVisibility,
@@ -79,6 +93,23 @@ export function createTable(options) {
79
93
  });
80
94
  onDestroy(() => subscription.unsubscribe());
81
95
  }
96
+ registerFilterRevisions(table, filterRevisions);
97
+ // Debounced filter edits outlive the editors that made them, but not the table.
98
+ onDestroy(() => cancelPendingFilterEdits(table));
99
+ // A reset drops pending filter edits too — including one on a column with no committed
100
+ // filter, whose revision the reset leaves alone, so the edit would otherwise commit
101
+ // afterwards and bring a filter back. TanStack assigns these as own methods of the table
102
+ // (`table.reset` also writes state directly, bypassing onColumnFiltersChange).
103
+ const resetColumnFilters = table.resetColumnFilters;
104
+ table.resetColumnFilters = (defaultState) => {
105
+ cancelPendingFilterEdits(table);
106
+ resetColumnFilters(defaultState);
107
+ };
108
+ const resetTable = table.reset;
109
+ table.reset = () => {
110
+ cancelPendingFilterEdits(table);
111
+ resetTable();
112
+ };
82
113
  return table;
83
114
  }
84
115
  function createColumnVisibility(columns) {
@@ -117,7 +148,7 @@ function createColumns(columns, localeCtx) {
117
148
  };
118
149
  }
119
150
  const columnId = getColumnId(column);
120
- const derivedFilterFn = column.filterFn ?? getFilterFnForType(column.type);
151
+ const derivedFilterFn = column.filterFn ?? getFilterFnForType(column);
121
152
  const columnDef = {
122
153
  id: columnId,
123
154
  header: column.header,
@@ -180,27 +211,26 @@ export function getColumnId(column) {
180
211
  return column.accessorKey;
181
212
  throw new Error('DataTableColumn with accessorFn requires an id.');
182
213
  }
183
- const TYPE_DATE_FORMAT_DEFAULTS = {
184
- date: { day: '2-digit', month: '2-digit', year: 'numeric' },
185
- time: { hour: '2-digit', minute: '2-digit' },
186
- 'date-time': {
187
- day: '2-digit',
188
- month: '2-digit',
189
- year: 'numeric',
190
- hour: '2-digit',
191
- minute: '2-digit'
192
- }
193
- };
194
- function getFilterFnForType(type) {
195
- switch (type) {
196
- case 'number':
197
- case 'currency':
198
- case 'percent':
214
+ /**
215
+ * A calendar-day range filter for a date column, judged in the zone the column is shown in
216
+ * (read on every pass, so it follows a changed local zone like the cells do).
217
+ */
218
+ function dateRangeFilterFn(formatOptions) {
219
+ const filterFn = (row, columnId, range) => matchesDateRange(row.getValue(columnId), range, dateCellTimeZone(formatOptions));
220
+ filterFn.resolveFilterValue = resolveDateRange;
221
+ filterFn.autoRemove = isEmptyDateRange;
222
+ return filterFn;
223
+ }
224
+ function getFilterFnForType(column) {
225
+ switch (filterKindFor(column.type)) {
226
+ case 'range':
199
227
  return 'inNumberRange';
200
228
  case 'boolean':
201
229
  return 'equals';
202
230
  case 'select':
203
231
  return oneOfFilterFn;
232
+ case 'date':
233
+ return dateRangeFilterFn(column.formatOptions);
204
234
  default:
205
235
  return undefined;
206
236
  }
@@ -220,14 +250,7 @@ function applyTypeFormat(column, value, localeCtx) {
220
250
  }).format(numericValue);
221
251
  }
222
252
  if (column.type === 'date' || column.type === 'time' || column.type === 'date-time') {
223
- const dateValue = value instanceof Date ? value : new Date(value);
224
- if (isNaN(dateValue.getTime()))
225
- return undefined;
226
- const locale = column.formatLocale ?? localeCtx().locale;
227
- return new Intl.DateTimeFormat(locale, {
228
- ...TYPE_DATE_FORMAT_DEFAULTS[column.type],
229
- ...column.formatOptions
230
- }).format(dateValue);
253
+ return formatDateCell(value, column.type, column.formatLocale ?? localeCtx().locale, column.formatOptions);
231
254
  }
232
255
  if (column.type === 'boolean')
233
256
  return value ? 'Yes' : 'No';