@urbicon-ui/blocks 6.8.1 → 6.10.1

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,455 @@
1
+ <script lang="ts">
2
+ import { useBlocksI18n } from '../..';
3
+ import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
+ import {
5
+ journeyTimelineVariants,
6
+ type JourneyTimelineSlots,
7
+ type JourneyTimelineVariants
8
+ } from './journey-timeline.variants';
9
+ import type { JourneyNode, JourneyStatus, JourneyTimelineProps } from './index';
10
+
11
+ const bt = useBlocksI18n();
12
+
13
+ let {
14
+ items,
15
+ orientation = 'vertical',
16
+ size = 'md',
17
+ detail: detailProp,
18
+ focusId = $bindable(),
19
+ defaultFocusId,
20
+ onFocusChange,
21
+ node,
22
+ meta,
23
+ class: className = '',
24
+ unstyled: unstyledProp = false,
25
+ slotClasses: slotClassesProp = {},
26
+ preset,
27
+ ...restProps
28
+ }: JourneyTimelineProps = $props();
29
+
30
+ const blocksConfig = getBlocksConfig();
31
+ const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
32
+
33
+ // Horizontal always renders the shared panel — inline expansion inside a
34
+ // horizontal rail has no sane geometry. `detail` only chooses for vertical.
35
+ const detailMode = $derived(orientation === 'horizontal' ? 'panel' : (detailProp ?? 'inline'));
36
+
37
+ // The chronicle axis renders as soon as any node carries `meta` (or a rich
38
+ // `meta` snippet is provided).
39
+ const hasMetaRail = $derived(!!meta || items.some((it) => it.meta !== undefined));
40
+
41
+ // --- focus state (controlled via bind:focusId, else internal) -------------
42
+ const focusableItems = $derived(items.filter((it) => it.focusable !== false));
43
+ const isFocusable = (id: string | undefined) =>
44
+ id !== undefined && focusableItems.some((it) => it.id === id);
45
+
46
+ // Default focus: explicit → first `active` node → first focusable node.
47
+ const defaultFocus = $derived(
48
+ (isFocusable(defaultFocusId) ? defaultFocusId : undefined) ??
49
+ items.find((it) => it.status === 'active' && it.focusable !== false)?.id ??
50
+ focusableItems[0]?.id
51
+ );
52
+
53
+ let internalFocusId = $state<string | undefined>(undefined);
54
+ // A stale internal focus (the focused node was removed) falls back to the
55
+ // default so a node is always in focus. A controlled `focusId` is honoured
56
+ // as-is (write-strict) and only surfaced via the dev warning below.
57
+ const activeFocusId = $derived(
58
+ focusId !== undefined ? focusId : isFocusable(internalFocusId) ? internalFocusId : defaultFocus
59
+ );
60
+
61
+ // The roving-tabindex target: the last keyboard/click-touched node, else the
62
+ // focused node. Kept distinct from `activeFocusId` so arrow keys can move DOM
63
+ // focus without changing the focused node (that happens on Enter/Space/click).
64
+ // Guarded against removed nodes so the tab stop never lands on nothing.
65
+ let rovingId = $state<string | undefined>(undefined);
66
+ // Every candidate is validated against the live focusable set (not just the
67
+ // first non-undefined one), so a stale roving/active id — or a bad controlled
68
+ // focusId — can never leave every button with tabindex=-1.
69
+ const tabbableId = $derived(
70
+ [rovingId, activeFocusId, focusableItems[0]?.id].find((id) => isFocusable(id))
71
+ );
72
+
73
+ // Dev-only signals for caller mistakes that otherwise fail silently. Mirrors
74
+ // the `Select`/`Guide` precedent (`import.meta.env?.DEV && console.warn`).
75
+ $effect(() => {
76
+ if (!import.meta.env?.DEV || items.length === 0) return;
77
+ if (focusId !== undefined && !isFocusable(focusId)) {
78
+ console.warn(
79
+ `[JourneyTimeline] focusId "${focusId}" matches no focusable node — no node shows its detail.`
80
+ );
81
+ }
82
+ if (focusId === undefined && defaultFocusId !== undefined && !isFocusable(defaultFocusId)) {
83
+ console.warn(
84
+ `[JourneyTimeline] defaultFocusId "${defaultFocusId}" matches no focusable node — falling back to the first active/focusable node.`
85
+ );
86
+ }
87
+ if (!node && focusableItems.length > 0) {
88
+ console.warn(
89
+ '[JourneyTimeline] focusable nodes render as buttons but no `node` snippet was provided. Pass a `node` snippet or set focusable:false on nodes that carry no detail.'
90
+ );
91
+ }
92
+ if (orientation === 'horizontal' && detailProp === 'inline') {
93
+ console.warn(
94
+ '[JourneyTimeline] detail="inline" is ignored for orientation="horizontal" — the horizontal rail always renders the shared panel.'
95
+ );
96
+ }
97
+ });
98
+
99
+ function setFocus(id: string): boolean {
100
+ const target = items.find((it) => it.id === id);
101
+ if (!target || target.focusable === false || id === activeFocusId) return false;
102
+ if (focusId !== undefined) focusId = id;
103
+ else internalFocusId = id;
104
+ onFocusChange?.(id);
105
+ return true;
106
+ }
107
+
108
+ function activate(item: JourneyNode) {
109
+ rovingId = item.id;
110
+ if (setFocus(item.id)) pinFocusAnchor(item.id);
111
+ }
112
+
113
+ // --- focus anchor pinning --------------------------------------------------
114
+ // When activating a node below the currently open card, that card's collapse
115
+ // would yank the freshly activated header up the page. While the height
116
+ // transition runs, counter-scroll each frame so the activated header stays
117
+ // visually stationary — the context moves, the focus point doesn't. Real user
118
+ // scroll input cancels the pin immediately. Inline vertical detail only; the
119
+ // panel modes never change geometry.
120
+ // Only one pin may drive the scroll at a time: activating another node while
121
+ // a pin is in flight cancels it first (two loops anchored to different
122
+ // headers would fight over the scroll position frame by frame).
123
+ let cancelActivePin: (() => void) | undefined;
124
+ function pinFocusAnchor(id: string) {
125
+ if (orientation !== 'vertical' || detailMode !== 'inline' || !node || !rootRef) return;
126
+ cancelActivePin?.();
127
+ const anchor = rootRef.querySelector<HTMLElement>(
128
+ `[data-journey-trigger][data-node-id="${id}"]`
129
+ );
130
+ if (!anchor) return;
131
+
132
+ // Nearest scrollable ancestor, else the window.
133
+ let scroller: HTMLElement | null = rootRef.parentElement;
134
+ while (scroller) {
135
+ const style = getComputedStyle(scroller);
136
+ if (/(auto|scroll)/.test(style.overflowY) && scroller.scrollHeight > scroller.clientHeight)
137
+ break;
138
+ scroller = scroller.parentElement;
139
+ }
140
+
141
+ // Pin for as long as the grid-rows transition runs (motion tokens collapse
142
+ // to 1ms under prefers-reduced-motion, so the pin is a single correction).
143
+ const detailEl = rootRef.querySelector<HTMLElement>('[data-journey-detail]');
144
+ const raw = detailEl ? getComputedStyle(detailEl).transitionDuration.split(',')[0].trim() : '';
145
+ const parsed = raw.endsWith('ms')
146
+ ? Number.parseFloat(raw)
147
+ : raw.endsWith('s')
148
+ ? Number.parseFloat(raw) * 1000
149
+ : Number.NaN;
150
+ const duration = Math.min(Number.isFinite(parsed) ? parsed + 80 : 380, 600);
151
+
152
+ const startTop = anchor.getBoundingClientRect().top;
153
+ const start = performance.now();
154
+ let cancelled = false;
155
+ const cancel = () => {
156
+ cancelled = true;
157
+ };
158
+ cancelActivePin = cancel;
159
+ window.addEventListener('wheel', cancel, { passive: true, capture: true });
160
+ window.addEventListener('touchmove', cancel, { passive: true, capture: true });
161
+ const cleanup = () => {
162
+ window.removeEventListener('wheel', cancel, { capture: true });
163
+ window.removeEventListener('touchmove', cancel, { capture: true });
164
+ if (cancelActivePin === cancel) cancelActivePin = undefined;
165
+ };
166
+ const step = () => {
167
+ if (cancelled || !anchor.isConnected) {
168
+ cleanup();
169
+ return;
170
+ }
171
+ const drift = anchor.getBoundingClientRect().top - startTop;
172
+ if (drift !== 0) {
173
+ if (scroller) scroller.scrollTop += drift;
174
+ else window.scrollBy(0, drift);
175
+ }
176
+ if (performance.now() - start < duration) requestAnimationFrame(step);
177
+ else cleanup();
178
+ };
179
+ requestAnimationFrame(step);
180
+ }
181
+
182
+ // --- keyboard roving navigation -------------------------------------------
183
+ let rootRef = $state<HTMLDivElement>();
184
+
185
+ function triggerEls(): HTMLElement[] {
186
+ return rootRef
187
+ ? Array.from(rootRef.querySelectorAll<HTMLElement>('[data-journey-trigger]'))
188
+ : [];
189
+ }
190
+
191
+ function moveFocus(delta: number) {
192
+ const els = triggerEls();
193
+ if (els.length === 0) return;
194
+ const currentId = rovingId ?? activeFocusId;
195
+ const currentIdx = els.findIndex((el) => el.dataset.nodeId === currentId);
196
+ const startIdx = currentIdx === -1 ? (delta > 0 ? -1 : 0) : currentIdx;
197
+ const next = els[(startIdx + delta + els.length) % els.length];
198
+ if (!next) return;
199
+ rovingId = next.dataset.nodeId;
200
+ next.focus();
201
+ }
202
+
203
+ function focusEdge(edge: 'first' | 'last') {
204
+ const els = triggerEls();
205
+ const el = edge === 'first' ? els[0] : els[els.length - 1];
206
+ if (!el) return;
207
+ rovingId = el.dataset.nodeId;
208
+ el.focus();
209
+ }
210
+
211
+ function handleKeydown(e: KeyboardEvent) {
212
+ // Only navigate when a node header is focused — never hijack arrow keys
213
+ // typed inside an expanded node's detail content.
214
+ if (!(e.target as HTMLElement)?.hasAttribute?.('data-journey-trigger')) return;
215
+ const forward = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
216
+ const backward = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
217
+ switch (e.key) {
218
+ case forward:
219
+ e.preventDefault();
220
+ moveFocus(1);
221
+ break;
222
+ case backward:
223
+ e.preventDefault();
224
+ moveFocus(-1);
225
+ break;
226
+ case 'Home':
227
+ e.preventDefault();
228
+ focusEdge('first');
229
+ break;
230
+ case 'End':
231
+ e.preventDefault();
232
+ focusEdge('last');
233
+ break;
234
+ // Enter / Space activate via the native <button> click → activate().
235
+ }
236
+ }
237
+
238
+ // --- styling ---------------------------------------------------------------
239
+ type Styles = ReturnType<typeof journeyTimelineVariants>;
240
+
241
+ const containerStyles = $derived(
242
+ unstyled
243
+ ? null
244
+ : journeyTimelineVariants({ orientation, size, detail: detailMode, withMeta: hasMetaRail })
245
+ );
246
+
247
+ function nodeStyles(item: JourneyNode, focused: boolean): Styles | null {
248
+ return unstyled
249
+ ? null
250
+ : journeyTimelineVariants({
251
+ orientation,
252
+ size,
253
+ detail: detailMode,
254
+ withMeta: hasMetaRail,
255
+ status: item.status,
256
+ focused,
257
+ interactive: item.focusable !== false,
258
+ // The connector leaving a completed node reads as "travelled".
259
+ travelled: item.status === 'complete',
260
+ connectorStyle: item.connector ?? 'solid'
261
+ });
262
+ }
263
+
264
+ const slotClasses = $derived(
265
+ resolveSlotClasses(
266
+ blocksConfig,
267
+ 'JourneyTimeline',
268
+ preset,
269
+ { orientation, size } satisfies JourneyTimelineVariants,
270
+ slotClassesProp
271
+ )
272
+ );
273
+
274
+ function sc(styles: Styles | null, key: JourneyTimelineSlots, extra?: string | false) {
275
+ const override = [slotClasses?.[key], extra].filter(Boolean).join(' ') || undefined;
276
+ if (unstyled || !styles) return override;
277
+ return styles[key]({ class: override });
278
+ }
279
+
280
+ // --- misc derived ----------------------------------------------------------
281
+ const focusedItem = $derived(
282
+ items.find((it) => it.focusable !== false && it.id === activeFocusId)
283
+ );
284
+
285
+ const propsId = $props.id();
286
+ const uid = `journey-${propsId}`;
287
+ const detailDomId = (index: number) => `${uid}-detail-${index}`;
288
+ const panelId = `${uid}-panel`;
289
+
290
+ function statusLabel(status: JourneyStatus) {
291
+ return bt(`journeyTimeline.status.${status}`);
292
+ }
293
+ </script>
294
+
295
+ {#snippet metaCell(item: JourneyNode, styles: Styles | null)}
296
+ {#if meta}
297
+ {@render meta(item)}
298
+ {:else if item.meta !== undefined}
299
+ <span class={sc(styles, 'meta')}>{item.meta}</span>
300
+ {/if}
301
+ {/snippet}
302
+
303
+ {#snippet labels(item: JourneyNode, styles: Styles | null)}
304
+ <span class={sc(styles, 'labelGroup')}>
305
+ <span class={sc(styles, 'title')}>{item.title}</span>
306
+ {#if item.subtitle}
307
+ <span class={sc(styles, 'subtitle')}>{item.subtitle}</span>
308
+ {/if}
309
+ </span>
310
+ <span class="sr-only">{statusLabel(item.status)}</span>
311
+ {/snippet}
312
+
313
+ {#snippet trigger(item: JourneyNode, index: number, focused: boolean, styles: Styles | null)}
314
+ {@const interactive = item.focusable !== false}
315
+ {#if interactive}
316
+ <button
317
+ type="button"
318
+ class={sc(styles, 'trigger')}
319
+ data-journey-trigger=""
320
+ data-node-id={item.id}
321
+ tabindex={item.id === tabbableId ? 0 : -1}
322
+ aria-expanded={node ? focused : undefined}
323
+ aria-controls={node
324
+ ? detailMode === 'panel'
325
+ ? // Reference the shared readout only while it actually renders — a
326
+ // bad controlled focusId leaves no panel to point at.
327
+ focusedItem && panelId
328
+ : detailDomId(index)
329
+ : undefined}
330
+ onclick={() => activate(item)}
331
+ >
332
+ {@render labels(item, styles)}
333
+ </button>
334
+ {:else}
335
+ <div class={sc(styles, 'trigger')} data-node-id={item.id}>
336
+ {@render labels(item, styles)}
337
+ </div>
338
+ {/if}
339
+ {/snippet}
340
+
341
+ <div
342
+ bind:this={rootRef}
343
+ class={sc(containerStyles, 'base', className)}
344
+ data-orientation={orientation}
345
+ data-detail={detailMode}
346
+ {...restProps}
347
+ >
348
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
349
+ <ol
350
+ class={sc(containerStyles, 'rail')}
351
+ aria-label={bt('journeyTimeline.label')}
352
+ onkeydown={handleKeydown}
353
+ >
354
+ {#each items as item, index (item.id)}
355
+ {@const focused = item.focusable !== false && item.id === activeFocusId}
356
+ {@const last = index === items.length - 1}
357
+ {@const styles = nodeStyles(item, focused)}
358
+ {#if orientation === 'horizontal'}
359
+ <li
360
+ class={sc(styles, 'node')}
361
+ data-journey-node=""
362
+ data-node-id={item.id}
363
+ aria-current={item.status === 'active' ? 'step' : undefined}
364
+ >
365
+ {#if hasMetaRail}
366
+ <div class={sc(styles, 'metaColumn')}>
367
+ {#if meta || item.meta !== undefined}
368
+ {@render metaCell(item, styles)}
369
+ {:else}
370
+ <!-- Empty placeholder keeps every station's meta row the same
371
+ height, so markers and titles stay on one baseline. -->
372
+ <span class={sc(styles, 'meta')} aria-hidden="true">&nbsp;</span>
373
+ {/if}
374
+ </div>
375
+ {/if}
376
+ <!-- DOM order trigger → spine (visual order flipped via order-*):
377
+ a segment label is announced after the node it departs from. -->
378
+ {@render trigger(item, index, focused, styles)}
379
+ <div class={sc(styles, 'markerColumn')}>
380
+ <span class={sc(styles, 'marker')} data-journey-marker="" aria-hidden="true"></span>
381
+ {#if !last}
382
+ <span data-journey-connector="" class={sc(styles, 'connector')} aria-hidden="true"
383
+ ></span>
384
+ {#if item.segmentLabel}
385
+ <span class={sc(styles, 'segment')} data-journey-segment="">
386
+ {item.segmentLabel}
387
+ </span>
388
+ <span data-journey-connector="" class={sc(styles, 'connector')} aria-hidden="true"
389
+ ></span>
390
+ {/if}
391
+ {/if}
392
+ </div>
393
+ </li>
394
+ {:else}
395
+ <li
396
+ class={sc(styles, 'node')}
397
+ data-journey-node=""
398
+ data-node-id={item.id}
399
+ aria-current={item.status === 'active' ? 'step' : undefined}
400
+ >
401
+ {#if hasMetaRail}
402
+ <div class={sc(styles, 'metaColumn')}>
403
+ {@render metaCell(item, styles)}
404
+ </div>
405
+ {/if}
406
+ <div class={sc(styles, 'markerColumn')}>
407
+ <span class={sc(styles, 'marker')} data-journey-marker="" aria-hidden="true"></span>
408
+ {#if !last}
409
+ <span data-journey-connector="" class={sc(styles, 'connector')} aria-hidden="true"
410
+ ></span>
411
+ {/if}
412
+ </div>
413
+ <div class={sc(styles, 'content', last && 'pb-0')}>
414
+ <div class={sc(styles, 'card')}>
415
+ {@render trigger(item, index, focused, styles)}
416
+ {#if detailMode === 'inline' && node && item.focusable !== false}
417
+ <div
418
+ id={detailDomId(index)}
419
+ role="region"
420
+ aria-label={item.title}
421
+ data-journey-detail=""
422
+ class={sc(styles, 'detail')}
423
+ style="grid-template-rows: {focused ? '1fr' : '0fr'}"
424
+ >
425
+ <div class={sc(styles, 'detailInner')}>
426
+ <div class={sc(styles, 'detailContent')}>
427
+ {#if focused}{@render node(item)}{/if}
428
+ </div>
429
+ </div>
430
+ </div>
431
+ {/if}
432
+ </div>
433
+ {#if item.segmentLabel && !last}
434
+ <div class={sc(styles, 'segment')} data-journey-segment="">
435
+ {item.segmentLabel}
436
+ </div>
437
+ {/if}
438
+ </div>
439
+ </li>
440
+ {/if}
441
+ {/each}
442
+ </ol>
443
+
444
+ {#if detailMode === 'panel' && node && focusedItem}
445
+ <div
446
+ id={panelId}
447
+ role="region"
448
+ aria-label={focusedItem.title}
449
+ data-journey-panel=""
450
+ class={sc(containerStyles, 'panel')}
451
+ >
452
+ {@render node(focusedItem)}
453
+ </div>
454
+ {/if}
455
+ </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,149 @@
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 title tone. */
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 dot marker + title tone:
14
+ * `complete` (success), `active` (primary, ringed), `pending` (hollow),
15
+ * `blocked` (danger), `skipped` (muted). The status is also announced through
16
+ * a visually-hidden label.
17
+ */
18
+ status: JourneyStatus;
19
+ /** Short context line shown below the title. */
20
+ subtitle?: string;
21
+ /**
22
+ * Label on the chronicle axis (the meta rail left of the markers) — a time,
23
+ * date, version, actor…. The rail renders as soon as any node provides `meta`
24
+ * (or the `meta` snippet is set). Vertical orientation renders it as a
25
+ * right-aligned column; horizontal as a date row above the spine.
26
+ */
27
+ meta?: string;
28
+ /**
29
+ * Line style of the connector *leaving* this node — lets the connector carry
30
+ * meaning (e.g. solid = ride, dashed/dotted = transfer, walk, gap in the
31
+ * record). @default 'solid'
32
+ */
33
+ connector?: 'solid' | 'dashed' | 'dotted';
34
+ /**
35
+ * Label for the segment between this node and the next (a duration, transport
36
+ * mode, "3 days in transit"…). Rendered along the connector; ignored on the
37
+ * last node.
38
+ */
39
+ segmentLabel?: string;
40
+ /**
41
+ * When `false`, the node is a pure waypoint: it renders marker + labels but
42
+ * cannot receive focus, shows no detail, and is skipped by keyboard navigation.
43
+ * @default true
44
+ */
45
+ focusable?: boolean;
46
+ }
47
+ /**
48
+ * @description Retrospective chronicle timeline (focus + context): an ordered
49
+ * record of what happened / where things stand — shipment tracking, audit
50
+ * trails, travel logs, billing runs. Exactly one focusable node is in focus and
51
+ * shows rich detail (`node` snippet); the rest stay quiet, compact context
52
+ * rows. The chronicle axis is first-class: per-node `meta` (time/date/actor)
53
+ * renders on a meta rail, connectors carry meaning (`solid | dashed | dotted`)
54
+ * and `segmentLabel` annotates the stretch between nodes. Detail placement is
55
+ * configurable: `detail="inline"` expands in place (vertical default);
56
+ * `detail="panel"` renders a stable readout beside (wide) or docked below
57
+ * (narrow) the rail — horizontal always uses the panel. Use `Stepper` for a
58
+ * prospective wizard/progress indicator and `Tab` for switching between peer
59
+ * views; JourneyTimeline is read-only observation of a sequence, not process
60
+ * control or navigation.
61
+ *
62
+ * @tag navigation
63
+ * @tag display
64
+ * @related Stepper
65
+ * @related Tab
66
+ * @related Accordion
67
+ * @stability beta
68
+ *
69
+ * @example Vertical chronicle with inline detail
70
+ * ```svelte
71
+ * <script lang="ts">
72
+ * import { JourneyTimeline, type JourneyNode } from '@urbicon-ui/blocks';
73
+ * const run: JourneyNode[] = [
74
+ * { id: 'readings', title: 'Meter readings', status: 'complete', meta: '3 Jun', segmentLabel: '2 days · validation' },
75
+ * { id: 'validate', title: 'Validation', status: 'complete', meta: '5 Jun', connector: 'dashed', segmentLabel: 'manual review' },
76
+ * { id: 'statement', title: 'Statements', status: 'active', meta: '6 Jun' },
77
+ * { id: 'dispatch', title: 'Dispatch', status: 'pending' }
78
+ * ];
79
+ * let focusId = $state('statement');
80
+ * </script>
81
+ *
82
+ * <JourneyTimeline items={run} bind:focusId>
83
+ * {#snippet node(item)}
84
+ * <p>Details for {item.title}…</p>
85
+ * {/snippet}
86
+ * </JourneyTimeline>
87
+ * ```
88
+ *
89
+ * @example Horizontal lifecycle — detail renders in the shared panel
90
+ * ```svelte
91
+ * <JourneyTimeline items={phases} orientation="horizontal" onFocusChange={(id) => track(id)}>
92
+ * {#snippet node(item)}
93
+ * <PhaseSummary phase={item.id} />
94
+ * {/snippet}
95
+ * </JourneyTimeline>
96
+ * ```
97
+ */
98
+ export interface JourneyTimelineProps extends Pick<JourneyTimelineVariants, 'orientation' | 'size'>, Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
99
+ /** The ordered journey nodes. */
100
+ items: JourneyNode[];
101
+ /** Layout direction. @default 'vertical' */
102
+ orientation?: 'vertical' | 'horizontal';
103
+ /** Marker + label scale. @default 'md' */
104
+ size?: 'sm' | 'md' | 'lg';
105
+ /**
106
+ * Where the focused node's detail renders. `inline` expands in place inside
107
+ * the rail; `panel` renders a stable readout — beside the rail on wide
108
+ * viewports, docked to the viewport bottom on narrow ones. Horizontal
109
+ * orientation always uses the panel and ignores `inline` (DEV warning).
110
+ * @default 'inline' (vertical) / 'panel' (horizontal)
111
+ */
112
+ detail?: 'inline' | 'panel';
113
+ /**
114
+ * The focused node id. Supports `bind:focusId`. When omitted the component is
115
+ * uncontrolled and falls back to `defaultFocusId`, then the first `active`
116
+ * node, then the first focusable node.
117
+ */
118
+ focusId?: string;
119
+ /** Initial focused node id in uncontrolled mode. Ignored once `focusId` is bound. */
120
+ defaultFocusId?: string;
121
+ /** Fires when the focused node changes (click or keyboard). */
122
+ onFocusChange?: (id: string) => void;
123
+ /** Renders the detail of the focused node. Receives the focused `JourneyNode`. */
124
+ node?: Snippet<[JourneyNode]>;
125
+ /**
126
+ * Rich override for the meta rail — receives each `JourneyNode` and replaces
127
+ * the plain `item.meta` text (e.g. planned + actual time with a Badge).
128
+ */
129
+ meta?: Snippet<[JourneyNode]>;
130
+ /** Extra classes merged onto the root element. */
131
+ class?: string;
132
+ /** Remove all default tv() classes. */
133
+ unstyled?: boolean;
134
+ /**
135
+ * Per-slot class overrides. Slots: base | rail | node | metaColumn | meta |
136
+ * markerColumn | marker | connector | content | card | trigger | labelGroup |
137
+ * title | subtitle | segment | detail | detailInner | detailContent | panel
138
+ */
139
+ slotClasses?: Partial<Record<JourneyTimelineSlots, string>>;
140
+ /**
141
+ * Apply a named preset registered via `<BlocksProvider presets={{ JourneyTimeline: {...} }}>`.
142
+ * Prefer this over `class` overrides when the requested look falls outside the
143
+ * semantic intent palette — presets keep hover/active/dark-mode logic coherent
144
+ * and make the custom look reusable across the project.
145
+ */
146
+ preset?: string;
147
+ }
148
+ export { default as JourneyTimeline } from './JourneyTimeline.svelte';
149
+ 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';