@db-vincent/components 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -4
- package/src/astro/Accordion.astro +37 -0
- package/src/astro/Alert.astro +73 -0
- package/src/astro/Avatar.astro +88 -0
- package/src/astro/AvatarGroup.astro +63 -0
- package/src/astro/Badge.astro +23 -0
- package/src/astro/Breadcrumbs.astro +45 -0
- package/src/astro/Button.astro +125 -0
- package/src/astro/Card.astro +63 -0
- package/src/astro/Checkbox.astro +61 -0
- package/src/astro/Chip.astro +135 -0
- package/src/astro/CodeBlock.astro +17 -0
- package/src/astro/Drawer.astro +30 -0
- package/src/astro/EmptyState.astro +45 -0
- package/src/astro/Example.astro +37 -0
- package/src/astro/Grid.astro +56 -0
- package/src/astro/GridItem.astro +25 -0
- package/src/astro/Icon.astro +52 -0
- package/src/astro/Menu.astro +151 -0
- package/src/astro/MenuItem.astro +57 -0
- package/src/astro/Modal.astro +124 -0
- package/src/astro/Navbar.astro +756 -0
- package/src/astro/PageHeader.astro +34 -0
- package/src/astro/Pagination.astro +212 -0
- package/src/astro/Popover.astro +51 -0
- package/src/astro/Progress.astro +71 -0
- package/src/astro/Radio.astro +58 -0
- package/src/astro/RadioGroup.astro +25 -0
- package/src/astro/Section.astro +25 -0
- package/src/astro/Select.astro +420 -0
- package/src/astro/Sidebar.astro +215 -0
- package/src/astro/Skeleton.astro +41 -0
- package/src/astro/Spinner.astro +20 -0
- package/src/astro/Stepper.astro +82 -0
- package/src/astro/Switch.astro +78 -0
- package/src/astro/Table.astro +268 -0
- package/src/astro/Tabs.astro +83 -0
- package/src/astro/TextField.astro +305 -0
- package/src/astro/ThemeToggle.astro +66 -0
- package/src/astro/Toast.astro +181 -0
- package/src/astro/Tooltip.astro +54 -0
- package/src/astro/TopBar.astro +29 -0
- package/src/data/icons.ts +120 -0
- package/src/scripts/avatar.ts +33 -0
- package/src/scripts/menu.ts +218 -0
- package/src/scripts/tabs.ts +127 -0
- package/src/scripts/toast.ts +565 -0
- package/src/scripts/tooltip.ts +127 -0
- package/src/styles/accordion.css +79 -0
- package/src/styles/alert.css +108 -0
- package/src/styles/avatar.css +131 -0
- package/src/styles/badge.css +48 -0
- package/src/styles/base.css +205 -0
- package/src/styles/breadcrumbs.css +54 -0
- package/src/styles/button.css +174 -0
- package/src/styles/card.css +112 -0
- package/src/styles/chip.css +106 -0
- package/src/styles/choice.css +143 -0
- package/src/styles/code.css +44 -0
- package/src/styles/empty.css +59 -0
- package/src/styles/example.css +57 -0
- package/src/styles/grid.css +64 -0
- package/src/styles/input.css +208 -0
- package/src/styles/menu.css +151 -0
- package/src/styles/modal.css +285 -0
- package/src/styles/motion.css +114 -0
- package/src/styles/nav.css +232 -0
- package/src/styles/navbar.css +658 -0
- package/src/styles/page-header.css +62 -0
- package/src/styles/pagination.css +81 -0
- package/src/styles/popover.css +134 -0
- package/src/styles/progress.css +95 -0
- package/src/styles/section.css +38 -0
- package/src/styles/select.css +246 -0
- package/src/styles/skeleton.css +66 -0
- package/src/styles/spinner.css +44 -0
- package/src/styles/sr-only.css +14 -0
- package/src/styles/stepper.css +169 -0
- package/src/styles/switch.css +143 -0
- package/src/styles/table.css +177 -0
- package/src/styles/tabs.css +120 -0
- package/src/styles/theme-toggle.css +31 -0
- package/src/styles/toast.css +225 -0
- package/src/styles/tooltip.css +63 -0
- package/src/styles/topbar.css +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import "../styles/toast.css";
|
|
2
|
+
import { ICONS } from "../data/icons";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Toast runtime. A small, dependency-free API shaped like shadcn/ui's toast
|
|
6
|
+
* manager:
|
|
7
|
+
*
|
|
8
|
+
* import { toast } from "@db-vincent/components/scripts/toast";
|
|
9
|
+
*
|
|
10
|
+
* toast.add({ title: "Event created", description: "Sunday at 9:00 AM" });
|
|
11
|
+
* const id = toast.add({ title: "Saved", type: "success" });
|
|
12
|
+
* toast.close(id);
|
|
13
|
+
* toast.promise(task, { loading: "Saving…", success: "Saved." });
|
|
14
|
+
*
|
|
15
|
+
* The shared region is created lazily so the module can be imported anywhere.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type ToastType =
|
|
19
|
+
"info" | "success" | "warning" | "error" | "danger" | "loading";
|
|
20
|
+
|
|
21
|
+
export interface ToastContent {
|
|
22
|
+
title: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ToastActionProps {
|
|
27
|
+
/** Visible action label. `children` mirrors the React API. */
|
|
28
|
+
label?: string;
|
|
29
|
+
children?: string;
|
|
30
|
+
href?: string;
|
|
31
|
+
onClick?: (event: MouseEvent, toastId: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ToastOptions {
|
|
35
|
+
id?: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
/** Alias for `description`, kept for the existing Astro component API. */
|
|
39
|
+
message?: string;
|
|
40
|
+
type?: ToastType;
|
|
41
|
+
/** Alias for `type`, kept for the existing Astro component API. */
|
|
42
|
+
tone?: ToastType;
|
|
43
|
+
/** Milliseconds before auto-dismiss. `0` or `Infinity` keeps it open. */
|
|
44
|
+
duration?: number;
|
|
45
|
+
dismissible?: boolean;
|
|
46
|
+
icon?: boolean;
|
|
47
|
+
action?: ToastActionProps;
|
|
48
|
+
/** Alias for `action`, mirrors shadcn/ui's prop name. */
|
|
49
|
+
actionProps?: ToastActionProps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ToastPromiseOptions<T> {
|
|
53
|
+
loading: string | ToastContent;
|
|
54
|
+
success: string | ToastContent | ((value: T) => string | ToastContent);
|
|
55
|
+
error: string | ToastContent | ((error: unknown) => string | ToastContent);
|
|
56
|
+
/** Duration for the resolved success/error toast. */
|
|
57
|
+
duration?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ResolvedType = "info" | "success" | "warning" | "danger" | "loading";
|
|
61
|
+
|
|
62
|
+
const DEFAULT_DURATION = 5000;
|
|
63
|
+
const ERROR_DURATION = 8000;
|
|
64
|
+
const MAX_VISIBLE = 3;
|
|
65
|
+
const EXIT_DELAY = 180;
|
|
66
|
+
|
|
67
|
+
const ICON_BY_TYPE: Record<ResolvedType, string> = {
|
|
68
|
+
info: "info",
|
|
69
|
+
success: "check-circle",
|
|
70
|
+
warning: "alert-triangle",
|
|
71
|
+
danger: "alert-circle",
|
|
72
|
+
loading: "loader",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const ASSERTIVE: ReadonlySet<ResolvedType> = new Set(["warning", "danger"]);
|
|
76
|
+
|
|
77
|
+
interface ToastRecord {
|
|
78
|
+
element: HTMLElement;
|
|
79
|
+
id: string;
|
|
80
|
+
duration: number;
|
|
81
|
+
remaining: number;
|
|
82
|
+
startedAt: number;
|
|
83
|
+
timer: number | null;
|
|
84
|
+
options: ToastOptions;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const records = new Map<string, ToastRecord>();
|
|
88
|
+
const origins = new WeakMap<HTMLElement, HTMLElement>();
|
|
89
|
+
const actionHandlers = new WeakMap<HTMLElement, (event: MouseEvent) => void>();
|
|
90
|
+
const boundRegions = new WeakSet<HTMLElement>();
|
|
91
|
+
|
|
92
|
+
let region: HTMLElement | null = null;
|
|
93
|
+
let counter = 0;
|
|
94
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
95
|
+
|
|
96
|
+
function nextId(): string {
|
|
97
|
+
do {
|
|
98
|
+
counter += 1;
|
|
99
|
+
} while (records.has(`toast-${counter}`));
|
|
100
|
+
return `toast-${counter}`;
|
|
101
|
+
}
|
|
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
|
+
|
|
118
|
+
function normalizeType(type?: ToastType): ResolvedType {
|
|
119
|
+
if (type === "error") return "danger";
|
|
120
|
+
return type ?? "info";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function iconMarkup(name: string): string {
|
|
124
|
+
const paths = ICONS[name] ?? ICONS.info;
|
|
125
|
+
return `<svg class="ds-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths}</svg>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ensureRegion(): HTMLElement {
|
|
129
|
+
if (region?.isConnected) return region;
|
|
130
|
+
|
|
131
|
+
const existing = document.querySelector<HTMLElement>("[data-toast-region]");
|
|
132
|
+
const element = existing ?? document.createElement("div");
|
|
133
|
+
|
|
134
|
+
if (!existing) {
|
|
135
|
+
element.className = "ds-toast-region";
|
|
136
|
+
element.setAttribute("data-toast-region", "");
|
|
137
|
+
element.setAttribute("role", "region");
|
|
138
|
+
element.setAttribute("aria-label", "Notifications");
|
|
139
|
+
element.setAttribute("aria-live", "polite");
|
|
140
|
+
element.setAttribute("aria-relevant", "additions text");
|
|
141
|
+
document.body.append(element);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!boundRegions.has(element)) {
|
|
145
|
+
boundRegions.add(element);
|
|
146
|
+
element.addEventListener("click", onRegionClick);
|
|
147
|
+
document.addEventListener("keydown", onKeydown);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
region = element;
|
|
151
|
+
return element;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onRegionClick(event: MouseEvent): void {
|
|
155
|
+
const target = event.target;
|
|
156
|
+
if (!(target instanceof Element)) return;
|
|
157
|
+
|
|
158
|
+
const close = target.closest("[data-toast-close]");
|
|
159
|
+
if (close) {
|
|
160
|
+
const element = close.closest<HTMLElement>("[data-toast]");
|
|
161
|
+
if (element) dismiss(element);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const action = target.closest("[data-toast-action]");
|
|
166
|
+
if (action) {
|
|
167
|
+
const element = action.closest<HTMLElement>("[data-toast]");
|
|
168
|
+
if (element) actionHandlers.get(element)?.(event);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const OPEN_POPUP = 'dialog[open], [role="listbox"], [aria-expanded="true"]';
|
|
173
|
+
|
|
174
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
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
|
+
}
|
|
182
|
+
const toasts = region?.querySelectorAll<HTMLElement>("[data-toast]");
|
|
183
|
+
const latest = toasts?.[toasts.length - 1];
|
|
184
|
+
if (latest) dismiss(latest);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createAction(
|
|
188
|
+
element: HTMLElement,
|
|
189
|
+
id: string,
|
|
190
|
+
action: ToastActionProps,
|
|
191
|
+
): HTMLElement | null {
|
|
192
|
+
const label = action.label ?? action.children ?? "Action";
|
|
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");
|
|
198
|
+
node.className = "ds-toast__action";
|
|
199
|
+
node.setAttribute("data-toast-action", "");
|
|
200
|
+
node.textContent = label;
|
|
201
|
+
|
|
202
|
+
if (href) {
|
|
203
|
+
(node as HTMLAnchorElement).href = href;
|
|
204
|
+
} else {
|
|
205
|
+
(node as HTMLButtonElement).type = "button";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (action.onClick) {
|
|
209
|
+
actionHandlers.set(element, (event) => action.onClick?.(event, id));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return node;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function render(element: HTMLElement, id: string, options: ToastOptions): void {
|
|
216
|
+
const type = normalizeType(options.type ?? options.tone);
|
|
217
|
+
const assertive = ASSERTIVE.has(type);
|
|
218
|
+
|
|
219
|
+
element.className = `ds-toast ds-toast--${type}`;
|
|
220
|
+
element.setAttribute("data-toast", "");
|
|
221
|
+
element.setAttribute("data-toast-id", id);
|
|
222
|
+
element.setAttribute("data-toast-type", type);
|
|
223
|
+
element.setAttribute("role", assertive ? "alert" : "status");
|
|
224
|
+
element.setAttribute("aria-live", assertive ? "assertive" : "polite");
|
|
225
|
+
element.setAttribute("aria-atomic", "true");
|
|
226
|
+
|
|
227
|
+
element.replaceChildren();
|
|
228
|
+
actionHandlers.delete(element);
|
|
229
|
+
|
|
230
|
+
if (options.icon !== false) {
|
|
231
|
+
const icon = document.createElement("span");
|
|
232
|
+
icon.className = "ds-toast__icon";
|
|
233
|
+
icon.setAttribute("aria-hidden", "true");
|
|
234
|
+
icon.innerHTML = iconMarkup(ICON_BY_TYPE[type]);
|
|
235
|
+
element.append(icon);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const content = document.createElement("div");
|
|
239
|
+
content.className = "ds-toast__content";
|
|
240
|
+
|
|
241
|
+
const title = document.createElement("p");
|
|
242
|
+
title.className = "ds-toast__title";
|
|
243
|
+
title.textContent = options.title ?? "";
|
|
244
|
+
content.append(title);
|
|
245
|
+
|
|
246
|
+
const description = options.description ?? options.message;
|
|
247
|
+
if (description) {
|
|
248
|
+
const body = document.createElement("p");
|
|
249
|
+
body.className = "ds-toast__body";
|
|
250
|
+
body.textContent = description;
|
|
251
|
+
content.append(body);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
element.append(content);
|
|
255
|
+
|
|
256
|
+
const action = options.action ?? options.actionProps;
|
|
257
|
+
if (action) {
|
|
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
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (options.dismissible !== false) {
|
|
268
|
+
const close = document.createElement("button");
|
|
269
|
+
close.className = "ds-toast__close";
|
|
270
|
+
close.type = "button";
|
|
271
|
+
close.setAttribute("aria-label", "Close toast");
|
|
272
|
+
close.setAttribute("data-toast-close", "");
|
|
273
|
+
close.innerHTML = iconMarkup("x");
|
|
274
|
+
element.append(close);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function clearTimer(record: ToastRecord): void {
|
|
279
|
+
if (record.timer !== null) {
|
|
280
|
+
window.clearTimeout(record.timer);
|
|
281
|
+
record.timer = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function schedule(record: ToastRecord): void {
|
|
286
|
+
clearTimer(record);
|
|
287
|
+
if (!Number.isFinite(record.duration) || record.duration <= 0) return;
|
|
288
|
+
if (record.remaining <= 0) {
|
|
289
|
+
dismiss(record.element);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
record.startedAt = performance.now();
|
|
293
|
+
record.timer = window.setTimeout(
|
|
294
|
+
() => dismiss(record.element),
|
|
295
|
+
record.remaining,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function pause(record: ToastRecord): void {
|
|
300
|
+
if (record.timer === null) return;
|
|
301
|
+
clearTimer(record);
|
|
302
|
+
record.remaining -= performance.now() - record.startedAt;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resume(record: ToastRecord): void {
|
|
306
|
+
if (record.timer !== null) return;
|
|
307
|
+
schedule(record);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function wire(record: ToastRecord): void {
|
|
311
|
+
const { element } = record;
|
|
312
|
+
|
|
313
|
+
element.addEventListener("pointerenter", () => pause(record));
|
|
314
|
+
element.addEventListener("pointerleave", () => resume(record));
|
|
315
|
+
element.addEventListener("focusin", () => pause(record));
|
|
316
|
+
element.addEventListener("focusout", (event) => {
|
|
317
|
+
const next = event.relatedTarget;
|
|
318
|
+
if (!(next instanceof Node) || !element.contains(next)) resume(record);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Only the newest MAX_VISIBLE toasts are on screen. Older ones stay mounted
|
|
324
|
+
* (like shadcn/ui's `data-limited` toasts) instead of being thrown away: they
|
|
325
|
+
* are inert, hidden and their countdown is paused until a visible toast makes
|
|
326
|
+
* room, then they surface with the time they had left.
|
|
327
|
+
*/
|
|
328
|
+
function applyVisibility(): void {
|
|
329
|
+
if (!region) return;
|
|
330
|
+
|
|
331
|
+
const newestFirst = Array.from(
|
|
332
|
+
region.querySelectorAll<HTMLElement>(
|
|
333
|
+
"[data-toast]:not([data-toast-leaving])",
|
|
334
|
+
),
|
|
335
|
+
).reverse();
|
|
336
|
+
|
|
337
|
+
newestFirst.forEach((element, index) => {
|
|
338
|
+
const record = records.get(element.dataset.toastId ?? "");
|
|
339
|
+
const limited = index >= MAX_VISIBLE;
|
|
340
|
+
|
|
341
|
+
if (limited === element.hasAttribute("data-toast-limited")) return;
|
|
342
|
+
|
|
343
|
+
if (limited) {
|
|
344
|
+
element.setAttribute("data-toast-limited", "");
|
|
345
|
+
element.inert = true;
|
|
346
|
+
if (record) pause(record);
|
|
347
|
+
} else {
|
|
348
|
+
element.removeAttribute("data-toast-limited");
|
|
349
|
+
element.inert = false;
|
|
350
|
+
if (record) resume(record);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
recalcStack();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Give every visible toast its stack depth and the pixel offset it needs when
|
|
359
|
+
* the region expands on hover/focus. Heights are measured, so descriptions
|
|
360
|
+
* that wrap to several lines still separate cleanly.
|
|
361
|
+
*/
|
|
362
|
+
function recalcStack(): void {
|
|
363
|
+
if (!region) return;
|
|
364
|
+
|
|
365
|
+
const visible = Array.from(
|
|
366
|
+
region.querySelectorAll<HTMLElement>(
|
|
367
|
+
"[data-toast]:not([data-toast-leaving]):not([data-toast-limited])",
|
|
368
|
+
),
|
|
369
|
+
).reverse();
|
|
370
|
+
const gap = Number.parseFloat(getComputedStyle(region).rowGap) || 0;
|
|
371
|
+
let offset = 0;
|
|
372
|
+
|
|
373
|
+
visible.forEach((toast, index) => {
|
|
374
|
+
toast.style.setProperty("--toast-index", String(index));
|
|
375
|
+
toast.style.setProperty("--toast-offset", `${offset}px`);
|
|
376
|
+
offset += toast.offsetHeight + gap;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function observeToast(element: HTMLElement): void {
|
|
381
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
382
|
+
resizeObserver ??= new ResizeObserver(() => recalcStack());
|
|
383
|
+
resizeObserver.observe(element);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function forgetToast(element: HTMLElement): void {
|
|
387
|
+
resizeObserver?.unobserve(element);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function add(options: ToastOptions): string {
|
|
391
|
+
const id = options.id ?? nextId();
|
|
392
|
+
const element = document.createElement("div");
|
|
393
|
+
element.id = id;
|
|
394
|
+
const duration = options.duration ?? DEFAULT_DURATION;
|
|
395
|
+
|
|
396
|
+
const record: ToastRecord = {
|
|
397
|
+
element,
|
|
398
|
+
id,
|
|
399
|
+
duration,
|
|
400
|
+
remaining: duration,
|
|
401
|
+
startedAt: 0,
|
|
402
|
+
timer: null,
|
|
403
|
+
options,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
render(element, id, options);
|
|
407
|
+
records.set(id, record);
|
|
408
|
+
ensureRegion().append(element);
|
|
409
|
+
|
|
410
|
+
wire(record);
|
|
411
|
+
observeToast(element);
|
|
412
|
+
schedule(record);
|
|
413
|
+
applyVisibility();
|
|
414
|
+
|
|
415
|
+
return id;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function update(id: string, options: ToastOptions): boolean {
|
|
419
|
+
const record = records.get(id);
|
|
420
|
+
if (!record) return false;
|
|
421
|
+
|
|
422
|
+
// Content fields are replaced, not merged: a promise transition from
|
|
423
|
+
// "Creating…" to "Created." must not leave the loading description behind.
|
|
424
|
+
record.options = {
|
|
425
|
+
...record.options,
|
|
426
|
+
...options,
|
|
427
|
+
title: options.title ?? record.options.title,
|
|
428
|
+
description: options.description,
|
|
429
|
+
message: options.message,
|
|
430
|
+
};
|
|
431
|
+
record.duration = options.duration ?? DEFAULT_DURATION;
|
|
432
|
+
record.remaining = record.duration;
|
|
433
|
+
|
|
434
|
+
render(record.element, id, record.options);
|
|
435
|
+
recalcStack();
|
|
436
|
+
schedule(record);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function dismiss(element: HTMLElement): void {
|
|
441
|
+
const id = element.dataset.toastId;
|
|
442
|
+
const record = id ? records.get(id) : undefined;
|
|
443
|
+
if (record) {
|
|
444
|
+
clearTimer(record);
|
|
445
|
+
records.delete(record.id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (element.hasAttribute("data-toast-leaving")) return;
|
|
449
|
+
element.setAttribute("data-toast-leaving", "");
|
|
450
|
+
recalcStack();
|
|
451
|
+
|
|
452
|
+
const hadFocus = element.contains(document.activeElement);
|
|
453
|
+
const origin = origins.get(element);
|
|
454
|
+
const remove = () => {
|
|
455
|
+
forgetToast(element);
|
|
456
|
+
element.remove();
|
|
457
|
+
applyVisibility();
|
|
458
|
+
if (hadFocus) origin?.focus();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const reduced =
|
|
462
|
+
typeof matchMedia === "function" &&
|
|
463
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
464
|
+
|
|
465
|
+
if (reduced) remove();
|
|
466
|
+
else window.setTimeout(remove, EXIT_DELAY);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function close(id: string): boolean {
|
|
470
|
+
const record = records.get(id);
|
|
471
|
+
if (!record) return false;
|
|
472
|
+
dismiss(record.element);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Mount an already-rendered toast (the Astro template clone) in the shared
|
|
478
|
+
* region and give it the same timer, pause-on-hover and dismissal behaviour.
|
|
479
|
+
*/
|
|
480
|
+
export function mount(element: HTMLElement, origin?: HTMLElement): string {
|
|
481
|
+
// Clones of one template share an id, so every mounted toast gets its own.
|
|
482
|
+
const id = nextId();
|
|
483
|
+
element.id = id;
|
|
484
|
+
element.setAttribute("data-toast-id", id);
|
|
485
|
+
const rawDuration = element.dataset.toastDuration;
|
|
486
|
+
const parsed = rawDuration === undefined ? Number.NaN : Number(rawDuration);
|
|
487
|
+
const duration = Number.isFinite(parsed) ? parsed : DEFAULT_DURATION;
|
|
488
|
+
|
|
489
|
+
const record: ToastRecord = {
|
|
490
|
+
element,
|
|
491
|
+
id,
|
|
492
|
+
duration,
|
|
493
|
+
remaining: duration,
|
|
494
|
+
startedAt: 0,
|
|
495
|
+
timer: null,
|
|
496
|
+
options: {},
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
if (origin) origins.set(element, origin);
|
|
500
|
+
records.set(id, record);
|
|
501
|
+
ensureRegion().append(element);
|
|
502
|
+
|
|
503
|
+
wire(record);
|
|
504
|
+
observeToast(element);
|
|
505
|
+
schedule(record);
|
|
506
|
+
applyVisibility();
|
|
507
|
+
|
|
508
|
+
return id;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function resolveContent<T>(
|
|
512
|
+
value: string | ToastContent | ((input: T) => string | ToastContent),
|
|
513
|
+
input?: T,
|
|
514
|
+
): ToastContent {
|
|
515
|
+
const resolved =
|
|
516
|
+
typeof value === "function"
|
|
517
|
+
? (value as (input: T) => string | ToastContent)(input as T)
|
|
518
|
+
: value;
|
|
519
|
+
return typeof resolved === "string" ? { title: resolved } : resolved;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Update one toast as an async task moves through loading, success and error,
|
|
524
|
+
* matching shadcn/ui's `toast.promise(promise, { loading, success, error })`.
|
|
525
|
+
* The original promise is returned untouched.
|
|
526
|
+
*/
|
|
527
|
+
export function promise<T>(
|
|
528
|
+
task: Promise<T>,
|
|
529
|
+
options: ToastPromiseOptions<T>,
|
|
530
|
+
): Promise<T> {
|
|
531
|
+
const id = add({
|
|
532
|
+
...resolveContent(options.loading),
|
|
533
|
+
type: "loading",
|
|
534
|
+
duration: 0,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
task.then(
|
|
538
|
+
(value) => {
|
|
539
|
+
update(id, {
|
|
540
|
+
...resolveContent(options.success, value),
|
|
541
|
+
type: "success",
|
|
542
|
+
duration: options.duration ?? DEFAULT_DURATION,
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
(error: unknown) => {
|
|
546
|
+
update(id, {
|
|
547
|
+
...resolveContent(options.error, error),
|
|
548
|
+
type: "error",
|
|
549
|
+
duration: options.duration ?? ERROR_DURATION,
|
|
550
|
+
});
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
return task;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export const toast = {
|
|
558
|
+
add,
|
|
559
|
+
update,
|
|
560
|
+
close,
|
|
561
|
+
dismiss,
|
|
562
|
+
promise,
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
export default toast;
|
|
@@ -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
|
+
}
|