@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.
@@ -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
- 'w-full border-0 bg-transparent py-3 text-sm',
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',
@@ -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";
@@ -337,174 +339,7 @@ declare const blocksTranslations: {
337
339
  };
338
340
  };
339
341
  };
340
- export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
341
- readonly accessibility: {
342
- readonly avatar: "Avatar";
343
- readonly clearInput: "Clear input";
344
- readonly clearSearch: "Clear search";
345
- readonly clearSelection: "Clear selection";
346
- readonly closeDialog: "Close dialog";
347
- readonly closeDrawer: "Close drawer";
348
- readonly dismiss: "Dismiss";
349
- readonly fileUpload: "File upload";
350
- readonly loading: "Loading";
351
- readonly maximum: "Maximum";
352
- readonly minimum: "Minimum";
353
- readonly pagination: "Page navigation";
354
- readonly progress: "Progress";
355
- readonly slider: "Slider";
356
- readonly toggle: "Toggle";
357
- readonly removableBadge: "Removable badge";
358
- readonly removeBadge: "Remove badge";
359
- readonly removeFile: "Remove {{name}}";
360
- };
361
- readonly button: {
362
- readonly close: "Close";
363
- readonly apply: "Apply";
364
- readonly cancel: "Cancel";
365
- readonly confirm: "Confirm";
366
- };
367
- readonly menu: {
368
- readonly placeholder: "Select an option...";
369
- };
370
- readonly common: {
371
- readonly loading: "Loading...";
372
- };
373
- readonly languages: {
374
- readonly de: "Deutsch";
375
- readonly en: "English";
376
- readonly es: "Español";
377
- readonly fr: "Français";
378
- readonly it: "Italiano";
379
- readonly nl: "Nederlands";
380
- };
381
- readonly localeSwitcher: {
382
- readonly ariaLabel: "Language selection";
383
- readonly placeholder: "Select language...";
384
- };
385
- readonly pagination: {
386
- readonly next: "Next";
387
- readonly first: "First";
388
- readonly last: "Last";
389
- readonly page: "Page";
390
- readonly previous: "Previous";
391
- };
392
- readonly calendar: {
393
- readonly recurring: "Recurring event";
394
- readonly previousMonth: "Previous month";
395
- readonly nextMonth: "Next month";
396
- readonly previousWeek: "Previous week";
397
- readonly nextWeek: "Next week";
398
- readonly previousDay: "Previous day";
399
- readonly nextDay: "Next day";
400
- readonly previousYear: "Previous year";
401
- readonly nextYear: "Next year";
402
- readonly today: "Today";
403
- readonly calendarGrid: "Calendar";
404
- readonly weekdays: "Weekdays";
405
- readonly weekNumber: "Week number";
406
- readonly events: "Events";
407
- readonly noEvents: "No events";
408
- readonly showMore: "{{count}} more";
409
- readonly legend: "Legend";
410
- readonly yearView: "Year overview";
411
- readonly weekView: "Week view";
412
- readonly dayView: "Day view";
413
- readonly monthView: "Month view";
414
- readonly viewMonth: "Month";
415
- readonly viewYear: "Year";
416
- readonly viewWeek: "Week";
417
- readonly viewDay: "Day";
418
- readonly multiDayLabel: "Day {{current}} of {{total}}";
419
- readonly agendaView: "Agenda view";
420
- readonly viewAgenda: "Agenda";
421
- readonly viewSwitcher: "View mode";
422
- };
423
- readonly planner: {
424
- readonly previousWeek: "Previous week";
425
- readonly nextWeek: "Next week";
426
- readonly previousMonth: "Previous month";
427
- readonly nextMonth: "Next month";
428
- readonly previousRange: "Previous range";
429
- readonly nextRange: "Next range";
430
- readonly today: "Today";
431
- readonly grid: "Planner";
432
- readonly weekNumber: "Week number";
433
- readonly itemCount: "{{count}} items";
434
- };
435
- readonly commandPalette: {
436
- readonly noResults: "No results found.";
437
- readonly search: "Search...";
438
- readonly hints: {
439
- readonly navigate: "Navigate";
440
- readonly select: "Select";
441
- readonly close: "Close";
442
- };
443
- };
444
- readonly compositionBar: {
445
- readonly total: "Total";
446
- readonly summary: "Composition: {{total}} ({{count}} shares) — {{parts}}";
447
- readonly share: "Share";
448
- readonly value: "Value";
449
- readonly percent: "Percent";
450
- readonly remaining: "remaining";
451
- };
452
- readonly guide: {
453
- readonly next: "Next";
454
- readonly previous: "Back";
455
- readonly skip: "Skip tour";
456
- readonly done: "Done";
457
- readonly step: "Step {{current}} of {{total}}";
458
- readonly tour: "Guided tour";
459
- readonly close: "Close";
460
- readonly openHelp: "Help";
461
- readonly backToList: "All topics";
462
- readonly info: "More information";
463
- readonly infoAbout: "More information about {{label}}";
464
- readonly dismiss: "Dismiss hint";
465
- readonly startTour: "Start the guided tour";
466
- readonly actionRequired: "Complete the highlighted action to continue";
467
- };
468
- readonly datepicker: {
469
- readonly placeholder: "Select a date...";
470
- readonly rangePlaceholder: "Select a date range...";
471
- readonly clear: "Clear selection";
472
- readonly openCalendar: "Open calendar";
473
- readonly invalidDate: "Invalid date";
474
- readonly outOfRange: "Date is outside the allowed range";
475
- readonly invalidRange: "Invalid date range";
476
- };
477
- readonly fileUpload: {
478
- readonly exists: "File already added";
479
- readonly invalidType: "File type {{type}} is not allowed";
480
- readonly tooLarge: "File exceeds {{size}} limit";
481
- readonly tooMany: "Maximum {{count}} files allowed";
482
- readonly tooSmall: "File must be at least {{size}}";
483
- };
484
- readonly sankey: {
485
- readonly summary: "Sankey diagram: {{nodes}} nodes, {{links}} links — {{flows}}";
486
- readonly source: "Source";
487
- readonly target: "Target";
488
- readonly value: "Value";
489
- };
490
- readonly slider: {
491
- readonly rangeStatus: {
492
- readonly insideRecommended: "In recommended range";
493
- readonly insideValid: "In valid range";
494
- readonly insideValidOnly: "Outside recommended range, but valid";
495
- readonly outsideValid: "Outside valid range";
496
- };
497
- };
498
- readonly themeSwitcher: {
499
- readonly lightMode: "Light mode";
500
- readonly darkMode: "Dark mode";
501
- readonly systemTheme: "System theme";
502
- };
503
- readonly time: {
504
- readonly ago: "{{value}} {{unit}} ago";
505
- readonly now: "now";
506
- };
507
- }>;
342
+ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<import("@urbicon-ui/i18n").Translations>;
508
343
  /**
509
344
  * Context-scoped translation hook for the blocks package. Call during component
510
345
  * initialisation, then use the returned `t` (conventionally aliased `bt`):
@@ -517,174 +352,7 @@ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
517
352
  * Resolves against the nearest `<I18nProvider>`'s locale (or the base locale
518
353
  * when none is mounted) and re-renders reactively on locale change.
519
354
  */
520
- export declare const useBlocksI18n: () => import("@urbicon-ui/i18n").TypedTranslationFunction<{
521
- readonly accessibility: {
522
- readonly avatar: "Avatar";
523
- readonly clearInput: "Clear input";
524
- readonly clearSearch: "Clear search";
525
- readonly clearSelection: "Clear selection";
526
- readonly closeDialog: "Close dialog";
527
- readonly closeDrawer: "Close drawer";
528
- readonly dismiss: "Dismiss";
529
- readonly fileUpload: "File upload";
530
- readonly loading: "Loading";
531
- readonly maximum: "Maximum";
532
- readonly minimum: "Minimum";
533
- readonly pagination: "Page navigation";
534
- readonly progress: "Progress";
535
- readonly slider: "Slider";
536
- readonly toggle: "Toggle";
537
- readonly removableBadge: "Removable badge";
538
- readonly removeBadge: "Remove badge";
539
- readonly removeFile: "Remove {{name}}";
540
- };
541
- readonly button: {
542
- readonly close: "Close";
543
- readonly apply: "Apply";
544
- readonly cancel: "Cancel";
545
- readonly confirm: "Confirm";
546
- };
547
- readonly menu: {
548
- readonly placeholder: "Select an option...";
549
- };
550
- readonly common: {
551
- readonly loading: "Loading...";
552
- };
553
- readonly languages: {
554
- readonly de: "Deutsch";
555
- readonly en: "English";
556
- readonly es: "Español";
557
- readonly fr: "Français";
558
- readonly it: "Italiano";
559
- readonly nl: "Nederlands";
560
- };
561
- readonly localeSwitcher: {
562
- readonly ariaLabel: "Language selection";
563
- readonly placeholder: "Select language...";
564
- };
565
- readonly pagination: {
566
- readonly next: "Next";
567
- readonly first: "First";
568
- readonly last: "Last";
569
- readonly page: "Page";
570
- readonly previous: "Previous";
571
- };
572
- readonly calendar: {
573
- readonly recurring: "Recurring event";
574
- readonly previousMonth: "Previous month";
575
- readonly nextMonth: "Next month";
576
- readonly previousWeek: "Previous week";
577
- readonly nextWeek: "Next week";
578
- readonly previousDay: "Previous day";
579
- readonly nextDay: "Next day";
580
- readonly previousYear: "Previous year";
581
- readonly nextYear: "Next year";
582
- readonly today: "Today";
583
- readonly calendarGrid: "Calendar";
584
- readonly weekdays: "Weekdays";
585
- readonly weekNumber: "Week number";
586
- readonly events: "Events";
587
- readonly noEvents: "No events";
588
- readonly showMore: "{{count}} more";
589
- readonly legend: "Legend";
590
- readonly yearView: "Year overview";
591
- readonly weekView: "Week view";
592
- readonly dayView: "Day view";
593
- readonly monthView: "Month view";
594
- readonly viewMonth: "Month";
595
- readonly viewYear: "Year";
596
- readonly viewWeek: "Week";
597
- readonly viewDay: "Day";
598
- readonly multiDayLabel: "Day {{current}} of {{total}}";
599
- readonly agendaView: "Agenda view";
600
- readonly viewAgenda: "Agenda";
601
- readonly viewSwitcher: "View mode";
602
- };
603
- readonly planner: {
604
- readonly previousWeek: "Previous week";
605
- readonly nextWeek: "Next week";
606
- readonly previousMonth: "Previous month";
607
- readonly nextMonth: "Next month";
608
- readonly previousRange: "Previous range";
609
- readonly nextRange: "Next range";
610
- readonly today: "Today";
611
- readonly grid: "Planner";
612
- readonly weekNumber: "Week number";
613
- readonly itemCount: "{{count}} items";
614
- };
615
- readonly commandPalette: {
616
- readonly noResults: "No results found.";
617
- readonly search: "Search...";
618
- readonly hints: {
619
- readonly navigate: "Navigate";
620
- readonly select: "Select";
621
- readonly close: "Close";
622
- };
623
- };
624
- readonly compositionBar: {
625
- readonly total: "Total";
626
- readonly summary: "Composition: {{total}} ({{count}} shares) — {{parts}}";
627
- readonly share: "Share";
628
- readonly value: "Value";
629
- readonly percent: "Percent";
630
- readonly remaining: "remaining";
631
- };
632
- readonly guide: {
633
- readonly next: "Next";
634
- readonly previous: "Back";
635
- readonly skip: "Skip tour";
636
- readonly done: "Done";
637
- readonly step: "Step {{current}} of {{total}}";
638
- readonly tour: "Guided tour";
639
- readonly close: "Close";
640
- readonly openHelp: "Help";
641
- readonly backToList: "All topics";
642
- readonly info: "More information";
643
- readonly infoAbout: "More information about {{label}}";
644
- readonly dismiss: "Dismiss hint";
645
- readonly startTour: "Start the guided tour";
646
- readonly actionRequired: "Complete the highlighted action to continue";
647
- };
648
- readonly datepicker: {
649
- readonly placeholder: "Select a date...";
650
- readonly rangePlaceholder: "Select a date range...";
651
- readonly clear: "Clear selection";
652
- readonly openCalendar: "Open calendar";
653
- readonly invalidDate: "Invalid date";
654
- readonly outOfRange: "Date is outside the allowed range";
655
- readonly invalidRange: "Invalid date range";
656
- };
657
- readonly fileUpload: {
658
- readonly exists: "File already added";
659
- readonly invalidType: "File type {{type}} is not allowed";
660
- readonly tooLarge: "File exceeds {{size}} limit";
661
- readonly tooMany: "Maximum {{count}} files allowed";
662
- readonly tooSmall: "File must be at least {{size}}";
663
- };
664
- readonly sankey: {
665
- readonly summary: "Sankey diagram: {{nodes}} nodes, {{links}} links — {{flows}}";
666
- readonly source: "Source";
667
- readonly target: "Target";
668
- readonly value: "Value";
669
- };
670
- readonly slider: {
671
- readonly rangeStatus: {
672
- readonly insideRecommended: "In recommended range";
673
- readonly insideValid: "In valid range";
674
- readonly insideValidOnly: "Outside recommended range, but valid";
675
- readonly outsideValid: "Outside valid range";
676
- };
677
- };
678
- readonly themeSwitcher: {
679
- readonly lightMode: "Light mode";
680
- readonly darkMode: "Dark mode";
681
- readonly systemTheme: "System theme";
682
- };
683
- readonly time: {
684
- readonly ago: "{{value}} {{unit}} ago";
685
- readonly now: "now";
686
- };
687
- }>;
355
+ export declare const useBlocksI18n: any;
688
356
  export declare const hasBlocksTranslation: (key: string) => boolean, getBlocksLocales: () => import("@urbicon-ui/i18n").Locale[];
689
357
  export type BlocksTranslationKey = keyof typeof enTranslations;
690
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 */
@@ -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;
@@ -212,9 +231,12 @@
212
231
  }
213
232
  }
214
233
 
215
- function handleClickOutside(event: MouseEvent) {
234
+ function handleClickOutside(event: PointerEvent) {
216
235
  const target = event.target as Node;
217
- if (!inputEl?.contains(target) && !listboxEl?.contains(target)) {
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) {
@@ -226,8 +248,12 @@
226
248
 
227
249
  $effect(() => {
228
250
  if (open) {
229
- document.addEventListener('mousedown', handleClickOutside);
230
- 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);
231
257
  }
232
258
  });
233
259
 
@@ -273,6 +299,7 @@
273
299
  {/if}
274
300
 
275
301
  <div
302
+ bind:this={wrapperEl}
276
303
  class={unstyled
277
304
  ? (slotClasses?.inputWrapper ?? '')
278
305
  : styles.inputWrapper({ class: slotClasses?.inputWrapper })}
@@ -309,11 +336,25 @@
309
336
  <CloseIcon class="h-3.5 w-3.5" />
310
337
  </button>
311
338
  {:else}
312
- <ChevronDownIcon
339
+ <button
340
+ type="button"
341
+ tabindex={-1}
342
+ {disabled}
343
+ aria-label={bt('accessibility.toggleOptions')}
344
+ aria-controls={listboxId}
345
+ aria-expanded={open}
346
+ onmousedown={(e) => e.preventDefault()}
347
+ onclick={toggleOpen}
313
348
  class={unstyled
314
- ? (slotClasses?.chevron ?? '')
315
- : styles.chevron({ class: slotClasses?.chevron })}
316
- />
349
+ ? (slotClasses?.chevronButton ?? '')
350
+ : styles.chevronButton({ class: slotClasses?.chevronButton })}
351
+ >
352
+ <ChevronDownIcon
353
+ class={unstyled
354
+ ? (slotClasses?.chevron ?? '')
355
+ : styles.chevron({ class: slotClasses?.chevron })}
356
+ />
357
+ </button>
317
358
  {/if}
318
359
 
319
360
  {#if name}