@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/src/data/icons.ts CHANGED
@@ -43,6 +43,7 @@ export const ICONS: Record<string, string> = {
43
43
  clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
44
44
  command:
45
45
  '<path d="M9 6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6Z"/>',
46
+ copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"/>',
46
47
  cpu: '<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
47
48
  database:
48
49
  '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
@@ -97,8 +98,23 @@ export const ICONS: Record<string, string> = {
97
98
  trash:
98
99
  '<path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6M14 11v6"/>',
99
100
  upload: '<path d="M12 21V9"/><path d="m7 14 5-5 5 5"/><path d="M5 3h14"/>',
101
+ user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
100
102
  users:
101
103
  '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>',
102
104
  x: '<path d="M18 6 6 18M6 6l12 12"/>',
103
105
  zap: '<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z"/>',
106
+ "toggle-right":
107
+ '<rect x="2" y="7" width="20" height="10" rx="5"/><circle cx="16" cy="12" r="2.5"/>',
108
+ "panel-right":
109
+ '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/>',
110
+ "message-square-text":
111
+ '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/><path d="M8 9h8M8 13h5"/>',
112
+ "folder-tabs":
113
+ '<path d="M3 21V5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3h9a1 1 0 0 1 1 1v12Z"/>',
114
+ tooltip:
115
+ '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="m9 16 3 4 3-4"/>',
116
+ "list-ordered":
117
+ '<path d="M10 6h11M10 12h11M10 18h11"/><path d="M4 6h1v4M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3a1 1 0 0 0-2 0"/>',
118
+ "bar-progress":
119
+ '<rect x="3" y="9" width="18" height="6" rx="3"/><path d="M6 12h6"/>',
104
120
  };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Avatar runtime. Dependency-free; shared by the Astro component and the
3
+ * React port.
4
+ *
5
+ * import { getInitials, mountAvatar } from "@db-vincent/components/scripts/avatar";
6
+ *
7
+ * An image that fails to load marks the avatar `data-failed`, which hides the
8
+ * image and leaves the initials (or icon) underneath showing.
9
+ */
10
+
11
+ /** First letter of the first and last word, upper-cased: "ada king lovelace" -> "AL". */
12
+ export function getInitials(name: string): string {
13
+ const words = name.trim().split(/\s+/).filter(Boolean);
14
+ if (words.length === 0) return "";
15
+ const first = words[0];
16
+ const last = words.length > 1 ? words[words.length - 1] : "";
17
+ return `${[...first][0] ?? ""}${[...last][0] ?? ""}`.toUpperCase();
18
+ }
19
+
20
+ /** Wire up one `[data-ds-avatar]` root. Returns a cleanup function. */
21
+ export function mountAvatar(root: HTMLElement): () => void {
22
+ const image = root.querySelector<HTMLImageElement>(".ds-avatar__img");
23
+ if (!image) return () => {};
24
+
25
+ const fail = () => {
26
+ root.dataset.failed = "";
27
+ };
28
+ image.addEventListener("error", fail);
29
+ // The error may have fired before this script ran.
30
+ if (image.complete && image.naturalWidth === 0) fail();
31
+
32
+ return () => image.removeEventListener("error", fail);
33
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Menu runtime. Dependency-free; the Astro component calls `mountMenu`, the
3
+ * React port uses the pure helpers. The surface is a native `popover="auto"`,
4
+ * so light dismiss, Escape and focus return come from the platform. This adds
5
+ * the WAI-ARIA menu behaviour: roving focus, arrow keys, Home/End, typeahead,
6
+ * Tab to close, and checkbox/radio state.
7
+ *
8
+ * import { mountMenu } from "@db-vincent/components/scripts/menu";
9
+ * const destroy = mountMenu(document.querySelector("[data-ds-menu]"));
10
+ */
11
+
12
+ const ITEM = '[role^="menuitem"]';
13
+ const TYPEAHEAD_RESET_MS = 500;
14
+
15
+ /**
16
+ * Index a navigation key should focus, wrapping at both ends, or `null` when
17
+ * the key is not a menu navigation key. `current` is -1 when no item has focus.
18
+ */
19
+ export function nextMenuIndex(
20
+ key: string,
21
+ current: number,
22
+ count: number,
23
+ ): number | null {
24
+ if (count === 0) return null;
25
+ if (key === "Home") return 0;
26
+ if (key === "End") return count - 1;
27
+ if (key === "ArrowDown") return current < 0 ? 0 : (current + 1) % count;
28
+ if (key === "ArrowUp")
29
+ return current < 0 ? count - 1 : (current - 1 + count) % count;
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Index of the next label starting with `query`, case-insensitive, or `null`.
35
+ * A single character cycles through matches after `current`; a longer query
36
+ * keeps the current item when it still matches.
37
+ */
38
+ export function typeaheadIndex(
39
+ labels: readonly string[],
40
+ current: number,
41
+ query: string,
42
+ ): number | null {
43
+ const needle = query.trim().toLowerCase();
44
+ if (!needle || labels.length === 0) return null;
45
+ const start = needle.length === 1 ? current + 1 : Math.max(current, 0);
46
+ for (let offset = 0; offset < labels.length; offset += 1) {
47
+ const index = (start + offset) % labels.length;
48
+ if (labels[index].trim().toLowerCase().startsWith(needle)) return index;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** Wire up one `[data-ds-menu]` root. Returns a cleanup function. */
54
+ export function mountMenu(root: HTMLElement): () => void {
55
+ const trigger = root.querySelector<HTMLElement>('[aria-haspopup="menu"]');
56
+ const surface = root.querySelector<HTMLElement>('[role="menu"]');
57
+ if (!trigger || !surface) return () => {};
58
+
59
+ const items = () => [...surface.querySelectorAll<HTMLElement>(ITEM)];
60
+ const labelOf = (item: HTMLElement) => item.textContent ?? "";
61
+
62
+ let viaKeyboard = false;
63
+ let landOn: "first" | "last" = "first";
64
+ let query = "";
65
+ let queryTimer = 0;
66
+
67
+ const focusItem = (index: number | null) => {
68
+ if (index !== null) items()[index]?.focus({ preventScroll: false });
69
+ };
70
+
71
+ const onTriggerKeydown = (event: KeyboardEvent) => {
72
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
73
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
74
+ event.preventDefault();
75
+ viaKeyboard = true;
76
+ landOn = event.key === "ArrowUp" ? "last" : "first";
77
+ if (!surface.matches(":popover-open")) surface.showPopover();
78
+ } else if (event.key === "Enter" || event.key === " ") {
79
+ viaKeyboard = true;
80
+ landOn = "first";
81
+ }
82
+ };
83
+
84
+ const onTriggerPointerdown = () => {
85
+ viaKeyboard = false;
86
+ };
87
+
88
+ const onBeforeToggle = (event: Event) => {
89
+ if ((event as ToggleEvent).newState === "open") {
90
+ // A keyboard open appears at once; only pointer opens animate.
91
+ surface.toggleAttribute("data-instant", viaKeyboard);
92
+ }
93
+ };
94
+
95
+ const onToggle = (event: Event) => {
96
+ const open = (event as ToggleEvent).newState === "open";
97
+ trigger.setAttribute("aria-expanded", String(open));
98
+ if (open) {
99
+ if (viaKeyboard) {
100
+ const all = items();
101
+ focusItem(landOn === "last" ? all.length - 1 : 0);
102
+ } else {
103
+ surface.focus({ preventScroll: true });
104
+ }
105
+ return;
106
+ }
107
+ query = "";
108
+ window.clearTimeout(queryTimer);
109
+ const active = document.activeElement;
110
+ if (!active || active === document.body || surface.contains(active)) {
111
+ trigger.focus({ preventScroll: true });
112
+ }
113
+ };
114
+
115
+ const activate = (item: HTMLElement) => {
116
+ if (item.getAttribute("aria-disabled") === "true") return;
117
+ const role = item.getAttribute("role");
118
+ if (role === "menuitemcheckbox") {
119
+ item.setAttribute(
120
+ "aria-checked",
121
+ String(item.getAttribute("aria-checked") !== "true"),
122
+ );
123
+ } else if (role === "menuitemradio") {
124
+ const group = item.closest('[role="group"]') ?? surface;
125
+ for (const radio of group.querySelectorAll<HTMLElement>(
126
+ '[role="menuitemradio"]',
127
+ )) {
128
+ radio.setAttribute("aria-checked", String(radio === item));
129
+ }
130
+ }
131
+ root.dispatchEvent(
132
+ new CustomEvent("ds-menu-select", {
133
+ detail: {
134
+ value: item.dataset.value ?? null,
135
+ checked:
136
+ role === "menuitem"
137
+ ? null
138
+ : item.getAttribute("aria-checked") === "true",
139
+ },
140
+ }),
141
+ );
142
+ surface.hidePopover();
143
+ };
144
+
145
+ const onClick = (event: MouseEvent) => {
146
+ const item = (event.target as Element).closest<HTMLElement>(ITEM);
147
+ if (!item) return;
148
+ if (item.getAttribute("aria-disabled") === "true") {
149
+ event.preventDefault();
150
+ return;
151
+ }
152
+ activate(item);
153
+ };
154
+
155
+ const onKeydown = (event: KeyboardEvent) => {
156
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
157
+ const all = items();
158
+ const current = all.indexOf(document.activeElement as HTMLElement);
159
+
160
+ if (event.key === "Tab") {
161
+ surface.hidePopover();
162
+ return;
163
+ }
164
+
165
+ const next = nextMenuIndex(event.key, current, all.length);
166
+ if (next !== null) {
167
+ event.preventDefault();
168
+ focusItem(next);
169
+ return;
170
+ }
171
+
172
+ // Space activates links like it does buttons.
173
+ if (
174
+ event.key === " " &&
175
+ !query &&
176
+ current >= 0 &&
177
+ all[current].tagName === "A"
178
+ ) {
179
+ event.preventDefault();
180
+ all[current].click();
181
+ return;
182
+ }
183
+
184
+ if (event.key.length === 1 && (event.key !== " " || query)) {
185
+ event.preventDefault();
186
+ query += event.key;
187
+ window.clearTimeout(queryTimer);
188
+ queryTimer = window.setTimeout(() => (query = ""), TYPEAHEAD_RESET_MS);
189
+ focusItem(typeaheadIndex(all.map(labelOf), current, query));
190
+ }
191
+ };
192
+
193
+ // Hover keeps a single highlight: the pointer moves focus with it.
194
+ const onPointerMove = (event: PointerEvent) => {
195
+ const item = (event.target as Element).closest<HTMLElement>(ITEM);
196
+ if (item && item !== document.activeElement)
197
+ item.focus({ preventScroll: true });
198
+ };
199
+
200
+ trigger.addEventListener("keydown", onTriggerKeydown);
201
+ trigger.addEventListener("pointerdown", onTriggerPointerdown);
202
+ surface.addEventListener("beforetoggle", onBeforeToggle);
203
+ surface.addEventListener("toggle", onToggle);
204
+ surface.addEventListener("click", onClick);
205
+ surface.addEventListener("keydown", onKeydown);
206
+ surface.addEventListener("pointermove", onPointerMove);
207
+
208
+ return () => {
209
+ window.clearTimeout(queryTimer);
210
+ trigger.removeEventListener("keydown", onTriggerKeydown);
211
+ trigger.removeEventListener("pointerdown", onTriggerPointerdown);
212
+ surface.removeEventListener("beforetoggle", onBeforeToggle);
213
+ surface.removeEventListener("toggle", onToggle);
214
+ surface.removeEventListener("click", onClick);
215
+ surface.removeEventListener("keydown", onKeydown);
216
+ surface.removeEventListener("pointermove", onPointerMove);
217
+ };
218
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Tabs runtime. Dependency-free; shared by the Astro component and the React
3
+ * port. Follows the WAI-ARIA tabs pattern with automatic activation: arrow
4
+ * keys, Home and End move focus and select in one step.
5
+ *
6
+ * import { mountTabs } from "@db-vincent/components/scripts/tabs";
7
+ * const destroy = mountTabs(document.querySelector("[data-ds-tabs]"));
8
+ */
9
+
10
+ const TAB = '[role="tab"]';
11
+
12
+ /**
13
+ * Index of the tab a key should move to, skipping disabled tabs, or `null`
14
+ * when the key is not a tabs key. `current` is the focused tab's index.
15
+ */
16
+ export function nextTabIndex(
17
+ key: string,
18
+ current: number,
19
+ disabled: readonly boolean[],
20
+ orientation: "horizontal" | "vertical" = "horizontal",
21
+ ): number | null {
22
+ const count = disabled.length;
23
+ const step =
24
+ key === (orientation === "vertical" ? "ArrowDown" : "ArrowRight")
25
+ ? 1
26
+ : key === (orientation === "vertical" ? "ArrowUp" : "ArrowLeft")
27
+ ? -1
28
+ : 0;
29
+
30
+ if (key === "Home" || key === "End") {
31
+ const order =
32
+ key === "Home" ? [...disabled.keys()] : [...disabled.keys()].reverse();
33
+ return order.find((index) => !disabled[index]) ?? null;
34
+ }
35
+ if (!step) return null;
36
+
37
+ for (let offset = 1; offset <= count; offset += 1) {
38
+ const index = (((current + step * offset) % count) + count) % count;
39
+ if (!disabled[index]) return index;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Position the indicator under `tab`. Geometry is exposed as custom
46
+ * properties so the slide stays a transform (see tabs.css).
47
+ */
48
+ export function placeIndicator(
49
+ list: HTMLElement,
50
+ tab: HTMLElement | null,
51
+ ): void {
52
+ if (!tab) return;
53
+ const track = list.scrollWidth;
54
+ list.style.setProperty("--ds-tabs-track", `${track}px`);
55
+ list.style.setProperty("--ds-tabs-x", `${tab.offsetLeft}px`);
56
+ list.style.setProperty("--ds-tabs-scale", String(tab.offsetWidth / track));
57
+ // First placement must not animate from the empty position.
58
+ if (!list.hasAttribute("data-indicator")) {
59
+ list.setAttribute("data-indicator", "");
60
+ requestAnimationFrame(() => list.setAttribute("data-indicator", "ready"));
61
+ }
62
+ }
63
+
64
+ /** Wire up one `[data-ds-tabs]` root. Returns a cleanup function. */
65
+ export function mountTabs(root: HTMLElement): () => void {
66
+ const list = root.querySelector<HTMLElement>('[role="tablist"]');
67
+ if (!list) return () => {};
68
+
69
+ const tabs = () => [...list.querySelectorAll<HTMLButtonElement>(TAB)];
70
+ const panelFor = (tab: HTMLElement) =>
71
+ root.querySelector<HTMLElement>(
72
+ `#${CSS.escape(tab.getAttribute("aria-controls") ?? "")}`,
73
+ );
74
+
75
+ const select = (tab: HTMLButtonElement, focus: boolean) => {
76
+ for (const other of tabs()) {
77
+ const selected = other === tab;
78
+ other.setAttribute("aria-selected", String(selected));
79
+ other.tabIndex = selected ? 0 : -1;
80
+ const panel = panelFor(other);
81
+ if (panel) panel.hidden = !selected;
82
+ }
83
+ placeIndicator(list, tab);
84
+ if (focus) tab.focus();
85
+ root.dispatchEvent(
86
+ new CustomEvent("ds-tabs-change", { detail: { id: tab.id } }),
87
+ );
88
+ };
89
+
90
+ const selectedTab = () =>
91
+ tabs().find((tab) => tab.getAttribute("aria-selected") === "true") ?? null;
92
+
93
+ const onClick = (event: MouseEvent) => {
94
+ const tab = (event.target as Element).closest<HTMLButtonElement>(TAB);
95
+ if (tab && !tab.disabled) select(tab, false);
96
+ };
97
+
98
+ const onKeydown = (event: KeyboardEvent) => {
99
+ const all = tabs();
100
+ const current = all.indexOf(document.activeElement as HTMLButtonElement);
101
+ if (current < 0 || event.altKey || event.ctrlKey || event.metaKey) return;
102
+ const vertical = list.getAttribute("aria-orientation") === "vertical";
103
+ const next = nextTabIndex(
104
+ event.key,
105
+ current,
106
+ all.map((tab) => tab.disabled),
107
+ vertical ? "vertical" : "horizontal",
108
+ );
109
+ if (next === null) return;
110
+ event.preventDefault();
111
+ select(all[next], true);
112
+ };
113
+
114
+ list.addEventListener("click", onClick);
115
+ list.addEventListener("keydown", onKeydown);
116
+
117
+ const resize = new ResizeObserver(() => placeIndicator(list, selectedTab()));
118
+ resize.observe(list);
119
+ for (const tab of tabs()) resize.observe(tab);
120
+ placeIndicator(list, selectedTab());
121
+
122
+ return () => {
123
+ list.removeEventListener("click", onClick);
124
+ list.removeEventListener("keydown", onKeydown);
125
+ resize.disconnect();
126
+ };
127
+ }
@@ -94,10 +94,27 @@ let counter = 0;
94
94
  let resizeObserver: ResizeObserver | null = null;
95
95
 
96
96
  function nextId(): string {
97
- counter += 1;
97
+ do {
98
+ counter += 1;
99
+ } while (records.has(`toast-${counter}`));
98
100
  return `toast-${counter}`;
99
101
  }
100
102
 
103
+ const SAFE_PROTOCOLS: ReadonlySet<string> = new Set([
104
+ "http:",
105
+ "https:",
106
+ "mailto:",
107
+ ]);
108
+
109
+ /** Relative and #fragment URLs resolve to the page protocol, so they pass. */
110
+ function isSafeHref(href: string): boolean {
111
+ try {
112
+ return SAFE_PROTOCOLS.has(new URL(href, document.baseURI).protocol);
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
101
118
  function normalizeType(type?: ToastType): ResolvedType {
102
119
  if (type === "error") return "danger";
103
120
  return type ?? "info";
@@ -152,8 +169,16 @@ function onRegionClick(event: MouseEvent): void {
152
169
  }
153
170
  }
154
171
 
172
+ const OPEN_POPUP = 'dialog[open], [role="listbox"], [aria-expanded="true"]';
173
+
155
174
  function onKeydown(event: KeyboardEvent): void {
156
- if (event.key !== "Escape") return;
175
+ if (event.key !== "Escape" || event.defaultPrevented) return;
176
+ const target = event.target;
177
+ const inRegion = target instanceof Node && !!region?.contains(target);
178
+ // Escape belongs to an open dialog or popup unless focus is in the toasts.
179
+ if (!inRegion && target instanceof Element && target.closest(OPEN_POPUP)) {
180
+ return;
181
+ }
157
182
  const toasts = region?.querySelectorAll<HTMLElement>("[data-toast]");
158
183
  const latest = toasts?.[toasts.length - 1];
159
184
  if (latest) dismiss(latest);
@@ -163,15 +188,19 @@ function createAction(
163
188
  element: HTMLElement,
164
189
  id: string,
165
190
  action: ToastActionProps,
166
- ): HTMLElement {
191
+ ): HTMLElement | null {
167
192
  const label = action.label ?? action.children ?? "Action";
168
- const node = document.createElement(action.href ? "a" : "button");
193
+ const href = action.href && isSafeHref(action.href) ? action.href : undefined;
194
+ // An action whose only target was an unsafe URL has nothing left to do.
195
+ if (action.href && !href && !action.onClick) return null;
196
+
197
+ const node = document.createElement(href ? "a" : "button");
169
198
  node.className = "ds-toast__action";
170
199
  node.setAttribute("data-toast-action", "");
171
200
  node.textContent = label;
172
201
 
173
- if (action.href) {
174
- (node as HTMLAnchorElement).href = action.href;
202
+ if (href) {
203
+ (node as HTMLAnchorElement).href = href;
175
204
  } else {
176
205
  (node as HTMLButtonElement).type = "button";
177
206
  }
@@ -226,10 +255,13 @@ function render(element: HTMLElement, id: string, options: ToastOptions): void {
226
255
 
227
256
  const action = options.action ?? options.actionProps;
228
257
  if (action) {
229
- const actions = document.createElement("div");
230
- actions.className = "ds-toast__actions";
231
- actions.append(createAction(element, id, action));
232
- element.append(actions);
258
+ const node = createAction(element, id, action);
259
+ if (node) {
260
+ const actions = document.createElement("div");
261
+ actions.className = "ds-toast__actions";
262
+ actions.append(node);
263
+ element.append(actions);
264
+ }
233
265
  }
234
266
 
235
267
  if (options.dismissible !== false) {
@@ -446,8 +478,10 @@ export function close(id: string): boolean {
446
478
  * region and give it the same timer, pause-on-hover and dismissal behaviour.
447
479
  */
448
480
  export function mount(element: HTMLElement, origin?: HTMLElement): string {
449
- const id = element.id || nextId();
481
+ // Clones of one template share an id, so every mounted toast gets its own.
482
+ const id = nextId();
450
483
  element.id = id;
484
+ element.setAttribute("data-toast-id", id);
451
485
  const rawDuration = element.dataset.toastDuration;
452
486
  const parsed = rawDuration === undefined ? Number.NaN : Number(rawDuration);
453
487
  const duration = Number.isFinite(parsed) ? parsed : DEFAULT_DURATION;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Tooltip runtime. Dependency-free; shared by the Astro component and the
3
+ * React port. The surface is a `popover="manual"` element so it escapes
4
+ * clipping; this script decides when it shows.
5
+ *
6
+ * import { mountTooltip } from "@db-vincent/components/scripts/tooltip";
7
+ * const destroy = mountTooltip(document.querySelector("[data-ds-tooltip]"));
8
+ *
9
+ * Hover opens after a delay and only on hover-capable fine pointers. Keyboard
10
+ * focus opens at once. Escape, leaving, pressing the trigger and blur close it.
11
+ * While one tooltip is open, and briefly after it closes, the next one opens
12
+ * with `data-instant` so it skips the animation.
13
+ */
14
+
15
+ const HOVER_CAPABLE = "(hover: hover) and (pointer: fine)";
16
+ const DEFAULT_DELAY_MS = 400;
17
+ /** Time to cross the gap between trigger and surface without closing. */
18
+ const LEAVE_GRACE_MS = 100;
19
+ /** How long after a close the next tooltip still opens instantly. */
20
+ const WARM_MS = 300;
21
+
22
+ interface Handle {
23
+ hide: (instant: boolean) => void;
24
+ }
25
+
26
+ let current: Handle | null = null;
27
+ let warmUntil = 0;
28
+
29
+ /** A tooltip is open, or one closed moments ago: skip delay and animation. */
30
+ const isWarm = () => current !== null || performance.now() < warmUntil;
31
+
32
+ /** Wire up one `[data-ds-tooltip]` root. Returns a cleanup function. */
33
+ export function mountTooltip(root: HTMLElement): () => void {
34
+ const surface = root.querySelector<HTMLElement>('[role="tooltip"]');
35
+ const trigger = root.querySelector<HTMLElement>(
36
+ ':scope > :not([role="tooltip"])',
37
+ );
38
+ if (!surface || !trigger) return () => {};
39
+
40
+ const describedBy = new Set(
41
+ (trigger.getAttribute("aria-describedby") ?? "")
42
+ .split(/\s+/)
43
+ .filter(Boolean),
44
+ );
45
+ describedBy.add(surface.id);
46
+ trigger.setAttribute("aria-describedby", [...describedBy].join(" "));
47
+
48
+ const delay = Number(root.dataset.delay ?? DEFAULT_DELAY_MS);
49
+ let showTimer = 0;
50
+ let hideTimer = 0;
51
+
52
+ const isOpen = () => surface.matches(":popover-open");
53
+ const clearTimers = () => {
54
+ window.clearTimeout(showTimer);
55
+ window.clearTimeout(hideTimer);
56
+ };
57
+
58
+ const onDocumentKeydown = (event: KeyboardEvent) => {
59
+ if (event.key !== "Escape" || !isOpen()) return;
60
+ // Dismiss the tooltip first; do not also close a surrounding dialog.
61
+ event.preventDefault();
62
+ event.stopPropagation();
63
+ hide(false);
64
+ };
65
+
66
+ const handle: Handle = { hide: (instant) => hide(instant) };
67
+
68
+ function hide(instant: boolean) {
69
+ clearTimers();
70
+ if (!isOpen()) return;
71
+ surface!.toggleAttribute("data-instant", instant);
72
+ surface!.hidePopover();
73
+ document.removeEventListener("keydown", onDocumentKeydown, true);
74
+ if (current === handle) {
75
+ current = null;
76
+ warmUntil = performance.now() + WARM_MS;
77
+ }
78
+ }
79
+
80
+ function show() {
81
+ clearTimers();
82
+ if (isOpen()) return;
83
+ const instant = isWarm();
84
+ current?.hide(true);
85
+ surface!.toggleAttribute("data-instant", instant);
86
+ surface!.showPopover();
87
+ current = handle;
88
+ document.addEventListener("keydown", onDocumentKeydown, true);
89
+ }
90
+
91
+ const onPointerEnter = (event: PointerEvent) => {
92
+ if (event.pointerType === "touch" || !matchMedia(HOVER_CAPABLE).matches)
93
+ return;
94
+ clearTimers();
95
+ if (isOpen()) return;
96
+ if (isWarm()) show();
97
+ else showTimer = window.setTimeout(show, delay);
98
+ };
99
+
100
+ const onPointerLeave = () => {
101
+ clearTimers();
102
+ if (isOpen())
103
+ hideTimer = window.setTimeout(() => hide(false), LEAVE_GRACE_MS);
104
+ };
105
+
106
+ const onFocusIn = (event: FocusEvent) => {
107
+ if ((event.target as Element).matches(":focus-visible")) show();
108
+ };
109
+
110
+ const onFocusOut = () => hide(false);
111
+ const onPointerDown = () => hide(false);
112
+
113
+ root.addEventListener("pointerenter", onPointerEnter);
114
+ root.addEventListener("pointerleave", onPointerLeave);
115
+ root.addEventListener("pointerdown", onPointerDown);
116
+ root.addEventListener("focusin", onFocusIn);
117
+ root.addEventListener("focusout", onFocusOut);
118
+
119
+ return () => {
120
+ hide(true);
121
+ root.removeEventListener("pointerenter", onPointerEnter);
122
+ root.removeEventListener("pointerleave", onPointerLeave);
123
+ root.removeEventListener("pointerdown", onPointerDown);
124
+ root.removeEventListener("focusin", onFocusIn);
125
+ root.removeEventListener("focusout", onFocusOut);
126
+ };
127
+ }
@@ -1,3 +1,5 @@
1
+ @layer reset, tokens, base, components, motion, utilities;
2
+
1
3
  @layer components {
2
4
  .ds-accordion {
3
5
  display: grid;
@@ -1,3 +1,5 @@
1
+ @layer reset, tokens, base, components, motion, utilities;
2
+
1
3
  @layer components {
2
4
  .ds-alert {
3
5
  background: var(--alert-info-bg);