@urbicon-ui/blocks 6.3.7 → 6.3.9

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,6 +7,7 @@
7
7
  let {
8
8
  items,
9
9
  size = 'md',
10
+ wrap = true,
10
11
  separator,
11
12
  maxItems,
12
13
  itemsBeforeCollapse = 1,
@@ -23,7 +24,7 @@
23
24
  const blocksConfig = getBlocksConfig();
24
25
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
25
26
 
26
- const variantProps: BreadcrumbVariants = $derived({ size });
27
+ const variantProps: BreadcrumbVariants = $derived({ size, wrap });
27
28
 
28
29
  const styles = $derived(breadcrumbVariants(variantProps));
29
30
 
@@ -14,45 +14,62 @@ export declare const breadcrumbVariants: (props?: import("../../utils/variants.j
14
14
  separator: string;
15
15
  };
16
16
  };
17
+ wrap: {
18
+ true: {
19
+ list: string;
20
+ };
21
+ false: {
22
+ nav: string;
23
+ list: string;
24
+ currentPage: string;
25
+ };
26
+ };
17
27
  }> | undefined) => {
18
28
  nav: (props?: ({
19
29
  size?: "sm" | "md" | "lg" | undefined;
30
+ wrap?: boolean | undefined;
20
31
  } & {
21
32
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
22
33
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
23
34
  }) | undefined) => string;
24
35
  list: (props?: ({
25
36
  size?: "sm" | "md" | "lg" | undefined;
37
+ wrap?: boolean | undefined;
26
38
  } & {
27
39
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
28
40
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
29
41
  }) | undefined) => string;
30
42
  item: (props?: ({
31
43
  size?: "sm" | "md" | "lg" | undefined;
44
+ wrap?: boolean | undefined;
32
45
  } & {
33
46
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
34
47
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
35
48
  }) | undefined) => string;
36
49
  link: (props?: ({
37
50
  size?: "sm" | "md" | "lg" | undefined;
51
+ wrap?: boolean | undefined;
38
52
  } & {
39
53
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
40
54
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
41
55
  }) | undefined) => string;
42
56
  currentPage: (props?: ({
43
57
  size?: "sm" | "md" | "lg" | undefined;
58
+ wrap?: boolean | undefined;
44
59
  } & {
45
60
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
46
61
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
47
62
  }) | undefined) => string;
48
63
  ellipsis: (props?: ({
49
64
  size?: "sm" | "md" | "lg" | undefined;
65
+ wrap?: boolean | undefined;
50
66
  } & {
51
67
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
52
68
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
53
69
  }) | undefined) => string;
54
70
  separator: (props?: ({
55
71
  size?: "sm" | "md" | "lg" | undefined;
72
+ wrap?: boolean | undefined;
56
73
  } & {
57
74
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
58
75
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
@@ -2,7 +2,7 @@ import { tv } from '../../utils/variants.js';
2
2
  export const breadcrumbVariants = tv({
3
3
  slots: {
4
4
  nav: 'flex items-center',
5
- list: 'flex items-center flex-wrap',
5
+ list: 'flex items-center',
6
6
  item: 'inline-flex items-center',
7
7
  link: [
8
8
  'text-text-secondary hover:text-text-primary',
@@ -35,9 +35,29 @@ export const breadcrumbVariants = tv({
35
35
  list: 'text-base gap-1',
36
36
  separator: 'mx-2'
37
37
  }
38
+ },
39
+ // Overflow strategy when the trail runs out of horizontal room.
40
+ // `wrap` (default): the trail wraps to multiple lines — the standard
41
+ // free-flowing breadcrumb. `false`: the trail stays on one line and the
42
+ // current page (always the last item) truncates while the ancestor links
43
+ // hold their width — for tight single-line bars (sticky headers, toolbars)
44
+ // where the page name is the part worth shortening. Pair with `maxItems`
45
+ // to fold the middle into a "…" before the current page has to truncate.
46
+ wrap: {
47
+ true: {
48
+ list: 'flex-wrap'
49
+ },
50
+ false: {
51
+ nav: 'min-w-0',
52
+ list: 'flex-nowrap min-w-0 [&>li:not(:last-child)]:shrink-0 [&>li:last-child]:min-w-0 [&>li:last-child]:shrink',
53
+ // Let the current page consume the available width and truncate, rather
54
+ // than capping at the wrap-mode max-width.
55
+ currentPage: 'max-w-none'
56
+ }
38
57
  }
39
58
  },
40
59
  defaultVariants: {
41
- size: 'md'
60
+ size: 'md',
61
+ wrap: true
42
62
  }
43
63
  });
@@ -52,6 +52,13 @@ export interface BreadcrumbProps extends BreadcrumbVariants, Omit<HTMLAttributes
52
52
  items: BreadcrumbItem[];
53
53
  /** Size @default 'md' */
54
54
  size?: 'sm' | 'md' | 'lg';
55
+ /**
56
+ * Let the trail wrap onto multiple lines (`true`, default) or keep it on a
57
+ * single line where the current page truncates and the ancestor links hold
58
+ * their width (`false`). Use `false` for tight single-line bars such as a
59
+ * sticky header or toolbar. @default true
60
+ */
61
+ wrap?: boolean;
55
62
  /** Custom separator snippet (default: "/") */
56
63
  separator?: Snippet;
57
64
  /**
@@ -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, useFloatingListbox } from '../../utils';
10
+ import { useFormField, getTierContext, useFloatingPanel } from '../../utils';
11
11
  import type { ComboboxProps, ComboboxOption } from './index';
12
12
 
13
13
  const bt = useBlocksI18n();
@@ -263,10 +263,13 @@
263
263
  }
264
264
  });
265
265
 
266
- useFloatingListbox({
266
+ useFloatingPanel({
267
267
  reference: () => inputEl,
268
268
  floating: () => listboxEl,
269
- open: () => open
269
+ open: () => open,
270
+ // The listbox always tracks the input width exactly; the generic panel
271
+ // helper defaults to no width-sync, so the listbox pattern opts in.
272
+ syncWidth: () => true
270
273
  });
271
274
 
272
275
  const listboxId = $derived(`${id}-listbox`);
@@ -16,7 +16,12 @@ export const menuVariants = tv({
16
16
  // modify trigger stays on the subtler container border.
17
17
  content: [
18
18
  'box-border border shadow-[var(--blocks-shadow-md)] p-1',
19
- 'max-h-80 overflow-y-auto',
19
+ // 20rem (max-h-80) is the static design cap; the wrapping Popover's
20
+ // useFloatingPanel sets `--blocks-overlay-available-height` on the panel,
21
+ // which cascades to this content surface so the menu shrinks above the
22
+ // iOS keyboard and recovers when it closes. Falls back to the design cap
23
+ // when the var is unset (SSR / no JS).
24
+ 'max-h-[min(20rem,var(--blocks-overlay-available-height,100dvh))] overflow-y-auto',
20
25
  'bg-surface-elevated'
21
26
  ],
22
27
  header: ['sticky top-0 z-10 bg-surface-elevated', 'px-2 py-1 border-b border-border-hairline'],
@@ -1,14 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
3
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
- import {
5
- autoUpdate,
6
- computePosition,
7
- flip,
8
- size as floatingSize,
9
- offset,
10
- shift
11
- } from '../../utils/floating';
4
+ import { useFloatingPanel } from '../../utils';
12
5
  import { popoverVariants } from './popover.variants';
13
6
  import type { PopoverProps } from './index';
14
7
 
@@ -56,7 +49,6 @@
56
49
 
57
50
  let internalTriggerElement = $state<HTMLElement | null>(null);
58
51
  let popoverElement = $state<HTMLElement | null>(null);
59
- let cleanupFloatingUI = $state<(() => void) | undefined>();
60
52
 
61
53
  const effectiveTriggerElement = $derived(triggerElement || internalTriggerElement);
62
54
 
@@ -245,94 +237,24 @@
245
237
  });
246
238
 
247
239
  // ── Floating UI positioning + native show/hide ─────────────
248
-
249
- $effect(() => {
250
- if (!open || !effectiveTriggerElement || !popoverElement) {
251
- if (popoverElement?.matches(':popover-open')) {
252
- try {
253
- popoverElement.hidePopover();
254
- } catch (err) {
255
- // Pre-check eliminated "already hidden"; real causes are
256
- // detached elements or popover API absence. Surface to telemetry.
257
- console.warn('[Popover] hidePopover failed', err);
258
- }
259
- }
260
- return;
261
- }
262
-
263
- // Show the popover in the top layer so it has layout for positioning
264
- if (!popoverElement.matches(':popover-open')) {
265
- try {
266
- popoverElement.showPopover();
267
- } catch (err) {
268
- // Real causes: legacy browser without Popover API (Safari < 17,
269
- // Chrome < 114, Firefox < 125), element detached, or `popover`
270
- // attribute overridden by a consumer via restProps.
271
- console.warn('[Popover] showPopover failed', err);
272
- }
273
- }
274
-
275
- const updatePosition = () => {
276
- if (!effectiveTriggerElement || !popoverElement) return;
277
-
278
- return computePosition(effectiveTriggerElement, popoverElement, {
279
- placement,
280
- // `fixed` is the right strategy when the panel sits in the popover
281
- // top layer (viewport-coordinated); `absolute` matches the in-flow
282
- // mode where the panel is positioned relative to the trigger's
283
- // offset parent.
284
- strategy: usePortal ? 'fixed' : 'absolute',
285
- middleware: [
286
- offset(offsetDistance),
287
- flip(),
288
- shift({ padding: shiftPadding }),
289
- floatingSize({
290
- apply({ rects }) {
291
- if (!popoverElement) return;
292
- // `syncWidth` clamps the panel to the trigger width exactly
293
- // (Select/Combobox semantics — items longer than the field
294
- // truncate or wrap). `syncMinWidth` is the looser Menu-style
295
- // variant: panel ≥ trigger.width, but content is free to grow
296
- // it. Hard sync wins if both are set.
297
- popoverElement.style.width = syncWidth ? `${rects.reference.width}px` : '';
298
- popoverElement.style.minWidth =
299
- !syncWidth && syncMinWidth ? `${rects.reference.width}px` : '';
300
- }
301
- })
302
- ]
303
- })
304
- .then(({ x, y }) => {
305
- if (!popoverElement) return;
306
- // `autoUpdate` may fire after the trigger has detached, in which
307
- // case Floating UI returns NaN. Bail rather than writing `NaNpx`.
308
- if (!Number.isFinite(x) || !Number.isFinite(y)) return;
309
- Object.assign(popoverElement.style, {
310
- left: `${x}px`,
311
- top: `${y}px`
312
- });
313
- })
314
- .catch((err) => {
315
- // Middleware can throw synchronously (e.g. geometry read on a
316
- // detached node). Without a catch this becomes an unhandled
317
- // rejection with no Popover context.
318
- console.warn('[Popover] computePosition failed', err);
319
- });
320
- };
321
-
322
- updatePosition();
323
- cleanupFloatingUI = autoUpdate(effectiveTriggerElement, popoverElement, updatePosition);
324
-
325
- return () => {
326
- cleanupFloatingUI?.();
327
- cleanupFloatingUI = undefined;
328
- if (popoverElement?.matches(':popover-open')) {
329
- try {
330
- popoverElement.hidePopover();
331
- } catch (err) {
332
- console.warn('[Popover] hidePopover failed', err);
333
- }
334
- }
335
- };
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
336
258
  });
337
259
 
338
260
  // ── Trigger handlers ───────────────────────────────────────
@@ -390,9 +312,10 @@
390
312
  `position: absolute`, anchored to the trigger's offset parent. The
391
313
  `popover` attribute is suppressed so the browser does not promote the
392
314
  element to the top layer; Floating UI still drives `left` / `top` via
393
- the existing autoUpdate effect, and the `open`/`!open` toggle just
394
- shows / hides the element. Used by Menu / Select consumers that live
395
- inside another popover (avoids nested-top-layer focus & z-index quirks).
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).
396
319
  -->
397
320
  <div
398
321
  bind:this={popoverElement}
@@ -4,7 +4,13 @@ export const popoverVariants = tv({
4
4
  base: [
5
5
  'bg-surface-elevated border border-border-hairline rounded-contain',
6
6
  'shadow-[var(--blocks-shadow-md)] backdrop-blur-sm',
7
- 'overflow-y-auto max-h-[calc(100dvh-4rem)]'
7
+ // `calc(100dvh-4rem)` is the static design cap; Floating UI's `size`
8
+ // middleware (via useFloatingPanel) narrows it to the room actually left
9
+ // between the anchor and the visual viewport edge through
10
+ // `--blocks-overlay-available-height`, so the panel shrinks above the iOS
11
+ // keyboard and recovers when it closes. The var falls back to 100dvh when
12
+ // unset (SSR / no JS), leaving the design cap in charge.
13
+ 'overflow-y-auto max-h-[min(calc(100dvh-4rem),var(--blocks-overlay-available-height,100dvh))]'
8
14
  ],
9
15
  variants: {
10
16
  size: {
@@ -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, useFloatingListbox } from '../../utils';
3
+ import { useFormField, getTierContext, useFloatingPanel } 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
- useFloatingListbox({
231
+ useFloatingPanel({
232
232
  reference: () => triggerRef,
233
233
  floating: () => listboxRef,
234
234
  open: () => open,
@@ -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 FloatingListboxOptions, useFloatingListbox } from './use-floating-listbox.svelte.js';
14
+ export { type FloatingPanelOptions, 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 { useFloatingListbox } from './use-floating-listbox.svelte.js';
14
+ export { 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';
@@ -0,0 +1,44 @@
1
+ import { type Placement } from './floating.js';
2
+ export interface FloatingPanelOptions {
3
+ /** The anchor the panel positions against. */
4
+ reference: () => HTMLElement | null | undefined;
5
+ /** The floating panel element. */
6
+ floating: () => HTMLElement | null | undefined;
7
+ /** Whether the panel is currently open. */
8
+ open: () => boolean;
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`.
13
+ */
14
+ portal?: () => boolean;
15
+ /** Preferred placement. @default 'bottom-start' */
16
+ placement?: () => Placement;
17
+ /** Main-axis gap between anchor and panel, in px. @default 4 */
18
+ offsetDistance?: () => number;
19
+ /** Viewport padding kept by the shift middleware, in px. @default 8 */
20
+ shiftPadding?: () => number;
21
+ /** Clamp the panel width to exactly the anchor width. @default false */
22
+ syncWidth?: () => boolean;
23
+ /** Clamp the panel's *min*-width to the anchor width (it may still grow). @default false */
24
+ syncMinWidth?: () => boolean;
25
+ }
26
+ /**
27
+ * Manages native popover state + Floating UI positioning for anchored overlay
28
+ * panels (Select, Combobox, Popover, and through it Menu). Handles show/hide,
29
+ * the autoUpdate lifecycle, position computation, a keyboard-aware height cap,
30
+ * and cleanup — one positioning codepath shared across every overlay so an
31
+ * iOS/visualViewport or flip/shift fix lands everywhere at once.
32
+ *
33
+ * `popover` (top layer) keeps the panel above any `overflow` clipping from
34
+ * parent containers. `popover="manual"` (set by the caller in markup) leaves
35
+ * the caller's own dismiss handlers in control; this helper only drives
36
+ * `showPopover()`/`hidePopover()` and never reads the dismiss mode.
37
+ *
38
+ * The Floating-UI `size` middleware feeds the room actually left between the
39
+ * anchor and the (visual) viewport edge into `--blocks-overlay-available-height`.
40
+ * Variants cap height via `max-h-[min(<design-cap>,var(--…,100dvh))]`, so the
41
+ * static design cap stays in CSS and the panel only ever shrinks to fit — and
42
+ * recovers once room is restored (e.g. the iOS keyboard closes).
43
+ */
44
+ export declare function useFloatingPanel(opts: FloatingPanelOptions): void;
@@ -0,0 +1,109 @@
1
+ import { autoUpdate, computePosition, flip, size as floatingSize, offset, shift } from './floating.js';
2
+ /**
3
+ * Manages native popover state + Floating UI positioning for anchored overlay
4
+ * panels (Select, Combobox, Popover, and through it Menu). Handles show/hide,
5
+ * the autoUpdate lifecycle, position computation, a keyboard-aware height cap,
6
+ * and cleanup — one positioning codepath shared across every overlay so an
7
+ * iOS/visualViewport or flip/shift fix lands everywhere at once.
8
+ *
9
+ * `popover` (top layer) keeps the panel above any `overflow` clipping from
10
+ * parent containers. `popover="manual"` (set by the caller in markup) leaves
11
+ * the caller's own dismiss handlers in control; this helper only drives
12
+ * `showPopover()`/`hidePopover()` and never reads the dismiss mode.
13
+ *
14
+ * The Floating-UI `size` middleware feeds the room actually left between the
15
+ * anchor and the (visual) viewport edge into `--blocks-overlay-available-height`.
16
+ * Variants cap height via `max-h-[min(<design-cap>,var(--…,100dvh))]`, so the
17
+ * static design cap stays in CSS and the panel only ever shrinks to fit — and
18
+ * recovers once room is restored (e.g. the iOS keyboard closes).
19
+ */
20
+ export function useFloatingPanel(opts) {
21
+ let cleanupPosition;
22
+ $effect(() => {
23
+ const ref = opts.reference();
24
+ const floating = opts.floating();
25
+ const isOpen = opts.open();
26
+ const usePortal = opts.portal?.() ?? true;
27
+ const placement = opts.placement?.() ?? 'bottom-start';
28
+ const offsetDistance = opts.offsetDistance?.() ?? 4;
29
+ const shiftPadding = opts.shiftPadding?.() ?? 8;
30
+ const syncWidth = opts.syncWidth?.() ?? false;
31
+ const syncMinWidth = opts.syncMinWidth?.() ?? false;
32
+ // No panel element → nothing to show, hide, or position.
33
+ if (!floating)
34
+ return;
35
+ // Closed — OR open but the anchor has gone (e.g. a conditionally-rendered
36
+ // trigger unmounted while the panel was up). Either way tear down and hide
37
+ // so a portalled panel can't linger, unanchored, in the top layer. The
38
+ // hide path deliberately needs only `floating`, not `ref`.
39
+ if (!isOpen || !ref) {
40
+ cleanupPosition?.();
41
+ cleanupPosition = undefined;
42
+ // Drop the keyboard-aware height clamp so the next open re-measures from
43
+ // the full design cap instead of inheriting a stale value.
44
+ floating.style.removeProperty('--blocks-overlay-available-height');
45
+ if (usePortal && floating.matches(':popover-open')) {
46
+ try {
47
+ floating.hidePopover();
48
+ }
49
+ catch {
50
+ /* already hidden */
51
+ }
52
+ }
53
+ return;
54
+ }
55
+ if (usePortal && !floating.matches(':popover-open')) {
56
+ try {
57
+ floating.showPopover();
58
+ }
59
+ catch {
60
+ /* already shown or not supported */
61
+ }
62
+ }
63
+ cleanupPosition?.();
64
+ cleanupPosition = autoUpdate(ref, floating, () => {
65
+ computePosition(ref, floating, {
66
+ placement,
67
+ strategy: usePortal ? 'fixed' : 'absolute',
68
+ middleware: [
69
+ offset(offsetDistance),
70
+ flip(),
71
+ shift({ padding: shiftPadding }),
72
+ floatingSize({
73
+ apply({ availableHeight, rects }) {
74
+ // Cap the panel to the room actually left between the anchor and
75
+ // the (visual) viewport edge — decisive on iOS, where the
76
+ // keyboard shrinks the visualViewport. Exposed as a CSS var that
77
+ // the variant feeds into `max-h-[min(<cap>,var(--…))]`, so the
78
+ // design token stays the upper bound while this tracks the live
79
+ // available height (and recovers when room is restored).
80
+ floating.style.setProperty('--blocks-overlay-available-height', `${Math.max(0, Math.round(availableHeight))}px`);
81
+ // `syncWidth` clamps the panel to the anchor width exactly
82
+ // (Select/Combobox semantics — items wider than the field
83
+ // truncate or wrap). `syncMinWidth` is the looser Menu-style
84
+ // variant: panel ≥ anchor width, but content is free to grow it.
85
+ // Hard sync wins if both are set; otherwise width resets so a
86
+ // toggled-off sync cannot leave a stale inline width behind.
87
+ floating.style.width = syncWidth ? `${rects.reference.width}px` : '';
88
+ floating.style.minWidth =
89
+ !syncWidth && syncMinWidth ? `${rects.reference.width}px` : '';
90
+ }
91
+ })
92
+ ]
93
+ })
94
+ .then(({ x, y }) => {
95
+ if (!Number.isFinite(x) || !Number.isFinite(y))
96
+ return;
97
+ Object.assign(floating.style, {
98
+ left: `${x}px`,
99
+ top: `${y}px`
100
+ });
101
+ })
102
+ .catch(() => { });
103
+ });
104
+ return () => {
105
+ cleanupPosition?.();
106
+ cleanupPosition = undefined;
107
+ };
108
+ });
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.3.7",
3
+ "version": "6.3.9",
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.7",
95
- "@urbicon-ui/shared-types": "6.3.7",
94
+ "@urbicon-ui/i18n": "6.3.9",
95
+ "@urbicon-ui/shared-types": "6.3.9",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",
@@ -1,17 +0,0 @@
1
- export interface FloatingListboxOptions {
2
- reference: () => HTMLElement | undefined;
3
- floating: () => HTMLElement | undefined;
4
- open: () => boolean;
5
- portal?: () => boolean;
6
- syncWidth?: () => boolean;
7
- }
8
- /**
9
- * Manages native popover state + Floating UI positioning for listbox-pattern
10
- * components (Select, Combobox). Handles show/hide, autoUpdate lifecycle,
11
- * position computation, and cleanup.
12
- *
13
- * `popover="manual"` puts the listbox in the browser top layer so overflow
14
- * clipping from parent containers cannot hide it. Manual mode (not auto)
15
- * keeps the component's own dismiss handlers in control.
16
- */
17
- export declare function useFloatingListbox(opts: FloatingListboxOptions): void;
@@ -1,87 +0,0 @@
1
- import { autoUpdate, computePosition, flip, size as floatingSize, offset, shift } from './floating.js';
2
- /**
3
- * Manages native popover state + Floating UI positioning for listbox-pattern
4
- * components (Select, Combobox). Handles show/hide, autoUpdate lifecycle,
5
- * position computation, and cleanup.
6
- *
7
- * `popover="manual"` puts the listbox in the browser top layer so overflow
8
- * clipping from parent containers cannot hide it. Manual mode (not auto)
9
- * keeps the component's own dismiss handlers in control.
10
- */
11
- export function useFloatingListbox(opts) {
12
- let cleanupPosition;
13
- $effect(() => {
14
- const ref = opts.reference();
15
- const floating = opts.floating();
16
- if (!ref || !floating)
17
- return;
18
- const isOpen = opts.open();
19
- const usePortal = opts.portal?.() ?? true;
20
- const syncWidth = opts.syncWidth?.() ?? true;
21
- if (!isOpen) {
22
- cleanupPosition?.();
23
- cleanupPosition = undefined;
24
- // Drop the keyboard-aware height clamp so the next open re-measures from
25
- // the full design cap instead of inheriting a stale value.
26
- floating.style.removeProperty('--blocks-overlay-available-height');
27
- if (usePortal && floating.matches(':popover-open')) {
28
- try {
29
- floating.hidePopover();
30
- }
31
- catch {
32
- /* already hidden */
33
- }
34
- }
35
- return;
36
- }
37
- if (usePortal && !floating.matches(':popover-open')) {
38
- try {
39
- floating.showPopover();
40
- }
41
- catch {
42
- /* already shown or not supported */
43
- }
44
- }
45
- cleanupPosition?.();
46
- cleanupPosition = autoUpdate(ref, floating, () => {
47
- if (!ref || !floating)
48
- return;
49
- computePosition(ref, floating, {
50
- placement: 'bottom-start',
51
- strategy: usePortal ? 'fixed' : 'absolute',
52
- middleware: [
53
- offset(4),
54
- flip(),
55
- shift({ padding: 8 }),
56
- floatingSize({
57
- apply({ availableHeight, rects }) {
58
- // Cap the panel to the room actually left between the anchor and
59
- // the (visual) viewport edge — decisive on iOS, where the
60
- // keyboard shrinks the visualViewport. Exposed as a CSS var that
61
- // the variant feeds into `max-h-[min(15rem,var(--…))]`, so the
62
- // 15rem design token stays the upper bound while this tracks the
63
- // live available height (and recovers when room is restored).
64
- floating.style.setProperty('--blocks-overlay-available-height', `${Math.max(0, Math.round(availableHeight))}px`);
65
- if (syncWidth) {
66
- floating.style.width = `${rects.reference.width}px`;
67
- }
68
- }
69
- })
70
- ]
71
- })
72
- .then(({ x, y }) => {
73
- if (!Number.isFinite(x) || !Number.isFinite(y))
74
- return;
75
- Object.assign(floating.style, {
76
- left: `${x}px`,
77
- top: `${y}px`
78
- });
79
- })
80
- .catch(() => { });
81
- });
82
- return () => {
83
- cleanupPosition?.();
84
- cleanupPosition = undefined;
85
- };
86
- });
87
- }