@urbicon-ui/blocks 6.9.0 → 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.
@@ -1,11 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { useBlocksI18n } from '../..';
3
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
4
  import {
10
5
  journeyTimelineVariants,
11
6
  type JourneyTimelineSlots,
@@ -15,20 +10,16 @@
15
10
 
16
11
  const bt = useBlocksI18n();
17
12
 
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
13
  let {
24
14
  items,
25
15
  orientation = 'vertical',
26
16
  size = 'md',
17
+ detail: detailProp,
27
18
  focusId = $bindable(),
28
19
  defaultFocusId,
29
- scrollSpy = false,
30
20
  onFocusChange,
31
21
  node,
22
+ meta,
32
23
  class: className = '',
33
24
  unstyled: unstyledProp = false,
34
25
  slotClasses: slotClassesProp = {},
@@ -39,6 +30,14 @@
39
30
  const blocksConfig = getBlocksConfig();
40
31
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
41
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
+
42
41
  // --- focus state (controlled via bind:focusId, else internal) -------------
43
42
  const focusableItems = $derived(items.filter((it) => it.focusable !== false));
44
43
  const isFocusable = (id: string | undefined) =>
@@ -52,17 +51,17 @@
52
51
  );
53
52
 
54
53
  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.
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.
58
57
  const activeFocusId = $derived(
59
58
  focusId !== undefined ? focusId : isFocusable(internalFocusId) ? internalFocusId : defaultFocus
60
59
  );
61
60
 
62
61
  // 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.
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.
66
65
  let rovingId = $state<string | undefined>(undefined);
67
66
  // Every candidate is validated against the live focusable set (not just the
68
67
  // first non-undefined one), so a stale roving/active id — or a bad controlled
@@ -77,7 +76,7 @@
77
76
  if (!import.meta.env?.DEV || items.length === 0) return;
78
77
  if (focusId !== undefined && !isFocusable(focusId)) {
79
78
  console.warn(
80
- `[JourneyTimeline] focusId "${focusId}" matches no focusable node — every node stays collapsed.`
79
+ `[JourneyTimeline] focusId "${focusId}" matches no focusable node — no node shows its detail.`
81
80
  );
82
81
  }
83
82
  if (focusId === undefined && defaultFocusId !== undefined && !isFocusable(defaultFocusId)) {
@@ -87,22 +86,97 @@
87
86
  }
88
87
  if (!node && focusableItems.length > 0) {
89
88
  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.'
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.'
91
95
  );
92
96
  }
93
97
  });
94
98
 
95
- function setFocus(id: string) {
99
+ function setFocus(id: string): boolean {
96
100
  const target = items.find((it) => it.id === id);
97
- if (!target || target.focusable === false || id === activeFocusId) return;
101
+ if (!target || target.focusable === false || id === activeFocusId) return false;
98
102
  if (focusId !== undefined) focusId = id;
99
103
  else internalFocusId = id;
100
104
  onFocusChange?.(id);
105
+ return true;
101
106
  }
102
107
 
103
108
  function activate(item: JourneyNode) {
104
109
  rovingId = item.id;
105
- setFocus(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);
106
180
  }
107
181
 
108
182
  // --- keyboard roving navigation -------------------------------------------
@@ -161,47 +235,13 @@
161
235
  }
162
236
  }
163
237
 
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
238
  // --- styling ---------------------------------------------------------------
201
239
  type Styles = ReturnType<typeof journeyTimelineVariants>;
202
240
 
203
241
  const containerStyles = $derived(
204
- unstyled ? null : journeyTimelineVariants({ orientation, size })
242
+ unstyled
243
+ ? null
244
+ : journeyTimelineVariants({ orientation, size, detail: detailMode, withMeta: hasMetaRail })
205
245
  );
206
246
 
207
247
  function nodeStyles(item: JourneyNode, focused: boolean): Styles | null {
@@ -210,11 +250,14 @@
210
250
  : journeyTimelineVariants({
211
251
  orientation,
212
252
  size,
253
+ detail: detailMode,
254
+ withMeta: hasMetaRail,
213
255
  status: item.status,
214
256
  focused,
215
257
  interactive: item.focusable !== false,
216
258
  // The connector leaving a completed node reads as "travelled".
217
- connectorComplete: item.status === 'complete'
259
+ travelled: item.status === 'complete',
260
+ connectorStyle: item.connector ?? 'solid'
218
261
  });
219
262
  }
220
263
 
@@ -235,7 +278,6 @@
235
278
  }
236
279
 
237
280
  // --- misc derived ----------------------------------------------------------
238
- const iconSize = $derived(size === 'sm' ? 14 : size === 'lg' ? 20 : 16);
239
281
  const focusedItem = $derived(
240
282
  items.find((it) => it.focusable !== false && it.id === activeFocusId)
241
283
  );
@@ -250,21 +292,15 @@
250
292
  }
251
293
  </script>
252
294
 
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} />
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>
263
300
  {/if}
264
301
  {/snippet}
265
302
 
266
- {#snippet header(item: JourneyNode, styles: Styles | null)}
267
- <span class={sc(styles, 'marker')}>{@render markerGlyph(item)}</span>
303
+ {#snippet labels(item: JourneyNode, styles: Styles | null)}
268
304
  <span class={sc(styles, 'labelGroup')}>
269
305
  <span class={sc(styles, 'title')}>{item.title}</span>
270
306
  {#if item.subtitle}
@@ -285,17 +321,19 @@
285
321
  tabindex={item.id === tabbableId ? 0 : -1}
286
322
  aria-expanded={node ? focused : undefined}
287
323
  aria-controls={node
288
- ? orientation === 'horizontal'
289
- ? panelId
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
290
328
  : detailDomId(index)
291
329
  : undefined}
292
330
  onclick={() => activate(item)}
293
331
  >
294
- {@render header(item, styles)}
332
+ {@render labels(item, styles)}
295
333
  </button>
296
334
  {:else}
297
335
  <div class={sc(styles, 'trigger')} data-node-id={item.id}>
298
- {@render header(item, styles)}
336
+ {@render labels(item, styles)}
299
337
  </div>
300
338
  {/if}
301
339
  {/snippet}
@@ -304,6 +342,7 @@
304
342
  bind:this={rootRef}
305
343
  class={sc(containerStyles, 'base', className)}
306
344
  data-orientation={orientation}
345
+ data-detail={detailMode}
307
346
  {...restProps}
308
347
  >
309
348
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -314,6 +353,7 @@
314
353
  >
315
354
  {#each items as item, index (item.id)}
316
355
  {@const focused = item.focusable !== false && item.id === activeFocusId}
356
+ {@const last = index === items.length - 1}
317
357
  {@const styles = nodeStyles(item, focused)}
318
358
  {#if orientation === 'horizontal'}
319
359
  <li
@@ -322,8 +362,34 @@
322
362
  data-node-id={item.id}
323
363
  aria-current={item.status === 'active' ? 'step' : undefined}
324
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. -->
325
378
  {@render trigger(item, index, focused, styles)}
326
- <span data-journey-connector class={sc(styles, 'connector')} aria-hidden="true"></span>
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>
327
393
  </li>
328
394
  {:else}
329
395
  <li
@@ -332,25 +398,41 @@
332
398
  data-node-id={item.id}
333
399
  aria-current={item.status === 'active' ? 'step' : undefined}
334
400
  >
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>
401
+ {#if hasMetaRail}
402
+ <div class={sc(styles, 'metaColumn')}>
403
+ {@render metaCell(item, styles)}
340
404
  </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}
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>
352
429
  </div>
353
430
  </div>
431
+ {/if}
432
+ </div>
433
+ {#if item.segmentLabel && !last}
434
+ <div class={sc(styles, 'segment')} data-journey-segment="">
435
+ {item.segmentLabel}
354
436
  </div>
355
437
  {/if}
356
438
  </div>
@@ -359,11 +441,12 @@
359
441
  {/each}
360
442
  </ol>
361
443
 
362
- {#if orientation === 'horizontal' && node && focusedItem}
444
+ {#if detailMode === 'panel' && node && focusedItem}
363
445
  <div
364
446
  id={panelId}
365
447
  role="region"
366
448
  aria-label={focusedItem.title}
449
+ data-journey-panel=""
367
450
  class={sc(containerStyles, 'panel')}
368
451
  >
369
452
  {@render node(focusedItem)}
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import type { JourneyTimelineSlots, JourneyTimelineVariants } from './journey-timeline.variants.js';
4
- /** Lifecycle status of a journey node — drives marker colour and glyph. */
4
+ /** Lifecycle status of a journey node — drives marker colour and title tone. */
5
5
  export type JourneyStatus = 'complete' | 'active' | 'pending' | 'blocked' | 'skipped';
6
6
  /** A single node (waypoint) on a {@link JourneyTimelineProps | JourneyTimeline}. */
7
7
  export interface JourneyNode {
@@ -10,27 +10,54 @@ export interface JourneyNode {
10
10
  /** Node title, always visible next to the marker. */
11
11
  title: string;
12
12
  /**
13
- * Lifecycle status. Maps to a semantic marker colour + glyph:
14
- * `complete` (success), `active` (primary ), `pending` (empty circle),
15
- * `blocked` (danger), `skipped` (muted).
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.
16
17
  */
17
18
  status: JourneyStatus;
18
- /** Short status line shown below the title while collapsed. */
19
+ /** Short context line shown below the title. */
19
20
  subtitle?: string;
20
21
  /**
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.
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.
23
43
  * @default true
24
44
  */
25
45
  focusable?: boolean;
26
46
  }
27
47
  /**
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`.
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.
34
61
  *
35
62
  * @tag navigation
36
63
  * @tag display
@@ -39,26 +66,27 @@ export interface JourneyNode {
39
66
  * @related Accordion
40
67
  * @stability beta
41
68
  *
42
- * @example Vertical journey with inline detail
69
+ * @example Vertical chronicle with inline detail
43
70
  * ```svelte
44
71
  * <script lang="ts">
45
72
  * 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' }
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' }
50
78
  * ];
51
- * let focusId = $state('review');
79
+ * let focusId = $state('statement');
52
80
  * </script>
53
81
  *
54
- * <JourneyTimeline items={stages} bind:focusId>
82
+ * <JourneyTimeline items={run} bind:focusId>
55
83
  * {#snippet node(item)}
56
84
  * <p>Details for {item.title}…</p>
57
85
  * {/snippet}
58
86
  * </JourneyTimeline>
59
87
  * ```
60
88
  *
61
- * @example Horizontal lifecycle with a shared detail panel + scroll-spy off
89
+ * @example Horizontal lifecycle detail renders in the shared panel
62
90
  * ```svelte
63
91
  * <JourneyTimeline items={phases} orientation="horizontal" onFocusChange={(id) => track(id)}>
64
92
  * {#snippet node(item)}
@@ -70,36 +98,43 @@ export interface JourneyNode {
70
98
  export interface JourneyTimelineProps extends Pick<JourneyTimelineVariants, 'orientation' | 'size'>, Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
71
99
  /** The ordered journey nodes. */
72
100
  items: JourneyNode[];
73
- /** Layout direction. Vertical expands detail inline; horizontal into a panel below the rail. @default 'vertical' */
101
+ /** Layout direction. @default 'vertical' */
74
102
  orientation?: 'vertical' | 'horizontal';
75
103
  /** Marker + label scale. @default 'md' */
76
104
  size?: 'sm' | 'md' | 'lg';
77
105
  /**
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.
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.
81
117
  */
82
118
  focusId?: string;
83
119
  /** Initial focused node id in uncontrolled mode. Ignored once `focusId` is bound. */
84
120
  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). */
121
+ /** Fires when the focused node changes (click or keyboard). */
92
122
  onFocusChange?: (id: string) => void;
93
- /** Renders the body of the focused node. Receives the focused `JourneyNode`. */
123
+ /** Renders the detail of the focused node. Receives the focused `JourneyNode`. */
94
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]>;
95
130
  /** Extra classes merged onto the root element. */
96
131
  class?: string;
97
132
  /** Remove all default tv() classes. */
98
133
  unstyled?: boolean;
99
134
  /**
100
- * Per-slot class overrides. Slots: base | rail | node | trigger | marker |
101
- * connectorColumn | connector | labelGroup | title | subtitle | body | detail |
102
- * detailInner | detailContent | panel
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
103
138
  */
104
139
  slotClasses?: Partial<Record<JourneyTimelineSlots, string>>;
105
140
  /**