@db-vincent/components 0.0.0-stage → 0.1.0
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/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 +116 -0
- package/src/astro/CodeBlock.astro +17 -0
- package/src/astro/EmptyState.astro +45 -0
- package/src/astro/Example.astro +37 -0
- package/src/astro/Grid.astro +50 -0
- package/src/astro/GridItem.astro +23 -0
- package/src/astro/Icon.astro +52 -0
- package/src/astro/Modal.astro +117 -0
- package/src/astro/Navbar.astro +756 -0
- package/src/astro/PageHeader.astro +34 -0
- package/src/astro/Pagination.astro +162 -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 +403 -0
- package/src/astro/Sidebar.astro +215 -0
- package/src/astro/Skeleton.astro +39 -0
- package/src/astro/Spinner.astro +19 -0
- package/src/astro/Table.astro +254 -0
- package/src/astro/TextField.astro +287 -0
- package/src/astro/ThemeToggle.astro +66 -0
- package/src/astro/Toast.astro +181 -0
- package/src/astro/TopBar.astro +29 -0
- package/src/data/icons.ts +104 -0
- package/src/scripts/toast.ts +531 -0
- package/src/styles/accordion.css +77 -0
- package/src/styles/alert.css +106 -0
- package/src/styles/badge.css +46 -0
- package/src/styles/base.css +203 -0
- package/src/styles/breadcrumbs.css +52 -0
- package/src/styles/button.css +163 -0
- package/src/styles/card.css +108 -0
- package/src/styles/chip.css +104 -0
- package/src/styles/choice.css +137 -0
- package/src/styles/code.css +42 -0
- package/src/styles/empty.css +57 -0
- package/src/styles/example.css +55 -0
- package/src/styles/grid.css +62 -0
- package/src/styles/input.css +198 -0
- package/src/styles/modal.css +143 -0
- package/src/styles/motion.css +96 -0
- package/src/styles/nav.css +230 -0
- package/src/styles/navbar.css +656 -0
- package/src/styles/page-header.css +59 -0
- package/src/styles/pagination.css +77 -0
- package/src/styles/section.css +36 -0
- package/src/styles/select.css +235 -0
- package/src/styles/skeleton.css +64 -0
- package/src/styles/spinner.css +42 -0
- package/src/styles/table.css +175 -0
- package/src/styles/theme-toggle.css +29 -0
- package/src/styles/toast.css +207 -0
- package/src/styles/topbar.css +30 -0
- package/README.md +0 -3
|
@@ -0,0 +1,531 @@
|
|
|
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
|
+
counter += 1;
|
|
98
|
+
return `toast-${counter}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeType(type?: ToastType): ResolvedType {
|
|
102
|
+
if (type === "error") return "danger";
|
|
103
|
+
return type ?? "info";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function iconMarkup(name: string): string {
|
|
107
|
+
const paths = ICONS[name] ?? ICONS.info;
|
|
108
|
+
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>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ensureRegion(): HTMLElement {
|
|
112
|
+
if (region?.isConnected) return region;
|
|
113
|
+
|
|
114
|
+
const existing = document.querySelector<HTMLElement>("[data-toast-region]");
|
|
115
|
+
const element = existing ?? document.createElement("div");
|
|
116
|
+
|
|
117
|
+
if (!existing) {
|
|
118
|
+
element.className = "ds-toast-region";
|
|
119
|
+
element.setAttribute("data-toast-region", "");
|
|
120
|
+
element.setAttribute("role", "region");
|
|
121
|
+
element.setAttribute("aria-label", "Notifications");
|
|
122
|
+
element.setAttribute("aria-live", "polite");
|
|
123
|
+
element.setAttribute("aria-relevant", "additions text");
|
|
124
|
+
document.body.append(element);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!boundRegions.has(element)) {
|
|
128
|
+
boundRegions.add(element);
|
|
129
|
+
element.addEventListener("click", onRegionClick);
|
|
130
|
+
document.addEventListener("keydown", onKeydown);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
region = element;
|
|
134
|
+
return element;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onRegionClick(event: MouseEvent): void {
|
|
138
|
+
const target = event.target;
|
|
139
|
+
if (!(target instanceof Element)) return;
|
|
140
|
+
|
|
141
|
+
const close = target.closest("[data-toast-close]");
|
|
142
|
+
if (close) {
|
|
143
|
+
const element = close.closest<HTMLElement>("[data-toast]");
|
|
144
|
+
if (element) dismiss(element);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const action = target.closest("[data-toast-action]");
|
|
149
|
+
if (action) {
|
|
150
|
+
const element = action.closest<HTMLElement>("[data-toast]");
|
|
151
|
+
if (element) actionHandlers.get(element)?.(event);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
156
|
+
if (event.key !== "Escape") return;
|
|
157
|
+
const toasts = region?.querySelectorAll<HTMLElement>("[data-toast]");
|
|
158
|
+
const latest = toasts?.[toasts.length - 1];
|
|
159
|
+
if (latest) dismiss(latest);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function createAction(
|
|
163
|
+
element: HTMLElement,
|
|
164
|
+
id: string,
|
|
165
|
+
action: ToastActionProps,
|
|
166
|
+
): HTMLElement {
|
|
167
|
+
const label = action.label ?? action.children ?? "Action";
|
|
168
|
+
const node = document.createElement(action.href ? "a" : "button");
|
|
169
|
+
node.className = "ds-toast__action";
|
|
170
|
+
node.setAttribute("data-toast-action", "");
|
|
171
|
+
node.textContent = label;
|
|
172
|
+
|
|
173
|
+
if (action.href) {
|
|
174
|
+
(node as HTMLAnchorElement).href = action.href;
|
|
175
|
+
} else {
|
|
176
|
+
(node as HTMLButtonElement).type = "button";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (action.onClick) {
|
|
180
|
+
actionHandlers.set(element, (event) => action.onClick?.(event, id));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return node;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function render(element: HTMLElement, id: string, options: ToastOptions): void {
|
|
187
|
+
const type = normalizeType(options.type ?? options.tone);
|
|
188
|
+
const assertive = ASSERTIVE.has(type);
|
|
189
|
+
|
|
190
|
+
element.className = `ds-toast ds-toast--${type}`;
|
|
191
|
+
element.setAttribute("data-toast", "");
|
|
192
|
+
element.setAttribute("data-toast-id", id);
|
|
193
|
+
element.setAttribute("data-toast-type", type);
|
|
194
|
+
element.setAttribute("role", assertive ? "alert" : "status");
|
|
195
|
+
element.setAttribute("aria-live", assertive ? "assertive" : "polite");
|
|
196
|
+
element.setAttribute("aria-atomic", "true");
|
|
197
|
+
|
|
198
|
+
element.replaceChildren();
|
|
199
|
+
actionHandlers.delete(element);
|
|
200
|
+
|
|
201
|
+
if (options.icon !== false) {
|
|
202
|
+
const icon = document.createElement("span");
|
|
203
|
+
icon.className = "ds-toast__icon";
|
|
204
|
+
icon.setAttribute("aria-hidden", "true");
|
|
205
|
+
icon.innerHTML = iconMarkup(ICON_BY_TYPE[type]);
|
|
206
|
+
element.append(icon);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const content = document.createElement("div");
|
|
210
|
+
content.className = "ds-toast__content";
|
|
211
|
+
|
|
212
|
+
const title = document.createElement("p");
|
|
213
|
+
title.className = "ds-toast__title";
|
|
214
|
+
title.textContent = options.title ?? "";
|
|
215
|
+
content.append(title);
|
|
216
|
+
|
|
217
|
+
const description = options.description ?? options.message;
|
|
218
|
+
if (description) {
|
|
219
|
+
const body = document.createElement("p");
|
|
220
|
+
body.className = "ds-toast__body";
|
|
221
|
+
body.textContent = description;
|
|
222
|
+
content.append(body);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
element.append(content);
|
|
226
|
+
|
|
227
|
+
const action = options.action ?? options.actionProps;
|
|
228
|
+
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);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (options.dismissible !== false) {
|
|
236
|
+
const close = document.createElement("button");
|
|
237
|
+
close.className = "ds-toast__close";
|
|
238
|
+
close.type = "button";
|
|
239
|
+
close.setAttribute("aria-label", "Close toast");
|
|
240
|
+
close.setAttribute("data-toast-close", "");
|
|
241
|
+
close.innerHTML = iconMarkup("x");
|
|
242
|
+
element.append(close);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clearTimer(record: ToastRecord): void {
|
|
247
|
+
if (record.timer !== null) {
|
|
248
|
+
window.clearTimeout(record.timer);
|
|
249
|
+
record.timer = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function schedule(record: ToastRecord): void {
|
|
254
|
+
clearTimer(record);
|
|
255
|
+
if (!Number.isFinite(record.duration) || record.duration <= 0) return;
|
|
256
|
+
if (record.remaining <= 0) {
|
|
257
|
+
dismiss(record.element);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
record.startedAt = performance.now();
|
|
261
|
+
record.timer = window.setTimeout(
|
|
262
|
+
() => dismiss(record.element),
|
|
263
|
+
record.remaining,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function pause(record: ToastRecord): void {
|
|
268
|
+
if (record.timer === null) return;
|
|
269
|
+
clearTimer(record);
|
|
270
|
+
record.remaining -= performance.now() - record.startedAt;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resume(record: ToastRecord): void {
|
|
274
|
+
if (record.timer !== null) return;
|
|
275
|
+
schedule(record);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function wire(record: ToastRecord): void {
|
|
279
|
+
const { element } = record;
|
|
280
|
+
|
|
281
|
+
element.addEventListener("pointerenter", () => pause(record));
|
|
282
|
+
element.addEventListener("pointerleave", () => resume(record));
|
|
283
|
+
element.addEventListener("focusin", () => pause(record));
|
|
284
|
+
element.addEventListener("focusout", (event) => {
|
|
285
|
+
const next = event.relatedTarget;
|
|
286
|
+
if (!(next instanceof Node) || !element.contains(next)) resume(record);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Only the newest MAX_VISIBLE toasts are on screen. Older ones stay mounted
|
|
292
|
+
* (like shadcn/ui's `data-limited` toasts) instead of being thrown away: they
|
|
293
|
+
* are inert, hidden and their countdown is paused until a visible toast makes
|
|
294
|
+
* room, then they surface with the time they had left.
|
|
295
|
+
*/
|
|
296
|
+
function applyVisibility(): void {
|
|
297
|
+
if (!region) return;
|
|
298
|
+
|
|
299
|
+
const newestFirst = Array.from(
|
|
300
|
+
region.querySelectorAll<HTMLElement>(
|
|
301
|
+
"[data-toast]:not([data-toast-leaving])",
|
|
302
|
+
),
|
|
303
|
+
).reverse();
|
|
304
|
+
|
|
305
|
+
newestFirst.forEach((element, index) => {
|
|
306
|
+
const record = records.get(element.dataset.toastId ?? "");
|
|
307
|
+
const limited = index >= MAX_VISIBLE;
|
|
308
|
+
|
|
309
|
+
if (limited === element.hasAttribute("data-toast-limited")) return;
|
|
310
|
+
|
|
311
|
+
if (limited) {
|
|
312
|
+
element.setAttribute("data-toast-limited", "");
|
|
313
|
+
element.inert = true;
|
|
314
|
+
if (record) pause(record);
|
|
315
|
+
} else {
|
|
316
|
+
element.removeAttribute("data-toast-limited");
|
|
317
|
+
element.inert = false;
|
|
318
|
+
if (record) resume(record);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
recalcStack();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Give every visible toast its stack depth and the pixel offset it needs when
|
|
327
|
+
* the region expands on hover/focus. Heights are measured, so descriptions
|
|
328
|
+
* that wrap to several lines still separate cleanly.
|
|
329
|
+
*/
|
|
330
|
+
function recalcStack(): void {
|
|
331
|
+
if (!region) return;
|
|
332
|
+
|
|
333
|
+
const visible = Array.from(
|
|
334
|
+
region.querySelectorAll<HTMLElement>(
|
|
335
|
+
"[data-toast]:not([data-toast-leaving]):not([data-toast-limited])",
|
|
336
|
+
),
|
|
337
|
+
).reverse();
|
|
338
|
+
const gap = Number.parseFloat(getComputedStyle(region).rowGap) || 0;
|
|
339
|
+
let offset = 0;
|
|
340
|
+
|
|
341
|
+
visible.forEach((toast, index) => {
|
|
342
|
+
toast.style.setProperty("--toast-index", String(index));
|
|
343
|
+
toast.style.setProperty("--toast-offset", `${offset}px`);
|
|
344
|
+
offset += toast.offsetHeight + gap;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function observeToast(element: HTMLElement): void {
|
|
349
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
350
|
+
resizeObserver ??= new ResizeObserver(() => recalcStack());
|
|
351
|
+
resizeObserver.observe(element);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function forgetToast(element: HTMLElement): void {
|
|
355
|
+
resizeObserver?.unobserve(element);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function add(options: ToastOptions): string {
|
|
359
|
+
const id = options.id ?? nextId();
|
|
360
|
+
const element = document.createElement("div");
|
|
361
|
+
element.id = id;
|
|
362
|
+
const duration = options.duration ?? DEFAULT_DURATION;
|
|
363
|
+
|
|
364
|
+
const record: ToastRecord = {
|
|
365
|
+
element,
|
|
366
|
+
id,
|
|
367
|
+
duration,
|
|
368
|
+
remaining: duration,
|
|
369
|
+
startedAt: 0,
|
|
370
|
+
timer: null,
|
|
371
|
+
options,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
render(element, id, options);
|
|
375
|
+
records.set(id, record);
|
|
376
|
+
ensureRegion().append(element);
|
|
377
|
+
|
|
378
|
+
wire(record);
|
|
379
|
+
observeToast(element);
|
|
380
|
+
schedule(record);
|
|
381
|
+
applyVisibility();
|
|
382
|
+
|
|
383
|
+
return id;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function update(id: string, options: ToastOptions): boolean {
|
|
387
|
+
const record = records.get(id);
|
|
388
|
+
if (!record) return false;
|
|
389
|
+
|
|
390
|
+
// Content fields are replaced, not merged: a promise transition from
|
|
391
|
+
// "Creating…" to "Created." must not leave the loading description behind.
|
|
392
|
+
record.options = {
|
|
393
|
+
...record.options,
|
|
394
|
+
...options,
|
|
395
|
+
title: options.title ?? record.options.title,
|
|
396
|
+
description: options.description,
|
|
397
|
+
message: options.message,
|
|
398
|
+
};
|
|
399
|
+
record.duration = options.duration ?? DEFAULT_DURATION;
|
|
400
|
+
record.remaining = record.duration;
|
|
401
|
+
|
|
402
|
+
render(record.element, id, record.options);
|
|
403
|
+
recalcStack();
|
|
404
|
+
schedule(record);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function dismiss(element: HTMLElement): void {
|
|
409
|
+
const id = element.dataset.toastId;
|
|
410
|
+
const record = id ? records.get(id) : undefined;
|
|
411
|
+
if (record) {
|
|
412
|
+
clearTimer(record);
|
|
413
|
+
records.delete(record.id);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (element.hasAttribute("data-toast-leaving")) return;
|
|
417
|
+
element.setAttribute("data-toast-leaving", "");
|
|
418
|
+
recalcStack();
|
|
419
|
+
|
|
420
|
+
const hadFocus = element.contains(document.activeElement);
|
|
421
|
+
const origin = origins.get(element);
|
|
422
|
+
const remove = () => {
|
|
423
|
+
forgetToast(element);
|
|
424
|
+
element.remove();
|
|
425
|
+
applyVisibility();
|
|
426
|
+
if (hadFocus) origin?.focus();
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const reduced =
|
|
430
|
+
typeof matchMedia === "function" &&
|
|
431
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
432
|
+
|
|
433
|
+
if (reduced) remove();
|
|
434
|
+
else window.setTimeout(remove, EXIT_DELAY);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function close(id: string): boolean {
|
|
438
|
+
const record = records.get(id);
|
|
439
|
+
if (!record) return false;
|
|
440
|
+
dismiss(record.element);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Mount an already-rendered toast (the Astro template clone) in the shared
|
|
446
|
+
* region and give it the same timer, pause-on-hover and dismissal behaviour.
|
|
447
|
+
*/
|
|
448
|
+
export function mount(element: HTMLElement, origin?: HTMLElement): string {
|
|
449
|
+
const id = element.id || nextId();
|
|
450
|
+
element.id = id;
|
|
451
|
+
const rawDuration = element.dataset.toastDuration;
|
|
452
|
+
const parsed = rawDuration === undefined ? Number.NaN : Number(rawDuration);
|
|
453
|
+
const duration = Number.isFinite(parsed) ? parsed : DEFAULT_DURATION;
|
|
454
|
+
|
|
455
|
+
const record: ToastRecord = {
|
|
456
|
+
element,
|
|
457
|
+
id,
|
|
458
|
+
duration,
|
|
459
|
+
remaining: duration,
|
|
460
|
+
startedAt: 0,
|
|
461
|
+
timer: null,
|
|
462
|
+
options: {},
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
if (origin) origins.set(element, origin);
|
|
466
|
+
records.set(id, record);
|
|
467
|
+
ensureRegion().append(element);
|
|
468
|
+
|
|
469
|
+
wire(record);
|
|
470
|
+
observeToast(element);
|
|
471
|
+
schedule(record);
|
|
472
|
+
applyVisibility();
|
|
473
|
+
|
|
474
|
+
return id;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function resolveContent<T>(
|
|
478
|
+
value: string | ToastContent | ((input: T) => string | ToastContent),
|
|
479
|
+
input?: T,
|
|
480
|
+
): ToastContent {
|
|
481
|
+
const resolved =
|
|
482
|
+
typeof value === "function"
|
|
483
|
+
? (value as (input: T) => string | ToastContent)(input as T)
|
|
484
|
+
: value;
|
|
485
|
+
return typeof resolved === "string" ? { title: resolved } : resolved;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Update one toast as an async task moves through loading, success and error,
|
|
490
|
+
* matching shadcn/ui's `toast.promise(promise, { loading, success, error })`.
|
|
491
|
+
* The original promise is returned untouched.
|
|
492
|
+
*/
|
|
493
|
+
export function promise<T>(
|
|
494
|
+
task: Promise<T>,
|
|
495
|
+
options: ToastPromiseOptions<T>,
|
|
496
|
+
): Promise<T> {
|
|
497
|
+
const id = add({
|
|
498
|
+
...resolveContent(options.loading),
|
|
499
|
+
type: "loading",
|
|
500
|
+
duration: 0,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
task.then(
|
|
504
|
+
(value) => {
|
|
505
|
+
update(id, {
|
|
506
|
+
...resolveContent(options.success, value),
|
|
507
|
+
type: "success",
|
|
508
|
+
duration: options.duration ?? DEFAULT_DURATION,
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
(error: unknown) => {
|
|
512
|
+
update(id, {
|
|
513
|
+
...resolveContent(options.error, error),
|
|
514
|
+
type: "error",
|
|
515
|
+
duration: options.duration ?? ERROR_DURATION,
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
return task;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export const toast = {
|
|
524
|
+
add,
|
|
525
|
+
update,
|
|
526
|
+
close,
|
|
527
|
+
dismiss,
|
|
528
|
+
promise,
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
export default toast;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.ds-accordion {
|
|
3
|
+
display: grid;
|
|
4
|
+
gap: var(--accordion-gap);
|
|
5
|
+
max-width: var(--accordion-max-width);
|
|
6
|
+
width: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.ds-accordion__item {
|
|
10
|
+
background: var(--accordion-item-bg);
|
|
11
|
+
border: 1px solid var(--accordion-item-border);
|
|
12
|
+
border-radius: var(--accordion-item-radius);
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ds-accordion__trigger {
|
|
17
|
+
align-items: center;
|
|
18
|
+
background: transparent;
|
|
19
|
+
color: var(--accordion-trigger-fg);
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
display: flex;
|
|
22
|
+
font-size: var(--accordion-font-size);
|
|
23
|
+
font-weight: var(--weight-medium);
|
|
24
|
+
gap: var(--space-4);
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
list-style: none;
|
|
27
|
+
padding: var(--accordion-trigger-padding-y)
|
|
28
|
+
var(--accordion-trigger-padding-x);
|
|
29
|
+
transition: background-color var(--duration-fast) var(--ease-standard);
|
|
30
|
+
width: 100%;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ds-accordion__trigger::-webkit-details-marker {
|
|
34
|
+
display: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ds-accordion__trigger:hover {
|
|
38
|
+
background: var(--accordion-trigger-bg-hover);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.ds-accordion__item[open] .ds-accordion__trigger {
|
|
42
|
+
background: var(--accordion-trigger-bg-open);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.ds-accordion__trigger:focus-visible {
|
|
46
|
+
outline: var(--accordion-focus-width) solid var(--accordion-focus-color);
|
|
47
|
+
outline-offset: var(--accordion-focus-offset);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ds-accordion__icon {
|
|
51
|
+
color: var(--accordion-icon);
|
|
52
|
+
transition: transform var(--duration-base) var(--ease-standard);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ds-accordion__item[open] .ds-accordion__icon {
|
|
56
|
+
transform: rotate(180deg);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ds-accordion__panel {
|
|
60
|
+
background: var(--accordion-panel-bg);
|
|
61
|
+
border-top: 1px solid var(--accordion-panel-border);
|
|
62
|
+
color: var(--accordion-panel-fg);
|
|
63
|
+
font-size: var(--accordion-font-size);
|
|
64
|
+
line-height: var(--leading-base);
|
|
65
|
+
padding: var(--accordion-panel-padding);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ds-accordion__panel > * + * {
|
|
69
|
+
margin-top: var(--space-3);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (prefers-reduced-motion: reduce) {
|
|
73
|
+
.ds-accordion__icon {
|
|
74
|
+
transition: none;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.ds-alert {
|
|
3
|
+
background: var(--alert-info-bg);
|
|
4
|
+
border: 1px solid var(--alert-info-border);
|
|
5
|
+
border-radius: var(--alert-radius);
|
|
6
|
+
color: var(--alert-info-fg);
|
|
7
|
+
display: flex;
|
|
8
|
+
gap: var(--alert-gap);
|
|
9
|
+
padding: var(--alert-padding);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.ds-alert--success {
|
|
13
|
+
background: var(--alert-success-bg);
|
|
14
|
+
border-color: var(--alert-success-border);
|
|
15
|
+
color: var(--alert-success-fg);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.ds-alert--warning {
|
|
19
|
+
background: var(--alert-warning-bg);
|
|
20
|
+
border-color: var(--alert-warning-border);
|
|
21
|
+
color: var(--alert-warning-fg);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.ds-alert--danger {
|
|
25
|
+
background: var(--alert-danger-bg);
|
|
26
|
+
border-color: var(--alert-danger-border);
|
|
27
|
+
color: var(--alert-danger-fg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.ds-alert__icon {
|
|
31
|
+
flex: none;
|
|
32
|
+
margin-block-start: 0.1rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ds-alert__content {
|
|
36
|
+
flex: 1;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ds-alert__title {
|
|
41
|
+
font-size: var(--alert-title-size);
|
|
42
|
+
font-weight: var(--weight-semibold);
|
|
43
|
+
line-height: var(--leading-sm);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ds-alert__body {
|
|
47
|
+
color: var(--alert-body-fg);
|
|
48
|
+
font-size: var(--alert-body-size);
|
|
49
|
+
line-height: var(--leading-sm);
|
|
50
|
+
margin-block-start: var(--space-1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ds-alert__actions {
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-wrap: wrap;
|
|
56
|
+
gap: var(--space-2);
|
|
57
|
+
margin-block-start: var(--space-3);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ds-alert__close {
|
|
61
|
+
align-items: center;
|
|
62
|
+
background: transparent;
|
|
63
|
+
border: 0;
|
|
64
|
+
border-radius: var(--radius-sm);
|
|
65
|
+
color: var(--alert-info-close-fg);
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
flex: none;
|
|
68
|
+
height: var(--space-7);
|
|
69
|
+
justify-content: center;
|
|
70
|
+
transition: background-color var(--duration-fast) var(--ease-standard);
|
|
71
|
+
width: var(--space-7);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ds-alert--success .ds-alert__close {
|
|
75
|
+
color: var(--alert-success-close-fg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ds-alert--warning .ds-alert__close {
|
|
79
|
+
color: var(--alert-warning-close-fg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.ds-alert--danger .ds-alert__close {
|
|
83
|
+
color: var(--alert-danger-close-fg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ds-alert__close:hover {
|
|
87
|
+
background: var(--alert-info-close-bg-hover);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ds-alert--success .ds-alert__close:hover {
|
|
91
|
+
background: var(--alert-success-close-bg-hover);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.ds-alert--warning .ds-alert__close:hover {
|
|
95
|
+
background: var(--alert-warning-close-bg-hover);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.ds-alert--danger .ds-alert__close:hover {
|
|
99
|
+
background: var(--alert-danger-close-bg-hover);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ds-alert__close:focus-visible {
|
|
103
|
+
outline: var(--alert-focus-width) solid var(--alert-focus-color);
|
|
104
|
+
outline-offset: var(--alert-focus-offset);
|
|
105
|
+
}
|
|
106
|
+
}
|