@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.
- package/README.md +14 -0
- package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
- 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 +1 -1
- 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/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/Avatar/Avatar.svelte +38 -24
- package/dist/primitives/Avatar/index.d.ts +1 -1
- package/dist/primitives/Button/Button.svelte +12 -25
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
- 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 +31 -8
- package/dist/primitives/Drawer/Drawer.svelte +29 -7
- package/dist/primitives/Input/Input.svelte +7 -1
- 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 +1 -1
- package/dist/primitives/Select/index.d.ts +8 -1
- package/dist/primitives/Slider/Slider.svelte +32 -3
- package/dist/primitives/Stepper/stepper.variants.js +1 -1
- package/dist/primitives/Textarea/Textarea.svelte +5 -1
- package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
- package/dist/style/foundation.css +30 -3
- package/dist/style/interaction.css +19 -2
- package/dist/style/semantic.css +72 -1
- package/dist/style/themes/neutral.css +10 -1
- package/dist/style/themes/ocean.css +5 -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 +1 -1
- 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/use-floating-panel.svelte.js +9 -3
- package/dist/utils/variants.js +5 -1
- 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={
|
|
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
|
-
/**
|
|
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
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -111,13 +111,13 @@ export const journeyTimelineVariants = tv({
|
|
|
111
111
|
},
|
|
112
112
|
size: {
|
|
113
113
|
sm: {
|
|
114
|
-
meta: 'text-
|
|
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-
|
|
120
|
-
segment: 'text-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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` below — the 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
|
|
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')
|
|
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,
|
|
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
|
|
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;
|