@urbicon-ui/blocks 6.3.12 → 6.3.14

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, floatingPanelStyle } from '../../utils';
10
+ import { useFormField, getTierContext, useFloatingPanel, floatingPanelHidden } from '../../utils';
11
11
  import type { ComboboxProps, ComboboxOption } from './index';
12
12
 
13
13
  const bt = useBlocksI18n();
@@ -373,8 +373,15 @@
373
373
  open/close cycles. In top-layer mode the `popover="manual"` UA-rule
374
374
  `[popover]:not(:popover-open)` hides it until `showPopover()` runs; inside a
375
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
+ and `display` drives visibility instead, so the listbox renders in the
377
+ dialog's own top-layer subtree (Codeberg #23).
378
+
379
+ The positioning frame is applied with per-property `style:` directives, NOT
380
+ a single `style={…}` string: a dynamic `style={…}` compiles to
381
+ `setAttribute('style')`, replacing the whole attribute and wiping the
382
+ `left`/`top` Floating UI writes imperatively (the iOS `inset: auto` clobber
383
+ behind Codeberg #23). `style:` directives and Floating UI's writes are both
384
+ per-property and never overwrite one another.
378
385
  -->
379
386
  <div
380
387
  bind:this={listboxEl}
@@ -385,7 +392,11 @@
385
392
  class={unstyled
386
393
  ? (slotClasses?.listbox ?? '')
387
394
  : styles.listbox({ class: slotClasses?.listbox })}
388
- style={floatingPanelStyle(panel, open, 'overflow-y: auto; ')}
395
+ style:position={panel.strategy}
396
+ style:inset="auto"
397
+ style:margin="0"
398
+ style:overflow-y="auto"
399
+ style:display={floatingPanelHidden(panel, open) ? 'none' : null}
389
400
  >
390
401
  {#if open}
391
402
  {#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, floatingPanelStyle } from '../../utils';
4
+ import { useFloatingPanel, floatingPanelHidden } from '../../utils';
5
5
  import { popoverVariants } from './popover.variants';
6
6
  import type { PopoverProps } from './index';
7
7
 
@@ -308,17 +308,21 @@
308
308
  `{...restProps}` so a consumer-supplied `popover="manual"` or override
309
309
  of `role`/`id` cannot silently break the show/hide flow or ARIA pairing.
310
310
 
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:
314
- red"` still works; a consumer passing `style="position: absolute"`
315
- cannot accidentally break Floating UI's coordinate system.
316
- -->
317
- <!--
311
+ The consumer `style` prop is the static `style` attribute; the load-bearing
312
+ positioning frame follows as per-property `style:` directives, which the
313
+ browser applies on top of (and so win over) the consumer string. A consumer
314
+ passing `style="background: red"` still works; `style="position: absolute"`
315
+ is overridden by `style:position` and cannot break Floating UI's coordinates.
316
+ Keep the prop static — a reactive `style` recompiles to `setAttribute('style')`
317
+ and would momentarily wipe the directive + Floating UI writes.
318
+
318
319
  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:
320
+ suppressed so the browser does not promote the element to the top layer; the
321
+ `style:` directives drive `position` + `display` while Floating UI drives
322
+ `left` / `top`. Per-property `style:` directives are used (never a single
323
+ dynamic `style={…}` string) so Svelte's `setAttribute('style')` can't wipe the
324
+ imperative `left`/`top` writes — the iOS `inset: auto` clobber (Codeberg #23).
325
+ Two triggers:
322
326
  • `usePortal=false` (e.g. Menu / Select inside another popover) →
323
327
  `position: absolute`, avoiding nested-top-layer focus & z-index quirks.
324
328
  • nested in an open modal `<dialog>` → `position: fixed`, so the panel paints
@@ -331,7 +335,11 @@
331
335
  class={popoverClasses}
332
336
  {...restProps}
333
337
  popover={panel.topLayer ? popoverMode : null}
334
- style={floatingPanelStyle(panel, open, style ? `${style}; ` : '')}
338
+ style={style || null}
339
+ style:position={panel.strategy}
340
+ style:inset="auto"
341
+ style:margin="0"
342
+ style:display={floatingPanelHidden(panel, open) ? 'none' : null}
335
343
  {role}
336
344
  aria-modal={ariaModal || undefined}
337
345
  {id}
@@ -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, floatingPanelStyle } from '../../utils';
3
+ import { useFormField, getTierContext, useFloatingPanel, floatingPanelHidden } from '../../utils';
4
4
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
5
5
  import { resolveIcon } from '../../icons';
6
6
  import ChevronDownIconDefault from '../../icons/ChevronDownIcon.svelte';
@@ -509,8 +509,11 @@
509
509
  open/close cycles. Top-layer mode uses `popover="manual"` + the UA
510
510
  `[popover]:not(:popover-open)` display rule until `showPopover()` runs; the
511
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
+ `usePortal=false`) drop the popover attribute and drive visibility via
513
+ `display`. The positioning frame uses per-property `style:` directives, not
514
+ a `style={…}` string, so Svelte's `setAttribute('style')` can never wipe the
515
+ `left`/`top` Floating UI writes imperatively (iOS `inset: auto` clobber,
516
+ Codeberg #23).
514
517
 
515
518
  `tabindex={-1}` keeps the listbox programmatically focusable without
516
519
  adding it to the tab order — the ARIA Listbox / `aria-activedescendant`
@@ -530,7 +533,11 @@
530
533
  class={unstyled
531
534
  ? (slotClasses?.listbox ?? '')
532
535
  : styles.listbox({ class: slotClasses?.listbox })}
533
- style={floatingPanelStyle(panel, open, 'overflow-y: auto; ')}
536
+ style:position={panel.strategy}
537
+ style:inset="auto"
538
+ style:margin="0"
539
+ style:overflow-y="auto"
540
+ style:display={floatingPanelHidden(panel, open) ? 'none' : null}
534
541
  aria-labelledby={labelId}
535
542
  >
536
543
  {#if open}
@@ -48,9 +48,57 @@ 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
+ }
74
+ /**
75
+ * `true` when `floating` is a popover currently promoted to the browser top
76
+ * layer. Guarded: `:popover-open` predates the Popover API, so `matches()`
77
+ * throws `SyntaxError` on engines that lack it (Safari < 17) — there, no element
78
+ * can be in the top layer, so `false` is the correct answer (and never throwing
79
+ * keeps `computePosition` from aborting mid-flight, which would leave the panel
80
+ * unpositioned — Codeberg #23).
81
+ */
82
+ function isTopLayerPopover(floating) {
83
+ try {
84
+ return floating.matches(':popover-open');
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
51
90
  function viewportToLocal(floating, x, y, strategy) {
52
- if (strategy === 'fixed')
53
- return { x, y };
91
+ if (strategy === 'fixed') {
92
+ // A top-layer popover is positioned against the viewport regardless of any
93
+ // transformed ancestor, so its coordinates are used as-is. A non-top-layer
94
+ // fixed panel (the in-dialog mode) is positioned against its containing
95
+ // block, which can be a transformed ancestor on WebKit — compensate for its
96
+ // measured viewport offset so the same coordinates land correctly there too.
97
+ if (isTopLayerPopover(floating))
98
+ return { x, y };
99
+ const origin = fixedOriginOffset(floating);
100
+ return { x: x - origin.x, y: y - origin.y };
101
+ }
54
102
  const offsetParent = floating.offsetParent;
55
103
  if (!offsetParent)
56
104
  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, type FloatingPanelState, floatingPanelStyle, useFloatingPanel } from './use-floating-panel.svelte.js';
14
+ export { type FloatingPanelOptions, type FloatingPanelState, floatingPanelHidden, 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 { floatingPanelStyle, useFloatingPanel } from './use-floating-panel.svelte.js';
14
+ export { floatingPanelHidden, 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';
@@ -64,8 +64,8 @@ export interface FloatingPanelState {
64
64
  * returns `topLayer: false`) when the anchor sits inside an open modal
65
65
  * `<dialog>`, where a second top-layer element is invisible on iOS/WebKit
66
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}.
67
+ * mirror the returned `topLayer`/`strategy` in their markup via per-property
68
+ * `style:` directives and {@link floatingPanelHidden}.
69
69
  *
70
70
  * The Floating-UI `size` middleware feeds the room actually left between the
71
71
  * anchor and the (visual) viewport edge into `--blocks-overlay-available-height`.
@@ -75,15 +75,27 @@ export interface FloatingPanelState {
75
75
  */
76
76
  export declare function useFloatingPanel(opts: FloatingPanelOptions): FloatingPanelState;
77
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).
78
+ * Whether a panel driven by {@link useFloatingPanel} must be hidden via
79
+ * `display: none` while closed. Top-layer panels carry the `popover` attribute
80
+ * and lean on the UA `[popover]:not(:popover-open){display:none}` rule, so they
81
+ * report `false`; the in-place modes (in-dialog `fixed`, or the explicit
82
+ * `portal=false` inline mode) have no such rule and are hidden by the caller.
84
83
  *
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.
84
+ * Callers apply the positioning frame with `style:` DIRECTIVES, never a single
85
+ * dynamic `style={…}` string `style={…}` compiles to `setAttribute('style')`,
86
+ * which replaces the whole attribute and would wipe the `left`/`top` Floating UI
87
+ * writes imperatively (the iOS `inset: auto` clobber behind Codeberg #23).
88
+ * Per-property `style:` directives and Floating UI's `style.left/top` writes
89
+ * coexist without ever overwriting each other:
90
+ *
91
+ * ```svelte
92
+ * <div
93
+ * popover={panel.topLayer ? 'manual' : null}
94
+ * style:position={panel.strategy}
95
+ * style:inset="auto"
96
+ * style:margin="0"
97
+ * style:display={floatingPanelHidden(panel, open) ? 'none' : null}
98
+ * >
99
+ * ```
88
100
  */
89
- export declare function floatingPanelStyle(panel: FloatingPanelState, open: boolean, extra?: string): string;
101
+ export declare function floatingPanelHidden(panel: FloatingPanelState, open: boolean): boolean;
@@ -16,8 +16,8 @@ import { isAnchoredInModalDialog } from './overlay.js';
16
16
  * returns `topLayer: false`) when the anchor sits inside an open modal
17
17
  * `<dialog>`, where a second top-layer element is invisible on iOS/WebKit
18
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}.
19
+ * mirror the returned `topLayer`/`strategy` in their markup via per-property
20
+ * `style:` directives and {@link floatingPanelHidden}.
21
21
  *
22
22
  * The Floating-UI `size` middleware feeds the room actually left between the
23
23
  * anchor and the (visual) viewport edge into `--blocks-overlay-available-height`.
@@ -151,19 +151,29 @@ export function useFloatingPanel(opts) {
151
151
  };
152
152
  }
153
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).
154
+ * Whether a panel driven by {@link useFloatingPanel} must be hidden via
155
+ * `display: none` while closed. Top-layer panels carry the `popover` attribute
156
+ * and lean on the UA `[popover]:not(:popover-open){display:none}` rule, so they
157
+ * report `false`; the in-place modes (in-dialog `fixed`, or the explicit
158
+ * `portal=false` inline mode) have no such rule and are hidden by the caller.
160
159
  *
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.
160
+ * Callers apply the positioning frame with `style:` DIRECTIVES, never a single
161
+ * dynamic `style={…}` string `style={…}` compiles to `setAttribute('style')`,
162
+ * which replaces the whole attribute and would wipe the `left`/`top` Floating UI
163
+ * writes imperatively (the iOS `inset: auto` clobber behind Codeberg #23).
164
+ * Per-property `style:` directives and Floating UI's `style.left/top` writes
165
+ * coexist without ever overwriting each other:
166
+ *
167
+ * ```svelte
168
+ * <div
169
+ * popover={panel.topLayer ? 'manual' : null}
170
+ * style:position={panel.strategy}
171
+ * style:inset="auto"
172
+ * style:margin="0"
173
+ * style:display={floatingPanelHidden(panel, open) ? 'none' : null}
174
+ * >
175
+ * ```
164
176
  */
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}`;
177
+ export function floatingPanelHidden(panel, open) {
178
+ return !panel.topLayer && !open;
169
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.3.12",
3
+ "version": "6.3.14",
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.12",
95
- "@urbicon-ui/shared-types": "6.3.12",
94
+ "@urbicon-ui/i18n": "6.3.14",
95
+ "@urbicon-ui/shared-types": "6.3.14",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",