@db-vincent/components 0.0.0-stage → 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 (62) 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/Badge.astro +23 -0
  5. package/src/astro/Breadcrumbs.astro +45 -0
  6. package/src/astro/Button.astro +125 -0
  7. package/src/astro/Card.astro +63 -0
  8. package/src/astro/Checkbox.astro +61 -0
  9. package/src/astro/Chip.astro +116 -0
  10. package/src/astro/CodeBlock.astro +17 -0
  11. package/src/astro/EmptyState.astro +45 -0
  12. package/src/astro/Example.astro +37 -0
  13. package/src/astro/Grid.astro +50 -0
  14. package/src/astro/GridItem.astro +23 -0
  15. package/src/astro/Icon.astro +52 -0
  16. package/src/astro/Modal.astro +117 -0
  17. package/src/astro/Navbar.astro +756 -0
  18. package/src/astro/PageHeader.astro +34 -0
  19. package/src/astro/Pagination.astro +162 -0
  20. package/src/astro/Radio.astro +58 -0
  21. package/src/astro/RadioGroup.astro +25 -0
  22. package/src/astro/Section.astro +25 -0
  23. package/src/astro/Select.astro +403 -0
  24. package/src/astro/Sidebar.astro +215 -0
  25. package/src/astro/Skeleton.astro +39 -0
  26. package/src/astro/Spinner.astro +19 -0
  27. package/src/astro/Table.astro +254 -0
  28. package/src/astro/TextField.astro +287 -0
  29. package/src/astro/ThemeToggle.astro +66 -0
  30. package/src/astro/Toast.astro +181 -0
  31. package/src/astro/TopBar.astro +29 -0
  32. package/src/data/icons.ts +104 -0
  33. package/src/scripts/toast.ts +531 -0
  34. package/src/styles/accordion.css +77 -0
  35. package/src/styles/alert.css +106 -0
  36. package/src/styles/badge.css +46 -0
  37. package/src/styles/base.css +203 -0
  38. package/src/styles/breadcrumbs.css +52 -0
  39. package/src/styles/button.css +163 -0
  40. package/src/styles/card.css +108 -0
  41. package/src/styles/chip.css +104 -0
  42. package/src/styles/choice.css +137 -0
  43. package/src/styles/code.css +42 -0
  44. package/src/styles/empty.css +57 -0
  45. package/src/styles/example.css +55 -0
  46. package/src/styles/grid.css +62 -0
  47. package/src/styles/input.css +198 -0
  48. package/src/styles/modal.css +143 -0
  49. package/src/styles/motion.css +96 -0
  50. package/src/styles/nav.css +230 -0
  51. package/src/styles/navbar.css +656 -0
  52. package/src/styles/page-header.css +59 -0
  53. package/src/styles/pagination.css +77 -0
  54. package/src/styles/section.css +36 -0
  55. package/src/styles/select.css +235 -0
  56. package/src/styles/skeleton.css +64 -0
  57. package/src/styles/spinner.css +42 -0
  58. package/src/styles/table.css +175 -0
  59. package/src/styles/theme-toggle.css +29 -0
  60. package/src/styles/toast.css +207 -0
  61. package/src/styles/topbar.css +30 -0
  62. package/README.md +0 -3
@@ -0,0 +1,34 @@
1
+ ---
2
+ import "../styles/page-header.css";
3
+
4
+ /**
5
+ * Page header. One h1, an optional lede, metadata and actions. There is no
6
+ * eyebrow: the breadcrumb in the navbar already says where the page sits.
7
+ * `display` is for the single landing page per site, not for every route.
8
+ */
9
+ interface Props {
10
+ title?: string;
11
+ lede?: string;
12
+ display?: boolean;
13
+ class?: string;
14
+ }
15
+
16
+ const { title, lede, display = false, class: className } = Astro.props;
17
+ ---
18
+
19
+ <header
20
+ class:list={[
21
+ "ds-page-header",
22
+ display && "ds-page-header--display",
23
+ className,
24
+ ]}
25
+ >
26
+ <h1 class="ds-page-header__title">{title ?? <slot name="title" />}</h1>
27
+ <p class="ds-page-header__lede">{lede ?? <slot name="lede" />}</p>
28
+ <div class="ds-page-header__meta">
29
+ <slot name="meta" />
30
+ </div>
31
+ <div class="ds-page-header__actions">
32
+ <slot name="actions" />
33
+ </div>
34
+ </header>
@@ -0,0 +1,162 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/pagination.css";
4
+
5
+ interface Props {
6
+ current: number;
7
+ total: number;
8
+ href?: string;
9
+ label?: string;
10
+ interactive?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ const {
15
+ current,
16
+ total,
17
+ href = "?page={page}",
18
+ label = "Pagination",
19
+ interactive = false,
20
+ class: className,
21
+ } = Astro.props;
22
+
23
+ const urlFor = (page: number) => href.replace("{page}", String(page));
24
+
25
+ type PageToken = number | "ellipsis-start" | "ellipsis-end";
26
+ const tokens: PageToken[] = [];
27
+ if (total <= 7) {
28
+ for (let page = 1; page <= total; page++) tokens.push(page);
29
+ } else {
30
+ tokens.push(1);
31
+ if (current > 4) tokens.push("ellipsis-start");
32
+ const start = Math.max(2, current - 1);
33
+ const end = Math.min(total - 1, current + 1);
34
+ for (let page = start; page <= end; page++) tokens.push(page);
35
+ if (current < total - 3) tokens.push("ellipsis-end");
36
+ tokens.push(total);
37
+ }
38
+
39
+ const Tag = interactive ? "button" : "a";
40
+ const pageProps = (page: number) =>
41
+ interactive
42
+ ? { type: "button" as const, "data-page": page }
43
+ : { href: urlFor(page), "data-page": page };
44
+ ---
45
+
46
+ <nav
47
+ class:list={[
48
+ "ds-pagination",
49
+ interactive && "ds-pagination--interactive",
50
+ className,
51
+ ]}
52
+ aria-label={label}
53
+ data-pagination-interactive={interactive ? "true" : undefined}
54
+ data-total={total}
55
+ >
56
+ <ul class="ds-pagination__list">
57
+ <li>
58
+ <Tag
59
+ class="ds-pagination__link ds-pagination__link--direction"
60
+ {...pageProps(current - 1)}
61
+ aria-disabled={current <= 1 ? "true" : undefined}
62
+ disabled={interactive && current <= 1 ? true : undefined}
63
+ data-direction="prev"
64
+ rel={interactive ? undefined : "prev"}
65
+ >
66
+ <Icon name="chevron-left" size={14} />
67
+ <span class="ds-pagination__label">Previous</span>
68
+ </Tag>
69
+ </li>
70
+ {tokens.map((token) =>
71
+ typeof token === "number" ? (
72
+ <li>
73
+ <Tag
74
+ class="ds-pagination__link"
75
+ {...pageProps(token)}
76
+ aria-current={token === current ? "page" : undefined}
77
+ aria-label={`Page ${token}`}
78
+ >
79
+ {token}
80
+ </Tag>
81
+ </li>
82
+ ) : (
83
+ <li>
84
+ <span class="ds-pagination__ellipsis" aria-hidden="true">
85
+ …
86
+ </span>
87
+ </li>
88
+ ),
89
+ )}
90
+ <li>
91
+ <Tag
92
+ class="ds-pagination__link ds-pagination__link--direction"
93
+ {...pageProps(current + 1)}
94
+ aria-disabled={current >= total ? "true" : undefined}
95
+ disabled={interactive && current >= total ? true : undefined}
96
+ data-direction="next"
97
+ rel={interactive ? undefined : "next"}
98
+ >
99
+ <span class="ds-pagination__label">Next</span>
100
+ <Icon name="chevron-right" size={14} />
101
+ </Tag>
102
+ </li>
103
+ </ul>
104
+ </nav>
105
+
106
+ <script>
107
+ const bind = () => {
108
+ document
109
+ .querySelectorAll<HTMLElement>("[data-pagination-interactive]")
110
+ .forEach((nav) => {
111
+ if (nav.dataset.bound) return;
112
+ nav.dataset.bound = "true";
113
+ nav.addEventListener("click", (event) => {
114
+ const control = (event.target as HTMLElement).closest<HTMLElement>(
115
+ "[data-page]",
116
+ );
117
+ if (!control || control.hasAttribute("disabled")) return;
118
+ event.preventDefault();
119
+ const page = Number(control.dataset.page);
120
+ const total = Number(nav.dataset.total);
121
+ nav.querySelectorAll<HTMLElement>("[data-page]").forEach((item) => {
122
+ if (Number(item.dataset.page) === page) {
123
+ item.setAttribute("aria-current", "page");
124
+ } else {
125
+ item.removeAttribute("aria-current");
126
+ }
127
+ });
128
+ const previous = nav.querySelector<HTMLButtonElement>(
129
+ '[data-direction="prev"]',
130
+ );
131
+ const next = nav.querySelector<HTMLButtonElement>(
132
+ '[data-direction="next"]',
133
+ );
134
+ if (previous) {
135
+ previous.dataset.page = String(Math.max(1, page - 1));
136
+ previous.disabled = page <= 1;
137
+ previous.setAttribute(
138
+ "aria-disabled",
139
+ page <= 1 ? "true" : "false",
140
+ );
141
+ }
142
+ if (next) {
143
+ next.dataset.page = String(Math.min(total, page + 1));
144
+ next.disabled = page >= total;
145
+ next.setAttribute(
146
+ "aria-disabled",
147
+ page >= total ? "true" : "false",
148
+ );
149
+ }
150
+ nav.dispatchEvent(
151
+ new CustomEvent("page-change", {
152
+ bubbles: true,
153
+ detail: { page },
154
+ }),
155
+ );
156
+ });
157
+ });
158
+ };
159
+
160
+ bind();
161
+ document.addEventListener("astro:page-load", bind);
162
+ </script>
@@ -0,0 +1,58 @@
1
+ ---
2
+ import "../styles/choice.css";
3
+
4
+ /** Radio. Wrap a set in RadioGroup for a native fieldset and legend. */
5
+ interface Props {
6
+ label: string;
7
+ description?: string;
8
+ id?: string;
9
+ name: string;
10
+ value: string;
11
+ checked?: boolean;
12
+ disabled?: boolean;
13
+ required?: boolean;
14
+ class?: string;
15
+ }
16
+
17
+ const {
18
+ label,
19
+ description,
20
+ id,
21
+ name,
22
+ value,
23
+ checked = false,
24
+ disabled = false,
25
+ required = false,
26
+ class: className,
27
+ } = Astro.props;
28
+
29
+ const inputId = id ?? `radio-${Math.random().toString(36).slice(2, 9)}`;
30
+ const descriptionId = description ? `${inputId}-description` : undefined;
31
+ // The name comes from the label text only; the description is announced
32
+ // once, as a description, instead of twice (name + description).
33
+ const labelId = `${inputId}-label`;
34
+ ---
35
+
36
+ <label class:list={["ds-choice", "ds-choice--radio", className]}>
37
+ <input
38
+ class="ds-choice__input"
39
+ id={inputId}
40
+ type="radio"
41
+ name={name}
42
+ value={value}
43
+ checked={checked}
44
+ disabled={disabled}
45
+ required={required}
46
+ aria-labelledby={description ? labelId : undefined}
47
+ aria-describedby={descriptionId}
48
+ />
49
+ <span class="ds-choice__box" aria-hidden="true"></span>
50
+ <span class="ds-choice__label">
51
+ <span id={labelId}>{label}</span>
52
+ {description && (
53
+ <span class="ds-choice__description" id={descriptionId}>
54
+ {description}
55
+ </span>
56
+ )}
57
+ </span>
58
+ </label>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import "../styles/choice.css";
3
+
4
+ /** Native fieldset/legend grouping for radios or checkboxes. */
5
+ interface Props {
6
+ legend: string;
7
+ hint?: string;
8
+ class?: string;
9
+ }
10
+
11
+ const { legend, hint, class: className } = Astro.props;
12
+ const hintId = hint
13
+ ? `group-${Math.random().toString(36).slice(2, 9)}-hint`
14
+ : undefined;
15
+ ---
16
+
17
+ <fieldset class:list={["ds-choice-group", className]} aria-describedby={hintId}>
18
+ <legend class="ds-choice-group__legend">{legend}</legend>
19
+ {hint && (
20
+ <p class="ds-choice-group__hint" id={hintId}>
21
+ {hint}
22
+ </p>
23
+ )}
24
+ <slot />
25
+ </fieldset>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import "../styles/section.css";
3
+
4
+ /** Numbered documentation section: heading, mono index and optional intro. */
5
+ interface Props {
6
+ title: string;
7
+ id: string;
8
+ index?: string;
9
+ intro?: string;
10
+ class?: string;
11
+ }
12
+
13
+ const { title, id, index, intro, class: className } = Astro.props;
14
+ ---
15
+
16
+ <section class:list={["ds-section", className]} aria-labelledby={id}>
17
+ <div class="ds-section__head">
18
+ <h2 class="ds-section__title" id={id}>
19
+ {title}
20
+ </h2>
21
+ {index && <span class="ds-section__index">{index}</span>}
22
+ </div>
23
+ {intro && <p class="ds-section__intro">{intro}</p>}
24
+ <slot />
25
+ </section>
@@ -0,0 +1,403 @@
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
+ 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
+ );
212
+
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
+ };
224
+
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
+ };
236
+
237
+ const enabled = (index: number) =>
238
+ options[index]?.getAttribute("aria-disabled") !== "true";
239
+
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
+ };
252
+
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
+ };
261
+
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
+ };
268
+
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
+ };
279
+
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
+ };
309
+
310
+ trigger.addEventListener("click", () => {
311
+ if (popup.hidden) open();
312
+ else close();
313
+ });
314
+
315
+ trigger.addEventListener("keydown", (event) => {
316
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
317
+ event.preventDefault();
318
+ if (popup.hidden) open();
319
+ }
320
+ });
321
+
322
+ list.addEventListener("click", (event) => {
323
+ const option = (event.target as HTMLElement).closest<HTMLElement>(
324
+ '[role="option"]',
325
+ );
326
+ if (option) choose(option);
327
+ });
328
+
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) {
359
+ event.preventDefault();
360
+ done.focus();
361
+ break;
362
+ }
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);
376
+ break;
377
+ }
378
+ }
379
+ }
380
+ }
381
+ });
382
+
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
+ });
395
+
396
+ document.addEventListener("pointerdown", (event) => {
397
+ if (!popup.hidden && !root.contains(event.target as Node)) close();
398
+ });
399
+
400
+ syncValues();
401
+ updateLabel();
402
+ });
403
+ </script>