@urbicon-ui/blocks 6.3.11 → 6.3.12
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/dist/primitives/Combobox/Combobox.svelte +10 -8
- package/dist/primitives/Popover/Popover.svelte +45 -40
- package/dist/primitives/Popover/Popover.svelte.d.ts +1 -1
- package/dist/primitives/Select/Select.svelte +10 -12
- package/dist/primitives/Tooltip/Tooltip.svelte +17 -4
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/overlay.d.ts +18 -0
- package/dist/utils/overlay.js +32 -0
- package/dist/utils/use-floating-panel.svelte.d.ts +49 -4
- package/dist/utils/use-floating-panel.svelte.js +69 -9
- package/package.json +3 -3
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import CloseIconDefault from '../../icons/CloseIcon.svelte';
|
|
8
8
|
import ChevronDownIconDefault from '../../icons/ChevronDownIcon.svelte';
|
|
9
9
|
import CheckIconDefault from '../../icons/CheckIcon.svelte';
|
|
10
|
-
import { useFormField, getTierContext, useFloatingPanel } from '../../utils';
|
|
10
|
+
import { useFormField, getTierContext, useFloatingPanel, floatingPanelStyle } from '../../utils';
|
|
11
11
|
import type { ComboboxProps, ComboboxOption } from './index';
|
|
12
12
|
|
|
13
13
|
const bt = useBlocksI18n();
|
|
@@ -263,7 +263,7 @@
|
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
useFloatingPanel({
|
|
266
|
+
const panel = useFloatingPanel({
|
|
267
267
|
reference: () => inputEl,
|
|
268
268
|
floating: () => listboxEl,
|
|
269
269
|
open: () => open,
|
|
@@ -369,21 +369,23 @@
|
|
|
369
369
|
{/if}
|
|
370
370
|
|
|
371
371
|
<!--
|
|
372
|
-
Listbox stays mounted
|
|
373
|
-
|
|
374
|
-
`[popover]:not(:popover-open)`
|
|
375
|
-
|
|
372
|
+
Listbox stays mounted so `bind:this` and `aria-controls` are stable across
|
|
373
|
+
open/close cycles. In top-layer mode the `popover="manual"` UA-rule
|
|
374
|
+
`[popover]:not(:popover-open)` hides it until `showPopover()` runs; inside a
|
|
375
|
+
modal dialog (`panel.topLayer === false`) the popover attribute is dropped
|
|
376
|
+
and `floatingPanelStyle` drives visibility via `display` instead, so the
|
|
377
|
+
listbox renders in the dialog's own top-layer subtree (Codeberg #23).
|
|
376
378
|
-->
|
|
377
379
|
<div
|
|
378
380
|
bind:this={listboxEl}
|
|
379
381
|
id={listboxId}
|
|
380
382
|
role="listbox"
|
|
381
|
-
popover=
|
|
383
|
+
popover={panel.topLayer ? 'manual' : null}
|
|
382
384
|
tabindex={-1}
|
|
383
385
|
class={unstyled
|
|
384
386
|
? (slotClasses?.listbox ?? '')
|
|
385
387
|
: styles.listbox({ class: slotClasses?.listbox })}
|
|
386
|
-
style=
|
|
388
|
+
style={floatingPanelStyle(panel, open, 'overflow-y: auto; ')}
|
|
387
389
|
>
|
|
388
390
|
{#if open}
|
|
389
391
|
{#if filtered.length === 0}
|
|
@@ -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 } from '../../utils';
|
|
4
|
+
import { useFloatingPanel, floatingPanelStyle } from '../../utils';
|
|
5
5
|
import { popoverVariants } from './popover.variants';
|
|
6
6
|
import type { PopoverProps } from './index';
|
|
7
7
|
|
|
@@ -52,6 +52,30 @@
|
|
|
52
52
|
|
|
53
53
|
const effectiveTriggerElement = $derived(triggerElement || internalTriggerElement);
|
|
54
54
|
|
|
55
|
+
// ── Floating UI positioning + native show/hide ─────────────
|
|
56
|
+
//
|
|
57
|
+
// Delegated to the shared `useFloatingPanel` helper — the same positioning
|
|
58
|
+
// codepath Select/Combobox use — so the iOS/visualViewport handling and the
|
|
59
|
+
// keyboard-aware height cap (`--blocks-overlay-available-height`, consumed by
|
|
60
|
+
// popoverVariants/menuVariants) land here too instead of being a second,
|
|
61
|
+
// drifting copy. The helper drives `showPopover()`/`hidePopover()` purely from
|
|
62
|
+
// `open` and reports the effective render mode (`panel.topLayer` /
|
|
63
|
+
// `panel.strategy`), read below by `popoverMode` (so a panel that leaves the
|
|
64
|
+
// top layer inside a modal dialog switches to manual dismiss) and by the panel
|
|
65
|
+
// markup. The `popover="auto"`/`"manual"` dismiss wiring stays independent of
|
|
66
|
+
// show/hide.
|
|
67
|
+
const panel = useFloatingPanel({
|
|
68
|
+
reference: () => effectiveTriggerElement,
|
|
69
|
+
floating: () => popoverElement,
|
|
70
|
+
open: () => open,
|
|
71
|
+
portal: () => usePortal,
|
|
72
|
+
placement: () => placement,
|
|
73
|
+
offsetDistance: () => offsetDistance,
|
|
74
|
+
shiftPadding: () => shiftPadding,
|
|
75
|
+
syncWidth: () => syncWidth,
|
|
76
|
+
syncMinWidth: () => syncMinWidth
|
|
77
|
+
});
|
|
78
|
+
|
|
55
79
|
// Forward aria-expanded / aria-haspopup to the first interactive descendant
|
|
56
80
|
// of the trigger wrapper. Without this, the wrapper div itself would need
|
|
57
81
|
// those attributes — but axe flags `aria-expanded` on an element with no
|
|
@@ -109,8 +133,12 @@
|
|
|
109
133
|
// timing. Consumers with an external CLICK trigger (Menu, DatePicker)
|
|
110
134
|
// get a clean toggle; hover-driven external triggers (Calendar event
|
|
111
135
|
// popovers) are unaffected since they never relied on light-dismiss.
|
|
136
|
+
// A panel that is NOT top-layer-promoted (nested in a modal dialog → Codeberg
|
|
137
|
+
// #23) emits no native `toggle` events, so `auto` mode's browser-driven
|
|
138
|
+
// Escape / light-dismiss never fires. Force `manual` there so the explicit
|
|
139
|
+
// Escape / outside-pointerdown listeners below own the dismiss instead.
|
|
112
140
|
const popoverMode = $derived<'auto' | 'manual'>(
|
|
113
|
-
autoTrigger && closeOnEscape && closeOnClickOutside ? 'auto' : 'manual'
|
|
141
|
+
panel.topLayer && autoTrigger && closeOnEscape && closeOnClickOutside ? 'auto' : 'manual'
|
|
114
142
|
);
|
|
115
143
|
|
|
116
144
|
// ── Native popover state sync (popover="auto" mode only) ──
|
|
@@ -236,27 +264,6 @@
|
|
|
236
264
|
}
|
|
237
265
|
});
|
|
238
266
|
|
|
239
|
-
// ── Floating UI positioning + native show/hide ─────────────
|
|
240
|
-
//
|
|
241
|
-
// Delegated to the shared `useFloatingPanel` helper — the same positioning
|
|
242
|
-
// codepath Select/Combobox use — so the iOS/visualViewport handling and the
|
|
243
|
-
// keyboard-aware height cap (`--blocks-overlay-available-height`, consumed by
|
|
244
|
-
// popoverVariants/menuVariants) land here too instead of being a second,
|
|
245
|
-
// drifting copy. The helper drives `showPopover()`/`hidePopover()` purely
|
|
246
|
-
// from `open`; the `popover="auto"`/`"manual"` dismiss wiring above is
|
|
247
|
-
// independent of it.
|
|
248
|
-
useFloatingPanel({
|
|
249
|
-
reference: () => effectiveTriggerElement,
|
|
250
|
-
floating: () => popoverElement,
|
|
251
|
-
open: () => open,
|
|
252
|
-
portal: () => usePortal,
|
|
253
|
-
placement: () => placement,
|
|
254
|
-
offsetDistance: () => offsetDistance,
|
|
255
|
-
shiftPadding: () => shiftPadding,
|
|
256
|
-
syncWidth: () => syncWidth,
|
|
257
|
-
syncMinWidth: () => syncMinWidth
|
|
258
|
-
});
|
|
259
|
-
|
|
260
267
|
// ── Trigger handlers ───────────────────────────────────────
|
|
261
268
|
|
|
262
269
|
function handleTriggerPointerDown() {
|
|
@@ -301,32 +308,30 @@
|
|
|
301
308
|
`{...restProps}` so a consumer-supplied `popover="manual"` or override
|
|
302
309
|
of `role`/`id` cannot silently break the show/hide flow or ARIA pairing.
|
|
303
310
|
|
|
304
|
-
The explicit `style` prop is interpolated FIRST
|
|
305
|
-
positioning tokens (`position
|
|
306
|
-
last and win the CSS cascade. A consumer passing `style="background:
|
|
311
|
+
The explicit `style` prop is interpolated FIRST (via `floatingPanelStyle`) so
|
|
312
|
+
that the load-bearing positioning tokens (`position`, `margin: 0`, `inset:
|
|
313
|
+
auto`) come last and win the CSS cascade. A consumer passing `style="background:
|
|
307
314
|
red"` still works; a consumer passing `style="position: absolute"`
|
|
308
315
|
cannot accidentally break Floating UI's coordinate system.
|
|
309
316
|
-->
|
|
310
317
|
<!--
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
`
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
Non-top-layer render (`panel.topLayer === false`): the `popover` attribute is
|
|
319
|
+
suppressed so the browser does not promote the element to the top layer;
|
|
320
|
+
`floatingPanelStyle` drives `position` + `display` while Floating UI drives
|
|
321
|
+
`left` / `top`. Two triggers:
|
|
322
|
+
• `usePortal=false` (e.g. Menu / Select inside another popover) →
|
|
323
|
+
`position: absolute`, avoiding nested-top-layer focus & z-index quirks.
|
|
324
|
+
• nested in an open modal `<dialog>` → `position: fixed`, so the panel paints
|
|
325
|
+
in the dialog's own top-layer subtree instead of a second popover WebKit
|
|
326
|
+
drops over a modal dialog (Codeberg #23).
|
|
319
327
|
-->
|
|
328
|
+
|
|
320
329
|
<div
|
|
321
330
|
bind:this={popoverElement}
|
|
322
331
|
class={popoverClasses}
|
|
323
332
|
{...restProps}
|
|
324
|
-
popover={
|
|
325
|
-
style={
|
|
326
|
-
? `${style}; position: fixed; margin: 0; inset: auto;`
|
|
327
|
-
: open
|
|
328
|
-
? `${style}; position: absolute; margin: 0; inset: auto;`
|
|
329
|
-
: `${style}; position: absolute; margin: 0; inset: auto; display: none;`}
|
|
333
|
+
popover={panel.topLayer ? popoverMode : null}
|
|
334
|
+
style={floatingPanelStyle(panel, open, style ? `${style}; ` : '')}
|
|
330
335
|
{role}
|
|
331
336
|
aria-modal={ariaModal || undefined}
|
|
332
337
|
{id}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { PopoverProps } from './index.js';
|
|
2
|
-
declare const Popover: import("svelte").Component<PopoverProps, {}, "
|
|
2
|
+
declare const Popover: import("svelte").Component<PopoverProps, {}, "open" | "triggerElement">;
|
|
3
3
|
type Popover = ReturnType<typeof Popover>;
|
|
4
4
|
export default Popover;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends string | number | boolean = string">
|
|
2
2
|
import { useBlocksI18n, mintRegistry } from '../..';
|
|
3
|
-
import { useFormField, getTierContext, useFloatingPanel } from '../../utils';
|
|
3
|
+
import { useFormField, getTierContext, useFloatingPanel, floatingPanelStyle } from '../../utils';
|
|
4
4
|
import { getBlocksConfig, resolveSlotClasses } from '../../provider';
|
|
5
5
|
import { resolveIcon } from '../../icons';
|
|
6
6
|
import ChevronDownIconDefault from '../../icons/ChevronDownIcon.svelte';
|
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
activeIndex = next;
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
useFloatingPanel({
|
|
231
|
+
const panel = useFloatingPanel({
|
|
232
232
|
reference: () => triggerRef,
|
|
233
233
|
floating: () => listboxRef,
|
|
234
234
|
open: () => open,
|
|
@@ -505,10 +505,12 @@
|
|
|
505
505
|
{/if}
|
|
506
506
|
|
|
507
507
|
<!--
|
|
508
|
-
Listbox stays mounted
|
|
509
|
-
|
|
510
|
-
`[popover]:not(:popover-open)`
|
|
511
|
-
|
|
508
|
+
Listbox stays mounted so `bind:this` and `aria-controls` are stable across
|
|
509
|
+
open/close cycles. Top-layer mode uses `popover="manual"` + the UA
|
|
510
|
+
`[popover]:not(:popover-open)` display rule until `showPopover()` runs; the
|
|
511
|
+
in-place modes (nested in a modal dialog → Codeberg #23, or the explicit
|
|
512
|
+
`usePortal=false`) drop the popover attribute and let `floatingPanelStyle`
|
|
513
|
+
drive visibility via `display`.
|
|
512
514
|
|
|
513
515
|
`tabindex={-1}` keeps the listbox programmatically focusable without
|
|
514
516
|
adding it to the tab order — the ARIA Listbox / `aria-activedescendant`
|
|
@@ -522,17 +524,13 @@
|
|
|
522
524
|
bind:this={listboxRef}
|
|
523
525
|
id={listboxId}
|
|
524
526
|
role="listbox"
|
|
525
|
-
popover={
|
|
527
|
+
popover={panel.topLayer ? 'manual' : null}
|
|
526
528
|
aria-multiselectable={multiple || undefined}
|
|
527
529
|
tabindex={-1}
|
|
528
530
|
class={unstyled
|
|
529
531
|
? (slotClasses?.listbox ?? '')
|
|
530
532
|
: styles.listbox({ class: slotClasses?.listbox })}
|
|
531
|
-
style={
|
|
532
|
-
? 'position: fixed; margin: 0; inset: auto; overflow-y: auto;'
|
|
533
|
-
: open
|
|
534
|
-
? 'position: absolute; overflow-y: auto;'
|
|
535
|
-
: 'display: none; position: absolute; overflow-y: auto;'}
|
|
533
|
+
style={floatingPanelStyle(panel, open, 'overflow-y: auto; ')}
|
|
536
534
|
aria-labelledby={labelId}
|
|
537
535
|
>
|
|
538
536
|
{#if open}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
offset,
|
|
12
12
|
arrow as floatingArrow
|
|
13
13
|
} from '../../utils/floating';
|
|
14
|
+
import { isAnchoredInModalDialog } from '../../utils/overlay';
|
|
14
15
|
|
|
15
16
|
let {
|
|
16
17
|
children,
|
|
@@ -48,6 +49,14 @@
|
|
|
48
49
|
resolveSlotClasses(blocksConfig, 'Tooltip', preset, variantProps, slotClassesProp)
|
|
49
50
|
);
|
|
50
51
|
|
|
52
|
+
// Inside an open modal <dialog>, a popover shown via showPopover() forms a
|
|
53
|
+
// second top-layer element WebKit won't render above the dialog (Codeberg
|
|
54
|
+
// #23). Evaluated while `visible` so the check runs once the dialog is
|
|
55
|
+
// already modal; the tooltip then renders in place (position:fixed + the
|
|
56
|
+
// opacity `visible` variant + pointer-events-none) within the dialog's own
|
|
57
|
+
// top-layer subtree, no showPopover() needed.
|
|
58
|
+
const topLayer = $derived(visible ? !isAnchoredInModalDialog(triggerElement) : true);
|
|
59
|
+
|
|
51
60
|
let showTimeout: number;
|
|
52
61
|
let hideTimeout: number;
|
|
53
62
|
let cleanup: (() => void) | undefined = undefined;
|
|
@@ -174,7 +183,9 @@
|
|
|
174
183
|
return;
|
|
175
184
|
}
|
|
176
185
|
|
|
177
|
-
|
|
186
|
+
// Skip top-layer promotion inside a modal dialog (see `topLayer`): the
|
|
187
|
+
// tooltip stays a dialog descendant and shows via the opacity variant.
|
|
188
|
+
if (topLayer && !tooltipElement.matches(':popover-open')) {
|
|
178
189
|
try {
|
|
179
190
|
tooltipElement.showPopover();
|
|
180
191
|
} catch (err) {
|
|
@@ -216,8 +227,10 @@
|
|
|
216
227
|
Tooltip element stays mounted so `bind:this={tooltipElement}` is stable
|
|
217
228
|
across hover cycles and Floating UI's `arrow` middleware always has a
|
|
218
229
|
target (otherwise the first showPopover would compute positions before
|
|
219
|
-
the arrow node existed).
|
|
220
|
-
via the UA stylesheet until `showPopover()` is called
|
|
230
|
+
the arrow node existed). In top-layer mode the `popover="manual"` attribute
|
|
231
|
+
hides it via the UA stylesheet until `showPopover()` is called; inside a
|
|
232
|
+
modal dialog (`topLayer === false`) the attribute is dropped and the
|
|
233
|
+
`visible` opacity variant drives visibility instead (Codeberg #23).
|
|
221
234
|
|
|
222
235
|
Load-bearing attributes (`popover`, `style`, `role`, `id`) intentionally
|
|
223
236
|
follow `{...restProps}` so a consumer-supplied `popover="auto"`, custom
|
|
@@ -230,7 +243,7 @@
|
|
|
230
243
|
? [slotClasses?.base, className].filter(Boolean).join(' ')
|
|
231
244
|
: styles.base({ class: [slotClasses?.base, className] })}
|
|
232
245
|
{...restProps}
|
|
233
|
-
popover=
|
|
246
|
+
popover={topLayer ? 'manual' : null}
|
|
234
247
|
style="position: fixed; margin: 0; inset: auto; overflow: visible;"
|
|
235
248
|
role="tooltip"
|
|
236
249
|
id={tooltipId}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -11,6 +11,6 @@ export { type EasingFn, getOverlayMotion, OVERLAY_MOTION_DEFAULTS, type OverlayM
|
|
|
11
11
|
export * from './persistent-state.svelte.js';
|
|
12
12
|
export { getTierContext, type InteractiveTier, setTierContext, type TierContext } from './tier-context.js';
|
|
13
13
|
export * from './types.js';
|
|
14
|
-
export { type FloatingPanelOptions, useFloatingPanel } from './use-floating-panel.svelte.js';
|
|
14
|
+
export { type FloatingPanelOptions, type FloatingPanelState, floatingPanelStyle, useFloatingPanel } from './use-floating-panel.svelte.js';
|
|
15
15
|
export { computeFormFieldAria, type UseFormFieldInputs, type UseFormFieldReturn, useFormField } from './use-form-field.svelte.js';
|
|
16
16
|
export { cx, type TVProps, tv, type VariantProps } from './variants.js';
|
package/dist/utils/index.js
CHANGED
|
@@ -11,6 +11,6 @@ export { getOverlayMotion, OVERLAY_MOTION_DEFAULTS } from './overlay-tokens.js';
|
|
|
11
11
|
export * from './persistent-state.svelte.js';
|
|
12
12
|
export { getTierContext, setTierContext } from './tier-context.js';
|
|
13
13
|
export * from './types.js';
|
|
14
|
-
export { useFloatingPanel } from './use-floating-panel.svelte.js';
|
|
14
|
+
export { floatingPanelStyle, useFloatingPanel } from './use-floating-panel.svelte.js';
|
|
15
15
|
export { computeFormFieldAria, useFormField } from './use-form-field.svelte.js';
|
|
16
16
|
export { cx, tv } from './variants.js';
|
package/dist/utils/overlay.d.ts
CHANGED
|
@@ -6,3 +6,21 @@ export declare function unlockBodyScroll(): void;
|
|
|
6
6
|
export declare function focusFirstElement(container: HTMLElement | undefined): void;
|
|
7
7
|
export declare function showDialogModal(dialogEl: HTMLDialogElement | undefined, panelEl: HTMLElement | undefined): void;
|
|
8
8
|
export declare function closeDialogModal(dialogEl: HTMLDialogElement | undefined, previouslyFocused: HTMLElement | null): void;
|
|
9
|
+
/**
|
|
10
|
+
* True when `el` is a descendant of an OPEN *modal* `<dialog>` — one opened via
|
|
11
|
+
* `showModal()`, which matches `:modal` and occupies the browser top layer.
|
|
12
|
+
*
|
|
13
|
+
* A popover shown via `showPopover()` from inside such a dialog forms a *second*
|
|
14
|
+
* top-layer element, which WebKit/iOS fails to render above the dialog (Codeberg
|
|
15
|
+
* #23 — the documented "top layer: popover vs. dialog" conflict; Chromium
|
|
16
|
+
* tolerates it). Anchored overlays use this to skip top-layer promotion when
|
|
17
|
+
* nested in a modal dialog and render inside the dialog's own subtree instead.
|
|
18
|
+
*
|
|
19
|
+
* DOM-based on purpose: it transparently covers `Drawer` (also `showModal()`)
|
|
20
|
+
* and even consumer-authored `<dialog>` wrappers, and distinguishes modal from
|
|
21
|
+
* non-modal (`show()`) dialogs — neither of which a context/registry would catch.
|
|
22
|
+
*
|
|
23
|
+
* Null-safe so it can be called during SSR, before `bind:this` resolves, or
|
|
24
|
+
* while an anchor is mid-teardown.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isAnchoredInModalDialog(el: HTMLElement | null | undefined): boolean;
|
package/dist/utils/overlay.js
CHANGED
|
@@ -81,3 +81,35 @@ export function closeDialogModal(dialogEl, previouslyFocused) {
|
|
|
81
81
|
unlockBodyScroll();
|
|
82
82
|
previouslyFocused?.focus();
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* True when `el` is a descendant of an OPEN *modal* `<dialog>` — one opened via
|
|
86
|
+
* `showModal()`, which matches `:modal` and occupies the browser top layer.
|
|
87
|
+
*
|
|
88
|
+
* A popover shown via `showPopover()` from inside such a dialog forms a *second*
|
|
89
|
+
* top-layer element, which WebKit/iOS fails to render above the dialog (Codeberg
|
|
90
|
+
* #23 — the documented "top layer: popover vs. dialog" conflict; Chromium
|
|
91
|
+
* tolerates it). Anchored overlays use this to skip top-layer promotion when
|
|
92
|
+
* nested in a modal dialog and render inside the dialog's own subtree instead.
|
|
93
|
+
*
|
|
94
|
+
* DOM-based on purpose: it transparently covers `Drawer` (also `showModal()`)
|
|
95
|
+
* and even consumer-authored `<dialog>` wrappers, and distinguishes modal from
|
|
96
|
+
* non-modal (`show()`) dialogs — neither of which a context/registry would catch.
|
|
97
|
+
*
|
|
98
|
+
* Null-safe so it can be called during SSR, before `bind:this` resolves, or
|
|
99
|
+
* while an anchor is mid-teardown.
|
|
100
|
+
*/
|
|
101
|
+
export function isAnchoredInModalDialog(el) {
|
|
102
|
+
if (!isBrowser || !el)
|
|
103
|
+
return false;
|
|
104
|
+
const dialog = el.closest('dialog');
|
|
105
|
+
if (!dialog)
|
|
106
|
+
return false;
|
|
107
|
+
try {
|
|
108
|
+
return dialog.matches(':modal');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// `:modal` predates the Popover API in every engine, so an engine that
|
|
112
|
+
// throws on the selector cannot exhibit the top-layer conflict anyway.
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -7,9 +7,17 @@ export interface FloatingPanelOptions {
|
|
|
7
7
|
/** Whether the panel is currently open. */
|
|
8
8
|
open: () => boolean;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* `false`
|
|
12
|
-
* nested-overlay scenarios
|
|
10
|
+
* Caller hint for top-layer promotion via the native popover API (default
|
|
11
|
+
* `true`). `false` forces the panel into normal DOM flow (`position:
|
|
12
|
+
* absolute`) for nested-overlay scenarios (e.g. a Menu/Select inside a
|
|
13
|
+
* Popover) — the caller drives visibility via `display`.
|
|
14
|
+
*
|
|
15
|
+
* Even when `true`, the panel is automatically kept OUT of the top layer
|
|
16
|
+
* while it sits inside an open modal `<dialog>` — it then renders
|
|
17
|
+
* `position: fixed` within the dialog's own subtree, because a second
|
|
18
|
+
* top-layer element over a modal dialog is invisible on iOS/WebKit
|
|
19
|
+
* (Codeberg #23). Read the effective state from the returned
|
|
20
|
+
* `topLayer`/`strategy`.
|
|
13
21
|
*/
|
|
14
22
|
portal?: () => boolean;
|
|
15
23
|
/** Preferred placement. @default 'bottom-start' */
|
|
@@ -23,6 +31,23 @@ export interface FloatingPanelOptions {
|
|
|
23
31
|
/** Clamp the panel's *min*-width to the anchor width (it may still grow). @default false */
|
|
24
32
|
syncMinWidth?: () => boolean;
|
|
25
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Effective render mode of a floating panel, returned by {@link useFloatingPanel}
|
|
36
|
+
* so the calling component's markup stays in lockstep with the positioning
|
|
37
|
+
* effect — both read the same derived values, so the `popover` attribute and the
|
|
38
|
+
* `showPopover()` decision can never diverge.
|
|
39
|
+
*/
|
|
40
|
+
export interface FloatingPanelState {
|
|
41
|
+
/**
|
|
42
|
+
* `true` when the panel is promoted to the browser top layer (the markup sets
|
|
43
|
+
* the `popover` attribute and the effect calls `showPopover()`); `false` when
|
|
44
|
+
* it renders in place — inside a modal `<dialog>`, or the explicit
|
|
45
|
+
* `portal=false` inline mode.
|
|
46
|
+
*/
|
|
47
|
+
readonly topLayer: boolean;
|
|
48
|
+
/** CSS positioning strategy the panel element must match (`position: <strategy>`). */
|
|
49
|
+
readonly strategy: 'fixed' | 'absolute';
|
|
50
|
+
}
|
|
26
51
|
/**
|
|
27
52
|
* Manages native popover state + Floating UI positioning for anchored overlay
|
|
28
53
|
* panels (Select, Combobox, Popover, and through it Menu). Handles show/hide,
|
|
@@ -35,10 +60,30 @@ export interface FloatingPanelOptions {
|
|
|
35
60
|
* the caller's own dismiss handlers in control; this helper only drives
|
|
36
61
|
* `showPopover()`/`hidePopover()` and never reads the dismiss mode.
|
|
37
62
|
*
|
|
63
|
+
* Top-layer promotion is automatic but conditional: the helper skips it (and
|
|
64
|
+
* returns `topLayer: false`) when the anchor sits inside an open modal
|
|
65
|
+
* `<dialog>`, where a second top-layer element is invisible on iOS/WebKit
|
|
66
|
+
* (Codeberg #23) — the panel then renders `position: fixed` in place. Callers
|
|
67
|
+
* mirror the returned `topLayer`/`strategy` in their markup via
|
|
68
|
+
* {@link floatingPanelStyle}.
|
|
69
|
+
*
|
|
38
70
|
* The Floating-UI `size` middleware feeds the room actually left between the
|
|
39
71
|
* anchor and the (visual) viewport edge into `--blocks-overlay-available-height`.
|
|
40
72
|
* Variants cap height via `max-h-[min(<design-cap>,var(--…,100dvh))]`, so the
|
|
41
73
|
* static design cap stays in CSS and the panel only ever shrinks to fit — and
|
|
42
74
|
* recovers once room is restored (e.g. the iOS keyboard closes).
|
|
43
75
|
*/
|
|
44
|
-
export declare function useFloatingPanel(opts: FloatingPanelOptions):
|
|
76
|
+
export declare function useFloatingPanel(opts: FloatingPanelOptions): FloatingPanelState;
|
|
77
|
+
/**
|
|
78
|
+
* Inline style for a panel driven by {@link useFloatingPanel}. Pins the
|
|
79
|
+
* positioning coordinate system Floating UI expects — `position` matches the
|
|
80
|
+
* strategy, `margin:0; inset:auto` neutralise the UA popover centering — and,
|
|
81
|
+
* when the panel is NOT top-layer-promoted, drives visibility from `open` (the
|
|
82
|
+
* UA `[popover]:not(:popover-open){display:none}` rule only applies while the
|
|
83
|
+
* `popover` attribute is present, which the in-place modes omit).
|
|
84
|
+
*
|
|
85
|
+
* `extra` is prepended so a caller's own declarations (e.g. a consumer `style`
|
|
86
|
+
* prop, or `overflow-y:auto`) stay overridable by the load-bearing positioning
|
|
87
|
+
* tokens that follow.
|
|
88
|
+
*/
|
|
89
|
+
export declare function floatingPanelStyle(panel: FloatingPanelState, open: boolean, extra?: string): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoUpdate, computePosition, flip, size as floatingSize, offset, shift } from './floating.js';
|
|
2
|
+
import { isAnchoredInModalDialog } from './overlay.js';
|
|
2
3
|
/**
|
|
3
4
|
* Manages native popover state + Floating UI positioning for anchored overlay
|
|
4
5
|
* panels (Select, Combobox, Popover, and through it Menu). Handles show/hide,
|
|
@@ -11,6 +12,13 @@ import { autoUpdate, computePosition, flip, size as floatingSize, offset, shift
|
|
|
11
12
|
* the caller's own dismiss handlers in control; this helper only drives
|
|
12
13
|
* `showPopover()`/`hidePopover()` and never reads the dismiss mode.
|
|
13
14
|
*
|
|
15
|
+
* Top-layer promotion is automatic but conditional: the helper skips it (and
|
|
16
|
+
* returns `topLayer: false`) when the anchor sits inside an open modal
|
|
17
|
+
* `<dialog>`, where a second top-layer element is invisible on iOS/WebKit
|
|
18
|
+
* (Codeberg #23) — the panel then renders `position: fixed` in place. Callers
|
|
19
|
+
* mirror the returned `topLayer`/`strategy` in their markup via
|
|
20
|
+
* {@link floatingPanelStyle}.
|
|
21
|
+
*
|
|
14
22
|
* The Floating-UI `size` middleware feeds the room actually left between the
|
|
15
23
|
* anchor and the (visual) viewport edge into `--blocks-overlay-available-height`.
|
|
16
24
|
* Variants cap height via `max-h-[min(<design-cap>,var(--…,100dvh))]`, so the
|
|
@@ -19,11 +27,24 @@ import { autoUpdate, computePosition, flip, size as floatingSize, offset, shift
|
|
|
19
27
|
*/
|
|
20
28
|
export function useFloatingPanel(opts) {
|
|
21
29
|
let cleanupPosition;
|
|
30
|
+
// Effective render mode — derived synchronously, never via an $effect-set
|
|
31
|
+
// $state. A lagged mode would leave the element carrying a stale
|
|
32
|
+
// `popover="manual"` for one frame and re-trigger the exact WebKit
|
|
33
|
+
// double-top-layer bug this guards against (Codeberg #23).
|
|
34
|
+
const inModalDialog = $derived(opts.open() ? isAnchoredInModalDialog(opts.reference()) : false);
|
|
35
|
+
const portalHint = $derived(opts.portal?.() ?? true);
|
|
36
|
+
// Top layer (native popover) only when the caller opted in AND the anchor is
|
|
37
|
+
// not nested in a modal dialog. `strategy` stays `fixed` for the dialog case
|
|
38
|
+
// so the panel escapes the dialog body's overflow clipping; only the explicit
|
|
39
|
+
// inline mode (`portal=false`) falls back to `absolute`.
|
|
40
|
+
const topLayer = $derived(portalHint && !inModalDialog);
|
|
41
|
+
const strategy = $derived(portalHint ? 'fixed' : 'absolute');
|
|
22
42
|
$effect(() => {
|
|
23
43
|
const ref = opts.reference();
|
|
24
44
|
const floating = opts.floating();
|
|
25
45
|
const isOpen = opts.open();
|
|
26
|
-
const
|
|
46
|
+
const useTopLayer = topLayer;
|
|
47
|
+
const positionStrategy = strategy;
|
|
27
48
|
const placement = opts.placement?.() ?? 'bottom-start';
|
|
28
49
|
const offsetDistance = opts.offsetDistance?.() ?? 4;
|
|
29
50
|
const shiftPadding = opts.shiftPadding?.() ?? 8;
|
|
@@ -42,29 +63,41 @@ export function useFloatingPanel(opts) {
|
|
|
42
63
|
// Drop the keyboard-aware height clamp so the next open re-measures from
|
|
43
64
|
// the full design cap instead of inheriting a stale value.
|
|
44
65
|
floating.style.removeProperty('--blocks-overlay-available-height');
|
|
45
|
-
|
|
66
|
+
// Guard on the live `:popover-open` state only (not the current mode):
|
|
67
|
+
// a panel that was promoted to the top layer must still be torn down even
|
|
68
|
+
// if its mode has since flipped to `fixed`/`inline`.
|
|
69
|
+
if (floating.matches(':popover-open')) {
|
|
46
70
|
try {
|
|
47
71
|
floating.hidePopover();
|
|
48
72
|
}
|
|
49
|
-
catch {
|
|
50
|
-
|
|
73
|
+
catch (err) {
|
|
74
|
+
// Pre-check ruled out "already hidden": a real failure here is a
|
|
75
|
+
// detached node or a consumer-overridden popover attribute. Surface
|
|
76
|
+
// it (as Tooltip/Guide do) instead of leaving the panel stuck in the
|
|
77
|
+
// top layer with no diagnostics.
|
|
78
|
+
console.warn('[useFloatingPanel] hidePopover failed', err);
|
|
51
79
|
}
|
|
52
80
|
}
|
|
53
81
|
return;
|
|
54
82
|
}
|
|
55
|
-
if (
|
|
83
|
+
if (useTopLayer && !floating.matches(':popover-open')) {
|
|
56
84
|
try {
|
|
57
85
|
floating.showPopover();
|
|
58
86
|
}
|
|
59
|
-
catch {
|
|
60
|
-
|
|
87
|
+
catch (err) {
|
|
88
|
+
// Pre-check ruled out "already shown": a real failure here (missing
|
|
89
|
+
// Popover API on a legacy browser, a detached node, or a consumer-
|
|
90
|
+
// overridden popover attribute) would leave the panel stuck
|
|
91
|
+
// `display:none` via the UA rule with no diagnostics — exactly the
|
|
92
|
+
// silent "overlay never opens" class of bug. Surface it for telemetry.
|
|
93
|
+
console.warn('[useFloatingPanel] showPopover failed', err);
|
|
61
94
|
}
|
|
62
95
|
}
|
|
63
96
|
cleanupPosition?.();
|
|
64
97
|
cleanupPosition = autoUpdate(ref, floating, () => {
|
|
65
98
|
computePosition(ref, floating, {
|
|
66
99
|
placement,
|
|
67
|
-
strategy:
|
|
100
|
+
strategy: positionStrategy,
|
|
68
101
|
middleware: [
|
|
69
102
|
offset(offsetDistance),
|
|
70
103
|
flip(),
|
|
@@ -99,11 +132,38 @@ export function useFloatingPanel(opts) {
|
|
|
99
132
|
top: `${y}px`
|
|
100
133
|
});
|
|
101
134
|
})
|
|
102
|
-
|
|
135
|
+
// Mirrors Tooltip/Guide: middleware can throw synchronously on a
|
|
136
|
+
// detached node; surface it instead of swallowing it silently.
|
|
137
|
+
.catch((err) => console.warn('[useFloatingPanel] computePosition failed', err));
|
|
103
138
|
});
|
|
104
139
|
return () => {
|
|
105
140
|
cleanupPosition?.();
|
|
106
141
|
cleanupPosition = undefined;
|
|
107
142
|
};
|
|
108
143
|
});
|
|
144
|
+
return {
|
|
145
|
+
get topLayer() {
|
|
146
|
+
return topLayer;
|
|
147
|
+
},
|
|
148
|
+
get strategy() {
|
|
149
|
+
return strategy;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Inline style for a panel driven by {@link useFloatingPanel}. Pins the
|
|
155
|
+
* positioning coordinate system Floating UI expects — `position` matches the
|
|
156
|
+
* strategy, `margin:0; inset:auto` neutralise the UA popover centering — and,
|
|
157
|
+
* when the panel is NOT top-layer-promoted, drives visibility from `open` (the
|
|
158
|
+
* UA `[popover]:not(:popover-open){display:none}` rule only applies while the
|
|
159
|
+
* `popover` attribute is present, which the in-place modes omit).
|
|
160
|
+
*
|
|
161
|
+
* `extra` is prepended so a caller's own declarations (e.g. a consumer `style`
|
|
162
|
+
* prop, or `overflow-y:auto`) stay overridable by the load-bearing positioning
|
|
163
|
+
* tokens that follow.
|
|
164
|
+
*/
|
|
165
|
+
export function floatingPanelStyle(panel, open, extra = '') {
|
|
166
|
+
const positioning = `position: ${panel.strategy}; margin: 0; inset: auto;`;
|
|
167
|
+
const hidden = !panel.topLayer && !open ? ' display: none;' : '';
|
|
168
|
+
return `${extra}${positioning}${hidden}`;
|
|
109
169
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/blocks",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.12",
|
|
4
4
|
"description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -91,8 +91,8 @@
|
|
|
91
91
|
"@sveltejs/package": "^2.5.8",
|
|
92
92
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
93
93
|
"@tailwindcss/vite": "^4.3.1",
|
|
94
|
-
"@urbicon-ui/i18n": "6.3.
|
|
95
|
-
"@urbicon-ui/shared-types": "6.3.
|
|
94
|
+
"@urbicon-ui/i18n": "6.3.12",
|
|
95
|
+
"@urbicon-ui/shared-types": "6.3.12",
|
|
96
96
|
"prettier": "^3.8.4",
|
|
97
97
|
"prettier-plugin-svelte": "^4.1.1",
|
|
98
98
|
"prettier-plugin-tailwindcss": "^0.8.0",
|