@marianmeres/stuic 3.169.0 → 3.171.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.
@@ -0,0 +1,204 @@
1
+ # Timeline
2
+
3
+ Vertical event list with markers on a rail — activity feeds, audit logs, order
4
+ tracking, "our history" pages. Renders an ordered list (`<ol>`) where every item
5
+ has a marker (dot, icon bubble, or anything via snippet), an optional time label,
6
+ a title, a description and an optional footer. Data-driven: pass `items`, override
7
+ parts with snippets. Static by design — there is no "current" step; for
8
+ progress-style flows use `Stepper` (vertical orientation).
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ | ------------------ | ------------------------- | ---------- | ------------------------------------------------------------------------------------- |
14
+ | `items` | `TimelineItem[]` | required | The events, in the order they should appear (sort them yourself) |
15
+ | `align` | `"start" \| "alternate"` | `"start"` | Rail on the start side with content after it, or centered rail with alternating sides |
16
+ | `timePosition` | `"inline" \| "opposite"` | `"inline"` | Time label above the title, or in its own column on the other side of the rail |
17
+ | `formatTime` | `(datetime, item) => THC` | - | Fallback label for items that have `datetime` but no `time` |
18
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
19
+ | `class` | `string` | - | Additional CSS classes (merged via twMerge) |
20
+ | `classItem` | `string` | - | Class for every item (`li`) |
21
+ | `classMarker` | `string` | - | Class for the marker |
22
+ | `classContent` | `string` | - | Class for the content cell |
23
+ | `classTime` | `string` | - | Class for the time label (inline or opposite) |
24
+ | `classTitle` | `string` | - | Class for the title |
25
+ | `classDescription` | `string` | - | Class for the description |
26
+ | `classFooter` | `string` | - | Class for the footer area |
27
+ | `el` | `HTMLOListElement` | - | Element reference (bindable) |
28
+
29
+ Any other attribute (`aria-label`, `data-*`, …) is passed to the `<ol>`.
30
+
31
+ ### `TimelineItem`
32
+
33
+ | Field | Type | Description |
34
+ | ------------- | ---------------- | ----------------------------------------------------------------------------------------------------- |
35
+ | `title` | `THC` | Primary line (text, html, or component — see `Thc`) |
36
+ | `description` | `THC` | Secondary text under the title |
37
+ | `time` | `THC` | Time label, preformatted ("2 hours ago"); falls back to `formatTime(datetime)` when omitted |
38
+ | `datetime` | `string \| Date` | Machine-readable timestamp → `<time datetime="…">` (a `Date` becomes ISO 8601); never displayed alone |
39
+ | `icon` | `THC` | Marker content (e.g. `{ html: iconCheck() }`); switches the marker from dot to icon bubble |
40
+ | `intent` | `IntentColorKey` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` — colors the marker |
41
+ | `href` | `string` | Renders the title as a link |
42
+
43
+ ## Snippet Props
44
+
45
+ All three receive `{ item, index }`.
46
+
47
+ | Snippet | Description |
48
+ | -------------- | ---------------------------------------------------------------------- |
49
+ | `renderMarker` | Override the marker content entirely (avatars, badges…) for every item |
50
+ | `renderItem` | Override the whole content cell (time, title, description, footer) |
51
+ | `renderFooter` | Per-item footer area below the description (actions, attachments…) |
52
+
53
+ ## Usage
54
+
55
+ ### Basic
56
+
57
+ ```svelte
58
+ <script lang="ts">
59
+ import { Timeline } from "@marianmeres/stuic";
60
+ </script>
61
+
62
+ <Timeline
63
+ items={[
64
+ { title: "Order placed", description: "Ref #1001", time: "09:00" },
65
+ { title: "Payment received", time: "09:05", intent: "success" },
66
+ { title: "Shipped", description: "Tracking number sent", time: "Yesterday" },
67
+ ]}
68
+ />
69
+ ```
70
+
71
+ ### Icons and intents
72
+
73
+ `icon` turns the dot into a bubble; `intent` colors either.
74
+
75
+ ```svelte
76
+ <script lang="ts">
77
+ import { Timeline, iconCheck, iconAlertWarning } from "@marianmeres/stuic";
78
+ </script>
79
+
80
+ <Timeline
81
+ items={[
82
+ { title: "Deployed", icon: { html: iconCheck() }, intent: "success" },
83
+ {
84
+ title: "Health check failed",
85
+ icon: { html: iconAlertWarning() },
86
+ intent: "destructive",
87
+ },
88
+ { title: "Rolled back", intent: "warning" },
89
+ ]}
90
+ />
91
+ ```
92
+
93
+ ### Machine-readable times + a formatter
94
+
95
+ `datetime` is metadata (`<time datetime>`); `time` is what people see. Give every item
96
+ a `datetime` and one `formatTime` and skip per-item labels:
97
+
98
+ ```svelte
99
+ <script lang="ts">
100
+ const fmt = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" });
101
+ </script>
102
+
103
+ <Timeline
104
+ items={events.map((e) => ({ title: e.message, datetime: e.createdAt }))}
105
+ formatTime={(d) => fmt.format(new Date(d))}
106
+ />
107
+ ```
108
+
109
+ ### Audit-log layout (time column)
110
+
111
+ ```svelte
112
+ <Timeline {items} timePosition="opposite" />
113
+ ```
114
+
115
+ ### Alternating sides (history page)
116
+
117
+ ```svelte
118
+ <Timeline {items} align="alternate" />
119
+ ```
120
+
121
+ The rail is centered and content alternates sides (`timePosition="opposite"` puts the
122
+ time on the free side). There is no automatic collapse on narrow screens — switch to
123
+ `align="start"` yourself below your breakpoint if needed.
124
+
125
+ ### Custom markers (avatars)
126
+
127
+ ```svelte
128
+ <Timeline {items}>
129
+ {#snippet renderMarker({ item })}
130
+ <Avatar src={item.actorAvatar} size="sm" />
131
+ {/snippet}
132
+ </Timeline>
133
+ ```
134
+
135
+ `renderMarker` replaces the marker for every item (`data-marker="custom"`): the box is
136
+ sized like the icon bubble but carries no background — the snippet brings its own look.
137
+
138
+ ### Footer actions / custom item content
139
+
140
+ ```svelte
141
+ <Timeline {items}>
142
+ {#snippet renderFooter({ item })}
143
+ <Button size="sm" variant="outline" onclick={() => open(item)}>Details</Button>
144
+ {/snippet}
145
+ </Timeline>
146
+
147
+ <!-- or take over the whole content cell -->
148
+ <Timeline {items}>
149
+ {#snippet renderItem({ item })}
150
+ <strong>{item.title}</strong> — <em>{item.time}</em>
151
+ {/snippet}
152
+ </Timeline>
153
+ ```
154
+
155
+ ### Grouping
156
+
157
+ There is no built-in "Today / Yesterday" grouping — render one `Timeline` per group
158
+ under your own headings.
159
+
160
+ ## Accessibility
161
+
162
+ - Renders a real `<ol>` (explicit `role="list"`, so Safari keeps the list semantics
163
+ despite `list-style: none`); pass `aria-label` if the page has several lists.
164
+ - Markers are `aria-hidden` — they are decorative; the time and title carry the
165
+ information. Reading order is time → title → description → footer (in the opposite
166
+ layout the time cell still comes first in the DOM).
167
+ - `datetime` renders a `<time>` element; without it the label is a plain `<span>`
168
+ (the spec requires a valid date string in a bare `<time>`).
169
+
170
+ ## CSS Variables
171
+
172
+ | Variable | Default | Description |
173
+ | ------------------------------------------------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
174
+ | `--stuic-timeline-gap` | `1rem` | Column gap (opposite ↔ rail ↔ content) |
175
+ | `--stuic-timeline-gap-vertical` | `1.5rem` | Distance between items |
176
+ | `--stuic-timeline-line-height` | `1.5rem` | Height of the first content line; the marker centers on it |
177
+ | `--stuic-timeline-connector-thickness` | `2px` | Rail line thickness |
178
+ | `--stuic-timeline-connector-bg` | `--stuic-color-border` | Rail line color |
179
+ | `--stuic-timeline-marker-size` | `0.75rem` | Dot diameter |
180
+ | `--stuic-timeline-marker-bg` | `--stuic-color-muted-foreground` | Dot color (no intent) |
181
+ | `--stuic-timeline-marker-size-icon` | `2rem` | Icon bubble / custom marker diameter |
182
+ | `--stuic-timeline-marker-bg-icon` / `-text-icon` | muted / muted-foreground | Icon bubble colors (no intent); an intent applies a soft tint |
183
+ | `--stuic-timeline-marker-icon-size` | `1rem` | Size of an svg inside the bubble |
184
+ | `--stuic-timeline-marker-radius` | full circle | Marker radius |
185
+ | `--stuic-timeline-marker-ring-width` / `-ring-color` | `2px` / `--stuic-color-background` | Ring separating the marker from the line behind it (set the color to your card's background inside cards) |
186
+ | `--stuic-timeline-time-font-size` / `-text` | `--text-sm` / muted-foreground | Time label |
187
+ | `--stuic-timeline-title-font-size` / `-font-weight` / `-text` | `--text-base` / medium / foreground | Title |
188
+ | `--stuic-timeline-description-font-size` / `-text` / `-margin-top` | `--text-sm` / muted-foreground / `0.125rem` | Description |
189
+ | `--stuic-timeline-footer-gap` / `-margin-top` | `0.5rem` / `0.5rem` | Footer area |
190
+
191
+ ## Data Attributes
192
+
193
+ - `data-align` - `"start" | "alternate"` (root)
194
+ - `data-time-position` - `"inline" | "opposite"` (root)
195
+ - `data-intent` - the item's intent (`li`, only when set)
196
+ - `data-marker` - `"dot" | "icon" | "custom"` (marker element)
197
+
198
+ ## Layout notes
199
+
200
+ The list is a CSS grid and every item a `subgrid` row, so the rail column (sized by
201
+ the widest marker) and the opposite time column stay aligned across items without
202
+ any fixed widths. Markers of different sizes (dots and bubbles in one list) stay on
203
+ the same axis, and the rail line runs behind them — the ring (`--stuic-timeline-marker-ring-*`)
204
+ is what visually separates marker and line.
@@ -0,0 +1,246 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLOlAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
6
+
7
+ /** Which side of the rail the content sits on */
8
+ export type TimelineAlign = "start" | "alternate";
9
+
10
+ /** Where the time label renders */
11
+ export type TimelineTimePosition = "inline" | "opposite";
12
+
13
+ /** What the marker renders as (`data-marker` on the marker element) */
14
+ export type TimelineMarkerKind = "dot" | "icon" | "custom";
15
+
16
+ export interface TimelineItem {
17
+ /** Primary line (the event) */
18
+ title?: THC;
19
+ /** Secondary text under the title */
20
+ description?: THC;
21
+ /**
22
+ * Time label, preformatted ("2 hours ago", "Sep 1, 14:32"). When omitted and
23
+ * `datetime` is set, the component's `formatTime` (if any) produces it.
24
+ */
25
+ time?: THC;
26
+ /**
27
+ * Machine-readable timestamp — renders the label as `<time datetime="…">`
28
+ * (a `Date` is serialized to ISO 8601). Never displayed on its own.
29
+ */
30
+ datetime?: string | Date;
31
+ /**
32
+ * Marker content (typically `{ html: iconCheck() }`). Its presence switches the
33
+ * marker from the small dot to the icon bubble.
34
+ */
35
+ icon?: THC;
36
+ /** Semantic marker color (dot fill, or the bubble's soft tint) */
37
+ intent?: IntentColorKey;
38
+ /** When set, the title renders as a link */
39
+ href?: string;
40
+ }
41
+
42
+ export interface TimelineSnippetArg {
43
+ item: TimelineItem;
44
+ index: number;
45
+ }
46
+
47
+ export interface Props extends Omit<HTMLOlAttributes, "children"> {
48
+ /** The events, in the order they should appear (sort them yourself) */
49
+ items: TimelineItem[];
50
+ /**
51
+ * `"start"` (default): rail on the start side, content after it.
52
+ * `"alternate"`: centered rail, content alternating sides (history pages).
53
+ */
54
+ align?: TimelineAlign;
55
+ /**
56
+ * `"inline"` (default): the time label above the title, inside the content.
57
+ * `"opposite"`: its own column on the other side of the rail (audit-log look).
58
+ */
59
+ timePosition?: TimelineTimePosition;
60
+ /**
61
+ * Fallback formatter for items that have `datetime` but no `time`. Return
62
+ * value is THC, so a plain string is fine.
63
+ */
64
+ formatTime?: (datetime: string | Date, item: TimelineItem) => THC;
65
+ /** Override the marker content entirely (avatars, badges…) */
66
+ renderMarker?: Snippet<[TimelineSnippetArg]>;
67
+ /** Override the whole content cell (time, title, description, footer) */
68
+ renderItem?: Snippet<[TimelineSnippetArg]>;
69
+ /** Per-item footer area below the description (actions, attachments…) */
70
+ renderFooter?: Snippet<[TimelineSnippetArg]>;
71
+ /** Skip all default styling */
72
+ unstyled?: boolean;
73
+ /** Additional CSS classes */
74
+ class?: string;
75
+ /** Class for every item (`li`) */
76
+ classItem?: string;
77
+ /** Class for the marker */
78
+ classMarker?: string;
79
+ /** Class for the content cell */
80
+ classContent?: string;
81
+ /** Class for the time label (inline or opposite) */
82
+ classTime?: string;
83
+ /** Class for the title */
84
+ classTitle?: string;
85
+ /** Class for the description */
86
+ classDescription?: string;
87
+ /** Class for the footer area */
88
+ classFooter?: string;
89
+ /** Bindable element reference */
90
+ el?: HTMLOListElement;
91
+ }
92
+ </script>
93
+
94
+ <script lang="ts">
95
+ import { twMerge } from "../../utils/tw-merge.js";
96
+ import Thc from "../Thc/Thc.svelte";
97
+
98
+ let {
99
+ items,
100
+ align = "start",
101
+ timePosition = "inline",
102
+ formatTime,
103
+ renderMarker,
104
+ renderItem,
105
+ renderFooter,
106
+ unstyled = false,
107
+ class: classProp,
108
+ classItem: classItemProp,
109
+ classMarker: classMarkerProp,
110
+ classContent: classContentProp,
111
+ classTime: classTimeProp,
112
+ classTitle: classTitleProp,
113
+ classDescription: classDescriptionProp,
114
+ classFooter: classFooterProp,
115
+ el = $bindable(),
116
+ ...rest
117
+ }: Props = $props();
118
+
119
+ let _class = $derived(unstyled ? classProp : twMerge("stuic-timeline", classProp));
120
+ let _classItem = $derived(
121
+ unstyled ? classItemProp : twMerge("stuic-timeline-item", classItemProp)
122
+ );
123
+ let _classMarker = $derived(
124
+ unstyled ? classMarkerProp : twMerge("stuic-timeline-marker", classMarkerProp)
125
+ );
126
+ let _classContent = $derived(
127
+ unstyled ? classContentProp : twMerge("stuic-timeline-content", classContentProp)
128
+ );
129
+ let _classTime = $derived(
130
+ unstyled ? classTimeProp : twMerge("stuic-timeline-time", classTimeProp)
131
+ );
132
+ let _classTitle = $derived(
133
+ unstyled ? classTitleProp : twMerge("stuic-timeline-title", classTitleProp)
134
+ );
135
+ let _classDescription = $derived(
136
+ unstyled
137
+ ? classDescriptionProp
138
+ : twMerge("stuic-timeline-description", classDescriptionProp)
139
+ );
140
+ let _classFooter = $derived(
141
+ unstyled ? classFooterProp : twMerge("stuic-timeline-footer", classFooterProp)
142
+ );
143
+
144
+ function markerKind(item: TimelineItem): TimelineMarkerKind {
145
+ if (renderMarker) return "custom";
146
+ if (item.icon) return "icon";
147
+ return "dot";
148
+ }
149
+
150
+ function timeLabel(item: TimelineItem): THC | undefined {
151
+ if (item.time !== undefined && item.time !== null && item.time !== "")
152
+ return item.time;
153
+ if (item.datetime !== undefined && formatTime) return formatTime(item.datetime, item);
154
+ return undefined;
155
+ }
156
+
157
+ function datetimeAttr(item: TimelineItem): string | undefined {
158
+ if (item.datetime === undefined) return undefined;
159
+ return item.datetime instanceof Date ? item.datetime.toISOString() : item.datetime;
160
+ }
161
+ </script>
162
+
163
+ {#snippet timeEl(item: TimelineItem, label: THC)}
164
+ {@const datetime = datetimeAttr(item)}
165
+ <!-- <time> only when it can carry a machine-readable value -->
166
+ <svelte:element this={datetime ? "time" : "span"} class={_classTime} {datetime}>
167
+ <Thc thc={label} />
168
+ </svelte:element>
169
+ {/snippet}
170
+
171
+ {#if items.length}
172
+ <!-- svelte-ignore a11y_no_redundant_roles -->
173
+ <ol
174
+ bind:this={el}
175
+ class={_class}
176
+ role="list"
177
+ data-align={!unstyled ? align : undefined}
178
+ data-time-position={!unstyled ? timePosition : undefined}
179
+ {...rest}
180
+ >
181
+ {#each items as item, index (index)}
182
+ {@const label = timeLabel(item)}
183
+ {@const kind = markerKind(item)}
184
+ <li
185
+ class={_classItem}
186
+ data-intent={!unstyled && item.intent ? item.intent : undefined}
187
+ >
188
+ {#if timePosition === "opposite"}
189
+ <div class={unstyled ? undefined : "stuic-timeline-opposite"}>
190
+ {#if label !== undefined}
191
+ {@render timeEl(item, label)}
192
+ {/if}
193
+ </div>
194
+ {/if}
195
+
196
+ <div class={unstyled ? undefined : "stuic-timeline-rail"}>
197
+ <span
198
+ class={_classMarker}
199
+ data-marker={!unstyled ? kind : undefined}
200
+ aria-hidden="true"
201
+ >
202
+ {#if renderMarker}
203
+ {@render renderMarker({ item, index })}
204
+ {:else if item.icon}
205
+ <Thc thc={item.icon} />
206
+ {/if}
207
+ </span>
208
+ </div>
209
+
210
+ <div class={_classContent}>
211
+ {#if renderItem}
212
+ {@render renderItem({ item, index })}
213
+ {:else}
214
+ {#if timePosition === "inline" && label !== undefined}
215
+ {@render timeEl(item, label)}
216
+ {/if}
217
+ {#if item.title}
218
+ <div class={_classTitle}>
219
+ {#if item.href}
220
+ <a
221
+ href={item.href}
222
+ class={unstyled ? undefined : "stuic-timeline-link"}
223
+ >
224
+ <Thc thc={item.title} />
225
+ </a>
226
+ {:else}
227
+ <Thc thc={item.title} />
228
+ {/if}
229
+ </div>
230
+ {/if}
231
+ {#if item.description}
232
+ <div class={_classDescription}>
233
+ <Thc thc={item.description} />
234
+ </div>
235
+ {/if}
236
+ {#if renderFooter}
237
+ <div class={_classFooter}>
238
+ {@render renderFooter({ item, index })}
239
+ </div>
240
+ {/if}
241
+ {/if}
242
+ </div>
243
+ </li>
244
+ {/each}
245
+ </ol>
246
+ {/if}
@@ -0,0 +1,87 @@
1
+ import type { HTMLOlAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
5
+ /** Which side of the rail the content sits on */
6
+ export type TimelineAlign = "start" | "alternate";
7
+ /** Where the time label renders */
8
+ export type TimelineTimePosition = "inline" | "opposite";
9
+ /** What the marker renders as (`data-marker` on the marker element) */
10
+ export type TimelineMarkerKind = "dot" | "icon" | "custom";
11
+ export interface TimelineItem {
12
+ /** Primary line (the event) */
13
+ title?: THC;
14
+ /** Secondary text under the title */
15
+ description?: THC;
16
+ /**
17
+ * Time label, preformatted ("2 hours ago", "Sep 1, 14:32"). When omitted and
18
+ * `datetime` is set, the component's `formatTime` (if any) produces it.
19
+ */
20
+ time?: THC;
21
+ /**
22
+ * Machine-readable timestamp — renders the label as `<time datetime="…">`
23
+ * (a `Date` is serialized to ISO 8601). Never displayed on its own.
24
+ */
25
+ datetime?: string | Date;
26
+ /**
27
+ * Marker content (typically `{ html: iconCheck() }`). Its presence switches the
28
+ * marker from the small dot to the icon bubble.
29
+ */
30
+ icon?: THC;
31
+ /** Semantic marker color (dot fill, or the bubble's soft tint) */
32
+ intent?: IntentColorKey;
33
+ /** When set, the title renders as a link */
34
+ href?: string;
35
+ }
36
+ export interface TimelineSnippetArg {
37
+ item: TimelineItem;
38
+ index: number;
39
+ }
40
+ export interface Props extends Omit<HTMLOlAttributes, "children"> {
41
+ /** The events, in the order they should appear (sort them yourself) */
42
+ items: TimelineItem[];
43
+ /**
44
+ * `"start"` (default): rail on the start side, content after it.
45
+ * `"alternate"`: centered rail, content alternating sides (history pages).
46
+ */
47
+ align?: TimelineAlign;
48
+ /**
49
+ * `"inline"` (default): the time label above the title, inside the content.
50
+ * `"opposite"`: its own column on the other side of the rail (audit-log look).
51
+ */
52
+ timePosition?: TimelineTimePosition;
53
+ /**
54
+ * Fallback formatter for items that have `datetime` but no `time`. Return
55
+ * value is THC, so a plain string is fine.
56
+ */
57
+ formatTime?: (datetime: string | Date, item: TimelineItem) => THC;
58
+ /** Override the marker content entirely (avatars, badges…) */
59
+ renderMarker?: Snippet<[TimelineSnippetArg]>;
60
+ /** Override the whole content cell (time, title, description, footer) */
61
+ renderItem?: Snippet<[TimelineSnippetArg]>;
62
+ /** Per-item footer area below the description (actions, attachments…) */
63
+ renderFooter?: Snippet<[TimelineSnippetArg]>;
64
+ /** Skip all default styling */
65
+ unstyled?: boolean;
66
+ /** Additional CSS classes */
67
+ class?: string;
68
+ /** Class for every item (`li`) */
69
+ classItem?: string;
70
+ /** Class for the marker */
71
+ classMarker?: string;
72
+ /** Class for the content cell */
73
+ classContent?: string;
74
+ /** Class for the time label (inline or opposite) */
75
+ classTime?: string;
76
+ /** Class for the title */
77
+ classTitle?: string;
78
+ /** Class for the description */
79
+ classDescription?: string;
80
+ /** Class for the footer area */
81
+ classFooter?: string;
82
+ /** Bindable element reference */
83
+ el?: HTMLOListElement;
84
+ }
85
+ declare const Timeline: import("svelte").Component<Props, {}, "el">;
86
+ type Timeline = ReturnType<typeof Timeline>;
87
+ export default Timeline;