@urbicon-ui/blocks 6.23.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 (68) 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/components/Sparkline/Sparkline.svelte +10 -4
  14. package/dist/components/Sparkline/index.d.ts +9 -1
  15. package/dist/internal/charts/variants.js +2 -2
  16. package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
  17. package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
  18. package/dist/internal/date-grid/date-grid.svelte.js +63 -7
  19. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  20. package/dist/primitives/Avatar/index.d.ts +1 -1
  21. package/dist/primitives/Badge/Badge.svelte +8 -2
  22. package/dist/primitives/Badge/index.d.ts +67 -32
  23. package/dist/primitives/Button/Button.svelte +12 -25
  24. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  25. package/dist/primitives/Checkbox/Checkbox.svelte +1 -1
  26. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  27. package/dist/primitives/Collapsible/index.d.ts +13 -2
  28. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  29. package/dist/primitives/ConfirmDialog/index.d.ts +15 -3
  30. package/dist/primitives/Dialog/Dialog.svelte +31 -8
  31. package/dist/primitives/Drawer/Drawer.svelte +29 -7
  32. package/dist/primitives/Input/Input.svelte +7 -1
  33. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  34. package/dist/primitives/Menu/Menu.svelte +8 -0
  35. package/dist/primitives/Menu/index.d.ts +11 -13
  36. package/dist/primitives/Pagination/Pagination.svelte +28 -15
  37. package/dist/primitives/Popover/Popover.svelte +107 -4
  38. package/dist/primitives/Popover/index.d.ts +15 -2
  39. package/dist/primitives/Popover/index.js +1 -1
  40. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  41. package/dist/primitives/Popover/popover.variants.js +35 -0
  42. package/dist/primitives/Progress/progress.variants.js +1 -1
  43. package/dist/primitives/RadioGroup/RadioGroup.svelte +6 -2
  44. package/dist/primitives/RadioGroup/RadioItem.svelte +1 -1
  45. package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
  46. package/dist/primitives/Select/index.d.ts +8 -1
  47. package/dist/primitives/Slider/Slider.svelte +32 -3
  48. package/dist/primitives/Stepper/stepper.variants.js +1 -1
  49. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  50. package/dist/primitives/Toast/Toaster.svelte +81 -7
  51. package/dist/primitives/Toggle/Toggle.svelte +6 -2
  52. package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
  53. package/dist/style/foundation.css +30 -3
  54. package/dist/style/interaction.css +19 -2
  55. package/dist/style/semantic.css +72 -1
  56. package/dist/style/themes/neutral.css +10 -1
  57. package/dist/style/themes/ocean.css +5 -1
  58. package/dist/utils/compose-handlers.d.ts +72 -0
  59. package/dist/utils/compose-handlers.js +65 -0
  60. package/dist/utils/figma-token-export.js +1 -1
  61. package/dist/utils/guide.svelte.js +92 -29
  62. package/dist/utils/index.d.ts +1 -1
  63. package/dist/utils/index.js +1 -1
  64. package/dist/utils/overlay-tokens.d.ts +17 -0
  65. package/dist/utils/overlay-tokens.js +30 -0
  66. package/dist/utils/use-floating-panel.svelte.js +9 -3
  67. package/dist/utils/variants.js +5 -1
  68. package/package.json +3 -3
@@ -34,7 +34,7 @@
34
34
  onRemove,
35
35
  onclick,
36
36
  onHover,
37
- role = 'status',
37
+ role,
38
38
  ...restProps
39
39
  }: BadgeProps = $props();
40
40
 
@@ -56,6 +56,12 @@
56
56
  const isInteractive = $derived(purpose === 'chip' || interactive || !!onclick);
57
57
  const isRemovable = $derived(removable && !isDot);
58
58
 
59
+ // An interactive badge (clickable chip / onclick) carries button semantics:
60
+ // it is focusable and Enter/Space-activatable, so screen readers must hear a
61
+ // button, not a `status` region. An explicit `role` always wins; a disabled
62
+ // badge is inert (pointer-events-none, guarded handlers) so it stays `status`.
63
+ const effRole = $derived(role ?? (isInteractive && !disabled ? 'button' : 'status'));
64
+
59
65
  const variantProps: BadgeVariants = $derived({
60
66
  tier: effectiveTier,
61
67
  intent,
@@ -124,7 +130,7 @@
124
130
  ? [slotClasses?.base, className].filter(Boolean).join(' ')
125
131
  : styles.base({ class: [slotClasses?.base, className] })
126
132
  ]}
127
- {role}
133
+ role={effRole}
128
134
  data-purpose={purpose}
129
135
  tabindex={isInteractive && !disabled ? 0 : undefined}
130
136
  onmouseenter={handleMouseEnter}
@@ -3,24 +3,13 @@ import type { HTMLAttributes } from 'svelte/elements';
3
3
  import type { MintProp } from '../../mint/index.js';
4
4
  import type { BadgePlacement, BadgeSlots, BadgeVariants } from './badge.variants.js';
5
5
  /**
6
- * Shared fields that apply to every Badge variant `dot` and the
7
- * label-style variants both accept these.
6
+ * Shared fields that apply to every Badge arm. `variant`, `purpose`, and the
7
+ * label-only props (`children` / `counter` / `removable` / `interactive` /
8
+ * `onRemove`) are declared per-arm instead — that split is what lets *both*
9
+ * dot spellings (`variant="dot"` and the canonical `purpose="dot"`) forbid the
10
+ * label-only props at the type level.
8
11
  */
9
- interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter'>, Omit<HTMLAttributes<HTMLElement>, 'children'> {
10
- /**
11
- * The badge's semantic purpose — the canonical axis that resolves what the
12
- * badge *is*, since a bare Badge served five overlapping roles. Orchestrates
13
- * the low-level visual props so you rarely set them directly:
14
- * - `status` — a state marker (Active, Failed); pairs with `intent`.
15
- * - `tag` — a neutral inline label (category, version).
16
- * - `counter` — a compact numeric pill (replaces the `counter` boolean).
17
- * - `dot` — a pure indicator, content hidden (replaces `variant="dot"`).
18
- * - `chip` — a removable, interactive filter chip; pair with `removable`.
19
- *
20
- * Leave unset to drive the badge purely by the low-level props (back-compat).
21
- * When set, `purpose` wins over `variant="dot"` / the `counter` boolean.
22
- */
23
- purpose?: 'status' | 'tag' | 'counter' | 'dot' | 'chip';
12
+ interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter' | 'removable' | 'interactive'>, Omit<HTMLAttributes<HTMLElement>, 'children'> {
24
13
  /** Add a pulsing animation to draw attention (e.g. for live indicators). */
25
14
  pulse?: boolean;
26
15
  /** Visually disable the badge (reduced opacity, no pointer events). */
@@ -29,7 +18,7 @@ interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter'>, Omi
29
18
  border?: boolean;
30
19
  /** Anchor the badge absolutely within a `position: relative` parent. */
31
20
  placement?: BadgePlacement;
32
- /** Click handler. Automatically enables interactive styles. */
21
+ /** Click handler. Automatically enables interactive styles (and `role="button"`). */
33
22
  onclick?: (event: MouseEvent) => void;
34
23
  /** Called when the hover state changes. */
35
24
  onHover?: (hovered: boolean) => void;
@@ -46,30 +35,73 @@ interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter'>, Omi
46
35
  * and make the custom look reusable across the project.
47
36
  */
48
37
  preset?: string;
49
- /** ARIA role. Defaults to `"status"`. Use `"alert"` for time-sensitive notifications. */
50
- role?: 'status' | 'alert' | 'badge';
38
+ /**
39
+ * ARIA role. A static badge is announced as `"status"`; an interactive badge
40
+ * (`onclick` or `purpose="chip"`, when not `disabled`) defaults to `"button"`
41
+ * so assistive tech announces its activation semantics. Set explicitly to
42
+ * override — e.g. `"alert"` for time-sensitive notifications. An explicit
43
+ * value always wins over the derived default.
44
+ */
45
+ role?: 'status' | 'alert' | 'badge' | 'button';
51
46
  /** Micro-interaction preset. Only applies when the badge is interactive. */
52
47
  mint?: MintProp;
53
48
  }
54
49
  /**
55
- * Dot-style badge a pure indicator. Content is `sr-only` (visually
56
- * hidden), so `children` / `counter` / `removable` / `interactive` /
57
- * `onRemove` are excluded by the type: an invisible remove-button or
58
- * hover-scale on a 2.5 × 2.5 px dot is never the intended UI.
50
+ * The label-only props a pure-indicator dot forbids. An invisible remove
51
+ * button, counter shape, or hover-scale on a 2.5 × 2.5 px dot is never the
52
+ * intended UI, so every dot arm excludes them at the type level.
59
53
  */
60
- interface BadgeDotProps extends BadgeBaseProps {
61
- /** Visual variant. `dot` renders a pure indicator (content hidden); the label variants accept the full surface. */
62
- variant: 'dot';
54
+ interface BadgeDotForbiddenProps {
63
55
  children?: never;
64
56
  counter?: never;
65
57
  removable?: never;
66
58
  interactive?: never;
67
59
  onRemove?: never;
68
60
  }
61
+ /**
62
+ * Dot badge selected by the canonical `purpose="dot"` — a pure indicator whose
63
+ * content is hidden. `variant` is inert here (the dot look always wins), and
64
+ * the label-only props are excluded by {@link BadgeDotForbiddenProps}.
65
+ */
66
+ interface BadgeDotByPurposeProps extends BadgeBaseProps, BadgeDotForbiddenProps {
67
+ /**
68
+ * The canonical dot spelling — forces the pure-indicator look regardless of
69
+ * `variant`. For the label roles use `status` / `tag` / `counter` / `chip`
70
+ * (their own arm).
71
+ */
72
+ purpose: 'dot';
73
+ /** Inert under `purpose="dot"`; accepted only so a leftover `variant` compiles. */
74
+ variant?: 'filled' | 'outlined' | 'soft' | 'dot';
75
+ }
76
+ /**
77
+ * Dot badge selected by the deprecated `variant="dot"`. Prefer `purpose="dot"`.
78
+ * Same exclusions as {@link BadgeDotByPurposeProps}.
79
+ */
80
+ interface BadgeDotProps extends BadgeBaseProps, BadgeDotForbiddenProps {
81
+ /** Visual variant. `dot` renders a pure indicator (content hidden); the label variants accept the full surface. */
82
+ variant: 'dot';
83
+ /** Only the dot purpose is non-contradictory with `variant="dot"`. */
84
+ purpose?: 'dot';
85
+ }
69
86
  /**
70
87
  * Label-style badge — accepts content, counter shape, remove button, etc.
71
88
  */
72
89
  interface BadgeStandardProps extends BadgeBaseProps {
90
+ /**
91
+ * The badge's semantic purpose — the canonical axis that resolves what the
92
+ * badge *is*, since a bare Badge served overlapping roles. Orchestrates the
93
+ * low-level visual props so you rarely set them directly:
94
+ * - `status` — a state marker (Active, Failed); pairs with `intent`.
95
+ * - `tag` — a neutral inline label (category, version).
96
+ * - `counter` — a compact numeric pill (replaces the `counter` boolean).
97
+ * - `chip` — a removable, interactive filter chip; pair with `removable`.
98
+ *
99
+ * For a pure indicator use `purpose="dot"` (its own arm — it forbids
100
+ * content / counter / remove). Leave unset to drive the badge purely by the
101
+ * low-level props (back-compat); when set, `purpose` wins over the `counter`
102
+ * boolean.
103
+ */
104
+ purpose?: 'status' | 'tag' | 'counter' | 'chip';
73
105
  /** Visual variant. `dot` renders a pure indicator (content hidden); the label variants accept the full surface. @default 'filled' */
74
106
  variant?: 'filled' | 'outlined' | 'soft';
75
107
  /** Badge content (text, icons, numbers). */
@@ -93,10 +125,13 @@ interface BadgeStandardProps extends BadgeBaseProps {
93
125
  * @related Alert
94
126
  * @related Toast
95
127
  *
96
- * Badge props are a discriminated union on `variant`: `variant="dot"`
97
- * forbids `children` / `counter` / `removable` / `interactive` /
98
- * `onRemove` at the type level, while `filled` / `outlined` / `soft`
99
- * accept the full surface.
128
+ * Badge props are a discriminated union. The pure-indicator dot — spelled
129
+ * canonically as `purpose="dot"` or via the deprecated `variant="dot"`
130
+ * forbids `children` / `counter` / `removable` / `interactive` / `onRemove`
131
+ * at the type level, while the label arms (`filled` / `outlined` / `soft`;
132
+ * `purpose` `status` / `tag` / `counter` / `chip`) accept the full surface.
133
+ * An interactive badge (`onclick` or `purpose="chip"`) is announced as a
134
+ * `button`, a static one as `status` (override via `role`).
100
135
  *
101
136
  * @example Purpose-driven (canonical) — the intent reads from `purpose`
102
137
  * ```svelte
@@ -114,6 +149,6 @@ interface BadgeStandardProps extends BadgeBaseProps {
114
149
  * </div>
115
150
  * ```
116
151
  */
117
- export type BadgeProps = BadgeDotProps | BadgeStandardProps;
152
+ export type BadgeProps = BadgeDotByPurposeProps | BadgeDotProps | BadgeStandardProps;
118
153
  export { default as Badge } from './Badge.svelte';
119
154
  export { type BadgePlacement, type BadgeVariants, badgeVariants, PLACEMENT_VALUES } from './badge.variants.js';
@@ -153,25 +153,15 @@
153
153
  `blocks-intent-{name}` class on the button root scopes the glow color
154
154
  via `mint/styles.css`. */
155
155
 
156
- :global(.blocks-button .blocks-ripple) {
157
- position: absolute;
158
- border-radius: 50%;
159
- background: rgba(255, 255, 255, 0.4);
160
- pointer-events: none;
161
- animation: ripple var(--blocks-duration-slow) var(--blocks-ease-confident);
162
- z-index: 1;
163
- }
164
-
165
- @keyframes ripple {
166
- 0% {
167
- transform: scale(0);
168
- opacity: 1;
169
- }
170
- 100% {
171
- transform: scale(1);
172
- opacity: 0;
173
- }
174
- }
156
+ /* The ripple mint owns the ripple end-to-end: `mint/ripple.ts` appends a
157
+ `<span class="blocks-mint-ripple">` on click and drives it with the Web
158
+ Animations API, tinted `currentColor` so it resolves per variant/intent —
159
+ white on a filled button, the intent hue on outlined/ghost. Button used to
160
+ carry a competing `.blocks-ripple` rule here (a hardcoded
161
+ `rgba(255,255,255,0.4)` plus its own @keyframes) that no element ever
162
+ matched — nothing has emitted that class since the initial commit, and it
163
+ would have been invisible on light variants had it matched. Removed rather
164
+ than tokenised; see the same note in `mint/styles.css` (XC-12/PAG-1). */
175
165
 
176
166
  :global(.sr-only) {
177
167
  position: absolute;
@@ -185,10 +175,7 @@
185
175
  border-width: 0;
186
176
  }
187
177
 
188
- @media (prefers-reduced-motion: reduce) {
189
- :global(.blocks-button .blocks-ripple) {
190
- animation: none !important;
191
- opacity: 0 !important;
192
- }
193
- }
178
+ /* No reduced-motion rule for the ripple here either: `mint/ripple.ts` bails at
179
+ click time on `prefers-reduced-motion`, and `mint/styles.css` hides
180
+ `.blocks-mint-ripple` outright — both on the class that actually exists. */
194
181
  </style>
@@ -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}
@@ -126,6 +126,7 @@
126
126
  for={id}
127
127
  >
128
128
  <input
129
+ {...restProps}
129
130
  bind:this={inputRef}
130
131
  {id}
131
132
  type="checkbox"
@@ -139,7 +140,6 @@
139
140
  aria-invalid={ff.invalid ? 'true' : undefined}
140
141
  aria-describedby={describedBy}
141
142
  onchange={handleChange}
142
- {...restProps}
143
143
  />
144
144
 
145
145
  <span
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { HTMLDialogAttributes } from 'svelte/elements';
2
3
  import type { DialogIntent } from '../Dialog/index.js';
3
4
  import type { DialogSlots } from '../Dialog/dialog.variants.js';
4
5
  /**
@@ -15,7 +16,8 @@ export type ConfirmIntent = Exclude<DialogIntent, 'neutral'>;
15
16
  *
16
17
  * `onConfirm` may be `async`; while the returned promise is pending the
17
18
  * dialog locks itself (no backdrop dismiss, no escape, confirm button
18
- * 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`.
19
21
  *
20
22
  * @tag overlay
21
23
  * @related Dialog
@@ -36,7 +38,7 @@ export type ConfirmIntent = Exclude<DialogIntent, 'neutral'>;
36
38
  * />
37
39
  * ```
38
40
  */
39
- export interface ConfirmDialogProps {
41
+ export interface ConfirmDialogProps extends Omit<HTMLDialogAttributes, 'children' | 'open' | 'title' | 'draggable'> {
40
42
  /** Controls visibility. Supports bind:open. */
41
43
  open?: boolean;
42
44
  /** Heading shown in the dialog header. */
@@ -59,9 +61,19 @@ export interface ConfirmDialogProps {
59
61
  cancelLabel?: string;
60
62
  /**
61
63
  * Confirm handler. May return a promise — the dialog stays open and shows
62
- * 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}.
63
67
  */
64
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;
65
77
  /** Fired when the user cancels (button, backdrop, or Escape). */
66
78
  onCancel?: () => void;
67
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