@urbicon-ui/blocks 6.24.0 → 6.25.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/README.md +14 -0
  2. package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
  3. package/dist/components/Calendar/calendar.variants.js +5 -3
  4. package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
  5. package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
  7. package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
  8. package/dist/components/CommandPalette/index.d.ts +19 -0
  9. package/dist/components/CompositionBar/CompositionBar.svelte +1 -1
  10. package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
  11. package/dist/components/Planner/planner.variants.js +1 -1
  12. package/dist/components/Sankey/sankey.variants.js +1 -1
  13. package/dist/internal/charts/variants.js +2 -2
  14. package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
  15. package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
  16. package/dist/internal/date-grid/date-grid.svelte.js +63 -7
  17. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  18. package/dist/primitives/Avatar/index.d.ts +1 -1
  19. package/dist/primitives/Button/Button.svelte +12 -25
  20. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  21. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  22. package/dist/primitives/Collapsible/index.d.ts +13 -2
  23. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  24. package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
  25. package/dist/primitives/Dialog/Dialog.svelte +31 -8
  26. package/dist/primitives/Drawer/Drawer.svelte +29 -7
  27. package/dist/primitives/Input/Input.svelte +7 -1
  28. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  29. package/dist/primitives/Menu/Menu.svelte +8 -0
  30. package/dist/primitives/Menu/index.d.ts +11 -13
  31. package/dist/primitives/Popover/Popover.svelte +107 -4
  32. package/dist/primitives/Popover/index.d.ts +15 -2
  33. package/dist/primitives/Popover/index.js +1 -1
  34. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  35. package/dist/primitives/Popover/popover.variants.js +35 -0
  36. package/dist/primitives/Progress/progress.variants.js +1 -1
  37. package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
  38. package/dist/primitives/Select/index.d.ts +8 -1
  39. package/dist/primitives/Slider/Slider.svelte +32 -3
  40. package/dist/primitives/Stepper/stepper.variants.js +1 -1
  41. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  42. package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
  43. package/dist/style/foundation.css +30 -3
  44. package/dist/style/interaction.css +19 -2
  45. package/dist/style/semantic.css +72 -1
  46. package/dist/style/themes/neutral.css +10 -1
  47. package/dist/style/themes/ocean.css +5 -1
  48. package/dist/utils/compose-handlers.d.ts +72 -0
  49. package/dist/utils/compose-handlers.js +65 -0
  50. package/dist/utils/figma-token-export.js +1 -1
  51. package/dist/utils/guide.svelte.js +92 -29
  52. package/dist/utils/index.d.ts +1 -1
  53. package/dist/utils/index.js +1 -1
  54. package/dist/utils/overlay-tokens.d.ts +17 -0
  55. package/dist/utils/overlay-tokens.js +30 -0
  56. package/dist/utils/use-floating-panel.svelte.js +9 -3
  57. package/dist/utils/variants.js +5 -1
  58. package/package.json +3 -3
@@ -247,6 +247,10 @@
247
247
  }
248
248
 
249
249
  const ariaRole = $derived(selection === 'single' ? 'radiogroup' : 'group');
250
+ // ARIA allows `aria-orientation` on `radiogroup`, not on `group` — emitting it
251
+ // on the multi-selection arm is an `aria-allowed-attr` violation. The visual
252
+ // orientation is unaffected; it comes from the variant classes, not from ARIA.
253
+ const ariaOrientation = $derived(ariaRole === 'radiogroup' ? orientation : undefined);
250
254
  </script>
251
255
 
252
256
  <div
@@ -257,7 +261,7 @@
257
261
  : styles.base({ class: [slotClasses?.base, className] })}
258
262
  aria-label={ariaLabel}
259
263
  aria-labelledby={ariaLabelledBy}
260
- aria-orientation={orientation}
264
+ aria-orientation={ariaOrientation}
261
265
  aria-disabled={disabled || undefined}
262
266
  onkeydown={handleKeyDown}
263
267
  {...restProps}
@@ -42,6 +42,16 @@
42
42
  let internalOpen = $state(defaultOpen ?? false);
43
43
  const isOpen = $derived(open !== undefined ? open : internalOpen);
44
44
 
45
+ // Single mutation point. Family contract (COMPONENT-API-CONVENTIONS.md
46
+ // §Open-state vocabulary): the transition is applied optimistically — `open`
47
+ // (or the uncontrolled seed) is written *before* `onOpenChange` fires, once
48
+ // per transition. With `bind:open` that write is the propagation. A consumer
49
+ // passing `open` without `bind:` must mirror every `onOpenChange` back into
50
+ // its state (Svelte can't distinguish `open={x}` from `bind:open={x}` at
51
+ // runtime, so a rejected transition is undetectable from in here). To veto
52
+ // transitions, own them instead: drive `open` from your source of truth and
53
+ // toggle it from a custom `trigger` snippet — see AccordionItem's
54
+ // collapsible=false handling.
45
55
  function toggle() {
46
56
  if (disabled) return;
47
57
  const next = !isOpen;
@@ -32,11 +32,22 @@ import type { CollapsibleSlots, CollapsibleVariants } from './collapsible.varian
32
32
  * ```
33
33
  */
34
34
  export interface CollapsibleProps extends CollapsibleVariants, Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
35
- /** Whether the content is visible. Supports bind:open. */
35
+ /**
36
+ * Whether the content is visible. Supports bind:open. Trigger-driven transitions
37
+ * are applied optimistically: `open` is updated first, then `onOpenChange` reports
38
+ * the change. When passing `open` without `bind:`, mirror every `onOpenChange`
39
+ * back into your state — an ignored change leaves the component and your source
40
+ * of truth diverged. To conditionally reject transitions, drive `open` from your
41
+ * own state and toggle it from a custom `trigger` snippet instead of calling the
42
+ * provided `toggle`.
43
+ */
36
44
  open?: boolean;
37
45
  /** Initial open state for uncontrolled usage @default false */
38
46
  defaultOpen?: boolean;
39
- /** Callback fired when open state changes */
47
+ /**
48
+ * Callback fired once per trigger-driven open transition, after the state is
49
+ * applied. Not fired for consumer writes via `bind:open`.
50
+ */
40
51
  onOpenChange?: (open: boolean) => void;
41
52
  /** Disable the trigger @default false */
42
53
  disabled?: boolean;
@@ -15,6 +15,7 @@
15
15
  cancelLabel,
16
16
  confirmIntent,
17
17
  onConfirm,
18
+ onError,
18
19
  onCancel,
19
20
  loading = false,
20
21
  closeOnBackdropClick = true,
@@ -39,6 +40,19 @@
39
40
  busy = true;
40
41
  await onConfirm();
41
42
  open = false;
43
+ } catch (error) {
44
+ // Failure contract: skip the auto-close (dialog stays open) and re-enable
45
+ // (busy cleared in `finally`) so the user can retry or cancel. The
46
+ // rejection is handed to `onError`; without one it is surfaced DEV-only
47
+ // instead of escaping the ignored onclick promise as an unhandled
48
+ // rejection (Combobox queryFn / Toast promise precedent). A throwing
49
+ // `onError` is a consumer bug and deliberately escapes (fail-loud,
50
+ // mirrors createCronRunner).
51
+ if (onError) {
52
+ onError(error);
53
+ } else if (import.meta.env?.DEV) {
54
+ console.error('[ConfirmDialog] onConfirm rejected:', error);
55
+ }
42
56
  } finally {
43
57
  busy = false;
44
58
  }
@@ -16,7 +16,8 @@ export type ConfirmIntent = Exclude<DialogIntent, 'neutral'>;
16
16
  *
17
17
  * `onConfirm` may be `async`; while the returned promise is pending the
18
18
  * dialog locks itself (no backdrop dismiss, no escape, confirm button
19
- * shows a spinner). Auto-closes on resolve.
19
+ * shows a spinner). Auto-closes on resolve; on reject it stays open and
20
+ * re-enables, handing the error to `onError`.
20
21
  *
21
22
  * @tag overlay
22
23
  * @related Dialog
@@ -60,9 +61,19 @@ export interface ConfirmDialogProps extends Omit<HTMLDialogAttributes, 'children
60
61
  cancelLabel?: string;
61
62
  /**
62
63
  * Confirm handler. May return a promise — the dialog stays open and shows
63
- * a loading state while it resolves, then auto-closes on success.
64
+ * a loading state while it resolves, then auto-closes on success. If the
65
+ * promise rejects the dialog stays open and re-enables; the rejection is
66
+ * reported via {@link ConfirmDialogProps.onError}.
64
67
  */
65
68
  onConfirm?: () => void | Promise<void>;
69
+ /**
70
+ * Fired when an async `onConfirm` rejects (or a sync one throws). The
71
+ * dialog stays open and re-enables so the user can retry or cancel — use
72
+ * this to surface the failure (toast, inline message). Without a handler
73
+ * the rejection is logged DEV-only (`console.error`) and swallowed in
74
+ * production; it never escapes as an unhandled promise rejection.
75
+ */
76
+ onError?: (error: unknown) => void;
66
77
  /** Fired when the user cancels (button, backdrop, or Escape). */
67
78
  onCancel?: () => void;
68
79
  /**
@@ -6,6 +6,7 @@
6
6
  import { onDestroy, tick } from 'svelte';
7
7
  import { fade, scale } from 'svelte/transition';
8
8
  import { trapFocus, showDialogModal, closeDialogModal } from '../../utils/overlay';
9
+ import { composeHandlers } from '../../utils/compose-handlers';
9
10
  import { overlayStack, getOverlayMotion } from '../../utils';
10
11
  import Button from '../Button/Button.svelte';
11
12
  import type { DialogProps } from './index';
@@ -34,6 +35,16 @@
34
35
  unstyled: unstyledProp = false,
35
36
  slotClasses: slotClassesProp = {},
36
37
  preset,
38
+ // Pulled out of restProps so the `{...restProps}`-first spread (internal
39
+ // attributes win — see docs/COMPONENT-API-CONVENTIONS.md) can't clobber
40
+ // them, and so neither side loses: the behavioural handlers below are
41
+ // composed (internal first, consumer second) and the two ARIA attributes
42
+ // are merged, rather than one silently replacing the other.
43
+ onclick: onclickProp,
44
+ onkeydown: onkeydownProp,
45
+ onclose: oncloseProp,
46
+ 'aria-labelledby': ariaLabelledby,
47
+ 'aria-describedby': ariaDescribedby,
37
48
  ...restProps
38
49
  }: DialogProps = $props();
39
50
 
@@ -134,6 +145,14 @@
134
145
  const bodyId = `dialog-body-${uid}`;
135
146
  const overlayId = `dialog-${uid}`;
136
147
 
148
+ // A rendered `title` owns the labelling; without one, a consumer-supplied
149
+ // `aria-labelledby` (e.g. a heading inside `children`) is the fallback, so
150
+ // external labelling survives the restProps-first spread.
151
+ const labelledBy = $derived(titleId ?? ariaLabelledby);
152
+ // Merged, not replaced: a consumer's description is supplemental to the
153
+ // dialog's own body — internal id first, consumer id last (mirrors Input).
154
+ const describedBy = $derived([bodyId, ariaDescribedby].filter(Boolean).join(' ') || undefined);
155
+
137
156
  const variantProps: DialogVariants = $derived({ size, placement, intent });
138
157
  const styles = $derived(dialogVariants(variantProps));
139
158
  const slotClasses = $derived(
@@ -176,6 +195,12 @@
176
195
  if (closeOnBackdropClick) requestClose();
177
196
  }
178
197
 
198
+ // Native `close` event (ESC handled by the UA, form[method=dialog], .close()).
199
+ // Named rather than inline so it can be composed with a consumer `onclose`.
200
+ function handleNativeClose() {
201
+ if (open) requestClose();
202
+ }
203
+
179
204
  function handleOutroEnd() {
180
205
  closeDialogModal(dialogEl, previouslyFocused, releaseScrollLock);
181
206
  releaseScrollLock = undefined;
@@ -230,18 +255,16 @@
230
255
 
231
256
  {#if isVisible}
232
257
  <dialog
258
+ {...restProps}
233
259
  bind:this={dialogEl}
234
260
  class={unstyled ? (slotClasses?.dialog ?? '') : styles.dialog({ class: slotClasses?.dialog })}
235
- onclick={handleBackdropClick}
236
- onkeydown={handleKeydown}
237
- onclose={() => {
238
- if (open) requestClose();
239
- }}
240
- aria-labelledby={titleId}
241
- aria-describedby={bodyId}
261
+ onclick={composeHandlers(handleBackdropClick, onclickProp)}
262
+ onkeydown={composeHandlers(handleKeydown, onkeydownProp)}
263
+ onclose={composeHandlers(handleNativeClose, oncloseProp)}
264
+ aria-labelledby={labelledBy}
265
+ aria-describedby={describedBy}
242
266
  aria-modal="true"
243
267
  data-state={open ? 'open' : 'closed'}
244
- {...restProps}
245
268
  >
246
269
  {#if open}
247
270
  <div
@@ -6,6 +6,7 @@
6
6
  import { onDestroy, tick } from 'svelte';
7
7
  import { fade, fly } from 'svelte/transition';
8
8
  import { trapFocus, showDialogModal, closeDialogModal } from '../../utils/overlay';
9
+ import { composeHandlers } from '../../utils/compose-handlers';
9
10
  import { overlayStack, getOverlayMotion } from '../../utils';
10
11
  import Button from '../Button/Button.svelte';
11
12
  import type { DrawerProps } from './index';
@@ -34,6 +35,15 @@
34
35
  unstyled: unstyledProp = false,
35
36
  slotClasses: slotClassesProp = {},
36
37
  preset,
38
+ // Pulled out of restProps so the `{...restProps}`-first spread (internal
39
+ // attributes win — see docs/COMPONENT-API-CONVENTIONS.md) can't clobber
40
+ // them, and so neither side loses: the behavioural handlers below are
41
+ // composed (internal first, consumer second) and the two ARIA attributes
42
+ // are merged, rather than one silently replacing the other. Mirrors Dialog.
43
+ onkeydown: onkeydownProp,
44
+ onclose: oncloseProp,
45
+ 'aria-labelledby': ariaLabelledby,
46
+ 'aria-describedby': ariaDescribedby,
37
47
  ...restProps
38
48
  }: DrawerProps = $props();
39
49
 
@@ -66,6 +76,14 @@
66
76
  const bodyId = `drawer-body-${uid}`;
67
77
  const overlayId = `drawer-${uid}`;
68
78
 
79
+ // A rendered `title` owns the labelling; without one, a consumer-supplied
80
+ // `aria-labelledby` (e.g. a heading inside `children`) is the fallback, so
81
+ // external labelling survives the restProps-first spread.
82
+ const labelledBy = $derived(titleId ?? ariaLabelledby);
83
+ // Merged, not replaced: a consumer's description is supplemental to the
84
+ // drawer's own body — internal id first, consumer id last (mirrors Input).
85
+ const describedBy = $derived([bodyId, ariaDescribedby].filter(Boolean).join(' ') || undefined);
86
+
69
87
  const variantProps: DrawerVariants = $derived({ placement, size, intent, accentEdge });
70
88
  const styles = $derived(drawerVariants(variantProps));
71
89
  const slotClasses = $derived(
@@ -159,6 +177,12 @@
159
177
  if (event.key === 'Tab') trapFocus(event, panelElement);
160
178
  }
161
179
 
180
+ // Native `close` event (ESC handled by the UA, form[method=dialog], .close()).
181
+ // Named rather than inline so it can be composed with a consumer `onclose`.
182
+ function handleNativeClose() {
183
+ if (open) requestClose();
184
+ }
185
+
162
186
  function handleOutroEnd() {
163
187
  closeDialogModal(dialogElement, previouslyFocused, releaseScrollLock);
164
188
  releaseScrollLock = undefined;
@@ -171,17 +195,15 @@
171
195
 
172
196
  {#if isVisible}
173
197
  <dialog
198
+ {...restProps}
174
199
  bind:this={dialogElement}
175
200
  class={unstyled ? (slotClasses?.dialog ?? '') : styles.dialog({ class: slotClasses?.dialog })}
176
- onkeydown={handleKeydown}
177
- onclose={() => {
178
- if (open) requestClose();
179
- }}
180
- aria-labelledby={titleId}
181
- aria-describedby={bodyId}
201
+ onkeydown={composeHandlers(handleKeydown, onkeydownProp)}
202
+ onclose={composeHandlers(handleNativeClose, oncloseProp)}
203
+ aria-labelledby={labelledBy}
204
+ aria-describedby={describedBy}
182
205
  aria-modal="true"
183
206
  data-state={open ? 'open' : 'closed'}
184
- {...restProps}
185
207
  >
186
208
  {#if open}
187
209
  <div
@@ -43,6 +43,7 @@
43
43
  persistVersion = 1,
44
44
  persistNamespace,
45
45
  onkeydown: userOnKeydown,
46
+ id: idProp,
46
47
  'aria-describedby': ariaDescribedby,
47
48
  ...restProps
48
49
  }: InputProps = $props();
@@ -118,9 +119,14 @@
118
119
  );
119
120
 
120
121
  // ARIA wiring is shared with every form primitive — see XC-2.
122
+ // A consumer-supplied `id` wins over the generated one: pairing an Input
123
+ // with an external `<label for>` is only possible if the id we are handed
124
+ // actually reaches the `<input>`. `$props.id()` may only appear as a
125
+ // top-level initializer, hence the two-step derive.
121
126
  const propsId = $props.id();
127
+ const fieldId = $derived(idProp ?? `input-${propsId}`);
122
128
  const ff = useFormField(() => ({
123
- fieldId: `input-${propsId}`,
129
+ fieldId,
124
130
  helper,
125
131
  error,
126
132
  required,
@@ -111,13 +111,13 @@ export const journeyTimelineVariants = tv({
111
111
  },
112
112
  size: {
113
113
  sm: {
114
- meta: 'text-[11px]',
114
+ meta: 'text-2xs',
115
115
  marker: 'size-2.5',
116
116
  node: 'gap-x-2.5',
117
117
  card: '-mx-1.5 px-1.5 py-1.5',
118
118
  title: 'text-xs',
119
- subtitle: 'text-[11px]',
120
- segment: 'text-[11px]',
119
+ subtitle: 'text-2xs',
120
+ segment: 'text-2xs',
121
121
  content: 'pb-4',
122
122
  detailContent: 'text-xs pt-2',
123
123
  panel: 'p-3 text-xs'
@@ -8,6 +8,7 @@
8
8
  import { getTierContext } from '../../utils/tier-context';
9
9
  const ChevronDownIcon = resolveIcon('chevronDown', ChevronDownIconDefault);
10
10
  import Popover from '../Popover/Popover.svelte';
11
+ import { popoverMotion } from '../Popover/popover.variants';
11
12
  import { setMenuContext, type MenuContext, type MenuRegistryItem } from './menu.context';
12
13
  import MenuItemComp from './MenuItem.svelte';
13
14
  import MenuSubmenu from './MenuSubmenu.svelte';
@@ -471,6 +472,12 @@
471
472
  only painted layer. Without this, the panel renders as two stacked
472
473
  surfaces (Popover wrapper + Menu content) and shows a near-empty box
473
474
  with double borders.
475
+
476
+ The stripped default ALSO carried the enter/exit motion, so the exact
477
+ fragment (`popoverMotion`) is re-applied via `class` — it survives
478
+ Popover's `unstyled` by design and animates the transparent wrapper,
479
+ which fades/scales the Menu chrome inside it. A Menu-level `unstyled`
480
+ strips it again; consumers rebuild on the panel's `data-state`.
474
481
  -->
475
482
  <Popover
476
483
  bind:open
@@ -479,6 +486,7 @@
479
486
  {usePortal}
480
487
  autoTrigger={false}
481
488
  unstyled
489
+ class={unstyled ? undefined : popoverMotion}
482
490
  syncMinWidth={contextTrigger ? false : syncWidth}
483
491
  offsetDistance={effectiveTier === 'commit' ? 8 : 4}
484
492
  trigger={contextTrigger ? undefined : triggerContent}
@@ -7,18 +7,9 @@ import type { Placement } from '../../utils/floating.js';
7
7
  import type { InteractiveTier } from '../../utils/tier-context.js';
8
8
  import type { MenuSlots } from './menu.variants.js';
9
9
  /**
10
- * @description Action menu (`role="menu"`) triggered by a button. Items are
11
- * verbs the user can invoke Edit, Delete, Share, Export and dispatch an
12
- * `onSelect` callback when activated. For picking a value from a list use
13
- * `Select` (or `Combobox` for searchable). Menu and Select are deliberately
14
- * disjoint: Menu's ARIA semantics, keyboard model (Tab through items, Enter
15
- * triggers), and visual chrome match the Action surface family; Select's
16
- * `role="listbox"` + arrow-navigation match the Form surface family.
17
- *
18
- * @tag action
19
- * @related Select
20
- * @related Combobox
21
- * @related Popover
10
+ * Menu-specific props. The catalog JSDoc (`@description`/`@tag`/`@related`)
11
+ * lives on `MenuProps` belowthe interface docs-gen actually picks up
12
+ * so it is not duplicated here.
22
13
  *
23
14
  * @example Items array with `onSelect` callbacks
24
15
  * ```svelte
@@ -172,7 +163,14 @@ export interface MenuSpecificProps<TItem extends MenuItemType = MenuItemType> {
172
163
  mint?: MintProp;
173
164
  }
174
165
  /**
175
- * @description Action menu triggered by a button with nested items, icons, separators, and keyboard navigation.
166
+ * @description Action menu (`role="menu"`) triggered by a button, with nested
167
+ * submenus, sections, icons, and separators. Items are verbs the user can
168
+ * invoke — Edit, Delete, Share, Export — and dispatch an `onSelect` callback
169
+ * when activated; Menu holds no selection state. For picking a value from a
170
+ * list use `Select` (or `Combobox` for searchable). Menu and Select are
171
+ * deliberately disjoint: Menu's `role="menu"`/`menuitem` semantics with
172
+ * arrow-key roving and Action-family chrome versus Select's `role="listbox"`
173
+ * value commitment with Form-family chrome.
176
174
  *
177
175
  * @tag action
178
176
  * @related Select
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
3
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
- import { useFloatingPanel, floatingPanelHidden } from '../../utils';
4
+ import { useFloatingPanel, floatingPanelHidden, maxTransitionDurationMs } from '../../utils';
5
5
  import { popoverVariants } from './popover.variants';
6
6
  import type { PopoverProps } from './index';
7
7
 
@@ -21,6 +21,9 @@
21
21
  autoTrigger = true,
22
22
  size = 'md',
23
23
 
24
+ transitionDuration,
25
+ transitionEasing,
26
+
24
27
  onOpenChange,
25
28
  onClickOutside: onClickOutsideProp,
26
29
  onEscape: onEscapeProp,
@@ -47,11 +50,64 @@
47
50
  resolveSlotClasses(blocksConfig, 'Popover', preset, { size }, slotClassesProp)
48
51
  );
49
52
 
53
+ // Per-instance motion overrides. Set the shared popover CSS variables inline
54
+ // only when a prop is provided, so the unset default keeps inheriting the
55
+ // reduced-motion-aware token (mirrors Tooltip's transitionDuration path).
56
+ const popoverDurationInline = $derived(
57
+ transitionDuration != null ? `${transitionDuration}ms` : undefined
58
+ );
59
+
50
60
  let internalTriggerElement = $state<HTMLElement | null>(null);
51
61
  let popoverElement = $state<HTMLElement | null>(null);
52
62
 
53
63
  const effectiveTriggerElement = $derived(triggerElement || internalTriggerElement);
54
64
 
65
+ // ── Exit-motion lag (ACC-3 rest) ───────────────────────────
66
+ //
67
+ // Closing used to tear three things down in one flush: `open` flips, the
68
+ // children block unmounts, and `useFloatingPanel` hides the panel. The CSS
69
+ // exit transition (see `popoverMotion` in popover.variants.ts) keeps the
70
+ // *panel element* painted via `allow-discrete`, but the children must
71
+ // outlive `open` or the panel fades out empty. `exiting` lags the teardown
72
+ // by the panel's actual computed transition duration — read from the live
73
+ // style so per-instance props, theme token overrides, and reduced motion all
74
+ // shorten it automatically. A zero duration (jsdom/node tests, browsers
75
+ // without the CSS, `unstyled` without rebuilt motion) tears down
76
+ // synchronously: exactly the pre-motion behaviour.
77
+ //
78
+ // `$effect.pre` matters: the flag must be `true` in the SAME flush that
79
+ // renders `open === false`, otherwise the children unmount one frame before
80
+ // the lag starts. The `prevOpenForExit` tracker (non-reactive, seeded via
81
+ // `untrack` like `prevPopoverMode` below) keeps the effect keyed on real
82
+ // transitions — without it, the `bind:this` assignment would re-run the
83
+ // effect on mount and flash a closed-by-default popover open for one lag.
84
+ const EXIT_MOTION_BUFFER_MS = 50;
85
+ let exiting = $state(false);
86
+ let exitTimer: ReturnType<typeof setTimeout> | undefined;
87
+ let prevOpenForExit = untrack(() => open);
88
+ $effect.pre(() => {
89
+ if (open === prevOpenForExit) return;
90
+ prevOpenForExit = open;
91
+ if (open) {
92
+ clearTimeout(exitTimer);
93
+ exiting = false;
94
+ return;
95
+ }
96
+ const ms = maxTransitionDurationMs(untrack(() => popoverElement));
97
+ if (ms <= 0) return;
98
+ exiting = true;
99
+ exitTimer = setTimeout(() => {
100
+ exiting = false;
101
+ }, ms + EXIT_MOTION_BUFFER_MS);
102
+ });
103
+
104
+ // While `exiting`, the panel is still fading: keep children mounted and (in
105
+ // the in-place modes) keep `display` un-hidden. `useFloatingPanel` stays on
106
+ // the raw `open` on purpose — `hidePopover()` fires immediately and the
107
+ // discrete transition carries the visual exit, so a re-open during the fade
108
+ // reverses smoothly instead of waiting out the lag.
109
+ const panelVisible = $derived(open || exiting);
110
+
55
111
  // ── Floating UI positioning + native show/hide ─────────────
56
112
  //
57
113
  // Delegated to the shared `useFloatingPanel` helper — the same positioning
@@ -291,6 +347,27 @@
291
347
  target?.focus();
292
348
  }
293
349
 
350
+ // Un-arm `dismissedByTrigger` at the start of the NEXT pointer gesture,
351
+ // wherever it lands. Registered as a one-shot capture listener when the
352
+ // guard arms: capture order (document before trigger) guarantees a stale
353
+ // flag is cleared before any new arm, and `once` keeps at most one listener
354
+ // pending. This closes the aborted-click hole (pointerdown on the trigger,
355
+ // pointer released elsewhere → no click ever consumed the flag → the next
356
+ // trigger click was swallowed once).
357
+ function disarmDismissedByTrigger() {
358
+ dismissedByTrigger = false;
359
+ }
360
+
361
+ // Unmount cleanup for the imperative leftovers: a pending exit-lag timer
362
+ // and a still-armed disarm listener. Dependency-free → runs once,
363
+ // teardown on destroy.
364
+ $effect(() => {
365
+ return () => {
366
+ clearTimeout(exitTimer);
367
+ document.removeEventListener('pointerdown', disarmDismissedByTrigger, true);
368
+ };
369
+ });
370
+
294
371
  function handleTriggerPointerDown() {
295
372
  // Arm the "this pointerdown already dismissed it" guard only in auto
296
373
  // mode, where the browser's light dismiss really closes the popover
@@ -298,7 +375,15 @@
298
375
  // re-opening it). In manual mode nothing light-dismisses — the click
299
376
  // itself must toggle-close, so arming the guard there left the trigger
300
377
  // unable to close its own popover.
301
- if (open && popoverMode === 'auto') dismissedByTrigger = true;
378
+ if (open && popoverMode === 'auto') {
379
+ dismissedByTrigger = true;
380
+ // This event has already passed the document's capture phase, so the
381
+ // listener can only fire on a LATER pointerdown.
382
+ document.addEventListener('pointerdown', disarmDismissedByTrigger, {
383
+ capture: true,
384
+ once: true
385
+ });
386
+ }
302
387
  }
303
388
 
304
389
  function handleTriggerClick(event: MouseEvent) {
@@ -361,6 +446,20 @@
361
446
  drops over a modal dialog (Codeberg #23).
362
447
  -->
363
448
 
449
+ <!--
450
+ `data-state` sits after `{...restProps}` like the other load-bearing
451
+ attributes: it drives the enter/exit motion CSS (popoverMotion) and is the
452
+ documented styling hook for consumers rebuilding motion under `unstyled`.
453
+ `{#if panelVisible}` (not `open`) keeps the children mounted while the exit
454
+ transition plays; `style:display` follows the same lagged flag so the
455
+ in-place modes can fade out before they hide.
456
+
457
+ `inert` while closed (GuidePanel's pattern): the exit-fading children are
458
+ still mounted and displayed, so without it a Tab right after dismiss would
459
+ focus into the visually dismissed panel and Enter could re-fire a consumer
460
+ action; pointer-events-none (popoverMotion) only covers mouse/touch. Also
461
+ drops the fading subtree from the a11y tree immediately.
462
+ -->
364
463
  <div
365
464
  bind:this={popoverElement}
366
465
  class={popoverClasses}
@@ -370,12 +469,16 @@
370
469
  style:position={panel.strategy}
371
470
  style:inset="auto"
372
471
  style:margin="0"
373
- style:display={floatingPanelHidden(panel, open) ? 'none' : null}
472
+ style:display={floatingPanelHidden(panel, panelVisible) ? 'none' : null}
473
+ style:--blocks-popover-duration={popoverDurationInline}
474
+ style:--blocks-popover-easing={transitionEasing}
475
+ data-state={open ? 'open' : 'closed'}
476
+ inert={!open || undefined}
374
477
  {role}
375
478
  aria-modal={ariaModal || undefined}
376
479
  {id}
377
480
  >
378
- {#if open}
481
+ {#if panelVisible}
379
482
  {@render children()}
380
483
  {/if}
381
484
  </div>
@@ -70,6 +70,19 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
70
70
  open?: boolean;
71
71
  /** When true (default), the trigger wrapper handles click and keyboard to toggle the popover. Set to `false` to manage `open` yourself. */
72
72
  autoTrigger?: boolean;
73
+ /**
74
+ * Override the enter/exit fade duration in ms. Defaults to the shared token
75
+ * `--blocks-popover-duration` (150ms; collapses to 1ms under
76
+ * `prefers-reduced-motion`).
77
+ */
78
+ transitionDuration?: number;
79
+ /**
80
+ * Override the enter/exit easing as a CSS `<easing-function>` (e.g.
81
+ * `'linear'`, `'ease-out'`, `'cubic-bezier(0.4,0,0.2,1)'`). Defaults to the
82
+ * token `--blocks-popover-easing`. A CSS string, not a JS easing fn, because
83
+ * the popover motion is a pure CSS transition.
84
+ */
85
+ transitionEasing?: string;
73
86
  /**
74
87
  * Whether the popover closes on Escape key. Default `true`.
75
88
  * Set to `false` for cases where Escape should be intercepted by an
@@ -98,7 +111,7 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
98
111
  onEscape?: () => void;
99
112
  /** Extra classes merged onto the floating panel element. */
100
113
  class?: string;
101
- /** Strip all default tv() classes. Combine with `class` or `slotClasses` for full custom styling. */
114
+ /** Strip all default tv() classes (including the enter/exit motion). Combine with `class` or `slotClasses` for full custom styling; the panel always carries `data-state="open" | "closed"`, so custom motion can rebuild on that hook (see `popoverMotion` in popover.variants.ts for the reference implementation). */
102
115
  unstyled?: boolean;
103
116
  /** Per-slot class overrides. Available slots: `base` (the floating panel). */
104
117
  slotClasses?: Partial<Record<'base', string>>;
@@ -111,4 +124,4 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
111
124
  preset?: string;
112
125
  }
113
126
  export { default as Popover } from './Popover.svelte';
114
- export { type PopoverVariants, popoverVariants } from './popover.variants.js';
127
+ export { type PopoverVariants, popoverMotion, popoverVariants } from './popover.variants.js';
@@ -1,2 +1,2 @@
1
1
  export { default as Popover } from './Popover.svelte';
2
- export { popoverVariants } from './popover.variants.js';
2
+ export { popoverMotion, popoverVariants } from './popover.variants.js';
@@ -1,4 +1,29 @@
1
1
  import { type VariantProps } from '../../utils/variants.js';
2
+ /**
3
+ * CSS-native enter/exit motion for the floating panel (ACC-3 rest), keyed on
4
+ * the `data-state` attribute Popover always stamps. Kept as its own fragment
5
+ * because two call sites need exactly these classes: `popoverVariants.base`
6
+ * below, and Menu — which renders its inner Popover `unstyled` (to avoid a
7
+ * double surface) and re-applies the fragment via Popover's `class` prop.
8
+ *
9
+ * How the two halves work:
10
+ * - **Enter** — the panel un-hides via `showPopover()` (top layer) or a
11
+ * `display` flip (in-place mode), so a plain transition has no before-state;
12
+ * `starting:` (`@starting-style`) supplies it.
13
+ * - **Exit** — `hidePopover()` / a native light dismiss yank the panel to
14
+ * `display: none` in the same style recalc that flips `data-state`;
15
+ * `transition-discrete` (`transition-behavior: allow-discrete`) on
16
+ * `display`/`overlay` keeps it painted (and in the top layer) until the
17
+ * fade lands. Popover lags the children-teardown to match — see the
18
+ * exit-motion block in Popover.svelte.
19
+ *
20
+ * Browsers without `@starting-style`/`allow-discrete` simply skip the motion
21
+ * and keep today's instant toggle. Duration/easing resolve through the
22
+ * `--blocks-popover-*` tokens (interaction.css), which reduced motion
23
+ * collapses to 1ms; `motion-reduce:duration-[1ms]` guards the inline
24
+ * per-instance override path, which can't see the media query.
25
+ */
26
+ export declare const popoverMotion: string;
2
27
  export declare const popoverVariants: ((props?: import("../../utils/variants.js").TVProps<{
3
28
  size: {
4
29
  sm: string;