@urbicon-ui/blocks 6.3.6 → 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.
@@ -339,175 +339,7 @@ declare const blocksTranslations: {
339
339
  };
340
340
  };
341
341
  };
342
- export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
343
- readonly accessibility: {
344
- readonly avatar: "Avatar";
345
- readonly clearInput: "Clear input";
346
- readonly clearSearch: "Clear search";
347
- readonly clearSelection: "Clear selection";
348
- readonly closeDialog: "Close dialog";
349
- readonly closeDrawer: "Close drawer";
350
- readonly dismiss: "Dismiss";
351
- readonly fileUpload: "File upload";
352
- readonly loading: "Loading";
353
- readonly maximum: "Maximum";
354
- readonly minimum: "Minimum";
355
- readonly pagination: "Page navigation";
356
- readonly progress: "Progress";
357
- readonly slider: "Slider";
358
- readonly toggle: "Toggle";
359
- readonly toggleOptions: "Toggle options";
360
- readonly removableBadge: "Removable badge";
361
- readonly removeBadge: "Remove badge";
362
- readonly removeFile: "Remove {{name}}";
363
- };
364
- readonly button: {
365
- readonly close: "Close";
366
- readonly apply: "Apply";
367
- readonly cancel: "Cancel";
368
- readonly confirm: "Confirm";
369
- };
370
- readonly menu: {
371
- readonly placeholder: "Select an option...";
372
- };
373
- readonly common: {
374
- readonly loading: "Loading...";
375
- };
376
- readonly languages: {
377
- readonly de: "Deutsch";
378
- readonly en: "English";
379
- readonly es: "Español";
380
- readonly fr: "Français";
381
- readonly it: "Italiano";
382
- readonly nl: "Nederlands";
383
- };
384
- readonly localeSwitcher: {
385
- readonly ariaLabel: "Language selection";
386
- readonly placeholder: "Select language...";
387
- };
388
- readonly pagination: {
389
- readonly next: "Next";
390
- readonly first: "First";
391
- readonly last: "Last";
392
- readonly page: "Page";
393
- readonly previous: "Previous";
394
- };
395
- readonly calendar: {
396
- readonly recurring: "Recurring event";
397
- readonly previousMonth: "Previous month";
398
- readonly nextMonth: "Next month";
399
- readonly previousWeek: "Previous week";
400
- readonly nextWeek: "Next week";
401
- readonly previousDay: "Previous day";
402
- readonly nextDay: "Next day";
403
- readonly previousYear: "Previous year";
404
- readonly nextYear: "Next year";
405
- readonly today: "Today";
406
- readonly calendarGrid: "Calendar";
407
- readonly weekdays: "Weekdays";
408
- readonly weekNumber: "Week number";
409
- readonly events: "Events";
410
- readonly noEvents: "No events";
411
- readonly showMore: "{{count}} more";
412
- readonly legend: "Legend";
413
- readonly yearView: "Year overview";
414
- readonly weekView: "Week view";
415
- readonly dayView: "Day view";
416
- readonly monthView: "Month view";
417
- readonly viewMonth: "Month";
418
- readonly viewYear: "Year";
419
- readonly viewWeek: "Week";
420
- readonly viewDay: "Day";
421
- readonly multiDayLabel: "Day {{current}} of {{total}}";
422
- readonly agendaView: "Agenda view";
423
- readonly viewAgenda: "Agenda";
424
- readonly viewSwitcher: "View mode";
425
- };
426
- readonly planner: {
427
- readonly previousWeek: "Previous week";
428
- readonly nextWeek: "Next week";
429
- readonly previousMonth: "Previous month";
430
- readonly nextMonth: "Next month";
431
- readonly previousRange: "Previous range";
432
- readonly nextRange: "Next range";
433
- readonly today: "Today";
434
- readonly grid: "Planner";
435
- readonly weekNumber: "Week number";
436
- readonly itemCount: "{{count}} items";
437
- };
438
- readonly commandPalette: {
439
- readonly noResults: "No results found.";
440
- readonly search: "Search...";
441
- readonly hints: {
442
- readonly navigate: "Navigate";
443
- readonly select: "Select";
444
- readonly close: "Close";
445
- };
446
- };
447
- readonly compositionBar: {
448
- readonly total: "Total";
449
- readonly summary: "Composition: {{total}} ({{count}} shares) — {{parts}}";
450
- readonly share: "Share";
451
- readonly value: "Value";
452
- readonly percent: "Percent";
453
- readonly remaining: "remaining";
454
- };
455
- readonly guide: {
456
- readonly next: "Next";
457
- readonly previous: "Back";
458
- readonly skip: "Skip tour";
459
- readonly done: "Done";
460
- readonly step: "Step {{current}} of {{total}}";
461
- readonly tour: "Guided tour";
462
- readonly close: "Close";
463
- readonly openHelp: "Help";
464
- readonly backToList: "All topics";
465
- readonly info: "More information";
466
- readonly infoAbout: "More information about {{label}}";
467
- readonly dismiss: "Dismiss hint";
468
- readonly startTour: "Start the guided tour";
469
- readonly actionRequired: "Complete the highlighted action to continue";
470
- };
471
- readonly datepicker: {
472
- readonly placeholder: "Select a date...";
473
- readonly rangePlaceholder: "Select a date range...";
474
- readonly clear: "Clear selection";
475
- readonly openCalendar: "Open calendar";
476
- readonly invalidDate: "Invalid date";
477
- readonly outOfRange: "Date is outside the allowed range";
478
- readonly invalidRange: "Invalid date range";
479
- };
480
- readonly fileUpload: {
481
- readonly exists: "File already added";
482
- readonly invalidType: "File type {{type}} is not allowed";
483
- readonly tooLarge: "File exceeds {{size}} limit";
484
- readonly tooMany: "Maximum {{count}} files allowed";
485
- readonly tooSmall: "File must be at least {{size}}";
486
- };
487
- readonly sankey: {
488
- readonly summary: "Sankey diagram: {{nodes}} nodes, {{links}} links — {{flows}}";
489
- readonly source: "Source";
490
- readonly target: "Target";
491
- readonly value: "Value";
492
- };
493
- readonly slider: {
494
- readonly rangeStatus: {
495
- readonly insideRecommended: "In recommended range";
496
- readonly insideValid: "In valid range";
497
- readonly insideValidOnly: "Outside recommended range, but valid";
498
- readonly outsideValid: "Outside valid range";
499
- };
500
- };
501
- readonly themeSwitcher: {
502
- readonly lightMode: "Light mode";
503
- readonly darkMode: "Dark mode";
504
- readonly systemTheme: "System theme";
505
- };
506
- readonly time: {
507
- readonly ago: "{{value}} {{unit}} ago";
508
- readonly now: "now";
509
- };
510
- }>;
342
+ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<import("@urbicon-ui/i18n").Translations>;
511
343
  /**
512
344
  * Context-scoped translation hook for the blocks package. Call during component
513
345
  * initialisation, then use the returned `t` (conventionally aliased `bt`):
@@ -520,175 +352,7 @@ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
520
352
  * Resolves against the nearest `<I18nProvider>`'s locale (or the base locale
521
353
  * when none is mounted) and re-renders reactively on locale change.
522
354
  */
523
- export declare const useBlocksI18n: () => import("@urbicon-ui/i18n").TypedTranslationFunction<{
524
- readonly accessibility: {
525
- readonly avatar: "Avatar";
526
- readonly clearInput: "Clear input";
527
- readonly clearSearch: "Clear search";
528
- readonly clearSelection: "Clear selection";
529
- readonly closeDialog: "Close dialog";
530
- readonly closeDrawer: "Close drawer";
531
- readonly dismiss: "Dismiss";
532
- readonly fileUpload: "File upload";
533
- readonly loading: "Loading";
534
- readonly maximum: "Maximum";
535
- readonly minimum: "Minimum";
536
- readonly pagination: "Page navigation";
537
- readonly progress: "Progress";
538
- readonly slider: "Slider";
539
- readonly toggle: "Toggle";
540
- readonly toggleOptions: "Toggle options";
541
- readonly removableBadge: "Removable badge";
542
- readonly removeBadge: "Remove badge";
543
- readonly removeFile: "Remove {{name}}";
544
- };
545
- readonly button: {
546
- readonly close: "Close";
547
- readonly apply: "Apply";
548
- readonly cancel: "Cancel";
549
- readonly confirm: "Confirm";
550
- };
551
- readonly menu: {
552
- readonly placeholder: "Select an option...";
553
- };
554
- readonly common: {
555
- readonly loading: "Loading...";
556
- };
557
- readonly languages: {
558
- readonly de: "Deutsch";
559
- readonly en: "English";
560
- readonly es: "Español";
561
- readonly fr: "Français";
562
- readonly it: "Italiano";
563
- readonly nl: "Nederlands";
564
- };
565
- readonly localeSwitcher: {
566
- readonly ariaLabel: "Language selection";
567
- readonly placeholder: "Select language...";
568
- };
569
- readonly pagination: {
570
- readonly next: "Next";
571
- readonly first: "First";
572
- readonly last: "Last";
573
- readonly page: "Page";
574
- readonly previous: "Previous";
575
- };
576
- readonly calendar: {
577
- readonly recurring: "Recurring event";
578
- readonly previousMonth: "Previous month";
579
- readonly nextMonth: "Next month";
580
- readonly previousWeek: "Previous week";
581
- readonly nextWeek: "Next week";
582
- readonly previousDay: "Previous day";
583
- readonly nextDay: "Next day";
584
- readonly previousYear: "Previous year";
585
- readonly nextYear: "Next year";
586
- readonly today: "Today";
587
- readonly calendarGrid: "Calendar";
588
- readonly weekdays: "Weekdays";
589
- readonly weekNumber: "Week number";
590
- readonly events: "Events";
591
- readonly noEvents: "No events";
592
- readonly showMore: "{{count}} more";
593
- readonly legend: "Legend";
594
- readonly yearView: "Year overview";
595
- readonly weekView: "Week view";
596
- readonly dayView: "Day view";
597
- readonly monthView: "Month view";
598
- readonly viewMonth: "Month";
599
- readonly viewYear: "Year";
600
- readonly viewWeek: "Week";
601
- readonly viewDay: "Day";
602
- readonly multiDayLabel: "Day {{current}} of {{total}}";
603
- readonly agendaView: "Agenda view";
604
- readonly viewAgenda: "Agenda";
605
- readonly viewSwitcher: "View mode";
606
- };
607
- readonly planner: {
608
- readonly previousWeek: "Previous week";
609
- readonly nextWeek: "Next week";
610
- readonly previousMonth: "Previous month";
611
- readonly nextMonth: "Next month";
612
- readonly previousRange: "Previous range";
613
- readonly nextRange: "Next range";
614
- readonly today: "Today";
615
- readonly grid: "Planner";
616
- readonly weekNumber: "Week number";
617
- readonly itemCount: "{{count}} items";
618
- };
619
- readonly commandPalette: {
620
- readonly noResults: "No results found.";
621
- readonly search: "Search...";
622
- readonly hints: {
623
- readonly navigate: "Navigate";
624
- readonly select: "Select";
625
- readonly close: "Close";
626
- };
627
- };
628
- readonly compositionBar: {
629
- readonly total: "Total";
630
- readonly summary: "Composition: {{total}} ({{count}} shares) — {{parts}}";
631
- readonly share: "Share";
632
- readonly value: "Value";
633
- readonly percent: "Percent";
634
- readonly remaining: "remaining";
635
- };
636
- readonly guide: {
637
- readonly next: "Next";
638
- readonly previous: "Back";
639
- readonly skip: "Skip tour";
640
- readonly done: "Done";
641
- readonly step: "Step {{current}} of {{total}}";
642
- readonly tour: "Guided tour";
643
- readonly close: "Close";
644
- readonly openHelp: "Help";
645
- readonly backToList: "All topics";
646
- readonly info: "More information";
647
- readonly infoAbout: "More information about {{label}}";
648
- readonly dismiss: "Dismiss hint";
649
- readonly startTour: "Start the guided tour";
650
- readonly actionRequired: "Complete the highlighted action to continue";
651
- };
652
- readonly datepicker: {
653
- readonly placeholder: "Select a date...";
654
- readonly rangePlaceholder: "Select a date range...";
655
- readonly clear: "Clear selection";
656
- readonly openCalendar: "Open calendar";
657
- readonly invalidDate: "Invalid date";
658
- readonly outOfRange: "Date is outside the allowed range";
659
- readonly invalidRange: "Invalid date range";
660
- };
661
- readonly fileUpload: {
662
- readonly exists: "File already added";
663
- readonly invalidType: "File type {{type}} is not allowed";
664
- readonly tooLarge: "File exceeds {{size}} limit";
665
- readonly tooMany: "Maximum {{count}} files allowed";
666
- readonly tooSmall: "File must be at least {{size}}";
667
- };
668
- readonly sankey: {
669
- readonly summary: "Sankey diagram: {{nodes}} nodes, {{links}} links — {{flows}}";
670
- readonly source: "Source";
671
- readonly target: "Target";
672
- readonly value: "Value";
673
- };
674
- readonly slider: {
675
- readonly rangeStatus: {
676
- readonly insideRecommended: "In recommended range";
677
- readonly insideValid: "In valid range";
678
- readonly insideValidOnly: "Outside recommended range, but valid";
679
- readonly outsideValid: "Outside valid range";
680
- };
681
- };
682
- readonly themeSwitcher: {
683
- readonly lightMode: "Light mode";
684
- readonly darkMode: "Dark mode";
685
- readonly systemTheme: "System theme";
686
- };
687
- readonly time: {
688
- readonly ago: "{{value}} {{unit}} ago";
689
- readonly now: "now";
690
- };
691
- }>;
355
+ export declare const useBlocksI18n: any;
692
356
  export declare const hasBlocksTranslation: (key: string) => boolean, getBlocksLocales: () => import("@urbicon-ui/i18n").Locale[];
693
357
  export type BlocksTranslationKey = keyof typeof enTranslations;
694
358
  export { blocksTranslations };
@@ -1,12 +1,17 @@
1
1
  <script lang="ts">
2
+ import { tick } from 'svelte';
2
3
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
3
4
  import { breadcrumbVariants, type BreadcrumbVariants } from './breadcrumb.variants';
4
- import type { BreadcrumbProps } from './index';
5
+ import type { BreadcrumbProps, BreadcrumbItem } from './index';
5
6
 
6
7
  let {
7
8
  items,
8
9
  size = 'md',
9
10
  separator,
11
+ maxItems,
12
+ itemsBeforeCollapse = 1,
13
+ itemsAfterCollapse = 1,
14
+ expandLabel = 'Show all breadcrumb items',
10
15
  'aria-label': ariaLabel = 'Breadcrumb',
11
16
  class: className = '',
12
17
  unstyled: unstyledProp = false,
@@ -25,9 +30,62 @@
25
30
  const slotClasses = $derived(
26
31
  resolveSlotClasses(blocksConfig, 'Breadcrumb', preset, variantProps, slotClassesProp)
27
32
  );
33
+
34
+ // Long trails collapse their middle into a single "…" affordance. Clicking
35
+ // it expands the full trail for the rest of the component's life — the
36
+ // established expandable-breadcrumb pattern. `before`/`after` clamp to sane
37
+ // bounds and the current page (last item) is always kept visible.
38
+ let expanded = $state(false);
39
+ let navEl = $state<HTMLElement>();
40
+
41
+ const before = $derived(Math.max(0, itemsBeforeCollapse));
42
+ const after = $derived(Math.max(1, itemsAfterCollapse));
43
+
44
+ type BreadcrumbEntry =
45
+ | { kind: 'item'; item: BreadcrumbItem; current: boolean }
46
+ | { kind: 'ellipsis' };
47
+
48
+ const entries = $derived.by((): BreadcrumbEntry[] => {
49
+ const last = items.length - 1;
50
+ const collapse =
51
+ maxItems != null &&
52
+ maxItems > 0 &&
53
+ !expanded &&
54
+ items.length > maxItems &&
55
+ // Only worth it when "…" actually replaces ≥2 items — folding a single
56
+ // hidden item saves no space and just adds an interaction.
57
+ items.length - before - after >= 2;
58
+
59
+ if (!collapse) {
60
+ return items.map((item, i) => ({ kind: 'item' as const, item, current: i === last }));
61
+ }
62
+
63
+ const result: BreadcrumbEntry[] = [];
64
+ for (let i = 0; i < before; i++) {
65
+ result.push({ kind: 'item', item: items[i], current: false });
66
+ }
67
+ result.push({ kind: 'ellipsis' });
68
+ for (let i = items.length - after; i < items.length; i++) {
69
+ result.push({ kind: 'item', item: items[i], current: i === last });
70
+ }
71
+ return result;
72
+ });
73
+
74
+ function expand() {
75
+ expanded = true;
76
+ // Keep keyboard focus inside the trail: land on the first item that was
77
+ // hidden (now revealed) rather than dropping to the top of the page when
78
+ // the "…" button unmounts. Head items are links 0…before-1, so the first
79
+ // revealed item is link index `before`.
80
+ const target = before;
81
+ tick().then(() => {
82
+ navEl?.querySelectorAll<HTMLAnchorElement>('a')[target]?.focus();
83
+ });
84
+ }
28
85
  </script>
29
86
 
30
87
  <nav
88
+ bind:this={navEl}
31
89
  class={unstyled
32
90
  ? [slotClasses?.nav, className].filter(Boolean).join(' ')
33
91
  : styles.nav({ class: [slotClasses?.nav, className] })}
@@ -35,26 +93,39 @@
35
93
  {...restProps}
36
94
  >
37
95
  <ol class={unstyled ? (slotClasses?.list ?? '') : styles.list({ class: slotClasses?.list })}>
38
- {#each items as item, i (`${item.label}-${i}`)}
96
+ {#each entries as entry, i (entry.kind === 'ellipsis' ? 'ellipsis' : `${entry.item.label}-${i}`)}
39
97
  <li class={unstyled ? (slotClasses?.item ?? '') : styles.item({ class: slotClasses?.item })}>
40
- {#if i === items.length - 1}
98
+ {#if entry.kind === 'ellipsis'}
99
+ <button
100
+ type="button"
101
+ class={unstyled
102
+ ? (slotClasses?.ellipsis ?? '')
103
+ : styles.ellipsis({ class: slotClasses?.ellipsis })}
104
+ aria-label={expandLabel}
105
+ onclick={expand}
106
+ >
107
+
108
+ </button>
109
+ {:else if entry.current}
41
110
  <span
42
111
  class={unstyled
43
112
  ? (slotClasses?.currentPage ?? '')
44
113
  : styles.currentPage({ class: slotClasses?.currentPage })}
45
- aria-current="page">{item.label}</span
114
+ aria-current="page">{entry.item.label}</span
46
115
  >
47
116
  {:else}
48
117
  <!-- eslint-disable svelte/no-navigation-without-resolve -- BreadcrumbItem.href is opaque to the library -->
49
118
  <a
50
- href={item.href}
119
+ href={entry.item.href}
51
120
  class={unstyled ? (slotClasses?.link ?? '') : styles.link({ class: slotClasses?.link })}
52
- aria-label={item['aria-label']}
53
- onclick={item.onclick}
121
+ aria-label={entry.item['aria-label']}
122
+ onclick={entry.item.onclick}
54
123
  >
55
- {item.label}
124
+ {entry.item.label}
56
125
  </a>
57
126
  <!-- eslint-enable svelte/no-navigation-without-resolve -->
127
+ {/if}
128
+ {#if i < entries.length - 1}
58
129
  <span
59
130
  class={unstyled
60
131
  ? (slotClasses?.separator ?? '')
@@ -45,6 +45,12 @@ export declare const breadcrumbVariants: (props?: import("../../utils/variants.j
45
45
  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
46
  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
47
  }) | undefined) => string;
48
+ ellipsis: (props?: ({
49
+ size?: "sm" | "md" | "lg" | undefined;
50
+ } & {
51
+ 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
+ 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
+ }) | undefined) => string;
48
54
  separator: (props?: ({
49
55
  size?: "sm" | "md" | "lg" | undefined;
50
56
  } & {
@@ -11,6 +11,14 @@ export const breadcrumbVariants = tv({
11
11
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:rounded-sm'
12
12
  ],
13
13
  currentPage: 'font-medium text-text-primary truncate max-w-48',
14
+ // Collapse affordance: the "…" button shown in place of the folded middle
15
+ // items. Styled as a quiet link; expands the full trail on click.
16
+ ellipsis: [
17
+ 'inline-flex items-center justify-center rounded-sm px-1',
18
+ 'text-text-tertiary hover:text-text-primary cursor-pointer',
19
+ 'transition-colors duration-[var(--blocks-duration-fast)]',
20
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50'
21
+ ],
14
22
  separator: 'mx-2 text-text-tertiary select-none'
15
23
  },
16
24
  variants: {
@@ -41,6 +41,11 @@ export interface BreadcrumbItem {
41
41
  * {#snippet separator()}<ChevronRight size={14} />{/snippet}
42
42
  * </Breadcrumb>
43
43
  * ```
44
+ *
45
+ * @example Collapse a long trail — middle items fold into an expandable "…"
46
+ * ```svelte
47
+ * <Breadcrumb items={deepTrail} maxItems={4} />
48
+ * ```
44
49
  */
45
50
  export interface BreadcrumbProps extends BreadcrumbVariants, Omit<HTMLAttributes<HTMLElement>, 'children'> {
46
51
  /** Ordered breadcrumb items (last item is current page) */
@@ -49,6 +54,19 @@ export interface BreadcrumbProps extends BreadcrumbVariants, Omit<HTMLAttributes
49
54
  size?: 'sm' | 'md' | 'lg';
50
55
  /** Custom separator snippet (default: "/") */
51
56
  separator?: Snippet;
57
+ /**
58
+ * Collapse the trail when it has more than this many items: the middle items
59
+ * fold into a single "…" button that expands the full trail on click. The
60
+ * first `itemsBeforeCollapse` and last `itemsAfterCollapse` items stay
61
+ * visible (the current page is always kept). Omit to never collapse.
62
+ */
63
+ maxItems?: number;
64
+ /** Leading items kept visible when collapsed. @default 1 */
65
+ itemsBeforeCollapse?: number;
66
+ /** Trailing items kept visible when collapsed; the current page is always included. @default 1 */
67
+ itemsAfterCollapse?: number;
68
+ /** Accessible label for the "…" button that expands a collapsed trail. @default 'Show all breadcrumb items' */
69
+ expandLabel?: string;
52
70
  /** Accessible label for the nav element @default 'Breadcrumb' */
53
71
  'aria-label'?: string;
54
72
  /** Custom CSS class */
@@ -231,7 +231,7 @@
231
231
  }
232
232
  }
233
233
 
234
- function handleClickOutside(event: MouseEvent) {
234
+ function handleClickOutside(event: PointerEvent) {
235
235
  const target = event.target as Node;
236
236
  // Exclude the whole input wrapper (input + chevron toggle + clear button),
237
237
  // so clicking the chevron to close doesn't race this handler into a
@@ -248,8 +248,12 @@
248
248
 
249
249
  $effect(() => {
250
250
  if (open) {
251
- document.addEventListener('mousedown', handleClickOutside);
252
- return () => document.removeEventListener('mousedown', handleClickOutside);
251
+ // `pointerdown` (not `mousedown`) for reliable outside-dismiss on touch:
252
+ // iOS fires `pointerdown` immediately on tap, whereas a synthetic
253
+ // `mousedown` can be delayed or dropped when the tapped element is the
254
+ // moving top-layer popover. Mirrors Popover's manual-mode dismiss.
255
+ document.addEventListener('pointerdown', handleClickOutside);
256
+ return () => document.removeEventListener('pointerdown', handleClickOutside);
253
257
  }
254
258
  });
255
259
 
@@ -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',
@@ -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
@@ -373,7 +373,7 @@
373
373
  }
374
374
  }
375
375
 
376
- function handleClickOutside(event: MouseEvent) {
376
+ function handleClickOutside(event: PointerEvent) {
377
377
  const target = event.target as Node;
378
378
  if (
379
379
  open &&
@@ -407,7 +407,7 @@
407
407
  const hasSelection = $derived(multiple ? selectedOptions.length > 0 : selectedOption !== null);
408
408
  </script>
409
409
 
410
- <svelte:window onclick={handleClickOutside} />
410
+ <svelte:window onpointerdown={handleClickOutside} />
411
411
 
412
412
  <div
413
413
  class={unstyled
@@ -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',
@@ -240,8 +240,21 @@ export function size(options) {
240
240
  name: 'size',
241
241
  fn(state) {
242
242
  const overflow = detectOverflow(state);
243
- const availableWidth = state.rects.floating.width - Math.max(0, overflow.left) - Math.max(0, overflow.right);
244
- const availableHeight = state.rects.floating.height - Math.max(0, overflow.top) - Math.max(0, overflow.bottom);
243
+ const side = getSide(state.placement);
244
+ const vertical = side === 'top' || side === 'bottom';
245
+ // Room in the placement's MAIN axis is measured anchor → viewport edge:
246
+ // `dimension - overflow[side]` cancels the floating element's own
247
+ // dimension, so the value reflects true available room and grows back
248
+ // once space is restored. Subtracting the *clamped* overflow of both
249
+ // edges instead would only ever ratchet down — a one-way "latch" that
250
+ // keeps the panel short after the iOS keyboard closes. The CROSS axis
251
+ // keeps the "what currently fits between both edges" measure.
252
+ const availableWidth = vertical
253
+ ? state.rects.floating.width - Math.max(0, overflow.left) - Math.max(0, overflow.right)
254
+ : state.rects.floating.width - overflow[side];
255
+ const availableHeight = vertical
256
+ ? state.rects.floating.height - overflow[side]
257
+ : state.rects.floating.height - Math.max(0, overflow.top) - Math.max(0, overflow.bottom);
245
258
  options.apply({ ...state, availableWidth, availableHeight });
246
259
  const newW = state.elements.floating.offsetWidth;
247
260
  const newH = state.elements.floating.offsetHeight;
@@ -284,6 +297,21 @@ export function autoUpdate(reference, floating, callback) {
284
297
  }
285
298
  window.addEventListener('resize', callback);
286
299
  cleanups.push(() => window.removeEventListener('resize', callback));
300
+ // iOS Safari resizes and offsets the *visual* viewport when the on-screen
301
+ // keyboard opens (and during pinch-zoom / overscroll) WITHOUT firing a
302
+ // `window` 'resize' or 'scroll'. A `position: fixed` overlay would otherwise
303
+ // stay pinned to stale coordinates — visibly detaching from its anchor and
304
+ // drifting as the page settles. Tracking the visualViewport keeps anchored
305
+ // overlays glued to their reference on touch devices.
306
+ const vv = window.visualViewport;
307
+ if (vv) {
308
+ vv.addEventListener('resize', callback);
309
+ vv.addEventListener('scroll', callback, { passive: true });
310
+ cleanups.push(() => {
311
+ vv.removeEventListener('resize', callback);
312
+ vv.removeEventListener('scroll', callback);
313
+ });
314
+ }
287
315
  return () => {
288
316
  for (const fn of cleanups)
289
317
  fn();
@@ -21,6 +21,9 @@ export function useFloatingListbox(opts) {
21
21
  if (!isOpen) {
22
22
  cleanupPosition?.();
23
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');
24
27
  if (usePortal && floating.matches(':popover-open')) {
25
28
  try {
26
29
  floating.hidePopover();
@@ -50,15 +53,20 @@ export function useFloatingListbox(opts) {
50
53
  offset(4),
51
54
  flip(),
52
55
  shift({ padding: 8 }),
53
- ...(syncWidth
54
- ? [
55
- floatingSize({
56
- apply({ rects }) {
57
- floating.style.width = `${rects.reference.width}px`;
58
- }
59
- })
60
- ]
61
- : [])
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
+ })
62
70
  ]
63
71
  })
64
72
  .then(({ x, y }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.3.6",
3
+ "version": "6.3.7",
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.6",
95
- "@urbicon-ui/shared-types": "6.3.6",
94
+ "@urbicon-ui/i18n": "6.3.7",
95
+ "@urbicon-ui/shared-types": "6.3.7",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",