@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.
- package/package.json +28 -4
- package/src/astro/Accordion.astro +37 -0
- package/src/astro/Alert.astro +73 -0
- package/src/astro/Avatar.astro +88 -0
- package/src/astro/AvatarGroup.astro +63 -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 +135 -0
- package/src/astro/CodeBlock.astro +17 -0
- package/src/astro/Drawer.astro +30 -0
- package/src/astro/EmptyState.astro +45 -0
- package/src/astro/Example.astro +37 -0
- package/src/astro/Grid.astro +56 -0
- package/src/astro/GridItem.astro +25 -0
- package/src/astro/Icon.astro +52 -0
- package/src/astro/Menu.astro +151 -0
- package/src/astro/MenuItem.astro +57 -0
- package/src/astro/Modal.astro +124 -0
- package/src/astro/Navbar.astro +756 -0
- package/src/astro/PageHeader.astro +34 -0
- package/src/astro/Pagination.astro +212 -0
- package/src/astro/Popover.astro +51 -0
- package/src/astro/Progress.astro +71 -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 +420 -0
- package/src/astro/Sidebar.astro +215 -0
- package/src/astro/Skeleton.astro +41 -0
- package/src/astro/Spinner.astro +20 -0
- package/src/astro/Stepper.astro +82 -0
- package/src/astro/Switch.astro +78 -0
- package/src/astro/Table.astro +268 -0
- package/src/astro/Tabs.astro +83 -0
- package/src/astro/TextField.astro +305 -0
- package/src/astro/ThemeToggle.astro +66 -0
- package/src/astro/Toast.astro +181 -0
- package/src/astro/Tooltip.astro +54 -0
- package/src/astro/TopBar.astro +29 -0
- package/src/data/icons.ts +120 -0
- package/src/scripts/avatar.ts +33 -0
- package/src/scripts/menu.ts +218 -0
- package/src/scripts/tabs.ts +127 -0
- package/src/scripts/toast.ts +565 -0
- package/src/scripts/tooltip.ts +127 -0
- package/src/styles/accordion.css +79 -0
- package/src/styles/alert.css +108 -0
- package/src/styles/avatar.css +131 -0
- package/src/styles/badge.css +48 -0
- package/src/styles/base.css +205 -0
- package/src/styles/breadcrumbs.css +54 -0
- package/src/styles/button.css +174 -0
- package/src/styles/card.css +112 -0
- package/src/styles/chip.css +106 -0
- package/src/styles/choice.css +143 -0
- package/src/styles/code.css +44 -0
- package/src/styles/empty.css +59 -0
- package/src/styles/example.css +57 -0
- package/src/styles/grid.css +64 -0
- package/src/styles/input.css +208 -0
- package/src/styles/menu.css +151 -0
- package/src/styles/modal.css +285 -0
- package/src/styles/motion.css +114 -0
- package/src/styles/nav.css +232 -0
- package/src/styles/navbar.css +658 -0
- package/src/styles/page-header.css +62 -0
- package/src/styles/pagination.css +81 -0
- package/src/styles/popover.css +134 -0
- package/src/styles/progress.css +95 -0
- package/src/styles/section.css +38 -0
- package/src/styles/select.css +246 -0
- package/src/styles/skeleton.css +66 -0
- package/src/styles/spinner.css +44 -0
- package/src/styles/sr-only.css +14 -0
- package/src/styles/stepper.css +169 -0
- package/src/styles/switch.css +143 -0
- package/src/styles/table.css +177 -0
- package/src/styles/tabs.css +120 -0
- package/src/styles/theme-toggle.css +31 -0
- package/src/styles/toast.css +225 -0
- package/src/styles/tooltip.css +63 -0
- package/src/styles/topbar.css +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from "./Icon.astro";
|
|
3
|
+
import "../styles/chip.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Chip. Renders a span by default. `interactive` renders a toggle button with
|
|
7
|
+
* aria-pressed; `removable` adds a separate remove button with its own label.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
interactive?: boolean;
|
|
11
|
+
selected?: boolean;
|
|
12
|
+
removable?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
removeLabel?: string;
|
|
15
|
+
href?: string;
|
|
16
|
+
size?: "sm" | "md";
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
interactive = false,
|
|
22
|
+
selected = false,
|
|
23
|
+
removable = false,
|
|
24
|
+
disabled = false,
|
|
25
|
+
removeLabel,
|
|
26
|
+
href,
|
|
27
|
+
size = "md",
|
|
28
|
+
class: className,
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
|
|
31
|
+
// The remove button names the item it removes ("Remove 4K"), not just "Remove".
|
|
32
|
+
// The slot HTML is already entity-escaped and Astro escapes the attribute
|
|
33
|
+
// again, so decode before it reaches aria-label.
|
|
34
|
+
const ENTITIES: Record<string, string> = {
|
|
35
|
+
amp: "&",
|
|
36
|
+
lt: "<",
|
|
37
|
+
gt: ">",
|
|
38
|
+
quot: '"',
|
|
39
|
+
apos: "'",
|
|
40
|
+
nbsp: " ",
|
|
41
|
+
};
|
|
42
|
+
const decode = (html: string) =>
|
|
43
|
+
html.replace(
|
|
44
|
+
/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi,
|
|
45
|
+
(match, dec, hex, name) => {
|
|
46
|
+
if (name) return ENTITIES[name.toLowerCase()] ?? match;
|
|
47
|
+
const code = Number.parseInt(dec ?? hex, dec ? 10 : 16);
|
|
48
|
+
return code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
const text = decode(
|
|
52
|
+
(await Astro.slots.render("default")).replace(/<[^>]+>/g, ""),
|
|
53
|
+
).trim();
|
|
54
|
+
const removeName = removeLabel ?? (text ? `Remove ${text}` : "Remove");
|
|
55
|
+
|
|
56
|
+
const classes = [
|
|
57
|
+
"ds-chip",
|
|
58
|
+
size === "sm" && "ds-chip--sm",
|
|
59
|
+
selected && "is-selected",
|
|
60
|
+
disabled && "is-disabled",
|
|
61
|
+
className,
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" ");
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
{removable ? (
|
|
68
|
+
<span class={classes}>
|
|
69
|
+
<slot />
|
|
70
|
+
<button
|
|
71
|
+
class="ds-chip__remove"
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label={removeName}
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
data-chip-remove
|
|
76
|
+
>
|
|
77
|
+
<Icon name="x" size={14} />
|
|
78
|
+
</button>
|
|
79
|
+
</span>
|
|
80
|
+
) : href ? (
|
|
81
|
+
<a class={classes} href={href}>
|
|
82
|
+
<slot />
|
|
83
|
+
</a>
|
|
84
|
+
) : interactive ? (
|
|
85
|
+
<button
|
|
86
|
+
class={classes}
|
|
87
|
+
type="button"
|
|
88
|
+
aria-pressed={selected ? "true" : "false"}
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
data-chip-toggle
|
|
91
|
+
>
|
|
92
|
+
<Icon name="check" size={14} class="ds-chip__check" />
|
|
93
|
+
<slot />
|
|
94
|
+
</button>
|
|
95
|
+
) : (
|
|
96
|
+
<span class={classes}>
|
|
97
|
+
<slot />
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
document.addEventListener("click", (event) => {
|
|
103
|
+
const target = event.target as HTMLElement;
|
|
104
|
+
const remove = target.closest<HTMLButtonElement>("[data-chip-remove]");
|
|
105
|
+
if (remove) {
|
|
106
|
+
const chip = remove.closest<HTMLElement>(".ds-chip");
|
|
107
|
+
if (!chip) return;
|
|
108
|
+
const focusable = (el: Element | null) =>
|
|
109
|
+
el?.matches("[data-chip-toggle]")
|
|
110
|
+
? (el as HTMLElement)
|
|
111
|
+
: el?.querySelector<HTMLElement>(
|
|
112
|
+
"[data-chip-remove], [data-chip-toggle]",
|
|
113
|
+
);
|
|
114
|
+
const target =
|
|
115
|
+
focusable(chip.nextElementSibling) ??
|
|
116
|
+
focusable(chip.previousElementSibling);
|
|
117
|
+
const fallback = chip.parentElement;
|
|
118
|
+
chip.remove();
|
|
119
|
+
if (target) {
|
|
120
|
+
target.focus();
|
|
121
|
+
} else if (fallback) {
|
|
122
|
+
fallback.tabIndex = -1;
|
|
123
|
+
fallback.focus();
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const toggle = target.closest<HTMLButtonElement>("[data-chip-toggle]");
|
|
129
|
+
if (!toggle || toggle.disabled) return;
|
|
130
|
+
toggle.setAttribute(
|
|
131
|
+
"aria-pressed",
|
|
132
|
+
toggle.getAttribute("aria-pressed") === "true" ? "false" : "true",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
</script>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../styles/code.css";
|
|
3
|
+
|
|
4
|
+
/** Code sample. Renders the exact source passed in; no highlighting tricks. */
|
|
5
|
+
interface Props {
|
|
6
|
+
code?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { code, label, class: className } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<figure class:list={["ds-code", className]}>
|
|
15
|
+
{label && <figcaption class="ds-code__label">{label}</figcaption>}
|
|
16
|
+
<pre class="ds-code__pre"><code>{code ?? <slot />}</code></pre>
|
|
17
|
+
</figure>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Modal from "./Modal.astro";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Side drawer: a Modal anchored to the `start` or `end` edge (or `bottom`).
|
|
6
|
+
* Same native <dialog> behaviour, styles and script as Modal; this wrapper only
|
|
7
|
+
* changes the defaults.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
triggerLabel?: string;
|
|
14
|
+
triggerVariant?: "primary" | "secondary" | "ghost" | "danger";
|
|
15
|
+
size?: "sm" | "md" | "lg";
|
|
16
|
+
placement?: "start" | "end" | "bottom";
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
triggerLabel = "Open drawer",
|
|
22
|
+
placement = "end",
|
|
23
|
+
...rest
|
|
24
|
+
} = Astro.props;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<Modal {...rest} triggerLabel={triggerLabel} placement={placement}>
|
|
28
|
+
<slot />
|
|
29
|
+
<slot name="footer" slot="footer" />
|
|
30
|
+
</Modal>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from "./Icon.astro";
|
|
3
|
+
import Skeleton from "./Skeleton.astro";
|
|
4
|
+
import "../styles/empty.css";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Empty state. Says what is missing, why it matters, and the next action.
|
|
8
|
+
* `dashed` frames the region; `skeleton` previews the shape of the content
|
|
9
|
+
* that will replace it.
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
icon?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
message: string;
|
|
15
|
+
dashed?: boolean;
|
|
16
|
+
skeleton?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
icon = "inbox",
|
|
22
|
+
title,
|
|
23
|
+
message,
|
|
24
|
+
dashed = false,
|
|
25
|
+
skeleton = false,
|
|
26
|
+
class: className,
|
|
27
|
+
} = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<div class:list={["ds-empty", dashed && "ds-empty--dashed", className]}>
|
|
31
|
+
<span class="ds-empty__icon" aria-hidden="true">
|
|
32
|
+
<Icon name={icon} size={24} />
|
|
33
|
+
</span>
|
|
34
|
+
{skeleton && (
|
|
35
|
+
<div class="ds-empty__skeleton" aria-hidden="true">
|
|
36
|
+
<Skeleton variant="heading" />
|
|
37
|
+
<Skeleton lines={2} />
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
<h3 class="ds-empty__title">{title}</h3>
|
|
41
|
+
<p class="ds-empty__body">{message}</p>
|
|
42
|
+
<div class="ds-empty__actions">
|
|
43
|
+
<slot />
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import CodeBlock from "./CodeBlock.astro";
|
|
3
|
+
import "../styles/example.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A live example and the exact source that produced it. Every specimen in the
|
|
7
|
+
* explorer goes through this component so no demo can drift from its code.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
title: string;
|
|
11
|
+
code: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
flush?: boolean;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
title,
|
|
19
|
+
code,
|
|
20
|
+
description,
|
|
21
|
+
flush = false,
|
|
22
|
+
class: className,
|
|
23
|
+
} = Astro.props;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<figure class:list={["ds-example", className]}>
|
|
27
|
+
<figcaption class="ds-example__caption">
|
|
28
|
+
<span class="ds-example__title">{title}</span>
|
|
29
|
+
{description && <span class="ds-example__description">{description}</span>}
|
|
30
|
+
</figcaption>
|
|
31
|
+
<div
|
|
32
|
+
class:list={["ds-example__preview", flush && "ds-example__preview--flush"]}
|
|
33
|
+
>
|
|
34
|
+
<slot />
|
|
35
|
+
</div>
|
|
36
|
+
<CodeBlock label="Astro" code={code} />
|
|
37
|
+
</figure>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../styles/grid.css";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
columns?: number;
|
|
6
|
+
phoneColumns?: number;
|
|
7
|
+
gap?: "sm" | "md" | "lg";
|
|
8
|
+
auto?: boolean;
|
|
9
|
+
masonry?: boolean;
|
|
10
|
+
minItem?: string;
|
|
11
|
+
container?: boolean;
|
|
12
|
+
as?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
columns = 12,
|
|
18
|
+
phoneColumns = 1,
|
|
19
|
+
gap = "md",
|
|
20
|
+
auto = false,
|
|
21
|
+
masonry = false,
|
|
22
|
+
minItem = "16rem",
|
|
23
|
+
container = false,
|
|
24
|
+
as: Tag = "div",
|
|
25
|
+
class: className,
|
|
26
|
+
} = Astro.props;
|
|
27
|
+
|
|
28
|
+
const safeLength = (v: string | undefined, fallback?: string) =>
|
|
29
|
+
v && /^\d+(\.\d+)?(rem|em|px|%|ch|vw|vh)$/.test(v) ? v : fallback;
|
|
30
|
+
const safeInt = (n: number, fallback: number) =>
|
|
31
|
+
Number.isFinite(n) ? Math.max(1, Math.floor(n)) : fallback;
|
|
32
|
+
const cols = safeInt(columns, 12);
|
|
33
|
+
const phoneCols = safeInt(phoneColumns, 1);
|
|
34
|
+
const style = `--grid-columns:${cols};--grid-columns-phone:${phoneCols};--grid-min:${safeLength(minItem, "16rem")};--grid-masonry-columns:${cols}`;
|
|
35
|
+
const classes = [
|
|
36
|
+
"ds-grid",
|
|
37
|
+
gap !== "md" && `ds-grid--gap-${gap}`,
|
|
38
|
+
auto && "ds-grid--auto",
|
|
39
|
+
masonry && "ds-grid--masonry",
|
|
40
|
+
className,
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(" ");
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
{container ? (
|
|
47
|
+
<div class="ds-grid__container">
|
|
48
|
+
<Tag class={classes} style={style}>
|
|
49
|
+
<slot />
|
|
50
|
+
</Tag>
|
|
51
|
+
</div>
|
|
52
|
+
) : (
|
|
53
|
+
<Tag class={classes} style={style}>
|
|
54
|
+
<slot />
|
|
55
|
+
</Tag>
|
|
56
|
+
)}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../styles/grid.css";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
span?: number;
|
|
6
|
+
phoneSpan?: number;
|
|
7
|
+
as?: string;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
span = 1,
|
|
13
|
+
phoneSpan = 1,
|
|
14
|
+
as: Tag = "div",
|
|
15
|
+
class: className,
|
|
16
|
+
} = Astro.props;
|
|
17
|
+
|
|
18
|
+
const safeInt = (n: number, fallback: number) =>
|
|
19
|
+
Number.isFinite(n) ? Math.max(1, Math.floor(n)) : fallback;
|
|
20
|
+
const style = `--grid-item-span:${safeInt(span, 1)};--grid-item-span-phone:${safeInt(phoneSpan, 1)}`;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<Tag class:list={["ds-grid__item", className]} style={style}>
|
|
24
|
+
<slot />
|
|
25
|
+
</Tag>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Functional icon set. Decorative by default (aria-hidden). Pass `label` only
|
|
4
|
+
* when the icon is the entire accessible name, e.g. an icon-only button.
|
|
5
|
+
* Icons are 24×24, stroke-based, and inherit the current text color.
|
|
6
|
+
*/
|
|
7
|
+
import { ICONS } from "../data/icons";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
name: string;
|
|
11
|
+
size?: number | string;
|
|
12
|
+
stroke?: number;
|
|
13
|
+
label?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { name, size = 16, stroke, label, class: className } = Astro.props;
|
|
18
|
+
|
|
19
|
+
/* Three sizes carry the whole interface: 14 (inline with small text and
|
|
20
|
+
inside compact controls), 16 (default), 20 (status and dialog chrome).
|
|
21
|
+
24 is reserved for empty states. The stroke is derived from the size so
|
|
22
|
+
every icon renders at the same optical weight (about 1.2-1.5px). */
|
|
23
|
+
const px = typeof size === "number" ? size : Number.parseFloat(size) || 16;
|
|
24
|
+
const OPTICAL_STROKE: Record<number, number> = {
|
|
25
|
+
14: 2,
|
|
26
|
+
16: 1.75,
|
|
27
|
+
20: 1.6,
|
|
28
|
+
24: 1.5,
|
|
29
|
+
};
|
|
30
|
+
const strokeWidth = stroke ?? OPTICAL_STROKE[px] ?? (px <= 14 ? 2 : 1.6);
|
|
31
|
+
|
|
32
|
+
const paths = ICONS[name];
|
|
33
|
+
if (!paths) {
|
|
34
|
+
throw new Error(`Icon: unknown name "${name}".`);
|
|
35
|
+
}
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<svg
|
|
39
|
+
class:list={["ds-icon", className]}
|
|
40
|
+
width={size}
|
|
41
|
+
height={size}
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
stroke="currentColor"
|
|
45
|
+
stroke-width={strokeWidth}
|
|
46
|
+
stroke-linecap="round"
|
|
47
|
+
stroke-linejoin="round"
|
|
48
|
+
role={label ? "img" : undefined}
|
|
49
|
+
aria-label={label}
|
|
50
|
+
aria-hidden={label ? undefined : "true"}
|
|
51
|
+
set:html={paths}
|
|
52
|
+
/>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Button from "./Button.astro";
|
|
3
|
+
import MenuItem from "./MenuItem.astro";
|
|
4
|
+
import "../styles/popover.css";
|
|
5
|
+
import "../styles/menu.css";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Menu: a trigger button and a list of commands in a native popover. Opens
|
|
9
|
+
* with Enter, Space or the arrow keys; arrows, Home, End and typing move
|
|
10
|
+
* between items; Escape closes and returns focus to the trigger; Tab closes.
|
|
11
|
+
* Picking an item fires `ds-menu-select` on the root with `detail.value`
|
|
12
|
+
* (and `detail.checked` for checkbox and radio items), then closes.
|
|
13
|
+
*
|
|
14
|
+
* <Menu triggerLabel="Actions" items={[
|
|
15
|
+
* { value: "restart", label: "Restart" },
|
|
16
|
+
* { type: "separator" },
|
|
17
|
+
* { value: "delete", label: "Delete", danger: true },
|
|
18
|
+
* ]} />
|
|
19
|
+
*/
|
|
20
|
+
interface Item {
|
|
21
|
+
value: string;
|
|
22
|
+
label: string;
|
|
23
|
+
icon?: string;
|
|
24
|
+
/** Renders a link instead of a button. */
|
|
25
|
+
href?: string;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
danger?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CheckItem {
|
|
31
|
+
type: "checkbox";
|
|
32
|
+
value: string;
|
|
33
|
+
label: string;
|
|
34
|
+
checked?: boolean;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RadioItem {
|
|
39
|
+
type: "radio";
|
|
40
|
+
value: string;
|
|
41
|
+
label: string;
|
|
42
|
+
checked?: boolean;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Separator {
|
|
47
|
+
type: "separator";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Group {
|
|
51
|
+
type: "group";
|
|
52
|
+
label: string;
|
|
53
|
+
items: (Item | CheckItem | RadioItem)[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Entry = Item | CheckItem | RadioItem | Separator | Group;
|
|
57
|
+
|
|
58
|
+
interface Props {
|
|
59
|
+
items: Entry[];
|
|
60
|
+
/** Trigger text, or its accessible name when `triggerIcon` is set. */
|
|
61
|
+
triggerLabel?: string;
|
|
62
|
+
/** Icon-only trigger, for example "more-horizontal". */
|
|
63
|
+
triggerIcon?: string;
|
|
64
|
+
triggerVariant?: "primary" | "secondary" | "ghost" | "danger";
|
|
65
|
+
placement?: "top" | "bottom" | "start" | "end";
|
|
66
|
+
id?: string;
|
|
67
|
+
class?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const {
|
|
71
|
+
items,
|
|
72
|
+
triggerLabel = "Actions",
|
|
73
|
+
triggerIcon,
|
|
74
|
+
triggerVariant = "secondary",
|
|
75
|
+
placement = "bottom",
|
|
76
|
+
id,
|
|
77
|
+
class: className,
|
|
78
|
+
} = Astro.props;
|
|
79
|
+
|
|
80
|
+
const rootId = id ?? `menu-${Math.random().toString(36).slice(2, 9)}`;
|
|
81
|
+
const surfaceId = `${rootId}-surface`;
|
|
82
|
+
const triggerId = `${rootId}-trigger`;
|
|
83
|
+
|
|
84
|
+
const isGroup = (entry: Entry): entry is Group =>
|
|
85
|
+
"type" in entry && entry.type === "group";
|
|
86
|
+
const isSeparator = (entry: Entry): entry is Separator =>
|
|
87
|
+
"type" in entry && entry.type === "separator";
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
<span class="ds-anchor" id={rootId} data-ds-menu>
|
|
91
|
+
<Button
|
|
92
|
+
id={triggerId}
|
|
93
|
+
variant={triggerVariant}
|
|
94
|
+
type="button"
|
|
95
|
+
popovertarget={surfaceId}
|
|
96
|
+
aria-haspopup="menu"
|
|
97
|
+
aria-expanded="false"
|
|
98
|
+
aria-controls={surfaceId}
|
|
99
|
+
iconOnly={Boolean(triggerIcon)}
|
|
100
|
+
ariaLabel={triggerIcon ? triggerLabel : undefined}
|
|
101
|
+
icon={triggerIcon ?? "chevron-down"}
|
|
102
|
+
iconPosition="end"
|
|
103
|
+
>
|
|
104
|
+
{!triggerIcon && triggerLabel}
|
|
105
|
+
</Button>
|
|
106
|
+
<div
|
|
107
|
+
class:list={[
|
|
108
|
+
"ds-popover",
|
|
109
|
+
"ds-menu",
|
|
110
|
+
`ds-popover--${placement}`,
|
|
111
|
+
className,
|
|
112
|
+
]}
|
|
113
|
+
id={surfaceId}
|
|
114
|
+
popover="auto"
|
|
115
|
+
role="menu"
|
|
116
|
+
tabindex="-1"
|
|
117
|
+
aria-labelledby={triggerId}
|
|
118
|
+
>
|
|
119
|
+
{items.map((entry, index) =>
|
|
120
|
+
isSeparator(entry) ? (
|
|
121
|
+
<div class="ds-menu__separator" role="separator" />
|
|
122
|
+
) : isGroup(entry) ? (
|
|
123
|
+
<div role="group" aria-labelledby={`${rootId}-group-${index}`}>
|
|
124
|
+
<div class="ds-menu__label" id={`${rootId}-group-${index}`}>
|
|
125
|
+
{entry.label}
|
|
126
|
+
</div>
|
|
127
|
+
{entry.items.map((item) => (
|
|
128
|
+
<MenuItem {...item} />
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
) : (
|
|
132
|
+
<MenuItem {...entry} />
|
|
133
|
+
),
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
</span>
|
|
137
|
+
|
|
138
|
+
<script>
|
|
139
|
+
import { mountMenu } from "../scripts/menu";
|
|
140
|
+
|
|
141
|
+
const bind = () => {
|
|
142
|
+
document.querySelectorAll<HTMLElement>("[data-ds-menu]").forEach((root) => {
|
|
143
|
+
if (root.dataset.dsBound) return;
|
|
144
|
+
root.dataset.dsBound = "true";
|
|
145
|
+
mountMenu(root);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
bind();
|
|
150
|
+
document.addEventListener("astro:page-load", bind);
|
|
151
|
+
</script>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from "./Icon.astro";
|
|
3
|
+
|
|
4
|
+
/** One row of a Menu. Internal: use Menu with an `items` array. */
|
|
5
|
+
interface Props {
|
|
6
|
+
type?: "item" | "checkbox" | "radio";
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: string;
|
|
10
|
+
href?: string;
|
|
11
|
+
checked?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
danger?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
type = "item",
|
|
18
|
+
value,
|
|
19
|
+
label,
|
|
20
|
+
icon,
|
|
21
|
+
href,
|
|
22
|
+
checked = false,
|
|
23
|
+
disabled = false,
|
|
24
|
+
danger = false,
|
|
25
|
+
} = Astro.props;
|
|
26
|
+
|
|
27
|
+
const role =
|
|
28
|
+
type === "checkbox"
|
|
29
|
+
? "menuitemcheckbox"
|
|
30
|
+
: type === "radio"
|
|
31
|
+
? "menuitemradio"
|
|
32
|
+
: "menuitem";
|
|
33
|
+
const Tag = href && type === "item" ? "a" : "button";
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<Tag
|
|
37
|
+
class:list={["ds-menu__item", danger && "ds-menu__item--danger"]}
|
|
38
|
+
role={role}
|
|
39
|
+
tabindex="-1"
|
|
40
|
+
type={Tag === "button" ? "button" : undefined}
|
|
41
|
+
href={Tag === "a" && !disabled ? href : undefined}
|
|
42
|
+
aria-checked={type === "item" ? undefined : String(checked)}
|
|
43
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
44
|
+
data-value={value}
|
|
45
|
+
>
|
|
46
|
+
{type !== "item" && (
|
|
47
|
+
<span class="ds-menu__indicator" aria-hidden="true">
|
|
48
|
+
<Icon name="check" size={16} />
|
|
49
|
+
</span>
|
|
50
|
+
)}
|
|
51
|
+
{icon && (
|
|
52
|
+
<span class="ds-menu__icon" aria-hidden="true">
|
|
53
|
+
<Icon name={icon} size={16} />
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
{label}
|
|
57
|
+
</Tag>
|