@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.
- package/dist/i18n/index.d.ts +20 -0
- package/dist/primitives/JourneyTimeline/JourneyTimeline.svelte +455 -0
- package/dist/primitives/JourneyTimeline/JourneyTimeline.svelte.d.ts +4 -0
- package/dist/primitives/JourneyTimeline/index.d.ts +149 -0
- package/dist/primitives/JourneyTimeline/index.js +2 -0
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +397 -0
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +329 -0
- package/dist/primitives/Stepper/index.d.ts +1 -0
- package/dist/primitives/Tab/tab.variants.d.ts +8 -8
- package/dist/primitives/index.d.ts +2 -0
- package/dist/primitives/index.js +1 -0
- package/dist/translations/de.d.ts +10 -0
- package/dist/translations/de.js +10 -0
- package/dist/translations/en.d.ts +10 -0
- package/dist/translations/en.js +10 -0
- package/package.json +3 -3
package/dist/i18n/index.d.ts
CHANGED
|
@@ -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"> </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,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';
|