@db-vincent/components 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +3 -3
  2. package/src/astro/Avatar.astro +88 -0
  3. package/src/astro/AvatarGroup.astro +63 -0
  4. package/src/astro/Chip.astro +22 -3
  5. package/src/astro/Drawer.astro +30 -0
  6. package/src/astro/Grid.astro +7 -1
  7. package/src/astro/GridItem.astro +3 -1
  8. package/src/astro/Menu.astro +151 -0
  9. package/src/astro/MenuItem.astro +57 -0
  10. package/src/astro/Modal.astro +10 -3
  11. package/src/astro/Pagination.astro +82 -32
  12. package/src/astro/Popover.astro +51 -0
  13. package/src/astro/Progress.astro +71 -0
  14. package/src/astro/Select.astro +211 -194
  15. package/src/astro/Skeleton.astro +4 -2
  16. package/src/astro/Spinner.astro +1 -0
  17. package/src/astro/Stepper.astro +82 -0
  18. package/src/astro/Switch.astro +78 -0
  19. package/src/astro/Table.astro +90 -76
  20. package/src/astro/Tabs.astro +83 -0
  21. package/src/astro/TextField.astro +100 -82
  22. package/src/astro/Toast.astro +1 -1
  23. package/src/astro/Tooltip.astro +54 -0
  24. package/src/data/icons.ts +16 -0
  25. package/src/scripts/avatar.ts +33 -0
  26. package/src/scripts/menu.ts +218 -0
  27. package/src/scripts/tabs.ts +127 -0
  28. package/src/scripts/toast.ts +45 -11
  29. package/src/scripts/tooltip.ts +127 -0
  30. package/src/styles/accordion.css +2 -0
  31. package/src/styles/alert.css +2 -0
  32. package/src/styles/avatar.css +131 -0
  33. package/src/styles/badge.css +2 -0
  34. package/src/styles/base.css +2 -0
  35. package/src/styles/breadcrumbs.css +2 -0
  36. package/src/styles/button.css +13 -2
  37. package/src/styles/card.css +8 -4
  38. package/src/styles/chip.css +2 -0
  39. package/src/styles/choice.css +8 -2
  40. package/src/styles/code.css +2 -0
  41. package/src/styles/empty.css +2 -0
  42. package/src/styles/example.css +2 -0
  43. package/src/styles/grid.css +2 -0
  44. package/src/styles/input.css +11 -1
  45. package/src/styles/menu.css +151 -0
  46. package/src/styles/modal.css +169 -27
  47. package/src/styles/motion.css +21 -3
  48. package/src/styles/nav.css +2 -0
  49. package/src/styles/navbar.css +2 -0
  50. package/src/styles/page-header.css +3 -0
  51. package/src/styles/pagination.css +4 -0
  52. package/src/styles/popover.css +134 -0
  53. package/src/styles/progress.css +95 -0
  54. package/src/styles/section.css +2 -0
  55. package/src/styles/select.css +12 -1
  56. package/src/styles/skeleton.css +2 -0
  57. package/src/styles/spinner.css +2 -0
  58. package/src/styles/sr-only.css +14 -0
  59. package/src/styles/stepper.css +169 -0
  60. package/src/styles/switch.css +143 -0
  61. package/src/styles/table.css +2 -0
  62. package/src/styles/tabs.css +120 -0
  63. package/src/styles/theme-toggle.css +2 -0
  64. package/src/styles/toast.css +41 -23
  65. package/src/styles/tooltip.css +63 -0
  66. package/src/styles/topbar.css +2 -0
@@ -0,0 +1,51 @@
1
+ ---
2
+ import Button from "./Button.astro";
3
+ import "../styles/popover.css";
4
+
5
+ /**
6
+ * Popover on the native `popover` attribute. The trigger's `popovertarget`
7
+ * supplies toggling, light dismiss, Escape and focus return, so no script is
8
+ * needed. Placement uses CSS anchor positioning and flips near the viewport
9
+ * edge. For a list of commands use Menu; for a hint use Tooltip.
10
+ */
11
+ interface Props {
12
+ /** Accessible name for the surface. */
13
+ label: string;
14
+ triggerLabel?: string;
15
+ triggerVariant?: "primary" | "secondary" | "ghost" | "danger";
16
+ placement?: "top" | "bottom" | "start" | "end";
17
+ id?: string;
18
+ class?: string;
19
+ }
20
+
21
+ const {
22
+ label,
23
+ triggerLabel = "Open",
24
+ triggerVariant = "secondary",
25
+ placement = "bottom",
26
+ id,
27
+ class: className,
28
+ } = Astro.props;
29
+
30
+ const surfaceId = id ?? `popover-${Math.random().toString(36).slice(2, 9)}`;
31
+ ---
32
+
33
+ <span class="ds-anchor">
34
+ <Button
35
+ variant={triggerVariant}
36
+ type="button"
37
+ popovertarget={surfaceId}
38
+ aria-haspopup="dialog"
39
+ >
40
+ {triggerLabel}
41
+ </Button>
42
+ <div
43
+ class:list={["ds-popover", `ds-popover--${placement}`, className]}
44
+ id={surfaceId}
45
+ popover="auto"
46
+ role="dialog"
47
+ aria-label={label}
48
+ >
49
+ <slot />
50
+ </div>
51
+ </span>
@@ -0,0 +1,71 @@
1
+ ---
2
+ import "../styles/progress.css";
3
+
4
+ /**
5
+ * Progress bar. Determinate when `value` is set, indeterminate otherwise.
6
+ * Uses role="progressbar" rather than <progress> so the bar can animate with
7
+ * a transform and fall back to a static bar under reduced motion.
8
+ */
9
+ interface Props {
10
+ label: string;
11
+ /** Current value, 0 to `max`. Omit for an indeterminate wait. */
12
+ value?: number;
13
+ max?: number;
14
+ /** Overrides the visible and announced value, e.g. "3 of 5 files". */
15
+ valueText?: string;
16
+ tone?: "accent" | "success" | "warning" | "danger";
17
+ size?: "sm" | "md";
18
+ id?: string;
19
+ class?: string;
20
+ }
21
+
22
+ const {
23
+ label,
24
+ value,
25
+ max = 100,
26
+ valueText,
27
+ tone = "accent",
28
+ size = "md",
29
+ id,
30
+ class: className,
31
+ } = Astro.props;
32
+
33
+ const labelId = `${id ?? `progress-${Math.random().toString(36).slice(2, 9)}`}-label`;
34
+ const determinate = value !== undefined;
35
+ const clamped = determinate ? Math.min(Math.max(value, 0), max) : 0;
36
+ const fraction = max > 0 ? clamped / max : 0;
37
+ const text =
38
+ valueText ?? (determinate ? `${Math.round(fraction * 100)}%` : undefined);
39
+ ---
40
+
41
+ <div
42
+ class:list={[
43
+ "ds-progress",
44
+ tone !== "accent" && `ds-progress--${tone}`,
45
+ size !== "md" && `ds-progress--${size}`,
46
+ !determinate && "ds-progress--indeterminate",
47
+ className,
48
+ ]}
49
+ id={id}
50
+ >
51
+ <div class="ds-progress__head">
52
+ <span class="ds-progress__label" id={labelId}>
53
+ {label}
54
+ </span>
55
+ {text && <span class="ds-progress__value">{text}</span>}
56
+ </div>
57
+ <div
58
+ class="ds-progress__track"
59
+ role="progressbar"
60
+ aria-labelledby={labelId}
61
+ aria-valuemin="0"
62
+ aria-valuemax={max}
63
+ aria-valuenow={determinate ? clamped : undefined}
64
+ aria-valuetext={text}
65
+ >
66
+ <div
67
+ class="ds-progress__bar"
68
+ style={determinate ? `--ds-progress-value: ${fraction}` : undefined}
69
+ ></div>
70
+ </div>
71
+ </div>
@@ -186,218 +186,235 @@ const display =
186
186
  </div>
187
187
 
188
188
  <script>
189
- document.querySelectorAll<HTMLElement>("[data-ds-select]").forEach((root) => {
190
- const trigger = root.querySelector<HTMLButtonElement>(
191
- "[data-ds-select-trigger]",
192
- );
193
- const popup = root.querySelector<HTMLElement>("[data-ds-popup]");
194
- const list = root.querySelector<HTMLElement>('[role="listbox"]');
195
- const values = root.querySelector<HTMLElement>("[data-ds-values]");
196
- const valueLabel = root.querySelector<HTMLElement>("[data-ds-value]");
197
- const done = root.querySelector<HTMLButtonElement>("[data-ds-done]");
198
- if (!trigger || !popup || !list || !valueLabel) return;
199
-
200
- const multiple = root.dataset.multiple === "true";
201
- const name = root.dataset.name || "";
202
- const placeholder = root.dataset.placeholder || "Select an option";
203
- const options = Array.from(
204
- list.querySelectorAll<HTMLElement>('[role="option"]'),
205
- );
206
- let active = -1;
207
-
208
- const selected = () =>
209
- options.filter(
210
- (option) => option.getAttribute("aria-selected") === "true",
211
- );
189
+ const bind = () => {
190
+ document
191
+ .querySelectorAll<HTMLElement>("[data-ds-select]")
192
+ .forEach((root) => {
193
+ if (root.dataset.dsBound) return;
194
+ root.dataset.dsBound = "true";
195
+ const trigger = root.querySelector<HTMLButtonElement>(
196
+ "[data-ds-select-trigger]",
197
+ );
198
+ const popup = root.querySelector<HTMLElement>("[data-ds-popup]");
199
+ const list = root.querySelector<HTMLElement>('[role="listbox"]');
200
+ const values = root.querySelector<HTMLElement>("[data-ds-values]");
201
+ const valueLabel = root.querySelector<HTMLElement>("[data-ds-value]");
202
+ const done = root.querySelector<HTMLButtonElement>("[data-ds-done]");
203
+ if (!trigger || !popup || !list || !valueLabel) return;
212
204
 
213
- const updateLabel = () => {
214
- const chosen = selected();
215
- valueLabel.textContent =
216
- chosen.length === 0
217
- ? placeholder
218
- : chosen.length === 1
219
- ? chosen[0].dataset.label || ""
220
- : `${chosen.length} selected`;
221
- trigger.classList.toggle("is-placeholder", chosen.length === 0);
222
- trigger.title = chosen.map((option) => option.dataset.label).join(", ");
223
- };
205
+ const multiple = root.dataset.multiple === "true";
206
+ const name = root.dataset.name || "";
207
+ const placeholder = root.dataset.placeholder || "Select an option";
208
+ const options = Array.from(
209
+ list.querySelectorAll<HTMLElement>('[role="option"]'),
210
+ );
211
+ let active = -1;
224
212
 
225
- const syncValues = () => {
226
- if (!values) return;
227
- values.replaceChildren();
228
- selected().forEach((option) => {
229
- const input = document.createElement("input");
230
- input.type = "hidden";
231
- input.name = name;
232
- input.value = option.dataset.value || "";
233
- values.append(input);
234
- });
235
- };
213
+ const selected = () =>
214
+ options.filter(
215
+ (option) => option.getAttribute("aria-selected") === "true",
216
+ );
236
217
 
237
- const enabled = (index: number) =>
238
- options[index]?.getAttribute("aria-disabled") !== "true";
218
+ const updateLabel = () => {
219
+ const chosen = selected();
220
+ valueLabel.textContent =
221
+ chosen.length === 0
222
+ ? placeholder
223
+ : chosen.length === 1
224
+ ? chosen[0].dataset.label || ""
225
+ : `${chosen.length} selected`;
226
+ trigger.classList.toggle("is-placeholder", chosen.length === 0);
227
+ trigger.title = chosen
228
+ .map((option) => option.dataset.label)
229
+ .join(", ");
230
+ };
239
231
 
240
- // Move from `from` in `direction`, skipping disabled options; stays put
241
- // at either end.
242
- const step = (from: number, direction: 1 | -1) => {
243
- for (
244
- let index = from + direction;
245
- index >= 0 && index < options.length;
246
- index += direction
247
- ) {
248
- if (enabled(index)) return index;
249
- }
250
- return active;
251
- };
232
+ const syncValues = () => {
233
+ if (!values) return;
234
+ values.replaceChildren();
235
+ selected().forEach((option) => {
236
+ const input = document.createElement("input");
237
+ input.type = "hidden";
238
+ input.name = name;
239
+ input.value = option.dataset.value || "";
240
+ values.append(input);
241
+ });
242
+ };
252
243
 
253
- const setActive = (index: number, focus = false) => {
254
- if (!options.length) return;
255
- active = Math.max(0, Math.min(options.length - 1, index));
256
- options.forEach((option, position) =>
257
- option.classList.toggle("is-active", position === active),
258
- );
259
- if (focus) options[active]?.focus();
260
- };
244
+ const enabled = (index: number) =>
245
+ options[index]?.getAttribute("aria-disabled") !== "true";
261
246
 
262
- const close = (focusTrigger = false) => {
263
- popup.hidden = true;
264
- trigger.setAttribute("aria-expanded", "false");
265
- root.classList.remove("is-open");
266
- if (focusTrigger) trigger.focus();
267
- };
247
+ // Move from `from` in `direction`, skipping disabled options; stays put
248
+ // at either end.
249
+ const step = (from: number, direction: 1 | -1) => {
250
+ for (
251
+ let index = from + direction;
252
+ index >= 0 && index < options.length;
253
+ index += direction
254
+ ) {
255
+ if (enabled(index)) return index;
256
+ }
257
+ return active;
258
+ };
268
259
 
269
- const open = () => {
270
- if (trigger.disabled) return;
271
- popup.hidden = false;
272
- trigger.setAttribute("aria-expanded", "true");
273
- root.classList.add("is-open");
274
- const current = options.findIndex(
275
- (option) => option.getAttribute("aria-selected") === "true",
276
- );
277
- setActive(current >= 0 ? current : step(-1, 1), true);
278
- };
260
+ const setActive = (index: number, focus = false) => {
261
+ if (!options.length) return;
262
+ active = Math.max(0, Math.min(options.length - 1, index));
263
+ options.forEach((option, position) =>
264
+ option.classList.toggle("is-active", position === active),
265
+ );
266
+ if (focus) options[active]?.focus();
267
+ };
279
268
 
280
- const choose = (option: HTMLElement) => {
281
- if (option.getAttribute("aria-disabled") === "true") return;
282
- if (multiple) {
283
- option.setAttribute(
284
- "aria-selected",
285
- option.getAttribute("aria-selected") === "true" ? "false" : "true",
286
- );
287
- } else {
288
- options.forEach((entry) =>
289
- entry.setAttribute(
290
- "aria-selected",
291
- entry === option ? "true" : "false",
292
- ),
293
- );
294
- }
295
- syncValues();
296
- updateLabel();
297
- root.dispatchEvent(
298
- new CustomEvent("change", {
299
- bubbles: true,
300
- detail: { values: selected().map((entry) => entry.dataset.value) },
301
- }),
302
- );
303
- if (multiple) {
304
- option.focus();
305
- } else {
306
- close(true);
307
- }
308
- };
269
+ const close = (focusTrigger = false) => {
270
+ popup.hidden = true;
271
+ trigger.setAttribute("aria-expanded", "false");
272
+ root.classList.remove("is-open");
273
+ if (focusTrigger) trigger.focus();
274
+ };
309
275
 
310
- trigger.addEventListener("click", () => {
311
- if (popup.hidden) open();
312
- else close();
313
- });
276
+ const open = () => {
277
+ if (trigger.disabled) return;
278
+ popup.hidden = false;
279
+ trigger.setAttribute("aria-expanded", "true");
280
+ root.classList.add("is-open");
281
+ const current = options.findIndex(
282
+ (option) => option.getAttribute("aria-selected") === "true",
283
+ );
284
+ setActive(current >= 0 ? current : step(-1, 1), true);
285
+ };
314
286
 
315
- trigger.addEventListener("keydown", (event) => {
316
- if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
317
- event.preventDefault();
318
- if (popup.hidden) open();
319
- }
320
- });
287
+ const choose = (option: HTMLElement) => {
288
+ if (option.getAttribute("aria-disabled") === "true") return;
289
+ if (multiple) {
290
+ option.setAttribute(
291
+ "aria-selected",
292
+ option.getAttribute("aria-selected") === "true"
293
+ ? "false"
294
+ : "true",
295
+ );
296
+ } else {
297
+ options.forEach((entry) =>
298
+ entry.setAttribute(
299
+ "aria-selected",
300
+ entry === option ? "true" : "false",
301
+ ),
302
+ );
303
+ }
304
+ syncValues();
305
+ updateLabel();
306
+ root.dispatchEvent(
307
+ new CustomEvent("change", {
308
+ bubbles: true,
309
+ detail: {
310
+ values: selected().map((entry) => entry.dataset.value),
311
+ },
312
+ }),
313
+ );
314
+ if (multiple) {
315
+ option.focus();
316
+ } else {
317
+ close(true);
318
+ }
319
+ };
321
320
 
322
- list.addEventListener("click", (event) => {
323
- const option = (event.target as HTMLElement).closest<HTMLElement>(
324
- '[role="option"]',
325
- );
326
- if (option) choose(option);
327
- });
321
+ trigger.addEventListener("click", () => {
322
+ if (popup.hidden) open();
323
+ else close();
324
+ });
328
325
 
329
- list.addEventListener("keydown", (event) => {
330
- switch (event.key) {
331
- case "ArrowDown":
332
- event.preventDefault();
333
- setActive(step(active, 1), true);
334
- break;
335
- case "ArrowUp":
336
- event.preventDefault();
337
- setActive(step(active, -1), true);
338
- break;
339
- case "Home":
340
- event.preventDefault();
341
- setActive(step(-1, 1), true);
342
- break;
343
- case "End":
344
- event.preventDefault();
345
- setActive(step(options.length, -1), true);
346
- break;
347
- case "Enter":
348
- case " ":
349
- event.preventDefault();
350
- if (active >= 0) choose(options[active]);
351
- break;
352
- case "Escape":
353
- event.preventDefault();
354
- close(true);
355
- break;
356
- case "Tab":
357
- // In multiple mode Tab reaches the Done button before leaving.
358
- if (multiple && done && !event.shiftKey) {
326
+ trigger.addEventListener("keydown", (event) => {
327
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
359
328
  event.preventDefault();
360
- done.focus();
361
- break;
329
+ if (popup.hidden) open();
362
330
  }
363
- close();
364
- trigger.focus();
365
- break;
366
- default:
367
- if (event.key.length === 1) {
368
- const letter = event.key.toLowerCase();
369
- // Wrap-search from the option after the active one; no match
370
- // keeps the current option.
371
- for (let offset = 1; offset <= options.length; offset += 1) {
372
- const index = (active + offset) % options.length;
373
- const label = (options[index].dataset.label || "").toLowerCase();
374
- if (enabled(index) && label.startsWith(letter)) {
375
- setActive(index, true);
331
+ });
332
+
333
+ list.addEventListener("click", (event) => {
334
+ const option = (event.target as HTMLElement).closest<HTMLElement>(
335
+ '[role="option"]',
336
+ );
337
+ if (option) choose(option);
338
+ });
339
+
340
+ list.addEventListener("keydown", (event) => {
341
+ switch (event.key) {
342
+ case "ArrowDown":
343
+ event.preventDefault();
344
+ setActive(step(active, 1), true);
345
+ break;
346
+ case "ArrowUp":
347
+ event.preventDefault();
348
+ setActive(step(active, -1), true);
349
+ break;
350
+ case "Home":
351
+ event.preventDefault();
352
+ setActive(step(-1, 1), true);
353
+ break;
354
+ case "End":
355
+ event.preventDefault();
356
+ setActive(step(options.length, -1), true);
357
+ break;
358
+ case "Enter":
359
+ case " ":
360
+ event.preventDefault();
361
+ if (active >= 0) choose(options[active]);
362
+ break;
363
+ case "Escape":
364
+ event.preventDefault();
365
+ close(true);
366
+ break;
367
+ case "Tab":
368
+ // In multiple mode Tab reaches the Done button before leaving.
369
+ if (multiple && done && !event.shiftKey) {
370
+ event.preventDefault();
371
+ done.focus();
376
372
  break;
377
373
  }
378
- }
374
+ close();
375
+ trigger.focus();
376
+ break;
377
+ default:
378
+ if (event.key.length === 1) {
379
+ const letter = event.key.toLowerCase();
380
+ // Wrap-search from the option after the active one; no match
381
+ // keeps the current option.
382
+ for (let offset = 1; offset <= options.length; offset += 1) {
383
+ const index = (active + offset) % options.length;
384
+ const label = (
385
+ options[index].dataset.label || ""
386
+ ).toLowerCase();
387
+ if (enabled(index) && label.startsWith(letter)) {
388
+ setActive(index, true);
389
+ break;
390
+ }
391
+ }
392
+ }
379
393
  }
380
- }
381
- });
394
+ });
382
395
 
383
- done?.addEventListener("click", () => close(true));
384
- done?.addEventListener("keydown", (event) => {
385
- if (event.key === "Escape") {
386
- event.preventDefault();
387
- close(true);
388
- } else if (event.key === "Tab" && event.shiftKey) {
389
- event.preventDefault();
390
- setActive(active, true);
391
- } else if (event.key === "Tab") {
392
- close();
393
- }
394
- });
396
+ done?.addEventListener("click", () => close(true));
397
+ done?.addEventListener("keydown", (event) => {
398
+ if (event.key === "Escape") {
399
+ event.preventDefault();
400
+ close(true);
401
+ } else if (event.key === "Tab" && event.shiftKey) {
402
+ event.preventDefault();
403
+ setActive(active, true);
404
+ } else if (event.key === "Tab") {
405
+ close();
406
+ }
407
+ });
395
408
 
396
- document.addEventListener("pointerdown", (event) => {
397
- if (!popup.hidden && !root.contains(event.target as Node)) close();
398
- });
409
+ document.addEventListener("pointerdown", (event) => {
410
+ if (!popup.hidden && !root.contains(event.target as Node)) close();
411
+ });
412
+
413
+ syncValues();
414
+ updateLabel();
415
+ });
416
+ };
399
417
 
400
- syncValues();
401
- updateLabel();
402
- });
418
+ bind();
419
+ document.addEventListener("astro:page-load", bind);
403
420
  </script>
@@ -18,13 +18,15 @@ const {
18
18
  width,
19
19
  class: className,
20
20
  } = Astro.props;
21
+ const safeWidth =
22
+ width && /^\d+(\.\d+)?(rem|em|px|%|ch|vw)$/.test(width) ? width : undefined;
21
23
  ---
22
24
 
23
25
  {variant === "paragraph" ? (
24
26
  <div
25
27
  class:list={["ds-skeleton-stack", className]}
26
28
  aria-hidden="true"
27
- style={width ? `width:${width}` : undefined}
29
+ style={safeWidth ? `width:${safeWidth}` : undefined}
28
30
  >
29
31
  {Array.from({ length: lines }).map(() => (
30
32
  <span class="ds-skeleton ds-skeleton--text"></span>
@@ -34,6 +36,6 @@ const {
34
36
  <span
35
37
  class:list={["ds-skeleton", `ds-skeleton--${variant}`, className]}
36
38
  aria-hidden="true"
37
- style={width ? `width:${width}` : undefined}
39
+ style={safeWidth ? `width:${safeWidth}` : undefined}
38
40
  ></span>
39
41
  )}
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import "../styles/sr-only.css";
2
3
  import "../styles/spinner.css";
3
4
 
4
5
  /** Loading spinner. Announces through a visually hidden status, not motion. */
@@ -0,0 +1,82 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/stepper.css";
4
+ import "../styles/sr-only.css";
5
+
6
+ /**
7
+ * Progress through an ordered process. Presentational: an <ol> where the
8
+ * current step carries aria-current="step". State is shown by marker shape
9
+ * (number, check, cross) and read out as visually hidden text, never by
10
+ * colour alone.
11
+ */
12
+ interface Step {
13
+ label: string;
14
+ description?: string;
15
+ state?: "complete" | "current" | "upcoming" | "error";
16
+ }
17
+
18
+ interface Props {
19
+ steps: Step[];
20
+ orientation?: "horizontal" | "vertical";
21
+ /** Accessible name for the list, e.g. "Setup progress". */
22
+ label?: string;
23
+ class?: string;
24
+ }
25
+
26
+ const {
27
+ steps,
28
+ orientation = "horizontal",
29
+ label,
30
+ class: className,
31
+ } = Astro.props;
32
+
33
+ const STATE_TEXT = {
34
+ complete: "Completed",
35
+ error: "Error",
36
+ upcoming: "Not started",
37
+ current: "",
38
+ };
39
+ ---
40
+
41
+ <ol
42
+ class:list={[
43
+ "ds-stepper",
44
+ orientation === "vertical" && "ds-stepper--vertical",
45
+ className,
46
+ ]}
47
+ aria-label={label}
48
+ >
49
+ {steps.map((step, index) => {
50
+ const state = step.state ?? "upcoming";
51
+ return (
52
+ <li
53
+ class="ds-stepper__item"
54
+ data-state={state}
55
+ aria-current={state === "current" ? "step" : undefined}
56
+ >
57
+ <span class="ds-stepper__marker" aria-hidden="true">
58
+ {state === "complete" ? (
59
+ <Icon name="check" size={16} />
60
+ ) : state === "error" ? (
61
+ <Icon name="x" size={16} />
62
+ ) : (
63
+ index + 1
64
+ )}
65
+ </span>
66
+ <span class="ds-stepper__body">
67
+ <span class="ds-stepper__label">
68
+ {step.label}
69
+ {STATE_TEXT[state] && (
70
+ <span class="ds-sr-only">
71
+ , {STATE_TEXT[state].toLowerCase()}
72
+ </span>
73
+ )}
74
+ </span>
75
+ {step.description && (
76
+ <span class="ds-stepper__description">{step.description}</span>
77
+ )}
78
+ </span>
79
+ </li>
80
+ );
81
+ })}
82
+ </ol>