@urbicon-ui/blocks 6.9.0 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,6 +137,7 @@ declare const blocksTranslations: {
137
137
  readonly complete: "Completed";
138
138
  readonly active: "In progress";
139
139
  readonly pending: "Pending";
140
+ readonly attention: "Needs attention";
140
141
  readonly blocked: "Blocked";
141
142
  readonly skipped: "Skipped";
142
143
  };
@@ -318,6 +319,7 @@ declare const blocksTranslations: {
318
319
  readonly complete: "Abgeschlossen";
319
320
  readonly active: "In Bearbeitung";
320
321
  readonly pending: "Ausstehend";
322
+ readonly attention: "Aufmerksamkeit erforderlich";
321
323
  readonly blocked: "Blockiert";
322
324
  readonly skipped: "Übersprungen";
323
325
  };
@@ -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,18 @@
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,
23
+ marker,
24
+ trailing,
32
25
  class: className = '',
33
26
  unstyled: unstyledProp = false,
34
27
  slotClasses: slotClassesProp = {},
@@ -39,6 +32,14 @@
39
32
  const blocksConfig = getBlocksConfig();
40
33
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
41
34
 
35
+ // Horizontal always renders the shared panel — inline expansion inside a
36
+ // horizontal rail has no sane geometry. `detail` only chooses for vertical.
37
+ const detailMode = $derived(orientation === 'horizontal' ? 'panel' : (detailProp ?? 'inline'));
38
+
39
+ // The chronicle axis renders as soon as any node carries `meta` (or a rich
40
+ // `meta` snippet is provided).
41
+ const hasMetaRail = $derived(!!meta || items.some((it) => it.meta !== undefined));
42
+
42
43
  // --- focus state (controlled via bind:focusId, else internal) -------------
43
44
  const focusableItems = $derived(items.filter((it) => it.focusable !== false));
44
45
  const isFocusable = (id: string | undefined) =>
@@ -52,17 +53,17 @@
52
53
  );
53
54
 
54
55
  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.
56
+ // A stale internal focus (the focused node was removed) falls back to the
57
+ // default so a node is always in focus. A controlled `focusId` is honoured
58
+ // as-is (write-strict) and only surfaced via the dev warning below.
58
59
  const activeFocusId = $derived(
59
60
  focusId !== undefined ? focusId : isFocusable(internalFocusId) ? internalFocusId : defaultFocus
60
61
  );
61
62
 
62
63
  // 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.
64
+ // focused node. Kept distinct from `activeFocusId` so arrow keys can move DOM
65
+ // focus without changing the focused node (that happens on Enter/Space/click).
66
+ // Guarded against removed nodes so the tab stop never lands on nothing.
66
67
  let rovingId = $state<string | undefined>(undefined);
67
68
  // Every candidate is validated against the live focusable set (not just the
68
69
  // first non-undefined one), so a stale roving/active id — or a bad controlled
@@ -77,7 +78,7 @@
77
78
  if (!import.meta.env?.DEV || items.length === 0) return;
78
79
  if (focusId !== undefined && !isFocusable(focusId)) {
79
80
  console.warn(
80
- `[JourneyTimeline] focusId "${focusId}" matches no focusable node — every node stays collapsed.`
81
+ `[JourneyTimeline] focusId "${focusId}" matches no focusable node — no node shows its detail.`
81
82
  );
82
83
  }
83
84
  if (focusId === undefined && defaultFocusId !== undefined && !isFocusable(defaultFocusId)) {
@@ -87,22 +88,97 @@
87
88
  }
88
89
  if (!node && focusableItems.length > 0) {
89
90
  console.warn(
90
- '[JourneyTimeline] focusable nodes render as expandable buttons but no `node` snippet was provided. Pass a `node` snippet or set focusable:false on nodes that carry no detail.'
91
+ '[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.'
92
+ );
93
+ }
94
+ if (orientation === 'horizontal' && detailProp === 'inline') {
95
+ console.warn(
96
+ '[JourneyTimeline] detail="inline" is ignored for orientation="horizontal" — the horizontal rail always renders the shared panel.'
91
97
  );
92
98
  }
93
99
  });
94
100
 
95
- function setFocus(id: string) {
101
+ function setFocus(id: string): boolean {
96
102
  const target = items.find((it) => it.id === id);
97
- if (!target || target.focusable === false || id === activeFocusId) return;
103
+ if (!target || target.focusable === false || id === activeFocusId) return false;
98
104
  if (focusId !== undefined) focusId = id;
99
105
  else internalFocusId = id;
100
106
  onFocusChange?.(id);
107
+ return true;
101
108
  }
102
109
 
103
110
  function activate(item: JourneyNode) {
104
111
  rovingId = item.id;
105
- setFocus(item.id);
112
+ if (setFocus(item.id)) pinFocusAnchor(item.id);
113
+ }
114
+
115
+ // --- focus anchor pinning --------------------------------------------------
116
+ // When activating a node below the currently open card, that card's collapse
117
+ // would yank the freshly activated header up the page. While the height
118
+ // transition runs, counter-scroll each frame so the activated header stays
119
+ // visually stationary — the context moves, the focus point doesn't. Real user
120
+ // scroll input cancels the pin immediately. Inline vertical detail only; the
121
+ // panel modes never change geometry.
122
+ // Only one pin may drive the scroll at a time: activating another node while
123
+ // a pin is in flight cancels it first (two loops anchored to different
124
+ // headers would fight over the scroll position frame by frame).
125
+ let cancelActivePin: (() => void) | undefined;
126
+ function pinFocusAnchor(id: string) {
127
+ if (orientation !== 'vertical' || detailMode !== 'inline' || !node || !rootRef) return;
128
+ cancelActivePin?.();
129
+ const anchor = rootRef.querySelector<HTMLElement>(
130
+ `[data-journey-trigger][data-node-id="${id}"]`
131
+ );
132
+ if (!anchor) return;
133
+
134
+ // Nearest scrollable ancestor, else the window.
135
+ let scroller: HTMLElement | null = rootRef.parentElement;
136
+ while (scroller) {
137
+ const style = getComputedStyle(scroller);
138
+ if (/(auto|scroll)/.test(style.overflowY) && scroller.scrollHeight > scroller.clientHeight)
139
+ break;
140
+ scroller = scroller.parentElement;
141
+ }
142
+
143
+ // Pin for as long as the grid-rows transition runs (motion tokens collapse
144
+ // to 1ms under prefers-reduced-motion, so the pin is a single correction).
145
+ const detailEl = rootRef.querySelector<HTMLElement>('[data-journey-detail]');
146
+ const raw = detailEl ? getComputedStyle(detailEl).transitionDuration.split(',')[0].trim() : '';
147
+ const parsed = raw.endsWith('ms')
148
+ ? Number.parseFloat(raw)
149
+ : raw.endsWith('s')
150
+ ? Number.parseFloat(raw) * 1000
151
+ : Number.NaN;
152
+ const duration = Math.min(Number.isFinite(parsed) ? parsed + 80 : 380, 600);
153
+
154
+ const startTop = anchor.getBoundingClientRect().top;
155
+ const start = performance.now();
156
+ let cancelled = false;
157
+ const cancel = () => {
158
+ cancelled = true;
159
+ };
160
+ cancelActivePin = cancel;
161
+ window.addEventListener('wheel', cancel, { passive: true, capture: true });
162
+ window.addEventListener('touchmove', cancel, { passive: true, capture: true });
163
+ const cleanup = () => {
164
+ window.removeEventListener('wheel', cancel, { capture: true });
165
+ window.removeEventListener('touchmove', cancel, { capture: true });
166
+ if (cancelActivePin === cancel) cancelActivePin = undefined;
167
+ };
168
+ const step = () => {
169
+ if (cancelled || !anchor.isConnected) {
170
+ cleanup();
171
+ return;
172
+ }
173
+ const drift = anchor.getBoundingClientRect().top - startTop;
174
+ if (drift !== 0) {
175
+ if (scroller) scroller.scrollTop += drift;
176
+ else window.scrollBy(0, drift);
177
+ }
178
+ if (performance.now() - start < duration) requestAnimationFrame(step);
179
+ else cleanup();
180
+ };
181
+ requestAnimationFrame(step);
106
182
  }
107
183
 
108
184
  // --- keyboard roving navigation -------------------------------------------
@@ -161,47 +237,13 @@
161
237
  }
162
238
  }
163
239
 
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
240
  // --- styling ---------------------------------------------------------------
201
241
  type Styles = ReturnType<typeof journeyTimelineVariants>;
202
242
 
203
243
  const containerStyles = $derived(
204
- unstyled ? null : journeyTimelineVariants({ orientation, size })
244
+ unstyled
245
+ ? null
246
+ : journeyTimelineVariants({ orientation, size, detail: detailMode, withMeta: hasMetaRail })
205
247
  );
206
248
 
207
249
  function nodeStyles(item: JourneyNode, focused: boolean): Styles | null {
@@ -210,11 +252,14 @@
210
252
  : journeyTimelineVariants({
211
253
  orientation,
212
254
  size,
255
+ detail: detailMode,
256
+ withMeta: hasMetaRail,
213
257
  status: item.status,
214
258
  focused,
215
259
  interactive: item.focusable !== false,
216
260
  // The connector leaving a completed node reads as "travelled".
217
- connectorComplete: item.status === 'complete'
261
+ travelled: item.status === 'complete',
262
+ connectorStyle: item.connector ?? 'solid'
218
263
  });
219
264
  }
220
265
 
@@ -235,7 +280,6 @@
235
280
  }
236
281
 
237
282
  // --- misc derived ----------------------------------------------------------
238
- const iconSize = $derived(size === 'sm' ? 14 : size === 'lg' ? 20 : 16);
239
283
  const focusedItem = $derived(
240
284
  items.find((it) => it.focusable !== false && it.id === activeFocusId)
241
285
  );
@@ -250,21 +294,15 @@
250
294
  }
251
295
  </script>
252
296
 
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} />
297
+ {#snippet metaCell(item: JourneyNode, styles: Styles | null)}
298
+ {#if meta}
299
+ {@render meta(item)}
300
+ {:else if item.meta !== undefined}
301
+ <span class={sc(styles, 'meta')}>{item.meta}</span>
263
302
  {/if}
264
303
  {/snippet}
265
304
 
266
- {#snippet header(item: JourneyNode, styles: Styles | null)}
267
- <span class={sc(styles, 'marker')}>{@render markerGlyph(item)}</span>
305
+ {#snippet labels(item: JourneyNode, styles: Styles | null)}
268
306
  <span class={sc(styles, 'labelGroup')}>
269
307
  <span class={sc(styles, 'title')}>{item.title}</span>
270
308
  {#if item.subtitle}
@@ -274,6 +312,25 @@
274
312
  <span class="sr-only">{statusLabel(item.status)}</span>
275
313
  {/snippet}
276
314
 
315
+ {#snippet markerDot(item: JourneyNode, styles: Styles | null)}
316
+ <!-- Decorative either way: the status is announced via the sr-only label,
317
+ so custom glyph content never needs to be readable. -->
318
+ <span class={sc(styles, 'marker')} data-journey-marker="" aria-hidden="true">
319
+ {#if marker}{@render marker(item)}{/if}
320
+ </span>
321
+ {/snippet}
322
+
323
+ {#snippet headerRow(item: JourneyNode, index: number, focused: boolean, styles: Styles | null)}
324
+ <div class={sc(styles, 'header')}>
325
+ {@render trigger(item, index, focused, styles)}
326
+ {#if trailing}
327
+ <div class={sc(styles, 'trailing')} data-journey-trailing="">
328
+ {@render trailing(item)}
329
+ </div>
330
+ {/if}
331
+ </div>
332
+ {/snippet}
333
+
277
334
  {#snippet trigger(item: JourneyNode, index: number, focused: boolean, styles: Styles | null)}
278
335
  {@const interactive = item.focusable !== false}
279
336
  {#if interactive}
@@ -285,17 +342,19 @@
285
342
  tabindex={item.id === tabbableId ? 0 : -1}
286
343
  aria-expanded={node ? focused : undefined}
287
344
  aria-controls={node
288
- ? orientation === 'horizontal'
289
- ? panelId
345
+ ? detailMode === 'panel'
346
+ ? // Reference the shared readout only while it actually renders — a
347
+ // bad controlled focusId leaves no panel to point at.
348
+ focusedItem && panelId
290
349
  : detailDomId(index)
291
350
  : undefined}
292
351
  onclick={() => activate(item)}
293
352
  >
294
- {@render header(item, styles)}
353
+ {@render labels(item, styles)}
295
354
  </button>
296
355
  {:else}
297
356
  <div class={sc(styles, 'trigger')} data-node-id={item.id}>
298
- {@render header(item, styles)}
357
+ {@render labels(item, styles)}
299
358
  </div>
300
359
  {/if}
301
360
  {/snippet}
@@ -304,6 +363,7 @@
304
363
  bind:this={rootRef}
305
364
  class={sc(containerStyles, 'base', className)}
306
365
  data-orientation={orientation}
366
+ data-detail={detailMode}
307
367
  {...restProps}
308
368
  >
309
369
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -314,6 +374,7 @@
314
374
  >
315
375
  {#each items as item, index (item.id)}
316
376
  {@const focused = item.focusable !== false && item.id === activeFocusId}
377
+ {@const last = index === items.length - 1}
317
378
  {@const styles = nodeStyles(item, focused)}
318
379
  {#if orientation === 'horizontal'}
319
380
  <li
@@ -322,8 +383,34 @@
322
383
  data-node-id={item.id}
323
384
  aria-current={item.status === 'active' ? 'step' : undefined}
324
385
  >
325
- {@render trigger(item, index, focused, styles)}
326
- <span data-journey-connector class={sc(styles, 'connector')} aria-hidden="true"></span>
386
+ {#if hasMetaRail}
387
+ <div class={sc(styles, 'metaColumn')}>
388
+ {#if meta || item.meta !== undefined}
389
+ {@render metaCell(item, styles)}
390
+ {:else}
391
+ <!-- Empty placeholder keeps every station's meta row the same
392
+ height, so markers and titles stay on one baseline. -->
393
+ <span class={sc(styles, 'meta')} aria-hidden="true">&nbsp;</span>
394
+ {/if}
395
+ </div>
396
+ {/if}
397
+ <!-- DOM order header → spine (visual order flipped via order-*):
398
+ a segment label is announced after the node it departs from. -->
399
+ {@render headerRow(item, index, focused, styles)}
400
+ <div class={sc(styles, 'markerColumn')}>
401
+ {@render markerDot(item, styles)}
402
+ {#if !last}
403
+ <span data-journey-connector="" class={sc(styles, 'connector')} aria-hidden="true"
404
+ ></span>
405
+ {#if item.segmentLabel}
406
+ <span class={sc(styles, 'segment')} data-journey-segment="">
407
+ {item.segmentLabel}
408
+ </span>
409
+ <span data-journey-connector="" class={sc(styles, 'connector')} aria-hidden="true"
410
+ ></span>
411
+ {/if}
412
+ {/if}
413
+ </div>
327
414
  </li>
328
415
  {:else}
329
416
  <li
@@ -332,25 +419,41 @@
332
419
  data-node-id={item.id}
333
420
  aria-current={item.status === 'active' ? 'step' : undefined}
334
421
  >
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>
422
+ {#if hasMetaRail}
423
+ <div class={sc(styles, 'metaColumn')}>
424
+ {@render metaCell(item, styles)}
340
425
  </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}
426
+ {/if}
427
+ <div class={sc(styles, 'markerColumn')}>
428
+ {@render markerDot(item, styles)}
429
+ {#if !last}
430
+ <span data-journey-connector="" class={sc(styles, 'connector')} aria-hidden="true"
431
+ ></span>
432
+ {/if}
433
+ </div>
434
+ <div class={sc(styles, 'content', last && 'pb-0')}>
435
+ <div class={sc(styles, 'card')}>
436
+ {@render headerRow(item, index, focused, styles)}
437
+ {#if detailMode === 'inline' && node && item.focusable !== false}
438
+ <div
439
+ id={detailDomId(index)}
440
+ role="region"
441
+ aria-label={item.title}
442
+ data-journey-detail=""
443
+ class={sc(styles, 'detail')}
444
+ style="grid-template-rows: {focused ? '1fr' : '0fr'}"
445
+ >
446
+ <div class={sc(styles, 'detailInner')}>
447
+ <div class={sc(styles, 'detailContent')}>
448
+ {#if focused}{@render node(item)}{/if}
449
+ </div>
352
450
  </div>
353
451
  </div>
452
+ {/if}
453
+ </div>
454
+ {#if item.segmentLabel && !last}
455
+ <div class={sc(styles, 'segment')} data-journey-segment="">
456
+ {item.segmentLabel}
354
457
  </div>
355
458
  {/if}
356
459
  </div>
@@ -359,11 +462,12 @@
359
462
  {/each}
360
463
  </ol>
361
464
 
362
- {#if orientation === 'horizontal' && node && focusedItem}
465
+ {#if detailMode === 'panel' && node && focusedItem}
363
466
  <div
364
467
  id={panelId}
365
468
  role="region"
366
469
  aria-label={focusedItem.title}
470
+ data-journey-panel=""
367
471
  class={sc(containerStyles, 'panel')}
368
472
  >
369
473
  {@render node(focusedItem)}