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
@@ -1,26 +1,15 @@
1
1
  <script lang="ts" generics="T extends RowData">
2
- import { onDestroy } from 'svelte';
3
2
  import type { Column, RowData } from '@tanstack/svelte-table';
4
3
  import * as Popover from '../../popover';
5
4
  import * as Tooltip from '../../tooltip';
6
5
  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';
6
+ import { getColumnLabel, type DataTableInstance } from '../data-table-utils';
10
7
  import type { DataTableFeatures } from '../features';
11
- import NumberInput from '../../number-input/number-input.svelte';
12
- import * as Field from '../../field';
13
8
  import Badge from '../../badge/badge.svelte';
14
9
  import { button } from '../../button/button.variants';
15
10
  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';
11
+ import { ColumnFilterEditing } from '../column-filter-editing.svelte';
12
+ import ColumnFilterEditor from '../filter-editors/column-filter-editor.svelte';
24
13
 
25
14
  type Props = {
26
15
  table: DataTableInstance<T>;
@@ -33,23 +22,12 @@
33
22
  // Popover and tooltip share one trigger element, so both must agree on its id.
34
23
  const triggerId = $props.id();
35
24
 
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({});
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
+ });
53
31
 
54
32
  const columnFilters = $derived.by(() => table.atoms.columnFilters.get());
55
33
  const columnVisibility = $derived.by(() => table.atoms.columnVisibility.get());
@@ -87,20 +65,6 @@
87
65
  );
88
66
  });
89
67
 
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
68
  function addFilter(column: Column<DataTableFeatures, T, unknown>) {
105
69
  manualFilterIds = [...manualFilterIds, column.id];
106
70
  showColumnPicker = false;
@@ -109,103 +73,17 @@
109
73
 
110
74
  function removeFilter(column: Column<DataTableFeatures, T, unknown>) {
111
75
  manualFilterIds = manualFilterIds.filter((id) => id !== column.id);
112
- textEdits.cancel(column.id);
113
- rangeEdits.cancel(column.id);
76
+ editing.cancel(column.id);
114
77
  column.setFilterValue(undefined);
115
- delete localSelectSearch[column.id];
116
78
  }
117
79
 
118
80
  function clearFilters() {
119
- textEdits.cancelAll();
120
- rangeEdits.cancelAll();
121
- localSelectSearch = {};
81
+ editing.cancelAll();
122
82
  manualFilterIds = [];
123
83
  showColumnPicker = false;
124
84
  columnSearchText = '';
125
85
  table.resetColumnFilters();
126
86
  }
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
87
  </script>
210
88
 
211
89
  <Popover.Root ids={{ trigger: triggerId }} positioning={{ placement: 'bottom-end' }}>
@@ -243,7 +121,7 @@
243
121
 
244
122
  <Popover.Content class="flex w-70 flex-col gap-3 p-3" showArrow={false}>
245
123
  <div class="mr-1 flex items-center justify-between py-2.5">
246
- <span class="text-sm font-medium text-ink">Filters</span>
124
+ <Popover.Title class="text-sm font-medium text-ink">Filters</Popover.Title>
247
125
  {#if activeCount > 0}
248
126
  <button type="button" onclick={clearFilters} class="text-xs text-primary hover:underline">
249
127
  Clear all
@@ -269,141 +147,7 @@
269
147
  </button>
270
148
  </div>
271
149
 
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}
150
+ <ColumnFilterEditor {column} {editing} />
407
151
  </div>
408
152
  {/each}
409
153
  </ScrollArea.Content>
@@ -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>;
@@ -1,4 +1,4 @@
1
- import type { FilterFnOption, RowData } from '@tanstack/svelte-table';
1
+ import type { Column, FilterFnOption, RowData } from '@tanstack/svelte-table';
2
2
  import type { Component, Snippet } from 'svelte';
3
3
  import type { DataTableFeatures } from './features';
4
4
  export type DataTableAlign = 'left' | 'center' | 'right';
@@ -63,6 +63,8 @@ export type DataTableGroupColumn<T extends RowData> = DataTableColumnBase & {
63
63
  formatLocale?: never;
64
64
  };
65
65
  export type DataTableColumn<T extends RowData> = DataTableLeafColumn<T> | DataTableGroupColumn<T>;
66
+ /** A column of a compote data table, as `table.getColumn()` and `getAllLeafColumns()` return it. */
67
+ export type DataTableColumnInstance<T extends RowData> = Column<DataTableFeatures, T, unknown>;
66
68
  export type DataTableColumnMeta = {
67
69
  align?: DataTableAlign;
68
70
  type?: DataTableColumnType;
@@ -14,6 +14,8 @@
14
14
  caption?: string;
15
15
  emptyMessage?: string;
16
16
  class?: ClassValue;
17
+ /** Show a filter funnel in the header of every filterable column. */
18
+ headerFilters?: boolean;
17
19
  onRowClick?: (details: { row: T; event: MouseEvent }) => void;
18
20
  onRowDoubleClick?: (details: { row: T; event: MouseEvent }) => void;
19
21
  };
@@ -23,6 +25,7 @@
23
25
  caption,
24
26
  emptyMessage = 'No rows found',
25
27
  class: className,
28
+ headerFilters = false,
26
29
  onRowClick,
27
30
  onRowDoubleClick,
28
31
  ...rest
@@ -52,7 +55,7 @@
52
55
  {#if caption}
53
56
  <caption class="sr-only">{caption}</caption>
54
57
  {/if}
55
- <DataTableHead {table} {view} isVirtual />
58
+ <DataTableHead {table} {view} isVirtual {headerFilters} />
56
59
  {#if scrollContainerRef}
57
60
  <DataTableVirtualRows
58
61
  {table}
@@ -8,6 +8,8 @@ declare function $$render<T extends RowData>(): {
8
8
  caption?: string;
9
9
  emptyMessage?: string;
10
10
  class?: ClassValue;
11
+ /** Show a filter funnel in the header of every filterable column. */
12
+ headerFilters?: boolean;
11
13
  onRowClick?: (details: {
12
14
  row: T;
13
15
  event: MouseEvent;
@@ -1,3 +1,3 @@
1
1
  export { default as Root } from './data-table-virtualized.svelte';
2
- export { ColumnFilter, ColumnVisibility, FlexRender, Refresh, Search, Title, Toolbar, createDataTableColumnHelper, createTable, renderComponent, renderSnippet } from '../index';
3
- export type { CreateDataTableOptions, DataTableAccessorFnColumn, DataTableAccessorKeyColumn, DataTableAlign, DataTableCellPropsResolver, DataTableCellRenderProps, DataTableColumn, DataTableColumnBase, DataTableColumnOptions, DataTableColumnType, DataTableGroupColumn, DataTableInstance, DataTableLeafColumn, DataTableLeafColumnBase } from '../index';
2
+ export { ColumnFilter, ColumnFilterEditing, ColumnFilterEditor, ColumnVisibility, EMPTY_SELECT_VALUE, FlexRender, Refresh, Search, Title, Toolbar, createDataTableColumnHelper, createTable, filterKindFor, getColumnFilterKind, getColumnLabel, renderComponent, renderSnippet, selectFilterOptions } from '../index';
3
+ export type { ColumnFilterEditingOptions, ColumnFilterKind, CreateDataTableOptions, DataTableAccessorFnColumn, DataTableAccessorKeyColumn, DataTableAlign, DataTableCellPropsResolver, DataTableCellRenderProps, DataTableColumn, DataTableColumnBase, DataTableColumnInstance, DataTableColumnOptions, DataTableColumnType, DataTableGroupColumn, DataTableInstance, DataTableLeafColumn, DataTableLeafColumnBase, FilterableColumn, SelectFilterOption, SelectFilterValue } from '../index';
@@ -1,2 +1,2 @@
1
1
  export { default as Root } from './data-table-virtualized.svelte';
2
- export { ColumnFilter, ColumnVisibility, FlexRender, Refresh, Search, Title, Toolbar, createDataTableColumnHelper, createTable, renderComponent, renderSnippet } from '../index';
2
+ export { ColumnFilter, ColumnFilterEditing, ColumnFilterEditor, ColumnVisibility, EMPTY_SELECT_VALUE, FlexRender, Refresh, Search, Title, Toolbar, createDataTableColumnHelper, createTable, filterKindFor, getColumnFilterKind, getColumnLabel, renderComponent, renderSnippet, selectFilterOptions } from '../index';
@@ -2,6 +2,7 @@
2
2
  import { PhX } from '../../icons';
3
3
  import { Dialog } from '@ark-ui/svelte/dialog';
4
4
  import { Portal } from '@ark-ui/svelte/portal';
5
+ import { releaseFocusOnExitProps } from '../../utils/release-focus-on-exit';
5
6
  import type { AlertDialogProps } from './dialog.types';
6
7
 
7
8
  let {
@@ -37,6 +38,7 @@
37
38
  >
38
39
  <Dialog.Content
39
40
  class="relative w-full max-w-md rounded-lg border bg-surface-1 p-6 shadow-xl data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
41
+ {...releaseFocusOnExitProps}
40
42
  >
41
43
  <Dialog.Title class="text-lg leading-none font-semibold tracking-tight">
42
44
  {title}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { Dialog } from '@ark-ui/svelte/dialog';
3
3
  import { Portal } from '@ark-ui/svelte/portal';
4
+ import { releaseFocusOnExitProps } from '../../utils/release-focus-on-exit';
4
5
  import { cn } from 'tailwind-variants';
5
6
  import type { DialogProps } from './dialog.types';
6
7
 
@@ -35,6 +36,7 @@
35
36
  'relative w-full max-w-2xl rounded-lg border bg-surface-1 p-6 shadow-xl data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
36
37
  contentClass
37
38
  )}
39
+ {...releaseFocusOnExitProps}
38
40
  >
39
41
  {@render children()}
40
42
  </Dialog.Content>
@@ -3,6 +3,7 @@
3
3
  import type { DrawerContentBaseProps } from '@ark-ui/svelte/drawer';
4
4
  import type { ClassValue } from 'svelte/elements';
5
5
  import { cn } from 'tailwind-variants';
6
+ import { releaseFocusOnExitProps } from '../../utils/release-focus-on-exit';
6
7
 
7
8
  interface Props extends DrawerContentBaseProps {
8
9
  class?: ClassValue;
@@ -54,6 +55,7 @@
54
55
  'pointer-events-auto relative flex h-full w-full flex-col bg-surface-1 shadow-xl outline-none',
55
56
  className
56
57
  )}
58
+ {...releaseFocusOnExitProps}
57
59
  >
58
60
  {@render children?.()}
59
61
  </Drawer.Content>
@@ -1,28 +1,54 @@
1
1
  <script lang="ts">
2
- import { Popover } from '@ark-ui/svelte/popover';
2
+ import { Popover, usePopoverContext } from '@ark-ui/svelte/popover';
3
+ import type { PopoverContentProps } from '@ark-ui/svelte/popover';
3
4
  import { Portal } from '@ark-ui/svelte/portal';
4
5
  import { cn } from 'tailwind-variants';
6
+ import { releaseFocusOnExitProps } from '../../utils/release-focus-on-exit';
5
7
  import type { ClassValue } from 'svelte/elements';
6
8
  import type { Snippet } from 'svelte';
9
+ import { getPopoverLabels } from './popover-labels.svelte';
7
10
 
8
- interface Props {
11
+ interface Props extends Omit<PopoverContentProps, 'class' | 'children'> {
9
12
  class?: ClassValue;
10
13
  children: Snippet;
11
14
  showArrow?: boolean;
12
15
  }
13
16
 
14
- let { class: className, children, showArrow = true }: Props = $props();
17
+ let {
18
+ class: className,
19
+ children,
20
+ showArrow = true,
21
+ 'aria-labelledby': ariaLabelledby,
22
+ 'aria-describedby': ariaDescribedby,
23
+ ...rest
24
+ }: Props = $props();
25
+
26
+ const popover = usePopoverContext();
27
+ const labels = getPopoverLabels();
28
+
29
+ // Explicit labelling wins; otherwise link the Title / Description rendered inside.
30
+ const labelledby = $derived(
31
+ ariaLabelledby ??
32
+ (labels?.hasTitle && !rest['aria-label'] ? popover().getTitleProps().id : undefined)
33
+ );
34
+ const describedby = $derived(
35
+ ariaDescribedby ?? (labels?.hasDescription ? popover().getDescriptionProps().id : undefined)
36
+ );
15
37
  </script>
16
38
 
17
39
  <Portal>
18
40
  <Popover.Positioner>
19
41
  <Popover.Content
42
+ {...rest}
43
+ aria-labelledby={labelledby}
44
+ aria-describedby={describedby}
20
45
  class={cn(
21
46
  'z-50 w-72 rounded-md border bg-surface-document p-4 shadow-md outline-none [--arrow-background:var(--compote-surface-1)] [--arrow-size:10px]',
22
47
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
23
48
  'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
24
49
  className
25
50
  )}
51
+ {...releaseFocusOnExitProps}
26
52
  >
27
53
  {#if showArrow}
28
54
  <Popover.Arrow>
@@ -1,6 +1,7 @@
1
+ import type { PopoverContentProps } from '@ark-ui/svelte/popover';
1
2
  import type { ClassValue } from 'svelte/elements';
2
3
  import type { Snippet } from 'svelte';
3
- interface Props {
4
+ interface Props extends Omit<PopoverContentProps, 'class' | 'children'> {
4
5
  class?: ClassValue;
5
6
  children: Snippet;
6
7
  showArrow?: boolean;
@@ -2,12 +2,16 @@
2
2
  import { Popover } from '@ark-ui/svelte/popover';
3
3
  import type { PopoverDescriptionBaseProps } from '@ark-ui/svelte/popover';
4
4
  import type { ClassValue } from 'svelte/elements';
5
+ import { getPopoverLabels } from './popover-labels.svelte';
5
6
 
6
7
  interface Props extends PopoverDescriptionBaseProps {
7
8
  class?: ClassValue;
8
9
  }
9
10
 
10
11
  let { class: className, children, ...rest }: Props = $props();
12
+
13
+ const labels = getPopoverLabels();
14
+ $effect(() => labels?.registerDescription());
11
15
  </script>
12
16
 
13
17
  <Popover.Description {...rest} class={className ?? 'mt-1 text-sm text-ink-dim'}>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Which labelling parts a popover currently renders. Zag checks for a title/description only
3
+ * when its machine starts — before lazily mounted content exists — so it never sets
4
+ * `aria-labelledby` / `aria-describedby` on the content. Title and Description register here
5
+ * instead, and Content links them by the ids zag gives them.
6
+ */
7
+ export declare class PopoverLabels {
8
+ #private;
9
+ get hasTitle(): boolean;
10
+ get hasDescription(): boolean;
11
+ /** Registers a rendered title; returns the unregister function. */
12
+ registerTitle(): () => void;
13
+ /** Registers a rendered description; returns the unregister function. */
14
+ registerDescription(): () => void;
15
+ }
16
+ export declare function setPopoverLabels(): PopoverLabels;
17
+ /** Undefined when the part is rendered outside compote's `Popover.Root`. */
18
+ export declare function getPopoverLabels(): PopoverLabels | undefined;
@@ -0,0 +1,35 @@
1
+ import { getContext, setContext, untrack } from 'svelte';
2
+ const key = Symbol('compote-popover-labels');
3
+ /**
4
+ * Which labelling parts a popover currently renders. Zag checks for a title/description only
5
+ * when its machine starts — before lazily mounted content exists — so it never sets
6
+ * `aria-labelledby` / `aria-describedby` on the content. Title and Description register here
7
+ * instead, and Content links them by the ids zag gives them.
8
+ */
9
+ export class PopoverLabels {
10
+ #titles = $state(0);
11
+ #descriptions = $state(0);
12
+ get hasTitle() {
13
+ return this.#titles > 0;
14
+ }
15
+ get hasDescription() {
16
+ return this.#descriptions > 0;
17
+ }
18
+ /** Registers a rendered title; returns the unregister function. */
19
+ registerTitle() {
20
+ untrack(() => this.#titles++);
21
+ return () => untrack(() => this.#titles--);
22
+ }
23
+ /** Registers a rendered description; returns the unregister function. */
24
+ registerDescription() {
25
+ untrack(() => this.#descriptions++);
26
+ return () => untrack(() => this.#descriptions--);
27
+ }
28
+ }
29
+ export function setPopoverLabels() {
30
+ return setContext(key, new PopoverLabels());
31
+ }
32
+ /** Undefined when the part is rendered outside compote's `Popover.Root`. */
33
+ export function getPopoverLabels() {
34
+ return getContext(key);
35
+ }
@@ -2,6 +2,7 @@
2
2
  import { Popover } from '@ark-ui/svelte/popover';
3
3
  import type { PopoverRootBaseProps } from '@ark-ui/svelte/popover';
4
4
  import type { Snippet } from 'svelte';
5
+ import { setPopoverLabels } from './popover-labels.svelte';
5
6
 
6
7
  interface Props extends PopoverRootBaseProps {
7
8
  open?: boolean;
@@ -17,6 +18,8 @@
17
18
  unmountOnExit = true,
18
19
  ...restProps
19
20
  }: Props = $props();
21
+
22
+ setPopoverLabels();
20
23
  </script>
21
24
 
22
25
  <Popover.Root bind:open {lazyMount} {unmountOnExit} {...restProps}>
@@ -2,12 +2,16 @@
2
2
  import { Popover } from '@ark-ui/svelte/popover';
3
3
  import type { PopoverTitleBaseProps } from '@ark-ui/svelte/popover';
4
4
  import type { ClassValue } from 'svelte/elements';
5
+ import { getPopoverLabels } from './popover-labels.svelte';
5
6
 
6
7
  interface Props extends PopoverTitleBaseProps {
7
8
  class?: ClassValue;
8
9
  }
9
10
 
10
11
  let { class: className, children, ...rest }: Props = $props();
12
+
13
+ const labels = getPopoverLabels();
14
+ $effect(() => labels?.registerTitle());
11
15
  </script>
12
16
 
13
17
  <Popover.Title {...rest} class={className ?? 'text-sm font-semibold'}>
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ let { class: className = '', ...restProps } = $props();
3
+ </script>
4
+
5
+ <svg
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ width="1em"
8
+ height="1em"
9
+ viewBox="0 0 256 256"
10
+ class={className}
11
+ {...restProps}
12
+ >
13
+ <path
14
+ fill="currentColor"
15
+ d="m227.81 66.76l-.08.09L160 139.17v55.49a16 16 0 0 1-7.13 13.34l-32 21.34A16 16 0 0 1 96 216v-76.83L28.27 66.85l-.08-.09A16 16 0 0 1 40 40h176a16 16 0 0 1 11.84 26.76Z"
16
+ />
17
+ </svg>
18
+
19
+ <!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE -->
@@ -0,0 +1,5 @@
1
+ declare const PhFunnelFill: import("svelte").Component<{
2
+ class?: string;
3
+ } & Record<string, any>, {}, "">;
4
+ type PhFunnelFill = ReturnType<typeof PhFunnelFill>;
5
+ export default PhFunnelFill;