@urbicon-ui/blocks 6.24.0 → 6.26.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 (73) hide show
  1. package/README.md +14 -0
  2. package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
  3. package/dist/components/Calendar/calendar.variants.d.ts +63 -63
  4. package/dist/components/Calendar/calendar.variants.js +5 -3
  5. package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
  6. package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
  7. package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
  8. package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
  9. package/dist/components/CommandPalette/index.d.ts +19 -0
  10. package/dist/components/CompositionBar/CompositionBar.svelte +2 -2
  11. package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
  12. package/dist/components/Planner/planner.variants.js +1 -1
  13. package/dist/components/Sankey/sankey.variants.js +1 -1
  14. package/dist/i18n/index.d.ts +378 -2
  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/Alert/alert.variants.d.ts +8 -8
  20. package/dist/primitives/Alert/alert.variants.js +1 -1
  21. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  22. package/dist/primitives/Avatar/index.d.ts +1 -1
  23. package/dist/primitives/Badge/badge.variants.js +1 -1
  24. package/dist/primitives/Button/Button.svelte +12 -25
  25. package/dist/primitives/Button/button.variants.js +2 -2
  26. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  27. package/dist/primitives/Checkbox/checkbox.variants.js +2 -2
  28. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  29. package/dist/primitives/Collapsible/index.d.ts +13 -2
  30. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  31. package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
  32. package/dist/primitives/Dialog/Dialog.svelte +36 -8
  33. package/dist/primitives/Drawer/Drawer.svelte +34 -7
  34. package/dist/primitives/Input/Input.svelte +7 -1
  35. package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
  36. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  37. package/dist/primitives/Menu/Menu.svelte +8 -0
  38. package/dist/primitives/Menu/index.d.ts +11 -13
  39. package/dist/primitives/Popover/Popover.svelte +107 -4
  40. package/dist/primitives/Popover/index.d.ts +15 -2
  41. package/dist/primitives/Popover/index.js +1 -1
  42. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  43. package/dist/primitives/Popover/popover.variants.js +35 -0
  44. package/dist/primitives/Progress/progress.variants.js +1 -1
  45. package/dist/primitives/RadioGroup/radioGroup.variants.js +2 -2
  46. package/dist/primitives/Select/index.d.ts +8 -1
  47. package/dist/primitives/Slider/Slider.svelte +32 -3
  48. package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
  49. package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
  50. package/dist/primitives/Stepper/stepper.variants.js +2 -2
  51. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  52. package/dist/primitives/Toast/toast.variants.d.ts +12 -12
  53. package/dist/primitives/Tooltip/tooltip.variants.d.ts +3 -3
  54. package/dist/primitives/Tooltip/tooltip.variants.js +14 -3
  55. package/dist/style/foundation.css +41 -7
  56. package/dist/style/interaction.css +19 -2
  57. package/dist/style/semantic.css +84 -1
  58. package/dist/style/themes/forest.css +3 -1
  59. package/dist/style/themes/neutral.css +10 -1
  60. package/dist/style/themes/ocean.css +5 -1
  61. package/dist/style/themes/sunset.css +3 -1
  62. package/dist/utils/compose-handlers.d.ts +72 -0
  63. package/dist/utils/compose-handlers.js +65 -0
  64. package/dist/utils/figma-token-export.js +6 -2
  65. package/dist/utils/guide.svelte.js +92 -29
  66. package/dist/utils/index.d.ts +1 -1
  67. package/dist/utils/index.js +1 -1
  68. package/dist/utils/overlay-tokens.d.ts +17 -0
  69. package/dist/utils/overlay-tokens.js +30 -0
  70. package/dist/utils/overlay.js +24 -2
  71. package/dist/utils/use-floating-panel.svelte.js +9 -3
  72. package/dist/utils/variants.js +5 -1
  73. package/package.json +3 -3
@@ -42,35 +42,42 @@
42
42
 
43
43
  const isInteractive = $derived(clickable || interactive || !!onclick);
44
44
 
45
- function getRandomColor(name?: string): string {
46
- if (!name) return '#4b5563'; // gray-600
45
+ // The identity palette lives in `style/semantic.css` (--color-avatar-1..12),
46
+ // so a brand rethemes it without touching this component and dark mode
47
+ // resolves through light-dark() like every other token. Spelled out one
48
+ // `var()` per slot rather than built from a template string: Tailwind's
49
+ // scanner only keeps @theme variables it can see referenced in the source,
50
+ // and a computed `--color-avatar-${n}` is invisible to it — the same reason
51
+ // `internal/charts/utils.ts` enumerates its chart palette literally.
52
+ const identityPalette = [
53
+ 'var(--color-avatar-1)',
54
+ 'var(--color-avatar-2)',
55
+ 'var(--color-avatar-3)',
56
+ 'var(--color-avatar-4)',
57
+ 'var(--color-avatar-5)',
58
+ 'var(--color-avatar-6)',
59
+ 'var(--color-avatar-7)',
60
+ 'var(--color-avatar-8)',
61
+ 'var(--color-avatar-9)',
62
+ 'var(--color-avatar-10)',
63
+ 'var(--color-avatar-11)',
64
+ 'var(--color-avatar-12)'
65
+ ];
47
66
 
48
- // Tailwind *-700 shades dark enough that white text hits WCAG AA 4.5:1
49
- // on every hue. Lighter shades (*-500) used before produced ~3:1 contrast.
50
- const colors = [
51
- '#b91c1c', // red-700
52
- '#c2410c', // orange-700
53
- '#b45309', // amber-700
54
- '#a16207', // yellow-700
55
- '#4d7c0f', // lime-700
56
- '#15803d', // green-700
57
- '#047857', // emerald-700
58
- '#0e7490', // cyan-700
59
- '#0369a1', // sky-700
60
- '#1d4ed8', // blue-700
61
- '#4338ca', // indigo-700
62
- '#6d28d9', // violet-700
63
- '#7e22ce', // purple-700
64
- '#a21caf', // fuchsia-700
65
- '#be185d' // pink-700
66
- ];
67
+ // Deterministic name slot. The same name must always land on the same hue,
68
+ // so this stays a pure function of `name` with no randomness despite the
69
+ // `randomColor` prop name.
70
+ function getIdentityColor(name?: string): string | undefined {
71
+ // No name means no identity to encode — fall through to the `frame` slot's
72
+ // neutral surface tokens instead of inventing a grey.
73
+ if (!name) return undefined;
67
74
 
68
75
  let hash = 0;
69
76
  for (let i = 0; i < name.length; i++) {
70
77
  hash = name.charCodeAt(i) + ((hash << 5) - hash);
71
78
  }
72
79
 
73
- return colors[Math.abs(hash) % colors.length];
80
+ return identityPalette[Math.abs(hash) % identityPalette.length];
74
81
  }
75
82
 
76
83
  function getInitials(name?: string): string {
@@ -83,7 +90,7 @@
83
90
  .slice(0, 2);
84
91
  }
85
92
 
86
- const randomBg = $derived(randomColor ? getRandomColor(name) : undefined);
93
+ const randomBg = $derived(randomColor ? getIdentityColor(name) : undefined);
87
94
 
88
95
  const variantProps: AvatarVariants = $derived({
89
96
  size,
@@ -107,8 +114,15 @@
107
114
  // Inline property names stay kebab-case — a previous camelCase
108
115
  // `backgroundColor` was silently ignored by browsers, dropping the avatar back
109
116
  // to surface-interactive + white text.
117
+ //
118
+ // The initials take `--color-text-on-dark`, the palette's contrast partner: it
119
+ // flips white → neutral-900 in step with the fills going dark → light, so the
120
+ // initials stay legible in both modes. A literal `white` (the previous value)
121
+ // only worked because the old fills were frozen dark in dark mode too.
110
122
  const frameStyle = $derived(
111
- randomColor && randomBg ? `background-color: ${randomBg}; color: white` : undefined
123
+ randomColor && randomBg
124
+ ? `background-color: ${randomBg}; color: var(--color-text-on-dark)`
125
+ : undefined
112
126
  );
113
127
 
114
128
  // `ringColor` recolours the ring on the outer `base`. The ring is a
@@ -34,7 +34,7 @@ export interface AvatarProps extends AvatarVariants, Omit<HTMLAttributes<HTMLDiv
34
34
  children?: Snippet;
35
35
  /** Custom ring color (CSS value). Overrides `ringIntent` when set. */
36
36
  ringColor?: string;
37
- /** Derive a deterministic background color from `name`. The same name always produces the same color, making it easy to visually distinguish users without images. Overrides `intent`. */
37
+ /** Derive a deterministic background color from `name`. The same name always produces the same color, making it easy to visually distinguish users without images. Overrides `intent`. Picks from the 12-slot identity palette (`--color-avatar-1` … `--color-avatar-12`), which resolves light/dark automatically — override those tokens to rebrand it. Without a `name` there is no identity to encode, so the avatar keeps its neutral surface. */
38
38
  randomColor?: boolean;
39
39
  /** Mark the avatar as clickable (adds hover/focus styles and keyboard support). Alias for the `interactive` variant. */
40
40
  clickable?: boolean;
@@ -307,7 +307,7 @@ export const badgeVariants = tv({
307
307
  intent: 'warning',
308
308
  variant: 'filled',
309
309
  class: {
310
- base: 'bg-warning text-text-on-surface border-warning'
310
+ base: 'bg-warning text-text-on-warning border-warning'
311
311
  }
312
312
  },
313
313
  {
@@ -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>
@@ -175,7 +175,7 @@ export const buttonVariants = tv({
175
175
  intent: 'warning',
176
176
  variant: 'filled',
177
177
  class: {
178
- base: 'bg-warning text-text-on-surface border-warning hover:bg-warning-hover active:bg-warning-active'
178
+ base: 'bg-warning text-text-on-warning border-warning hover:bg-warning-hover active:bg-warning-active'
179
179
  }
180
180
  },
181
181
  {
@@ -369,7 +369,7 @@ export const buttonVariants = tv({
369
369
  active: true,
370
370
  variant: 'outlined',
371
371
  intent: 'warning',
372
- class: { base: 'bg-warning text-text-on-surface border-warning hover:bg-warning-hover' }
372
+ class: { base: 'bg-warning text-text-on-warning border-warning hover:bg-warning-hover' }
373
373
  },
374
374
  {
375
375
  active: true,
@@ -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}
@@ -146,7 +146,7 @@ export const checkboxVariants = tv({
146
146
  checked: true,
147
147
  intent: 'warning',
148
148
  class: {
149
- box: 'bg-warning border-warning text-text-on-surface group-hover:bg-warning-hover group-active:bg-warning-active'
149
+ box: 'bg-warning border-warning text-text-on-warning group-hover:bg-warning-hover group-active:bg-warning-active'
150
150
  }
151
151
  },
152
152
  {
@@ -189,7 +189,7 @@ export const checkboxVariants = tv({
189
189
  indeterminate: true,
190
190
  intent: 'warning',
191
191
  class: {
192
- box: 'bg-warning border-warning text-text-on-surface group-hover:bg-warning-hover group-active:bg-warning-active'
192
+ box: 'bg-warning border-warning text-text-on-warning group-hover:bg-warning-hover group-active:bg-warning-active'
193
193
  }
194
194
  },
195
195
  {
@@ -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
@@ -256,6 +279,10 @@
256
279
  easing: motion.easing
257
280
  }}
258
281
  ></div>
282
+ <!-- tabindex=-1: focus target of last resort — focusFirstElement falls back
283
+ to the panel when children contain nothing focusable (untitled dialog),
284
+ keeping the element-level ESC handler + Tab trap live. Negative, so it
285
+ never joins the Tab cycle. -->
259
286
  <div
260
287
  bind:this={panelEl}
261
288
  class={unstyled
@@ -263,6 +290,7 @@
263
290
  : styles.panel({ class: [slotClasses?.panel, className] })}
264
291
  data-intent={intent}
265
292
  role="document"
293
+ tabindex="-1"
266
294
  style:translate={dragX !== 0 || dragY !== 0 ? `${dragX}px ${dragY}px` : undefined}
267
295
  transition:scale={{
268
296
  duration: motion.enterDuration,
@@ -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
@@ -196,6 +218,10 @@
196
218
  easing: motion.easing
197
219
  }}
198
220
  ></div>
221
+ <!-- tabindex=-1: focus target of last resort — focusFirstElement falls back
222
+ to the panel when children contain nothing focusable (untitled drawer
223
+ with hideCloseButton), keeping the element-level ESC handler + Tab trap
224
+ live. Negative, so it never joins the Tab cycle. -->
199
225
  <div
200
226
  bind:this={panelElement}
201
227
  class={unstyled
@@ -203,6 +229,7 @@
203
229
  : styles.panel({ class: [slotClasses?.panel, className] })}
204
230
  data-intent={intent}
205
231
  role="document"
232
+ tabindex="-1"
206
233
  transition:fly={flyParams}
207
234
  onoutroend={handleOutroEnd}
208
235
  >
@@ -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,