@db-vincent/components 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/astro/Avatar.astro +88 -0
- package/src/astro/AvatarGroup.astro +63 -0
- package/src/astro/Chip.astro +22 -3
- package/src/astro/Drawer.astro +30 -0
- package/src/astro/Grid.astro +7 -1
- package/src/astro/GridItem.astro +3 -1
- package/src/astro/Menu.astro +151 -0
- package/src/astro/MenuItem.astro +57 -0
- package/src/astro/Modal.astro +10 -3
- package/src/astro/Pagination.astro +82 -32
- package/src/astro/Popover.astro +51 -0
- package/src/astro/Progress.astro +71 -0
- package/src/astro/Select.astro +211 -194
- package/src/astro/Skeleton.astro +4 -2
- package/src/astro/Spinner.astro +1 -0
- package/src/astro/Stepper.astro +82 -0
- package/src/astro/Switch.astro +78 -0
- package/src/astro/Table.astro +90 -76
- package/src/astro/Tabs.astro +83 -0
- package/src/astro/TextField.astro +100 -82
- package/src/astro/Toast.astro +1 -1
- package/src/astro/Tooltip.astro +54 -0
- package/src/data/icons.ts +16 -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 +45 -11
- package/src/scripts/tooltip.ts +127 -0
- package/src/styles/accordion.css +2 -0
- package/src/styles/alert.css +2 -0
- package/src/styles/avatar.css +131 -0
- package/src/styles/badge.css +2 -0
- package/src/styles/base.css +2 -0
- package/src/styles/breadcrumbs.css +2 -0
- package/src/styles/button.css +13 -2
- package/src/styles/card.css +8 -4
- package/src/styles/chip.css +2 -0
- package/src/styles/choice.css +8 -2
- package/src/styles/code.css +2 -0
- package/src/styles/empty.css +2 -0
- package/src/styles/example.css +2 -0
- package/src/styles/grid.css +2 -0
- package/src/styles/input.css +11 -1
- package/src/styles/menu.css +151 -0
- package/src/styles/modal.css +169 -27
- package/src/styles/motion.css +21 -3
- package/src/styles/nav.css +2 -0
- package/src/styles/navbar.css +2 -0
- package/src/styles/page-header.css +3 -0
- package/src/styles/pagination.css +4 -0
- package/src/styles/popover.css +134 -0
- package/src/styles/progress.css +95 -0
- package/src/styles/section.css +2 -0
- package/src/styles/select.css +12 -1
- package/src/styles/skeleton.css +2 -0
- package/src/styles/spinner.css +2 -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 +2 -0
- package/src/styles/tabs.css +120 -0
- package/src/styles/theme-toggle.css +2 -0
- package/src/styles/toast.css +41 -23
- package/src/styles/tooltip.css +63 -0
- package/src/styles/topbar.css +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@db-vincent/components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Minerva design-system components for Astro. Token-driven, dependency-free and accessibility-checked. (Placeholder scope `@ds`; see AGENTS.md.)",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"./styles/*.css": "./src/styles/*.css"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"astro": ">=4"
|
|
25
|
+
"astro": ">=4 <8"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@db-vincent/tokens": "
|
|
28
|
+
"@db-vincent/tokens": "0.1.1"
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from "./Icon.astro";
|
|
3
|
+
import { getInitials } from "../scripts/avatar";
|
|
4
|
+
import "../styles/avatar.css";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Avatar: an image with initials (or an icon) underneath, so a missing or
|
|
8
|
+
* failed image still shows who it is. The root is role="img" named by `name`
|
|
9
|
+
* plus the status, so the status never relies on colour. Image errors are
|
|
10
|
+
* handled by scripts/avatar.ts.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Full name. Becomes the accessible name and the source of the initials. */
|
|
14
|
+
name: string;
|
|
15
|
+
src?: string;
|
|
16
|
+
size?: "sm" | "md" | "lg";
|
|
17
|
+
shape?: "circle" | "square";
|
|
18
|
+
/** What shows without an image. Defaults to the initials of `name`. */
|
|
19
|
+
fallback?: "initials" | "icon";
|
|
20
|
+
status?: "online" | "away" | "busy" | "offline";
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
name,
|
|
26
|
+
src,
|
|
27
|
+
size = "md",
|
|
28
|
+
shape = "circle",
|
|
29
|
+
fallback = "initials",
|
|
30
|
+
status,
|
|
31
|
+
class: className,
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
|
|
34
|
+
const initials = getInitials(name);
|
|
35
|
+
const showIcon = fallback === "icon" || initials === "";
|
|
36
|
+
const label = status ? `${name}, ${status}` : name;
|
|
37
|
+
const iconSize = size === "lg" ? 20 : size === "sm" ? 14 : 16;
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<span
|
|
41
|
+
class:list={[
|
|
42
|
+
"ds-avatar",
|
|
43
|
+
size !== "md" && `ds-avatar--${size}`,
|
|
44
|
+
shape === "square" && "ds-avatar--square",
|
|
45
|
+
className,
|
|
46
|
+
]}
|
|
47
|
+
role="img"
|
|
48
|
+
aria-label={label}
|
|
49
|
+
data-ds-avatar
|
|
50
|
+
>
|
|
51
|
+
<span class="ds-avatar__face">
|
|
52
|
+
<span class="ds-avatar__fallback" aria-hidden="true">
|
|
53
|
+
{showIcon ? <Icon name="user" size={iconSize} /> : initials}
|
|
54
|
+
</span>
|
|
55
|
+
{src && (
|
|
56
|
+
<img
|
|
57
|
+
class="ds-avatar__img"
|
|
58
|
+
src={src}
|
|
59
|
+
alt=""
|
|
60
|
+
loading="lazy"
|
|
61
|
+
decoding="async"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</span>
|
|
65
|
+
{status && (
|
|
66
|
+
<span
|
|
67
|
+
class={`ds-avatar__status ds-avatar__status--${status}`}
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
</span>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
import { mountAvatar } from "../scripts/avatar";
|
|
75
|
+
|
|
76
|
+
const bind = () => {
|
|
77
|
+
document
|
|
78
|
+
.querySelectorAll<HTMLElement>("[data-ds-avatar]")
|
|
79
|
+
.forEach((root) => {
|
|
80
|
+
if (root.dataset.dsBound) return;
|
|
81
|
+
root.dataset.dsBound = "true";
|
|
82
|
+
mountAvatar(root);
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
bind();
|
|
87
|
+
document.addEventListener("astro:page-load", bind);
|
|
88
|
+
</script>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Avatar from "./Avatar.astro";
|
|
3
|
+
import "../styles/avatar.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Overlapping avatars with a "+N" count for the rest. The group is named by
|
|
7
|
+
* `label` ("Project members"); the count is named "N more".
|
|
8
|
+
*/
|
|
9
|
+
interface Person {
|
|
10
|
+
name: string;
|
|
11
|
+
src?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
items: Person[];
|
|
16
|
+
/** Accessible name for the whole group. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Avatars shown before the "+N" count. */
|
|
19
|
+
max?: number;
|
|
20
|
+
size?: "sm" | "md" | "lg";
|
|
21
|
+
shape?: "circle" | "square";
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
items,
|
|
27
|
+
label,
|
|
28
|
+
max = 4,
|
|
29
|
+
size = "md",
|
|
30
|
+
shape = "circle",
|
|
31
|
+
class: className,
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
|
|
34
|
+
const visible = items.slice(0, max);
|
|
35
|
+
const hidden = items.length - visible.length;
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
class:list={["ds-avatar-group", className]}
|
|
40
|
+
role="group"
|
|
41
|
+
aria-label={label}
|
|
42
|
+
>
|
|
43
|
+
{visible.map((person) => (
|
|
44
|
+
<Avatar name={person.name} src={person.src} size={size} shape={shape} />
|
|
45
|
+
))}
|
|
46
|
+
{hidden > 0 && (
|
|
47
|
+
<span
|
|
48
|
+
class:list={[
|
|
49
|
+
"ds-avatar",
|
|
50
|
+
size !== "md" && `ds-avatar--${size}`,
|
|
51
|
+
shape === "square" && "ds-avatar--square",
|
|
52
|
+
]}
|
|
53
|
+
role="img"
|
|
54
|
+
aria-label={`${hidden} more`}
|
|
55
|
+
>
|
|
56
|
+
<span class="ds-avatar__face">
|
|
57
|
+
<span class="ds-avatar__fallback ds-avatar__count" aria-hidden="true">
|
|
58
|
+
+{hidden}
|
|
59
|
+
</span>
|
|
60
|
+
</span>
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
package/src/astro/Chip.astro
CHANGED
|
@@ -29,9 +29,28 @@ const {
|
|
|
29
29
|
} = Astro.props;
|
|
30
30
|
|
|
31
31
|
// The remove button names the item it removes ("Remove 4K"), not just "Remove".
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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();
|
|
35
54
|
const removeName = removeLabel ?? (text ? `Remove ${text}` : "Remove");
|
|
36
55
|
|
|
37
56
|
const classes = [
|
|
@@ -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>
|
package/src/astro/Grid.astro
CHANGED
|
@@ -25,7 +25,13 @@ const {
|
|
|
25
25
|
class: className,
|
|
26
26
|
} = Astro.props;
|
|
27
27
|
|
|
28
|
-
const
|
|
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}`;
|
|
29
35
|
const classes = [
|
|
30
36
|
"ds-grid",
|
|
31
37
|
gap !== "md" && `ds-grid--gap-${gap}`,
|
package/src/astro/GridItem.astro
CHANGED
|
@@ -15,7 +15,9 @@ const {
|
|
|
15
15
|
class: className,
|
|
16
16
|
} = Astro.props;
|
|
17
17
|
|
|
18
|
-
const
|
|
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)}`;
|
|
19
21
|
---
|
|
20
22
|
|
|
21
23
|
<Tag class:list={["ds-grid__item", className]} style={style}>
|
|
@@ -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>
|
package/src/astro/Modal.astro
CHANGED
|
@@ -7,6 +7,10 @@ import "../styles/modal.css";
|
|
|
7
7
|
* Modal dialog on native <dialog>. Native showModal() supplies the focus trap,
|
|
8
8
|
* Escape handling and inert background. The trigger is wired with ARIA; focus
|
|
9
9
|
* returns to the trigger on close automatically.
|
|
10
|
+
*
|
|
11
|
+
* `placement` anchors the dialog: "center" (default), or a drawer on the
|
|
12
|
+
* "bottom", "start" or "end" edge. Use the Drawer component for a drawer with
|
|
13
|
+
* its own defaults.
|
|
10
14
|
*/
|
|
11
15
|
interface Props {
|
|
12
16
|
id: string;
|
|
@@ -15,7 +19,7 @@ interface Props {
|
|
|
15
19
|
triggerLabel?: string;
|
|
16
20
|
triggerVariant?: "primary" | "secondary" | "ghost" | "danger";
|
|
17
21
|
size?: "sm" | "md" | "lg";
|
|
18
|
-
|
|
22
|
+
placement?: "center" | "start" | "end" | "bottom";
|
|
19
23
|
class?: string;
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -26,10 +30,13 @@ const {
|
|
|
26
30
|
triggerLabel = "Open dialog",
|
|
27
31
|
triggerVariant = "secondary",
|
|
28
32
|
size = "md",
|
|
29
|
-
|
|
33
|
+
placement: placementProp,
|
|
30
34
|
class: className,
|
|
31
35
|
} = Astro.props;
|
|
32
36
|
|
|
37
|
+
const placement = placementProp ?? "center";
|
|
38
|
+
const edge = placement !== "center";
|
|
39
|
+
|
|
33
40
|
const titleId = `${id}-title`;
|
|
34
41
|
const descriptionId = description ? `${id}-description` : undefined;
|
|
35
42
|
---
|
|
@@ -47,7 +54,7 @@ const descriptionId = description ? `${id}-description` : undefined;
|
|
|
47
54
|
class:list={[
|
|
48
55
|
"ds-modal",
|
|
49
56
|
size !== "md" && `ds-modal--${size}`,
|
|
50
|
-
|
|
57
|
+
edge && ["ds-modal--drawer", `ds-modal--${placement}`],
|
|
51
58
|
className,
|
|
52
59
|
]}
|
|
53
60
|
id={id}
|
|
@@ -52,6 +52,7 @@ const pageProps = (page: number) =>
|
|
|
52
52
|
aria-label={label}
|
|
53
53
|
data-pagination-interactive={interactive ? "true" : undefined}
|
|
54
54
|
data-total={total}
|
|
55
|
+
data-current={current}
|
|
55
56
|
>
|
|
56
57
|
<ul class="ds-pagination__list">
|
|
57
58
|
<li>
|
|
@@ -69,7 +70,7 @@ const pageProps = (page: number) =>
|
|
|
69
70
|
</li>
|
|
70
71
|
{tokens.map((token) =>
|
|
71
72
|
typeof token === "number" ? (
|
|
72
|
-
<li>
|
|
73
|
+
<li data-token>
|
|
73
74
|
<Tag
|
|
74
75
|
class="ds-pagination__link"
|
|
75
76
|
{...pageProps(token)}
|
|
@@ -80,7 +81,7 @@ const pageProps = (page: number) =>
|
|
|
80
81
|
</Tag>
|
|
81
82
|
</li>
|
|
82
83
|
) : (
|
|
83
|
-
<li>
|
|
84
|
+
<li data-token>
|
|
84
85
|
<span class="ds-pagination__ellipsis" aria-hidden="true">
|
|
85
86
|
…
|
|
86
87
|
</span>
|
|
@@ -104,6 +105,84 @@ const pageProps = (page: number) =>
|
|
|
104
105
|
</nav>
|
|
105
106
|
|
|
106
107
|
<script>
|
|
108
|
+
type PageToken = number | "ellipsis-start" | "ellipsis-end";
|
|
109
|
+
|
|
110
|
+
// Same windowing as the server render in the frontmatter.
|
|
111
|
+
const buildTokens = (current: number, total: number): PageToken[] => {
|
|
112
|
+
const tokens: PageToken[] = [];
|
|
113
|
+
if (total <= 7) {
|
|
114
|
+
for (let page = 1; page <= total; page++) tokens.push(page);
|
|
115
|
+
return tokens;
|
|
116
|
+
}
|
|
117
|
+
tokens.push(1);
|
|
118
|
+
if (current > 4) tokens.push("ellipsis-start");
|
|
119
|
+
const start = Math.max(2, current - 1);
|
|
120
|
+
const end = Math.min(total - 1, current + 1);
|
|
121
|
+
for (let page = start; page <= end; page++) tokens.push(page);
|
|
122
|
+
if (current < total - 3) tokens.push("ellipsis-end");
|
|
123
|
+
tokens.push(total);
|
|
124
|
+
return tokens;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const renderToken = (token: PageToken, current: number): HTMLLIElement => {
|
|
128
|
+
const item = document.createElement("li");
|
|
129
|
+
item.dataset.token = "";
|
|
130
|
+
if (typeof token !== "number") {
|
|
131
|
+
const ellipsis = document.createElement("span");
|
|
132
|
+
ellipsis.className = "ds-pagination__ellipsis";
|
|
133
|
+
ellipsis.setAttribute("aria-hidden", "true");
|
|
134
|
+
ellipsis.textContent = "\u2026";
|
|
135
|
+
item.append(ellipsis);
|
|
136
|
+
return item;
|
|
137
|
+
}
|
|
138
|
+
const button = document.createElement("button");
|
|
139
|
+
button.type = "button";
|
|
140
|
+
button.className = "ds-pagination__link";
|
|
141
|
+
button.dataset.page = String(token);
|
|
142
|
+
button.setAttribute("aria-label", `Page ${token}`);
|
|
143
|
+
if (token === current) button.setAttribute("aria-current", "page");
|
|
144
|
+
button.textContent = String(token);
|
|
145
|
+
item.append(button);
|
|
146
|
+
return item;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const render = (nav: HTMLElement, page: number) => {
|
|
150
|
+
const total = Number(nav.dataset.total);
|
|
151
|
+
nav.dataset.current = String(page);
|
|
152
|
+
const previous = nav.querySelector<HTMLButtonElement>(
|
|
153
|
+
'[data-direction="prev"]',
|
|
154
|
+
);
|
|
155
|
+
const next = nav.querySelector<HTMLButtonElement>(
|
|
156
|
+
'[data-direction="next"]',
|
|
157
|
+
);
|
|
158
|
+
const nextItem = next?.closest("li");
|
|
159
|
+
if (!previous || !next || !nextItem) return;
|
|
160
|
+
|
|
161
|
+
const hadFocus = nav.contains(document.activeElement);
|
|
162
|
+
nav.querySelectorAll("li[data-token]").forEach((item) => item.remove());
|
|
163
|
+
buildTokens(page, total).forEach((token) =>
|
|
164
|
+
nextItem.before(renderToken(token, page)),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
previous.dataset.page = String(Math.max(1, page - 1));
|
|
168
|
+
previous.disabled = page <= 1;
|
|
169
|
+
previous.setAttribute("aria-disabled", page <= 1 ? "true" : "false");
|
|
170
|
+
next.dataset.page = String(Math.min(total, page + 1));
|
|
171
|
+
next.disabled = page >= total;
|
|
172
|
+
next.setAttribute("aria-disabled", page >= total ? "true" : "false");
|
|
173
|
+
|
|
174
|
+
// Page buttons are re-created and a direction button can become
|
|
175
|
+
// disabled; hand focus to the current page when the control is gone.
|
|
176
|
+
const focused = document.activeElement;
|
|
177
|
+
const stillUsable =
|
|
178
|
+
focused instanceof HTMLButtonElement &&
|
|
179
|
+
nav.contains(focused) &&
|
|
180
|
+
!focused.disabled;
|
|
181
|
+
if (hadFocus && !stillUsable) {
|
|
182
|
+
nav.querySelector<HTMLElement>('[aria-current="page"]')?.focus();
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
107
186
|
const bind = () => {
|
|
108
187
|
document
|
|
109
188
|
.querySelectorAll<HTMLElement>("[data-pagination-interactive]")
|
|
@@ -117,36 +196,7 @@ const pageProps = (page: number) =>
|
|
|
117
196
|
if (!control || control.hasAttribute("disabled")) return;
|
|
118
197
|
event.preventDefault();
|
|
119
198
|
const page = Number(control.dataset.page);
|
|
120
|
-
|
|
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
|
-
}
|
|
199
|
+
render(nav, page);
|
|
150
200
|
nav.dispatchEvent(
|
|
151
201
|
new CustomEvent("page-change", {
|
|
152
202
|
bubbles: true,
|