@urbicon-ui/blocks 6.3.11 → 6.3.13

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.
@@ -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 (with `popover="manual"`) so `bind:this`
373
- and `aria-controls` are stable across open/close cycles. Native
374
- `[popover]:not(:popover-open)` UA-rule hides it via display:none
375
- until `showPopover()` runs in the effect above.
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="manual"
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="position: fixed; margin: 0; inset: auto; overflow-y: auto;"
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 so that the load-bearing
305
- positioning tokens (`position: fixed`, `margin: 0`, `inset: auto`) come
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
- `usePortal=false` mode renders the panel in the regular DOM flow with
312
- `position: absolute`, anchored to the trigger's offset parent. The
313
- `popover` attribute is suppressed so the browser does not promote the
314
- element to the top layer; Floating UI still drives `left` / `top` via
315
- `useFloatingPanel` (which skips show/hide when not portalled), and the
316
- `open`/`!open` toggle just shows / hides the element. Used by Menu /
317
- Select consumers that live inside another popover (avoids nested-top-layer
318
- focus & z-index quirks).
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={usePortal ? popoverMode : null}
325
- style={usePortal
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, {}, "triggerElement" | "open">;
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 (with `popover="manual"`) so `bind:this`
509
- and `aria-controls` are stable across open/close cycles. Native
510
- `[popover]:not(:popover-open)` UA-rule hides it via display:none
511
- until `showPopover()` runs in the effect above.
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={usePortal ? 'manual' : null}
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={usePortal
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
- if (!tooltipElement.matches(':popover-open')) {
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). The `popover="manual"` attribute hides it
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="manual"
246
+ popover={topLayer ? 'manual' : null}
234
247
  style="position: fixed; margin: 0; inset: auto; overflow: visible;"
235
248
  role="tooltip"
236
249
  id={tooltipId}
@@ -48,9 +48,41 @@ function getBaseCoords(ref, floating, placement) {
48
48
  }
49
49
  return { x, y };
50
50
  }
51
+ /**
52
+ * Viewport offset of a `position: fixed` element's *actual* containing block,
53
+ * measured by momentarily pinning it to (0,0) and reading where that lands.
54
+ *
55
+ * It is (0,0) when the element is genuinely viewport-fixed (the common case, so
56
+ * compensation is a no-op there). It is non-zero when a transformed / filtered /
57
+ * contained ancestor establishes the containing block — notably a modal
58
+ * `<dialog>` nested inside an off-canvas sidebar on iOS/WebKit, which (unlike
59
+ * Chromium/Firefox) does NOT re-anchor the dialog's fixed descendants to the
60
+ * viewport (Codeberg #23). Measuring rather than assuming keeps positioning
61
+ * correct on every engine without UA sniffing. The probe is synchronous (set →
62
+ * read → restore in one frame), so it never paints an intermediate (0,0) state.
63
+ */
64
+ function fixedOriginOffset(floating) {
65
+ const prevLeft = floating.style.left;
66
+ const prevTop = floating.style.top;
67
+ floating.style.left = '0px';
68
+ floating.style.top = '0px';
69
+ const origin = floating.getBoundingClientRect();
70
+ floating.style.left = prevLeft;
71
+ floating.style.top = prevTop;
72
+ return { x: origin.left, y: origin.top };
73
+ }
51
74
  function viewportToLocal(floating, x, y, strategy) {
52
- if (strategy === 'fixed')
53
- return { x, y };
75
+ if (strategy === 'fixed') {
76
+ // A top-layer popover is positioned against the viewport regardless of any
77
+ // transformed ancestor, so its coordinates are used as-is. A non-top-layer
78
+ // fixed panel (the in-dialog mode) is positioned against its containing
79
+ // block, which can be a transformed ancestor on WebKit — compensate for its
80
+ // measured viewport offset so the same coordinates land correctly there too.
81
+ if (floating.matches(':popover-open'))
82
+ return { x, y };
83
+ const origin = fixedOriginOffset(floating);
84
+ return { x: x - origin.x, y: y - origin.y };
85
+ }
54
86
  const offsetParent = floating.offsetParent;
55
87
  if (!offsetParent)
56
88
  return { x, y };
@@ -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';
@@ -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';
@@ -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;
@@ -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
- * Render in the browser top layer via the native popover API (default).
11
- * `false` keeps the panel in normal DOM flow (`position: absolute`) for
12
- * nested-overlay scenarios the caller drives visibility via `display`.
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): void;
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 usePortal = opts.portal?.() ?? true;
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
- if (usePortal && floating.matches(':popover-open')) {
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
- /* already hidden */
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 (usePortal && !floating.matches(':popover-open')) {
83
+ if (useTopLayer && !floating.matches(':popover-open')) {
56
84
  try {
57
85
  floating.showPopover();
58
86
  }
59
- catch {
60
- /* already shown or not supported */
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: usePortal ? 'fixed' : 'absolute',
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
- .catch(() => { });
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.11",
3
+ "version": "6.3.13",
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.11",
95
- "@urbicon-ui/shared-types": "6.3.11",
94
+ "@urbicon-ui/i18n": "6.3.13",
95
+ "@urbicon-ui/shared-types": "6.3.13",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",