compote-ui 0.79.2 → 0.81.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 (63) 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 +19 -0
  4. package/dist/components/data-table-v9/column-filter-kind.js +32 -0
  5. package/dist/components/data-table-v9/create-table.svelte.js +37 -25
  6. package/dist/components/data-table-v9/data-table-head.svelte +32 -3
  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 +4 -1
  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 +39 -0
  17. package/dist/components/data-table-v9/filter-editors/column-filter-editor.svelte.d.ts +35 -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/header/column-header-filter.svelte +66 -0
  27. package/dist/components/data-table-v9/header/column-header-filter.svelte.d.ts +30 -0
  28. package/dist/components/data-table-v9/index.d.ts +9 -1
  29. package/dist/components/data-table-v9/index.js +7 -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 +13 -269
  33. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte.d.ts +1 -1
  34. package/dist/components/data-table-v9/types.d.ts +3 -1
  35. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte +4 -1
  36. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte.d.ts +2 -0
  37. package/dist/components/data-table-v9/virtual/index.d.ts +2 -2
  38. package/dist/components/data-table-v9/virtual/index.js +1 -1
  39. package/dist/components/dialog/alert-dialog.svelte +2 -0
  40. package/dist/components/dialog/dialog-root.svelte +2 -0
  41. package/dist/components/drawer/drawer-content.svelte +2 -0
  42. package/dist/components/popover/popover-content.svelte +29 -3
  43. package/dist/components/popover/popover-content.svelte.d.ts +2 -1
  44. package/dist/components/popover/popover-description.svelte +4 -0
  45. package/dist/components/popover/popover-labels.svelte.d.ts +18 -0
  46. package/dist/components/popover/popover-labels.svelte.js +35 -0
  47. package/dist/components/popover/popover-root.svelte +3 -0
  48. package/dist/components/popover/popover-title.svelte +4 -0
  49. package/dist/icons/PhFunnelFill.svelte +19 -0
  50. package/dist/icons/PhFunnelFill.svelte.d.ts +5 -0
  51. package/dist/icons/index.d.ts +1 -0
  52. package/dist/icons/index.js +1 -0
  53. package/dist/utils/date-filter.d.ts +31 -0
  54. package/dist/utils/date-filter.js +58 -0
  55. package/dist/utils/date.d.ts +19 -1
  56. package/dist/utils/date.js +48 -1
  57. package/dist/utils/release-focus-on-exit.d.ts +22 -0
  58. package/dist/utils/release-focus-on-exit.js +26 -0
  59. package/package.json +1 -1
  60. package/skills/component-usage/references/overlays-and-floating-ui.md +7 -2
  61. package/skills/data-table/SKILL.md +80 -1
  62. package/skills/data-table/references/column-options.md +25 -0
  63. package/skills/data-table/references/table-state-and-reactivity.md +13 -8
@@ -16,6 +16,7 @@ export { default as PhFile } from './PhFile.svelte';
16
16
  export { default as PhFileArchive } from './PhFileArchive.svelte';
17
17
  export { default as PhFileText } from './PhFileText.svelte';
18
18
  export { default as PhFunnel } from './PhFunnel.svelte';
19
+ export { default as PhFunnelFill } from './PhFunnelFill.svelte';
19
20
  export { default as PhHeadphones } from './PhHeadphones.svelte';
20
21
  export { default as PhImage } from './PhImage.svelte';
21
22
  export { default as PhListMagnifyingGlass } from './PhListMagnifyingGlass.svelte';
@@ -16,6 +16,7 @@ export { default as PhFile } from './PhFile.svelte';
16
16
  export { default as PhFileArchive } from './PhFileArchive.svelte';
17
17
  export { default as PhFileText } from './PhFileText.svelte';
18
18
  export { default as PhFunnel } from './PhFunnel.svelte';
19
+ export { default as PhFunnelFill } from './PhFunnelFill.svelte';
19
20
  export { default as PhHeadphones } from './PhHeadphones.svelte';
20
21
  export { default as PhImage } from './PhImage.svelte';
21
22
  export { default as PhListMagnifyingGlass } from './PhListMagnifyingGlass.svelte';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Calendar-day range filters for date and date-time columns.
3
+ *
4
+ * The filter value is `[from?, to?]`, each an ISO calendar date (`YYYY-MM-DD`) or empty — no
5
+ * time, no zone, so it serializes cleanly. Both bounds are inclusive days. A cell matches
6
+ * when the day it shows in the display zone (see `readDateInZone`) falls inside the range.
7
+ * For an instant that is the half-open interval [start of `from`, start of the day after
8
+ * `to`) in that zone, with day starts taken from the calendar — so 23- and 25-hour DST days
9
+ * need no special handling.
10
+ */
11
+ import { CalendarDate } from '@internationalized/date';
12
+ /** A date range filter value as stored in table state. */
13
+ export type DateRangeFilterValue = [from?: string, to?: string];
14
+ /** A filter value read into calendar dates; an absent bound leaves that end open. */
15
+ export type DateRange = {
16
+ from?: CalendarDate;
17
+ to?: CalendarDate;
18
+ };
19
+ /**
20
+ * The range a filter value stands for. Invalid bounds are dropped and a reversed range is
21
+ * swapped (as TanStack's `inNumberRange` does). Always returns a range — never undefined —
22
+ * because the table falls back to the raw value when `resolveFilterValue` returns nullish.
23
+ */
24
+ export declare function resolveDateRange(filterValue: unknown): DateRange;
25
+ /** Whether a filter value has no usable bound, so the filter should be removed. */
26
+ export declare function isEmptyDateRange(filterValue: unknown): boolean;
27
+ /**
28
+ * Whether a cell value falls in `range`, judged by the calendar day it shows in `timeZone`.
29
+ * Empty or unparseable cell values never match while the filter is active.
30
+ */
31
+ export declare function matchesDateRange(value: unknown, range: DateRange, timeZone: string): boolean;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Calendar-day range filters for date and date-time columns.
3
+ *
4
+ * The filter value is `[from?, to?]`, each an ISO calendar date (`YYYY-MM-DD`) or empty — no
5
+ * time, no zone, so it serializes cleanly. Both bounds are inclusive days. A cell matches
6
+ * when the day it shows in the display zone (see `readDateInZone`) falls inside the range.
7
+ * For an instant that is the half-open interval [start of `from`, start of the day after
8
+ * `to`) in that zone, with day starts taken from the calendar — so 23- and 25-hour DST days
9
+ * need no special handling.
10
+ */
11
+ import { CalendarDate, parseDate, toCalendarDate } from '@internationalized/date';
12
+ import { readDateInZone } from './date';
13
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
14
+ /** A bound as a calendar date; anything but a valid `YYYY-MM-DD` reads as no bound. */
15
+ function readBound(value) {
16
+ if (typeof value !== 'string' || !DATE_ONLY.test(value))
17
+ return undefined;
18
+ try {
19
+ return parseDate(value);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /**
26
+ * The range a filter value stands for. Invalid bounds are dropped and a reversed range is
27
+ * swapped (as TanStack's `inNumberRange` does). Always returns a range — never undefined —
28
+ * because the table falls back to the raw value when `resolveFilterValue` returns nullish.
29
+ */
30
+ export function resolveDateRange(filterValue) {
31
+ if (!Array.isArray(filterValue))
32
+ return {};
33
+ const from = readBound(filterValue[0]);
34
+ const to = readBound(filterValue[1]);
35
+ if (from && to && from.compare(to) > 0)
36
+ return { from: to, to: from };
37
+ return { from, to };
38
+ }
39
+ /** Whether a filter value has no usable bound, so the filter should be removed. */
40
+ export function isEmptyDateRange(filterValue) {
41
+ const { from, to } = resolveDateRange(filterValue);
42
+ return !from && !to;
43
+ }
44
+ /**
45
+ * Whether a cell value falls in `range`, judged by the calendar day it shows in `timeZone`.
46
+ * Empty or unparseable cell values never match while the filter is active.
47
+ */
48
+ export function matchesDateRange(value, range, timeZone) {
49
+ const date = readDateInZone(value, timeZone);
50
+ if (!date)
51
+ return false;
52
+ const day = date instanceof CalendarDate ? date : toCalendarDate(date);
53
+ if (range.from && day.compare(range.from) < 0)
54
+ return false;
55
+ if (range.to && day.compare(range.to) > 0)
56
+ return false;
57
+ return true;
58
+ }
@@ -1,4 +1,4 @@
1
- import { type DateValue } from '@internationalized/date';
1
+ import { CalendarDate, ZonedDateTime, type DateValue } from '@internationalized/date';
2
2
  export type { DateValue };
3
3
  /**
4
4
  * Anything the date components accept for a single value: an
@@ -24,3 +24,21 @@ export declare function dateValueToString(value: DateValue): string;
24
24
  export declare function dateValueToDate(value: DateValue, timeZone?: string): Date;
25
25
  /** Emit a `DateValue` back in the original `shape` it was bound as. */
26
26
  export declare function fromDateValue(value: DateValue | null, shape: DateValueShape, timeZone?: string): DateValue | string | Date | null;
27
+ /**
28
+ * A value read for display in `timeZone`: a calendar date (a day, with no instant behind it)
29
+ * or an instant converted into `timeZone`.
30
+ */
31
+ export type ZonedOrCalendarDate = CalendarDate | ZonedDateTime;
32
+ /**
33
+ * Read any date-ish value — a `DateValue`, ISO/DB string, native `Date` or epoch-millisecond
34
+ * number — the way it should be shown in `timeZone`. Never throws: anything unparseable
35
+ * (including an out-of-range date like `2024-02-30`) is `null`.
36
+ *
37
+ * - `"2024-03-15"` and `CalendarDate` stay calendar dates. They are never turned into an
38
+ * instant, which would put them on the previous day in zones west of UTC.
39
+ * - `Date`, epoch numbers, offset strings (`Z`, `+02:00`) and `ZonedDateTime` are instants,
40
+ * converted into `timeZone`.
41
+ * - A datetime without an offset (`"2024-03-15T10:30"`, `CalendarDateTime`) is a wall-clock
42
+ * time in `timeZone`.
43
+ */
44
+ export declare function readDateInZone(value: unknown, timeZone: string): ZonedOrCalendarDate | null;
@@ -1,4 +1,4 @@
1
- import { parseDate, parseDateTime, parseAbsolute, parseAbsoluteToLocal, fromDate, getLocalTimeZone } from '@internationalized/date';
1
+ import { CalendarDate, CalendarDateTime, ZonedDateTime, parseDate, parseDateTime, parseAbsolute, parseAbsoluteToLocal, fromAbsolute, fromDate, getLocalTimeZone, toTimeZone, toZoned } from '@internationalized/date';
2
2
  const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
3
3
  // Trailing `Z`, or a `+HH`, `+HH:MM`, `+HHMM` offset.
4
4
  const HAS_OFFSET = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/;
@@ -62,3 +62,50 @@ export function fromDateValue(value, shape, timeZone) {
62
62
  return dateValueToDate(value, timeZone);
63
63
  return value;
64
64
  }
65
+ /**
66
+ * Read any date-ish value — a `DateValue`, ISO/DB string, native `Date` or epoch-millisecond
67
+ * number — the way it should be shown in `timeZone`. Never throws: anything unparseable
68
+ * (including an out-of-range date like `2024-02-30`) is `null`.
69
+ *
70
+ * - `"2024-03-15"` and `CalendarDate` stay calendar dates. They are never turned into an
71
+ * instant, which would put them on the previous day in zones west of UTC.
72
+ * - `Date`, epoch numbers, offset strings (`Z`, `+02:00`) and `ZonedDateTime` are instants,
73
+ * converted into `timeZone`.
74
+ * - A datetime without an offset (`"2024-03-15T10:30"`, `CalendarDateTime`) is a wall-clock
75
+ * time in `timeZone`.
76
+ */
77
+ export function readDateInZone(value, timeZone) {
78
+ try {
79
+ return readDate(value, timeZone);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ function readDate(value, timeZone) {
86
+ if (value == null)
87
+ return null;
88
+ if (value instanceof Date) {
89
+ return Number.isNaN(value.getTime()) ? null : fromDate(value, timeZone);
90
+ }
91
+ if (typeof value === 'number') {
92
+ return Number.isFinite(value) ? fromAbsolute(value, timeZone) : null;
93
+ }
94
+ if (value instanceof ZonedDateTime)
95
+ return toTimeZone(value, timeZone);
96
+ if (value instanceof CalendarDateTime)
97
+ return toZoned(value, timeZone);
98
+ if (value instanceof CalendarDate)
99
+ return value;
100
+ if (typeof value !== 'string')
101
+ return null;
102
+ const raw = value.trim();
103
+ if (!raw)
104
+ return null;
105
+ if (DATE_ONLY.test(raw))
106
+ return parseDate(raw);
107
+ const iso = normalizeIso(raw);
108
+ return HAS_OFFSET.test(iso)
109
+ ? parseAbsolute(iso, timeZone)
110
+ : toZoned(parseDateTime(iso), timeZone);
111
+ }
@@ -0,0 +1,22 @@
1
+ declare function releaseFocusOnExit(event: Event): void;
2
+ /**
3
+ * Spread onto Ark overlay content (popover, dialog, drawer) so a field focused inside it blurs
4
+ * before the content unmounts, not during the unmount.
5
+ *
6
+ * Zag's presence can unmount content from inside a Svelte effect (it does when the document is
7
+ * hidden). Svelte then removes the content mid-flush; a field still focused in there blurs during
8
+ * that removal, and zag handles some blurs with `flushSync` (DatePicker's machine, for one). That
9
+ * nested `flushSync` breaks the outer flush: "Cannot read properties of null (reading 'clear')"
10
+ * in Svelte's `flush_queued_effects` (still the case in svelte 5.57.2).
11
+ *
12
+ * Zag dispatches `exitcomplete` on the content node once presence decides to unmount, before
13
+ * Svelte removes it. Blurring there lets the field commit on blur as usual, at a point where a
14
+ * nested `flushSync` is safe. Usually focus is already back on the trigger by then (exit
15
+ * animations outlast the focus restore), and this does nothing.
16
+ *
17
+ * A spread because Ark's content prop types don't list the `exitcomplete` event.
18
+ */
19
+ export declare const releaseFocusOnExitProps: {
20
+ onexitcomplete: typeof releaseFocusOnExit;
21
+ };
22
+ export {};
@@ -0,0 +1,26 @@
1
+ function releaseFocusOnExit(event) {
2
+ const content = event.currentTarget;
3
+ if (!(content instanceof HTMLElement))
4
+ return;
5
+ const active = content.ownerDocument.activeElement;
6
+ if (active instanceof HTMLElement && content.contains(active))
7
+ active.blur();
8
+ }
9
+ /**
10
+ * Spread onto Ark overlay content (popover, dialog, drawer) so a field focused inside it blurs
11
+ * before the content unmounts, not during the unmount.
12
+ *
13
+ * Zag's presence can unmount content from inside a Svelte effect (it does when the document is
14
+ * hidden). Svelte then removes the content mid-flush; a field still focused in there blurs during
15
+ * that removal, and zag handles some blurs with `flushSync` (DatePicker's machine, for one). That
16
+ * nested `flushSync` breaks the outer flush: "Cannot read properties of null (reading 'clear')"
17
+ * in Svelte's `flush_queued_effects` (still the case in svelte 5.57.2).
18
+ *
19
+ * Zag dispatches `exitcomplete` on the content node once presence decides to unmount, before
20
+ * Svelte removes it. Blurring there lets the field commit on blur as usual, at a point where a
21
+ * nested `flushSync` is safe. Usually focus is already back on the trigger by then (exit
22
+ * animations outlast the focus restore), and this does nothing.
23
+ *
24
+ * A spread because Ark's content prop types don't list the `exitcomplete` event.
25
+ */
26
+ export const releaseFocusOnExitProps = { onexitcomplete: releaseFocusOnExit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compote-ui",
3
- "version": "0.79.2",
3
+ "version": "0.81.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -183,8 +183,13 @@ Composable floating panel anchored to a trigger.
183
183
  </Popover.Root>
184
184
  ```
185
185
 
186
- `Popover.Content` accepts `showArrow?: boolean` (default `true`). Control placement via
187
- `<Popover.Root positioning={{ placement: 'right' }}>`.
186
+ `Popover.Content` accepts `showArrow?: boolean` (default `true`) and forwards other props to Ark's
187
+ `Popover.Content`. Control placement via `<Popover.Root positioning={{ placement: 'right' }}>`.
188
+
189
+ The dialog is labelled automatically: while a `Popover.Title` / `Popover.Description` is rendered
190
+ inside, `Popover.Content` gets `aria-labelledby` / `aria-describedby` pointing at it — no `ids` or
191
+ manual `aria-*` needed (Zag alone misses them because content mounts lazily). An explicit
192
+ `aria-labelledby` / `aria-describedby` (or `aria-label`, for the title) on `Popover.Content` wins.
188
193
 
189
194
  ## HoverCard
190
195
 
@@ -8,7 +8,7 @@ description: >
8
8
  metadata:
9
9
  type: composition
10
10
  library: compote-ui
11
- library_version: '0.79.2'
11
+ library_version: '0.81.0'
12
12
  requires:
13
13
  - component-usage
14
14
  - theming
@@ -20,6 +20,13 @@ sources:
20
20
  - src/lib/components/data-table-v9/column-helper.ts
21
21
  - src/lib/components/data-table-v9/create-table.svelte.ts
22
22
  - src/lib/components/data-table-v9/data-table.svelte
23
+ - src/lib/components/data-table-v9/data-table-head.svelte
24
+ - src/lib/components/data-table-v9/header/column-header-filter.svelte
25
+ - src/lib/components/data-table-v9/column-filter-kind.ts
26
+ - src/lib/components/data-table-v9/filter-editors/column-filter-editor.svelte
27
+ - src/lib/components/data-table-v9/column-filter-editing.svelte.ts
28
+ - src/lib/utils/select-filter.ts
29
+ - src/lib/utils/date-filter.ts
23
30
  - src/lib/components/data-table-v9/virtual/data-table-virtualized.svelte
24
31
  - src/lib/components/data-table-v9/virtual/data-table-virtual-rows.svelte
25
32
  ---
@@ -162,6 +169,78 @@ shows the active filter count as a badge in the corner either way.
162
169
  <DataTable.ColumnVisibility {table} triggerLabel="Columns" />
163
170
  ```
164
171
 
172
+ ### Filter from column headers
173
+
174
+ Pass `headerFilters` to `DataTable.Root` / `VirtualDataTable.Root` to put a funnel in the header
175
+ of every filterable leaf column (not group headers, not `enableColumnFilter: false`, never
176
+ `type: 'action'`). The funnel shows on header hover/focus (always on touch screens); once the
177
+ column is filtered it stays visible, filled and in the primary color. Clicking it opens a popup
178
+ titled with the column name, with a "Clear" button and the editor for the column's type:
179
+
180
+ | Column `type` | Popup editor | Filter value |
181
+ | ----------------------------------- | -------------------------------------- | -------------------------------- |
182
+ | `number`, `currency`, `percent` | From / To number inputs | `[min?, max?]` (`inNumberRange`) |
183
+ | `boolean` | All / Yes / No | `true` / `false` |
184
+ | `select` | Search + checkbox list with row counts | array of options |
185
+ | `date`, `date-time` | From / To date pickers | `[from?, to?]` as `YYYY-MM-DD` |
186
+ | anything else (`text`, `time`, ...) | Text search | string |
187
+
188
+ Edits apply immediately (text and number inputs after a 300ms debounce — also when the popup
189
+ closes first). The header popups and `DataTable.ColumnFilter` edit the same table state, so a
190
+ filter set in one shows in the other. `headerFilters` is opt-in and defaults to `false`.
191
+
192
+ ```svelte
193
+ <div class="h-96 min-h-0">
194
+ <DataTable.Root {table} caption="Invoices" headerFilters />
195
+ </div>
196
+ ```
197
+
198
+ ### Build your own filter UI
199
+
200
+ For filter controls outside the toolbar and header popups (a facet panel beside the table, a
201
+ filter sidebar), use the same building blocks the Funnel uses. They read and write the table's
202
+ column filters, so the Funnel, header funnels and your UI always show the same state.
203
+
204
+ - `ColumnFilterEditor` — the Funnel's editor for one column, picked by its type (see the table in
205
+ "Filter from column headers"). Pass `editing` only if the editor can be torn down while it holds
206
+ a pending text/range edit (see below); without it the editor uses one bound to the column's table.
207
+ - `ColumnFilterEditing` — the debounced text/range edits pending on a table, shared by every host.
208
+ `isActive(columnId)` lets a host refuse writes from an editor it has already dropped (Ark's
209
+ NumberInput commits on the blur its unmount triggers).
210
+ - `getColumnFilterKind(column)` / `filterKindFor(type)` — the column's `ColumnFilterKind`
211
+ (`range`, `boolean`, `select`, `date` or `text`), or `null` for columns that never filter.
212
+ - `selectFilterOptions(column.getFacetedUniqueValues())` — a select column's options with row
213
+ counts, sorted by label, "(empty)" last with value `EMPTY_SELECT_VALUE` (`null`). Counts are
214
+ cross-filtered: every other active filter applies, the column's own does not.
215
+ - `getColumnLabel(column)` and the `DataTableColumnInstance<T>` type for the columns you list.
216
+
217
+ ```svelte
218
+ <script lang="ts">
219
+ import * as DataTable from 'compote-ui/data-table';
220
+
221
+ let { table }: { table: DataTable.DataTableInstance<Product> } = $props();
222
+
223
+ const facets = $derived(
224
+ table
225
+ .getAllLeafColumns()
226
+ .filter((column) => column.getCanFilter() && DataTable.getColumnFilterKind(column))
227
+ );
228
+ </script>
229
+
230
+ {#each facets as column (column.id)}
231
+ <h3>{DataTable.getColumnLabel(column)}</h3>
232
+ <DataTable.ColumnFilterEditor {column} />
233
+ {/each}
234
+ ```
235
+
236
+ A custom select list writes the same value the Funnel does — an array of options, `undefined`
237
+ when none is ticked:
238
+
239
+ ```ts
240
+ const selected = (column.getFilterValue() as DataTable.SelectFilterValue[] | undefined) ?? [];
241
+ column.setFilterValue(next.length ? next : undefined);
242
+ ```
243
+
165
244
  ### Add a refresh button
166
245
 
167
246
  `DataTable.Refresh` is data-source agnostic: it calls `onRefresh` and, if that returns a promise,
@@ -58,6 +58,29 @@ Type defaults:
58
58
 
59
59
  Use one `grow: true` column to absorb extra horizontal space.
60
60
 
61
+ ## Date columns: display and filter
62
+
63
+ `date`, `time` and `date-time` cells are read the same way for display and for the date filter,
64
+ in the column's display zone — `formatOptions.timeZone` when set, otherwise the viewer's local
65
+ zone:
66
+
67
+ - `'2024-03-15'` (and a `CalendarDate`) is a calendar day. It always shows and filters as
68
+ 15 March, also in zones west of UTC — it is never turned into a midnight-UTC instant.
69
+ - `Date`, epoch milliseconds, strings with an offset (`Z`, `+02:00`, DB `+02`) and
70
+ `ZonedDateTime` are instants, shown and filtered on the day they fall on in the display zone.
71
+ - A datetime without an offset (`'2024-03-15T10:30'`) is wall-clock time in the display zone.
72
+ - Unreadable values (e.g. `'2024-02-30'`) render as `-` and never match an active filter.
73
+
74
+ `date` and `date-time` columns filter by calendar-day range. The filter value is
75
+ `[from?, to?]`, each `YYYY-MM-DD` or empty (open-ended); both days are inclusive, a reversed
76
+ range is swapped, and an empty range removes the filter. Instants are judged by their calendar
77
+ day, so 23- and 25-hour DST days need no special handling. `time` columns keep the text filter.
78
+
79
+ ```ts
80
+ table.getColumn('createdAt')?.setFilterValue(['2024-03-01', '2024-03-31']); // March
81
+ table.getColumn('createdAt')?.setFilterValue([undefined, '2024-03-31']); // up to 31 March
82
+ ```
83
+
61
84
  ## Select columns with multiple values
62
85
 
63
86
  A `type: 'select'` column may return an array (tags, multi-select attributes). The Funnel then
@@ -134,6 +157,8 @@ booleans and object-valued columns are excluded from global search. Also availab
134
157
 
135
158
  `table: DataTableInstance<T>` (required), `caption?: string` (sr-only `<caption>`),
136
159
  `emptyMessage?: string` (default `'No rows found'`), `class?: ClassValue`,
160
+ `headerFilters?: boolean` (default `false`; funnel filter popups in column headers — see
161
+ "Filter from column headers" in SKILL.md),
137
162
  `onRowClick?: (details: { row: T; event: MouseEvent }) => void`,
138
163
  `onRowDoubleClick?: (details: { row: T; event: MouseEvent }) => void` — `row` is `row.original`.
139
164
  Clicks on the row-selection checkbox and url-cell buttons do **not** trigger either handler.
@@ -29,14 +29,19 @@ const filters = $derived(table.store.state.columnFilters);
29
29
  ```
30
30
 
31
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.
32
+ `table.setColumnFilters(...)`, `table.resetColumnFilters()`. `DataTable.ColumnFilter` and the
33
+ `headerFilters` popups show the live filter value in their inputs (they only hold edits still
34
+ inside the 300ms debounce), so an external change clears or updates the inputs, and editing one
35
+ range bound keeps the other bound's current value. An edit still inside the debounce is dropped
36
+ when its column's filter changes from outside — even if the filter ends on its old value again
37
+ (e.g. reset, then saved filters restored), since `createTable` keeps a per-column filter revision
38
+ that only goes up.
39
+
40
+ Pending edits belong to the table, not to the control they were typed in: the latest edit to a
41
+ column wins whether it came from a header popup or the toolbar, and it still commits after its
42
+ popup closes. `table.resetColumnFilters()` and `table.reset()` cancel every pending edit — also
43
+ one typed into a column that had no filter yet, which a reset would otherwise leave untouched —
44
+ and so does destroying the component that created the table.
40
45
 
41
46
  ```ts
42
47
  // e.g. drop every filter on columns that belong to the previous category