compote-ui 0.79.2 → 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 (58) 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 +37 -25
  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/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/table-view-state.svelte.d.ts +1 -0
  29. package/dist/components/data-table-v9/table-view-state.svelte.js +3 -0
  30. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte +203 -459
  31. package/dist/components/data-table-v9/toolbar/data-table-column-filter.svelte.d.ts +1 -1
  32. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte +82 -79
  33. package/dist/components/data-table-v9/virtual/data-table-virtualized.svelte.d.ts +2 -0
  34. package/dist/components/dialog/alert-dialog.svelte +73 -71
  35. package/dist/components/dialog/dialog-root.svelte +45 -43
  36. package/dist/components/drawer/drawer-content.svelte +213 -211
  37. package/dist/components/popover/popover-content.svelte +97 -71
  38. package/dist/components/popover/popover-content.svelte.d.ts +2 -1
  39. package/dist/components/popover/popover-description.svelte +19 -15
  40. package/dist/components/popover/popover-labels.svelte.d.ts +18 -0
  41. package/dist/components/popover/popover-labels.svelte.js +35 -0
  42. package/dist/components/popover/popover-root.svelte +27 -24
  43. package/dist/components/popover/popover-title.svelte +19 -15
  44. package/dist/icons/PhFunnelFill.svelte +19 -0
  45. package/dist/icons/PhFunnelFill.svelte.d.ts +5 -0
  46. package/dist/icons/index.d.ts +1 -0
  47. package/dist/icons/index.js +1 -0
  48. package/dist/utils/date-filter.d.ts +31 -0
  49. package/dist/utils/date-filter.js +58 -0
  50. package/dist/utils/date.d.ts +19 -1
  51. package/dist/utils/date.js +48 -1
  52. package/dist/utils/release-focus-on-exit.d.ts +22 -0
  53. package/dist/utils/release-focus-on-exit.js +26 -0
  54. package/package.json +1 -1
  55. package/skills/component-usage/references/overlays-and-floating-ui.md +278 -273
  56. package/skills/data-table/SKILL.md +314 -283
  57. package/skills/data-table/references/column-options.md +175 -150
  58. package/skills/data-table/references/table-state-and-reactivity.md +65 -60
@@ -1,459 +1,203 @@
1
- <script lang="ts" generics="T extends RowData">
2
- import { onDestroy } from 'svelte';
3
- import type { Column, RowData } from '@tanstack/svelte-table';
4
- import * as Popover from '../../popover';
5
- import * as Tooltip from '../../tooltip';
6
- import * as ScrollArea from '../../scroll-area';
7
- import Checkbox from '../../checkbox/checkbox.svelte';
8
- import { cn } from 'tailwind-variants';
9
- import type { DataTableInstance } from '../data-table-utils';
10
- import type { DataTableFeatures } from '../features';
11
- import NumberInput from '../../number-input/number-input.svelte';
12
- import * as Field from '../../field';
13
- import Badge from '../../badge/badge.svelte';
14
- import { button } from '../../button/button.variants';
15
- import { PhX, PhMagnifyingGlass, PhFunnel } from '../../../icons';
16
- import {
17
- selectFilterOptions,
18
- type SelectFilterOption,
19
- type SelectFilterValue
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';
24
-
25
- type Props = {
26
- table: DataTableInstance<T>;
27
- /** Text label for the trigger. When omitted, a funnel icon is shown instead. */
28
- triggerLabel?: string;
29
- };
30
-
31
- let { table, triggerLabel }: Props = $props();
32
-
33
- // Popover and tooltip share one trigger element, so both must agree on its id.
34
- const triggerId = $props.id();
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
-
52
- let localSelectSearch: Record<string, string> = $state({});
53
-
54
- const columnFilters = $derived.by(() => table.atoms.columnFilters.get());
55
- const columnVisibility = $derived.by(() => table.atoms.columnVisibility.get());
56
- const activeCount = $derived(columnFilters.length);
57
-
58
- // Filters the user has explicitly opened. Kept separate from the table's
59
- // columnFilters so a filter card stays visible after its value is cleared
60
- // (e.g. unchecking the last select option) until removed via the X button.
61
- let manualFilterIds = $state<string[]>([]);
62
- const activeFilterIds: string[] = $derived.by(() => {
63
- const ids = columnFilters.map((f) => f.id);
64
- for (const id of manualFilterIds) {
65
- if (!ids.includes(id)) ids.push(id);
66
- }
67
- return ids;
68
- });
69
- let showColumnPicker = $state(false);
70
- let columnSearchText = $state('');
71
-
72
- const activeColumns = $derived.by(() => {
73
- return activeFilterIds
74
- .map((id) => table.getColumn(id))
75
- .filter((col): col is Column<DataTableFeatures, T, unknown> => col != null);
76
- });
77
-
78
- const availableColumns = $derived.by(() => {
79
- void columnVisibility;
80
- return table
81
- .getAllLeafColumns()
82
- .filter((col) => col.getCanFilter() && !activeFilterIds.includes(col.id))
83
- .filter(
84
- (col) =>
85
- !columnSearchText ||
86
- getColumnLabel(col).toLowerCase().includes(columnSearchText.toLowerCase())
87
- );
88
- });
89
-
90
- onDestroy(() => {
91
- textEdits.cancelAll();
92
- rangeEdits.cancelAll();
93
- });
94
-
95
- function getColumnType(column: Column<DataTableFeatures, T, unknown>): string | undefined {
96
- return (column.columnDef.meta as Record<string, unknown> | undefined)?.type as
97
- string | undefined;
98
- }
99
-
100
- function getColumnLabel(column: Column<DataTableFeatures, T, unknown>): string {
101
- return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
102
- }
103
-
104
- function addFilter(column: Column<DataTableFeatures, T, unknown>) {
105
- manualFilterIds = [...manualFilterIds, column.id];
106
- showColumnPicker = false;
107
- columnSearchText = '';
108
- }
109
-
110
- function removeFilter(column: Column<DataTableFeatures, T, unknown>) {
111
- manualFilterIds = manualFilterIds.filter((id) => id !== column.id);
112
- textEdits.cancel(column.id);
113
- rangeEdits.cancel(column.id);
114
- column.setFilterValue(undefined);
115
- delete localSelectSearch[column.id];
116
- }
117
-
118
- function clearFilters() {
119
- textEdits.cancelAll();
120
- rangeEdits.cancelAll();
121
- localSelectSearch = {};
122
- manualFilterIds = [];
123
- showColumnPicker = false;
124
- columnSearchText = '';
125
- table.resetColumnFilters();
126
- }
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
-
142
- function handleTextInput(column: Column<DataTableFeatures, T, unknown>, value: string) {
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;
161
- }
162
-
163
- function handleNumericInput(
164
- column: Column<DataTableFeatures, T, unknown>,
165
- which: 'min' | 'max',
166
- value: number | null
167
- ) {
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
- );
176
- }
177
-
178
- function getSelectValues(column: Column<DataTableFeatures, T, unknown>): SelectFilterValue[] {
179
- return (column.getFilterValue() as SelectFilterValue[] | undefined) ?? [];
180
- }
181
-
182
- function handleSelectChange(
183
- column: Column<DataTableFeatures, T, unknown>,
184
- value: SelectFilterValue,
185
- checked: boolean
186
- ) {
187
- const current = getSelectValues(column);
188
- const next = checked ? [...current, value] : current.filter((v) => v !== value);
189
- column.setFilterValue(next.length ? next : undefined);
190
- }
191
-
192
- function getSelectOptions(column: Column<DataTableFeatures, T, unknown>): SelectFilterOption[] {
193
- return selectFilterOptions(column.getFacetedUniqueValues());
194
- }
195
-
196
- function getFacetedMinMax(
197
- column: Column<DataTableFeatures, T, unknown>
198
- ): [number | undefined, number | undefined] {
199
- const vals = column.getFacetedMinMaxValues();
200
- return vals ? [vals[0] as number, vals[1] as number] : [undefined, undefined];
201
- }
202
-
203
- function getColumnFormatOptions(
204
- column: Column<DataTableFeatures, T, unknown>
205
- ): Intl.NumberFormatOptions | undefined {
206
- return (column.columnDef.meta as Record<string, unknown> | undefined)?.formatOptions as
207
- Intl.NumberFormatOptions | undefined;
208
- }
209
- </script>
210
-
211
- <Popover.Root ids={{ trigger: triggerId }} positioning={{ placement: 'bottom-end' }}>
212
- <Tooltip.Root ids={{ trigger: triggerId }} disabled={!!triggerLabel}>
213
- <Tooltip.Trigger>
214
- {#snippet asChild(tooltipProps)}
215
- <Popover.Trigger
216
- {...tooltipProps()}
217
- aria-label={triggerLabel ? undefined : 'Filters'}
218
- class={button({
219
- variant: 'outline',
220
- size: triggerLabel ? 'default' : 'icon',
221
- class: 'relative'
222
- })}
223
- >
224
- {#if triggerLabel}
225
- {triggerLabel}
226
- {:else}
227
- <PhFunnel />
228
- {/if}
229
- {#if activeCount > 0}
230
- <Badge
231
- variant="solid"
232
- color="primary"
233
- class="absolute top-0 right-0 size-4 translate-x-1/3 -translate-y-1/3 justify-center px-1 text-xs"
234
- >
235
- {activeCount > 99 ? '99+' : activeCount}
236
- </Badge>
237
- {/if}
238
- </Popover.Trigger>
239
- {/snippet}
240
- </Tooltip.Trigger>
241
- <Tooltip.Content>Filters</Tooltip.Content>
242
- </Tooltip.Root>
243
-
244
- <Popover.Content class="flex w-70 flex-col gap-3 p-3" showArrow={false}>
245
- <div class="mr-1 flex items-center justify-between py-2.5">
246
- <span class="text-sm font-medium text-ink">Filters</span>
247
- {#if activeCount > 0}
248
- <button type="button" onclick={clearFilters} class="text-xs text-primary hover:underline">
249
- Clear all
250
- </button>
251
- {/if}
252
- </div>
253
-
254
- {#if activeColumns.length > 0}
255
- <div class="overflow-hidden border-t border-surface-2">
256
- <ScrollArea.Root>
257
- <ScrollArea.Viewport class="max-h-96">
258
- <ScrollArea.Content class="flex flex-col gap-3">
259
- {#each activeColumns as column (column.id)}
260
- <div class="border border-surface-3 p-3">
261
- <div class="mb-2 flex items-center justify-between">
262
- <span class="text-sm font-medium text-ink">{getColumnLabel(column)}</span>
263
- <button
264
- type="button"
265
- onclick={() => removeFilter(column)}
266
- class="text-ink-dim transition-colors hover:text-ink"
267
- >
268
- <PhX class="size-3.5" />
269
- </button>
270
- </div>
271
-
272
- {#if getColumnType(column) === 'number' || getColumnType(column) === 'currency' || getColumnType(column) === 'percent'}
273
- {@const [facetMin, facetMax] = getFacetedMinMax(column)}
274
- {@const colFormatOptions = getColumnFormatOptions(column)}
275
- <div class="flex flex-col gap-1.5">
276
- <div class="min-w-0 flex-1">
277
- <NumberInput
278
- layout="horizontal"
279
- label="From"
280
- bind:value={
281
- () => getRangeBound(column, 'min'),
282
- (bound) => handleNumericInput(column, 'min', bound ?? null)
283
- }
284
- min={facetMin}
285
- max={facetMax}
286
- formatOptions={colFormatOptions}
287
- />
288
- </div>
289
- <div class="min-w-0 flex-1">
290
- <NumberInput
291
- layout="horizontal"
292
- label="To"
293
- bind:value={
294
- () => getRangeBound(column, 'max'),
295
- (bound) => handleNumericInput(column, 'max', bound ?? null)
296
- }
297
- min={facetMin}
298
- max={facetMax}
299
- formatOptions={colFormatOptions}
300
- />
301
- </div>
302
- </div>
303
- {:else if getColumnType(column) === 'boolean'}
304
- {@const boolFilter = column.getFilterValue() as boolean | undefined}
305
- <div class="flex overflow-hidden rounded border border-border text-xs">
306
- <button
307
- type="button"
308
- onclick={() => column.setFilterValue(undefined)}
309
- class={cn(
310
- 'flex-1 px-2 py-1',
311
- boolFilter === undefined
312
- ? 'bg-surface-3 font-medium text-ink'
313
- : 'text-ink-dim hover:bg-surface-2'
314
- )}
315
- >
316
- All
317
- </button>
318
- <button
319
- type="button"
320
- onclick={() =>
321
- column.setFilterValue(boolFilter === true ? undefined : true)}
322
- class={cn(
323
- 'flex-1 border-x border-border px-2 py-1',
324
- boolFilter === true
325
- ? 'bg-surface-3 font-medium text-ink'
326
- : 'text-ink-dim hover:bg-surface-2'
327
- )}
328
- >
329
- Yes
330
- </button>
331
- <button
332
- type="button"
333
- onclick={() =>
334
- column.setFilterValue(boolFilter === false ? undefined : false)}
335
- class={cn(
336
- 'flex-1 px-2 py-1',
337
- boolFilter === false
338
- ? 'bg-surface-3 font-medium text-ink'
339
- : 'text-ink-dim hover:bg-surface-2'
340
- )}
341
- >
342
- No
343
- </button>
344
- </div>
345
- {:else if getColumnType(column) === 'select'}
346
- {@const allOptions = getSelectOptions(column)}
347
- {@const search = localSelectSearch[column.id] ?? ''}
348
- {@const options = search
349
- ? allOptions.filter((o) =>
350
- o.label.toLowerCase().includes(search.toLowerCase())
351
- )
352
- : allOptions}
353
- {@const selected = getSelectValues(column)}
354
- <div class="flex flex-col gap-1">
355
- <Field.Root hideMessageLine>
356
- <Field.Input
357
- placeholder="Search..."
358
- value={search}
359
- oninput={(e: Event) => {
360
- localSelectSearch[column.id] = (
361
- e.currentTarget as HTMLInputElement
362
- ).value;
363
- }}
364
- />
365
- </Field.Root>
366
- <ScrollArea.Root>
367
- <ScrollArea.Viewport class="max-h-40">
368
- <ScrollArea.Content>
369
- <div class="flex flex-col gap-0.5">
370
- {#each options as option (option.value)}
371
- <div
372
- class="flex min-h-7 items-center gap-2 rounded-sm pr-2 hover:bg-surface-2"
373
- >
374
- <Checkbox
375
- size="sm"
376
- label={option.label}
377
- class="min-h-7 flex-1 px-2"
378
- checked={selected.includes(option.value)}
379
- onCheckedChange={({ checked }) =>
380
- handleSelectChange(column, option.value, checked === true)}
381
- />
382
- <span class="text-xs text-ink-dim tabular-nums"
383
- >{option.count}</span
384
- >
385
- </div>
386
- {/each}
387
- </div>
388
- </ScrollArea.Content>
389
- </ScrollArea.Viewport>
390
- <ScrollArea.Scrollbar orientation="vertical">
391
- <ScrollArea.Thumb />
392
- </ScrollArea.Scrollbar>
393
- <ScrollArea.Corner />
394
- </ScrollArea.Root>
395
- </div>
396
- {:else}
397
- <Field.Root hideMessageLine>
398
- <Field.Input
399
- placeholder="Search..."
400
- bind:value={
401
- () => getTextValue(column),
402
- (text) => handleTextInput(column, String(text ?? ''))
403
- }
404
- />
405
- </Field.Root>
406
- {/if}
407
- </div>
408
- {/each}
409
- </ScrollArea.Content>
410
- </ScrollArea.Viewport>
411
- <ScrollArea.Scrollbar orientation="vertical">
412
- <ScrollArea.Thumb />
413
- </ScrollArea.Scrollbar>
414
- <ScrollArea.Corner />
415
- </ScrollArea.Root>
416
- </div>
417
- {/if}
418
-
419
- {#if showColumnPicker}
420
- <div class="border-t border-surface-3 p-3">
421
- <div class="relative mb-1">
422
- <PhMagnifyingGlass
423
- class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-ink-dim"
424
- />
425
- <input
426
- type="text"
427
- placeholder="Search columns..."
428
- bind:value={columnSearchText}
429
- class="h-8 w-full rounded border border-border bg-surface-1 pr-3 pl-7 text-sm text-ink outline-none placeholder:text-ink-dim focus:ring-1 focus:ring-ring"
430
- />
431
- </div>
432
- <div class="max-h-48 overflow-y-auto">
433
- {#each availableColumns as column (column.id)}
434
- <button
435
- type="button"
436
- onclick={() => addFilter(column)}
437
- class="w-full rounded px-2 py-1.5 text-left text-sm text-ink hover:bg-surface-2"
438
- >
439
- {getColumnLabel(column)}
440
- </button>
441
- {:else}
442
- <p class="px-2 py-3 text-center text-sm text-ink-dim">No more columns</p>
443
- {/each}
444
- </div>
445
- </div>
446
- {:else}
447
- <div class="border-t border-surface-3">
448
- <button
449
- type="button"
450
- onclick={() => (showColumnPicker = true)}
451
- class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-ink-dim hover:bg-surface-2 hover:text-ink"
452
- >
453
- <span class="text-base leading-none">+</span>
454
- Add Filter
455
- </button>
456
- </div>
457
- {/if}
458
- </Popover.Content>
459
- </Popover.Root>
1
+ <script lang="ts" generics="T extends RowData">
2
+ import type { Column, RowData } from '@tanstack/svelte-table';
3
+ import * as Popover from '../../popover';
4
+ import * as Tooltip from '../../tooltip';
5
+ import * as ScrollArea from '../../scroll-area';
6
+ import { getColumnLabel, type DataTableInstance } from '../data-table-utils';
7
+ import type { DataTableFeatures } from '../features';
8
+ import Badge from '../../badge/badge.svelte';
9
+ import { button } from '../../button/button.variants';
10
+ import { PhX, PhMagnifyingGlass, PhFunnel } from '../../../icons';
11
+ import { ColumnFilterEditing } from '../column-filter-editing.svelte';
12
+ import ColumnFilterEditor from '../filter-editors/column-filter-editor.svelte';
13
+
14
+ type Props = {
15
+ table: DataTableInstance<T>;
16
+ /** Text label for the trigger. When omitted, a funnel icon is shown instead. */
17
+ triggerLabel?: string;
18
+ };
19
+
20
+ let { table, triggerLabel }: Props = $props();
21
+
22
+ // Popover and tooltip share one trigger element, so both must agree on its id.
23
+ const triggerId = $props.id();
24
+
25
+ // Pending edits belong to the table: shared with the other filter hosts, and they still
26
+ // commit after this popover closes.
27
+ const editing = new ColumnFilterEditing({
28
+ table: () => table,
29
+ isActive: (columnId) => activeFilterIds.includes(columnId)
30
+ });
31
+
32
+ const columnFilters = $derived.by(() => table.atoms.columnFilters.get());
33
+ const columnVisibility = $derived.by(() => table.atoms.columnVisibility.get());
34
+ const activeCount = $derived(columnFilters.length);
35
+
36
+ // Filters the user has explicitly opened. Kept separate from the table's
37
+ // columnFilters so a filter card stays visible after its value is cleared
38
+ // (e.g. unchecking the last select option) until removed via the X button.
39
+ let manualFilterIds = $state<string[]>([]);
40
+ const activeFilterIds: string[] = $derived.by(() => {
41
+ const ids = columnFilters.map((f) => f.id);
42
+ for (const id of manualFilterIds) {
43
+ if (!ids.includes(id)) ids.push(id);
44
+ }
45
+ return ids;
46
+ });
47
+ let showColumnPicker = $state(false);
48
+ let columnSearchText = $state('');
49
+
50
+ const activeColumns = $derived.by(() => {
51
+ return activeFilterIds
52
+ .map((id) => table.getColumn(id))
53
+ .filter((col): col is Column<DataTableFeatures, T, unknown> => col != null);
54
+ });
55
+
56
+ const availableColumns = $derived.by(() => {
57
+ void columnVisibility;
58
+ return table
59
+ .getAllLeafColumns()
60
+ .filter((col) => col.getCanFilter() && !activeFilterIds.includes(col.id))
61
+ .filter(
62
+ (col) =>
63
+ !columnSearchText ||
64
+ getColumnLabel(col).toLowerCase().includes(columnSearchText.toLowerCase())
65
+ );
66
+ });
67
+
68
+ function addFilter(column: Column<DataTableFeatures, T, unknown>) {
69
+ manualFilterIds = [...manualFilterIds, column.id];
70
+ showColumnPicker = false;
71
+ columnSearchText = '';
72
+ }
73
+
74
+ function removeFilter(column: Column<DataTableFeatures, T, unknown>) {
75
+ manualFilterIds = manualFilterIds.filter((id) => id !== column.id);
76
+ editing.cancel(column.id);
77
+ column.setFilterValue(undefined);
78
+ }
79
+
80
+ function clearFilters() {
81
+ editing.cancelAll();
82
+ manualFilterIds = [];
83
+ showColumnPicker = false;
84
+ columnSearchText = '';
85
+ table.resetColumnFilters();
86
+ }
87
+ </script>
88
+
89
+ <Popover.Root ids={{ trigger: triggerId }} positioning={{ placement: 'bottom-end' }}>
90
+ <Tooltip.Root ids={{ trigger: triggerId }} disabled={!!triggerLabel}>
91
+ <Tooltip.Trigger>
92
+ {#snippet asChild(tooltipProps)}
93
+ <Popover.Trigger
94
+ {...tooltipProps()}
95
+ aria-label={triggerLabel ? undefined : 'Filters'}
96
+ class={button({
97
+ variant: 'outline',
98
+ size: triggerLabel ? 'default' : 'icon',
99
+ class: 'relative'
100
+ })}
101
+ >
102
+ {#if triggerLabel}
103
+ {triggerLabel}
104
+ {:else}
105
+ <PhFunnel />
106
+ {/if}
107
+ {#if activeCount > 0}
108
+ <Badge
109
+ variant="solid"
110
+ color="primary"
111
+ class="absolute top-0 right-0 size-4 translate-x-1/3 -translate-y-1/3 justify-center px-1 text-xs"
112
+ >
113
+ {activeCount > 99 ? '99+' : activeCount}
114
+ </Badge>
115
+ {/if}
116
+ </Popover.Trigger>
117
+ {/snippet}
118
+ </Tooltip.Trigger>
119
+ <Tooltip.Content>Filters</Tooltip.Content>
120
+ </Tooltip.Root>
121
+
122
+ <Popover.Content class="flex w-70 flex-col gap-3 p-3" showArrow={false}>
123
+ <div class="mr-1 flex items-center justify-between py-2.5">
124
+ <Popover.Title class="text-sm font-medium text-ink">Filters</Popover.Title>
125
+ {#if activeCount > 0}
126
+ <button type="button" onclick={clearFilters} class="text-xs text-primary hover:underline">
127
+ Clear all
128
+ </button>
129
+ {/if}
130
+ </div>
131
+
132
+ {#if activeColumns.length > 0}
133
+ <div class="overflow-hidden border-t border-surface-2">
134
+ <ScrollArea.Root>
135
+ <ScrollArea.Viewport class="max-h-96">
136
+ <ScrollArea.Content class="flex flex-col gap-3">
137
+ {#each activeColumns as column (column.id)}
138
+ <div class="border border-surface-3 p-3">
139
+ <div class="mb-2 flex items-center justify-between">
140
+ <span class="text-sm font-medium text-ink">{getColumnLabel(column)}</span>
141
+ <button
142
+ type="button"
143
+ onclick={() => removeFilter(column)}
144
+ class="text-ink-dim transition-colors hover:text-ink"
145
+ >
146
+ <PhX class="size-3.5" />
147
+ </button>
148
+ </div>
149
+
150
+ <ColumnFilterEditor {column} {editing} />
151
+ </div>
152
+ {/each}
153
+ </ScrollArea.Content>
154
+ </ScrollArea.Viewport>
155
+ <ScrollArea.Scrollbar orientation="vertical">
156
+ <ScrollArea.Thumb />
157
+ </ScrollArea.Scrollbar>
158
+ <ScrollArea.Corner />
159
+ </ScrollArea.Root>
160
+ </div>
161
+ {/if}
162
+
163
+ {#if showColumnPicker}
164
+ <div class="border-t border-surface-3 p-3">
165
+ <div class="relative mb-1">
166
+ <PhMagnifyingGlass
167
+ class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-ink-dim"
168
+ />
169
+ <input
170
+ type="text"
171
+ placeholder="Search columns..."
172
+ bind:value={columnSearchText}
173
+ class="h-8 w-full rounded border border-border bg-surface-1 pr-3 pl-7 text-sm text-ink outline-none placeholder:text-ink-dim focus:ring-1 focus:ring-ring"
174
+ />
175
+ </div>
176
+ <div class="max-h-48 overflow-y-auto">
177
+ {#each availableColumns as column (column.id)}
178
+ <button
179
+ type="button"
180
+ onclick={() => addFilter(column)}
181
+ class="w-full rounded px-2 py-1.5 text-left text-sm text-ink hover:bg-surface-2"
182
+ >
183
+ {getColumnLabel(column)}
184
+ </button>
185
+ {:else}
186
+ <p class="px-2 py-3 text-center text-sm text-ink-dim">No more columns</p>
187
+ {/each}
188
+ </div>
189
+ </div>
190
+ {:else}
191
+ <div class="border-t border-surface-3">
192
+ <button
193
+ type="button"
194
+ onclick={() => (showColumnPicker = true)}
195
+ class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-ink-dim hover:bg-surface-2 hover:text-ink"
196
+ >
197
+ <span class="text-base leading-none">+</span>
198
+ Add Filter
199
+ </button>
200
+ </div>
201
+ {/if}
202
+ </Popover.Content>
203
+ </Popover.Root>