@urbicon-ui/blocks 6.21.1 → 6.21.3

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 (78) hide show
  1. package/dist/components/NumberInput/NumberInput.svelte +197 -0
  2. package/dist/components/NumberInput/NumberInput.svelte.d.ts +4 -0
  3. package/dist/components/NumberInput/index.d.ts +55 -0
  4. package/dist/components/NumberInput/index.js +1 -0
  5. package/dist/components/index.d.ts +2 -0
  6. package/dist/components/index.js +1 -0
  7. package/dist/i18n/index.d.ts +6 -0
  8. package/dist/primitives/Avatar/avatar.variants.d.ts +6 -6
  9. package/dist/primitives/Badge/Badge.svelte +18 -8
  10. package/dist/primitives/Badge/index.d.ts +26 -6
  11. package/dist/primitives/Breadcrumb/Breadcrumb.svelte +11 -6
  12. package/dist/primitives/Breadcrumb/index.d.ts +2 -2
  13. package/dist/primitives/Button/button.variants.d.ts +4 -4
  14. package/dist/primitives/ButtonGroup/index.d.ts +1 -1
  15. package/dist/primitives/Card/card.variants.d.ts +5 -5
  16. package/dist/primitives/Checkbox/Checkbox.svelte +3 -3
  17. package/dist/primitives/Combobox/Combobox.svelte +478 -104
  18. package/dist/primitives/Combobox/combobox.variants.d.ts +89 -17
  19. package/dist/primitives/Combobox/combobox.variants.js +94 -13
  20. package/dist/primitives/Combobox/index.d.ts +166 -51
  21. package/dist/primitives/ConfirmDialog/index.d.ts +19 -0
  22. package/dist/primitives/Dialog/Dialog.svelte +74 -0
  23. package/dist/primitives/Dialog/dialog.variants.d.ts +9 -9
  24. package/dist/primitives/Dialog/index.d.ts +12 -1
  25. package/dist/primitives/Drawer/Drawer.svelte +2 -1
  26. package/dist/primitives/Drawer/drawer.variants.d.ts +8 -0
  27. package/dist/primitives/Drawer/drawer.variants.js +45 -10
  28. package/dist/primitives/Drawer/index.d.ts +13 -4
  29. package/dist/primitives/FormField/FormField.svelte +6 -6
  30. package/dist/primitives/FormField/index.d.ts +13 -4
  31. package/dist/primitives/Input/Input.svelte +8 -4
  32. package/dist/primitives/Input/input.variants.d.ts +9 -9
  33. package/dist/primitives/Menu/Menu.svelte +66 -4
  34. package/dist/primitives/Menu/index.d.ts +25 -0
  35. package/dist/primitives/Pagination/Pagination.svelte +28 -13
  36. package/dist/primitives/Pagination/index.d.ts +44 -0
  37. package/dist/primitives/Popover/Popover.svelte +34 -3
  38. package/dist/primitives/Popover/index.d.ts +3 -3
  39. package/dist/primitives/RadioGroup/RadioGroup.svelte +3 -3
  40. package/dist/primitives/Select/Select.svelte +21 -11
  41. package/dist/primitives/Select/index.d.ts +7 -0
  42. package/dist/primitives/Select/select.variants.d.ts +17 -17
  43. package/dist/primitives/Select/select.variants.js +18 -0
  44. package/dist/primitives/Skeleton/skeleton.variants.d.ts +3 -3
  45. package/dist/primitives/Slider/Slider.svelte +4 -8
  46. package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
  47. package/dist/primitives/Textarea/Textarea.svelte +13 -5
  48. package/dist/primitives/Textarea/textarea.variants.d.ts +7 -7
  49. package/dist/primitives/Textarea/textarea.variants.js +5 -2
  50. package/dist/primitives/Toast/Toaster.svelte +42 -2
  51. package/dist/primitives/Toast/index.d.ts +30 -0
  52. package/dist/primitives/Toast/toast.store.svelte.d.ts +25 -1
  53. package/dist/primitives/Toast/toast.store.svelte.js +91 -1
  54. package/dist/primitives/Toast/toast.variants.d.ts +18 -0
  55. package/dist/primitives/Toast/toast.variants.js +15 -0
  56. package/dist/primitives/Toggle/Toggle.svelte +17 -3
  57. package/dist/primitives/Toggle/index.d.ts +8 -2
  58. package/dist/primitives/Toggle/toggle.variants.d.ts +7 -0
  59. package/dist/primitives/Toggle/toggle.variants.js +26 -0
  60. package/dist/primitives/Toolbar/toolbar.variants.d.ts +6 -6
  61. package/dist/primitives/Tooltip/Tooltip.svelte +47 -21
  62. package/dist/primitives/Tooltip/Tooltip.svelte.d.ts +1 -1
  63. package/dist/primitives/Tooltip/index.d.ts +31 -3
  64. package/dist/primitives/Tooltip/tooltip.variants.d.ts +4 -4
  65. package/dist/primitives/Tooltip/tooltip.variants.js +2 -2
  66. package/dist/primitives/index.d.ts +3 -3
  67. package/dist/style/semantic.css +55 -29
  68. package/dist/translations/de.d.ts +3 -0
  69. package/dist/translations/de.js +4 -1
  70. package/dist/translations/en.d.ts +3 -0
  71. package/dist/translations/en.js +4 -1
  72. package/dist/utils/figma-token-export.d.ts +13 -0
  73. package/dist/utils/figma-token-export.js +99 -26
  74. package/dist/utils/guide.svelte.d.ts +8 -2
  75. package/dist/utils/guide.svelte.js +86 -5
  76. package/dist/utils/use-form-field.svelte.d.ts +9 -9
  77. package/dist/utils/use-form-field.svelte.js +12 -12
  78. package/package.json +3 -3
@@ -2,7 +2,7 @@ import { type SlotNames, type VariantProps } from '../../utils/variants.js';
2
2
  export declare const inputVariants: ((props?: {
3
3
  tier?: "commit" | "modify" | undefined;
4
4
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
5
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
5
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
6
6
  intent?: "default" | "success" | "warning" | "danger" | undefined;
7
7
  disabled?: boolean | undefined;
8
8
  readonly?: boolean | undefined;
@@ -16,7 +16,7 @@ export declare const inputVariants: ((props?: {
16
16
  wrapper: (props?: ({
17
17
  tier?: "commit" | "modify" | undefined;
18
18
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
19
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
19
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
20
20
  intent?: "default" | "success" | "warning" | "danger" | undefined;
21
21
  disabled?: boolean | undefined;
22
22
  readonly?: boolean | undefined;
@@ -32,7 +32,7 @@ export declare const inputVariants: ((props?: {
32
32
  container: (props?: ({
33
33
  tier?: "commit" | "modify" | undefined;
34
34
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
35
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
35
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
36
36
  intent?: "default" | "success" | "warning" | "danger" | undefined;
37
37
  disabled?: boolean | undefined;
38
38
  readonly?: boolean | undefined;
@@ -48,7 +48,7 @@ export declare const inputVariants: ((props?: {
48
48
  base: (props?: ({
49
49
  tier?: "commit" | "modify" | undefined;
50
50
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
51
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
51
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
52
52
  intent?: "default" | "success" | "warning" | "danger" | undefined;
53
53
  disabled?: boolean | undefined;
54
54
  readonly?: boolean | undefined;
@@ -64,7 +64,7 @@ export declare const inputVariants: ((props?: {
64
64
  label: (props?: ({
65
65
  tier?: "commit" | "modify" | undefined;
66
66
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
67
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
67
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
68
68
  intent?: "default" | "success" | "warning" | "danger" | undefined;
69
69
  disabled?: boolean | undefined;
70
70
  readonly?: boolean | undefined;
@@ -80,7 +80,7 @@ export declare const inputVariants: ((props?: {
80
80
  message: (props?: ({
81
81
  tier?: "commit" | "modify" | undefined;
82
82
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
83
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
83
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
84
84
  intent?: "default" | "success" | "warning" | "danger" | undefined;
85
85
  disabled?: boolean | undefined;
86
86
  readonly?: boolean | undefined;
@@ -96,7 +96,7 @@ export declare const inputVariants: ((props?: {
96
96
  iconContainer: (props?: ({
97
97
  tier?: "commit" | "modify" | undefined;
98
98
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
99
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
99
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
100
100
  intent?: "default" | "success" | "warning" | "danger" | undefined;
101
101
  disabled?: boolean | undefined;
102
102
  readonly?: boolean | undefined;
@@ -112,7 +112,7 @@ export declare const inputVariants: ((props?: {
112
112
  iconButton: (props?: ({
113
113
  tier?: "commit" | "modify" | undefined;
114
114
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
115
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
115
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
116
116
  intent?: "default" | "success" | "warning" | "danger" | undefined;
117
117
  disabled?: boolean | undefined;
118
118
  readonly?: boolean | undefined;
@@ -128,7 +128,7 @@ export declare const inputVariants: ((props?: {
128
128
  iconDecoration: (props?: ({
129
129
  tier?: "commit" | "modify" | undefined;
130
130
  variant?: "ghost" | "filled" | "outlined" | "underline" | undefined;
131
- size?: "sm" | "md" | "lg" | "xl" | "xs" | undefined;
131
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | undefined;
132
132
  intent?: "default" | "success" | "warning" | "danger" | undefined;
133
133
  disabled?: boolean | undefined;
134
134
  readonly?: boolean | undefined;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts" generics="TItem extends MenuItemType = MenuItemType">
2
+ import { tick } from 'svelte';
2
3
  import { useBlocksI18n } from '../..';
3
4
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
5
  import { Button, menuVariants, type MenuVariants } from '..';
@@ -27,11 +28,13 @@
27
28
  disabled = false,
28
29
  loading = false,
29
30
  open = $bindable(false),
31
+ onOpenChange,
30
32
  id: idProp,
31
33
  placement = 'bottom-start',
32
34
  syncWidth = true,
33
35
  usePortal = true,
34
36
  customTrigger,
37
+ contextTrigger,
35
38
  customItem,
36
39
  customHeader,
37
40
  customFooter,
@@ -71,6 +74,31 @@
71
74
  let openSubMenus = $state<Set<string>>(new Set());
72
75
  const childrenMode = $derived(!!children);
73
76
 
77
+ // ── Context-menu (right-click) anchoring ───────────────────────────────
78
+ // A context menu has no trigger button — it opens at the cursor. Floating UI
79
+ // anchors to an element, not a point, so a 0×0 fixed-position element is
80
+ // parked at the click coordinates and handed to Popover as the trigger.
81
+ let cursorAnchor = $state<HTMLElement>();
82
+ let contextX = $state(0);
83
+ let contextY = $state(0);
84
+
85
+ async function handleContextMenu(event: MouseEvent) {
86
+ if (disabled || loading) return;
87
+ // Suppress the native browser menu and anchor ours at the cursor.
88
+ event.preventDefault();
89
+ contextX = event.clientX;
90
+ contextY = event.clientY;
91
+ // Re-open at the new spot even if it was already open elsewhere: close and
92
+ // let Popover tear down (await tick) before reopening, so it re-reads the
93
+ // moved anchor rect. A synchronous setOpen(false)+setOpen(true) batches into
94
+ // no net change and would strand the menu at the previous cursor position.
95
+ if (open) {
96
+ setOpen(false);
97
+ await tick();
98
+ }
99
+ setOpen(true);
100
+ }
101
+
74
102
  // Map of declarative MenuItems by id — populated via the context's
75
103
  // `registerItem` / `unregisterItem` hooks. Used to debug + (in future)
76
104
  // power type-ahead search; the keyboard model itself walks DOM-focusable
@@ -127,13 +155,25 @@
127
155
  }
128
156
 
129
157
  // ── Open / close lifecycle ─────────────────────────────────────────────
158
+ // Single mutation point for internally-driven open changes, so
159
+ // `onOpenChange` fires exactly once per transition. Popover-owned dismiss
160
+ // paths (outside click) mutate `open` via `bind:open` instead and report
161
+ // through the forwarded Popover `onOpenChange` — the Escape path can't
162
+ // double-fire because `handlePanelKeydown` calls `preventDefault()`,
163
+ // which Popover's document-level Escape listener honors.
164
+ function setOpen(next: boolean) {
165
+ if (open === next) return;
166
+ open = next;
167
+ onOpenChange?.(next);
168
+ }
169
+
130
170
  function toggle() {
131
171
  if (disabled || loading) return;
132
- open = !open;
172
+ setOpen(!open);
133
173
  }
134
174
 
135
175
  function dismiss() {
136
- open = false;
176
+ setOpen(false);
137
177
  triggerRef?.focus();
138
178
  }
139
179
 
@@ -363,6 +403,26 @@
363
403
  .join(' ')}
364
404
  {...restProps}
365
405
  >
406
+ {#if contextTrigger}
407
+ <!-- Right-click target. `display: contents` drops the wrapper from layout so
408
+ the consumer's own element controls sizing; the contextmenu event still
409
+ bubbles to the handler. -->
410
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
411
+ <div class="contents" oncontextmenu={handleContextMenu}>
412
+ {@render contextTrigger()}
413
+ </div>
414
+ <!-- 0×0 anchor parked at the cursor for Popover to position the menu against. -->
415
+ <div
416
+ bind:this={cursorAnchor}
417
+ aria-hidden="true"
418
+ style:position="fixed"
419
+ style:left="{contextX}px"
420
+ style:top="{contextY}px"
421
+ style:width="0"
422
+ style:height="0"
423
+ ></div>
424
+ {/if}
425
+
366
426
  {#snippet triggerContent()}
367
427
  {#if customTrigger}
368
428
  {@render customTrigger(toggle, open, dismiss)}
@@ -414,13 +474,15 @@
414
474
  -->
415
475
  <Popover
416
476
  bind:open
477
+ onOpenChange={(o) => onOpenChange?.(o)}
417
478
  placement={placement as import('../../utils/floating').Placement}
418
479
  {usePortal}
419
480
  autoTrigger={false}
420
481
  unstyled
421
- syncMinWidth={syncWidth}
482
+ syncMinWidth={contextTrigger ? false : syncWidth}
422
483
  offsetDistance={effectiveTier === 'commit' ? 8 : 4}
423
- trigger={triggerContent}
484
+ trigger={contextTrigger ? undefined : triggerContent}
485
+ triggerElement={contextTrigger ? cursorAnchor : undefined}
424
486
  >
425
487
  <div
426
488
  bind:this={panelRef}
@@ -119,6 +119,12 @@ export interface MenuSpecificProps<TItem extends MenuItemType = MenuItemType> {
119
119
  * @default false
120
120
  */
121
121
  open?: boolean;
122
+ /**
123
+ * Fires when the menu opens or closes from user interaction (trigger
124
+ * click, item activation, Escape, Tab-out, outside click). Receives the
125
+ * new open state. Not called when the consumer writes `bind:open` directly.
126
+ */
127
+ onOpenChange?: (open: boolean) => void;
122
128
  /**
123
129
  * Button variant applied to the default trigger button.
124
130
  * @default 'outlined'
@@ -252,6 +258,25 @@ export interface MenuCustomSlots<TItem extends MenuItemType = MenuItemType> {
252
258
  * `aria-expanded={open}` + `aria-haspopup="menu"` for ARIA correctness.
253
259
  */
254
260
  customTrigger?: Snippet<[() => void, boolean, () => void]>;
261
+ /**
262
+ * Turn the menu into a **context menu**: instead of a trigger button, the
263
+ * snippet you pass becomes a right-click target. A `contextmenu` (right-click
264
+ * or long-press) on it opens the menu at the cursor position — the native
265
+ * browser context menu is suppressed. Keyboard navigation, dismissal and
266
+ * item selection behave exactly as in the dropdown menu; on dismiss, focus
267
+ * returns to wherever it was. Mutually exclusive with `customTrigger`/the
268
+ * default trigger button (when set, no trigger button renders).
269
+ *
270
+ * @example
271
+ * ```svelte
272
+ * <Menu {items} contextTrigger>
273
+ * {#snippet contextTrigger()}
274
+ * <div class="rounded-modify border border-border-subtle p-8">Right-click me</div>
275
+ * {/snippet}
276
+ * </Menu>
277
+ * ```
278
+ */
279
+ contextTrigger?: Snippet;
255
280
  /**
256
281
  * Custom per-item content. **Render visible content only** — the outer
257
282
  * `role="menuitem"` button is provided by Menu and handles the click /
@@ -34,6 +34,7 @@
34
34
  nextIcon,
35
35
  firstIcon,
36
36
  lastIcon,
37
+ renderItem,
37
38
  itemsPerPage = 10,
38
39
  totalItems,
39
40
  startItem,
@@ -323,19 +324,33 @@
323
324
 
324
325
  {#if showNumbers}
325
326
  {#each visiblePageNumbers as page (page)}
326
- <PaginationItem
327
- {size}
328
- {variant}
329
- {intent}
330
- {tier}
331
- {page}
332
- active={page === currentPage}
333
- disabled={disabled || loading}
334
- onPageClick={goToPage(page)}
335
- {mint}
336
- >
337
- {page}
338
- </PaginationItem>
327
+ {#if renderItem}
328
+ {@render renderItem({
329
+ page,
330
+ active: page === currentPage,
331
+ disabled: disabled || loading,
332
+ size,
333
+ variant,
334
+ intent,
335
+ tier,
336
+ mint,
337
+ select: goToPage(page)
338
+ })}
339
+ {:else}
340
+ <PaginationItem
341
+ {size}
342
+ {variant}
343
+ {intent}
344
+ {tier}
345
+ {page}
346
+ active={page === currentPage}
347
+ disabled={disabled || loading}
348
+ onPageClick={goToPage(page)}
349
+ {mint}
350
+ >
351
+ {page}
352
+ </PaginationItem>
353
+ {/if}
339
354
  {/each}
340
355
  {/if}
341
356
 
@@ -10,6 +10,33 @@ export interface PaginationPageItem {
10
10
  active?: boolean;
11
11
  disabled?: boolean;
12
12
  }
13
+ /**
14
+ * Context handed to the `renderItem` snippet for a single numbered page button.
15
+ * Bundles the page number, its active/disabled state, the style props forwarded
16
+ * from the Pagination (so a custom item stays visually consistent), and a
17
+ * `select` callback that changes the page (guarded against disabled / no-op /
18
+ * out-of-range internally).
19
+ */
20
+ export interface PaginationItemContext {
21
+ /** The 1-based page number this item represents. */
22
+ page: number;
23
+ /** Whether this item is the currently active page. */
24
+ active: boolean;
25
+ /** Whether the item is inert (component `disabled` or `loading`). */
26
+ disabled: boolean;
27
+ /** Button size forwarded from the Pagination props. */
28
+ size: 'sm' | 'md' | 'lg';
29
+ /** Button variant forwarded from the Pagination props. */
30
+ variant: 'outlined' | 'filled' | 'ghost';
31
+ /** Semantic intent forwarded from the Pagination props. */
32
+ intent: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral';
33
+ /** Semantic radius tier forwarded from the Pagination props. */
34
+ tier?: InteractiveTier;
35
+ /** Micro-interaction preset forwarded from the Pagination props. */
36
+ mint: MintProp;
37
+ /** Navigate to this page. No-op when disabled, already active, or out of range. */
38
+ select: () => void;
39
+ }
13
40
  /**
14
41
  * @description Navigation control for paged data sets.
15
42
  * Supports multiple layouts, intents, button variants, and configurable ellipsis behaviour.
@@ -69,6 +96,23 @@ export interface PaginationProps extends Omit<PaginationVariants, 'disabled' | '
69
96
  firstIcon?: Snippet;
70
97
  /** Custom icon rendered inside the "Last" button. */
71
98
  lastIcon?: Snippet;
99
+ /**
100
+ * Render each numbered page button yourself. Receives a {@link PaginationItemContext}
101
+ * with the page number, its active/disabled state, the forwarded style props
102
+ * (size, variant, intent, tier, mint) and a `select` callback. Only affects the
103
+ * numbered page buttons in the default layout — prev/next/first/last keep their
104
+ * own icon snippets, and the ellipsis is unaffected.
105
+ *
106
+ * @example
107
+ * ```svelte
108
+ * <Pagination {currentPage} {totalPages} {onPageChange}>
109
+ * {#snippet renderItem({ page, active, disabled, select })}
110
+ * <button class:active onclick={select} {disabled}>{page}</button>
111
+ * {/snippet}
112
+ * </Pagination>
113
+ * ```
114
+ */
115
+ renderItem?: Snippet<[PaginationItemContext]>;
72
116
  /** Items shown per page. Used by the table layout to compute "Showing X to Y of Z". */
73
117
  itemsPerPage?: number;
74
118
  /** Total number of items across all pages. Used by the table layout info text. */
@@ -184,7 +184,7 @@
184
184
  }
185
185
 
186
186
  if (!wasTrigger) {
187
- effectiveTriggerElement?.focus();
187
+ focusTrigger();
188
188
  }
189
189
  }
190
190
  }
@@ -209,11 +209,17 @@
209
209
  // intentionally consume Escape.
210
210
  function handleEscape(e: KeyboardEvent) {
211
211
  if (e.key !== 'Escape' || e.defaultPrevented) return;
212
+ // Re-check `open` (like the auto-mode toggle handler does): the
213
+ // listener teardown is deferred to the next effect flush, so a
214
+ // consumer handler earlier in this same dispatch may already have
215
+ // closed via `bind:open` — without this guard we'd report a second,
216
+ // transition-less onOpenChange(false) + onEscape and steal focus.
217
+ if (!open) return;
212
218
  e.preventDefault();
213
219
  open = false;
214
220
  onOpenChange?.(false);
215
221
  onEscapeProp?.();
216
- effectiveTriggerElement?.focus();
222
+ focusTrigger();
217
223
  }
218
224
  document.addEventListener('keydown', handleEscape);
219
225
  return () => document.removeEventListener('keydown', handleEscape);
@@ -231,6 +237,10 @@
231
237
  if (!target) return;
232
238
  if (popoverElement?.contains(target)) return;
233
239
  if (effectiveTriggerElement?.contains(target)) return;
240
+ // Same deferred-teardown re-check as handleEscape above: a consumer
241
+ // capture-phase pointerdown handler may have closed via `bind:open`
242
+ // within this dispatch — don't report a second close.
243
+ if (!open) return;
234
244
  open = false;
235
245
  onOpenChange?.(false);
236
246
  onClickOutsideProp?.();
@@ -266,8 +276,29 @@
266
276
 
267
277
  // ── Trigger handlers ───────────────────────────────────────
268
278
 
279
+ // Restore focus to the trigger after a dismiss. The snippet trigger is
280
+ // wrapped in a plain (non-focusable) div — `focus()` on it is a spec no-op —
281
+ // so target the interactive descendant instead (same query as the
282
+ // aria-expanded effect). An external `triggerElement` is the consumer's
283
+ // real control: focus it directly.
284
+ function focusTrigger() {
285
+ const target =
286
+ triggerElement ??
287
+ internalTriggerElement?.querySelector<HTMLElement>(
288
+ 'button, a[href], [role="button"], [tabindex]'
289
+ ) ??
290
+ internalTriggerElement;
291
+ target?.focus();
292
+ }
293
+
269
294
  function handleTriggerPointerDown() {
270
- if (open) dismissedByTrigger = true;
295
+ // Arm the "this pointerdown already dismissed it" guard only in auto
296
+ // mode, where the browser's light dismiss really closes the popover
297
+ // between pointerdown and click (the guard stops that click from
298
+ // re-opening it). In manual mode nothing light-dismisses — the click
299
+ // itself must toggle-close, so arming the guard there left the trigger
300
+ // unable to close its own popover.
301
+ if (open && popoverMode === 'auto') dismissedByTrigger = true;
271
302
  }
272
303
 
273
304
  function handleTriggerClick(event: MouseEvent) {
@@ -4,8 +4,8 @@ import type { Placement } from '../../utils/floating.js';
4
4
  import type { PopoverVariants } from './popover.variants.js';
5
5
  /**
6
6
  * @description Floating panel anchored to a trigger element. Uses the native Popover API
7
- * for top-layer rendering, light dismiss, and Escape handling. Floating UI provides
8
- * precise positioning with automatic flip, shift, and optional width syncing.
7
+ * for top-layer rendering, light dismiss, and Escape handling. The library's built-in
8
+ * positioning engine provides automatic flip, shift, and optional width syncing.
9
9
  *
10
10
  * @tag overlay
11
11
  * @related Tooltip
@@ -45,7 +45,7 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
45
45
  trigger?: Snippet;
46
46
  /** External trigger element ref. Use instead of the `trigger` snippet when the trigger lives outside the Popover tree. Supports `bind:triggerElement`. */
47
47
  triggerElement?: HTMLElement;
48
- /** Where the popover appears relative to the trigger. All Floating UI `Placement` values are supported. */
48
+ /** Where the popover appears relative to the trigger. All standard `Placement` values (side plus optional `-start`/`-end` alignment) are supported. */
49
49
  placement?: Placement;
50
50
  /** Gap in px between the trigger edge and the popover. */
51
51
  offsetDistance?: number;
@@ -46,7 +46,7 @@
46
46
  // ARIA wiring is shared with every form primitive — see XC-2.
47
47
  const ff = useFormField(() => ({
48
48
  fieldId: groupId,
49
- hint: helper,
49
+ helper,
50
50
  error,
51
51
  required,
52
52
  disabled
@@ -174,9 +174,9 @@
174
174
  >
175
175
  {error}
176
176
  </div>
177
- {:else if ff.hintId}
177
+ {:else if ff.helperId}
178
178
  <div
179
- id={ff.hintId}
179
+ id={ff.helperId}
180
180
  class={unstyled
181
181
  ? (slotClasses?.message ?? '')
182
182
  : styles.message({ class: slotClasses?.message })}
@@ -38,6 +38,7 @@
38
38
  mint = 'none',
39
39
  onValueChange,
40
40
  open = $bindable(false),
41
+ onOpenChange,
41
42
  usePortal = true,
42
43
  syncWidth = true,
43
44
  customTrigger,
@@ -101,7 +102,7 @@
101
102
  const labelId = $derived(label ? `${uid}-label` : undefined);
102
103
  const ff = useFormField(() => ({
103
104
  fieldId: uid,
104
- hint: helper,
105
+ helper,
105
106
  error,
106
107
  required,
107
108
  disabled
@@ -235,12 +236,21 @@
235
236
  syncWidth: () => syncWidth
236
237
  });
237
238
 
239
+ // Single mutation point for internally-driven open changes, so
240
+ // `onOpenChange` fires exactly once per transition (and never when the
241
+ // consumer writes `bind:open` directly).
242
+ function setOpen(next: boolean) {
243
+ if (open === next) return;
244
+ open = next;
245
+ onOpenChange?.(next);
246
+ }
247
+
238
248
  function toggle() {
239
249
  if (disabled) return;
240
250
  // Pointer-driven open (trigger onclick). Mark modality so the open effect
241
251
  // doesn't pre-highlight the first row for a mouse/touch user.
242
252
  if (!open) openedViaKeyboard = false;
243
- open = !open;
253
+ setOpen(!open);
244
254
  if (!open) activeIndex = -1;
245
255
  }
246
256
 
@@ -271,7 +281,7 @@
271
281
  dispatchValueChange?.(nextValue);
272
282
  }
273
283
  if (effectiveCloseOnSelect) {
274
- open = false;
284
+ setOpen(false);
275
285
  activeIndex = -1;
276
286
  focusTrigger();
277
287
  }
@@ -297,7 +307,7 @@
297
307
  // explicitly intent to focus elsewhere)
298
308
  function dismissByEscape() {
299
309
  if (!closeOnEscape) return false;
300
- open = false;
310
+ setOpen(false);
301
311
  activeIndex = -1;
302
312
  onEscape?.();
303
313
  return true;
@@ -319,7 +329,7 @@
319
329
  event.preventDefault();
320
330
  if (!open) {
321
331
  openedViaKeyboard = true;
322
- open = true;
332
+ setOpen(true);
323
333
  } else {
324
334
  activeIndex = activeIndex < enabledOptions.length - 1 ? activeIndex + 1 : 0;
325
335
  }
@@ -328,7 +338,7 @@
328
338
  event.preventDefault();
329
339
  if (!open) {
330
340
  openedViaKeyboard = true;
331
- open = true;
341
+ setOpen(true);
332
342
  } else {
333
343
  activeIndex = activeIndex > 0 ? activeIndex - 1 : enabledOptions.length - 1;
334
344
  }
@@ -338,7 +348,7 @@
338
348
  event.preventDefault();
339
349
  if (!open) {
340
350
  openedViaKeyboard = true;
341
- open = true;
351
+ setOpen(true);
342
352
  } else if (activeIndex >= 0 && activeIndex < enabledOptions.length) {
343
353
  selectOption(enabledOptions[activeIndex]);
344
354
  }
@@ -353,7 +363,7 @@
353
363
  // Tab leaves the widget — close (focus moves on via the default tab),
354
364
  // independent of closeOnEscape/closeOnClickOutside.
355
365
  if (open) {
356
- open = false;
366
+ setOpen(false);
357
367
  activeIndex = -1;
358
368
  }
359
369
  break;
@@ -382,7 +392,7 @@
382
392
  !listboxRef.contains(target)
383
393
  ) {
384
394
  if (!closeOnClickOutside) return;
385
- open = false;
395
+ setOpen(false);
386
396
  activeIndex = -1;
387
397
  onClickOutside?.();
388
398
  }
@@ -725,9 +735,9 @@
725
735
  >
726
736
  {error}
727
737
  </div>
728
- {:else if ff.hintId}
738
+ {:else if ff.helperId}
729
739
  <div
730
- id={ff.hintId}
740
+ id={ff.helperId}
731
741
  class={unstyled
732
742
  ? (slotClasses?.message ?? '')
733
743
  : styles.message({ class: slotClasses?.message })}
@@ -164,6 +164,13 @@ interface SelectBaseProps<T extends SelectValue = string> extends Omit<SelectVar
164
164
  * the Select wrapper). @default false
165
165
  */
166
166
  open?: boolean;
167
+ /**
168
+ * Fires when the listbox opens or closes from user interaction (trigger
169
+ * click, keyboard, selection, Escape, Tab-out, outside click). Receives the
170
+ * new open state — use it e.g. to lazy-load options on first open. Not
171
+ * called when the consumer writes `bind:open` directly.
172
+ */
173
+ onOpenChange?: (open: boolean) => void;
167
174
  /**
168
175
  * When true, the listbox is rendered into the browser top layer via the native
169
176
  * `popover` API, so it cannot be clipped by `overflow: auto` ancestors. When