@db-vincent/components 0.0.0-stage → 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 (86) hide show
  1. package/package.json +28 -4
  2. package/src/astro/Accordion.astro +37 -0
  3. package/src/astro/Alert.astro +73 -0
  4. package/src/astro/Avatar.astro +88 -0
  5. package/src/astro/AvatarGroup.astro +63 -0
  6. package/src/astro/Badge.astro +23 -0
  7. package/src/astro/Breadcrumbs.astro +45 -0
  8. package/src/astro/Button.astro +125 -0
  9. package/src/astro/Card.astro +63 -0
  10. package/src/astro/Checkbox.astro +61 -0
  11. package/src/astro/Chip.astro +135 -0
  12. package/src/astro/CodeBlock.astro +17 -0
  13. package/src/astro/Drawer.astro +30 -0
  14. package/src/astro/EmptyState.astro +45 -0
  15. package/src/astro/Example.astro +37 -0
  16. package/src/astro/Grid.astro +56 -0
  17. package/src/astro/GridItem.astro +25 -0
  18. package/src/astro/Icon.astro +52 -0
  19. package/src/astro/Menu.astro +151 -0
  20. package/src/astro/MenuItem.astro +57 -0
  21. package/src/astro/Modal.astro +124 -0
  22. package/src/astro/Navbar.astro +756 -0
  23. package/src/astro/PageHeader.astro +34 -0
  24. package/src/astro/Pagination.astro +212 -0
  25. package/src/astro/Popover.astro +51 -0
  26. package/src/astro/Progress.astro +71 -0
  27. package/src/astro/Radio.astro +58 -0
  28. package/src/astro/RadioGroup.astro +25 -0
  29. package/src/astro/Section.astro +25 -0
  30. package/src/astro/Select.astro +420 -0
  31. package/src/astro/Sidebar.astro +215 -0
  32. package/src/astro/Skeleton.astro +41 -0
  33. package/src/astro/Spinner.astro +20 -0
  34. package/src/astro/Stepper.astro +82 -0
  35. package/src/astro/Switch.astro +78 -0
  36. package/src/astro/Table.astro +268 -0
  37. package/src/astro/Tabs.astro +83 -0
  38. package/src/astro/TextField.astro +305 -0
  39. package/src/astro/ThemeToggle.astro +66 -0
  40. package/src/astro/Toast.astro +181 -0
  41. package/src/astro/Tooltip.astro +54 -0
  42. package/src/astro/TopBar.astro +29 -0
  43. package/src/data/icons.ts +120 -0
  44. package/src/scripts/avatar.ts +33 -0
  45. package/src/scripts/menu.ts +218 -0
  46. package/src/scripts/tabs.ts +127 -0
  47. package/src/scripts/toast.ts +565 -0
  48. package/src/scripts/tooltip.ts +127 -0
  49. package/src/styles/accordion.css +79 -0
  50. package/src/styles/alert.css +108 -0
  51. package/src/styles/avatar.css +131 -0
  52. package/src/styles/badge.css +48 -0
  53. package/src/styles/base.css +205 -0
  54. package/src/styles/breadcrumbs.css +54 -0
  55. package/src/styles/button.css +174 -0
  56. package/src/styles/card.css +112 -0
  57. package/src/styles/chip.css +106 -0
  58. package/src/styles/choice.css +143 -0
  59. package/src/styles/code.css +44 -0
  60. package/src/styles/empty.css +59 -0
  61. package/src/styles/example.css +57 -0
  62. package/src/styles/grid.css +64 -0
  63. package/src/styles/input.css +208 -0
  64. package/src/styles/menu.css +151 -0
  65. package/src/styles/modal.css +285 -0
  66. package/src/styles/motion.css +114 -0
  67. package/src/styles/nav.css +232 -0
  68. package/src/styles/navbar.css +658 -0
  69. package/src/styles/page-header.css +62 -0
  70. package/src/styles/pagination.css +81 -0
  71. package/src/styles/popover.css +134 -0
  72. package/src/styles/progress.css +95 -0
  73. package/src/styles/section.css +38 -0
  74. package/src/styles/select.css +246 -0
  75. package/src/styles/skeleton.css +66 -0
  76. package/src/styles/spinner.css +44 -0
  77. package/src/styles/sr-only.css +14 -0
  78. package/src/styles/stepper.css +169 -0
  79. package/src/styles/switch.css +143 -0
  80. package/src/styles/table.css +177 -0
  81. package/src/styles/tabs.css +120 -0
  82. package/src/styles/theme-toggle.css +31 -0
  83. package/src/styles/toast.css +225 -0
  84. package/src/styles/tooltip.css +63 -0
  85. package/src/styles/topbar.css +32 -0
  86. package/README.md +0 -3
@@ -0,0 +1,420 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/input.css";
4
+ import "../styles/select.css";
5
+
6
+ /**
7
+ * Select. A custom listbox so the popup follows the system: rounded surface,
8
+ * selected indicators and a proper scroll area. Supports single and multiple
9
+ * selection; the selection is mirrored into hidden inputs for form posts.
10
+ */
11
+ interface Option {
12
+ value: string;
13
+ label: string;
14
+ description?: string;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ interface Props {
19
+ label?: string;
20
+ id?: string;
21
+ name?: string;
22
+ options: Option[];
23
+ value?: string;
24
+ values?: string[];
25
+ multiple?: boolean;
26
+ placeholder?: string;
27
+ hint?: string;
28
+ error?: string;
29
+ disabled?: boolean;
30
+ required?: boolean;
31
+ optional?: boolean;
32
+ size?: "sm" | "md" | "lg";
33
+ class?: string;
34
+ }
35
+
36
+ const {
37
+ label,
38
+ id,
39
+ name,
40
+ options,
41
+ value,
42
+ values,
43
+ multiple = false,
44
+ placeholder,
45
+ hint,
46
+ error,
47
+ disabled = false,
48
+ required = false,
49
+ optional = false,
50
+ size = "md",
51
+ class: className,
52
+ } = Astro.props;
53
+
54
+ const selectId = id ?? `select-${Math.random().toString(36).slice(2, 9)}`;
55
+ const listId = `${selectId}-listbox`;
56
+ const hintId = `${selectId}-hint`;
57
+ const errorId = `${selectId}-error`;
58
+ const describedby =
59
+ [hint && hintId, error && errorId].filter(Boolean).join(" ") || undefined;
60
+
61
+ const selectedValues = new Set(
62
+ multiple ? (values ?? (value ? [value] : [])) : value ? [value] : [],
63
+ );
64
+ const chosen = options.filter((option) => selectedValues.has(option.value));
65
+ const display =
66
+ chosen.length === 0
67
+ ? (placeholder ?? "Select an option")
68
+ : chosen.length === 1
69
+ ? chosen[0].label
70
+ : `${chosen.length} selected`;
71
+ ---
72
+
73
+ <div
74
+ class:list={[
75
+ "ds-field",
76
+ "ds-select",
77
+ multiple && "ds-select--multiple",
78
+ size !== "md" && `ds-select--${size}`,
79
+ className,
80
+ ]}
81
+ data-ds-select
82
+ data-multiple={multiple ? "true" : "false"}
83
+ data-name={name}
84
+ data-placeholder={placeholder ?? "Select an option"}
85
+ >
86
+ {label && (
87
+ <label class="ds-field__label" for={selectId}>
88
+ {label}
89
+ {optional && <span class="ds-field__optional"> (optional)</span>}
90
+ {required && (
91
+ <span class="ds-field__required" aria-hidden="true">
92
+ {" "}
93
+ *
94
+ </span>
95
+ )}
96
+ </label>
97
+ )}
98
+ <div class="ds-select__anchor">
99
+ <button
100
+ class:list={[
101
+ "ds-select__control",
102
+ "ds-select__trigger",
103
+ error && "is-invalid",
104
+ chosen.length === 0 && "is-placeholder",
105
+ ]}
106
+ id={selectId}
107
+ type="button"
108
+ role="combobox"
109
+ aria-haspopup="listbox"
110
+ aria-expanded="false"
111
+ aria-controls={listId}
112
+ aria-required={required ? "true" : undefined}
113
+ aria-invalid={error ? "true" : undefined}
114
+ aria-describedby={describedby}
115
+ disabled={disabled}
116
+ data-ds-select-trigger
117
+ >
118
+ <span class="ds-select__value" data-ds-value>
119
+ {display}
120
+ </span>
121
+ <span class="ds-select__chevron" aria-hidden="true">
122
+ <Icon name="chevron-down" size={16} />
123
+ </span>
124
+ </button>
125
+
126
+ <div class="ds-select__popup" data-ds-popup hidden>
127
+ <ul
128
+ class="ds-select__list"
129
+ id={listId}
130
+ role="listbox"
131
+ aria-label={label ?? "Options"}
132
+ aria-multiselectable={multiple ? "true" : undefined}
133
+ >
134
+ {options.map((option, index) => (
135
+ <li
136
+ class="ds-select__option"
137
+ id={`${selectId}-option-${index}`}
138
+ role="option"
139
+ tabindex="-1"
140
+ data-value={option.value}
141
+ data-label={option.label}
142
+ aria-selected={selectedValues.has(option.value) ? "true" : "false"}
143
+ aria-disabled={option.disabled ? "true" : undefined}
144
+ >
145
+ <span class="ds-select__option-text">
146
+ <span class="ds-select__option-label">{option.label}</span>
147
+ {option.description && (
148
+ <span class="ds-select__option-description">
149
+ {option.description}
150
+ </span>
151
+ )}
152
+ </span>
153
+ <span class="ds-select__indicator" aria-hidden="true">
154
+ <Icon name="check" size={14} />
155
+ </span>
156
+ </li>
157
+ ))}
158
+ </ul>
159
+ {multiple && (
160
+ <div class="ds-select__footer">
161
+ <button class="ds-select__done" type="button" data-ds-done>
162
+ Done
163
+ </button>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+
169
+ <div data-ds-values>
170
+ {chosen.map((option) => (
171
+ <input type="hidden" name={name} value={option.value} />
172
+ ))}
173
+ </div>
174
+
175
+ {hint && (
176
+ <div class="ds-field__hint" id={hintId}>
177
+ {hint}
178
+ </div>
179
+ )}
180
+ {error && (
181
+ <div class="ds-field__error" id={errorId}>
182
+ <Icon name="alert-circle" size={14} />
183
+ {error}
184
+ </div>
185
+ )}
186
+ </div>
187
+
188
+ <script>
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;
204
+
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;
212
+
213
+ const selected = () =>
214
+ options.filter(
215
+ (option) => option.getAttribute("aria-selected") === "true",
216
+ );
217
+
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
+ };
231
+
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
+ };
243
+
244
+ const enabled = (index: number) =>
245
+ options[index]?.getAttribute("aria-disabled") !== "true";
246
+
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
+ };
259
+
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
+ };
268
+
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
+ };
275
+
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
+ };
286
+
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
+ };
320
+
321
+ trigger.addEventListener("click", () => {
322
+ if (popup.hidden) open();
323
+ else close();
324
+ });
325
+
326
+ trigger.addEventListener("keydown", (event) => {
327
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
328
+ event.preventDefault();
329
+ if (popup.hidden) open();
330
+ }
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();
372
+ break;
373
+ }
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
+ }
393
+ }
394
+ });
395
+
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
+ });
408
+
409
+ document.addEventListener("pointerdown", (event) => {
410
+ if (!popup.hidden && !root.contains(event.target as Node)) close();
411
+ });
412
+
413
+ syncValues();
414
+ updateLabel();
415
+ });
416
+ };
417
+
418
+ bind();
419
+ document.addEventListener("astro:page-load", bind);
420
+ </script>
@@ -0,0 +1,215 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import Badge from "./Badge.astro";
4
+ import "../styles/nav.css";
5
+
6
+ interface NavItem {
7
+ label: string;
8
+ href: string;
9
+ icon?: string;
10
+ active?: boolean;
11
+ badge?: string;
12
+ open?: boolean;
13
+ children?: { label: string; href: string; active?: boolean }[];
14
+ }
15
+
16
+ interface NavGroup {
17
+ label?: string;
18
+ items: NavItem[];
19
+ }
20
+
21
+ interface Props {
22
+ brand: string;
23
+ brandHref?: string;
24
+ groups: NavGroup[];
25
+ label?: string;
26
+ id?: string;
27
+ class?: string;
28
+ }
29
+
30
+ const {
31
+ brand,
32
+ brandHref = "/",
33
+ groups,
34
+ label = "Primary",
35
+ id = "ds-sidebar",
36
+ class: className,
37
+ } = Astro.props;
38
+ ---
39
+
40
+ <header class="ds-nav__mobilebar">
41
+ <a class="ds-nav__brand" href={brandHref}>
42
+ <span class="ds-nav__brand-mark" aria-hidden="true">
43
+ <Icon name="terminal" size={16} />
44
+ </span>
45
+ {brand}
46
+ </a>
47
+ <button
48
+ class="ds-nav__toggle"
49
+ type="button"
50
+ aria-controls={id}
51
+ aria-expanded="false"
52
+ data-nav-toggle
53
+ >
54
+ <Icon name="menu" size={20} label="Open navigation" />
55
+ </button>
56
+ </header>
57
+
58
+ <aside class:list={["ds-nav", className]} id={id} data-nav data-open="false">
59
+ <a class="ds-nav__brand" href={brandHref}>
60
+ <span class="ds-nav__brand-mark" aria-hidden="true">
61
+ <Icon name="terminal" size={16} />
62
+ </span>
63
+ {brand}
64
+ </a>
65
+
66
+ <div class="ds-nav__scroll">
67
+ <nav aria-label={label}>
68
+ {groups.map((group) => (
69
+ <div class="ds-nav__group">
70
+ {group.label && <div class="ds-nav__group-label">{group.label}</div>}
71
+ <ul class="ds-nav__list">
72
+ {group.items.map((item) =>
73
+ item.children?.length ? (
74
+ <li>
75
+ <details
76
+ class="ds-nav__submenu"
77
+ open={
78
+ item.open || item.children.some((child) => child.active)
79
+ }
80
+ >
81
+ <summary
82
+ class="ds-nav__summary"
83
+ data-active={
84
+ item.children.some((child) => child.active)
85
+ ? "true"
86
+ : undefined
87
+ }
88
+ >
89
+ {item.icon && (
90
+ <Icon name={item.icon} size={16} class="ds-nav__icon" />
91
+ )}
92
+ <span>{item.label}</span>
93
+ <Icon
94
+ name="chevron-right"
95
+ size={14}
96
+ class="ds-nav__chevron"
97
+ />
98
+ </summary>
99
+ <ul class="ds-nav__list">
100
+ {item.children.map((child) => (
101
+ <li>
102
+ <a
103
+ class="ds-nav__link"
104
+ href={child.href}
105
+ aria-current={child.active ? "page" : undefined}
106
+ >
107
+ <span>{child.label}</span>
108
+ </a>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ </details>
113
+ </li>
114
+ ) : (
115
+ <li>
116
+ <a
117
+ class="ds-nav__link"
118
+ href={item.href}
119
+ aria-current={item.active ? "page" : undefined}
120
+ >
121
+ {item.icon && (
122
+ <Icon name={item.icon} size={16} class="ds-nav__icon" />
123
+ )}
124
+ <span>{item.label}</span>
125
+ {item.badge && (
126
+ <span class="ds-nav__badge">
127
+ <Badge>{item.badge}</Badge>
128
+ </span>
129
+ )}
130
+ </a>
131
+ </li>
132
+ ),
133
+ )}
134
+ </ul>
135
+ </div>
136
+ ))}
137
+ </nav>
138
+ </div>
139
+
140
+ <div class="ds-nav__footer">
141
+ <slot />
142
+ </div>
143
+ </aside>
144
+ <div class="ds-nav__backdrop" data-nav-close hidden></div>
145
+
146
+ <script>
147
+ const setup = () => {
148
+ document
149
+ .querySelectorAll<HTMLElement>("[data-nav-toggle]")
150
+ .forEach((toggle) => {
151
+ if (toggle.dataset.bound) return;
152
+ toggle.dataset.bound = "true";
153
+ const id = toggle.getAttribute("aria-controls");
154
+ const nav = id ? document.getElementById(id) : null;
155
+ if (!nav) return;
156
+ const backdrop = nav.nextElementSibling as HTMLElement | null;
157
+
158
+ const setOpen = (open: boolean) => {
159
+ nav.dataset.open = String(open);
160
+ toggle.setAttribute("aria-expanded", String(open));
161
+ toggle.setAttribute(
162
+ "aria-label",
163
+ open ? "Close navigation" : "Open navigation",
164
+ );
165
+ if (backdrop) backdrop.hidden = !open;
166
+ document.body.style.overflow = open ? "hidden" : "";
167
+ if (open) {
168
+ const first = nav.querySelector<HTMLElement>("a, button, summary");
169
+ first?.focus();
170
+ } else {
171
+ toggle.focus();
172
+ }
173
+ };
174
+
175
+ toggle.addEventListener("click", () =>
176
+ setOpen(nav.dataset.open !== "true"),
177
+ );
178
+ backdrop?.addEventListener("click", () => setOpen(false));
179
+ nav.addEventListener("keydown", (event) => {
180
+ if (event.key === "Escape" && nav.dataset.open === "true") {
181
+ setOpen(false);
182
+ return;
183
+ }
184
+ if (event.key !== "Tab" || nav.dataset.open !== "true") return;
185
+ const focusable = Array.from(
186
+ nav.querySelectorAll<HTMLElement>(
187
+ 'a[href], button:not([disabled]), summary, [tabindex]:not([tabindex="-1"])',
188
+ ),
189
+ ).filter((element) =>
190
+ // Links inside a closed <details> are not reachable; skip them.
191
+ typeof element.checkVisibility === "function"
192
+ ? element.checkVisibility()
193
+ : element.offsetParent !== null,
194
+ );
195
+ if (!focusable.length) return;
196
+ const first = focusable[0];
197
+ const last = focusable[focusable.length - 1];
198
+ if (event.shiftKey && document.activeElement === first) {
199
+ event.preventDefault();
200
+ last.focus();
201
+ } else if (!event.shiftKey && document.activeElement === last) {
202
+ event.preventDefault();
203
+ first.focus();
204
+ }
205
+ });
206
+ window.addEventListener("resize", () => {
207
+ if (window.innerWidth > 900 && nav.dataset.open === "true")
208
+ setOpen(false);
209
+ });
210
+ });
211
+ };
212
+
213
+ setup();
214
+ document.addEventListener("astro:page-load", setup);
215
+ </script>
@@ -0,0 +1,41 @@
1
+ ---
2
+ import "../styles/skeleton.css";
3
+
4
+ /**
5
+ * Skeleton. Decorative by definition: aria-hidden, so a loading region must
6
+ * expose its own status ("Loading invoices…") and set aria-busy on the region.
7
+ */
8
+ interface Props {
9
+ variant?: "image" | "heading" | "paragraph";
10
+ lines?: number;
11
+ width?: string;
12
+ class?: string;
13
+ }
14
+
15
+ const {
16
+ variant = "paragraph",
17
+ lines = 3,
18
+ width,
19
+ class: className,
20
+ } = Astro.props;
21
+ const safeWidth =
22
+ width && /^\d+(\.\d+)?(rem|em|px|%|ch|vw)$/.test(width) ? width : undefined;
23
+ ---
24
+
25
+ {variant === "paragraph" ? (
26
+ <div
27
+ class:list={["ds-skeleton-stack", className]}
28
+ aria-hidden="true"
29
+ style={safeWidth ? `width:${safeWidth}` : undefined}
30
+ >
31
+ {Array.from({ length: lines }).map(() => (
32
+ <span class="ds-skeleton ds-skeleton--text"></span>
33
+ ))}
34
+ </div>
35
+ ) : (
36
+ <span
37
+ class:list={["ds-skeleton", `ds-skeleton--${variant}`, className]}
38
+ aria-hidden="true"
39
+ style={safeWidth ? `width:${safeWidth}` : undefined}
40
+ ></span>
41
+ )}
@@ -0,0 +1,20 @@
1
+ ---
2
+ import "../styles/sr-only.css";
3
+ import "../styles/spinner.css";
4
+
5
+ /** Loading spinner. Announces through a visually hidden status, not motion. */
6
+ interface Props {
7
+ size?: "sm" | "md" | "lg";
8
+ label?: string;
9
+ class?: string;
10
+ }
11
+
12
+ const { size = "md", label = "Loading", class: className } = Astro.props;
13
+ ---
14
+
15
+ <span
16
+ class:list={["ds-spinner", size !== "md" && `ds-spinner--${size}`, className]}
17
+ role="status"
18
+ >
19
+ <span class="ds-sr-only">{label}…</span>
20
+ </span>