@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.
- package/package.json +28 -4
- package/src/astro/Accordion.astro +37 -0
- package/src/astro/Alert.astro +73 -0
- package/src/astro/Badge.astro +23 -0
- package/src/astro/Breadcrumbs.astro +45 -0
- package/src/astro/Button.astro +125 -0
- package/src/astro/Card.astro +63 -0
- package/src/astro/Checkbox.astro +61 -0
- package/src/astro/Chip.astro +116 -0
- package/src/astro/CodeBlock.astro +17 -0
- package/src/astro/EmptyState.astro +45 -0
- package/src/astro/Example.astro +37 -0
- package/src/astro/Grid.astro +50 -0
- package/src/astro/GridItem.astro +23 -0
- package/src/astro/Icon.astro +52 -0
- package/src/astro/Modal.astro +117 -0
- package/src/astro/Navbar.astro +756 -0
- package/src/astro/PageHeader.astro +34 -0
- package/src/astro/Pagination.astro +162 -0
- package/src/astro/Radio.astro +58 -0
- package/src/astro/RadioGroup.astro +25 -0
- package/src/astro/Section.astro +25 -0
- package/src/astro/Select.astro +403 -0
- package/src/astro/Sidebar.astro +215 -0
- package/src/astro/Skeleton.astro +39 -0
- package/src/astro/Spinner.astro +19 -0
- package/src/astro/Table.astro +254 -0
- package/src/astro/TextField.astro +287 -0
- package/src/astro/ThemeToggle.astro +66 -0
- package/src/astro/Toast.astro +181 -0
- package/src/astro/TopBar.astro +29 -0
- package/src/data/icons.ts +104 -0
- package/src/scripts/toast.ts +531 -0
- package/src/styles/accordion.css +77 -0
- package/src/styles/alert.css +106 -0
- package/src/styles/badge.css +46 -0
- package/src/styles/base.css +203 -0
- package/src/styles/breadcrumbs.css +52 -0
- package/src/styles/button.css +163 -0
- package/src/styles/card.css +108 -0
- package/src/styles/chip.css +104 -0
- package/src/styles/choice.css +137 -0
- package/src/styles/code.css +42 -0
- package/src/styles/empty.css +57 -0
- package/src/styles/example.css +55 -0
- package/src/styles/grid.css +62 -0
- package/src/styles/input.css +198 -0
- package/src/styles/modal.css +143 -0
- package/src/styles/motion.css +96 -0
- package/src/styles/nav.css +230 -0
- package/src/styles/navbar.css +656 -0
- package/src/styles/page-header.css +59 -0
- package/src/styles/pagination.css +77 -0
- package/src/styles/section.css +36 -0
- package/src/styles/select.css +235 -0
- package/src/styles/skeleton.css +64 -0
- package/src/styles/spinner.css +42 -0
- package/src/styles/table.css +175 -0
- package/src/styles/theme-toggle.css +29 -0
- package/src/styles/toast.css +207 -0
- package/src/styles/topbar.css +30 -0
- 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>
|