@joewinke/jatui 0.39.0 → 0.40.0

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.
Files changed (41) hide show
  1. package/CLAUDE.md +23 -5
  2. package/THIRD_PARTY_NOTICES.md +16 -0
  3. package/dist/actions/scrollEdge.d.ts +55 -0
  4. package/dist/actions/scrollEdge.js +144 -0
  5. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
  6. package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
  7. package/dist/components/BottomSheet.svelte +7 -6
  8. package/dist/components/BottomSheet.svelte.d.ts +2 -0
  9. package/dist/components/CloseButton.svelte +75 -0
  10. package/dist/components/CloseButton.svelte.d.ts +29 -0
  11. package/dist/components/Drawer.svelte +10 -11
  12. package/dist/components/Drawer.svelte.d.ts +2 -8
  13. package/dist/components/IconButton.svelte +200 -0
  14. package/dist/components/IconButton.svelte.d.ts +46 -0
  15. package/dist/components/LinkButton.svelte +117 -0
  16. package/dist/components/LinkButton.svelte.d.ts +32 -0
  17. package/dist/components/MediaLightbox.svelte +2 -7
  18. package/dist/components/Modal.svelte +2 -10
  19. package/dist/components/PinInput.svelte +94 -31
  20. package/dist/components/PinInput.svelte.d.ts +4 -0
  21. package/dist/components/TextArea.svelte +67 -5
  22. package/dist/components/TextArea.svelte.d.ts +4 -6
  23. package/dist/components/ThemeSelector.svelte +29 -9
  24. package/dist/components/ThemeSelector.svelte.d.ts +6 -0
  25. package/dist/components/ThemeToggle.svelte +28 -3
  26. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  27. package/dist/components/basic/Breadcrumb.svelte +226 -11
  28. package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
  29. package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
  30. package/dist/components/basic/breadcrumbCollapse.js +12 -0
  31. package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
  32. package/dist/index.d.ts +14 -1
  33. package/dist/index.js +10 -1
  34. package/dist/styles/tokens.css +80 -1
  35. package/dist/utils/pinInput.d.ts +33 -0
  36. package/dist/utils/pinInput.js +63 -0
  37. package/dist/utils/textareaRows.d.ts +23 -0
  38. package/dist/utils/textareaRows.js +13 -0
  39. package/dist/utils/themeWipe.d.ts +36 -17
  40. package/dist/utils/themeWipe.js +145 -35
  41. package/package.json +1 -1
@@ -4,7 +4,14 @@
4
4
  *
5
5
  * Multi-line text input with validation, auto-resize, and character count.
6
6
  * Converted from Svelte 4 to Svelte 5 runes.
7
+ *
8
+ * Auto-grow (`autoResize`, or implied by `maxRows`) grows a whole line at a
9
+ * time from `minRows` (default `rows`) up to `maxRows`, then scrolls. Where
10
+ * the browser supports CSS `field-sizing: content` the growth is pure CSS
11
+ * (no per-keystroke measuring); elsewhere it falls back to the row-quantized
12
+ * JS path in utils/textareaRows.ts.
7
13
  */
14
+ import { textareaRowsHeight } from '../utils/textareaRows';
8
15
 
9
16
  interface Props {
10
17
  label?: string;
@@ -17,6 +24,10 @@
17
24
  disabled?: boolean;
18
25
  readonly?: boolean;
19
26
  autoResize?: boolean;
27
+ /** Auto-grow floor in rows. Default `rows`. */
28
+ minRows?: number;
29
+ /** Auto-grow ceiling in rows; past it the field scrolls. Setting it turns auto-grow on. */
30
+ maxRows?: number;
20
31
  error?: string;
21
32
  helpText?: string;
22
33
  showCount?: boolean;
@@ -39,6 +50,8 @@
39
50
  disabled = false,
40
51
  readonly = false,
41
52
  autoResize = false,
53
+ minRows,
54
+ maxRows,
42
55
  error = '',
43
56
  helpText = '',
44
57
  showCount = false,
@@ -73,24 +86,73 @@
73
86
  const characterCount = $derived(value?.length || 0);
74
87
  const isOverLimit = $derived(maxlength ? characterCount > maxlength : false);
75
88
 
89
+ const grows = $derived(autoResize || maxRows != null);
90
+ const floorRows = $derived(Math.max(1, minRows ?? rows));
91
+ // Decided after mount: SSR has no CSS.supports, and hydration does not
92
+ // repair a style attribute that differs from the server render.
93
+ let fieldSizing = $state(false);
94
+ $effect(() => {
95
+ fieldSizing = typeof CSS !== 'undefined' && CSS.supports?.('field-sizing', 'content') === true;
96
+ });
97
+
98
+ // Vertical padding + border, measured once mounted (border-box sizing).
99
+ let chromePx = $state(18);
100
+ let paddingPx = $state(16);
101
+ let lineHeightPx = $state(24);
102
+
103
+ function measureChrome(el: HTMLTextAreaElement) {
104
+ const cs = getComputedStyle(el);
105
+ const n = (v: string) => parseFloat(v) || 0;
106
+ paddingPx = n(cs.paddingTop) + n(cs.paddingBottom);
107
+ chromePx = paddingPx + n(cs.borderTopWidth) + n(cs.borderBottomWidth);
108
+ const lh = n(cs.lineHeight);
109
+ lineHeightPx = lh > 0 ? lh : n(cs.fontSize) * 1.5 || 24;
110
+ }
111
+
76
112
  function handleInput(event: Event) {
77
113
  const target = event.target as HTMLTextAreaElement;
78
114
  value = target.value;
79
- if (autoResize) autoResizeTextarea(target);
115
+ if (grows && !fieldSizing) autoResizeTextarea(target);
80
116
  oninput?.(value);
81
117
  }
82
118
 
83
119
  function autoResizeTextarea(textarea: HTMLTextAreaElement) {
84
120
  textarea.style.height = 'auto';
85
- textarea.style.height = textarea.scrollHeight + 'px';
121
+ const r = textareaRowsHeight({
122
+ scrollHeight: textarea.scrollHeight,
123
+ lineHeight: lineHeightPx,
124
+ chrome: chromePx,
125
+ padding: paddingPx,
126
+ minRows: floorRows,
127
+ maxRows
128
+ });
129
+ textarea.style.height = r.height + 'px';
130
+ textarea.style.overflowY = r.overflow;
86
131
  }
87
132
 
88
133
  $effect(() => {
89
- if (autoResize && textareaElement && value !== undefined) {
134
+ if (grows && textareaElement) measureChrome(textareaElement);
135
+ });
136
+
137
+ $effect(() => {
138
+ if (grows && !fieldSizing && textareaElement && value !== undefined) {
139
+ void floorRows;
140
+ void maxRows;
90
141
  autoResizeTextarea(textareaElement);
91
142
  }
92
143
  });
93
144
 
145
+ const growStyle = $derived.by(() => {
146
+ if (!grows) return '';
147
+ const min = `min-height: ${Math.round(floorRows * lineHeightPx + chromePx)}px;`;
148
+ if (!fieldSizing) return min;
149
+ const max =
150
+ maxRows != null
151
+ ? ` max-height: ${Math.round(Math.max(floorRows, maxRows) * lineHeightPx + chromePx)}px; overflow-y: auto;`
152
+ : '';
153
+ return `field-sizing: content; ${min}${max}`;
154
+ });
155
+
94
156
  // Error-state shake (transitions.dev). Replays `t-input-shake` when `error`
95
157
  // becomes set. See TextInput for the full rationale.
96
158
  let hadError = false;
@@ -127,7 +189,7 @@
127
189
  {id}
128
190
  {name}
129
191
  {placeholder}
130
- {rows}
192
+ rows={grows ? floorRows : rows}
131
193
  {maxlength}
132
194
  {minlength}
133
195
  {required}
@@ -138,7 +200,7 @@
138
200
  class={textareaClass}
139
201
  aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
140
202
  aria-invalid={error ? 'true' : 'false'}
141
- style={autoResize ? `min-height: calc(1.5em * ${rows} + 1rem);` : ''}
203
+ style={growStyle}
142
204
  onanimationend={() => textareaElement?.classList.remove('t-input-shake')}
143
205
  ></textarea>
144
206
 
@@ -1,9 +1,3 @@
1
- /**
2
- * TextArea Component
3
- *
4
- * Multi-line text input with validation, auto-resize, and character count.
5
- * Converted from Svelte 4 to Svelte 5 runes.
6
- */
7
1
  interface Props {
8
2
  label?: string;
9
3
  value?: string;
@@ -15,6 +9,10 @@ interface Props {
15
9
  disabled?: boolean;
16
10
  readonly?: boolean;
17
11
  autoResize?: boolean;
12
+ /** Auto-grow floor in rows. Default `rows`. */
13
+ minRows?: number;
14
+ /** Auto-grow ceiling in rows; past it the field scrolls. Setting it turns auto-grow on. */
15
+ maxRows?: number;
18
16
  error?: string;
19
17
  helpText?: string;
20
18
  showCount?: boolean;
@@ -6,6 +6,7 @@
6
6
  * By default includes all 33 standard DaisyUI themes. Pass custom `themes` prop to override.
7
7
  */
8
8
  import { onMount } from 'svelte';
9
+ import { themeWipe } from '../utils/themeWipe';
9
10
 
10
11
  interface ThemeOption {
11
12
  name: string;
@@ -55,6 +56,7 @@
55
56
  onThemeChange,
56
57
  compact = false,
57
58
  inline = false,
59
+ reveal = false,
58
60
  }: {
59
61
  dropUp?: boolean;
60
62
  themes?: ThemeOption[];
@@ -65,6 +67,12 @@
65
67
  compact?: boolean;
66
68
  /** Render a collapsible `<details>` theme list instead of a dropdown — for embedding inside another menu. */
67
69
  inline?: boolean;
70
+ /**
71
+ * Reveal the new theme with a circular View Transition wipe from the
72
+ * clicked item (`themeWipe`). `true`/'hard' = crisp edge, 'soft' =
73
+ * feathered. Level-scaled; instant under reduced motion. Default off.
74
+ */
75
+ reveal?: boolean | 'hard' | 'soft';
68
76
  } = $props();
69
77
 
70
78
  // svelte-ignore state_referenced_locally
@@ -87,9 +95,26 @@
87
95
  }
88
96
  });
89
97
 
90
- function handleThemeChange(themeName: string) {
98
+ function applyTheme(themeName: string) {
99
+ currentTheme = themeName;
100
+ document.documentElement.setAttribute('data-theme', themeName);
101
+ localStorage.setItem(storageKey, themeName);
102
+ onThemeChange?.(themeName);
103
+ }
104
+
105
+ function handleThemeChange(themeName: string, event?: MouseEvent) {
91
106
  if (themeName === currentTheme) return;
92
107
 
108
+ if (reveal) {
109
+ // Close the menu first so the snapshot doesn't freeze it open. The
110
+ // origin (pointer, or the item's centre on keyboard) is read now.
111
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
112
+ void themeWipe(event ?? null, () => applyTheme(themeName), {
113
+ edge: reveal === 'soft' ? 'soft' : 'hard'
114
+ });
115
+ return;
116
+ }
117
+
93
118
  // Close dropdown first so the trigger is visible during animation
94
119
  if (document.activeElement instanceof HTMLElement) {
95
120
  document.activeElement.blur();
@@ -97,12 +122,7 @@
97
122
 
98
123
  isAnimating = true;
99
124
 
100
- setTimeout(() => {
101
- currentTheme = themeName;
102
- document.documentElement.setAttribute('data-theme', themeName);
103
- localStorage.setItem(storageKey, themeName);
104
- onThemeChange?.(themeName);
105
- }, 150);
125
+ setTimeout(() => applyTheme(themeName), 150);
106
126
 
107
127
  setTimeout(() => {
108
128
  isAnimating = false;
@@ -133,7 +153,7 @@
133
153
  {#each themes as theme}
134
154
  <button
135
155
  class="flex items-center gap-2 w-full px-2 py-1 rounded transition-colors hover:bg-base-300 {currentTheme === theme.name ? 'bg-primary/20' : ''}"
136
- onclick={() => handleThemeChange(theme.name)}
156
+ onclick={(e) => handleThemeChange(theme.name, e)}
137
157
  >
138
158
  <div
139
159
  data-theme={theme.name}
@@ -193,7 +213,7 @@
193
213
  <li>
194
214
  <button
195
215
  class="gap-3 px-2"
196
- onclick={() => handleThemeChange(theme.name)}
216
+ onclick={(e) => handleThemeChange(theme.name, e)}
197
217
  class:active={currentTheme === theme.name}
198
218
  >
199
219
  <div
@@ -12,6 +12,12 @@ type $$ComponentProps = {
12
12
  compact?: boolean;
13
13
  /** Render a collapsible `<details>` theme list instead of a dropdown — for embedding inside another menu. */
14
14
  inline?: boolean;
15
+ /**
16
+ * Reveal the new theme with a circular View Transition wipe from the
17
+ * clicked item (`themeWipe`). `true`/'hard' = crisp edge, 'soft' =
18
+ * feathered. Level-scaled; instant under reduced motion. Default off.
19
+ */
20
+ reveal?: boolean | 'hard' | 'soft';
15
21
  };
16
22
  declare const ThemeSelector: import("svelte").Component<$$ComponentProps, {}, "">;
17
23
  type ThemeSelector = ReturnType<typeof ThemeSelector>;
@@ -19,12 +19,16 @@
19
19
  * motion.css global floor and a component-local guard, since not every
20
20
  * consumer loads motion.css). Colours are token-only — no literals.
21
21
  */
22
+ import { flushSync } from 'svelte';
23
+ import { themeWipe } from '../utils/themeWipe';
24
+
22
25
  let {
23
26
  theme = $bindable('light'),
24
27
  lightTheme = 'light',
25
28
  darkTheme = 'dark',
26
29
  onchange,
27
- size = 'md'
30
+ size = 'md',
31
+ reveal = false
28
32
  }: {
29
33
  /** Current theme name (bindable). Anything that is not `darkTheme` reads as light. */
30
34
  theme?: string;
@@ -36,6 +40,15 @@
36
40
  onchange?: (theme: string) => void;
37
41
  /** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
38
42
  size?: 'sm' | 'md' | 'lg';
43
+ /**
44
+ * Reveal the new theme with a circular View Transition wipe from the
45
+ * button (`themeWipe`): `true`/'hard' = crisp edge, 'soft' = feathered.
46
+ * The flip (bound `theme` + `onchange`) runs inside the transition, so
47
+ * the consumer's theme write must be synchronous in `onchange` or in an
48
+ * effect (it is flushed). Keyboard activation grows from the button's
49
+ * centre; reduced motion swaps instantly. Default off.
50
+ */
51
+ reveal?: boolean | 'hard' | 'soft';
39
52
  } = $props();
40
53
 
41
54
  let isDark = $derived(theme === darkTheme);
@@ -50,11 +63,23 @@
50
63
 
51
64
  const label = $derived(isDark ? 'Switch to light mode' : 'Switch to dark mode');
52
65
 
53
- function toggle() {
54
- const next = isDark ? lightTheme : darkTheme;
66
+ function flip(next: string) {
55
67
  theme = next;
56
68
  onchange?.(next);
57
69
  }
70
+
71
+ function toggle(event: MouseEvent) {
72
+ const next = isDark ? lightTheme : darkTheme;
73
+ if (!reveal) {
74
+ flip(next);
75
+ return;
76
+ }
77
+ // The origin is read synchronously: pointer position, or this button's
78
+ // centre for a keyboard activation.
79
+ void themeWipe(event, () => flushSync(() => flip(next)), {
80
+ edge: reveal === 'soft' ? 'soft' : 'hard'
81
+ });
82
+ }
58
83
  </script>
59
84
 
60
85
  <button
@@ -9,6 +9,15 @@ type $$ComponentProps = {
9
9
  onchange?: (theme: string) => void;
10
10
  /** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
11
11
  size?: 'sm' | 'md' | 'lg';
12
+ /**
13
+ * Reveal the new theme with a circular View Transition wipe from the
14
+ * button (`themeWipe`): `true`/'hard' = crisp edge, 'soft' = feathered.
15
+ * The flip (bound `theme` + `onchange`) runs inside the transition, so
16
+ * the consumer's theme write must be synchronous in `onchange` or in an
17
+ * effect (it is flushed). Keyboard activation grows from the button's
18
+ * centre; reduced motion swaps instantly. Default off.
19
+ */
20
+ reveal?: boolean | 'hard' | 'soft';
12
21
  };
13
22
  declare const ThemeToggle: import("svelte").Component<$$ComponentProps, {}, "theme">;
14
23
  type ThemeToggle = ReturnType<typeof ThemeToggle>;
@@ -1,27 +1,242 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * Breadcrumb — hierarchical location trail.
4
+ *
5
+ * <Breadcrumb items={[{ label: 'Home', href: '/' }, …, { label: 'README.md' }]} />
6
+ * <Breadcrumb {items} maxItems={4} /> Home / … / y / z
7
+ * <Breadcrumb items={[{ label: 'Home', href: '/', icon: HomeIcon }, …]} />
8
+ *
9
+ * `maxItems` (BoardUI wave 1, jatui-owg2z) keeps the root and the last
10
+ * `maxItems - 1` items and folds the middle into an ellipsis button whose
11
+ * menu lists the hidden levels (arrow keys, Escape, click-outside). The last
12
+ * item is the current page (`aria-current="page"`). Icons come from
13
+ * `item.icon` (any component taking `{ size, class }`, e.g. lucide-svelte)
14
+ * or the `icon` snippet for full control.
15
+ */
16
+ import { tick, type Component, type Snippet } from 'svelte';
17
+ import { floating } from '../../actions/floating';
18
+ import { collapseBreadcrumb } from './breadcrumbCollapse';
19
+
2
20
  export interface BreadcrumbItem {
3
21
  label: string;
4
22
  href?: string;
23
+ /** Leading icon component (receives `size` and `class`). */
24
+ icon?: Component<{ size?: number; class?: string }>;
5
25
  }
6
26
 
7
27
  interface Props {
8
28
  items: BreadcrumbItem[];
29
+ /** Collapse long trails to root / … / last (maxItems - 1). Unset = never collapse. */
30
+ maxItems?: number;
31
+ /** Custom leading icon per item — overrides `item.icon`. */
32
+ icon?: Snippet<[BreadcrumbItem, number]>;
33
+ /** Accessible name of the nav landmark. Default "Breadcrumb". */
34
+ ariaLabel?: string;
9
35
  class?: string;
10
36
  }
11
37
 
12
- let { items, class: className = '' }: Props = $props();
38
+ let { items, maxItems, icon, ariaLabel = 'Breadcrumb', class: className = '' }: Props = $props();
39
+
40
+ const slots = $derived(collapseBreadcrumb(items, maxItems));
41
+
42
+ let menuOpen = $state(false);
43
+ let triggerEl: HTMLButtonElement | undefined = $state();
44
+ let menuEl: HTMLUListElement | undefined = $state();
45
+
46
+ function openMenu() {
47
+ menuOpen = true;
48
+ // Focus the first hidden level once the menu has mounted.
49
+ void tick().then(() => menuEl?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
50
+ }
51
+
52
+ function closeMenu(refocus = true) {
53
+ menuOpen = false;
54
+ if (refocus) triggerEl?.focus();
55
+ }
56
+
57
+ // use:floating dismisses on a capture-phase click outside the menu — which
58
+ // includes the trigger, so a click meant to CLOSE would re-open it.
59
+ let dismissedAt = 0;
60
+ function dismiss() {
61
+ dismissedAt = performance.now();
62
+ closeMenu(false);
63
+ }
64
+ function onTriggerClick() {
65
+ if (performance.now() - dismissedAt < 100) return;
66
+ if (menuOpen) closeMenu();
67
+ else openMenu();
68
+ }
69
+
70
+ function onMenuKeydown(e: KeyboardEvent) {
71
+ const entries = [...(menuEl?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
72
+ const at = entries.indexOf(document.activeElement as HTMLElement);
73
+ let next = -1;
74
+ if (e.key === 'ArrowDown') next = (at + 1) % entries.length;
75
+ else if (e.key === 'ArrowUp') next = (at - 1 + entries.length) % entries.length;
76
+ else if (e.key === 'Home') next = 0;
77
+ else if (e.key === 'End') next = entries.length - 1;
78
+ else if (e.key === 'Escape') {
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ closeMenu();
82
+ return;
83
+ } else if (e.key === 'Tab') {
84
+ closeMenu(false);
85
+ return;
86
+ }
87
+ if (next >= 0) {
88
+ e.preventDefault();
89
+ entries[next]?.focus();
90
+ }
91
+ }
92
+
93
+ function onTriggerKeydown(e: KeyboardEvent) {
94
+ if (e.key === 'ArrowDown' && !menuOpen) {
95
+ e.preventDefault();
96
+ openMenu();
97
+ }
98
+ }
13
99
  </script>
14
100
 
15
- <div class="breadcrumbs text-sm {className}">
101
+ {#snippet lead(item: BreadcrumbItem, i: number)}
102
+ {#if icon}
103
+ {@render icon(item, i)}
104
+ {:else if item.icon}
105
+ {@const Icon = item.icon}
106
+ <Icon size={14} class="jatui-crumb-icon" />
107
+ {/if}
108
+ {/snippet}
109
+
110
+ <nav class="breadcrumbs text-sm {className}" aria-label={ariaLabel}>
16
111
  <ul>
17
- {#each items as item, i}
18
- <li>
19
- {#if item.href && i < items.length - 1}
20
- <a href={item.href}>{item.label}</a>
21
- {:else}
22
- <span>{item.label}</span>
23
- {/if}
24
- </li>
112
+ {#each slots as slot (slot.kind === 'item' ? slot.index : 'ellipsis')}
113
+ {#if slot.kind === 'item'}
114
+ {@const isLast = slot.index === items.length - 1}
115
+ <li>
116
+ {#if slot.item.href && !isLast}
117
+ <a href={slot.item.href} class="jatui-crumb">
118
+ {@render lead(slot.item, slot.index)}{slot.item.label}
119
+ </a>
120
+ {:else}
121
+ <span class="jatui-crumb" aria-current={isLast ? 'page' : undefined}>
122
+ {@render lead(slot.item, slot.index)}{slot.item.label}
123
+ </span>
124
+ {/if}
125
+ </li>
126
+ {:else}
127
+ <li>
128
+ <button
129
+ bind:this={triggerEl}
130
+ type="button"
131
+ class="jatui-crumb-more"
132
+ aria-haspopup="menu"
133
+ aria-expanded={menuOpen}
134
+ aria-label="Show {slot.hidden.length} hidden {slot.hidden.length === 1 ? 'level' : 'levels'}"
135
+ onclick={onTriggerClick}
136
+ onkeydown={onTriggerKeydown}
137
+ >
138
+ <span aria-hidden="true">…</span>
139
+ </button>
140
+ {#if menuOpen}
141
+ <ul
142
+ bind:this={menuEl}
143
+ role="menu"
144
+ aria-label="Hidden levels"
145
+ class="jatui-crumb-menu bg-base-100 border-base-300 z-50 border p-1"
146
+ use:floating={{
147
+ anchor: triggerEl ?? null,
148
+ placement: 'bottom-start',
149
+ offset: 4,
150
+ onDismiss: dismiss
151
+ }}
152
+ onkeydown={onMenuKeydown}
153
+ >
154
+ {#each slot.hidden as h (h.index)}
155
+ <li role="none">
156
+ {#if h.item.href}
157
+ <a
158
+ role="menuitem"
159
+ href={h.item.href}
160
+ class="jatui-crumb-menuitem"
161
+ onclick={() => closeMenu(false)}
162
+ >
163
+ {@render lead(h.item, h.index)}{h.item.label}
164
+ </a>
165
+ {:else}
166
+ <span role="menuitem" tabindex="-1" aria-disabled="true" class="jatui-crumb-menuitem">
167
+ {@render lead(h.item, h.index)}{h.item.label}
168
+ </span>
169
+ {/if}
170
+ </li>
171
+ {/each}
172
+ </ul>
173
+ {/if}
174
+ </li>
175
+ {/if}
25
176
  {/each}
26
177
  </ul>
27
- </div>
178
+ </nav>
179
+
180
+ <style>
181
+ .jatui-crumb,
182
+ .jatui-crumb-menuitem {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ gap: var(--jatui-space-md, 6px);
186
+ }
187
+ .jatui-crumb-more {
188
+ display: inline-flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ min-width: 1.75rem;
192
+ height: 1.5rem;
193
+ padding: 0 var(--jatui-space-sm, 4px);
194
+ border-radius: var(--jatui-radius-field, 0.25rem);
195
+ color: inherit;
196
+ cursor: pointer;
197
+ transition: background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
198
+ }
199
+ .jatui-crumb-more:hover,
200
+ .jatui-crumb-more[aria-expanded='true'] {
201
+ background-color: var(
202
+ --jatui-hover-bg,
203
+ color-mix(in oklab, var(--color-base-content) 6%, transparent)
204
+ );
205
+ }
206
+ .jatui-crumb-more:focus-visible,
207
+ .jatui-crumb-menuitem:focus-visible {
208
+ outline: var(--jatui-focus-ring-width, 2px) solid
209
+ var(--jatui-focus-ring, color-mix(in oklab, var(--color-primary) 70%, transparent));
210
+ outline-offset: var(--jatui-focus-ring-offset, 2px);
211
+ }
212
+ .jatui-crumb-menu {
213
+ min-width: 10rem;
214
+ max-width: 20rem;
215
+ border-radius: var(--jatui-radius-box, 0.5rem);
216
+ list-style: none;
217
+ }
218
+ /* DaisyUI's breadcrumbs paints a separator before every li — not inside the menu. */
219
+ .jatui-crumb-menu :global(li::before) {
220
+ display: none !important;
221
+ }
222
+ .jatui-crumb-menuitem {
223
+ display: flex;
224
+ width: 100%;
225
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
226
+ border-radius: var(--jatui-radius-field, 0.25rem);
227
+ white-space: nowrap;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ text-decoration: none;
231
+ }
232
+ .jatui-crumb-menuitem:hover,
233
+ .jatui-crumb-menuitem:focus-visible {
234
+ background-color: var(
235
+ --jatui-hover-bg,
236
+ color-mix(in oklab, var(--color-base-content) 6%, transparent)
237
+ );
238
+ }
239
+ .jatui-crumb-menuitem[aria-disabled='true'] {
240
+ opacity: 0.6;
241
+ }
242
+ </style>
@@ -1,11 +1,37 @@
1
+ /**
2
+ * Breadcrumb — hierarchical location trail.
3
+ *
4
+ * <Breadcrumb items={[{ label: 'Home', href: '/' }, …, { label: 'README.md' }]} />
5
+ * <Breadcrumb {items} maxItems={4} /> Home / … / y / z
6
+ * <Breadcrumb items={[{ label: 'Home', href: '/', icon: HomeIcon }, …]} />
7
+ *
8
+ * `maxItems` (BoardUI wave 1, jatui-owg2z) keeps the root and the last
9
+ * `maxItems - 1` items and folds the middle into an ellipsis button whose
10
+ * menu lists the hidden levels (arrow keys, Escape, click-outside). The last
11
+ * item is the current page (`aria-current="page"`). Icons come from
12
+ * `item.icon` (any component taking `{ size, class }`, e.g. lucide-svelte)
13
+ * or the `icon` snippet for full control.
14
+ */
15
+ import { type Component, type Snippet } from 'svelte';
1
16
  export interface BreadcrumbItem {
2
17
  label: string;
3
18
  href?: string;
19
+ /** Leading icon component (receives `size` and `class`). */
20
+ icon?: Component<{
21
+ size?: number;
22
+ class?: string;
23
+ }>;
4
24
  }
5
25
  interface Props {
6
26
  items: BreadcrumbItem[];
27
+ /** Collapse long trails to root / … / last (maxItems - 1). Unset = never collapse. */
28
+ maxItems?: number;
29
+ /** Custom leading icon per item — overrides `item.icon`. */
30
+ icon?: Snippet<[BreadcrumbItem, number]>;
31
+ /** Accessible name of the nav landmark. Default "Breadcrumb". */
32
+ ariaLabel?: string;
7
33
  class?: string;
8
34
  }
9
- declare const Breadcrumb: import("svelte").Component<Props, {}, "">;
35
+ declare const Breadcrumb: Component<Props, {}, "">;
10
36
  type Breadcrumb = ReturnType<typeof Breadcrumb>;
11
37
  export default Breadcrumb;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Breadcrumb overflow collapse (jatui-owg2z). Pure.
3
+ *
4
+ * Keeps the first item and the last `maxItems - 1`, folding the middle into
5
+ * one ellipsis entry: "Home / … / y / z". The current page (last item) is
6
+ * always visible; the root is visible whenever maxItems ≥ 2.
7
+ */
8
+ export type BreadcrumbSlot<T> = {
9
+ kind: 'item';
10
+ item: T;
11
+ index: number;
12
+ } | {
13
+ kind: 'ellipsis';
14
+ hidden: {
15
+ item: T;
16
+ index: number;
17
+ }[];
18
+ };
19
+ export declare function collapseBreadcrumb<T>(items: readonly T[], maxItems?: number): BreadcrumbSlot<T>[];
@@ -0,0 +1,12 @@
1
+ export function collapseBreadcrumb(items, maxItems) {
2
+ const all = items.map((item, index) => ({ kind: 'item', item, index }));
3
+ if (maxItems == null || !(maxItems > 0) || items.length <= maxItems)
4
+ return all;
5
+ const max = Math.max(1, Math.floor(maxItems));
6
+ if (max === 1) {
7
+ return [{ kind: 'ellipsis', hidden: all.slice(0, -1) }, all[all.length - 1]];
8
+ }
9
+ const tail = all.slice(items.length - (max - 1));
10
+ const hidden = all.slice(1, items.length - (max - 1));
11
+ return [all[0], { kind: 'ellipsis', hidden }, ...tail];
12
+ }
@@ -76,7 +76,7 @@ declare function $$render<N extends Node = Node, E extends Edge = Edge>(): {
76
76
  screenToFlowPosition: (p: import("@xyflow/system").XYPosition) => import("@xyflow/system").XYPosition;
77
77
  getViewport: () => Viewport;
78
78
  };
79
- bindings: "viewport" | "nodes" | "edges";
79
+ bindings: "viewport" | "edges" | "nodes";
80
80
  slots: {};
81
81
  events: {};
82
82
  };
@@ -84,7 +84,7 @@ declare class __sveltets_Render<N extends Node = Node, E extends Edge = Edge> {
84
84
  props(): ReturnType<typeof $$render<N, E>>['props'];
85
85
  events(): ReturnType<typeof $$render<N, E>>['events'];
86
86
  slots(): ReturnType<typeof $$render<N, E>>['slots'];
87
- bindings(): "viewport" | "nodes" | "edges";
87
+ bindings(): "viewport" | "edges" | "nodes";
88
88
  exports(): {
89
89
  /**
90
90
  * Imperative handle (`bind:this={canvas}` → `canvas.api.fitView()`). Every