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