@urbicon-ui/blocks 6.3.3 → 6.3.7

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.
@@ -150,6 +150,15 @@ export declare const comboboxVariants: (props?: import("../../utils/variants.js"
150
150
  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;
151
151
  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;
152
152
  }) | undefined) => string;
153
+ chevronButton: (props?: ({
154
+ tier?: "commit" | "modify" | undefined;
155
+ size?: "sm" | "md" | "lg" | undefined;
156
+ open?: boolean | undefined;
157
+ disabled?: boolean | undefined;
158
+ } & {
159
+ 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;
160
+ 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;
161
+ }) | undefined) => string;
153
162
  chevron: (props?: ({
154
163
  tier?: "commit" | "modify" | undefined;
155
164
  size?: "sm" | "md" | "lg" | undefined;
@@ -22,12 +22,16 @@ export const comboboxVariants = tv({
22
22
  // `position`, `width`, `overflow-y` are set inline in Combobox.svelte
23
23
  // so the native `popover="manual"` top-layer rendering works
24
24
  // correctly and the UA's `overflow: auto` doesn't render a duplicate
25
- // scrollbar. Width is sized by Floating UI's `size` middleware.
25
+ // scrollbar. Width AND a keyboard-aware max-height come from Floating
26
+ // UI's `size` middleware: it sets `--blocks-overlay-available-height`
27
+ // to the room left in the visual viewport (e.g. above the iOS keyboard);
28
+ // the `max-h-[min(15rem,…)]` below keeps 15rem as the upper design cap
29
+ // via `min()` and falls back to it whenever the var is unset.
26
30
  // tier: contain — floating dropdown panel.
27
31
  listbox: [
28
32
  'rounded-contain border',
29
33
  'bg-surface-elevated border-border-subtle shadow-[var(--blocks-shadow-md)]',
30
- 'max-h-60 p-1 space-y-0.5'
34
+ 'max-h-[min(15rem,var(--blocks-overlay-available-height,100dvh))] p-1 space-y-0.5'
31
35
  ],
32
36
  option: [
33
37
  'flex w-full items-center gap-2 rounded-modify px-3 py-2 text-left',
@@ -44,11 +48,20 @@ export const comboboxVariants = tv({
44
48
  'transition-colors duration-[var(--blocks-duration-fast)]',
45
49
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50'
46
50
  ],
47
- chevron: [
48
- 'absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none',
49
- 'text-text-tertiary w-4 h-4',
50
- 'transition-transform duration-[var(--blocks-duration-fast)]'
51
- ]
51
+ // Chevron toggle button. The input opens on focus and can't "toggle
52
+ // closed" (re-clicking a focused field is a no-op), so the chevron is the
53
+ // discoverable open/close affordance. `tabindex={-1}` keeps it out of the
54
+ // tab order — the input is the single combobox tab stop — and an
55
+ // `onmousedown` preventDefault keeps focus on the input when it's clicked.
56
+ chevronButton: [
57
+ 'absolute right-1.5 top-1/2 -translate-y-1/2',
58
+ 'inline-flex items-center justify-center rounded-modify p-1',
59
+ 'text-text-tertiary hover:text-text-primary cursor-pointer',
60
+ 'transition-colors duration-[var(--blocks-duration-fast)]',
61
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
62
+ 'disabled:cursor-not-allowed disabled:hover:text-text-tertiary'
63
+ ],
64
+ chevron: ['w-4 h-4', 'transition-transform duration-[var(--blocks-duration-fast)]']
52
65
  },
53
66
  variants: {
54
67
  // 3-tier semantic radius for the Combobox input. Default `modify`.
@@ -59,7 +72,9 @@ export const comboboxVariants = tv({
59
72
  },
60
73
  size: {
61
74
  sm: {
62
- input: 'h-8 px-3 pr-8 text-sm',
75
+ // `pointer-coarse:text-base` floors the input to 16px on touch-primary
76
+ // devices so iOS Safari doesn't auto-zoom (and never un-zoom) on focus.
77
+ input: 'h-8 px-3 pr-8 text-sm pointer-coarse:text-base',
63
78
  option: 'px-2 py-1.5 text-sm min-h-[2rem]'
64
79
  },
65
80
  md: {
@@ -54,13 +54,17 @@ export const inputVariants = tv({
54
54
  },
55
55
  size: {
56
56
  xs: {
57
- base: 'h-7 px-2 text-xs',
57
+ // `pointer-coarse:text-base` floors the font to 16px on touch-primary
58
+ // devices (iPhone/iPad) — below 16px iOS Safari auto-zooms the field on
59
+ // focus and never restores the zoom. Desktop keeps the designed 12px.
60
+ base: 'h-7 px-2 text-xs pointer-coarse:text-base',
58
61
  iconContainer: 'w-7',
59
62
  iconButton: 'p-0.5 [&_svg]:w-3 [&_svg]:h-3',
60
63
  iconDecoration: '[&_svg]:w-3 [&_svg]:h-3'
61
64
  },
62
65
  sm: {
63
- base: 'h-8 px-3 text-sm',
66
+ // See `xs` — floor to 16px on touch to avoid iOS Safari focus-zoom.
67
+ base: 'h-8 px-3 text-sm pointer-coarse:text-base',
64
68
  iconContainer: 'w-8',
65
69
  iconButton: 'p-0.5 [&_svg]:w-3.5 [&_svg]:h-3.5',
66
70
  iconDecoration: '[&_svg]:w-3.5 [&_svg]:h-3.5'
@@ -62,6 +62,10 @@
62
62
  const tierCtx = getTierContext();
63
63
  const effectiveTier = $derived(tier ?? tierCtx?.tier ?? 'commit');
64
64
 
65
+ // The element that had focus when the menu opened (normally the trigger
66
+ // button) — captured in the open effect below and refocused by `dismiss()`
67
+ // on Escape / selection. Not bound to Popover's `triggerElement`; Popover
68
+ // anchors + excludes via its own internal trigger-wrapper.
65
69
  let triggerRef = $state<HTMLElement>();
66
70
  let panelRef = $state<HTMLElement>();
67
71
  let openSubMenus = $state<Set<string>>(new Set());
@@ -207,12 +211,31 @@
207
211
  items[items.length - 1]?.focus();
208
212
  }
209
213
 
210
- // When the menu opens, move focus into it (W3C menu pattern: focus on
211
- // the first item). Skipping this step would leave focus on the trigger
212
- // and arrow-key navigation would not work until the user clicked.
214
+ // When the menu opens, move focus into it (W3C menu pattern) so arrow-key
215
+ // navigation works immediately otherwise focus would stay on the trigger
216
+ // and the panel keydown handler would never receive keys.
217
+ //
218
+ // BUT focusing the first *item* paints a `:focus-visible` ring on it. That
219
+ // ring is desirable for keyboard users (they navigated here) and jarring
220
+ // for pointer users (a tap shouldn't pre-highlight an entry as if selected
221
+ // — the stray green ring report). So we branch on the opener's modality:
222
+ // • keyboard-open (trigger matches `:focus-visible`) → focus first item
223
+ // • pointer-open → focus the ring-less
224
+ // panel container; arrows still work (handlePanelKeydown falls back to
225
+ // focusFirstItem / focusLastItem when no item is focused yet).
226
+ //
227
+ // We also capture the opener element here so `dismiss()` can restore focus
228
+ // to the trigger on Escape / selection (the previous `triggerRef` was never
229
+ // assigned, so that restore was a silent no-op).
213
230
  $effect(() => {
214
231
  if (!open) return;
215
- queueMicrotask(() => focusFirstItem());
232
+ const opener = document.activeElement as HTMLElement | null;
233
+ if (opener && opener !== document.body) triggerRef = opener;
234
+ const openedViaKeyboard = opener?.matches?.(':focus-visible') ?? false;
235
+ queueMicrotask(() => {
236
+ if (openedViaKeyboard) focusFirstItem();
237
+ else panelRef?.focus();
238
+ });
216
239
  });
217
240
 
218
241
  function itemSizeForDepth(_depth: number): 'sm' | 'md' | 'lg' {
@@ -289,20 +312,26 @@
289
312
  // keys to here via their own onkeydown handler.
290
313
  function handlePanelKeydown(e: KeyboardEvent) {
291
314
  const target = e.target as HTMLElement | null;
292
- const focused =
315
+ const active = document.activeElement as HTMLElement | null;
316
+ // Only treat a real menuitem as the navigation anchor. When focus is on
317
+ // the panel container itself (pointer-open), `focusedItem` is null and
318
+ // ArrowDown/Up fall back to focusing the first/last item.
319
+ const focusedItem =
293
320
  target?.getAttribute('role') === 'menuitem'
294
321
  ? target
295
- : (document.activeElement as HTMLElement | null);
322
+ : active?.getAttribute('role') === 'menuitem'
323
+ ? active
324
+ : null;
296
325
 
297
326
  switch (e.key) {
298
327
  case 'ArrowDown':
299
328
  e.preventDefault();
300
- if (focused) focusNextItem(focused);
329
+ if (focusedItem) focusNextItem(focusedItem);
301
330
  else focusFirstItem();
302
331
  break;
303
332
  case 'ArrowUp':
304
333
  e.preventDefault();
305
- if (focused) focusPrevItem(focused);
334
+ if (focusedItem) focusPrevItem(focusedItem);
306
335
  else focusLastItem();
307
336
  break;
308
337
  case 'Home':
@@ -386,7 +415,6 @@
386
415
  <Popover
387
416
  bind:open
388
417
  placement={placement as import('../../utils/floating').Placement}
389
- bind:triggerElement={triggerRef}
390
418
  {usePortal}
391
419
  autoTrigger={false}
392
420
  unstyled
@@ -399,9 +427,12 @@
399
427
  role="menu"
400
428
  tabindex={-1}
401
429
  onkeydown={handlePanelKeydown}
402
- class={unstyled
403
- ? (slotClasses?.content ?? '')
404
- : styles.content({ class: slotClasses?.content })}
430
+ class={[
431
+ unstyled ? (slotClasses?.content ?? '') : styles.content({ class: slotClasses?.content }),
432
+ // Panel is programmatically focused on pointer-open as the ring-less
433
+ // anchor for arrow-key nav — suppress any UA outline on the container.
434
+ 'focus:outline-none'
435
+ ]}
405
436
  >
406
437
  {#if customHeader}
407
438
  <div
@@ -104,8 +104,21 @@
104
104
  // Manual mode disables the browser's automatic dismiss entirely, which
105
105
  // is the only way to veto Escape or outside-click selectively — the
106
106
  // `beforetoggle` event is intentionally not cancelable when closing.
107
+ //
108
+ // `autoTrigger=false` (external trigger) ALSO forces manual mode. Native
109
+ // `popover="auto"` light-dismiss treats the external trigger as "outside"
110
+ // (the browser only exempts a real `popovertarget` invoker, which we do
111
+ // not wire through Floating UI). So a tap on an open external trigger
112
+ // light-dismisses the popover on `pointerdown`, then the consumer's own
113
+ // `onclick` toggle re-opens it — the close-then-reopen flicker seen on
114
+ // mobile. Manual mode's outside-pointerdown handler instead excludes the
115
+ // trigger via `contains`, so the consumer's toggle is the single source
116
+ // of truth. Deterministic across browsers; no reliance on light-dismiss
117
+ // timing. Consumers with an external CLICK trigger (Menu, DatePicker)
118
+ // get a clean toggle; hover-driven external triggers (Calendar event
119
+ // popovers) are unaffected since they never relied on light-dismiss.
107
120
  const popoverMode = $derived<'auto' | 'manual'>(
108
- closeOnEscape && closeOnClickOutside ? 'auto' : 'manual'
121
+ autoTrigger && closeOnEscape && closeOnClickOutside ? 'auto' : 'manual'
109
122
  );
110
123
 
111
124
  // ── Native popover state sync (popover="auto" mode only) ──
@@ -127,17 +140,6 @@
127
140
  return () => document.removeEventListener('keydown', handleEscape, true);
128
141
  });
129
142
 
130
- // For external triggers (autoTrigger=false), track pointerdown to coordinate
131
- // with light dismiss — prevents toggle-on-click when the trigger click causes dismiss.
132
- $effect(() => {
133
- if (!effectiveTriggerElement || autoTrigger) return;
134
- function handlePointerDown() {
135
- if (open) dismissedByTrigger = true;
136
- }
137
- effectiveTriggerElement.addEventListener('pointerdown', handlePointerDown);
138
- return () => effectiveTriggerElement.removeEventListener('pointerdown', handlePointerDown);
139
- });
140
-
141
143
  // Listen for native popover toggle events (light dismiss, Escape, programmatic).
142
144
  // Only relevant in `auto` mode — in manual mode the browser doesn't fire
143
145
  // toggle events for outside clicks or Escape, so the dismiss callbacks
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import { SvelteMap } from 'svelte/reactivity';
3
4
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
5
  import { getTierContext } from '../../utils';
@@ -21,6 +22,7 @@
21
22
  slotClasses: slotClassesProp = {},
22
23
  preset,
23
24
  ariaLabel,
25
+ collapseOnOverflow = true,
24
26
  ...restProps
25
27
  }: SegmentGroupProps = $props();
26
28
 
@@ -35,6 +37,15 @@
35
37
 
36
38
  let containerElement = $state<HTMLDivElement>();
37
39
  let indicatorStyle = $state('opacity: 0;');
40
+ // Content-aware overflow degradation: when the horizontal track can't fit its
41
+ // available width, collapse to a vertical radio-style stack. `collapsed`
42
+ // drives the `data-collapsed` attribute (CSS does the layout switch).
43
+ let collapsed = $state(false);
44
+ // Horizontal content width recorded at the moment we collapse, used as the
45
+ // hysteresis threshold to expand back. Without it the check would oscillate:
46
+ // once vertical the track no longer overflows horizontally, so a naive
47
+ // re-measure would immediately switch back to horizontal and overflow again.
48
+ let naturalWidth = 0;
38
49
  // SvelteMap instead of `$state(new Map())` — `.set()`/`.delete()` must be
39
50
  // reactive so the indicator updates when new items register.
40
51
  const registeredItems = new SvelteMap<string, HTMLElement>();
@@ -121,9 +132,63 @@
121
132
 
122
133
  $effect(() => {
123
134
  void value;
135
+ // Reposition the indicator when the layout flips orientation — the active
136
+ // item's box moves from a horizontal slot to a full-width row.
137
+ void collapsed;
124
138
  requestAnimationFrame(updateIndicator);
125
139
  });
126
140
 
141
+ // Detects whether the horizontal track fits its available width. Reads
142
+ // layout, so it runs from the ResizeObserver callback rather than a reactive
143
+ // effect. The +1 tolerance + recorded `naturalWidth` give it hysteresis so it
144
+ // settles instead of flip-flopping at the boundary.
145
+ //
146
+ // We measure the items' own geometry rather than `el.scrollWidth`: the track
147
+ // is `overflow-x-clip`, which is NOT a scroll container, and Chromium/WebKit
148
+ // clamp `scrollWidth` to `clientWidth` for clip boxes (so the old scrollWidth
149
+ // check silently never fired off Firefox). `getBoundingClientRect()` reports
150
+ // true layout positions regardless of clipping.
151
+ function measureOverflow() {
152
+ const el = containerElement;
153
+ if (!el || el.clientWidth === 0 || registeredItems.size === 0) return;
154
+ if (!collapsed) {
155
+ let minLeft = Infinity;
156
+ let maxRight = -Infinity;
157
+ for (const item of registeredItems.values()) {
158
+ const r = item.getBoundingClientRect();
159
+ if (r.width === 0 && r.height === 0) continue; // not laid out yet
160
+ minLeft = Math.min(minLeft, r.left);
161
+ maxRight = Math.max(maxRight, r.right);
162
+ }
163
+ if (maxRight === -Infinity) return;
164
+ const cs = getComputedStyle(el);
165
+ const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
166
+ const contentWidth = maxRight - minLeft;
167
+ // Overflow when the items can't fit the content box (clientWidth − padding).
168
+ if (contentWidth > el.clientWidth - padX + 1) {
169
+ // Min box width that fits the horizontal track again (used as the
170
+ // expand-back threshold while collapsed, where the items are stacked
171
+ // and can no longer be measured horizontally).
172
+ naturalWidth = contentWidth + padX;
173
+ collapsed = true;
174
+ }
175
+ } else if (naturalWidth > 0 && el.clientWidth >= naturalWidth) {
176
+ collapsed = false;
177
+ }
178
+ }
179
+
180
+ $effect(() => {
181
+ if (!collapseOnOverflow || !containerElement) return;
182
+ const el = containerElement;
183
+ const ro = new ResizeObserver(() => measureOverflow());
184
+ ro.observe(el);
185
+ // `untrack` so the synchronous first measure doesn't make `collapsed` a
186
+ // dependency of this effect (which would tear down + rebuild the observer
187
+ // on every collapse toggle).
188
+ untrack(() => measureOverflow());
189
+ return () => ro.disconnect();
190
+ });
191
+
127
192
  function handleKeyDown(event: KeyboardEvent) {
128
193
  if (disabled) return;
129
194
 
@@ -165,6 +230,8 @@
165
230
  <div
166
231
  bind:this={containerElement}
167
232
  role="radiogroup"
233
+ data-collapsed={collapsed || undefined}
234
+ aria-orientation={collapsed ? 'vertical' : 'horizontal'}
168
235
  class={unstyled
169
236
  ? [slotClasses?.base, className].filter(Boolean).join(' ')
170
237
  : styles.base({ class: [slotClasses?.base, className] })}
@@ -18,7 +18,7 @@ export interface SegmentGroupContext {
18
18
  readonly mint: MintProp;
19
19
  }
20
20
  /**
21
- * @description Inline segment control with animated sliding indicator for single-selection scenarios.
21
+ * @description Segment control with an animated sliding indicator for single selection; collapses to a vertical radio-style stack when its row can't fit the available width.
22
22
  * Compact mode/view switcher with smooth animation.
23
23
  *
24
24
  * @tag navigation
@@ -59,6 +59,16 @@ export interface SegmentGroupProps extends SegmentGroupVariants, Omit<HTMLAttrib
59
59
  preset?: string;
60
60
  /** Accessible label for the segment group. */
61
61
  ariaLabel?: string;
62
+ /**
63
+ * When the segments can't fit their available width, collapse the horizontal
64
+ * track to a vertical radio-style stack (all options stay visible) instead of
65
+ * overflowing. Triggered by real measured overflow (ResizeObserver), not a
66
+ * viewport breakpoint, so it only engages when an instance genuinely doesn't
67
+ * fit — a 2-segment switcher that fits stays horizontal. Set `false` to keep
68
+ * the track horizontal (it still won't push the page wider than its parent).
69
+ * @default true
70
+ */
71
+ collapseOnOverflow?: boolean;
62
72
  /**
63
73
  * Micro-interaction effect applied to each segment item (per-item, not the container).
64
74
  * Accepts a preset name, an array of names, or configured mint objects.
@@ -6,7 +6,18 @@ export const segmentGroupVariants = tv({
6
6
  // `commit` mirrors that. `modify` softens the track + indicator into a
7
7
  // compact rectangle for inline-toolbar contexts.
8
8
  base: [
9
- 'relative inline-flex items-center',
9
+ // `group` + `data-[collapsed]` drive the content-aware degradation below.
10
+ // `min-w-0 max-w-full` let the track shrink inside a flex/narrow parent
11
+ // instead of pushing the page wide; `overflow-x-clip` (with a clip-margin
12
+ // so focus rings aren't cut) contains any horizontal overflow until the
13
+ // ResizeObserver flips to the vertical stack.
14
+ 'group relative inline-flex items-center',
15
+ 'min-w-0 max-w-full overflow-x-clip [overflow-clip-margin:0.5rem]',
16
+ // Collapsed = real horizontal overflow detected → vertical radio-style
17
+ // stack (all options visible, large tap targets). SegmentGroup is already
18
+ // role=radiogroup, so this is layout-only; the 2D indicator follows the
19
+ // active row.
20
+ 'data-[collapsed]:flex-col data-[collapsed]:items-stretch data-[collapsed]:w-full data-[collapsed]:overflow-visible',
10
21
  'bg-surface-interactive p-1',
11
22
  'transition-colors duration-[var(--blocks-duration-fast)]'
12
23
  ],
@@ -17,6 +28,9 @@ export const segmentGroupVariants = tv({
17
28
  ],
18
29
  item: [
19
30
  'relative z-10 flex items-center justify-center',
31
+ // In the collapsed (vertical) layout each item becomes a full-width,
32
+ // left-aligned row — see the `data-[collapsed]` block on `base`.
33
+ 'group-data-[collapsed]:w-full group-data-[collapsed]:justify-start',
20
34
  'font-medium whitespace-nowrap cursor-pointer select-none',
21
35
  // `border-0` (not `border-none`) — keeps `border-style: solid` so the
22
36
  // text-appearance can add `border-b-[1.5px]` without a style conflict.
@@ -109,6 +109,10 @@
109
109
  }));
110
110
 
111
111
  let activeIndex = $state(-1);
112
+ // Tracks whether the most recent open was keyboard-driven, so the initial
113
+ // active-option highlight only defaults to the first row for keyboard users.
114
+ // Plain `let`: read in the open effect, intentionally not a reactive dep.
115
+ let openedViaKeyboard = false;
112
116
  let triggerRef = $state<HTMLElement>();
113
117
  let listboxRef = $state<HTMLDivElement>();
114
118
 
@@ -205,15 +209,23 @@
205
209
  }
206
210
  });
207
211
 
212
+ // Initial active-option highlight when the listbox opens. Where the virtual
213
+ // cursor starts:
214
+ // • the selected option, if any — shows the user where they are
215
+ // • else the first option ONLY on keyboard-open — gives arrows a starting
216
+ // point and matches the keyboard expectation of a focused first row
217
+ // • else nothing (-1) on pointer-open — avoids the "first element always
218
+ // marked" phantom highlight; the first ArrowDown then moves to row 0.
219
+ // The `activeIndex >= 0` guard preserves an in-progress cursor so multi-
220
+ // select (listbox stays open across picks) doesn't reset on every toggle.
208
221
  $effect(() => {
209
- if (open && listboxRef) {
210
- if (multiple) {
211
- activeIndex = activeIndex >= 0 ? activeIndex : 0;
212
- } else {
213
- const selectedIdx = enabledOptions.findIndex((o) => o.value === value);
214
- activeIndex = selectedIdx >= 0 ? selectedIdx : 0;
215
- }
216
- }
222
+ if (!open || !listboxRef) return;
223
+ if (activeIndex >= 0) return;
224
+ let next = -1;
225
+ const selectedIdx = multiple ? -1 : enabledOptions.findIndex((o) => o.value === value);
226
+ if (selectedIdx >= 0) next = selectedIdx;
227
+ else if (openedViaKeyboard) next = 0;
228
+ activeIndex = next;
217
229
  });
218
230
 
219
231
  useFloatingListbox({
@@ -226,6 +238,9 @@
226
238
 
227
239
  function toggle() {
228
240
  if (disabled) return;
241
+ // Pointer-driven open (trigger onclick). Mark modality so the open effect
242
+ // doesn't pre-highlight the first row for a mouse/touch user.
243
+ if (!open) openedViaKeyboard = false;
229
244
  open = !open;
230
245
  if (!open) activeIndex = -1;
231
246
  }
@@ -277,7 +292,7 @@
277
292
 
278
293
  // Focus-restoration policy follows the ARIA Listbox/Button pattern:
279
294
  // • selection → focus returns to the trigger button (`selectOption`)
280
- // • Escape → focus returns to the trigger button (handleListboxKeydown)
295
+ // • Escape → focus returns to the trigger button (handleTriggerKeydown)
281
296
  // • Tab → focus moves naturally to the next tab stop (no restore)
282
297
  // • outside → focus stays where the user clicked (no restore — user
283
298
  // explicitly intent to focus elsewhere)
@@ -289,69 +304,76 @@
289
304
  return true;
290
305
  }
291
306
 
307
+ // Single keyboard model, attached to the trigger (the focused element). The
308
+ // ARIA listbox pattern keeps DOM focus on the trigger and moves a virtual
309
+ // cursor via `aria-activedescendant`, so the listbox element never receives
310
+ // key events — every key is handled here, branching on `open`.
311
+ //
312
+ // (Previously a second handler lived on the listbox `div`; since focus never
313
+ // entered it, arrows were preventDefault'd but never advanced the cursor —
314
+ // the "keyboard does nothing / fights the page" report. Consolidated here.)
292
315
  function handleTriggerKeydown(event: KeyboardEvent) {
293
316
  if (disabled) return;
294
317
 
295
318
  switch (event.key) {
296
- case 'Enter':
297
- case ' ':
298
319
  case 'ArrowDown':
299
320
  event.preventDefault();
300
- if (!open) open = true;
301
- break;
302
- case 'ArrowUp':
303
- event.preventDefault();
304
- if (!open) open = true;
305
- break;
306
- case 'Escape':
307
- if (open && dismissByEscape()) {
308
- event.preventDefault();
321
+ if (!open) {
322
+ openedViaKeyboard = true;
323
+ open = true;
324
+ } else {
325
+ activeIndex = activeIndex < enabledOptions.length - 1 ? activeIndex + 1 : 0;
309
326
  }
310
327
  break;
311
- }
312
- }
313
-
314
- function handleListboxKeydown(event: KeyboardEvent) {
315
- switch (event.key) {
316
- case 'ArrowDown':
317
- event.preventDefault();
318
- activeIndex = activeIndex < enabledOptions.length - 1 ? activeIndex + 1 : 0;
319
- break;
320
328
  case 'ArrowUp':
321
329
  event.preventDefault();
322
- activeIndex = activeIndex > 0 ? activeIndex - 1 : enabledOptions.length - 1;
330
+ if (!open) {
331
+ openedViaKeyboard = true;
332
+ open = true;
333
+ } else {
334
+ activeIndex = activeIndex > 0 ? activeIndex - 1 : enabledOptions.length - 1;
335
+ }
323
336
  break;
324
337
  case 'Enter':
325
338
  case ' ':
326
339
  event.preventDefault();
327
- if (activeIndex >= 0 && activeIndex < enabledOptions.length) {
340
+ if (!open) {
341
+ openedViaKeyboard = true;
342
+ open = true;
343
+ } else if (activeIndex >= 0 && activeIndex < enabledOptions.length) {
328
344
  selectOption(enabledOptions[activeIndex]);
329
345
  }
330
346
  break;
331
347
  case 'Escape':
332
- if (dismissByEscape()) {
348
+ if (open && dismissByEscape()) {
333
349
  event.preventDefault();
334
350
  focusTrigger();
335
351
  }
336
352
  break;
337
353
  case 'Tab':
338
- // Tab always closes (focus is leaving the widget regardless),
354
+ // Tab leaves the widget — close (focus moves on via the default tab),
339
355
  // independent of closeOnEscape/closeOnClickOutside.
340
- open = false;
341
- activeIndex = -1;
356
+ if (open) {
357
+ open = false;
358
+ activeIndex = -1;
359
+ }
342
360
  break;
343
361
  case 'Home':
344
- event.preventDefault();
345
- activeIndex = 0;
362
+ if (open) {
363
+ event.preventDefault();
364
+ activeIndex = 0;
365
+ }
346
366
  break;
347
367
  case 'End':
348
- event.preventDefault();
349
- activeIndex = enabledOptions.length - 1;
368
+ if (open) {
369
+ event.preventDefault();
370
+ activeIndex = enabledOptions.length - 1;
371
+ }
350
372
  break;
351
373
  }
352
374
  }
353
375
 
354
- function handleClickOutside(event: MouseEvent) {
376
+ function handleClickOutside(event: PointerEvent) {
355
377
  const target = event.target as Node;
356
378
  if (
357
379
  open &&
@@ -385,7 +407,7 @@
385
407
  const hasSelection = $derived(multiple ? selectedOptions.length > 0 : selectedOption !== null);
386
408
  </script>
387
409
 
388
- <svelte:window onclick={handleClickOutside} />
410
+ <svelte:window onpointerdown={handleClickOutside} />
389
411
 
390
412
  <div
391
413
  class={unstyled
@@ -512,7 +534,6 @@
512
534
  ? 'position: absolute; overflow-y: auto;'
513
535
  : 'display: none; position: absolute; overflow-y: auto;'}
514
536
  aria-labelledby={labelId}
515
- onkeydown={handleListboxKeydown}
516
537
  >
517
538
  {#if open}
518
539
  {#if groups}
@@ -26,12 +26,16 @@ export const selectVariants = tv({
26
26
  // `position`, `width`, `overflow-y` are set inline in Select.svelte
27
27
  // so the native `popover="manual"` top-layer rendering works
28
28
  // correctly and the UA's `overflow: auto` doesn't render a duplicate
29
- // scrollbar. Width is sized by Floating UI's `size` middleware.
29
+ // scrollbar. Width AND a keyboard-aware max-height come from Floating
30
+ // UI's `size` middleware: it sets `--blocks-overlay-available-height`
31
+ // to the room left in the visual viewport (e.g. above the iOS keyboard);
32
+ // the `max-h-[min(15rem,…)]` below keeps 15rem as the upper design cap
33
+ // via `min()` and falls back to it whenever the var is unset.
30
34
  // tier: contain — floating dropdown panel.
31
35
  listbox: [
32
36
  'rounded-contain border',
33
37
  'bg-surface-elevated border-border-subtle shadow-[var(--blocks-shadow-md)]',
34
- 'max-h-60 p-1 space-y-0.5'
38
+ 'max-h-[min(15rem,var(--blocks-overlay-available-height,100dvh))] p-1 space-y-0.5'
35
39
  ],
36
40
  option: [
37
41
  'flex w-full items-center gap-2 rounded-modify px-3 cursor-pointer select-none',
@@ -39,7 +39,9 @@ export const textareaVariants = tv({
39
39
  }
40
40
  },
41
41
  size: {
42
- sm: { base: 'px-3 py-2 text-sm min-h-[5rem]' },
42
+ // `pointer-coarse:text-base` floors the font to 16px on touch so iOS
43
+ // Safari doesn't auto-zoom the field on focus. Desktop keeps 14px.
44
+ sm: { base: 'px-3 py-2 text-sm pointer-coarse:text-base min-h-[5rem]' },
43
45
  md: { base: 'px-4 py-3 text-base min-h-[7rem]' },
44
46
  lg: { base: 'px-6 py-4 text-lg min-h-[9rem]' }
45
47
  },
@@ -15,6 +15,7 @@ declare const _default: {
15
15
  readonly progress: "Fortschritt";
16
16
  readonly slider: "Schieberegler";
17
17
  readonly toggle: "Umschalter";
18
+ readonly toggleOptions: "Optionen umschalten";
18
19
  readonly removableBadge: "Entfernbarer Badge";
19
20
  readonly removeBadge: "Badge entfernen";
20
21
  readonly removeFile: "{{name}} entfernen";
@@ -15,6 +15,7 @@ export default {
15
15
  progress: 'Fortschritt',
16
16
  slider: 'Schieberegler',
17
17
  toggle: 'Umschalter',
18
+ toggleOptions: 'Optionen umschalten',
18
19
  removableBadge: 'Entfernbarer Badge',
19
20
  removeBadge: 'Badge entfernen',
20
21
  removeFile: '{{name}} entfernen'
@@ -15,6 +15,7 @@ declare const _default: {
15
15
  readonly progress: "Progress";
16
16
  readonly slider: "Slider";
17
17
  readonly toggle: "Toggle";
18
+ readonly toggleOptions: "Toggle options";
18
19
  readonly removableBadge: "Removable badge";
19
20
  readonly removeBadge: "Remove badge";
20
21
  readonly removeFile: "Remove {{name}}";
@@ -15,6 +15,7 @@ export default {
15
15
  progress: 'Progress',
16
16
  slider: 'Slider',
17
17
  toggle: 'Toggle',
18
+ toggleOptions: 'Toggle options',
18
19
  removableBadge: 'Removable badge',
19
20
  removeBadge: 'Remove badge',
20
21
  removeFile: 'Remove {{name}}'