@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.
- package/dist/primitives/JourneyTimeline/JourneyTimeline.svelte +178 -95
- package/dist/primitives/JourneyTimeline/index.d.ts +71 -36
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +174 -27
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +223 -69
- package/dist/primitives/Tab/tab.variants.d.ts +8 -8
- package/package.json +3 -3
|
@@ -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
|
|
56
|
-
// default so a node is always
|
|
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
|
-
//
|
|
64
|
-
// focus without
|
|
65
|
-
// against
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
{#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
?
|
|
289
|
-
?
|
|
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
|
|
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
|
|
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"> </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
|
-
<
|
|
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
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
|
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
|
|
14
|
-
* `complete` (success
|
|
15
|
-
* `blocked` (danger
|
|
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
|
|
19
|
+
/** Short context line shown below the title. */
|
|
19
20
|
subtitle?: string;
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
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
|
|
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
|
|
47
|
-
* { id: '
|
|
48
|
-
* { id: '
|
|
49
|
-
* { id: '
|
|
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('
|
|
79
|
+
* let focusId = $state('statement');
|
|
52
80
|
* </script>
|
|
53
81
|
*
|
|
54
|
-
* <JourneyTimeline items={
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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 |
|
|
101
|
-
*
|
|
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
|
/**
|