@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.
Files changed (66) hide show
  1. package/package.json +3 -3
  2. package/src/astro/Avatar.astro +88 -0
  3. package/src/astro/AvatarGroup.astro +63 -0
  4. package/src/astro/Chip.astro +22 -3
  5. package/src/astro/Drawer.astro +30 -0
  6. package/src/astro/Grid.astro +7 -1
  7. package/src/astro/GridItem.astro +3 -1
  8. package/src/astro/Menu.astro +151 -0
  9. package/src/astro/MenuItem.astro +57 -0
  10. package/src/astro/Modal.astro +10 -3
  11. package/src/astro/Pagination.astro +82 -32
  12. package/src/astro/Popover.astro +51 -0
  13. package/src/astro/Progress.astro +71 -0
  14. package/src/astro/Select.astro +211 -194
  15. package/src/astro/Skeleton.astro +4 -2
  16. package/src/astro/Spinner.astro +1 -0
  17. package/src/astro/Stepper.astro +82 -0
  18. package/src/astro/Switch.astro +78 -0
  19. package/src/astro/Table.astro +90 -76
  20. package/src/astro/Tabs.astro +83 -0
  21. package/src/astro/TextField.astro +100 -82
  22. package/src/astro/Toast.astro +1 -1
  23. package/src/astro/Tooltip.astro +54 -0
  24. package/src/data/icons.ts +16 -0
  25. package/src/scripts/avatar.ts +33 -0
  26. package/src/scripts/menu.ts +218 -0
  27. package/src/scripts/tabs.ts +127 -0
  28. package/src/scripts/toast.ts +45 -11
  29. package/src/scripts/tooltip.ts +127 -0
  30. package/src/styles/accordion.css +2 -0
  31. package/src/styles/alert.css +2 -0
  32. package/src/styles/avatar.css +131 -0
  33. package/src/styles/badge.css +2 -0
  34. package/src/styles/base.css +2 -0
  35. package/src/styles/breadcrumbs.css +2 -0
  36. package/src/styles/button.css +13 -2
  37. package/src/styles/card.css +8 -4
  38. package/src/styles/chip.css +2 -0
  39. package/src/styles/choice.css +8 -2
  40. package/src/styles/code.css +2 -0
  41. package/src/styles/empty.css +2 -0
  42. package/src/styles/example.css +2 -0
  43. package/src/styles/grid.css +2 -0
  44. package/src/styles/input.css +11 -1
  45. package/src/styles/menu.css +151 -0
  46. package/src/styles/modal.css +169 -27
  47. package/src/styles/motion.css +21 -3
  48. package/src/styles/nav.css +2 -0
  49. package/src/styles/navbar.css +2 -0
  50. package/src/styles/page-header.css +3 -0
  51. package/src/styles/pagination.css +4 -0
  52. package/src/styles/popover.css +134 -0
  53. package/src/styles/progress.css +95 -0
  54. package/src/styles/section.css +2 -0
  55. package/src/styles/select.css +12 -1
  56. package/src/styles/skeleton.css +2 -0
  57. package/src/styles/spinner.css +2 -0
  58. package/src/styles/sr-only.css +14 -0
  59. package/src/styles/stepper.css +169 -0
  60. package/src/styles/switch.css +143 -0
  61. package/src/styles/table.css +2 -0
  62. package/src/styles/tabs.css +120 -0
  63. package/src/styles/theme-toggle.css +2 -0
  64. package/src/styles/toast.css +41 -23
  65. package/src/styles/tooltip.css +63 -0
  66. 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.0",
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": "^0.1.0"
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>
@@ -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
- const text = (await Astro.slots.render("default"))
33
- .replace(/<[^>]+>/g, "")
34
- .trim();
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>
@@ -25,7 +25,13 @@ const {
25
25
  class: className,
26
26
  } = Astro.props;
27
27
 
28
- const style = `--grid-columns:${columns};--grid-columns-phone:${phoneColumns};--grid-min:${minItem};--grid-masonry-columns:${columns}`;
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}`,
@@ -15,7 +15,9 @@ const {
15
15
  class: className,
16
16
  } = Astro.props;
17
17
 
18
- const style = `--grid-item-span:${span};--grid-item-span-phone:${phoneSpan}`;
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>
@@ -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
- variant?: "dialog" | "sheet";
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
- variant = "dialog",
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
- variant === "sheet" && "ds-modal--sheet",
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
- 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
- }
199
+ render(nav, page);
150
200
  nav.dispatchEvent(
151
201
  new CustomEvent("page-change", {
152
202
  bubbles: true,