@delightstack/components 0.1.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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,965 @@
1
+ <script lang="ts" module>
2
+ export { default as Step } from './Steps.svelte';
3
+
4
+ export interface StepsContext {
5
+ /** Index of the currently active step */
6
+ current: number;
7
+ /** The layout direction of the steps */
8
+ orientation: 'horizontal' | 'vertical';
9
+ /** Whether steps can be clicked to navigate */
10
+ clickable: boolean;
11
+ /** Whether steps must be completed in order (only visited/adjacent steps are clickable) */
12
+ linear: boolean;
13
+ /** The size applied to all steps */
14
+ size: string;
15
+ /** Total number of registered steps */
16
+ totalSteps: number;
17
+ /** Registers a new step and returns its index */
18
+ register: () => number;
19
+ /** Navigates to the step at the given index */
20
+ navigate: (index: number) => void;
21
+ }
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ import { ripple } from '@delightstack/utilities';
26
+ import { getContext, setContext, type Snippet } from 'svelte';
27
+ import Button from '../actions/Button.svelte';
28
+ import Expand from '../display/Expand.svelte';
29
+
30
+ const propId = $props.id();
31
+
32
+ let {
33
+ /* --- Step item props --- */
34
+ /** The title text for this step */
35
+ title = '',
36
+
37
+ /** Optional description text below the title */
38
+ description = '',
39
+
40
+ /** Whether this step is optional */
41
+ optional = false,
42
+
43
+ /** Whether this step is in an error state */
44
+ error = false,
45
+
46
+ /* --- Steps container props --- */
47
+ /** The current active step index */
48
+ current = $bindable(0),
49
+
50
+ /** Layout orientation */
51
+ orientation = 'horizontal' as 'horizontal' | 'vertical',
52
+
53
+ /** Whether completed steps are clickable for navigation */
54
+ clickable = false,
55
+
56
+ /** Whether navigation is restricted to sequential order */
57
+ linear = true,
58
+
59
+ /** Size variant */
60
+ size = '1' as '0' | '1' | '2' | '3',
61
+
62
+ /** Show skeleton shimmer placeholders */
63
+ skeleton = false,
64
+
65
+ /** Number of skeleton placeholder steps */
66
+ skeleton_count = 4,
67
+
68
+ /** Element ID */
69
+ id = propId,
70
+
71
+ /** Additional CSS classes */
72
+ class: class_name = '',
73
+
74
+ /** Child content snippet */
75
+ children = undefined as undefined | Snippet,
76
+
77
+ /** Called when the active step changes */
78
+ onchange = undefined as ((detail: { step: number }) => void) | undefined,
79
+
80
+ /** Called when all steps are completed */
81
+ oncomplete = undefined as (() => void) | undefined,
82
+ } = $props();
83
+
84
+ /* ------------------------------------------------------------------ */
85
+ /* Determine whether this instance is a container or an item */
86
+ /* ------------------------------------------------------------------ */
87
+ const parentContext = getContext<StepsContext | undefined>('steps');
88
+ const isItem = !!parentContext;
89
+
90
+ /* ------------------------------------------------------------------ */
91
+ /* Steps container behaviour */
92
+ /* ------------------------------------------------------------------ */
93
+ let stepCounter = 0;
94
+
95
+ if (!isItem) {
96
+ const ctx = $state<StepsContext>({
97
+ current,
98
+ orientation,
99
+ clickable,
100
+ linear,
101
+ size,
102
+ totalSteps: 0,
103
+ register() {
104
+ const index = stepCounter++;
105
+ ctx.totalSteps = stepCounter;
106
+ return index;
107
+ },
108
+ navigate(index: number) {
109
+ if (!ctx.clickable) return;
110
+ if (ctx.linear && index > ctx.current) return;
111
+ if (index === ctx.current) return;
112
+ current = index;
113
+ onchange?.({ step: index });
114
+ },
115
+ });
116
+ setContext<StepsContext>('steps', ctx);
117
+
118
+ $effect(() => {
119
+ ctx.current = current;
120
+ ctx.orientation = orientation;
121
+ ctx.clickable = clickable;
122
+ ctx.linear = linear;
123
+ ctx.size = size;
124
+ });
125
+
126
+ $effect(() => {
127
+ if (current >= ctx.totalSteps && ctx.totalSteps > 0) {
128
+ oncomplete?.();
129
+ }
130
+ });
131
+ }
132
+
133
+ /* ------------------------------------------------------------------ */
134
+ /* Step item behaviour */
135
+ /* ------------------------------------------------------------------ */
136
+ const stepIndex = isItem ? parentContext.register() : -1;
137
+
138
+ const isComplete = $derived(isItem && !error && stepIndex < parentContext.current);
139
+ const isCurrent = $derived(isItem && stepIndex === parentContext.current);
140
+ const isUpcoming = $derived(isItem && stepIndex > parentContext.current);
141
+ const isError = $derived(isItem && error);
142
+ const isLast = $derived(isItem && stepIndex === parentContext.totalSteps - 1);
143
+ const connectorFilled = $derived(isItem && stepIndex < parentContext.current);
144
+
145
+ const canClick = $derived(
146
+ isItem &&
147
+ parentContext.clickable &&
148
+ (isComplete || (!parentContext.linear && !isCurrent)),
149
+ );
150
+
151
+ const stepState = $derived(
152
+ isError ? 'error' : isComplete ? 'complete' : isCurrent ? 'current' : 'upcoming',
153
+ );
154
+
155
+ const ariaLabel = $derived(
156
+ isItem
157
+ ? `Step ${stepIndex + 1}: ${title}${isComplete ? ', completed' : isCurrent ? ', current' : isError ? ', error' : ''}${optional ? ', optional' : ''}`
158
+ : undefined,
159
+ );
160
+
161
+ function handleClick() {
162
+ if (!canClick || !isItem) return;
163
+ parentContext.navigate(stepIndex);
164
+ }
165
+
166
+ function handleKeyDown(e: KeyboardEvent) {
167
+ if (e.key === 'Enter' || e.key === ' ') {
168
+ e.preventDefault();
169
+ handleClick();
170
+ }
171
+ }
172
+
173
+ /* ------------------------------------------------------------------ */
174
+ /* Size mapping */
175
+ /* ------------------------------------------------------------------ */
176
+ const CIRCLE_SIZES: Record<string, number> = {
177
+ '0': 24,
178
+ '1': 32,
179
+ '2': 40,
180
+ '3': 48,
181
+ };
182
+
183
+ const FONT_SIZES: Record<string, string> = {
184
+ '0': '0.625rem',
185
+ '1': '0.75rem',
186
+ '2': '0.875rem',
187
+ '3': '1rem',
188
+ };
189
+
190
+ const resolvedCircleSize = $derived(
191
+ isItem ? (CIRCLE_SIZES[parentContext.size] ?? 32) : (CIRCLE_SIZES[size] ?? 32),
192
+ );
193
+
194
+ const resolvedFontSize = $derived(
195
+ isItem
196
+ ? (FONT_SIZES[parentContext.size] ?? '0.75rem')
197
+ : (FONT_SIZES[size] ?? '0.75rem'),
198
+ );
199
+
200
+ const resolvedOrientation = $derived(isItem ? parentContext.orientation : orientation);
201
+
202
+ /* ------------------------------------------------------------------ */
203
+ /* Horizontal overflow (container only) */
204
+ /* */
205
+ /* When the row can't fit, it becomes a snap-scrollable strip with */
206
+ /* edge fade masks. Chevron pagers render only while overflowing and */
207
+ /* are shown only for fine pointers (CSS); touch users swipe. */
208
+ /* ------------------------------------------------------------------ */
209
+ let scroll_el = $state<HTMLElement | undefined>(undefined);
210
+ let overflowing = $state(false);
211
+ let canScrollPrev = $state(false);
212
+ let canScrollNext = $state(false);
213
+
214
+ function updateScrollState() {
215
+ if (!scroll_el) return;
216
+ // Small thresholds absorb sub-pixel scroll positions (fractional
217
+ // scrollLeft / device-pixel rounding) so the end states latch cleanly.
218
+ const { scrollLeft, clientWidth, scrollWidth } = scroll_el;
219
+ overflowing = scrollWidth > clientWidth + 1;
220
+ canScrollPrev = scrollLeft > 2;
221
+ canScrollNext = scrollLeft + clientWidth < scrollWidth - 2;
222
+ }
223
+
224
+ function scrollNext() {
225
+ scroll_el?.scrollBy({ left: scroll_el.clientWidth * 0.8, behavior: 'smooth' });
226
+ }
227
+ function scrollPrev() {
228
+ scroll_el?.scrollBy({ left: -scroll_el.clientWidth * 0.8, behavior: 'smooth' });
229
+ }
230
+
231
+ $effect(() => {
232
+ if (isItem || orientation === 'vertical' || !scroll_el) return;
233
+ const el = scroll_el;
234
+ updateScrollState();
235
+ const onScroll = () => updateScrollState();
236
+ el.addEventListener('scroll', onScroll, { passive: true });
237
+ const ro = new ResizeObserver(updateScrollState);
238
+ ro.observe(el);
239
+ // Content width changes (steps mounting, labels reflowing) don't resize
240
+ // the scroller's own box, so watch the steps too.
241
+ for (const child of Array.from(el.children)) ro.observe(child);
242
+ return () => {
243
+ el.removeEventListener('scroll', onScroll);
244
+ ro.disconnect();
245
+ };
246
+ });
247
+
248
+ // Keep the active step in view: when `current` changes while overflowing,
249
+ // center it in the strip (instantly on first sync so the initial paint
250
+ // doesn't animate).
251
+ let scrollSynced = false;
252
+ $effect(() => {
253
+ if (isItem || !scroll_el) return;
254
+ void current;
255
+ if (!overflowing) return;
256
+ const target = scroll_el.querySelector<HTMLElement>('[aria-current="step"]');
257
+ if (!target) return;
258
+ const left = target.offsetLeft + target.offsetWidth / 2 - scroll_el.clientWidth / 2;
259
+ scroll_el.scrollTo({ left, behavior: scrollSynced ? 'smooth' : 'instant' });
260
+ scrollSynced = true;
261
+ });
262
+ </script>
263
+
264
+ {#if isItem}
265
+ <!-- Step item -->
266
+ <div
267
+ class={['step', stepState, class_name].filter(Boolean).join(' ')}
268
+ class:vertical={resolvedOrientation === 'vertical'}
269
+ class:horizontal={resolvedOrientation !== 'vertical'}
270
+ {id}
271
+ style:--circle-size="{resolvedCircleSize}px"
272
+ style:--step-font-size={resolvedFontSize}
273
+ aria-current={isCurrent ? 'step' : undefined}>
274
+ <div class="main">
275
+ {#if canClick}
276
+ <button
277
+ type="button"
278
+ class="circle"
279
+ aria-label={ariaLabel}
280
+ {@attach ripple({ zIndex: 1 })}
281
+ onclick={handleClick}
282
+ onkeydown={handleKeyDown}>
283
+ {#if isError}
284
+ <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
285
+ <path
286
+ d="M6 18L18 6M6 6l12 12"
287
+ stroke="currentColor"
288
+ stroke-width="2.5"
289
+ stroke-linecap="round"
290
+ fill="none" />
291
+ </svg>
292
+ {:else if isComplete}
293
+ <svg
294
+ class="checkmark"
295
+ width="16"
296
+ height="16"
297
+ viewBox="0 0 24 24"
298
+ aria-hidden="true">
299
+ <path
300
+ d="M5 13l4 4L19 7"
301
+ stroke="currentColor"
302
+ stroke-width="2.5"
303
+ stroke-linecap="round"
304
+ stroke-linejoin="round"
305
+ fill="none" />
306
+ </svg>
307
+ {:else}
308
+ <span class="number">{stepIndex + 1}</span>
309
+ {/if}
310
+ </button>
311
+ {:else}
312
+ <span class="circle" role="img" aria-label={ariaLabel}>
313
+ {#if isError}
314
+ <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
315
+ <path
316
+ d="M6 18L18 6M6 6l12 12"
317
+ stroke="currentColor"
318
+ stroke-width="2.5"
319
+ stroke-linecap="round"
320
+ fill="none" />
321
+ </svg>
322
+ {:else if isComplete}
323
+ <svg
324
+ class="checkmark"
325
+ width="16"
326
+ height="16"
327
+ viewBox="0 0 24 24"
328
+ aria-hidden="true">
329
+ <path
330
+ d="M5 13l4 4L19 7"
331
+ stroke="currentColor"
332
+ stroke-width="2.5"
333
+ stroke-linecap="round"
334
+ stroke-linejoin="round"
335
+ fill="none" />
336
+ </svg>
337
+ {:else}
338
+ <span class="number">{stepIndex + 1}</span>
339
+ {/if}
340
+ </span>
341
+ {/if}
342
+
343
+ <div class="label">
344
+ <span class="title">{title}</span>
345
+ {#if description}
346
+ <span class="description">{description}</span>
347
+ {/if}
348
+ {#if optional}
349
+ <span class="optional">Optional</span>
350
+ {/if}
351
+ </div>
352
+ </div>
353
+
354
+ {#if !isLast}
355
+ <div class="connector">
356
+ <div class="fill" class:filled={connectorFilled}></div>
357
+ </div>
358
+ {/if}
359
+
360
+ {#if children}
361
+ <!-- Expand animates the content open/closed so the surrounding steps
362
+ reflow smoothly when the active step changes. Hidden content stays
363
+ mounted but inert (Expand handles that). -->
364
+ <Expand show={isCurrent} style="width: 100%">
365
+ <div class="content">
366
+ {@render children()}
367
+ </div>
368
+ </Expand>
369
+ {/if}
370
+ </div>
371
+ {:else if skeleton}
372
+ <!-- Skeleton -->
373
+ <div
374
+ class={['steps skeleton', class_name].filter(Boolean).join(' ')}
375
+ class:vertical={orientation === 'vertical'}
376
+ class:horizontal={orientation !== 'vertical'}
377
+ {id}
378
+ style:--circle-size="{CIRCLE_SIZES[size] ?? 32}px"
379
+ style:--step-font-size={FONT_SIZES[size] ?? '0.75rem'}
380
+ aria-hidden="true">
381
+ {#each { length: skeleton_count } as _, i}
382
+ <!-- Reuses the real .step/.main/.label/.connector layout
383
+ classes so every placeholder sits exactly where the real step will. -->
384
+ <div
385
+ class="step"
386
+ class:vertical={orientation === 'vertical'}
387
+ class:horizontal={orientation !== 'vertical'}
388
+ style:--shimmer-delay="{i * 120}ms">
389
+ <div class="main">
390
+ <div class="skeleton-circle"></div>
391
+ <div class="label">
392
+ <div class="skeleton-bar skeleton-title"></div>
393
+ <div class="skeleton-bar skeleton-desc"></div>
394
+ </div>
395
+ </div>
396
+ {#if i < skeleton_count - 1}
397
+ <div class="connector"></div>
398
+ {/if}
399
+ </div>
400
+ {/each}
401
+ </div>
402
+ {:else}
403
+ <!-- Steps container -->
404
+ <div
405
+ class="wrap"
406
+ class:vertical={orientation === 'vertical'}
407
+ class:horizontal={orientation !== 'vertical'}
408
+ style:--circle-size="{CIRCLE_SIZES[size] ?? 32}px">
409
+ {#if orientation !== 'vertical' && overflowing}
410
+ <Button
411
+ icon
412
+ translucent
413
+ size="00"
414
+ class="steps-nav steps-nav-prev"
415
+ aria-label="Scroll to previous steps"
416
+ disabled={!canScrollPrev}
417
+ disable_ripple={!canScrollPrev}
418
+ onclick={scrollPrev}>
419
+ <svg
420
+ viewBox="0 0 24 24"
421
+ fill="none"
422
+ stroke="currentColor"
423
+ stroke-width="2"
424
+ stroke-linecap="round"
425
+ stroke-linejoin="round"
426
+ aria-hidden="true">
427
+ <polyline points="15 18 9 12 15 6" />
428
+ </svg>
429
+ </Button>
430
+ {/if}
431
+ <div
432
+ bind:this={scroll_el}
433
+ class={['steps', class_name].filter(Boolean).join(' ')}
434
+ class:vertical={orientation === 'vertical'}
435
+ class:horizontal={orientation !== 'vertical'}
436
+ {id}
437
+ role="group"
438
+ aria-label="Progress"
439
+ style:--fade-l={canScrollPrev ? '3rem' : '0px'}
440
+ style:--fade-r={canScrollNext ? '3rem' : '0px'}>
441
+ {@render children?.()}
442
+ </div>
443
+ {#if orientation !== 'vertical' && overflowing}
444
+ <Button
445
+ icon
446
+ translucent
447
+ size="00"
448
+ class="steps-nav steps-nav-next"
449
+ aria-label="Scroll to next steps"
450
+ disabled={!canScrollNext}
451
+ disable_ripple={!canScrollNext}
452
+ onclick={scrollNext}>
453
+ <svg
454
+ viewBox="0 0 24 24"
455
+ fill="none"
456
+ stroke="currentColor"
457
+ stroke-width="2"
458
+ stroke-linecap="round"
459
+ stroke-linejoin="round"
460
+ aria-hidden="true">
461
+ <polyline points="9 18 15 12 9 6" />
462
+ </svg>
463
+ </Button>
464
+ {/if}
465
+ </div>
466
+ {/if}
467
+
468
+ <style>
469
+ /* ========== Steps Container ========== */
470
+ .wrap {
471
+ position: relative;
472
+ width: 100%;
473
+
474
+ /* Vertical (and SSR'd) layouts need no overflow chrome — the wrapper
475
+ disappears from layout entirely. */
476
+ &.vertical {
477
+ display: contents;
478
+ }
479
+
480
+ /* Chevron pagers: <Button icon> instances positioned over the strip's
481
+ faded edges, vertically centered on the STEP CIRCLE (not the whole
482
+ step, which is taller because of the label below). The scroll strip
483
+ adds 0.5rem of top padding (halo breathing room), so the circle's
484
+ center sits at 0.5rem + half the circle — match that exactly.
485
+ Touch devices swipe instead, so they only display for fine pointers. */
486
+ :global(.steps-nav) {
487
+ display: none;
488
+ position: absolute;
489
+ top: calc(0.5rem + var(--circle-size, 32px) / 2);
490
+ translate: 0 -50%;
491
+ z-index: 2;
492
+ /* Fade out when there's nothing more to scroll in that direction —
493
+ a clear "you're at the end" cue, not just a dead button. */
494
+ transition:
495
+ opacity 200ms ease,
496
+ visibility 200ms ease;
497
+ box-shadow:
498
+ 0 2px 6px rgba(0, 0, 0, 0.12),
499
+ 0 1px 2px rgba(0, 0, 0, 0.08);
500
+
501
+ /* Button puts `disabled` on its inner <button>, so reach it via :has().
502
+ Muting opacity + dropping the shadow reads as "nothing more this
503
+ way" — distinct from an active, liftable pager. */
504
+ &:has(button:disabled) {
505
+ opacity: 0.32;
506
+ box-shadow: none;
507
+ }
508
+ }
509
+ :global(.steps-nav-prev) {
510
+ left: -0.75rem;
511
+ }
512
+ :global(.steps-nav-next) {
513
+ right: -0.75rem;
514
+ }
515
+ @media (hover: hover) and (pointer: fine) {
516
+ :global(.steps-nav) {
517
+ display: inline-flex;
518
+ }
519
+ }
520
+ }
521
+
522
+ .steps {
523
+ display: flex;
524
+ align-items: flex-start;
525
+ width: 100%;
526
+
527
+ &.vertical {
528
+ flex-direction: column;
529
+ }
530
+
531
+ &.horizontal {
532
+ position: relative;
533
+ overflow-x: auto;
534
+ overscroll-behavior-x: contain;
535
+ scroll-snap-type: x proximity;
536
+ -webkit-overflow-scrolling: touch;
537
+ /* Hide the native scrollbar — overflow is paged via the chevrons on
538
+ desktop and swiped on touch (same pattern as Timeline). */
539
+ scrollbar-width: none;
540
+ /* Breathing room for the current-step halo / focus ring that the
541
+ scroll clip would otherwise crop; the negative margin cancels it
542
+ so nothing shifts when the steps fit. */
543
+ padding-block: 0.5rem;
544
+ margin-block: -0.5rem;
545
+ /* Edge fades hint at clipped content; 0px fades are a no-op mask. */
546
+ mask-image: linear-gradient(
547
+ to right,
548
+ transparent 0,
549
+ black var(--fade-l, 0px),
550
+ black calc(100% - var(--fade-r, 0px)),
551
+ transparent 100%
552
+ );
553
+ }
554
+ &.horizontal::-webkit-scrollbar {
555
+ display: none;
556
+ }
557
+ }
558
+
559
+ /* ========== Step Item ========== */
560
+ .step {
561
+ display: flex;
562
+ position: relative;
563
+
564
+ &.horizontal {
565
+ flex-direction: column;
566
+ align-items: center;
567
+ flex: 1;
568
+ /* The readability floor: steps refuse to crush below this, which is
569
+ what tips a crowded row into the scrollable overflow strip. */
570
+ min-width: var(--step-min-width, 7rem);
571
+ scroll-snap-align: center;
572
+ }
573
+
574
+ &.vertical {
575
+ flex-direction: column;
576
+ align-items: flex-start;
577
+ flex: none;
578
+
579
+ /* Inter-step spacing (the connector is absolutely positioned so it
580
+ can run continuously past expanded step content). */
581
+ &:not(:last-child) {
582
+ padding-bottom: 2rem;
583
+ }
584
+ }
585
+ }
586
+
587
+ .main {
588
+ display: flex;
589
+ gap: 0.625rem;
590
+ /* Press feedback for clickable steps: the indicator + label wrapper
591
+ squeezes toward its OWN center (not the row's), like Button's press. */
592
+ transform-origin: center;
593
+ transition: scale 150ms ease;
594
+
595
+ &:has(button.circle:active) {
596
+ scale: 0.95;
597
+ }
598
+
599
+ .step.horizontal & {
600
+ flex-direction: column;
601
+ align-items: center;
602
+ }
603
+
604
+ .step.vertical & {
605
+ flex-direction: row;
606
+ align-items: flex-start;
607
+ }
608
+ }
609
+
610
+ /* ========== Circle ========== */
611
+ .circle {
612
+ width: var(--circle-size);
613
+ height: var(--circle-size);
614
+ min-width: var(--circle-size);
615
+ min-height: var(--circle-size);
616
+ border-radius: 9999px;
617
+ display: flex;
618
+ align-items: center;
619
+ justify-content: center;
620
+ font-weight: 500;
621
+ font-size: var(--step-font-size);
622
+ border: 2px solid
623
+ light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
624
+ background: transparent;
625
+ color: light-dark(
626
+ var(--color-text-disabled, #9ca3af),
627
+ var(--color-text-disabled, #6b7280)
628
+ );
629
+ position: relative;
630
+ padding: 0;
631
+ cursor: default;
632
+ outline: none;
633
+ transition:
634
+ background-color 300ms ease,
635
+ border-color 300ms ease,
636
+ color 300ms ease,
637
+ box-shadow 300ms ease;
638
+
639
+ svg {
640
+ width: 55%;
641
+ height: 55%;
642
+ }
643
+
644
+ .step.complete & {
645
+ background: var(--color-success, #16a34a);
646
+ border-color: var(--color-success, #16a34a);
647
+ color: white;
648
+ }
649
+
650
+ /* "You are here" is neutral on purpose: a surface-filled circle with a
651
+ strong text-colored ring and a soft breathing halo. It reads clearly
652
+ without adding a hue that clashes with the success/error states. */
653
+ .step.current & {
654
+ background: var(--color-surface, light-dark(#ffffff, #111111));
655
+ border-color: var(--color-text, light-dark(#1a1a1a, #f5f5f5));
656
+ color: var(--color-text, light-dark(#1a1a1a, #f5f5f5));
657
+ font-weight: 600;
658
+ animation: steps-pulse 2.4s ease-in-out infinite;
659
+ }
660
+
661
+ .step.error & {
662
+ background: var(--color-error, #dc2626);
663
+ border-color: var(--color-error, #dc2626);
664
+ color: white;
665
+ animation: steps-shake 400ms ease;
666
+ }
667
+ }
668
+
669
+ button.circle {
670
+ cursor: pointer;
671
+
672
+ /* Hover colors snap IN instantly (the :hover rule owns the in-transition
673
+ and omits color properties) and ease OUT via the base .circle
674
+ transition — the library's "instant in, slow out" pattern. */
675
+ &:hover {
676
+ transition: none;
677
+ }
678
+ .step.complete &:hover {
679
+ background: var(--color-success-active, #128b7e);
680
+ border-color: var(--color-success-active, #128b7e);
681
+ }
682
+ .step.error &:hover {
683
+ background: var(--color-error-active, light-dark(#ca3030, #f55d5d));
684
+ border-color: var(--color-error-active, light-dark(#ca3030, #f55d5d));
685
+ }
686
+ .step.upcoming &:hover,
687
+ .step.current &:hover {
688
+ background: rgb(from var(--color-text, #888888) r g b / 0.08);
689
+ border-color: var(--color-text, light-dark(#1a1a1a, #f5f5f5));
690
+ color: var(--color-text, light-dark(#1a1a1a, #f5f5f5));
691
+ }
692
+
693
+ &:focus-visible {
694
+ outline: 2px solid var(--color-action, #2563eb);
695
+ outline-offset: 2px;
696
+ }
697
+ }
698
+
699
+ /* ========== Checkmark draw-in ========== */
700
+ .step.complete .checkmark path {
701
+ stroke-dasharray: 30;
702
+ stroke-dashoffset: 30;
703
+ animation: steps-check-draw 400ms ease forwards;
704
+ }
705
+
706
+ /* ========== Step Number ========== */
707
+ .number {
708
+ line-height: 1;
709
+ user-select: none;
710
+ }
711
+
712
+ /* ========== Labels ========== */
713
+ .label {
714
+ display: flex;
715
+ flex-direction: column;
716
+ gap: 0.125rem;
717
+
718
+ .step.horizontal & {
719
+ align-items: center;
720
+ text-align: center;
721
+ }
722
+
723
+ .step.vertical & {
724
+ align-items: flex-start;
725
+ text-align: left;
726
+ padding-top: 0.25rem;
727
+ }
728
+ }
729
+
730
+ .title {
731
+ font-weight: 500;
732
+ font-size: var(--step-font-size);
733
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
734
+ line-height: 1.3;
735
+
736
+ .step.upcoming & {
737
+ color: light-dark(
738
+ var(--color-text-disabled, #9ca3af),
739
+ var(--color-text-disabled, #6b7280)
740
+ );
741
+ }
742
+
743
+ /* Bolder label reinforces the neutral "current" indicator. */
744
+ .step.current & {
745
+ font-weight: 600;
746
+ }
747
+ }
748
+
749
+ .description {
750
+ font-size: calc(var(--step-font-size) * 0.85);
751
+ color: light-dark(
752
+ var(--color-text-disabled, #9ca3af),
753
+ var(--color-text-disabled, #6b7280)
754
+ );
755
+ line-height: 1.3;
756
+ }
757
+
758
+ .optional {
759
+ font-size: calc(var(--step-font-size) * 0.8);
760
+ font-style: italic;
761
+ color: light-dark(
762
+ var(--color-text-disabled, #9ca3af),
763
+ var(--color-text-disabled, #6b7280)
764
+ );
765
+ }
766
+
767
+ /* ========== Connector ========== */
768
+ .connector {
769
+ position: relative;
770
+ overflow: hidden;
771
+
772
+ .step.horizontal & {
773
+ width: 100%;
774
+ height: 2px;
775
+ position: absolute;
776
+ top: calc(var(--circle-size) / 2);
777
+ left: calc(50% + var(--circle-size) / 2 + 4px);
778
+ right: calc(-50% + var(--circle-size) / 2 + 4px);
779
+ width: calc(100% - var(--circle-size) - 8px);
780
+ background: light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
781
+ }
782
+
783
+ /* Absolutely positioned so the line spans the step's FULL height —
784
+ including expanded wizard content — keeping it visually continuous
785
+ from this circle down to the next one. (The step's padding-bottom
786
+ provides the minimum run between collapsed steps.) */
787
+ .step.vertical & {
788
+ position: absolute;
789
+ top: calc(var(--circle-size) + 0.25rem);
790
+ bottom: 0.25rem;
791
+ left: calc(var(--circle-size) / 2 - 1px);
792
+ width: 2px;
793
+ background: light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
794
+ }
795
+ }
796
+
797
+ .fill {
798
+ position: absolute;
799
+ inset: 0;
800
+ background: var(--color-success, #16a34a);
801
+ transform-origin: left;
802
+ transform: scaleX(0);
803
+ transition: transform 300ms ease;
804
+
805
+ .step.vertical & {
806
+ transform-origin: top;
807
+ transform: scaleY(0);
808
+ }
809
+
810
+ &.filled {
811
+ transform: scaleX(1);
812
+
813
+ .step.vertical & {
814
+ transform: scaleY(1);
815
+ }
816
+ }
817
+ }
818
+
819
+ /* ========== Step Content (wizard mode) ========== */
820
+ .content {
821
+ padding: 0.75rem 0;
822
+ width: 100%;
823
+
824
+ .step.vertical & {
825
+ padding-left: calc(var(--circle-size) + 0.625rem);
826
+ }
827
+ }
828
+
829
+ /* ========== Skeleton ========== */
830
+ /* Layout comes from the shared .step/.main/.label/.connector
831
+ rules (the placeholder markup reuses those classes), so only the shimmer
832
+ shapes are defined here. The connector renders bare: its border-color
833
+ background already matches an upcoming step's unfilled track. */
834
+ .steps.skeleton {
835
+ pointer-events: none;
836
+ }
837
+
838
+ .skeleton-circle,
839
+ .skeleton-bar {
840
+ position: relative;
841
+ overflow: hidden;
842
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
843
+
844
+ &::after {
845
+ content: '';
846
+ position: absolute;
847
+ inset: 0;
848
+ transform: translateX(-100%);
849
+ background-image: linear-gradient(
850
+ 105deg,
851
+ transparent 25%,
852
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
853
+ transparent 75%
854
+ );
855
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
856
+ infinite;
857
+ animation-delay: var(--shimmer-delay, 0s);
858
+ }
859
+ }
860
+
861
+ /* Matches .circle's outer box exactly (border-box, so the real 2px
862
+ border is already inside --circle-size). */
863
+ .skeleton-circle {
864
+ width: var(--circle-size);
865
+ height: var(--circle-size);
866
+ min-width: var(--circle-size);
867
+ min-height: var(--circle-size);
868
+ border-radius: 9999px;
869
+ }
870
+
871
+ /* Text pills in the label's own font scale; block margins pad each 0.7em
872
+ bar out to the real 1.3 line box so the label column height matches. */
873
+ .skeleton-bar {
874
+ height: 0.7em;
875
+ border-radius: var(--radius-full, 1e5px);
876
+
877
+ &.skeleton-title {
878
+ font-size: var(--step-font-size);
879
+ width: 4.5em;
880
+ margin-block: 0.3em;
881
+ }
882
+
883
+ &.skeleton-desc {
884
+ font-size: calc(var(--step-font-size) * 0.85);
885
+ width: 4em;
886
+ margin-block: 0.3em;
887
+ }
888
+ }
889
+
890
+ /* ========== Animations ========== */
891
+ /* Neutral breathing halo for the current step — derived from the text
892
+ color so it stays hue-free in both light and dark mode. */
893
+ @keyframes steps-pulse {
894
+ 0%,
895
+ 100% {
896
+ box-shadow: 0 0 0 3px rgb(from var(--color-text, #888888) r g b / 0.18);
897
+ }
898
+ 50% {
899
+ box-shadow: 0 0 0 7px rgb(from var(--color-text, #888888) r g b / 0.05);
900
+ }
901
+ }
902
+
903
+ @keyframes steps-shake {
904
+ 0%,
905
+ 100% {
906
+ transform: translateX(0);
907
+ }
908
+ 20% {
909
+ transform: translateX(-3px);
910
+ }
911
+ 40% {
912
+ transform: translateX(3px);
913
+ }
914
+ 60% {
915
+ transform: translateX(-2px);
916
+ }
917
+ 80% {
918
+ transform: translateX(2px);
919
+ }
920
+ }
921
+
922
+ @keyframes steps-check-draw {
923
+ to {
924
+ stroke-dashoffset: 0;
925
+ }
926
+ }
927
+
928
+ @keyframes -global-delight-skeleton-shimmer {
929
+ 0% {
930
+ transform: translateX(-100%);
931
+ }
932
+ 55%,
933
+ 100% {
934
+ transform: translateX(100%);
935
+ }
936
+ }
937
+
938
+ @media (prefers-reduced-motion: reduce) {
939
+ .step.current .circle {
940
+ animation: none;
941
+ /* Keep a static halo so "current" stays identifiable without motion. */
942
+ box-shadow: 0 0 0 3px rgb(from var(--color-text, #888888) r g b / 0.18);
943
+ }
944
+ .main {
945
+ transition: none;
946
+ }
947
+ .main:has(button.circle:active) {
948
+ scale: none;
949
+ }
950
+ .step.error .circle {
951
+ animation: none;
952
+ }
953
+ .step.complete .checkmark path {
954
+ animation: none;
955
+ stroke-dashoffset: 0;
956
+ }
957
+ .skeleton-circle::after,
958
+ .skeleton-bar::after {
959
+ animation: none;
960
+ }
961
+ .fill {
962
+ transition: none;
963
+ }
964
+ }
965
+ </style>