@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/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
|
+
}
|
package/src/scripts/toast.ts
CHANGED
|
@@ -94,10 +94,27 @@ let counter = 0;
|
|
|
94
94
|
let resizeObserver: ResizeObserver | null = null;
|
|
95
95
|
|
|
96
96
|
function nextId(): string {
|
|
97
|
-
|
|
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
|
|
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 (
|
|
174
|
-
(node as HTMLAnchorElement).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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/styles/accordion.css
CHANGED