@urbicon-ui/blocks 6.8.0 → 6.9.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.
@@ -131,6 +131,16 @@ declare const blocksTranslations: {
131
131
  readonly filterPlaceholder: "Filter topics…";
132
132
  readonly noResults: "No matching topics";
133
133
  };
134
+ readonly journeyTimeline: {
135
+ readonly label: "Journey";
136
+ readonly status: {
137
+ readonly complete: "Completed";
138
+ readonly active: "In progress";
139
+ readonly pending: "Pending";
140
+ readonly blocked: "Blocked";
141
+ readonly skipped: "Skipped";
142
+ };
143
+ };
134
144
  readonly datepicker: {
135
145
  readonly placeholder: "Select a date...";
136
146
  readonly rangePlaceholder: "Select a date range...";
@@ -302,6 +312,16 @@ declare const blocksTranslations: {
302
312
  readonly filterPlaceholder: "Themen filtern…";
303
313
  readonly noResults: "Keine passenden Themen";
304
314
  };
315
+ readonly journeyTimeline: {
316
+ readonly label: "Verlauf";
317
+ readonly status: {
318
+ readonly complete: "Abgeschlossen";
319
+ readonly active: "In Bearbeitung";
320
+ readonly pending: "Ausstehend";
321
+ readonly blocked: "Blockiert";
322
+ readonly skipped: "Übersprungen";
323
+ };
324
+ };
305
325
  readonly datepicker: {
306
326
  readonly placeholder: "Datum wählen...";
307
327
  readonly rangePlaceholder: "Zeitraum wählen...";
@@ -0,0 +1,372 @@
1
+ <script lang="ts">
2
+ import { useBlocksI18n } from '../..';
3
+ import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
+ import { resolveIcon } from '../../icons';
5
+ import CheckIconDefault from '../../icons/CheckIcon.svelte';
6
+ import CircleDotIconDefault from '../../icons/CircleDotIcon.svelte';
7
+ import BanIconDefault from '../../icons/BanIcon.svelte';
8
+ import MinusIconDefault from '../../icons/MinusIcon.svelte';
9
+ import {
10
+ journeyTimelineVariants,
11
+ type JourneyTimelineSlots,
12
+ type JourneyTimelineVariants
13
+ } from './journey-timeline.variants';
14
+ import type { JourneyNode, JourneyStatus, JourneyTimelineProps } from './index';
15
+
16
+ const bt = useBlocksI18n();
17
+
18
+ const CheckIcon = resolveIcon('check', CheckIconDefault);
19
+ const CircleDotIcon = resolveIcon('circleDot', CircleDotIconDefault);
20
+ const BanIcon = resolveIcon('ban', BanIconDefault);
21
+ const MinusIcon = resolveIcon('minus', MinusIconDefault);
22
+
23
+ let {
24
+ items,
25
+ orientation = 'vertical',
26
+ size = 'md',
27
+ focusId = $bindable(),
28
+ defaultFocusId,
29
+ scrollSpy = false,
30
+ onFocusChange,
31
+ node,
32
+ class: className = '',
33
+ unstyled: unstyledProp = false,
34
+ slotClasses: slotClassesProp = {},
35
+ preset,
36
+ ...restProps
37
+ }: JourneyTimelineProps = $props();
38
+
39
+ const blocksConfig = getBlocksConfig();
40
+ const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
41
+
42
+ // --- focus state (controlled via bind:focusId, else internal) -------------
43
+ const focusableItems = $derived(items.filter((it) => it.focusable !== false));
44
+ const isFocusable = (id: string | undefined) =>
45
+ id !== undefined && focusableItems.some((it) => it.id === id);
46
+
47
+ // Default focus: explicit → first `active` node → first focusable node.
48
+ const defaultFocus = $derived(
49
+ (isFocusable(defaultFocusId) ? defaultFocusId : undefined) ??
50
+ items.find((it) => it.status === 'active' && it.focusable !== false)?.id ??
51
+ focusableItems[0]?.id
52
+ );
53
+
54
+ let internalFocusId = $state<string | undefined>(undefined);
55
+ // A stale internal focus (the expanded node was removed) falls back to the
56
+ // default so a node is always open. A controlled `focusId` is honoured as-is
57
+ // (write-strict) and only surfaced via the dev warning below.
58
+ const activeFocusId = $derived(
59
+ focusId !== undefined ? focusId : isFocusable(internalFocusId) ? internalFocusId : defaultFocus
60
+ );
61
+
62
+ // The roving-tabindex target: the last keyboard/click-touched node, else the
63
+ // expanded node. Kept distinct from `activeFocusId` so arrow keys can move DOM
64
+ // focus without expanding (expansion happens on Enter/Space/click). Guarded
65
+ // against a removed node so the tab stop never lands on nothing.
66
+ let rovingId = $state<string | undefined>(undefined);
67
+ // Every candidate is validated against the live focusable set (not just the
68
+ // first non-undefined one), so a stale roving/active id — or a bad controlled
69
+ // focusId — can never leave every button with tabindex=-1.
70
+ const tabbableId = $derived(
71
+ [rovingId, activeFocusId, focusableItems[0]?.id].find((id) => isFocusable(id))
72
+ );
73
+
74
+ // Dev-only signals for caller mistakes that otherwise fail silently. Mirrors
75
+ // the `Select`/`Guide` precedent (`import.meta.env?.DEV && console.warn`).
76
+ $effect(() => {
77
+ if (!import.meta.env?.DEV || items.length === 0) return;
78
+ if (focusId !== undefined && !isFocusable(focusId)) {
79
+ console.warn(
80
+ `[JourneyTimeline] focusId "${focusId}" matches no focusable node — every node stays collapsed.`
81
+ );
82
+ }
83
+ if (focusId === undefined && defaultFocusId !== undefined && !isFocusable(defaultFocusId)) {
84
+ console.warn(
85
+ `[JourneyTimeline] defaultFocusId "${defaultFocusId}" matches no focusable node — falling back to the first active/focusable node.`
86
+ );
87
+ }
88
+ if (!node && focusableItems.length > 0) {
89
+ console.warn(
90
+ '[JourneyTimeline] focusable nodes render as expandable buttons but no `node` snippet was provided. Pass a `node` snippet or set focusable:false on nodes that carry no detail.'
91
+ );
92
+ }
93
+ });
94
+
95
+ function setFocus(id: string) {
96
+ const target = items.find((it) => it.id === id);
97
+ if (!target || target.focusable === false || id === activeFocusId) return;
98
+ if (focusId !== undefined) focusId = id;
99
+ else internalFocusId = id;
100
+ onFocusChange?.(id);
101
+ }
102
+
103
+ function activate(item: JourneyNode) {
104
+ rovingId = item.id;
105
+ setFocus(item.id);
106
+ }
107
+
108
+ // --- keyboard roving navigation -------------------------------------------
109
+ let rootRef = $state<HTMLDivElement>();
110
+
111
+ function triggerEls(): HTMLElement[] {
112
+ return rootRef
113
+ ? Array.from(rootRef.querySelectorAll<HTMLElement>('[data-journey-trigger]'))
114
+ : [];
115
+ }
116
+
117
+ function moveFocus(delta: number) {
118
+ const els = triggerEls();
119
+ if (els.length === 0) return;
120
+ const currentId = rovingId ?? activeFocusId;
121
+ const currentIdx = els.findIndex((el) => el.dataset.nodeId === currentId);
122
+ const startIdx = currentIdx === -1 ? (delta > 0 ? -1 : 0) : currentIdx;
123
+ const next = els[(startIdx + delta + els.length) % els.length];
124
+ if (!next) return;
125
+ rovingId = next.dataset.nodeId;
126
+ next.focus();
127
+ }
128
+
129
+ function focusEdge(edge: 'first' | 'last') {
130
+ const els = triggerEls();
131
+ const el = edge === 'first' ? els[0] : els[els.length - 1];
132
+ if (!el) return;
133
+ rovingId = el.dataset.nodeId;
134
+ el.focus();
135
+ }
136
+
137
+ function handleKeydown(e: KeyboardEvent) {
138
+ // Only navigate when a node header is focused — never hijack arrow keys
139
+ // typed inside an expanded node's detail content.
140
+ if (!(e.target as HTMLElement)?.hasAttribute?.('data-journey-trigger')) return;
141
+ const forward = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
142
+ const backward = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
143
+ switch (e.key) {
144
+ case forward:
145
+ e.preventDefault();
146
+ moveFocus(1);
147
+ break;
148
+ case backward:
149
+ e.preventDefault();
150
+ moveFocus(-1);
151
+ break;
152
+ case 'Home':
153
+ e.preventDefault();
154
+ focusEdge('first');
155
+ break;
156
+ case 'End':
157
+ e.preventDefault();
158
+ focusEdge('last');
159
+ break;
160
+ // Enter / Space activate via the native <button> click → activate().
161
+ }
162
+ }
163
+
164
+ // --- scroll-spy (optional) -------------------------------------------------
165
+ $effect(() => {
166
+ if (!scrollSpy || !rootRef) return;
167
+ // Re-subscribe when the node set changes (reading `items` makes it a dep).
168
+ const currentItems = items;
169
+ const nodeEls = Array.from(rootRef.querySelectorAll<HTMLElement>('[data-journey-node]'));
170
+ if (nodeEls.length === 0) return;
171
+
172
+ const visible = new Set<string>();
173
+ // `observe()` delivers a synthetic initial callback for every target before
174
+ // any scrolling. Seed `visible` from it but do NOT act — otherwise mounting
175
+ // would clobber the resolved default focus (or a controlled focusId) and fire
176
+ // a spurious onFocusChange. Scroll-spy only *follows* real scrolling.
177
+ let seeded = false;
178
+ const observer = new IntersectionObserver(
179
+ (entries) => {
180
+ for (const entry of entries) {
181
+ const id = (entry.target as HTMLElement).dataset.nodeId;
182
+ if (!id) continue;
183
+ if (entry.isIntersecting) visible.add(id);
184
+ else visible.delete(id);
185
+ }
186
+ if (!seeded) {
187
+ seeded = true;
188
+ return;
189
+ }
190
+ const topmost = currentItems.find((it) => it.focusable !== false && visible.has(it.id));
191
+ if (topmost) setFocus(topmost.id);
192
+ },
193
+ // A node counts as "current" while it sits in the top 40% of the viewport.
194
+ { rootMargin: '0px 0px -60% 0px', threshold: 0 }
195
+ );
196
+ for (const el of nodeEls) observer.observe(el);
197
+ return () => observer.disconnect();
198
+ });
199
+
200
+ // --- styling ---------------------------------------------------------------
201
+ type Styles = ReturnType<typeof journeyTimelineVariants>;
202
+
203
+ const containerStyles = $derived(
204
+ unstyled ? null : journeyTimelineVariants({ orientation, size })
205
+ );
206
+
207
+ function nodeStyles(item: JourneyNode, focused: boolean): Styles | null {
208
+ return unstyled
209
+ ? null
210
+ : journeyTimelineVariants({
211
+ orientation,
212
+ size,
213
+ status: item.status,
214
+ focused,
215
+ interactive: item.focusable !== false,
216
+ // The connector leaving a completed node reads as "travelled".
217
+ connectorComplete: item.status === 'complete'
218
+ });
219
+ }
220
+
221
+ const slotClasses = $derived(
222
+ resolveSlotClasses(
223
+ blocksConfig,
224
+ 'JourneyTimeline',
225
+ preset,
226
+ { orientation, size } satisfies JourneyTimelineVariants,
227
+ slotClassesProp
228
+ )
229
+ );
230
+
231
+ function sc(styles: Styles | null, key: JourneyTimelineSlots, extra?: string | false) {
232
+ const override = [slotClasses?.[key], extra].filter(Boolean).join(' ') || undefined;
233
+ if (unstyled || !styles) return override;
234
+ return styles[key]({ class: override });
235
+ }
236
+
237
+ // --- misc derived ----------------------------------------------------------
238
+ const iconSize = $derived(size === 'sm' ? 14 : size === 'lg' ? 20 : 16);
239
+ const focusedItem = $derived(
240
+ items.find((it) => it.focusable !== false && it.id === activeFocusId)
241
+ );
242
+
243
+ const propsId = $props.id();
244
+ const uid = `journey-${propsId}`;
245
+ const detailDomId = (index: number) => `${uid}-detail-${index}`;
246
+ const panelId = `${uid}-panel`;
247
+
248
+ function statusLabel(status: JourneyStatus) {
249
+ return bt(`journeyTimeline.status.${status}`);
250
+ }
251
+ </script>
252
+
253
+ <!-- Marker glyph — decorative; the status is conveyed by the sr-only label. -->
254
+ {#snippet markerGlyph(item: JourneyNode)}
255
+ {#if item.status === 'complete'}
256
+ <CheckIcon size={iconSize} />
257
+ {:else if item.status === 'active'}
258
+ <CircleDotIcon size={iconSize} />
259
+ {:else if item.status === 'blocked'}
260
+ <BanIcon size={iconSize} />
261
+ {:else if item.status === 'skipped'}
262
+ <MinusIcon size={iconSize} />
263
+ {/if}
264
+ {/snippet}
265
+
266
+ {#snippet header(item: JourneyNode, styles: Styles | null)}
267
+ <span class={sc(styles, 'marker')}>{@render markerGlyph(item)}</span>
268
+ <span class={sc(styles, 'labelGroup')}>
269
+ <span class={sc(styles, 'title')}>{item.title}</span>
270
+ {#if item.subtitle}
271
+ <span class={sc(styles, 'subtitle')}>{item.subtitle}</span>
272
+ {/if}
273
+ </span>
274
+ <span class="sr-only">{statusLabel(item.status)}</span>
275
+ {/snippet}
276
+
277
+ {#snippet trigger(item: JourneyNode, index: number, focused: boolean, styles: Styles | null)}
278
+ {@const interactive = item.focusable !== false}
279
+ {#if interactive}
280
+ <button
281
+ type="button"
282
+ class={sc(styles, 'trigger')}
283
+ data-journey-trigger=""
284
+ data-node-id={item.id}
285
+ tabindex={item.id === tabbableId ? 0 : -1}
286
+ aria-expanded={node ? focused : undefined}
287
+ aria-controls={node
288
+ ? orientation === 'horizontal'
289
+ ? panelId
290
+ : detailDomId(index)
291
+ : undefined}
292
+ onclick={() => activate(item)}
293
+ >
294
+ {@render header(item, styles)}
295
+ </button>
296
+ {:else}
297
+ <div class={sc(styles, 'trigger')} data-node-id={item.id}>
298
+ {@render header(item, styles)}
299
+ </div>
300
+ {/if}
301
+ {/snippet}
302
+
303
+ <div
304
+ bind:this={rootRef}
305
+ class={sc(containerStyles, 'base', className)}
306
+ data-orientation={orientation}
307
+ {...restProps}
308
+ >
309
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
310
+ <ol
311
+ class={sc(containerStyles, 'rail')}
312
+ aria-label={bt('journeyTimeline.label')}
313
+ onkeydown={handleKeydown}
314
+ >
315
+ {#each items as item, index (item.id)}
316
+ {@const focused = item.focusable !== false && item.id === activeFocusId}
317
+ {@const styles = nodeStyles(item, focused)}
318
+ {#if orientation === 'horizontal'}
319
+ <li
320
+ class={sc(styles, 'node')}
321
+ data-journey-node=""
322
+ data-node-id={item.id}
323
+ aria-current={item.status === 'active' ? 'step' : undefined}
324
+ >
325
+ {@render trigger(item, index, focused, styles)}
326
+ <span data-journey-connector class={sc(styles, 'connector')} aria-hidden="true"></span>
327
+ </li>
328
+ {:else}
329
+ <li
330
+ class={sc(styles, 'node')}
331
+ data-journey-node=""
332
+ data-node-id={item.id}
333
+ aria-current={item.status === 'active' ? 'step' : undefined}
334
+ >
335
+ {@render trigger(item, index, focused, styles)}
336
+ <div class={sc(styles, 'body')}>
337
+ <div class={sc(styles, 'connectorColumn')}>
338
+ <span data-journey-connector class={sc(styles, 'connector')} aria-hidden="true"
339
+ ></span>
340
+ </div>
341
+ {#if node && item.focusable !== false}
342
+ <div
343
+ id={detailDomId(index)}
344
+ role="region"
345
+ aria-label={item.title}
346
+ class={sc(styles, 'detail')}
347
+ style="grid-template-rows: {focused ? '1fr' : '0fr'}"
348
+ >
349
+ <div class={sc(styles, 'detailInner')}>
350
+ <div class={sc(styles, 'detailContent')}>
351
+ {#if focused}{@render node(item)}{/if}
352
+ </div>
353
+ </div>
354
+ </div>
355
+ {/if}
356
+ </div>
357
+ </li>
358
+ {/if}
359
+ {/each}
360
+ </ol>
361
+
362
+ {#if orientation === 'horizontal' && node && focusedItem}
363
+ <div
364
+ id={panelId}
365
+ role="region"
366
+ aria-label={focusedItem.title}
367
+ class={sc(containerStyles, 'panel')}
368
+ >
369
+ {@render node(focusedItem)}
370
+ </div>
371
+ {/if}
372
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { JourneyTimelineProps } from './index.js';
2
+ declare const JourneyTimeline: import("svelte").Component<JourneyTimelineProps, {}, "focusId">;
3
+ type JourneyTimeline = ReturnType<typeof JourneyTimeline>;
4
+ export default JourneyTimeline;
@@ -0,0 +1,114 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { JourneyTimelineSlots, JourneyTimelineVariants } from './journey-timeline.variants.js';
4
+ /** Lifecycle status of a journey node — drives marker colour and glyph. */
5
+ export type JourneyStatus = 'complete' | 'active' | 'pending' | 'blocked' | 'skipped';
6
+ /** A single node (waypoint) on a {@link JourneyTimelineProps | JourneyTimeline}. */
7
+ export interface JourneyNode {
8
+ /** Stable unique identifier — used as the focus key and for `{#each}` keying. */
9
+ id: string;
10
+ /** Node title, always visible next to the marker. */
11
+ title: string;
12
+ /**
13
+ * Lifecycle status. Maps to a semantic marker colour + glyph:
14
+ * `complete` (success ✓), `active` (primary ◉), `pending` (empty circle),
15
+ * `blocked` (danger ⊘), `skipped` (muted −).
16
+ */
17
+ status: JourneyStatus;
18
+ /** Short status line shown below the title while collapsed. */
19
+ subtitle?: string;
20
+ /**
21
+ * When `false`, the node is a pure waypoint: it renders a marker + labels but
22
+ * cannot receive focus, does not expand, and is skipped by keyboard navigation.
23
+ * @default true
24
+ */
25
+ focusable?: boolean;
26
+ }
27
+ /**
28
+ * @description Connected timeline whose markers *are* the progress indicator and
29
+ * where exactly one focusable node expands to reveal rich per-step detail (the
30
+ * "journey" / travel-log pattern). Data-driven via `items`; the focused node's
31
+ * body is rendered through the `node` snippet. For a compact progress indicator
32
+ * without a detail container use `Stepper`; for peer views without sequence use
33
+ * `Tab`.
34
+ *
35
+ * @tag navigation
36
+ * @tag display
37
+ * @related Stepper
38
+ * @related Tab
39
+ * @related Accordion
40
+ * @stability beta
41
+ *
42
+ * @example Vertical journey with inline detail
43
+ * ```svelte
44
+ * <script lang="ts">
45
+ * import { JourneyTimeline, type JourneyNode } from '@urbicon-ui/blocks';
46
+ * const stages: JourneyNode[] = [
47
+ * { id: 'draft', title: 'Draft', status: 'complete', subtitle: 'Sent 3 Jun' },
48
+ * { id: 'review', title: 'Review', status: 'active', subtitle: 'In progress' },
49
+ * { id: 'approve', title: 'Approval', status: 'pending' }
50
+ * ];
51
+ * let focusId = $state('review');
52
+ * </script>
53
+ *
54
+ * <JourneyTimeline items={stages} bind:focusId>
55
+ * {#snippet node(item)}
56
+ * <p>Details for {item.title}…</p>
57
+ * {/snippet}
58
+ * </JourneyTimeline>
59
+ * ```
60
+ *
61
+ * @example Horizontal lifecycle with a shared detail panel + scroll-spy off
62
+ * ```svelte
63
+ * <JourneyTimeline items={phases} orientation="horizontal" onFocusChange={(id) => track(id)}>
64
+ * {#snippet node(item)}
65
+ * <PhaseSummary phase={item.id} />
66
+ * {/snippet}
67
+ * </JourneyTimeline>
68
+ * ```
69
+ */
70
+ export interface JourneyTimelineProps extends Pick<JourneyTimelineVariants, 'orientation' | 'size'>, Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
71
+ /** The ordered journey nodes. */
72
+ items: JourneyNode[];
73
+ /** Layout direction. Vertical expands detail inline; horizontal into a panel below the rail. @default 'vertical' */
74
+ orientation?: 'vertical' | 'horizontal';
75
+ /** Marker + label scale. @default 'md' */
76
+ size?: 'sm' | 'md' | 'lg';
77
+ /**
78
+ * The expanded (focused) node id. Supports `bind:focusId`. When omitted the
79
+ * component is uncontrolled and falls back to `defaultFocusId`, then the first
80
+ * `active` node, then the first focusable node.
81
+ */
82
+ focusId?: string;
83
+ /** Initial focused node id in uncontrolled mode. Ignored once `focusId` is bound. */
84
+ defaultFocusId?: string;
85
+ /**
86
+ * When `true`, the focus follows the node scrolled to the top of the viewport
87
+ * (the travel-log feel), driven by an IntersectionObserver. Reduced-motion safe.
88
+ * @default false
89
+ */
90
+ scrollSpy?: boolean;
91
+ /** Fires when the focused node changes (click, keyboard, or scroll-spy). */
92
+ onFocusChange?: (id: string) => void;
93
+ /** Renders the body of the focused node. Receives the focused `JourneyNode`. */
94
+ node?: Snippet<[JourneyNode]>;
95
+ /** Extra classes merged onto the root element. */
96
+ class?: string;
97
+ /** Remove all default tv() classes. */
98
+ unstyled?: boolean;
99
+ /**
100
+ * Per-slot class overrides. Slots: base | rail | node | trigger | marker |
101
+ * connectorColumn | connector | labelGroup | title | subtitle | body | detail |
102
+ * detailInner | detailContent | panel
103
+ */
104
+ slotClasses?: Partial<Record<JourneyTimelineSlots, string>>;
105
+ /**
106
+ * Apply a named preset registered via `<BlocksProvider presets={{ JourneyTimeline: {...} }}>`.
107
+ * Prefer this over `class` overrides when the requested look falls outside the
108
+ * semantic intent palette — presets keep hover/active/dark-mode logic coherent
109
+ * and make the custom look reusable across the project.
110
+ */
111
+ preset?: string;
112
+ }
113
+ export { default as JourneyTimeline } from './JourneyTimeline.svelte';
114
+ export { type JourneyTimelineVariants, journeyTimelineVariants } from './journey-timeline.variants.js';
@@ -0,0 +1,2 @@
1
+ export { default as JourneyTimeline } from './JourneyTimeline.svelte';
2
+ export { journeyTimelineVariants } from './journey-timeline.variants.js';
@@ -0,0 +1,250 @@
1
+ import { type SlotNames, type VariantProps } from '../../utils/variants.js';
2
+ export declare const journeyTimelineVariants: (props?: import("../../utils/variants.js").TVProps<{
3
+ orientation: {
4
+ vertical: {
5
+ rail: string;
6
+ };
7
+ horizontal: {
8
+ base: string;
9
+ rail: string;
10
+ node: string;
11
+ trigger: string;
12
+ };
13
+ };
14
+ size: {
15
+ sm: {
16
+ marker: string;
17
+ connectorColumn: string;
18
+ trigger: string;
19
+ title: string;
20
+ subtitle: string;
21
+ detailContent: string;
22
+ };
23
+ md: {
24
+ marker: string;
25
+ connectorColumn: string;
26
+ trigger: string;
27
+ title: string;
28
+ subtitle: string;
29
+ detailContent: string;
30
+ };
31
+ lg: {
32
+ marker: string;
33
+ connectorColumn: string;
34
+ trigger: string;
35
+ title: string;
36
+ subtitle: string;
37
+ detailContent: string;
38
+ };
39
+ };
40
+ status: {
41
+ complete: {
42
+ marker: string;
43
+ };
44
+ active: {
45
+ marker: string;
46
+ title: string;
47
+ };
48
+ pending: {
49
+ marker: string;
50
+ };
51
+ blocked: {
52
+ marker: string;
53
+ title: string;
54
+ };
55
+ skipped: {
56
+ marker: string;
57
+ title: string;
58
+ };
59
+ };
60
+ focused: {
61
+ true: {
62
+ trigger: string;
63
+ title: string;
64
+ };
65
+ false: {};
66
+ };
67
+ interactive: {
68
+ true: {
69
+ trigger: string;
70
+ };
71
+ false: {
72
+ trigger: string;
73
+ };
74
+ };
75
+ connectorComplete: {
76
+ true: {
77
+ connector: string;
78
+ };
79
+ false: {};
80
+ };
81
+ }> | undefined) => {
82
+ base: (props?: ({
83
+ orientation?: "horizontal" | "vertical" | undefined;
84
+ size?: "sm" | "md" | "lg" | undefined;
85
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
86
+ focused?: boolean | undefined;
87
+ interactive?: boolean | undefined;
88
+ connectorComplete?: boolean | undefined;
89
+ } & {
90
+ 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;
91
+ 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;
92
+ }) | undefined) => string;
93
+ rail: (props?: ({
94
+ orientation?: "horizontal" | "vertical" | undefined;
95
+ size?: "sm" | "md" | "lg" | undefined;
96
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
97
+ focused?: boolean | undefined;
98
+ interactive?: boolean | undefined;
99
+ connectorComplete?: boolean | undefined;
100
+ } & {
101
+ 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;
102
+ 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;
103
+ }) | undefined) => string;
104
+ node: (props?: ({
105
+ orientation?: "horizontal" | "vertical" | undefined;
106
+ size?: "sm" | "md" | "lg" | undefined;
107
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
108
+ focused?: boolean | undefined;
109
+ interactive?: boolean | undefined;
110
+ connectorComplete?: boolean | undefined;
111
+ } & {
112
+ 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;
113
+ 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;
114
+ }) | undefined) => string;
115
+ trigger: (props?: ({
116
+ orientation?: "horizontal" | "vertical" | undefined;
117
+ size?: "sm" | "md" | "lg" | undefined;
118
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
119
+ focused?: boolean | undefined;
120
+ interactive?: boolean | undefined;
121
+ connectorComplete?: boolean | undefined;
122
+ } & {
123
+ 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;
124
+ 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;
125
+ }) | undefined) => string;
126
+ marker: (props?: ({
127
+ orientation?: "horizontal" | "vertical" | undefined;
128
+ size?: "sm" | "md" | "lg" | undefined;
129
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
130
+ focused?: boolean | undefined;
131
+ interactive?: boolean | undefined;
132
+ connectorComplete?: boolean | undefined;
133
+ } & {
134
+ 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;
135
+ 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;
136
+ }) | undefined) => string;
137
+ connectorColumn: (props?: ({
138
+ orientation?: "horizontal" | "vertical" | undefined;
139
+ size?: "sm" | "md" | "lg" | undefined;
140
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
141
+ focused?: boolean | undefined;
142
+ interactive?: boolean | undefined;
143
+ connectorComplete?: boolean | undefined;
144
+ } & {
145
+ 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;
146
+ 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;
147
+ }) | undefined) => string;
148
+ connector: (props?: ({
149
+ orientation?: "horizontal" | "vertical" | undefined;
150
+ size?: "sm" | "md" | "lg" | undefined;
151
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
152
+ focused?: boolean | undefined;
153
+ interactive?: boolean | undefined;
154
+ connectorComplete?: boolean | undefined;
155
+ } & {
156
+ 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;
157
+ 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;
158
+ }) | undefined) => string;
159
+ labelGroup: (props?: ({
160
+ orientation?: "horizontal" | "vertical" | undefined;
161
+ size?: "sm" | "md" | "lg" | undefined;
162
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
163
+ focused?: boolean | undefined;
164
+ interactive?: boolean | undefined;
165
+ connectorComplete?: boolean | undefined;
166
+ } & {
167
+ 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;
168
+ 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;
169
+ }) | undefined) => string;
170
+ title: (props?: ({
171
+ orientation?: "horizontal" | "vertical" | undefined;
172
+ size?: "sm" | "md" | "lg" | undefined;
173
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
174
+ focused?: boolean | undefined;
175
+ interactive?: boolean | undefined;
176
+ connectorComplete?: boolean | undefined;
177
+ } & {
178
+ 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;
179
+ 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;
180
+ }) | undefined) => string;
181
+ subtitle: (props?: ({
182
+ orientation?: "horizontal" | "vertical" | undefined;
183
+ size?: "sm" | "md" | "lg" | undefined;
184
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
185
+ focused?: boolean | undefined;
186
+ interactive?: boolean | undefined;
187
+ connectorComplete?: boolean | undefined;
188
+ } & {
189
+ 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;
190
+ 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;
191
+ }) | undefined) => string;
192
+ body: (props?: ({
193
+ orientation?: "horizontal" | "vertical" | undefined;
194
+ size?: "sm" | "md" | "lg" | undefined;
195
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
196
+ focused?: boolean | undefined;
197
+ interactive?: boolean | undefined;
198
+ connectorComplete?: boolean | undefined;
199
+ } & {
200
+ 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;
201
+ 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;
202
+ }) | undefined) => string;
203
+ detail: (props?: ({
204
+ orientation?: "horizontal" | "vertical" | undefined;
205
+ size?: "sm" | "md" | "lg" | undefined;
206
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
207
+ focused?: boolean | undefined;
208
+ interactive?: boolean | undefined;
209
+ connectorComplete?: boolean | undefined;
210
+ } & {
211
+ 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;
212
+ 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;
213
+ }) | undefined) => string;
214
+ detailInner: (props?: ({
215
+ orientation?: "horizontal" | "vertical" | undefined;
216
+ size?: "sm" | "md" | "lg" | undefined;
217
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
218
+ focused?: boolean | undefined;
219
+ interactive?: boolean | undefined;
220
+ connectorComplete?: boolean | undefined;
221
+ } & {
222
+ 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;
223
+ 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;
224
+ }) | undefined) => string;
225
+ detailContent: (props?: ({
226
+ orientation?: "horizontal" | "vertical" | undefined;
227
+ size?: "sm" | "md" | "lg" | undefined;
228
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
229
+ focused?: boolean | undefined;
230
+ interactive?: boolean | undefined;
231
+ connectorComplete?: boolean | undefined;
232
+ } & {
233
+ 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;
234
+ 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;
235
+ }) | undefined) => string;
236
+ panel: (props?: ({
237
+ orientation?: "horizontal" | "vertical" | undefined;
238
+ size?: "sm" | "md" | "lg" | undefined;
239
+ status?: "active" | "blocked" | "complete" | "pending" | "skipped" | undefined;
240
+ focused?: boolean | undefined;
241
+ interactive?: boolean | undefined;
242
+ connectorComplete?: boolean | undefined;
243
+ } & {
244
+ 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;
245
+ 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;
246
+ }) | undefined) => string;
247
+ };
248
+ export type JourneyTimelineVariants = VariantProps<typeof journeyTimelineVariants>;
249
+ /** Slot names derived from the tv() config — single source of truth for slotClasses. */
250
+ export type JourneyTimelineSlots = SlotNames<typeof journeyTimelineVariants>;
@@ -0,0 +1,175 @@
1
+ import { tv } from '../../utils/variants.js';
2
+ export const journeyTimelineVariants = tv({
3
+ slots: {
4
+ // Root wrapper. Always a <div> so the same element hosts the marker rail
5
+ // AND (horizontal only) the shared detail panel beneath it. The last node's
6
+ // connector is hidden in both orientations — nothing to connect onward to.
7
+ base: 'w-full [&_[data-journey-node]:last-child_[data-journey-connector]]:hidden',
8
+ // The ordered list of nodes. `flex-col` vertical, `flex-row` horizontal.
9
+ rail: 'flex list-none m-0 p-0',
10
+ // A single node <li>.
11
+ node: 'group/node',
12
+ // The interactive header — marker + title/subtitle. A <button> for focusable
13
+ // nodes, a plain <div> for pure waypoints (focusable === false).
14
+ trigger: [
15
+ 'flex items-center text-left w-full bg-transparent border-0 appearance-none p-1 -m-1',
16
+ 'rounded-contain',
17
+ 'transition-[color,background-color] duration-[var(--blocks-duration-fast)]',
18
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base'
19
+ ],
20
+ // The status-coloured circle.
21
+ marker: [
22
+ 'flex items-center justify-center shrink-0 font-semibold rounded-commit border-2 box-border',
23
+ 'transition-[color,background-color,border-color,box-shadow] duration-[var(--blocks-duration-fast)]'
24
+ ],
25
+ // Column (marker width) that carries the vertical connector below a marker.
26
+ // `flex-col` is essential: it makes the connector's `flex-1` grow *down* the
27
+ // column (a thin vertical line) instead of *across* it (a fat block).
28
+ connectorColumn: 'flex flex-col shrink-0 items-center',
29
+ // The connecting line between markers.
30
+ connector: [
31
+ 'bg-border-subtle',
32
+ 'transition-[background-color] duration-[var(--blocks-duration-normal)]'
33
+ ],
34
+ // Title + subtitle wrapper.
35
+ labelGroup: 'flex flex-col min-w-0',
36
+ title: [
37
+ 'font-medium leading-tight truncate',
38
+ 'transition-colors duration-[var(--blocks-duration-fast)]'
39
+ ],
40
+ subtitle: 'text-text-tertiary leading-tight mt-0.5 truncate',
41
+ // Lower row (vertical only): connector column + inline detail region.
42
+ body: 'flex',
43
+ // Grid-rows collapse wrapper (0fr → 1fr). Reduced-motion safe: the duration
44
+ // token collapses to 1ms under prefers-reduced-motion (interaction.css).
45
+ detail: [
46
+ 'grid',
47
+ 'transition-[grid-template-rows] duration-[var(--blocks-duration-normal)] ease-[var(--blocks-ease-smooth)]'
48
+ ],
49
+ detailInner: 'overflow-hidden min-h-0',
50
+ detailContent: 'text-text-secondary',
51
+ // Shared detail panel (horizontal only) rendered beneath the rail.
52
+ panel: 'text-text-secondary border border-border-subtle rounded-contain bg-surface-quiet'
53
+ },
54
+ variants: {
55
+ orientation: {
56
+ vertical: {
57
+ rail: 'flex-col'
58
+ },
59
+ horizontal: {
60
+ // Column layout so the shared detail panel sits below the rail.
61
+ base: 'flex flex-col',
62
+ rail: 'flex-row items-start',
63
+ // Each node grows to spread markers evenly; the last one hugs its content.
64
+ node: 'flex items-center [&:not(:last-child)]:flex-1',
65
+ // Compact header (marker + label side by side) so the connector can grow.
66
+ trigger: 'w-auto shrink-0'
67
+ }
68
+ },
69
+ size: {
70
+ sm: {
71
+ marker: 'size-7 text-xs',
72
+ connectorColumn: 'w-7',
73
+ trigger: 'gap-2',
74
+ title: 'text-xs',
75
+ subtitle: 'text-[11px]',
76
+ detailContent: 'text-sm pl-2'
77
+ },
78
+ md: {
79
+ marker: 'size-9 text-sm',
80
+ connectorColumn: 'w-9',
81
+ trigger: 'gap-2.5',
82
+ title: 'text-sm',
83
+ subtitle: 'text-xs',
84
+ detailContent: 'text-sm pl-2.5'
85
+ },
86
+ lg: {
87
+ marker: 'size-11 text-base',
88
+ connectorColumn: 'w-11',
89
+ trigger: 'gap-3',
90
+ title: 'text-base',
91
+ subtitle: 'text-sm',
92
+ detailContent: 'text-base pl-3'
93
+ }
94
+ },
95
+ // Marker colour + title tone per journey status. Mirrors the semantic intent
96
+ // palette (success / primary / danger / neutral) used across Badge, Alert,
97
+ // Progress and Stepper.
98
+ status: {
99
+ complete: {
100
+ marker: 'border-success bg-success text-text-on-primary'
101
+ },
102
+ active: {
103
+ marker: 'border-primary bg-primary text-text-on-primary shadow-[var(--blocks-shadow-sm)]',
104
+ title: 'text-text-primary'
105
+ },
106
+ pending: {
107
+ marker: 'border-border-default bg-surface-base text-text-tertiary'
108
+ },
109
+ blocked: {
110
+ marker: 'border-danger bg-danger text-text-on-primary',
111
+ title: 'text-danger'
112
+ },
113
+ skipped: {
114
+ marker: 'border-border-subtle bg-surface-subtle text-text-tertiary opacity-70',
115
+ title: 'text-text-tertiary'
116
+ }
117
+ },
118
+ // The currently expanded node. Distinct from DOM focus (focus-visible ring).
119
+ focused: {
120
+ true: {
121
+ trigger: 'bg-surface-subtle',
122
+ title: 'text-text-primary font-semibold'
123
+ },
124
+ false: {}
125
+ },
126
+ // Focusable nodes react to hover + show a pointer; pure waypoints do not.
127
+ interactive: {
128
+ true: {
129
+ trigger: 'cursor-pointer hover:bg-surface-hover'
130
+ },
131
+ false: {
132
+ trigger: 'cursor-default'
133
+ }
134
+ },
135
+ // The connector segment leading out of a completed node reads as "travelled".
136
+ connectorComplete: {
137
+ true: {
138
+ connector: 'bg-success'
139
+ },
140
+ false: {}
141
+ }
142
+ },
143
+ compoundVariants: [
144
+ // Vertical connector geometry: a thin line that grows down the marker column.
145
+ {
146
+ orientation: 'vertical',
147
+ class: {
148
+ connector: 'w-0.5 flex-1 my-1 min-h-4 rounded-commit'
149
+ }
150
+ },
151
+ // Horizontal connector geometry: a thin line filling the gap to the next marker.
152
+ {
153
+ orientation: 'horizontal',
154
+ class: {
155
+ connector: 'h-0.5 flex-1 mx-2 self-center rounded-commit'
156
+ }
157
+ },
158
+ // A focused pure waypoint keeps a bold title but gains no hover surface.
159
+ {
160
+ interactive: false,
161
+ focused: true,
162
+ class: {
163
+ trigger: 'bg-transparent'
164
+ }
165
+ }
166
+ ],
167
+ defaultVariants: {
168
+ orientation: 'vertical',
169
+ size: 'md',
170
+ status: 'pending',
171
+ focused: false,
172
+ interactive: true,
173
+ connectorComplete: false
174
+ }
175
+ });
@@ -21,6 +21,7 @@ export interface StepperContext {
21
21
  *
22
22
  * @tag navigation
23
23
  * @related Tab
24
+ * @related JourneyTimeline
24
25
  *
25
26
  * @example
26
27
  * ```svelte
@@ -30,6 +30,8 @@ export type { FormFieldProps, FormFieldSlotContext } from './FormField/index.js'
30
30
  export * from './FormField/index.js';
31
31
  export type { InputProps } from './Input/index.js';
32
32
  export * from './Input/index.js';
33
+ export type { JourneyNode, JourneyStatus, JourneyTimelineProps } from './JourneyTimeline/index.js';
34
+ export * from './JourneyTimeline/index.js';
33
35
  export type { MenuContext, MenuCustomSlots, MenuItemType, MenuObjectOption, MenuOption, MenuProps, MenuSectionHeader } from './Menu/index.js';
34
36
  export * from './Menu/index.js';
35
37
  export type { PaginationItemProps, PaginationProps } from './Pagination/index.js';
@@ -16,6 +16,7 @@ export * from './Dialog/index.js';
16
16
  export * from './Drawer/index.js';
17
17
  export * from './FormField/index.js';
18
18
  export * from './Input/index.js';
19
+ export * from './JourneyTimeline/index.js';
19
20
  export * from './Menu/index.js';
20
21
  export * from './Pagination/index.js';
21
22
  export * from './Popover/index.js';
@@ -129,6 +129,16 @@ declare const _default: {
129
129
  readonly filterPlaceholder: "Themen filtern…";
130
130
  readonly noResults: "Keine passenden Themen";
131
131
  };
132
+ readonly journeyTimeline: {
133
+ readonly label: "Verlauf";
134
+ readonly status: {
135
+ readonly complete: "Abgeschlossen";
136
+ readonly active: "In Bearbeitung";
137
+ readonly pending: "Ausstehend";
138
+ readonly blocked: "Blockiert";
139
+ readonly skipped: "Übersprungen";
140
+ };
141
+ };
132
142
  readonly datepicker: {
133
143
  readonly placeholder: "Datum wählen...";
134
144
  readonly rangePlaceholder: "Zeitraum wählen...";
@@ -129,6 +129,16 @@ export default {
129
129
  filterPlaceholder: 'Themen filtern…',
130
130
  noResults: 'Keine passenden Themen'
131
131
  },
132
+ journeyTimeline: {
133
+ label: 'Verlauf',
134
+ status: {
135
+ complete: 'Abgeschlossen',
136
+ active: 'In Bearbeitung',
137
+ pending: 'Ausstehend',
138
+ blocked: 'Blockiert',
139
+ skipped: 'Übersprungen'
140
+ }
141
+ },
132
142
  datepicker: {
133
143
  placeholder: 'Datum wählen...',
134
144
  rangePlaceholder: 'Zeitraum wählen...',
@@ -129,6 +129,16 @@ declare const _default: {
129
129
  readonly filterPlaceholder: "Filter topics…";
130
130
  readonly noResults: "No matching topics";
131
131
  };
132
+ readonly journeyTimeline: {
133
+ readonly label: "Journey";
134
+ readonly status: {
135
+ readonly complete: "Completed";
136
+ readonly active: "In progress";
137
+ readonly pending: "Pending";
138
+ readonly blocked: "Blocked";
139
+ readonly skipped: "Skipped";
140
+ };
141
+ };
132
142
  readonly datepicker: {
133
143
  readonly placeholder: "Select a date...";
134
144
  readonly rangePlaceholder: "Select a date range...";
@@ -129,6 +129,16 @@ export default {
129
129
  filterPlaceholder: 'Filter topics…',
130
130
  noResults: 'No matching topics'
131
131
  },
132
+ journeyTimeline: {
133
+ label: 'Journey',
134
+ status: {
135
+ complete: 'Completed',
136
+ active: 'In progress',
137
+ pending: 'Pending',
138
+ blocked: 'Blocked',
139
+ skipped: 'Skipped'
140
+ }
141
+ },
132
142
  datepicker: {
133
143
  placeholder: 'Select a date...',
134
144
  rangePlaceholder: 'Select a date range...',
@@ -127,6 +127,9 @@ export interface GuideNavigationSource {
127
127
  * Subscribe to navigations; `onNavigate` receives the new path. Returns an unsubscribe. The
128
128
  * controller subscribes only while a tour with at least one `route` step is active, so a tour
129
129
  * that never declares a route observes no navigation (preserving the manual-`goto` recipe).
130
+ * `onNavigate` may be invoked **synchronously, re-entrant** during the controller's own `navigate`
131
+ * hook (a SvelteKit `afterNavigate` source fires inside `goto`); the controller recognizes that as
132
+ * the tour's own navigation and keeps it running, so the source needs no `queueMicrotask` deferral.
130
133
  */
131
134
  subscribe(onNavigate: (path: string) => void): () => void;
132
135
  }
@@ -93,6 +93,20 @@ export function createBrowserNavigationSource() {
93
93
  }
94
94
  /** DEV-only: grace period for a navigated-to `route` step's target before warning it never showed. */
95
95
  const ROUTE_TARGET_TIMEOUT_MS = 4000;
96
+ /**
97
+ * Heuristic: does `landed` look like a router-normalized form of `route` — a trailing slash
98
+ * (`/expenses/`) or a base/locale prefix (`/de/expenses`) — rather than an unrelated redirect target
99
+ * (`/login`)? Used ONLY to decide whether a *synchronous* own-navigation landing on a non-exact path
100
+ * deserves a DEV diagnostic, never to change tour behavior, so a wrong guess merely over- or
101
+ * under-warns in DEV. `route` is a normalized pathname (leading `/`), so the suffix test lands on a
102
+ * real segment boundary — `/myexpenses` does not end with `/expenses`.
103
+ */
104
+ function looksLikeNormalizedRoute(landed, route) {
105
+ const stripSlash = (s) => (s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s);
106
+ const l = stripSlash(landed);
107
+ const r = stripSlash(route);
108
+ return l === r || l.endsWith(r);
109
+ }
96
110
  let tourIdCounter = 0;
97
111
  /** Opaque id for overlay-stack registration. Never SSR-rendered, so no hydration risk. */
98
112
  function nextOverlayId() {
@@ -157,6 +171,21 @@ export class GuideController {
157
171
  #navSource;
158
172
  /** The path a tour-internal navigation is heading to; distinguishes our own nav from a foreign one. */
159
173
  #expectedRoute = null;
174
+ /**
175
+ * `true` only while our own injected `#navigate()` call is on the stack. A `navigationSource` that
176
+ * reports its location change **synchronously, re-entrant** during that call (SvelteKit's
177
+ * `afterNavigate` fires inside `goto`) IS that very navigation — never a foreign one. This is the
178
+ * exact signal; the `path === #expectedRoute` check in `#onLocationChange` is only an async-source
179
+ * proxy for the same thing and breaks the moment the router reports a normalized path
180
+ * (trailing slash, base/locale prefix) that doesn't equal the raw `step.route`.
181
+ *
182
+ * A boolean (not a depth counter) is enough: the sole re-entrant callee of a sync source is
183
+ * `#onLocationChange`, which never calls `#maybeNavigate`. A `navigate` hook that *synchronously*
184
+ * re-enters `next`/`prev`/`startTour` is out of contract — its inner `#maybeNavigate` `finally`
185
+ * would clear this early; that only ever fails *closed* (a later own report read as foreign → stop),
186
+ * never open, so it can't keep a genuinely foreign navigation alive.
187
+ */
188
+ #selfNavigating = false;
160
189
  /** Last path the tour is known to be on — set at start, updated on each handled navigation. */
161
190
  #knownPath = '';
162
191
  /** Unsubscribe from the navigation source; non-null only while a route-using tour runs. */
@@ -406,6 +435,12 @@ export class GuideController {
406
435
  }
407
436
  this.#expectedRoute = route;
408
437
  this.#scheduleRouteTargetWarning();
438
+ // Mark the window during which a synchronous (re-entrant) location report from the
439
+ // navigationSource is unambiguously our own navigation. Reset in `finally` the instant the
440
+ // (sync part of the) hook returns — an *async* report (the default Navigation-API source, or a
441
+ // microtask-deferred one) then falls through to the normal `#expectedRoute` matching, so the
442
+ // async path is unchanged.
443
+ this.#selfNavigating = true;
409
444
  try {
410
445
  const result = this.#navigate(route);
411
446
  if (result && typeof result.then === 'function') {
@@ -421,6 +456,9 @@ export class GuideController {
421
456
  console.warn('[Guide] navigate hook threw:', err);
422
457
  this.#abortPendingNavigation(route);
423
458
  }
459
+ finally {
460
+ this.#selfNavigating = false;
461
+ }
424
462
  }
425
463
  /**
426
464
  * Roll back the bookkeeping for a navigation that failed (hook threw/rejected) — but only if it
@@ -435,9 +473,10 @@ export class GuideController {
435
473
  this.#clearRouteTargetWarning();
436
474
  }
437
475
  /**
438
- * React to a navigation observed while a route-using tour runs. A navigation matching the pending
439
- * `expectedRoute` is the tour's own clear the flag and keep running (the ring lands via the
440
- * surface's `reapplyStepHighlight`). Any other navigation is foreign the user left, or a
476
+ * React to a navigation observed while a route-using tour runs. A navigation that is the tour's own
477
+ * reported synchronously during our `#navigate()` call (`#selfNavigating`), or matching the
478
+ * pending `#expectedRoute` keeps it running (the ring lands via the surface's
479
+ * `reapplyStepHighlight`). Any other navigation is foreign — the user left, or a
441
480
  * youngest-gesture-wins race — and tears the tour down, analytics-silent (`stopTour`).
442
481
  */
443
482
  #onLocationChange(path) {
@@ -446,15 +485,36 @@ export class GuideController {
446
485
  if (path === this.#knownPath)
447
486
  return; // no pathname change (e.g. a hash/query update) — ignore
448
487
  this.#knownPath = path;
449
- if (this.#expectedRoute !== null && path === this.#expectedRoute) {
450
- // The tour's own navigation landed keep running. For a *targeted* step, keep `#expectedRoute`
451
- // set until the target settles (`#applyStepHighlight`) so a redirecting / multi-hop source (the
452
- // Navigation API emitting one event per hop) firing a *second* event for the redirect target is
453
- // still recognized as a mismatch (and DEV-warned) instead of silently stopping. A *targetless*
454
- // route step has no target to wait for — it is settled on landing, so clear now; otherwise a
455
- // later foreign navigation would misfire the "navigated toward …" warning. (`#maybeNavigate`
456
- // also resets it on the next step. Clearing here can't move into `#applyStepHighlight`, which
457
- // runs synchronously in `#activateStep` before the nav lands it would mis-read our own nav.)
488
+ const matchesExpected = this.#expectedRoute !== null && path === this.#expectedRoute;
489
+ // A location report is the tour's OWN navigation in two cases: it matches the pending
490
+ // `#expectedRoute` (the async-source proxy, for a report landing a tick after the hook returns),
491
+ // or it arrives synchronously, re-entrant, during our injected `#navigate()` call
492
+ // (`#selfNavigating`). The latter is a *causal* signal our navigate produced this report,
493
+ // whatever path it carries so it covers a router-normalized landing (trailing slash,
494
+ // base/locale prefix) that an exact match would miss. Either keeps the tour running.
495
+ if (this.#selfNavigating || matchesExpected) {
496
+ // A *synchronous* report on a path that isn't the expected route is still causally ours, but it
497
+ // landed somewhere unexpected: a normalized variant of `step.route` (fine — the target anchors
498
+ // once it mounts) or a genuine redirect off the route (an auth guard → `/login`). We keep
499
+ // running either way — stopping would re-break the normalized case this fix exists for — but a
500
+ // landing that doesn't even look like a normalized variant is most likely a redirect, so
501
+ // surface it in DEV. (A targetless step has no "target never appeared" timer, so an off-route
502
+ // bubble is otherwise invisible in both DEV and PROD.)
503
+ if (this.#dev &&
504
+ this.#selfNavigating &&
505
+ this.#expectedRoute !== null &&
506
+ !matchesExpected &&
507
+ !looksLikeNormalizedRoute(path, this.#expectedRoute)) {
508
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${this.#expectedRoute}" but synchronously landed on "${path}" — keeping the tour running as its own navigation. If this was a redirect off the step's route (not a normalized form of it), stop the tour explicitly from your router guard.`);
509
+ }
510
+ // For a *targeted* step, keep `#expectedRoute` set until the target settles
511
+ // (`#applyStepHighlight`) so a redirecting / multi-hop source (the Navigation API emitting one
512
+ // event per hop) firing a *second* event for the redirect target is still recognized as a
513
+ // mismatch (and DEV-warned) instead of silently stopping. A *targetless* route step has no
514
+ // target to wait for — it is settled on landing, so clear now; otherwise a later foreign
515
+ // navigation would misfire the "navigated toward …" warning. (`#maybeNavigate` also resets it
516
+ // on the next step. Clearing here can't move into `#applyStepHighlight`, which runs
517
+ // synchronously in `#activateStep` before the nav lands — it would mis-read our own nav.)
458
518
  if (this.currentStep?.target == null)
459
519
  this.#expectedRoute = null;
460
520
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.8.0",
3
+ "version": "6.9.0",
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.8.0",
95
- "@urbicon-ui/shared-types": "6.8.0",
94
+ "@urbicon-ui/i18n": "6.9.0",
95
+ "@urbicon-ui/shared-types": "6.9.0",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",