@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.
- package/README.md +14 -0
- package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
- package/dist/components/Calendar/calendar.variants.d.ts +63 -63
- package/dist/components/Calendar/calendar.variants.js +5 -3
- package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
- package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
- package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
- package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
- package/dist/components/CommandPalette/index.d.ts +19 -0
- package/dist/components/CompositionBar/CompositionBar.svelte +2 -2
- package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
- package/dist/components/Planner/planner.variants.js +1 -1
- package/dist/components/Sankey/sankey.variants.js +1 -1
- package/dist/i18n/index.d.ts +378 -2
- package/dist/internal/charts/variants.js +2 -2
- package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
- package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
- package/dist/internal/date-grid/date-grid.svelte.js +63 -7
- package/dist/primitives/Alert/alert.variants.d.ts +8 -8
- package/dist/primitives/Alert/alert.variants.js +1 -1
- package/dist/primitives/Avatar/Avatar.svelte +38 -24
- package/dist/primitives/Avatar/index.d.ts +1 -1
- package/dist/primitives/Badge/badge.variants.js +1 -1
- package/dist/primitives/Button/Button.svelte +12 -25
- package/dist/primitives/Button/button.variants.js +2 -2
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
- package/dist/primitives/Checkbox/checkbox.variants.js +2 -2
- package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
- package/dist/primitives/Collapsible/index.d.ts +13 -2
- package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
- package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
- package/dist/primitives/Dialog/Dialog.svelte +36 -8
- package/dist/primitives/Drawer/Drawer.svelte +34 -7
- package/dist/primitives/Input/Input.svelte +7 -1
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
- package/dist/primitives/Menu/Menu.svelte +8 -0
- package/dist/primitives/Menu/index.d.ts +11 -13
- package/dist/primitives/Popover/Popover.svelte +107 -4
- package/dist/primitives/Popover/index.d.ts +15 -2
- package/dist/primitives/Popover/index.js +1 -1
- package/dist/primitives/Popover/popover.variants.d.ts +25 -0
- package/dist/primitives/Popover/popover.variants.js +35 -0
- package/dist/primitives/Progress/progress.variants.js +1 -1
- package/dist/primitives/RadioGroup/radioGroup.variants.js +2 -2
- package/dist/primitives/Select/index.d.ts +8 -1
- package/dist/primitives/Slider/Slider.svelte +32 -3
- package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
- package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
- package/dist/primitives/Stepper/stepper.variants.js +2 -2
- package/dist/primitives/Textarea/Textarea.svelte +5 -1
- package/dist/primitives/Toast/toast.variants.d.ts +12 -12
- package/dist/primitives/Tooltip/tooltip.variants.d.ts +3 -3
- package/dist/primitives/Tooltip/tooltip.variants.js +14 -3
- package/dist/style/foundation.css +41 -7
- package/dist/style/interaction.css +19 -2
- package/dist/style/semantic.css +84 -1
- package/dist/style/themes/forest.css +3 -1
- package/dist/style/themes/neutral.css +10 -1
- package/dist/style/themes/ocean.css +5 -1
- package/dist/style/themes/sunset.css +3 -1
- package/dist/utils/compose-handlers.d.ts +72 -0
- package/dist/utils/compose-handlers.js +65 -0
- package/dist/utils/figma-token-export.js +6 -2
- package/dist/utils/guide.svelte.js +92 -29
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/overlay-tokens.d.ts +17 -0
- package/dist/utils/overlay-tokens.js +30 -0
- package/dist/utils/overlay.js +24 -2
- package/dist/utils/use-floating-panel.svelte.js +9 -3
- package/dist/utils/variants.js +5 -1
- package/package.json +3 -3
|
@@ -42,35 +42,42 @@
|
|
|
42
42
|
|
|
43
43
|
const isInteractive = $derived(clickable || interactive || !!onclick);
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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 ?
|
|
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
|
|
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;
|
|
@@ -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
|
-
:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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-
|
|
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-
|
|
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={
|
|
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-
|
|
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-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
129
|
+
fieldId,
|
|
124
130
|
helper,
|
|
125
131
|
error,
|
|
126
132
|
required,
|