@signal9/era-ui 34.12.2 → 34.13.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [34.13.0](https://github.com/sig-nine/era-ui/compare/v34.12.2...v34.13.0) (2026-08-31)
2
+
3
+ ### Features
4
+
5
+ * **timeline:** add activity inspection ([e43d313](https://github.com/sig-nine/era-ui/commit/e43d313577473f854c1b39ea7eda5ecafd0ff3db))
6
+
1
7
  ## [34.12.2](https://github.com/sig-nine/era-ui/compare/v34.12.1...v34.12.2) (2026-08-31)
2
8
 
3
9
  ### Bug Fixes
@@ -53,6 +53,10 @@ export declare class TimelineData {
53
53
  activityLayout: ActivityTimelineLayout;
54
54
  selectedId: TimelineStepId | null;
55
55
  onselect: ((step: ActivityTimelineStep) => void) | undefined;
56
+ expandedId: TimelineStepId | null;
57
+ hasActivityDetails: boolean;
58
+ onexpandedchange: ((id: TimelineStepId | null) => void) | undefined;
59
+ instanceId: string;
56
60
  /** Live clock, ticked by the root while any step is running. */
57
61
  now: number;
58
62
  /**
@@ -68,6 +72,8 @@ export declare class TimelineData {
68
72
  elapsed(step: AnyTimelineStep): number;
69
73
  get total(): number;
70
74
  get running(): boolean;
75
+ detailsId(): string;
76
+ toggleExpanded(step: ActivityTimelineStep): void;
71
77
  /**
72
78
  * This step's GROW FACTOR — its duration, straight.
73
79
  *
@@ -31,6 +31,10 @@ export class TimelineData {
31
31
  activityLayout = $state('scroll');
32
32
  selectedId = $state(null);
33
33
  onselect = $state();
34
+ expandedId = $state(null);
35
+ hasActivityDetails = $state(false);
36
+ onexpandedchange = $state();
37
+ instanceId = $state('');
34
38
  /** Live clock, ticked by the root while any step is running. */
35
39
  now = $state(0);
36
40
  /**
@@ -55,6 +59,14 @@ export class TimelineData {
55
59
  get running() {
56
60
  return this.steps.some((s) => s.state === 'running');
57
61
  }
62
+ detailsId() {
63
+ return `${this.instanceId}-activity-details`;
64
+ }
65
+ toggleExpanded(step) {
66
+ if (!this.hasActivityDetails)
67
+ return;
68
+ this.onexpandedchange?.(this.expandedId === step.id ? null : step.id);
69
+ }
58
70
  /**
59
71
  * This step's GROW FACTOR — its duration, straight.
60
72
  *
@@ -47,7 +47,9 @@
47
47
  import CircleSlash from '@lucide/svelte/icons/circle-slash';
48
48
  import CircleX from '@lucide/svelte/icons/circle-x';
49
49
  import LoaderCircle from '@lucide/svelte/icons/loader-circle';
50
+ import { mergeProps } from 'bits-ui';
50
51
  import { cn } from '../../utils/index.js';
52
+ import * as Tooltip from '../tooltip/index.js';
51
53
  import {
52
54
  getTimelineData,
53
55
  formatDuration,
@@ -60,11 +62,15 @@
60
62
  step,
61
63
  showClock = true,
62
64
  activityCompletedIcon,
65
+ activityIcon,
66
+ activityTooltip,
63
67
  class: className
64
68
  }: {
65
69
  step: AnyTimelineStep;
66
70
  showClock?: boolean;
67
71
  activityCompletedIcon?: Snippet<[ActivityTimelineStep]>;
72
+ activityIcon?: Snippet<[ActivityTimelineStep]>;
73
+ activityTooltip?: Snippet<[ActivityTimelineStep]>;
68
74
  class?: string;
69
75
  } = $props();
70
76
 
@@ -78,10 +84,49 @@
78
84
  const activityTone = $derived(ACTIVITY_TONE[activityStep.state]);
79
85
  const stateLabel = $derived(ACTIVITY_STATE_LABEL[activityStep.state]);
80
86
  const selected = $derived(data?.selectedId === step.id);
81
- const interactive = $derived(data?.variant === 'activity' && !!data.onselect);
87
+ const expanded = $derived(data?.expandedId === step.id);
88
+ const interactive = $derived(
89
+ data?.variant === 'activity' && (!!data.onselect || data.hasActivityDetails)
90
+ );
82
91
  const durationActivity = $derived(
83
92
  data?.variant === 'activity' && data.activityLayout === 'duration'
84
93
  );
94
+
95
+ function activateActivity() {
96
+ data?.onselect?.(activityStep);
97
+ data?.toggleExpanded(activityStep);
98
+ }
99
+
100
+ function handleActivityKeydown(event: KeyboardEvent) {
101
+ if (!data) return;
102
+ if (event.key === 'Escape' && expanded && data.hasActivityDetails) {
103
+ event.preventDefault();
104
+ data.onexpandedchange?.(null);
105
+ return;
106
+ }
107
+ const destinations = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
108
+ if (!destinations.includes(event.key)) return;
109
+ const list = (event.currentTarget as HTMLElement).closest('[role="list"]');
110
+ const controls = list
111
+ ? [...list.querySelectorAll<HTMLElement>('[data-timeline-activity-control]')]
112
+ : [];
113
+ const current = controls.indexOf(event.currentTarget as HTMLElement);
114
+ if (current < 0 || controls.length === 0) return;
115
+ event.preventDefault();
116
+ const target =
117
+ event.key === 'Home'
118
+ ? 0
119
+ : event.key === 'End'
120
+ ? controls.length - 1
121
+ : event.key === 'ArrowLeft'
122
+ ? Math.max(0, current - 1)
123
+ : Math.min(controls.length - 1, current + 1);
124
+ controls[target]?.focus();
125
+ }
126
+
127
+ function handleActivityFocus(event: FocusEvent) {
128
+ (event.currentTarget as HTMLElement).scrollIntoView({ block: 'nearest', inline: 'nearest' });
129
+ }
85
130
  </script>
86
131
 
87
132
  <!--
@@ -109,7 +154,36 @@
109
154
  -->
110
155
  {#if data?.variant === 'activity'}
111
156
  {#snippet activityContent()}
112
- {#if activityStep.state === 'running'}
157
+ {#if activityIcon}
158
+ <span
159
+ class="flex size-icon shrink-0 items-center justify-center {activityTone.icon}"
160
+ data-timeline-activity-identity
161
+ aria-hidden="true"
162
+ >
163
+ {@render activityIcon(activityStep)}
164
+ <span
165
+ class="flex items-center justify-center rounded-full bg-elevated {activityTone.icon}"
166
+ data-timeline-activity-indicator
167
+ >
168
+ {#if activityStep.state === 'running'}
169
+ <LoaderCircle
170
+ class="motion-safe:animate-spin"
171
+ style="animation-duration: calc(var(--era-duration) * 8)"
172
+ />
173
+ {:else if activityStep.state === 'awaiting'}
174
+ <CircleAlert />
175
+ {:else if activityStep.state === 'done'}
176
+ <CircleCheck />
177
+ {:else if activityStep.state === 'failed'}
178
+ <CircleX />
179
+ {:else if activityStep.state === 'cancelled'}
180
+ <CircleSlash />
181
+ {:else}
182
+ <Circle />
183
+ {/if}
184
+ </span>
185
+ </span>
186
+ {:else if activityStep.state === 'running'}
113
187
  <LoaderCircle
114
188
  class="size-icon shrink-0 {activityTone.icon} [animation-duration:calc(var(--era-duration)*8)] motion-safe:animate-spin"
115
189
  aria-hidden="true"
@@ -176,29 +250,61 @@
176
250
  data-timeline-activity-density={durationActivity ? data.density : undefined}
177
251
  data-timeline-step-id={step.id}
178
252
  >
179
- {#if interactive}
180
- <button
181
- type="button"
182
- aria-current={selected ? 'step' : undefined}
183
- aria-label={`${step.label}: ${stateLabel}`}
184
- title={activityTitle}
185
- class={activityClass}
186
- data-timeline-activity-control={durationActivity ? '' : undefined}
187
- onclick={() => data.onselect?.(activityStep)}
188
- onfocus={(event) =>
189
- event.currentTarget.scrollIntoView({ block: 'nearest', inline: 'nearest' })}
190
- >
191
- {@render activityContent()}
192
- </button>
193
- {:else}
194
- <div
195
- title={activityTitle}
196
- class={activityClass}
197
- data-timeline-activity-control={durationActivity ? '' : undefined}
198
- >
199
- {@render activityContent()}
200
- </div>
201
- {/if}
253
+ <Tooltip.Provider delayDuration={250}>
254
+ <Tooltip.Root>
255
+ <Tooltip.Trigger>
256
+ {#snippet child({ props })}
257
+ {#if interactive}
258
+ {@const triggerProps = mergeProps(props, {
259
+ onclick: activateActivity,
260
+ onkeydown: handleActivityKeydown,
261
+ onfocus: handleActivityFocus
262
+ })}
263
+ <button
264
+ {...triggerProps}
265
+ type="button"
266
+ aria-current={selected ? 'step' : undefined}
267
+ aria-label={`${step.label}: ${stateLabel}`}
268
+ aria-expanded={data.hasActivityDetails ? expanded : undefined}
269
+ aria-controls={data.hasActivityDetails ? data.detailsId() : undefined}
270
+ class={activityClass}
271
+ data-state={activityStep.state}
272
+ data-selected={selected ? '' : undefined}
273
+ data-expanded={expanded ? '' : undefined}
274
+ data-layout={data.activityLayout}
275
+ data-timeline-activity-control
276
+ >
277
+ {@render activityContent()}
278
+ </button>
279
+ {:else}
280
+ <div
281
+ {...props}
282
+ aria-label={`${step.label}: ${stateLabel}`}
283
+ class={activityClass}
284
+ data-state={activityStep.state}
285
+ data-selected={selected ? '' : undefined}
286
+ data-expanded={expanded ? '' : undefined}
287
+ data-layout={data.activityLayout}
288
+ data-timeline-activity-control
289
+ >
290
+ {@render activityContent()}
291
+ </div>
292
+ {/if}
293
+ {/snippet}
294
+ </Tooltip.Trigger>
295
+ <Tooltip.Content
296
+ role="tooltip"
297
+ class="h-auto max-w-prose flex-col items-start py-inset-pill whitespace-normal"
298
+ >
299
+ {#if activityTooltip}
300
+ {@render activityTooltip(activityStep)}
301
+ {:else}
302
+ <span class="text-fg">{step.label}</span>
303
+ <span class="font-mono text-micro text-muted tabular-nums">{activityTitle}</span>
304
+ {/if}
305
+ </Tooltip.Content>
306
+ </Tooltip.Root>
307
+ </Tooltip.Provider>
202
308
  </div>
203
309
  {:else}
204
310
  <div
@@ -250,6 +356,28 @@
250
356
  container-type: inline-size;
251
357
  }
252
358
 
359
+ [data-timeline-activity-identity] {
360
+ position: relative;
361
+ }
362
+
363
+ [data-timeline-activity-identity] > :not([data-timeline-activity-indicator]) {
364
+ width: 100%;
365
+ height: 100%;
366
+ }
367
+
368
+ [data-timeline-activity-indicator] {
369
+ position: absolute;
370
+ right: calc(var(--era-sp) / -8);
371
+ bottom: calc(var(--era-sp) / -8);
372
+ width: var(--era-sp);
373
+ height: var(--era-sp);
374
+ }
375
+
376
+ [data-timeline-activity-indicator] > :global(svg) {
377
+ width: 100%;
378
+ height: 100%;
379
+ }
380
+
253
381
  /* Once a segment has less than one control tier plus roughly three glyphs,
254
382
  fragments stop communicating useful information. Collapse the whole text
255
383
  treatment at once and preserve a concentric, state-bearing icon control. */
@@ -6,6 +6,8 @@ type $$ComponentProps = {
6
6
  step: AnyTimelineStep;
7
7
  showClock?: boolean;
8
8
  activityCompletedIcon?: Snippet<[ActivityTimelineStep]>;
9
+ activityIcon?: Snippet<[ActivityTimelineStep]>;
10
+ activityTooltip?: Snippet<[ActivityTimelineStep]>;
9
11
  class?: string;
10
12
  };
11
13
  declare const TimelineStep: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -23,10 +23,15 @@
23
23
  ariaLabel = 'Build timeline',
24
24
  selectedId = null,
25
25
  onselect,
26
+ expandedId = $bindable(null),
27
+ onexpandedchange,
26
28
  reveal,
27
29
  tick = 100,
28
30
  showClock = true,
29
31
  activityCompletedIcon,
32
+ activityIcon,
33
+ activityDetails,
34
+ activityTooltip,
30
35
  children,
31
36
  class: className
32
37
  }: {
@@ -43,19 +48,33 @@
43
48
  selectedId?: TimelineStepId | null;
44
49
  /** Supplying this callback turns activity steps into native buttons. */
45
50
  onselect?: (step: ActivityTimelineStep) => void;
51
+ /** Controlled expanded activity. Bind it or update it from `onexpandedchange`. */
52
+ expandedId?: TimelineStepId | null;
53
+ onexpandedchange?: (id: TimelineStepId | null) => void;
46
54
  /** Opt-in horizontal reveal target. `latest` follows appends; `running` follows the first active step. */
47
55
  reveal?: TimelineStepId | 'latest' | 'running';
48
56
  /** Live counter interval in ms. 0 stops the clock (the counter freezes). */
49
57
  tick?: number;
50
58
  showClock?: boolean;
51
59
  /** Consumer-rendered identity for completed activity steps. Other states keep
52
- * Era's lifecycle glyphs so running, approval, failure, and cancellation remain explicit. */
60
+ * Era's lifecycle glyphs. Ignored when the general `activityIcon` is supplied. */
53
61
  activityCompletedIcon?: Snippet<[ActivityTimelineStep]>;
62
+ /** Persistent consumer identity rendered throughout every activity lifecycle state. */
63
+ activityIcon?: Snippet<[ActivityTimelineStep]>;
64
+ /** Detail content rendered in Era's controlled disclosure panel. */
65
+ activityDetails?: Snippet<[ActivityTimelineStep]>;
66
+ /** Optional replacement for Era's label, state, duration, and description tooltip. */
67
+ activityTooltip?: Snippet<[ActivityTimelineStep]>;
54
68
  children?: Snippet;
55
69
  class?: string;
56
70
  } = $props();
71
+ const instanceId = $props.id();
57
72
 
58
73
  const data = setTimelineData(new TimelineData());
74
+ function requestExpanded(id: TimelineStepId | null) {
75
+ expandedId = id;
76
+ onexpandedchange?.(id);
77
+ }
59
78
  $effect(() => {
60
79
  data.steps = steps;
61
80
  data.variant = variant;
@@ -63,7 +82,16 @@
63
82
  data.density = density;
64
83
  data.selectedId = selectedId;
65
84
  data.onselect = onselect;
85
+ data.expandedId = expandedId;
86
+ data.hasActivityDetails = !!activityDetails;
87
+ data.onexpandedchange = requestExpanded;
88
+ data.instanceId = instanceId;
66
89
  });
90
+ const expandedStep = $derived(
91
+ variant === 'activity' && activityDetails
92
+ ? (steps.find((step) => step.id === expandedId) as ActivityTimelineStep | undefined)
93
+ : undefined
94
+ );
67
95
 
68
96
  let lastRevealed = '';
69
97
  $effect(() => {
@@ -126,6 +154,32 @@
126
154
  });
127
155
  </script>
128
156
 
157
+ {#snippet timelineList()}
158
+ <div
159
+ bind:this={ref}
160
+ role="list"
161
+ aria-label={ariaLabel}
162
+ aria-live={variant === 'activity' ? 'polite' : undefined}
163
+ class={cn(
164
+ 'flex w-full min-w-0 gap-gutter',
165
+ variant === 'activity'
166
+ ? activityLayout === 'duration'
167
+ ? 'max-w-full items-center overflow-x-clip overflow-y-visible'
168
+ : 'scrollbar-none max-w-full items-center overflow-x-auto overscroll-x-contain'
169
+ : 'items-start',
170
+ className
171
+ )}
172
+ >
173
+ {#if children}
174
+ {@render children()}
175
+ {:else}
176
+ {#each data.steps as step, i (step.id ?? i)}
177
+ <Step {activityCompletedIcon} {activityIcon} {activityTooltip} {step} {showClock} />
178
+ {/each}
179
+ {/if}
180
+ </div>
181
+ {/snippet}
182
+
129
183
  <!--
130
184
  A build timeline: one full-width bar cut into a segment per step, where a
131
185
  segment's WIDTH is how long that step took. You read "installing took three
@@ -138,26 +192,21 @@
138
192
  carries its own sr-only summary because the visual encoding — width — is
139
193
  exactly the part that does not survive being read aloud.
140
194
  -->
141
- <div
142
- bind:this={ref}
143
- role="list"
144
- aria-label={ariaLabel}
145
- aria-live={variant === 'activity' ? 'polite' : undefined}
146
- class={cn(
147
- 'flex w-full min-w-0 gap-gutter',
148
- variant === 'activity'
149
- ? activityLayout === 'duration'
150
- ? 'max-w-full items-center overflow-x-clip overflow-y-visible'
151
- : 'scrollbar-none max-w-full items-center overflow-x-auto overscroll-x-contain'
152
- : 'items-start',
153
- className
154
- )}
155
- >
156
- {#if children}
157
- {@render children()}
158
- {:else}
159
- {#each data.steps as step, i (step.id ?? i)}
160
- <Step {activityCompletedIcon} {step} {showClock} />
161
- {/each}
162
- {/if}
163
- </div>
195
+ {#if variant === 'activity' && activityDetails}
196
+ <div class="w-full min-w-0">
197
+ {@render timelineList()}
198
+ {#if expandedStep}
199
+ <div
200
+ id={data.detailsId()}
201
+ role="region"
202
+ aria-label={`${expandedStep.label} details`}
203
+ class="mt-gutter w-full min-w-0 overflow-hidden border border-divider bg-well p-card text-body text-fg"
204
+ data-timeline-activity-details={expandedStep.id}
205
+ >
206
+ {@render activityDetails(expandedStep)}
207
+ </div>
208
+ {/if}
209
+ </div>
210
+ {:else}
211
+ {@render timelineList()}
212
+ {/if}
@@ -14,17 +14,26 @@ type $$ComponentProps = {
14
14
  selectedId?: TimelineStepId | null;
15
15
  /** Supplying this callback turns activity steps into native buttons. */
16
16
  onselect?: (step: ActivityTimelineStep) => void;
17
+ /** Controlled expanded activity. Bind it or update it from `onexpandedchange`. */
18
+ expandedId?: TimelineStepId | null;
19
+ onexpandedchange?: (id: TimelineStepId | null) => void;
17
20
  /** Opt-in horizontal reveal target. `latest` follows appends; `running` follows the first active step. */
18
21
  reveal?: TimelineStepId | 'latest' | 'running';
19
22
  /** Live counter interval in ms. 0 stops the clock (the counter freezes). */
20
23
  tick?: number;
21
24
  showClock?: boolean;
22
25
  /** Consumer-rendered identity for completed activity steps. Other states keep
23
- * Era's lifecycle glyphs so running, approval, failure, and cancellation remain explicit. */
26
+ * Era's lifecycle glyphs. Ignored when the general `activityIcon` is supplied. */
24
27
  activityCompletedIcon?: Snippet<[ActivityTimelineStep]>;
28
+ /** Persistent consumer identity rendered throughout every activity lifecycle state. */
29
+ activityIcon?: Snippet<[ActivityTimelineStep]>;
30
+ /** Detail content rendered in Era's controlled disclosure panel. */
31
+ activityDetails?: Snippet<[ActivityTimelineStep]>;
32
+ /** Optional replacement for Era's label, state, duration, and description tooltip. */
33
+ activityTooltip?: Snippet<[ActivityTimelineStep]>;
25
34
  children?: Snippet;
26
35
  class?: string;
27
36
  };
28
- declare const Timeline: import("svelte").Component<$$ComponentProps, {}, "ref">;
37
+ declare const Timeline: import("svelte").Component<$$ComponentProps, {}, "ref" | "expandedId">;
29
38
  type Timeline = ReturnType<typeof Timeline>;
30
39
  export default Timeline;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "34.12.2",
3
+ "version": "34.13.0",
4
4
  "packageManager": "npm@11.19.0",
5
5
  "engines": {
6
6
  "node": ">=24 <27",
@@ -1,5 +1,11 @@
1
1
  # Era UI v34 release history
2
2
 
3
+ ## [34.13.0](https://github.com/sig-nine/era-ui/compare/v34.12.2...v34.13.0) (2026-08-31)
4
+
5
+ ### Features
6
+
7
+ * **timeline:** add activity inspection ([e43d313](https://github.com/sig-nine/era-ui/commit/e43d313577473f854c1b39ea7eda5ecafd0ff3db))
8
+
3
9
  ## [34.12.2](https://github.com/sig-nine/era-ui/compare/v34.12.1...v34.12.2) (2026-08-31)
4
10
 
5
11
  ### Bug Fixes
@@ -5,7 +5,7 @@ Load only the major versions crossed by an upgrade; use the package-root `CHANGE
5
5
 
6
6
  | Major | Range | Releases | History |
7
7
  | ---: | --- | ---: | --- |
8
- | 34 | 34.0.0–34.12.2 | 40 | [Read](./34.md) |
8
+ | 34 | 34.0.0–34.13.0 | 41 | [Read](./34.md) |
9
9
  | 33 | 33.0.0–33.0.1 | 2 | [Read](./33.md) |
10
10
  | 32 | 32.0.0–32.0.1 | 2 | [Read](./32.md) |
11
11
  | 31 | 31.0.0–31.0.0 | 1 | [Read](./31.md) |