@urbicon-ui/blocks 6.3.3 → 6.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/CommandPalette/commandPalette.variants.js +3 -1
- package/dist/i18n/index.d.ts +4 -0
- package/dist/primitives/Combobox/Combobox.svelte +42 -5
- package/dist/primitives/Combobox/combobox.variants.d.ts +9 -0
- package/dist/primitives/Combobox/combobox.variants.js +17 -6
- package/dist/primitives/Input/input.variants.js +6 -2
- package/dist/primitives/Menu/Menu.svelte +43 -12
- package/dist/primitives/Popover/Popover.svelte +14 -12
- package/dist/primitives/SegmentGroup/SegmentGroup.svelte +67 -0
- package/dist/primitives/SegmentGroup/index.d.ts +10 -0
- package/dist/primitives/SegmentGroup/segmentgroup.variants.js +15 -1
- package/dist/primitives/Select/Select.svelte +61 -40
- package/dist/primitives/Textarea/textarea.variants.js +3 -1
- package/dist/translations/de.d.ts +1 -0
- package/dist/translations/de.js +1 -0
- package/dist/translations/en.d.ts +1 -0
- package/dist/translations/en.js +1 -0
- package/package.json +9 -9
|
@@ -8,7 +8,9 @@ export const commandPaletteVariants = tv({
|
|
|
8
8
|
],
|
|
9
9
|
inputWrapper: ['flex items-center gap-3 border-b border-border-hairline px-4'],
|
|
10
10
|
input: [
|
|
11
|
-
|
|
11
|
+
// `pointer-coarse:text-base` floors the search field to 16px on touch
|
|
12
|
+
// devices — below 16px iOS Safari auto-zooms on focus and never restores.
|
|
13
|
+
'w-full border-0 bg-transparent py-3 text-sm pointer-coarse:text-base',
|
|
12
14
|
'text-text-primary placeholder:text-text-quaternary focus:outline-none'
|
|
13
15
|
],
|
|
14
16
|
inputIcon: 'h-4 w-4 shrink-0 text-text-tertiary',
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ declare const blocksTranslations: {
|
|
|
17
17
|
readonly progress: "Progress";
|
|
18
18
|
readonly slider: "Slider";
|
|
19
19
|
readonly toggle: "Toggle";
|
|
20
|
+
readonly toggleOptions: "Toggle options";
|
|
20
21
|
readonly removableBadge: "Removable badge";
|
|
21
22
|
readonly removeBadge: "Remove badge";
|
|
22
23
|
readonly removeFile: "Remove {{name}}";
|
|
@@ -185,6 +186,7 @@ declare const blocksTranslations: {
|
|
|
185
186
|
readonly progress: "Fortschritt";
|
|
186
187
|
readonly slider: "Schieberegler";
|
|
187
188
|
readonly toggle: "Umschalter";
|
|
189
|
+
readonly toggleOptions: "Optionen umschalten";
|
|
188
190
|
readonly removableBadge: "Entfernbarer Badge";
|
|
189
191
|
readonly removeBadge: "Badge entfernen";
|
|
190
192
|
readonly removeFile: "{{name}} entfernen";
|
|
@@ -354,6 +356,7 @@ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
|
|
|
354
356
|
readonly progress: "Progress";
|
|
355
357
|
readonly slider: "Slider";
|
|
356
358
|
readonly toggle: "Toggle";
|
|
359
|
+
readonly toggleOptions: "Toggle options";
|
|
357
360
|
readonly removableBadge: "Removable badge";
|
|
358
361
|
readonly removeBadge: "Remove badge";
|
|
359
362
|
readonly removeFile: "Remove {{name}}";
|
|
@@ -534,6 +537,7 @@ export declare const useBlocksI18n: () => import("@urbicon-ui/i18n").TypedTransl
|
|
|
534
537
|
readonly progress: "Progress";
|
|
535
538
|
readonly slider: "Slider";
|
|
536
539
|
readonly toggle: "Toggle";
|
|
540
|
+
readonly toggleOptions: "Toggle options";
|
|
537
541
|
readonly removableBadge: "Removable badge";
|
|
538
542
|
readonly removeBadge: "Remove badge";
|
|
539
543
|
readonly removeFile: "Remove {{name}}";
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
|
|
67
67
|
|
|
68
68
|
let inputEl = $state<HTMLInputElement>();
|
|
69
|
+
let wrapperEl = $state<HTMLElement>();
|
|
69
70
|
let listboxEl = $state<HTMLElement>();
|
|
70
71
|
let activeIndex = $state(-1);
|
|
71
72
|
|
|
@@ -135,6 +136,24 @@
|
|
|
135
136
|
if (!disabled) open = true;
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// Chevron toggle. The input opens on focus but has no way to close itself
|
|
140
|
+
// (re-clicking a focused field is a no-op — the "trigger doesn't close it"
|
|
141
|
+
// report), so the chevron button is the explicit open/close affordance.
|
|
142
|
+
// Focus stays on the input throughout (the chevron's onmousedown calls
|
|
143
|
+
// preventDefault), so closing here doesn't blur-then-reopen via handleFocus.
|
|
144
|
+
function toggleOpen() {
|
|
145
|
+
if (disabled) return;
|
|
146
|
+
if (open) {
|
|
147
|
+
open = false;
|
|
148
|
+
// Restore the selected label if the query was left dangling (mirrors the
|
|
149
|
+
// click-outside path), so the field doesn't read as blank after closing.
|
|
150
|
+
if (!value && selectedOption) query = selectedOption.label;
|
|
151
|
+
} else {
|
|
152
|
+
open = true;
|
|
153
|
+
inputEl?.focus();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
function scrollToActive() {
|
|
139
158
|
tick().then(() => {
|
|
140
159
|
if (!listboxEl) return;
|
|
@@ -214,7 +233,10 @@
|
|
|
214
233
|
|
|
215
234
|
function handleClickOutside(event: MouseEvent) {
|
|
216
235
|
const target = event.target as Node;
|
|
217
|
-
|
|
236
|
+
// Exclude the whole input wrapper (input + chevron toggle + clear button),
|
|
237
|
+
// so clicking the chevron to close doesn't race this handler into a
|
|
238
|
+
// close-then-reopen. The listbox lives in the top layer (separate subtree).
|
|
239
|
+
if (!wrapperEl?.contains(target) && !listboxEl?.contains(target)) {
|
|
218
240
|
if (!closeOnClickOutside) return;
|
|
219
241
|
open = false;
|
|
220
242
|
if (!value && selectedOption) {
|
|
@@ -273,6 +295,7 @@
|
|
|
273
295
|
{/if}
|
|
274
296
|
|
|
275
297
|
<div
|
|
298
|
+
bind:this={wrapperEl}
|
|
276
299
|
class={unstyled
|
|
277
300
|
? (slotClasses?.inputWrapper ?? '')
|
|
278
301
|
: styles.inputWrapper({ class: slotClasses?.inputWrapper })}
|
|
@@ -309,11 +332,25 @@
|
|
|
309
332
|
<CloseIcon class="h-3.5 w-3.5" />
|
|
310
333
|
</button>
|
|
311
334
|
{:else}
|
|
312
|
-
<
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
tabindex={-1}
|
|
338
|
+
{disabled}
|
|
339
|
+
aria-label={bt('accessibility.toggleOptions')}
|
|
340
|
+
aria-controls={listboxId}
|
|
341
|
+
aria-expanded={open}
|
|
342
|
+
onmousedown={(e) => e.preventDefault()}
|
|
343
|
+
onclick={toggleOpen}
|
|
313
344
|
class={unstyled
|
|
314
|
-
? (slotClasses?.
|
|
315
|
-
: styles.
|
|
316
|
-
|
|
345
|
+
? (slotClasses?.chevronButton ?? '')
|
|
346
|
+
: styles.chevronButton({ class: slotClasses?.chevronButton })}
|
|
347
|
+
>
|
|
348
|
+
<ChevronDownIcon
|
|
349
|
+
class={unstyled
|
|
350
|
+
? (slotClasses?.chevron ?? '')
|
|
351
|
+
: styles.chevron({ class: slotClasses?.chevron })}
|
|
352
|
+
/>
|
|
353
|
+
</button>
|
|
317
354
|
{/if}
|
|
318
355
|
|
|
319
356
|
{#if name}
|
|
@@ -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;
|
|
@@ -44,11 +44,20 @@ export const comboboxVariants = tv({
|
|
|
44
44
|
'transition-colors duration-[var(--blocks-duration-fast)]',
|
|
45
45
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50'
|
|
46
46
|
],
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
// Chevron toggle button. The input opens on focus and can't "toggle
|
|
48
|
+
// closed" (re-clicking a focused field is a no-op), so the chevron is the
|
|
49
|
+
// discoverable open/close affordance. `tabindex={-1}` keeps it out of the
|
|
50
|
+
// tab order — the input is the single combobox tab stop — and an
|
|
51
|
+
// `onmousedown` preventDefault keeps focus on the input when it's clicked.
|
|
52
|
+
chevronButton: [
|
|
53
|
+
'absolute right-1.5 top-1/2 -translate-y-1/2',
|
|
54
|
+
'inline-flex items-center justify-center rounded-modify p-1',
|
|
55
|
+
'text-text-tertiary hover:text-text-primary cursor-pointer',
|
|
56
|
+
'transition-colors duration-[var(--blocks-duration-fast)]',
|
|
57
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
58
|
+
'disabled:cursor-not-allowed disabled:hover:text-text-tertiary'
|
|
59
|
+
],
|
|
60
|
+
chevron: ['w-4 h-4', 'transition-transform duration-[var(--blocks-duration-fast)]']
|
|
52
61
|
},
|
|
53
62
|
variants: {
|
|
54
63
|
// 3-tier semantic radius for the Combobox input. Default `modify`.
|
|
@@ -59,7 +68,9 @@ export const comboboxVariants = tv({
|
|
|
59
68
|
},
|
|
60
69
|
size: {
|
|
61
70
|
sm: {
|
|
62
|
-
|
|
71
|
+
// `pointer-coarse:text-base` floors the input to 16px on touch-primary
|
|
72
|
+
// devices so iOS Safari doesn't auto-zoom (and never un-zoom) on focus.
|
|
73
|
+
input: 'h-8 px-3 pr-8 text-sm pointer-coarse:text-base',
|
|
63
74
|
option: 'px-2 py-1.5 text-sm min-h-[2rem]'
|
|
64
75
|
},
|
|
65
76
|
md: {
|
|
@@ -54,13 +54,17 @@ export const inputVariants = tv({
|
|
|
54
54
|
},
|
|
55
55
|
size: {
|
|
56
56
|
xs: {
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
//
|
|
212
|
-
// and
|
|
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
|
-
|
|
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
|
|
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
|
-
: (
|
|
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 (
|
|
329
|
+
if (focusedItem) focusNextItem(focusedItem);
|
|
301
330
|
else focusFirstItem();
|
|
302
331
|
break;
|
|
303
332
|
case 'ArrowUp':
|
|
304
333
|
e.preventDefault();
|
|
305
|
-
if (
|
|
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={
|
|
403
|
-
? (slotClasses?.content ?? '')
|
|
404
|
-
|
|
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] })}
|
|
@@ -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
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 (
|
|
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,64 +304,71 @@
|
|
|
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)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
354
|
+
// Tab leaves the widget — close (focus moves on via the default tab),
|
|
339
355
|
// independent of closeOnEscape/closeOnClickOutside.
|
|
340
|
-
open
|
|
341
|
-
|
|
356
|
+
if (open) {
|
|
357
|
+
open = false;
|
|
358
|
+
activeIndex = -1;
|
|
359
|
+
}
|
|
342
360
|
break;
|
|
343
361
|
case 'Home':
|
|
344
|
-
|
|
345
|
-
|
|
362
|
+
if (open) {
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
activeIndex = 0;
|
|
365
|
+
}
|
|
346
366
|
break;
|
|
347
367
|
case 'End':
|
|
348
|
-
|
|
349
|
-
|
|
368
|
+
if (open) {
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
activeIndex = enabledOptions.length - 1;
|
|
371
|
+
}
|
|
350
372
|
break;
|
|
351
373
|
}
|
|
352
374
|
}
|
|
@@ -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}
|
|
@@ -39,7 +39,9 @@ export const textareaVariants = tv({
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
size: {
|
|
42
|
-
|
|
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";
|
package/dist/translations/de.js
CHANGED
|
@@ -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}}";
|
package/dist/translations/en.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/blocks",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.6",
|
|
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": {
|
|
@@ -80,27 +80,27 @@
|
|
|
80
80
|
"./dist/i18n/index.d.ts"
|
|
81
81
|
],
|
|
82
82
|
"peerDependencies": {
|
|
83
|
-
"svelte": "^5.56.
|
|
84
|
-
"@sveltejs/kit": "^2.
|
|
83
|
+
"svelte": "^5.56.4",
|
|
84
|
+
"@sveltejs/kit": "^2.67.0",
|
|
85
85
|
"@urbicon-ui/i18n": "^6.0.0",
|
|
86
86
|
"@urbicon-ui/shared-types": "^6.0.0"
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {},
|
|
89
89
|
"devDependencies": {
|
|
90
|
-
"@sveltejs/kit": "^2.
|
|
90
|
+
"@sveltejs/kit": "^2.67.0",
|
|
91
91
|
"@sveltejs/package": "^2.5.8",
|
|
92
92
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
93
93
|
"@tailwindcss/vite": "^4.3.1",
|
|
94
|
-
"@urbicon-ui/i18n": "6.3.
|
|
95
|
-
"@urbicon-ui/shared-types": "6.3.
|
|
94
|
+
"@urbicon-ui/i18n": "6.3.6",
|
|
95
|
+
"@urbicon-ui/shared-types": "6.3.6",
|
|
96
96
|
"prettier": "^3.8.4",
|
|
97
97
|
"prettier-plugin-svelte": "^4.1.1",
|
|
98
98
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
99
|
-
"svelte": "^5.56.
|
|
100
|
-
"svelte-check": "^4.
|
|
99
|
+
"svelte": "^5.56.4",
|
|
100
|
+
"svelte-check": "^4.7.1",
|
|
101
101
|
"tailwindcss": "^4.3.1",
|
|
102
102
|
"typescript": "^6.0.3",
|
|
103
|
-
"vite": "^8.0
|
|
103
|
+
"vite": "^8.1.0",
|
|
104
104
|
"vitest": "^4.1.9"
|
|
105
105
|
}
|
|
106
106
|
}
|