@tundralibs/ui 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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/icons.svg +2 -0
- package/dist/ui.css +5993 -0
- package/dist/ui.js +2089 -0
- package/package.json +52 -0
package/dist/ui.js
ADDED
|
@@ -0,0 +1,2089 @@
|
|
|
1
|
+
/* shared/js/busy.js */
|
|
2
|
+
/* Loading state for rAPId swaps, automatically. The moment a
|
|
3
|
+
* [data-action] click or a form[data-action] submit is on its way, the
|
|
4
|
+
* region it will replace is marked `aria-busy="true"` + `data-busy`;
|
|
5
|
+
* skeleton.css paints a shimmer veil over it and turns pointer events
|
|
6
|
+
* off, and `rapid:swapped` / `rapid:error` clear it. A `data-load` region
|
|
7
|
+
* is left alone — it carries its own server-rendered skeleton until its
|
|
8
|
+
* fragment lands. Content-shaped skeletons stay the server's job (only it
|
|
9
|
+
* knows the shape); this is the generic "something is happening here".
|
|
10
|
+
* Runs against the same delegated events the runtime uses; no `rapid.*`
|
|
11
|
+
* call, no coupling beyond the two events it listens for. */
|
|
12
|
+
(() => {
|
|
13
|
+
const TIMEOUT = 15000;
|
|
14
|
+
const timers = new WeakMap();
|
|
15
|
+
|
|
16
|
+
function targetOf(el) {
|
|
17
|
+
const sel = el.getAttribute("data-target");
|
|
18
|
+
if (!sel) return el;
|
|
19
|
+
try {
|
|
20
|
+
return document.querySelector(sel);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clear(el) {
|
|
27
|
+
if (!el || el.nodeType !== 1) return;
|
|
28
|
+
el.removeAttribute("aria-busy");
|
|
29
|
+
delete el.dataset.busy;
|
|
30
|
+
const t = timers.get(el);
|
|
31
|
+
if (t) clearTimeout(t);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mark(el) {
|
|
35
|
+
const region = targetOf(el);
|
|
36
|
+
if (!region || region.hasAttribute("data-load")) return;
|
|
37
|
+
region.setAttribute("aria-busy", "true");
|
|
38
|
+
region.dataset.busy = "";
|
|
39
|
+
// Never leave a veil behind if no reply ever comes.
|
|
40
|
+
timers.set(region, setTimeout(() => clear(region), TIMEOUT));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The runtime's own listener runs first and calls preventDefault on the
|
|
44
|
+
// clicks it handles — so `defaultPrevented` is the normal case here, not
|
|
45
|
+
// a reason to stand down.
|
|
46
|
+
document.addEventListener("click", (event) => {
|
|
47
|
+
if (event.button !== 0) return;
|
|
48
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
49
|
+
const el = event.target.closest?.("[data-action]");
|
|
50
|
+
if (!el || el.tagName === "FORM") return;
|
|
51
|
+
// A real link inside a data-action container keeps its native
|
|
52
|
+
// navigation (runtime rule) — no swap, so nothing to mark.
|
|
53
|
+
const link = event.target.closest("a[href]");
|
|
54
|
+
if (link && link !== el && el.contains(link)) return;
|
|
55
|
+
mark(el);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
document.addEventListener("submit", (event) => {
|
|
59
|
+
const form = event.target;
|
|
60
|
+
if (form?.matches?.("form[data-action]")) mark(form);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
document.addEventListener("rapid:swapped", (event) => clear(event.target));
|
|
64
|
+
document.addEventListener("rapid:error", (event) => {
|
|
65
|
+
clear(event.target);
|
|
66
|
+
document.querySelectorAll("[data-busy]").forEach(clear);
|
|
67
|
+
});
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
/* shared/js/collapse-sidebar.js */
|
|
71
|
+
/*
|
|
72
|
+
* Desktop "mini sidebar" collapse — distinct from shared/js/toggle.js's
|
|
73
|
+
* [data-toggle], which also hides the target via the `hidden` attribute
|
|
74
|
+
* (wrong here: a collapsed sidebar must stay visible, just narrower).
|
|
75
|
+
*/
|
|
76
|
+
(() => {
|
|
77
|
+
const KEY = "ui-sidebar-collapsed";
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (localStorage.getItem(KEY) === "1") {
|
|
81
|
+
document.querySelectorAll(".sidebar").forEach((el) => {
|
|
82
|
+
el.classList.add("sidebar--collapsed");
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// No persistence available — starts expanded, which is fine.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
document.addEventListener("click", (event) => {
|
|
90
|
+
const trigger = event.target.closest?.("[data-collapse]");
|
|
91
|
+
if (!trigger) return;
|
|
92
|
+
const selector = trigger.getAttribute("data-collapse");
|
|
93
|
+
if (!selector || selector === "#") return;
|
|
94
|
+
const target = document.querySelector(selector);
|
|
95
|
+
if (!target) return;
|
|
96
|
+
|
|
97
|
+
const collapsed = target.classList.toggle("sidebar--collapsed");
|
|
98
|
+
trigger.setAttribute("aria-expanded", String(!collapsed));
|
|
99
|
+
try {
|
|
100
|
+
localStorage.setItem(KEY, collapsed ? "1" : "0");
|
|
101
|
+
} catch {
|
|
102
|
+
// Same fallback as above.
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
/* shared/js/dismiss.js */
|
|
108
|
+
/* Generic dismiss: a `[data-dismiss]` button removes its closest
|
|
109
|
+
* `[data-dismissible]` ancestor (used by both alert and toast). */
|
|
110
|
+
(() => {
|
|
111
|
+
document.addEventListener("click", (event) => {
|
|
112
|
+
const trigger = event.target.closest("[data-dismiss]");
|
|
113
|
+
if (!trigger) return;
|
|
114
|
+
trigger.closest("[data-dismissible]")?.remove();
|
|
115
|
+
});
|
|
116
|
+
})();
|
|
117
|
+
|
|
118
|
+
/* shared/js/enhance.js */
|
|
119
|
+
/*
|
|
120
|
+
* A pure opt-in marker — added, never assumed pre-set — so no core/layout
|
|
121
|
+
* template needs to put a class on <html> or <body> (rAPId's own
|
|
122
|
+
* `htmlDocument()` helper doesn't expose a hook for either). Every CSS
|
|
123
|
+
* rule that depends on JS being available is written as `.js SELECTOR`,
|
|
124
|
+
* so its absence (JS disabled, or this script not yet run) always means
|
|
125
|
+
* the plain, functional fallback rendering — never a broken one.
|
|
126
|
+
*/
|
|
127
|
+
(() => {
|
|
128
|
+
document.documentElement.classList.add("js");
|
|
129
|
+
})();
|
|
130
|
+
|
|
131
|
+
/* shared/js/filter.js */
|
|
132
|
+
/*
|
|
133
|
+
* Generic client-side search/filter: an element carrying
|
|
134
|
+
* `data-filter-scope` defines a filterable region. Inside it, a
|
|
135
|
+
* `[data-table-search]` input free-text filters, and any
|
|
136
|
+
* `[data-table-filter]` control narrows further (both AND together) — a
|
|
137
|
+
* <select>, or radio inputs (e.g. a Segmented group; only the checked
|
|
138
|
+
* one counts, and an empty value means "all"). Filterable items are a
|
|
139
|
+
* table's own `tbody tr` rows when the scope contains a `<table>`,
|
|
140
|
+
* otherwise any `[data-filter-item]` — so the same mechanism works for
|
|
141
|
+
* both a data table and a card grid. An optional `[data-filter-empty]`
|
|
142
|
+
* element in the scope is shown only when nothing matches.
|
|
143
|
+
*/
|
|
144
|
+
(() => {
|
|
145
|
+
function getItems(scope) {
|
|
146
|
+
const table = scope.querySelector("table");
|
|
147
|
+
if (table) return [...table.querySelectorAll("tbody tr")];
|
|
148
|
+
return [...scope.querySelectorAll("[data-filter-item]")];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function applyFilters(scope) {
|
|
152
|
+
const searchInput = scope.querySelector("[data-table-search]");
|
|
153
|
+
const term = (searchInput?.value || "").trim().toLowerCase();
|
|
154
|
+
const filters = [...scope.querySelectorAll("[data-table-filter]")]
|
|
155
|
+
.filter((el) => !(el.type === "radio" || el.type === "checkbox") || el.checked)
|
|
156
|
+
.map((el) => el.value)
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.map((v) => v.toLowerCase());
|
|
159
|
+
|
|
160
|
+
let visible = 0;
|
|
161
|
+
getItems(scope).forEach((item) => {
|
|
162
|
+
const text = item.textContent.toLowerCase();
|
|
163
|
+
const matches = (!term || text.includes(term)) &&
|
|
164
|
+
filters.every((f) => text.includes(f));
|
|
165
|
+
item.hidden = !matches;
|
|
166
|
+
if (matches) visible++;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const empty = scope.querySelector("[data-filter-empty]");
|
|
170
|
+
if (empty) empty.hidden = visible > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
document.addEventListener("input", (event) => {
|
|
174
|
+
if (!event.target.matches("[data-table-search]")) return;
|
|
175
|
+
const scope = event.target.closest("[data-filter-scope]");
|
|
176
|
+
if (scope) applyFilters(scope);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
document.addEventListener("change", (event) => {
|
|
180
|
+
if (!event.target.matches("[data-table-filter]")) return;
|
|
181
|
+
const scope = event.target.closest("[data-filter-scope]");
|
|
182
|
+
if (scope) applyFilters(scope);
|
|
183
|
+
});
|
|
184
|
+
})();
|
|
185
|
+
|
|
186
|
+
/* shared/js/theme-toggle.js */
|
|
187
|
+
/*
|
|
188
|
+
* Persists an explicit dark/light choice as `data-theme` on <html>,
|
|
189
|
+
* overriding `prefers-color-scheme` in either direction (see tokens'
|
|
190
|
+
* dark-mode block). A `[data-theme-toggle]` element flips it.
|
|
191
|
+
*/
|
|
192
|
+
(() => {
|
|
193
|
+
const KEY = "ui-theme";
|
|
194
|
+
const root = document.documentElement;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const saved = localStorage.getItem(KEY);
|
|
198
|
+
if (saved === "dark" || saved === "light") root.dataset.theme = saved;
|
|
199
|
+
} catch {
|
|
200
|
+
// localStorage unavailable (private mode, etc.) — falls back to
|
|
201
|
+
// prefers-color-scheme only, which is a fine default.
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
document.addEventListener("click", (event) => {
|
|
205
|
+
const trigger = event.target.closest("[data-theme-toggle]");
|
|
206
|
+
if (!trigger) return;
|
|
207
|
+
|
|
208
|
+
const systemDark = matchMedia("(prefers-color-scheme: dark)").matches;
|
|
209
|
+
const current = root.dataset.theme === "dark" ||
|
|
210
|
+
(root.dataset.theme !== "light" && systemDark)
|
|
211
|
+
? "dark"
|
|
212
|
+
: "light";
|
|
213
|
+
const next = current === "dark" ? "light" : "dark";
|
|
214
|
+
|
|
215
|
+
root.dataset.theme = next;
|
|
216
|
+
try {
|
|
217
|
+
localStorage.setItem(KEY, next);
|
|
218
|
+
} catch {
|
|
219
|
+
// Same fallback as above — nothing to persist to.
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
})();
|
|
223
|
+
|
|
224
|
+
/* shared/js/toggle.js */
|
|
225
|
+
/*
|
|
226
|
+
* Generic show/hide: a trigger carrying `data-toggle="<selector>"` flips
|
|
227
|
+
* `hidden`, `.is-open` and `aria-expanded` on the element(s) `<selector>`
|
|
228
|
+
* matches. Not `data-target` — that name is rAPId's own swap-target
|
|
229
|
+
* attribute (see CLAUDE.md §2); this is a different, unrelated attribute
|
|
230
|
+
* so the two mechanisms never collide on the same element.
|
|
231
|
+
*
|
|
232
|
+
* `data-toggle-class` on the trigger switches to class-only mode (no
|
|
233
|
+
* `hidden`): for things that must stay in the layout when "closed", like
|
|
234
|
+
* an off-canvas sidebar that is a normal column on desktop.
|
|
235
|
+
*/
|
|
236
|
+
(() => {
|
|
237
|
+
const classOnly = (trigger) => trigger.hasAttribute("data-toggle-class");
|
|
238
|
+
|
|
239
|
+
document.addEventListener("click", (event) => {
|
|
240
|
+
const trigger = event.target.closest?.("[data-toggle]");
|
|
241
|
+
if (!trigger) return;
|
|
242
|
+
const selector = trigger.getAttribute("data-toggle");
|
|
243
|
+
const target = selector && selector !== "#" && document.querySelector(selector);
|
|
244
|
+
if (!target) return;
|
|
245
|
+
|
|
246
|
+
const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
|
|
247
|
+
trigger.setAttribute("aria-expanded", String(!wasExpanded));
|
|
248
|
+
if (!classOnly(trigger)) target.toggleAttribute("hidden", wasExpanded);
|
|
249
|
+
target.classList.toggle("is-open", !wasExpanded);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
document.addEventListener("click", (event) => {
|
|
253
|
+
const dismisser = event.target.closest?.("[data-toggle-close]");
|
|
254
|
+
if (!dismisser) return;
|
|
255
|
+
const selector = dismisser.getAttribute("data-toggle-close");
|
|
256
|
+
const target = selector ? document.querySelector(selector) : dismisser.closest("[data-toggle-panel]");
|
|
257
|
+
if (!target) return;
|
|
258
|
+
close(target);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Escape closes an open class-only panel (the mobile drawer) and
|
|
262
|
+
// returns focus to whatever opened it.
|
|
263
|
+
document.addEventListener("keydown", (event) => {
|
|
264
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
265
|
+
document.querySelectorAll("[data-toggle-class][aria-expanded='true']").forEach((trigger) => {
|
|
266
|
+
const target = document.querySelector(trigger.getAttribute("data-toggle") ?? "");
|
|
267
|
+
if (!target) return;
|
|
268
|
+
close(target);
|
|
269
|
+
trigger.focus();
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
function close(target) {
|
|
275
|
+
target.classList.remove("is-open");
|
|
276
|
+
document.querySelectorAll(`[data-toggle][aria-expanded="true"]`).forEach((trigger) => {
|
|
277
|
+
const sel = trigger.getAttribute("data-toggle");
|
|
278
|
+
if (sel && document.querySelector(sel) === target) {
|
|
279
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
280
|
+
if (!classOnly(trigger)) target.setAttribute("hidden", "");
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
})();
|
|
285
|
+
|
|
286
|
+
/* shared/js/view-switch.js */
|
|
287
|
+
/*
|
|
288
|
+
* View switch: a radio (typically inside a Segmented) carrying
|
|
289
|
+
* `data-view-target="<selector>"` writes its value to `data-view` on the
|
|
290
|
+
* target when checked. CSS keyed off `[data-view="list"]` etc. does the
|
|
291
|
+
* rest — no layout logic here, so a theme decides what each view means.
|
|
292
|
+
*/
|
|
293
|
+
(() => {
|
|
294
|
+
document.addEventListener("change", (event) => {
|
|
295
|
+
const control = event.target.closest?.("[data-view-target]");
|
|
296
|
+
if (!control || (control.type === "radio" && !control.checked)) return;
|
|
297
|
+
const target = document.querySelector(control.getAttribute("data-view-target") ?? "");
|
|
298
|
+
if (target) target.setAttribute("data-view", control.value);
|
|
299
|
+
});
|
|
300
|
+
})();
|
|
301
|
+
|
|
302
|
+
/* components/chart/chart.js */
|
|
303
|
+
/* Chart: draws every [data-chart] with ApexCharts once the (deferred,
|
|
304
|
+
* pinned — see chart.ts) engine is present; a silent no-op without it.
|
|
305
|
+
* Colours come from the library's tokens: chart.css maps ApexCharts'
|
|
306
|
+
* `--apx-*` design tokens onto ours, the engine reads them on every
|
|
307
|
+
* render, and this script re-renders on a light/dark switch (the
|
|
308
|
+
* `data-theme` attribute or the OS preference) so charts follow the page.
|
|
309
|
+
* Idempotent per element (data-chart-initialized); re-runs after a
|
|
310
|
+
* rAPId swap; destroys the instance when its element leaves the DOM. */
|
|
311
|
+
(() => {
|
|
312
|
+
const SELECTOR = "[data-chart]";
|
|
313
|
+
const live = new Set();
|
|
314
|
+
const media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
|
|
315
|
+
|
|
316
|
+
function mode() {
|
|
317
|
+
const forced = document.documentElement.dataset.theme;
|
|
318
|
+
if (forced === "dark" || forced === "light") return forced;
|
|
319
|
+
return media?.matches ? "dark" : "light";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function themeOptions() {
|
|
323
|
+
const font = getComputedStyle(document.documentElement).getPropertyValue("--font-family-base").trim();
|
|
324
|
+
return {
|
|
325
|
+
theme: { mode: mode(), tokens: true },
|
|
326
|
+
chart: font ? { fontFamily: font, background: "transparent" } : { background: "transparent" },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function init(el) {
|
|
331
|
+
if (el.dataset.chartInitialized) return;
|
|
332
|
+
if (typeof globalThis.ApexCharts === "undefined") return;
|
|
333
|
+
const config = JSON.parse(el.dataset.chart || "{}");
|
|
334
|
+
const theme = themeOptions();
|
|
335
|
+
const options = {
|
|
336
|
+
...config,
|
|
337
|
+
theme: { ...theme.theme, ...(config.theme || {}) },
|
|
338
|
+
chart: { ...theme.chart, ...(config.chart || {}) },
|
|
339
|
+
};
|
|
340
|
+
const chart = new globalThis.ApexCharts(el, options);
|
|
341
|
+
el.dataset.chartInitialized = "true";
|
|
342
|
+
el.chart = chart;
|
|
343
|
+
live.add(el);
|
|
344
|
+
chart.render();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function destroy(el) {
|
|
348
|
+
if (!el.chart) return;
|
|
349
|
+
el.chart.destroy();
|
|
350
|
+
delete el.chart;
|
|
351
|
+
delete el.dataset.chartInitialized;
|
|
352
|
+
live.delete(el);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function retheme() {
|
|
356
|
+
for (const el of live) {
|
|
357
|
+
if (!el.isConnected) {
|
|
358
|
+
destroy(el);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
el.chart.refreshTokens?.();
|
|
362
|
+
el.chart.updateOptions(themeOptions(), false, true);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const initAll = () => document.querySelectorAll(SELECTOR).forEach(init);
|
|
367
|
+
initAll();
|
|
368
|
+
// The engine is a `defer`red script that runs after this bundle but
|
|
369
|
+
// before `load` — try again then. init is idempotent.
|
|
370
|
+
addEventListener("load", initAll);
|
|
371
|
+
document.addEventListener("rapid:swapped", initAll);
|
|
372
|
+
|
|
373
|
+
new MutationObserver((mutations) => {
|
|
374
|
+
for (const mutation of mutations) {
|
|
375
|
+
for (const node of mutation.addedNodes) {
|
|
376
|
+
if (node.nodeType !== 1) continue;
|
|
377
|
+
if (node.matches?.(SELECTOR)) init(node);
|
|
378
|
+
node.querySelectorAll?.(SELECTOR).forEach(init);
|
|
379
|
+
}
|
|
380
|
+
for (const node of mutation.removedNodes) {
|
|
381
|
+
if (node.nodeType !== 1) continue;
|
|
382
|
+
if (node.matches?.(SELECTOR)) destroy(node);
|
|
383
|
+
node.querySelectorAll?.(SELECTOR).forEach(destroy);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
387
|
+
|
|
388
|
+
new MutationObserver(retheme).observe(document.documentElement, {
|
|
389
|
+
attributes: true,
|
|
390
|
+
attributeFilter: ["data-theme"],
|
|
391
|
+
});
|
|
392
|
+
media?.addEventListener?.("change", retheme);
|
|
393
|
+
})();
|
|
394
|
+
|
|
395
|
+
/* components/collapsible/collapsible.js */
|
|
396
|
+
(() => {
|
|
397
|
+
document.addEventListener("click", (event) => {
|
|
398
|
+
const trigger = event.target.closest("[data-toggle]");
|
|
399
|
+
if (!trigger) return;
|
|
400
|
+
const group = trigger.closest("[data-accordion-group]");
|
|
401
|
+
if (!group) return;
|
|
402
|
+
|
|
403
|
+
group.querySelectorAll('[data-toggle][aria-expanded="true"]').forEach(
|
|
404
|
+
(other) => {
|
|
405
|
+
if (other === trigger) return;
|
|
406
|
+
const selector = other.getAttribute("data-toggle");
|
|
407
|
+
const panel = selector && document.querySelector(selector);
|
|
408
|
+
other.setAttribute("aria-expanded", "false");
|
|
409
|
+
panel?.setAttribute("hidden", "");
|
|
410
|
+
panel?.classList.remove("is-open");
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
})();
|
|
415
|
+
|
|
416
|
+
/* components/combobox/combobox.js */
|
|
417
|
+
/*
|
|
418
|
+
* Combobox: keyboard navigation over a server-rendered listbox, open on
|
|
419
|
+
* focus, pick on click/Enter, dismiss on Escape/outside click. Without
|
|
420
|
+
* this script it is a text input plus a visible listbox — still usable.
|
|
421
|
+
*
|
|
422
|
+
* Filtering: with `data-combobox-action` on the input AND rAPId's runtime
|
|
423
|
+
* present, each (debounced) keystroke does
|
|
424
|
+
* `window.rapid.swap(action?q=<text>, #<id>-list)` and the server's
|
|
425
|
+
* ComboboxList fragment replaces the list. Otherwise the rendered options
|
|
426
|
+
* are filtered client-side — non-matches and empty groups hidden, count
|
|
427
|
+
* updated. (rAPId's own runtime only reacts to click/submit, so this is
|
|
428
|
+
* the one place a component script triggers a swap programmatically.)
|
|
429
|
+
*
|
|
430
|
+
* Value: single-select writes the picked option's `data-value` into the
|
|
431
|
+
* hidden `[data-combobox-value]` input and its label into the visible
|
|
432
|
+
* one; typing clears the hidden value until a new pick. Multi-select adds
|
|
433
|
+
* a token (label + hidden input named after `data-combobox-name`);
|
|
434
|
+
* [data-combobox-remove] drops it again.
|
|
435
|
+
*/
|
|
436
|
+
(() => {
|
|
437
|
+
const ACTIVE = "data-active";
|
|
438
|
+
const X_ICON =
|
|
439
|
+
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
440
|
+
|
|
441
|
+
const inputOf = (root) => root.querySelector("input[role='combobox']");
|
|
442
|
+
const options = (root) => Array.from(root.querySelectorAll('[role="option"]')).filter((o) => !o.hidden);
|
|
443
|
+
|
|
444
|
+
function activeIndex(root) {
|
|
445
|
+
return options(root).findIndex((o) => o.hasAttribute(ACTIVE));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function clearActive(root) {
|
|
449
|
+
root.querySelectorAll(`[${ACTIVE}]`).forEach((o) => o.removeAttribute(ACTIVE));
|
|
450
|
+
inputOf(root)?.removeAttribute("aria-activedescendant");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function setActive(root, index) {
|
|
454
|
+
const opts = options(root);
|
|
455
|
+
if (!opts.length) return;
|
|
456
|
+
const i = (index + opts.length) % opts.length;
|
|
457
|
+
clearActive(root);
|
|
458
|
+
opts[i].setAttribute(ACTIVE, "");
|
|
459
|
+
const input = inputOf(root);
|
|
460
|
+
if (input && opts[i].id) input.setAttribute("aria-activedescendant", opts[i].id);
|
|
461
|
+
const list = opts[i].closest(".combobox__list");
|
|
462
|
+
if (!list) return;
|
|
463
|
+
const top = opts[i].offsetTop;
|
|
464
|
+
if (top < list.scrollTop) list.scrollTop = top;
|
|
465
|
+
const bottom = top + opts[i].offsetHeight;
|
|
466
|
+
if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const isOpen = (root) => root.classList.contains("combobox--open");
|
|
470
|
+
|
|
471
|
+
function open(root, on) {
|
|
472
|
+
const list = root.querySelector(".combobox__list");
|
|
473
|
+
const input = inputOf(root);
|
|
474
|
+
if (list) list.hidden = !on;
|
|
475
|
+
if (input) input.setAttribute("aria-expanded", on ? "true" : "false");
|
|
476
|
+
root.classList.toggle("combobox--open", !!on);
|
|
477
|
+
if (!on) clearActive(root);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* Client-side filter — only when the server is not doing it. */
|
|
481
|
+
function filterLocal(root) {
|
|
482
|
+
const input = inputOf(root);
|
|
483
|
+
const list = root.querySelector(".combobox__list");
|
|
484
|
+
if (!input || !list) return;
|
|
485
|
+
const q = input.value.trim().toLowerCase();
|
|
486
|
+
|
|
487
|
+
let visible = 0;
|
|
488
|
+
root.querySelectorAll('[role="option"]').forEach((o) => {
|
|
489
|
+
const label = o.querySelector(".combobox__option-label")?.textContent ?? o.textContent;
|
|
490
|
+
const hit = !q || label.toLowerCase().includes(q);
|
|
491
|
+
o.hidden = !hit;
|
|
492
|
+
if (hit) visible++;
|
|
493
|
+
});
|
|
494
|
+
clearActive(root);
|
|
495
|
+
|
|
496
|
+
root.querySelectorAll(".combobox__group").forEach((group) => {
|
|
497
|
+
let el = group.nextElementSibling;
|
|
498
|
+
let any = false;
|
|
499
|
+
while (el && !el.classList.contains("combobox__group") && !el.classList.contains("combobox__hints")) {
|
|
500
|
+
if (el.matches('[role="option"]') && !el.hidden) {
|
|
501
|
+
any = true;
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
el = el.nextElementSibling;
|
|
505
|
+
}
|
|
506
|
+
group.hidden = !any;
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const count = root.querySelector(".combobox__count");
|
|
510
|
+
if (count) count.textContent = visible === 1 ? "1 match" : `${visible} matches`;
|
|
511
|
+
|
|
512
|
+
let empty = list.querySelector(".combobox__empty");
|
|
513
|
+
if (!empty) {
|
|
514
|
+
empty = document.createElement("div");
|
|
515
|
+
empty.className = "combobox__empty";
|
|
516
|
+
empty.textContent = "No matches.";
|
|
517
|
+
list.insertBefore(empty, list.querySelector(".combobox__hints"));
|
|
518
|
+
}
|
|
519
|
+
empty.hidden = visible > 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const timers = new WeakMap();
|
|
523
|
+
function filter(root) {
|
|
524
|
+
const input = inputOf(root);
|
|
525
|
+
const action = input?.getAttribute("data-combobox-action");
|
|
526
|
+
const target = input?.getAttribute("data-combobox-target");
|
|
527
|
+
if (action && target && globalThis.rapid?.swap) {
|
|
528
|
+
clearTimeout(timers.get(root));
|
|
529
|
+
timers.set(
|
|
530
|
+
root,
|
|
531
|
+
setTimeout(() => {
|
|
532
|
+
const url = `${action}${action.includes("?") ? "&" : "?"}q=${encodeURIComponent(input.value.trim())}`;
|
|
533
|
+
globalThis.rapid.swap(url, target).then(() => clearActive(root));
|
|
534
|
+
}, 150),
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
filterLocal(root);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function pickSingle(root, opt) {
|
|
542
|
+
const input = inputOf(root);
|
|
543
|
+
const hidden = root.querySelector("[data-combobox-value]");
|
|
544
|
+
const label = (opt.querySelector(".combobox__option-label")?.textContent ?? opt.textContent).trim();
|
|
545
|
+
if (input) input.value = label;
|
|
546
|
+
if (hidden) hidden.value = opt.getAttribute("data-value") ?? label;
|
|
547
|
+
root.querySelectorAll('[role="option"]').forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
|
|
548
|
+
open(root, false);
|
|
549
|
+
// Wrappers (the Select) mirror the pick into their own control.
|
|
550
|
+
root.dispatchEvent(
|
|
551
|
+
new CustomEvent("combobox:pick", { bubbles: true, detail: { value: hidden?.value ?? label, label } }),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function addToken(root, opt) {
|
|
556
|
+
const field = root.querySelector(".combobox__field");
|
|
557
|
+
const input = inputOf(root);
|
|
558
|
+
const value = opt.getAttribute("data-value") ?? "";
|
|
559
|
+
const label = (opt.querySelector(".combobox__option-label")?.textContent ?? opt.textContent).trim();
|
|
560
|
+
const name = root.getAttribute("data-combobox-name") ?? "";
|
|
561
|
+
if (!field || !input) return;
|
|
562
|
+
if (Array.from(field.querySelectorAll(".combobox__token input")).some((h) => h.value === value)) return;
|
|
563
|
+
|
|
564
|
+
const token = document.createElement("span");
|
|
565
|
+
token.className = "combobox__token";
|
|
566
|
+
token.append(label);
|
|
567
|
+
const remove = document.createElement("button");
|
|
568
|
+
remove.type = "button";
|
|
569
|
+
remove.className = "combobox__token-remove";
|
|
570
|
+
remove.setAttribute("aria-label", `Remove ${label}`);
|
|
571
|
+
remove.setAttribute("data-combobox-remove", value);
|
|
572
|
+
remove.innerHTML = X_ICON; // constant markup, no data
|
|
573
|
+
const hidden = document.createElement("input");
|
|
574
|
+
hidden.type = "hidden";
|
|
575
|
+
hidden.name = name;
|
|
576
|
+
hidden.value = value;
|
|
577
|
+
token.append(remove, hidden);
|
|
578
|
+
field.insertBefore(token, input);
|
|
579
|
+
|
|
580
|
+
opt.setAttribute("aria-selected", "true");
|
|
581
|
+
input.value = "";
|
|
582
|
+
filter(root);
|
|
583
|
+
input.focus();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function removeToken(root, value) {
|
|
587
|
+
root.querySelectorAll(".combobox__token").forEach((t) => {
|
|
588
|
+
if (t.querySelector("input")?.value === value) t.remove();
|
|
589
|
+
});
|
|
590
|
+
root.querySelectorAll(`[role="option"][data-value="${CSS.escape(value)}"]`)
|
|
591
|
+
.forEach((o) => o.setAttribute("aria-selected", "false"));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
document.addEventListener("keydown", (event) => {
|
|
595
|
+
const root = event.target.closest?.("[data-combobox]");
|
|
596
|
+
if (!root) return;
|
|
597
|
+
|
|
598
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
599
|
+
event.preventDefault();
|
|
600
|
+
open(root, true);
|
|
601
|
+
setActive(root, activeIndex(root) + (event.key === "ArrowDown" ? 1 : -1));
|
|
602
|
+
} else if (event.key === "Enter") {
|
|
603
|
+
const i = activeIndex(root);
|
|
604
|
+
const opts = options(root);
|
|
605
|
+
if (i >= 0 && opts[i]) {
|
|
606
|
+
// Only swallow Enter when it picks something; otherwise a form
|
|
607
|
+
// around the combobox still submits normally.
|
|
608
|
+
event.preventDefault();
|
|
609
|
+
opts[i].click();
|
|
610
|
+
}
|
|
611
|
+
} else if (event.key === "Escape") {
|
|
612
|
+
// Only claim Escape while the list is open — otherwise it must
|
|
613
|
+
// reach the <dialog>/popover this combobox may be sitting in.
|
|
614
|
+
if (!isOpen(root)) return;
|
|
615
|
+
event.preventDefault();
|
|
616
|
+
event.stopPropagation();
|
|
617
|
+
open(root, false);
|
|
618
|
+
} else if (
|
|
619
|
+
event.key === "Backspace" && root.classList.contains("combobox--multi") &&
|
|
620
|
+
event.target.matches("input[role='combobox']") && !event.target.value
|
|
621
|
+
) {
|
|
622
|
+
const last = root.querySelector(".combobox__field .combobox__token:last-of-type input");
|
|
623
|
+
if (last) removeToken(root, last.value);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
document.addEventListener("input", (event) => {
|
|
628
|
+
const root = event.target.closest?.("[data-combobox]");
|
|
629
|
+
if (!root || !event.target.matches("input[role='combobox']")) return;
|
|
630
|
+
const hidden = root.querySelector("[data-combobox-value]");
|
|
631
|
+
if (hidden) hidden.value = ""; // typing un-picks; a pick sets it again
|
|
632
|
+
filter(root);
|
|
633
|
+
open(root, true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
document.addEventListener("focusin", (event) => {
|
|
637
|
+
const root = event.target.closest?.("[data-combobox]");
|
|
638
|
+
if (root && event.target.matches("input[role='combobox']")) open(root, true);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
document.addEventListener("click", (event) => {
|
|
642
|
+
// A click on the field itself (the caret, the padding) — not on the
|
|
643
|
+
// input or a token button — focuses the input and toggles the list.
|
|
644
|
+
const field = event.target.closest?.("[data-combobox] .combobox__field");
|
|
645
|
+
if (field && !event.target.closest("input, button")) {
|
|
646
|
+
const root = field.closest("[data-combobox]");
|
|
647
|
+
const input = inputOf(root);
|
|
648
|
+
if (input && !input.disabled) {
|
|
649
|
+
if (isOpen(root)) open(root, false);
|
|
650
|
+
else {
|
|
651
|
+
input.focus();
|
|
652
|
+
open(root, true);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const opt = event.target.closest?.("[data-combobox] [role='option']");
|
|
659
|
+
if (opt) {
|
|
660
|
+
if (opt.getAttribute("aria-disabled") === "true") return;
|
|
661
|
+
const root = opt.closest("[data-combobox]");
|
|
662
|
+
if (root.classList.contains("combobox--multi")) addToken(root, opt);
|
|
663
|
+
else pickSingle(root, opt);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const remove = event.target.closest?.("[data-combobox-remove]");
|
|
668
|
+
if (remove) {
|
|
669
|
+
const root = remove.closest("[data-combobox]");
|
|
670
|
+
if (root) removeToken(root, remove.getAttribute("data-combobox-remove") ?? "");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
document.querySelectorAll("[data-combobox].combobox--open").forEach((root) => {
|
|
675
|
+
if (event.target.closest?.("[data-combobox]") !== root) open(root, false);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
})();
|
|
679
|
+
|
|
680
|
+
/* components/command/command.js */
|
|
681
|
+
/*
|
|
682
|
+
* Command palette: arrow-key navigation, Enter to run, Escape clears the
|
|
683
|
+
* query then closes, Cmd/Ctrl+K or any [data-command-open] opens the
|
|
684
|
+
* (non-inline) palette, overlay click closes it. Focus returns to
|
|
685
|
+
* whatever opened it, and Tab is kept inside the floating sheet.
|
|
686
|
+
*
|
|
687
|
+
* Filtering: with `data-command-action` on the input and rAPId's runtime
|
|
688
|
+
* present, each (debounced) keystroke swaps the server's CommandList
|
|
689
|
+
* fragment into the list. Otherwise the rendered items are filtered
|
|
690
|
+
* client-side — non-matches and empty groups hidden, count updated, the
|
|
691
|
+
* first hit made active.
|
|
692
|
+
*/
|
|
693
|
+
(() => {
|
|
694
|
+
const ACTIVE = "data-active";
|
|
695
|
+
const openers = new WeakMap();
|
|
696
|
+
|
|
697
|
+
const inputOf = (root) => root.querySelector(".command__input");
|
|
698
|
+
const options = (root) => Array.from(root.querySelectorAll('[role="option"]')).filter((o) => !o.hidden);
|
|
699
|
+
|
|
700
|
+
function activeIndex(root) {
|
|
701
|
+
return options(root).findIndex((o) => o.hasAttribute(ACTIVE));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function clearActive(root) {
|
|
705
|
+
root.querySelectorAll(`[${ACTIVE}]`).forEach((o) => o.removeAttribute(ACTIVE));
|
|
706
|
+
inputOf(root)?.removeAttribute("aria-activedescendant");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function setActive(root, index) {
|
|
710
|
+
const opts = options(root);
|
|
711
|
+
if (!opts.length) return;
|
|
712
|
+
const i = (index + opts.length) % opts.length;
|
|
713
|
+
clearActive(root);
|
|
714
|
+
opts[i].setAttribute(ACTIVE, "");
|
|
715
|
+
const input = inputOf(root);
|
|
716
|
+
if (input && opts[i].id) input.setAttribute("aria-activedescendant", opts[i].id);
|
|
717
|
+
const list = opts[i].closest(".command__list");
|
|
718
|
+
if (!list) return;
|
|
719
|
+
const top = opts[i].offsetTop;
|
|
720
|
+
if (top < list.scrollTop) list.scrollTop = top;
|
|
721
|
+
const bottom = top + opts[i].offsetHeight;
|
|
722
|
+
if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function filterLocal(root) {
|
|
726
|
+
const input = inputOf(root);
|
|
727
|
+
const list = root.querySelector(".command__list");
|
|
728
|
+
if (!input || !list) return;
|
|
729
|
+
const q = input.value.trim().toLowerCase();
|
|
730
|
+
|
|
731
|
+
let visible = 0;
|
|
732
|
+
root.querySelectorAll('[role="option"]').forEach((o) => {
|
|
733
|
+
const label = o.querySelector(".command__item-label")?.textContent ?? o.textContent;
|
|
734
|
+
const hit = !q || label.toLowerCase().includes(q);
|
|
735
|
+
o.hidden = !hit;
|
|
736
|
+
if (hit) visible++;
|
|
737
|
+
});
|
|
738
|
+
clearActive(root);
|
|
739
|
+
|
|
740
|
+
root.querySelectorAll(".command__group").forEach((group) => {
|
|
741
|
+
let el = group.nextElementSibling;
|
|
742
|
+
let any = false;
|
|
743
|
+
while (el && !el.classList.contains("command__group")) {
|
|
744
|
+
if (el.matches('[role="option"]') && !el.hidden) {
|
|
745
|
+
any = true;
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
el = el.nextElementSibling;
|
|
749
|
+
}
|
|
750
|
+
group.hidden = !any;
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const count = root.querySelector(".command__count");
|
|
754
|
+
if (count) count.textContent = visible === 1 ? "1 result" : `${visible} results`;
|
|
755
|
+
|
|
756
|
+
let empty = list.querySelector(".command__empty");
|
|
757
|
+
if (!empty) {
|
|
758
|
+
empty = document.createElement("div");
|
|
759
|
+
empty.className = "command__empty";
|
|
760
|
+
empty.textContent = "Nothing matches that.";
|
|
761
|
+
list.append(empty);
|
|
762
|
+
}
|
|
763
|
+
empty.hidden = visible > 0;
|
|
764
|
+
if (visible) setActive(root, 0);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const timers = new WeakMap();
|
|
768
|
+
function filter(root) {
|
|
769
|
+
const input = inputOf(root);
|
|
770
|
+
const action = input?.getAttribute("data-command-action");
|
|
771
|
+
const target = input?.getAttribute("data-command-target");
|
|
772
|
+
if (action && target && globalThis.rapid?.swap) {
|
|
773
|
+
clearTimeout(timers.get(root));
|
|
774
|
+
timers.set(
|
|
775
|
+
root,
|
|
776
|
+
setTimeout(() => {
|
|
777
|
+
const url = `${action}${action.includes("?") ? "&" : "?"}q=${encodeURIComponent(input.value.trim())}`;
|
|
778
|
+
globalThis.rapid.swap(url, target).then(() => {
|
|
779
|
+
clearActive(root);
|
|
780
|
+
if (options(root).length) setActive(root, 0);
|
|
781
|
+
});
|
|
782
|
+
}, 150),
|
|
783
|
+
);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
filterLocal(root);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const isFloating = (root) => !root.classList.contains("command--inline");
|
|
790
|
+
|
|
791
|
+
function close(root) {
|
|
792
|
+
if (!isFloating(root)) return;
|
|
793
|
+
root.hidden = true;
|
|
794
|
+
const opener = openers.get(root);
|
|
795
|
+
openers.delete(root);
|
|
796
|
+
if (opener?.isConnected) opener.focus();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/* Escape / the esc affordance: clear a query first, then close. */
|
|
800
|
+
function dismiss(root) {
|
|
801
|
+
const input = inputOf(root);
|
|
802
|
+
if (input && input.value) {
|
|
803
|
+
input.value = "";
|
|
804
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
805
|
+
} else {
|
|
806
|
+
close(root);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function open(palette, opener) {
|
|
811
|
+
openers.set(palette, opener ?? document.activeElement);
|
|
812
|
+
palette.hidden = false;
|
|
813
|
+
inputOf(palette)?.focus();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
document.addEventListener("keydown", (event) => {
|
|
817
|
+
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
|
818
|
+
const palette = document.querySelector("[data-command]:not(.command--inline)");
|
|
819
|
+
if (!palette) return;
|
|
820
|
+
event.preventDefault();
|
|
821
|
+
open(palette);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const root = event.target.closest?.("[data-command]");
|
|
826
|
+
if (!root) return;
|
|
827
|
+
|
|
828
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
829
|
+
event.preventDefault();
|
|
830
|
+
setActive(root, activeIndex(root) + (event.key === "ArrowDown" ? 1 : -1));
|
|
831
|
+
} else if (event.key === "Enter") {
|
|
832
|
+
const i = activeIndex(root);
|
|
833
|
+
const opts = options(root);
|
|
834
|
+
if (i >= 0 && opts[i]) {
|
|
835
|
+
event.preventDefault();
|
|
836
|
+
opts[i].click();
|
|
837
|
+
}
|
|
838
|
+
} else if (event.key === "Escape") {
|
|
839
|
+
if (!isFloating(root) && !inputOf(root)?.value) return; // nothing to do; let it bubble
|
|
840
|
+
event.preventDefault();
|
|
841
|
+
event.stopPropagation();
|
|
842
|
+
dismiss(root);
|
|
843
|
+
} else if (event.key === "Tab" && isFloating(root)) {
|
|
844
|
+
// Keep focus inside the sheet (it is aria-modal).
|
|
845
|
+
const focusables = Array.from(
|
|
846
|
+
root.querySelectorAll("input, button, a[href], [tabindex]:not([tabindex='-1'])"),
|
|
847
|
+
).filter((el) => !el.hidden && el.offsetParent !== null);
|
|
848
|
+
if (!focusables.length) return;
|
|
849
|
+
const first = focusables[0];
|
|
850
|
+
const last = focusables[focusables.length - 1];
|
|
851
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
852
|
+
event.preventDefault();
|
|
853
|
+
last.focus();
|
|
854
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
855
|
+
event.preventDefault();
|
|
856
|
+
first.focus();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
document.addEventListener("input", (event) => {
|
|
862
|
+
const root = event.target.closest?.("[data-command]");
|
|
863
|
+
if (root && event.target.matches(".command__input")) filter(root);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
document.addEventListener("click", (event) => {
|
|
867
|
+
/* [data-command-open="#id"] (or bare: the page's floating palette) */
|
|
868
|
+
const opener = event.target.closest?.("[data-command-open]");
|
|
869
|
+
if (opener) {
|
|
870
|
+
const sel = opener.getAttribute("data-command-open");
|
|
871
|
+
const palette = sel
|
|
872
|
+
? document.querySelector(sel)
|
|
873
|
+
: document.querySelector("[data-command]:not(.command--inline)");
|
|
874
|
+
if (palette) {
|
|
875
|
+
event.preventDefault();
|
|
876
|
+
open(palette, opener);
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const btn = event.target.closest?.("[data-command-dismiss]");
|
|
882
|
+
if (btn) {
|
|
883
|
+
const root = btn.closest("[data-command]");
|
|
884
|
+
if (root) dismiss(root);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/* Running an item closes a floating palette. */
|
|
889
|
+
const item = event.target.closest?.("[data-command] [role='option']");
|
|
890
|
+
if (item) {
|
|
891
|
+
const root = item.closest("[data-command]");
|
|
892
|
+
if (root && isFloating(root)) close(root);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/* Overlay click (outside the sheet) closes a floating palette. */
|
|
897
|
+
const overlay = event.target.closest?.("[data-command]:not(.command--inline)");
|
|
898
|
+
if (overlay && !event.target.closest(".command__sheet")) close(overlay);
|
|
899
|
+
});
|
|
900
|
+
})();
|
|
901
|
+
|
|
902
|
+
/* components/data-table/data-table.js */
|
|
903
|
+
/*
|
|
904
|
+
* Data table: row selection is a pure view concern until submitted, so it
|
|
905
|
+
* lives here rather than round-tripping. The checkboxes are the source of
|
|
906
|
+
* truth the server reads back on submit; the bulk bar just mirrors them.
|
|
907
|
+
* Runs once on load and after every rAPId swap.
|
|
908
|
+
*/
|
|
909
|
+
(() => {
|
|
910
|
+
function refresh(table) {
|
|
911
|
+
const bar = table.querySelector("[data-bulk-bar]");
|
|
912
|
+
const rows = table.querySelectorAll("[data-select-row]");
|
|
913
|
+
const checked = table.querySelectorAll("[data-select-row]:checked").length;
|
|
914
|
+
|
|
915
|
+
if (bar) {
|
|
916
|
+
bar.hidden = checked === 0;
|
|
917
|
+
const count = bar.querySelector("[data-bulk-count]");
|
|
918
|
+
if (count) count.textContent = checked === 1 ? "1 row selected" : `${checked} rows selected`;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const all = table.querySelector("[data-select-all]");
|
|
922
|
+
if (all) {
|
|
923
|
+
all.checked = checked > 0 && checked === rows.length;
|
|
924
|
+
all.indeterminate = checked > 0 && checked < rows.length;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function setRow(box, on) {
|
|
929
|
+
box.checked = on;
|
|
930
|
+
box.closest("tr")?.classList.toggle("data-table__row--selected", on);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
document.addEventListener("change", (event) => {
|
|
934
|
+
const table = event.target.closest?.(".data-table");
|
|
935
|
+
if (!table) return;
|
|
936
|
+
if (event.target.matches("[data-select-all]")) {
|
|
937
|
+
table.querySelectorAll("[data-select-row]").forEach((box) => setRow(box, event.target.checked));
|
|
938
|
+
} else if (event.target.matches("[data-select-row]")) {
|
|
939
|
+
setRow(event.target, event.target.checked);
|
|
940
|
+
} else {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
refresh(table);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
document.addEventListener("click", (event) => {
|
|
947
|
+
const clear = event.target.closest?.("[data-bulk-clear]");
|
|
948
|
+
if (!clear) return;
|
|
949
|
+
const table = clear.closest(".data-table");
|
|
950
|
+
if (!table) return;
|
|
951
|
+
table.querySelectorAll("[data-select-row]").forEach((box) => setRow(box, false));
|
|
952
|
+
refresh(table);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
function initAll() {
|
|
956
|
+
document.querySelectorAll(".data-table").forEach(refresh);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (document.readyState === "loading") {
|
|
960
|
+
document.addEventListener("DOMContentLoaded", initAll);
|
|
961
|
+
} else {
|
|
962
|
+
initAll();
|
|
963
|
+
}
|
|
964
|
+
document.addEventListener("rapid:swapped", initAll);
|
|
965
|
+
})();
|
|
966
|
+
|
|
967
|
+
/* components/datepicker/datepicker.js */
|
|
968
|
+
/*
|
|
969
|
+
* Date picker.
|
|
970
|
+
*
|
|
971
|
+
* Always: toggle the panel from its trigger, close on Escape (scoped to
|
|
972
|
+
* this picker) or outside click, focus back to the trigger on close.
|
|
973
|
+
*
|
|
974
|
+
* Server mode (`data-datepicker-server`): month nav, day picks and
|
|
975
|
+
* presets are GET links carrying data-action/data-target — rAPId swaps
|
|
976
|
+
* the re-rendered DatePicker in; nothing here computes dates.
|
|
977
|
+
*
|
|
978
|
+
* Client mode (no build*Href): the calendar runs here — month nav
|
|
979
|
+
* re-renders the day grid, a pick writes the hidden input(s) and the
|
|
980
|
+
* trigger label, and in range mode the first pick is the start and the
|
|
981
|
+
* second the end. Same classes and ARIA as the server-rendered panel.
|
|
982
|
+
*/
|
|
983
|
+
(() => {
|
|
984
|
+
const MONTHS = [
|
|
985
|
+
"January",
|
|
986
|
+
"February",
|
|
987
|
+
"March",
|
|
988
|
+
"April",
|
|
989
|
+
"May",
|
|
990
|
+
"June",
|
|
991
|
+
"July",
|
|
992
|
+
"August",
|
|
993
|
+
"September",
|
|
994
|
+
"October",
|
|
995
|
+
"November",
|
|
996
|
+
"December",
|
|
997
|
+
];
|
|
998
|
+
const SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
999
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1000
|
+
const iso = (y, m, d) => `${y}-${pad(m + 1)}-${pad(d)}`;
|
|
1001
|
+
const fmt = (v) => {
|
|
1002
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v ?? "");
|
|
1003
|
+
return m ? `${Number(m[3])} ${SHORT[Number(m[2]) - 1]} ${m[1]}` : "";
|
|
1004
|
+
};
|
|
1005
|
+
const todayIso = () => {
|
|
1006
|
+
const t = new Date();
|
|
1007
|
+
return iso(t.getFullYear(), t.getMonth(), t.getDate());
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
function setOpen(dp, on, restoreFocus) {
|
|
1011
|
+
const panel = dp.querySelector(".datepicker__panel");
|
|
1012
|
+
const trigger = dp.querySelector("[data-datepicker-trigger]");
|
|
1013
|
+
if (panel) panel.hidden = !on;
|
|
1014
|
+
dp.classList.toggle("datepicker--open", !!on);
|
|
1015
|
+
if (trigger) {
|
|
1016
|
+
trigger.setAttribute("aria-expanded", on ? "true" : "false");
|
|
1017
|
+
if (!on && restoreFocus) trigger.focus();
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/* ------------------------------------------------------ client mode */
|
|
1022
|
+
function state(dp) {
|
|
1023
|
+
return {
|
|
1024
|
+
range: dp.hasAttribute("data-datepicker-range"),
|
|
1025
|
+
min: dp.getAttribute("data-datepicker-min") || "",
|
|
1026
|
+
max: dp.getAttribute("data-datepicker-max") || "",
|
|
1027
|
+
today: dp.getAttribute("data-datepicker-today") || todayIso(),
|
|
1028
|
+
start: dp.querySelector("[data-datepicker-start]")?.value || "",
|
|
1029
|
+
end: dp.querySelector("[data-datepicker-end]")?.value || "",
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function renderDays(dp, year, month) {
|
|
1034
|
+
const s = state(dp);
|
|
1035
|
+
const days = dp.querySelector(".datepicker__days");
|
|
1036
|
+
const heading = dp.querySelector(".datepicker__month");
|
|
1037
|
+
if (!days) return;
|
|
1038
|
+
days.replaceChildren();
|
|
1039
|
+
if (heading) heading.textContent = `${MONTHS[month]} ${year}`;
|
|
1040
|
+
dp.setAttribute("data-datepicker-view", `${year}-${month}`);
|
|
1041
|
+
|
|
1042
|
+
const len = new Date(year, month + 1, 0).getDate();
|
|
1043
|
+
const lead = (new Date(year, month, 1).getDay() + 6) % 7;
|
|
1044
|
+
const prevLen = new Date(year, month, 0).getDate();
|
|
1045
|
+
const cells = Math.ceil((lead + len) / 7) * 7;
|
|
1046
|
+
|
|
1047
|
+
for (let i = 0; i < cells; i++) {
|
|
1048
|
+
const offset = i - lead + 1;
|
|
1049
|
+
let y = year, m = month, d = offset;
|
|
1050
|
+
if (offset < 1) {
|
|
1051
|
+
m = month - 1;
|
|
1052
|
+
d = prevLen + offset;
|
|
1053
|
+
if (m < 0) {
|
|
1054
|
+
m = 11;
|
|
1055
|
+
y--;
|
|
1056
|
+
}
|
|
1057
|
+
} else if (offset > len) {
|
|
1058
|
+
m = month + 1;
|
|
1059
|
+
d = offset - len;
|
|
1060
|
+
if (m > 11) {
|
|
1061
|
+
m = 0;
|
|
1062
|
+
y++;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const key = iso(y, m, d);
|
|
1066
|
+
const btn = document.createElement("button");
|
|
1067
|
+
btn.type = "button";
|
|
1068
|
+
btn.className = "datepicker__day";
|
|
1069
|
+
if (offset < 1 || offset > len) btn.classList.add("datepicker__day--outside");
|
|
1070
|
+
if (key === s.today) {
|
|
1071
|
+
btn.classList.add("datepicker__day--today");
|
|
1072
|
+
btn.setAttribute("aria-current", "date");
|
|
1073
|
+
}
|
|
1074
|
+
if (s.start && s.end && key > s.start && key < s.end) btn.classList.add("datepicker__day--in-range");
|
|
1075
|
+
if (key === s.start) btn.classList.add("datepicker__day--start");
|
|
1076
|
+
if (key === s.end) btn.classList.add("datepicker__day--end");
|
|
1077
|
+
if (key === s.start || key === s.end) btn.setAttribute("aria-pressed", "true");
|
|
1078
|
+
if ((s.min && key < s.min) || (s.max && key > s.max)) btn.disabled = true;
|
|
1079
|
+
btn.setAttribute("data-day", key);
|
|
1080
|
+
btn.setAttribute("aria-label", `${d} ${MONTHS[m]} ${y}`);
|
|
1081
|
+
btn.textContent = String(d);
|
|
1082
|
+
days.append(btn);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function currentView(dp) {
|
|
1087
|
+
const v = dp.getAttribute("data-datepicker-view");
|
|
1088
|
+
if (v) {
|
|
1089
|
+
const [y, m] = v.split("-").map(Number);
|
|
1090
|
+
return [y, m];
|
|
1091
|
+
}
|
|
1092
|
+
const s = state(dp);
|
|
1093
|
+
const anchor = s.start || s.today;
|
|
1094
|
+
return [Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1];
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function pick(dp, key) {
|
|
1098
|
+
const s = state(dp);
|
|
1099
|
+
const startInput = dp.querySelector("[data-datepicker-start]");
|
|
1100
|
+
const endInput = dp.querySelector("[data-datepicker-end]");
|
|
1101
|
+
const label = dp.querySelector("[data-datepicker-label]");
|
|
1102
|
+
let start = key, end = "";
|
|
1103
|
+
if (s.range) {
|
|
1104
|
+
if (s.start && !s.end && key >= s.start) {
|
|
1105
|
+
start = s.start;
|
|
1106
|
+
end = key;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (startInput) startInput.value = start;
|
|
1110
|
+
if (endInput) endInput.value = end;
|
|
1111
|
+
if (label) {
|
|
1112
|
+
label.textContent = s.range ? `${fmt(start) || "Start date"} – ${fmt(end) || "End date"}` : fmt(start);
|
|
1113
|
+
}
|
|
1114
|
+
startInput?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1115
|
+
const [y, m] = currentView(dp);
|
|
1116
|
+
renderDays(dp, y, m);
|
|
1117
|
+
if (!s.range || end) setOpen(dp, false, true);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/* ----------------------------------------------------------- events */
|
|
1121
|
+
document.addEventListener("click", (event) => {
|
|
1122
|
+
const trigger = event.target.closest?.("[data-datepicker-trigger]");
|
|
1123
|
+
if (trigger) {
|
|
1124
|
+
const dp = trigger.closest("[data-datepicker]");
|
|
1125
|
+
const panel = dp?.querySelector(".datepicker__panel");
|
|
1126
|
+
if (panel) setOpen(dp, panel.hidden);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const dp = event.target.closest?.("[data-datepicker]");
|
|
1131
|
+
if (dp && !dp.hasAttribute("data-datepicker-server")) {
|
|
1132
|
+
const day = event.target.closest("button[data-day]");
|
|
1133
|
+
if (day && !day.disabled) {
|
|
1134
|
+
pick(dp, day.getAttribute("data-day"));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const nav = event.target.closest("button[data-month]");
|
|
1138
|
+
if (nav) {
|
|
1139
|
+
const [y, m] = nav.getAttribute("data-month").split("-").map(Number);
|
|
1140
|
+
renderDays(dp, y, m);
|
|
1141
|
+
// Every nav button's target moves with the view.
|
|
1142
|
+
const targets = {
|
|
1143
|
+
"prev-year": [y - 1, m],
|
|
1144
|
+
prev: m === 0 ? [y - 1, 11] : [y, m - 1],
|
|
1145
|
+
next: m === 11 ? [y + 1, 0] : [y, m + 1],
|
|
1146
|
+
"next-year": [y + 1, m],
|
|
1147
|
+
};
|
|
1148
|
+
dp.querySelectorAll("button[data-month][data-nav]").forEach((btn) => {
|
|
1149
|
+
const t = targets[btn.getAttribute("data-nav")];
|
|
1150
|
+
if (t) btn.setAttribute("data-month", `${t[0]}-${t[1]}`);
|
|
1151
|
+
});
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Outside click closes floating panels only — an inline picker is
|
|
1157
|
+
// page content (a docs or settings page that always shows the
|
|
1158
|
+
// calendar); its trigger still toggles it.
|
|
1159
|
+
document.querySelectorAll("[data-datepicker]:not(.datepicker--inline) .datepicker__panel:not([hidden])").forEach(
|
|
1160
|
+
(panel) => {
|
|
1161
|
+
const owner = panel.closest("[data-datepicker]");
|
|
1162
|
+
if (owner !== dp) setOpen(owner, false);
|
|
1163
|
+
},
|
|
1164
|
+
);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
document.addEventListener("keydown", (event) => {
|
|
1168
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
1169
|
+
const dp = event.target.closest?.("[data-datepicker]");
|
|
1170
|
+
const panel = dp?.querySelector(".datepicker__panel");
|
|
1171
|
+
if (!panel || panel.hidden || dp.classList.contains("datepicker--inline")) return;
|
|
1172
|
+
event.preventDefault();
|
|
1173
|
+
setOpen(dp, false, true);
|
|
1174
|
+
});
|
|
1175
|
+
})();
|
|
1176
|
+
|
|
1177
|
+
/* components/dropdown/dropdown.js */
|
|
1178
|
+
/*
|
|
1179
|
+
* Dropdown: [data-toggle] (shared/js/toggle.js) opens the panel; this
|
|
1180
|
+
* closes it on outside click, on choosing an item (a link, button or
|
|
1181
|
+
* rAPId action inside the panel), and on Escape — scoped to the dropdown
|
|
1182
|
+
* that has focus, falling back to all open ones. Focus returns to the
|
|
1183
|
+
* trigger on Escape.
|
|
1184
|
+
*
|
|
1185
|
+
* Inside a scroll container (a table body, a card with overflow) an
|
|
1186
|
+
* absolutely-positioned panel gets clipped, so the panel is switched to
|
|
1187
|
+
* fixed positioning at the trigger's viewport rect (set through the
|
|
1188
|
+
* CSSOM, which strict CSP allows — unlike a style *attribute*). Any
|
|
1189
|
+
* scroll or resize closes it rather than trying to track the trigger.
|
|
1190
|
+
*/
|
|
1191
|
+
(() => {
|
|
1192
|
+
const openPanels = () => document.querySelectorAll(".dropdown__panel.is-open");
|
|
1193
|
+
const triggerFor = (panel) => document.querySelector(`[data-toggle="#${CSS.escape(panel.id)}"]`);
|
|
1194
|
+
const CLIPPERS = ".data-table__scroll, [data-dropdown-clip]";
|
|
1195
|
+
|
|
1196
|
+
function place(panel, trigger) {
|
|
1197
|
+
if (!trigger?.closest(CLIPPERS)) return;
|
|
1198
|
+
const r = trigger.getBoundingClientRect();
|
|
1199
|
+
panel.classList.add("dropdown__panel--fixed");
|
|
1200
|
+
const width = panel.offsetWidth;
|
|
1201
|
+
const alignEnd = panel.classList.contains("dropdown__panel--end");
|
|
1202
|
+
let left = alignEnd ? r.right - width : r.left;
|
|
1203
|
+
left = Math.max(8, Math.min(left, innerWidth - width - 8));
|
|
1204
|
+
let top = r.bottom + 4;
|
|
1205
|
+
if (top + panel.offsetHeight > innerHeight - 8) top = Math.max(8, r.top - panel.offsetHeight - 4);
|
|
1206
|
+
panel.style.left = `${left}px`;
|
|
1207
|
+
panel.style.top = `${top}px`;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function close(panel, trigger) {
|
|
1211
|
+
panel.setAttribute("hidden", "");
|
|
1212
|
+
panel.classList.remove("is-open", "dropdown__panel--fixed");
|
|
1213
|
+
panel.style.removeProperty("left");
|
|
1214
|
+
panel.style.removeProperty("top");
|
|
1215
|
+
trigger?.setAttribute("aria-expanded", "false");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
document.addEventListener("click", (event) => {
|
|
1219
|
+
openPanels().forEach((panel) => {
|
|
1220
|
+
const trigger = triggerFor(panel);
|
|
1221
|
+
if (trigger?.contains(event.target)) {
|
|
1222
|
+
// toggle.js just opened it (its listener runs first) — place it.
|
|
1223
|
+
if (!panel.classList.contains("dropdown__panel--fixed")) place(panel, trigger);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (panel.contains(event.target)) {
|
|
1227
|
+
// A choice was made — close, unless the click was on something
|
|
1228
|
+
// that keeps the panel useful (a nested toggle, a form field).
|
|
1229
|
+
const item = event.target.closest("a[href], button, [data-action]");
|
|
1230
|
+
if (!item || item.hasAttribute("data-toggle")) return;
|
|
1231
|
+
}
|
|
1232
|
+
close(panel, trigger);
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
document.addEventListener("keydown", (event) => {
|
|
1237
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
1238
|
+
const own = event.target.closest?.(".dropdown");
|
|
1239
|
+
const panels = own ? own.querySelectorAll(".dropdown__panel.is-open") : openPanels();
|
|
1240
|
+
let closed = false;
|
|
1241
|
+
panels.forEach((panel) => {
|
|
1242
|
+
const trigger = triggerFor(panel);
|
|
1243
|
+
close(panel, trigger);
|
|
1244
|
+
trigger?.focus();
|
|
1245
|
+
closed = true;
|
|
1246
|
+
});
|
|
1247
|
+
if (closed) event.preventDefault();
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
const closeFixed = () => {
|
|
1251
|
+
document.querySelectorAll(".dropdown__panel--fixed.is-open").forEach((panel) => close(panel, triggerFor(panel)));
|
|
1252
|
+
};
|
|
1253
|
+
addEventListener("scroll", closeFixed, { capture: true, passive: true });
|
|
1254
|
+
addEventListener("resize", closeFixed, { passive: true });
|
|
1255
|
+
})();
|
|
1256
|
+
|
|
1257
|
+
/* components/dropzone/dropzone.js */
|
|
1258
|
+
/*
|
|
1259
|
+
* Dropzone: drag-over highlight and client-side removal of a pending row
|
|
1260
|
+
* ([data-dropzone-remove]). The actual upload is the form's job — the
|
|
1261
|
+
* real <input type="file"> covers the area, so click and keyboard open
|
|
1262
|
+
* the picker with no script at all.
|
|
1263
|
+
*/
|
|
1264
|
+
(() => {
|
|
1265
|
+
for (const type of ["dragenter", "dragover"]) {
|
|
1266
|
+
document.addEventListener(type, (event) => {
|
|
1267
|
+
const area = event.target.closest?.(".dropzone__area");
|
|
1268
|
+
if (!area) return;
|
|
1269
|
+
event.preventDefault();
|
|
1270
|
+
area.classList.add("dropzone__area--over");
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
for (const type of ["dragleave", "drop"]) {
|
|
1275
|
+
document.addEventListener(type, (event) => {
|
|
1276
|
+
event.target.closest?.(".dropzone__area")?.classList.remove("dropzone__area--over");
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
document.addEventListener("click", (event) => {
|
|
1281
|
+
const remove = event.target.closest?.("[data-dropzone-remove]");
|
|
1282
|
+
if (remove) remove.closest(".dropzone__file")?.remove();
|
|
1283
|
+
});
|
|
1284
|
+
})();
|
|
1285
|
+
|
|
1286
|
+
/* components/editor/editor.js */
|
|
1287
|
+
/* Editor: markdown mode toggles syntax around/before the textarea's
|
|
1288
|
+
* selection and swaps a server-rendered preview in through the runtime;
|
|
1289
|
+
* html mode drives a contenteditable surface with execCommand (every
|
|
1290
|
+
* command toggles, block formats included) and mirrors its HTML into the
|
|
1291
|
+
* textarea that submits. Toolbar buttons reflect the selection's state.
|
|
1292
|
+
* Delegated on document, keyed off data-editor-*; initAll re-runs after
|
|
1293
|
+
* a swap. */
|
|
1294
|
+
(() => {
|
|
1295
|
+
const WRAP = { bold: ["**", "**"], italic: ["_", "_"], strike: ["~~", "~~"], code: ["`", "`"] };
|
|
1296
|
+
const PREFIX = { heading: "## ", quote: "> ", ul: "- ", ol: "1. " };
|
|
1297
|
+
const BLOCK = { heading: "h2", quote: "blockquote", code: "pre" };
|
|
1298
|
+
const INLINE = { bold: "bold", italic: "italic", underline: "underline", strike: "strikeThrough" };
|
|
1299
|
+
const LIST = { ul: "insertUnorderedList", ol: "insertOrderedList" };
|
|
1300
|
+
|
|
1301
|
+
const sourceOf = (root) => root.querySelector(".editor__source");
|
|
1302
|
+
const surfaceOf = (root) => root.querySelector("[data-editor-surface]");
|
|
1303
|
+
const isHtml = (root) => root.getAttribute("data-editor-mode") === "html";
|
|
1304
|
+
|
|
1305
|
+
/* ------------------------------------------------------- markdown */
|
|
1306
|
+
function replace(ta, start, end, text, selStart, selEnd) {
|
|
1307
|
+
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
|
|
1308
|
+
ta.setSelectionRange(start + selStart, start + selEnd);
|
|
1309
|
+
ta.focus();
|
|
1310
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function lineRange(ta) {
|
|
1314
|
+
const start = ta.value.lastIndexOf("\n", ta.selectionStart - 1) + 1;
|
|
1315
|
+
const nl = ta.value.indexOf("\n", Math.max(ta.selectionEnd, start));
|
|
1316
|
+
const end = ta.selectionEnd > ta.selectionStart && ta.value[ta.selectionEnd - 1] === "\n"
|
|
1317
|
+
? ta.selectionEnd - 1
|
|
1318
|
+
: nl === -1
|
|
1319
|
+
? ta.value.length
|
|
1320
|
+
: nl;
|
|
1321
|
+
return [start, end];
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const STRIP_LINE = /^(#{1,6} |> |- |\* |\d+\. )+/;
|
|
1325
|
+
|
|
1326
|
+
function markdown(ta, cmd) {
|
|
1327
|
+
const start = ta.selectionStart;
|
|
1328
|
+
const end = ta.selectionEnd;
|
|
1329
|
+
const sel = ta.value.slice(start, end);
|
|
1330
|
+
|
|
1331
|
+
if (WRAP[cmd]) {
|
|
1332
|
+
const [a, z] = WRAP[cmd];
|
|
1333
|
+
const multi = cmd === "code" && sel.includes("\n");
|
|
1334
|
+
const open = multi ? "```\n" : a;
|
|
1335
|
+
const close = multi ? "\n```" : z;
|
|
1336
|
+
// Toggle: unwrap when the selection (or its surroundings) already carries the syntax.
|
|
1337
|
+
if (sel.startsWith(open) && sel.endsWith(close) && sel.length >= open.length + close.length) {
|
|
1338
|
+
const inner = sel.slice(open.length, sel.length - close.length);
|
|
1339
|
+
replace(ta, start, end, inner, 0, inner.length);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (ta.value.slice(start - open.length, start) === open && ta.value.slice(end, end + close.length) === close) {
|
|
1343
|
+
replace(ta, start - open.length, end + close.length, sel, 0, sel.length);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const body = sel || cmd;
|
|
1347
|
+
replace(ta, start, end, open + body + close, open.length, open.length + body.length);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (cmd === "link" || cmd === "image") {
|
|
1351
|
+
const text = sel || (cmd === "image" ? "alt text" : "text");
|
|
1352
|
+
const out = `${cmd === "image" ? "!" : ""}[${text}](url)`;
|
|
1353
|
+
replace(ta, start, end, out, out.length - 4, out.length - 1);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (cmd === "hr") {
|
|
1357
|
+
const before = start === 0 || ta.value[start - 1] === "\n" ? "" : "\n";
|
|
1358
|
+
const out = `${before}\n---\n\n`;
|
|
1359
|
+
replace(ta, start, end, out, out.length, out.length);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const [ls, le] = lineRange(ta);
|
|
1363
|
+
const lines = ta.value.slice(ls, le).split("\n");
|
|
1364
|
+
if (cmd === "clear") {
|
|
1365
|
+
const out = lines.map((l) => l.replace(STRIP_LINE, "")).join("\n")
|
|
1366
|
+
.replace(/!\[(.*?)\]\(\S+?\)/g, "$1")
|
|
1367
|
+
.replace(/\[(.+?)\]\(\S+?\)/g, "$1")
|
|
1368
|
+
.replace(/(\*\*|__|~~|`)(.+?)\1/g, "$2")
|
|
1369
|
+
.replace(/(^|[^\w])_(.+?)_(?=[^\w]|$)/g, "$1$2");
|
|
1370
|
+
replace(ta, ls, le, out, 0, out.length);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const prefix = PREFIX[cmd];
|
|
1374
|
+
if (!prefix) return;
|
|
1375
|
+
const has = (l) => cmd === "ol" ? /^\d+\. /.test(l) : l.startsWith(prefix);
|
|
1376
|
+
const allOn = lines.every((l) => l.trim() === "" || has(l));
|
|
1377
|
+
const out = lines.map((l, n) => {
|
|
1378
|
+
const bare = l.replace(STRIP_LINE, "");
|
|
1379
|
+
if (allOn) return bare; // toggle off
|
|
1380
|
+
if (l.trim() === "") return l;
|
|
1381
|
+
return (cmd === "ol" ? `${n + 1}. ` : prefix) + bare;
|
|
1382
|
+
}).join("\n");
|
|
1383
|
+
replace(ta, ls, le, out, 0, out.length);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function preview(root, on) {
|
|
1387
|
+
const panel = root.querySelector("[data-editor-preview-panel]");
|
|
1388
|
+
const ta = sourceOf(root);
|
|
1389
|
+
root.querySelectorAll("[data-editor-view]").forEach((b) => {
|
|
1390
|
+
b.setAttribute("aria-pressed", String(b.getAttribute("data-editor-view") === (on ? "preview" : "write")));
|
|
1391
|
+
});
|
|
1392
|
+
if (!on) {
|
|
1393
|
+
root.removeAttribute("data-editor-view");
|
|
1394
|
+
if (panel) panel.hidden = true;
|
|
1395
|
+
ta?.focus();
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
root.setAttribute("data-editor-view", "preview");
|
|
1399
|
+
if (!panel) return;
|
|
1400
|
+
panel.hidden = false;
|
|
1401
|
+
const action = root.getAttribute("data-editor-preview");
|
|
1402
|
+
if (action && globalThis.rapid?.swap) {
|
|
1403
|
+
panel.setAttribute("aria-busy", "true");
|
|
1404
|
+
globalThis.rapid.swap(action, panel, {
|
|
1405
|
+
method: "post",
|
|
1406
|
+
body: new URLSearchParams({ text: ta?.value ?? "" }).toString(),
|
|
1407
|
+
}).finally(() => panel.removeAttribute("aria-busy"));
|
|
1408
|
+
} else {
|
|
1409
|
+
// No server preview: say so, and show the source as written.
|
|
1410
|
+
panel.replaceChildren();
|
|
1411
|
+
const note = document.createElement("p");
|
|
1412
|
+
note.className = "editor__preview-note";
|
|
1413
|
+
note.textContent = "No preview route on this page — showing the Markdown source.";
|
|
1414
|
+
const pre = document.createElement("pre");
|
|
1415
|
+
pre.className = "editor__preview-raw";
|
|
1416
|
+
pre.textContent = ta?.value ?? "";
|
|
1417
|
+
panel.append(note, pre);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/* ----------------------------------------------------------- html */
|
|
1422
|
+
function sync(root) {
|
|
1423
|
+
const ta = sourceOf(root);
|
|
1424
|
+
const surface = surfaceOf(root);
|
|
1425
|
+
if (ta && surface) ta.value = surface.innerHTML;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const blockOf = () => String(document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, "");
|
|
1429
|
+
|
|
1430
|
+
function execute(root, cmd) {
|
|
1431
|
+
const surface = surfaceOf(root);
|
|
1432
|
+
if (!surface || surface.getAttribute("contenteditable") !== "true") return;
|
|
1433
|
+
surface.focus();
|
|
1434
|
+
if (INLINE[cmd]) document.execCommand(INLINE[cmd]);
|
|
1435
|
+
else if (LIST[cmd]) document.execCommand(LIST[cmd]);
|
|
1436
|
+
else if (BLOCK[cmd]) {
|
|
1437
|
+
// Toggle: the same block format again returns to a paragraph.
|
|
1438
|
+
document.execCommand("formatBlock", false, blockOf() === BLOCK[cmd] ? "<p>" : `<${BLOCK[cmd]}>`);
|
|
1439
|
+
} else if (cmd === "link") {
|
|
1440
|
+
const url = globalThis.prompt?.("Link URL", "https://");
|
|
1441
|
+
if (url) document.execCommand("createLink", false, url);
|
|
1442
|
+
} else if (cmd === "image") {
|
|
1443
|
+
const url = globalThis.prompt?.("Image URL", "https://");
|
|
1444
|
+
if (url) document.execCommand("insertImage", false, url);
|
|
1445
|
+
} else if (cmd === "hr") {
|
|
1446
|
+
document.execCommand("insertHorizontalRule");
|
|
1447
|
+
} else if (cmd === "clear") {
|
|
1448
|
+
document.execCommand("removeFormat");
|
|
1449
|
+
document.execCommand("unlink");
|
|
1450
|
+
if (document.queryCommandState("insertUnorderedList")) document.execCommand("insertUnorderedList");
|
|
1451
|
+
if (document.queryCommandState("insertOrderedList")) document.execCommand("insertOrderedList");
|
|
1452
|
+
if (blockOf() !== "p" && blockOf() !== "div") document.execCommand("formatBlock", false, "<p>");
|
|
1453
|
+
}
|
|
1454
|
+
sync(root);
|
|
1455
|
+
reflect(root);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** Toolbar state for the selection (html mode only — the browser knows). */
|
|
1459
|
+
function reflect(root) {
|
|
1460
|
+
if (!isHtml(root)) return;
|
|
1461
|
+
const block = blockOf();
|
|
1462
|
+
root.querySelectorAll("[data-editor-cmd]").forEach((btn) => {
|
|
1463
|
+
const cmd = btn.getAttribute("data-editor-cmd");
|
|
1464
|
+
let on = false;
|
|
1465
|
+
if (INLINE[cmd] || LIST[cmd]) on = document.queryCommandState(INLINE[cmd] ?? LIST[cmd]);
|
|
1466
|
+
else if (BLOCK[cmd]) on = block === BLOCK[cmd];
|
|
1467
|
+
btn.setAttribute("aria-pressed", String(on));
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function init(root) {
|
|
1472
|
+
if (root.dataset.editorReady) return;
|
|
1473
|
+
root.dataset.editorReady = "true";
|
|
1474
|
+
const ta = sourceOf(root);
|
|
1475
|
+
const surface = surfaceOf(root);
|
|
1476
|
+
if (surface && ta) {
|
|
1477
|
+
// The textarea holds the server's (sanitised) HTML; the surface
|
|
1478
|
+
// shows it. innerHTML here is the same trust as the markup itself.
|
|
1479
|
+
surface.innerHTML = ta.value;
|
|
1480
|
+
surface.setAttribute("contenteditable", ta.disabled ? "false" : "true");
|
|
1481
|
+
// Paragraphs, not <div>s, for new lines.
|
|
1482
|
+
document.execCommand("defaultParagraphSeparator", false, "p");
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const initAll = () => document.querySelectorAll("[data-editor]").forEach(init);
|
|
1487
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initAll);
|
|
1488
|
+
else initAll();
|
|
1489
|
+
document.addEventListener("rapid:swapped", initAll);
|
|
1490
|
+
|
|
1491
|
+
/* --------------------------------------------------------- events */
|
|
1492
|
+
document.addEventListener("click", (event) => {
|
|
1493
|
+
const tool = event.target.closest?.("[data-editor-cmd]");
|
|
1494
|
+
if (tool) {
|
|
1495
|
+
const root = tool.closest("[data-editor]");
|
|
1496
|
+
const cmd = tool.getAttribute("data-editor-cmd");
|
|
1497
|
+
if (isHtml(root)) execute(root, cmd);
|
|
1498
|
+
else {
|
|
1499
|
+
const ta = sourceOf(root);
|
|
1500
|
+
if (ta && !ta.disabled) markdown(ta, cmd);
|
|
1501
|
+
}
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const view = event.target.closest?.("[data-editor-view]");
|
|
1505
|
+
if (view) preview(view.closest("[data-editor]"), view.getAttribute("data-editor-view") === "preview");
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
document.addEventListener("input", (event) => {
|
|
1509
|
+
const surface = event.target.closest?.("[data-editor-surface]");
|
|
1510
|
+
if (surface) sync(surface.closest("[data-editor]"));
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
document.addEventListener("selectionchange", () => {
|
|
1514
|
+
const root = document.activeElement?.closest?.("[data-editor]");
|
|
1515
|
+
if (root) reflect(root);
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
document.addEventListener("keydown", (event) => {
|
|
1519
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
|
1520
|
+
const root = event.target.closest?.("[data-editor]");
|
|
1521
|
+
if (!root) return;
|
|
1522
|
+
const cmd = { b: "bold", i: "italic", u: "underline", k: "link" }[event.key.toLowerCase()];
|
|
1523
|
+
if (!cmd || (cmd === "underline" && !isHtml(root))) return;
|
|
1524
|
+
event.preventDefault();
|
|
1525
|
+
if (isHtml(root)) execute(root, cmd);
|
|
1526
|
+
else markdown(sourceOf(root), cmd);
|
|
1527
|
+
});
|
|
1528
|
+
})();
|
|
1529
|
+
|
|
1530
|
+
/* components/menu/menu.js */
|
|
1531
|
+
/*
|
|
1532
|
+
* Menu: mark the link matching the *full* current URL (path + hash) as
|
|
1533
|
+
* the current page, and open the sublist it sits in. The server can only
|
|
1534
|
+
* match on the path — a link like `settings.html#tab-billing` is one of
|
|
1535
|
+
* several children of the same page, and only the browser knows which
|
|
1536
|
+
* one the user followed. Runs on load and on every hash change.
|
|
1537
|
+
*/
|
|
1538
|
+
(() => {
|
|
1539
|
+
function sync() {
|
|
1540
|
+
const here = location.href;
|
|
1541
|
+
let matched = null;
|
|
1542
|
+
document.querySelectorAll(".menu__link[href]").forEach((link) => {
|
|
1543
|
+
if (!link.getAttribute("href")?.includes("#")) return;
|
|
1544
|
+
const target = new URL(link.getAttribute("href"), location.href).href;
|
|
1545
|
+
if (target === here) matched = link;
|
|
1546
|
+
});
|
|
1547
|
+
if (!matched) return;
|
|
1548
|
+
|
|
1549
|
+
matched.closest(".menu__list")?.querySelectorAll(".menu__link").forEach((link) => {
|
|
1550
|
+
if (link !== matched && link.getAttribute("href")?.includes("#")) {
|
|
1551
|
+
link.classList.remove("menu__link--active");
|
|
1552
|
+
link.removeAttribute("aria-current");
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
matched.classList.add("menu__link--active");
|
|
1556
|
+
matched.setAttribute("aria-current", "page");
|
|
1557
|
+
|
|
1558
|
+
const sublist = matched.closest(".menu__sublist");
|
|
1559
|
+
if (sublist) {
|
|
1560
|
+
sublist.hidden = false;
|
|
1561
|
+
sublist.classList.add("is-open");
|
|
1562
|
+
document.querySelector(`[data-toggle="#${CSS.escape(sublist.id)}"]`)
|
|
1563
|
+
?.setAttribute("aria-expanded", "true");
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
if (document.readyState === "loading") {
|
|
1568
|
+
document.addEventListener("DOMContentLoaded", sync);
|
|
1569
|
+
} else {
|
|
1570
|
+
sync();
|
|
1571
|
+
}
|
|
1572
|
+
addEventListener("hashchange", sync);
|
|
1573
|
+
document.addEventListener("rapid:swapped", sync);
|
|
1574
|
+
})();
|
|
1575
|
+
|
|
1576
|
+
/* components/modal/modal.js */
|
|
1577
|
+
/*
|
|
1578
|
+
* Modal: [data-modal-open="#id"] opens a native <dialog> with
|
|
1579
|
+
* showModal() (focus trap + ::backdrop for free), [data-modal-close]
|
|
1580
|
+
* closes it, and a click on the backdrop closes it — but only when the
|
|
1581
|
+
* pointer went DOWN on the backdrop too, so selecting text in a field
|
|
1582
|
+
* and releasing outside the dialog does not slam it shut.
|
|
1583
|
+
*/
|
|
1584
|
+
(() => {
|
|
1585
|
+
let downOnBackdrop = false;
|
|
1586
|
+
|
|
1587
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1588
|
+
downOnBackdrop = event.target instanceof HTMLDialogElement && event.target.open;
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
document.addEventListener("click", (event) => {
|
|
1592
|
+
const opener = event.target.closest?.("[data-modal-open]");
|
|
1593
|
+
if (opener) {
|
|
1594
|
+
const dialog = document.querySelector(opener.getAttribute("data-modal-open") ?? "");
|
|
1595
|
+
if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal();
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const closer = event.target.closest?.("[data-modal-close]");
|
|
1600
|
+
if (closer) {
|
|
1601
|
+
closer.closest("dialog")?.close();
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (downOnBackdrop && event.target instanceof HTMLDialogElement && event.target.open) {
|
|
1606
|
+
event.target.close();
|
|
1607
|
+
}
|
|
1608
|
+
downOnBackdrop = false;
|
|
1609
|
+
});
|
|
1610
|
+
})();
|
|
1611
|
+
|
|
1612
|
+
/* components/otp/otp.js */
|
|
1613
|
+
/*
|
|
1614
|
+
* One-time code cells. Everything here is an enhancement over the single
|
|
1615
|
+
* .otp__value input that the markup submits:
|
|
1616
|
+
*
|
|
1617
|
+
* - typing a character fills the cell and advances; Backspace on an
|
|
1618
|
+
* empty cell walks back and clears; Arrow keys move; Delete clears
|
|
1619
|
+
* - a paste, an SMS autofill or a password-manager fill that lands more
|
|
1620
|
+
* than one character in a cell is spread across the cells from there
|
|
1621
|
+
* - the carrier input is kept in sync (it is what the form submits) and
|
|
1622
|
+
* taken out of the tab order; on load the cells mirror a prefilled
|
|
1623
|
+
* carrier value (a re-render after a failed attempt)
|
|
1624
|
+
* - when every cell is filled: `otp:complete` bubbles from the root
|
|
1625
|
+
* (detail: { value }), and with [data-otp-submit] the surrounding
|
|
1626
|
+
* form is submitted via requestSubmit() so rAPId's submit handler
|
|
1627
|
+
* sees it like any other submit.
|
|
1628
|
+
*
|
|
1629
|
+
* Delegated from document; re-syncs after every rAPId swap.
|
|
1630
|
+
*/
|
|
1631
|
+
(() => {
|
|
1632
|
+
const cellsOf = (root) => Array.from(root.querySelectorAll(".otp__cell"));
|
|
1633
|
+
const carrierOf = (root) => root.querySelector(".otp__value");
|
|
1634
|
+
|
|
1635
|
+
function allowed(root, text) {
|
|
1636
|
+
const numeric = root.getAttribute("data-otp-mode") !== "alphanumeric";
|
|
1637
|
+
return (text || "").replace(numeric ? /[^0-9]/g : /[^0-9a-zA-Z]/g, "");
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function sync(root, { fromCarrier = false } = {}) {
|
|
1641
|
+
const cells = cellsOf(root);
|
|
1642
|
+
const carrier = carrierOf(root);
|
|
1643
|
+
if (!cells.length || !carrier) return;
|
|
1644
|
+
|
|
1645
|
+
if (fromCarrier) {
|
|
1646
|
+
const chars = allowed(root, carrier.value).slice(0, cells.length);
|
|
1647
|
+
cells.forEach((c, i) => c.value = chars[i] ?? "");
|
|
1648
|
+
}
|
|
1649
|
+
const value = cells.map((c) => c.value).join("");
|
|
1650
|
+
carrier.value = value;
|
|
1651
|
+
carrier.setAttribute("tabindex", "-1");
|
|
1652
|
+
carrier.setAttribute("aria-hidden", "true");
|
|
1653
|
+
cells.forEach((c) => c.toggleAttribute("data-filled", c.value !== ""));
|
|
1654
|
+
// The first empty cell is the one Tab lands on.
|
|
1655
|
+
const firstEmpty = cells.findIndex((c) => !c.value);
|
|
1656
|
+
cells.forEach((c, i) => c.setAttribute("tabindex", i === (firstEmpty < 0 ? 0 : firstEmpty) ? "0" : "-1"));
|
|
1657
|
+
|
|
1658
|
+
if (value.length === cells.length && !root.hasAttribute("data-otp-completed")) {
|
|
1659
|
+
root.setAttribute("data-otp-completed", "");
|
|
1660
|
+
root.dispatchEvent(new CustomEvent("otp:complete", { bubbles: true, detail: { value } }));
|
|
1661
|
+
if (root.hasAttribute("data-otp-submit")) root.closest("form")?.requestSubmit();
|
|
1662
|
+
} else if (value.length < cells.length) {
|
|
1663
|
+
root.removeAttribute("data-otp-completed");
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/* Write `text` starting at cell `from`, advance focus past the last written cell. */
|
|
1668
|
+
function fill(root, from, text) {
|
|
1669
|
+
const cells = cellsOf(root);
|
|
1670
|
+
const chars = allowed(root, text);
|
|
1671
|
+
if (!chars) {
|
|
1672
|
+
cells[from].value = "";
|
|
1673
|
+
sync(root);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
let i = from;
|
|
1677
|
+
for (const ch of chars) {
|
|
1678
|
+
if (i >= cells.length) break;
|
|
1679
|
+
cells[i].value = ch;
|
|
1680
|
+
i++;
|
|
1681
|
+
}
|
|
1682
|
+
sync(root);
|
|
1683
|
+
cells[Math.min(i, cells.length - 1)].focus();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
document.addEventListener("input", (event) => {
|
|
1687
|
+
const cell = event.target.closest?.(".otp__cell");
|
|
1688
|
+
if (!cell) return;
|
|
1689
|
+
const root = cell.closest("[data-otp]");
|
|
1690
|
+
const cells = cellsOf(root);
|
|
1691
|
+
fill(root, cells.indexOf(cell), cell.value);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
document.addEventListener("paste", (event) => {
|
|
1695
|
+
const cell = event.target.closest?.(".otp__cell");
|
|
1696
|
+
if (!cell) return;
|
|
1697
|
+
event.preventDefault();
|
|
1698
|
+
const root = cell.closest("[data-otp]");
|
|
1699
|
+
fill(root, cellsOf(root).indexOf(cell), event.clipboardData?.getData("text") ?? "");
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
document.addEventListener("keydown", (event) => {
|
|
1703
|
+
const cell = event.target.closest?.(".otp__cell");
|
|
1704
|
+
if (!cell) return;
|
|
1705
|
+
const root = cell.closest("[data-otp]");
|
|
1706
|
+
const cells = cellsOf(root);
|
|
1707
|
+
const i = cells.indexOf(cell);
|
|
1708
|
+
|
|
1709
|
+
if (event.key === "Backspace") {
|
|
1710
|
+
event.preventDefault();
|
|
1711
|
+
if (cell.value) {
|
|
1712
|
+
cell.value = "";
|
|
1713
|
+
} else if (i > 0) {
|
|
1714
|
+
cells[i - 1].value = "";
|
|
1715
|
+
cells[i - 1].focus();
|
|
1716
|
+
}
|
|
1717
|
+
sync(root);
|
|
1718
|
+
} else if (event.key === "Delete") {
|
|
1719
|
+
event.preventDefault();
|
|
1720
|
+
cell.value = "";
|
|
1721
|
+
sync(root);
|
|
1722
|
+
} else if (event.key === "ArrowLeft" && i > 0) {
|
|
1723
|
+
event.preventDefault();
|
|
1724
|
+
cells[i - 1].focus();
|
|
1725
|
+
} else if (event.key === "ArrowRight" && i < cells.length - 1) {
|
|
1726
|
+
event.preventDefault();
|
|
1727
|
+
cells[i + 1].focus();
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
document.addEventListener("focusin", (event) => {
|
|
1732
|
+
const cell = event.target.closest?.(".otp__cell");
|
|
1733
|
+
if (cell) cell.select();
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
function initAll() {
|
|
1737
|
+
document.querySelectorAll("[data-otp]").forEach((root) => sync(root, { fromCarrier: true }));
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (document.readyState === "loading") {
|
|
1741
|
+
document.addEventListener("DOMContentLoaded", initAll);
|
|
1742
|
+
} else {
|
|
1743
|
+
initAll();
|
|
1744
|
+
}
|
|
1745
|
+
document.addEventListener("rapid:swapped", initAll);
|
|
1746
|
+
})();
|
|
1747
|
+
|
|
1748
|
+
/* components/popover/popover.js */
|
|
1749
|
+
/*
|
|
1750
|
+
* Popover: toggle on [data-popover-trigger], dismiss on outside click or
|
|
1751
|
+
* Escape (scoped to the popover that has focus; Escape with focus
|
|
1752
|
+
* elsewhere closes them all). Focus returns to the trigger on close.
|
|
1753
|
+
*/
|
|
1754
|
+
(() => {
|
|
1755
|
+
function setOpen(pop, on) {
|
|
1756
|
+
const panel = pop.querySelector(".popover__panel");
|
|
1757
|
+
const trigger = pop.querySelector("[data-popover-trigger]");
|
|
1758
|
+
if (panel) panel.hidden = !on;
|
|
1759
|
+
if (trigger) trigger.setAttribute("aria-expanded", on ? "true" : "false");
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
const openPanels = () => document.querySelectorAll("[data-popover] .popover__panel:not([hidden])");
|
|
1763
|
+
|
|
1764
|
+
document.addEventListener("click", (event) => {
|
|
1765
|
+
const trigger = event.target.closest?.("[data-popover-trigger]");
|
|
1766
|
+
if (trigger) {
|
|
1767
|
+
const pop = trigger.closest("[data-popover]");
|
|
1768
|
+
const panel = pop?.querySelector(".popover__panel");
|
|
1769
|
+
if (panel) setOpen(pop, panel.hidden);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const inside = event.target.closest?.("[data-popover]");
|
|
1773
|
+
openPanels().forEach((panel) => {
|
|
1774
|
+
const pop = panel.closest("[data-popover]");
|
|
1775
|
+
if (pop !== inside) setOpen(pop, false);
|
|
1776
|
+
});
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
document.addEventListener("keydown", (event) => {
|
|
1780
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
1781
|
+
const own = event.target.closest?.("[data-popover]");
|
|
1782
|
+
const targets = own ? [own] : Array.from(openPanels()).map((p) => p.closest("[data-popover]"));
|
|
1783
|
+
let closed = false;
|
|
1784
|
+
targets.forEach((pop) => {
|
|
1785
|
+
const panel = pop.querySelector(".popover__panel");
|
|
1786
|
+
if (!panel || panel.hidden) return;
|
|
1787
|
+
setOpen(pop, false);
|
|
1788
|
+
pop.querySelector("[data-popover-trigger]")?.focus();
|
|
1789
|
+
closed = true;
|
|
1790
|
+
});
|
|
1791
|
+
if (closed) event.preventDefault();
|
|
1792
|
+
});
|
|
1793
|
+
})();
|
|
1794
|
+
|
|
1795
|
+
/* components/segmented/segmented.js */
|
|
1796
|
+
/* Segmented: a thumb that glides to the checked option. The control is
|
|
1797
|
+
* still plain radios + labels (submits, works without JS); this only
|
|
1798
|
+
* measures the checked label and writes --segmented-x / --segmented-w
|
|
1799
|
+
* onto the root, which segmented.css turns into the moving surface.
|
|
1800
|
+
* Re-measured on change, on resize, after a rapid swap. */
|
|
1801
|
+
(() => {
|
|
1802
|
+
function place(root) {
|
|
1803
|
+
const checked = root.querySelector(".segmented__input:checked");
|
|
1804
|
+
const label = checked?.nextElementSibling;
|
|
1805
|
+
if (!label) {
|
|
1806
|
+
root.style.setProperty("--segmented-w", "0px");
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
const r = root.getBoundingClientRect();
|
|
1810
|
+
const l = label.getBoundingClientRect();
|
|
1811
|
+
const rtl = getComputedStyle(root).direction === "rtl";
|
|
1812
|
+
const x = rtl ? r.right - root.clientLeft - 3 - l.right : l.left - r.left - root.clientLeft - 3;
|
|
1813
|
+
root.style.setProperty("--segmented-x", `${rtl ? -x : x}px`);
|
|
1814
|
+
root.style.setProperty("--segmented-w", `${l.width}px`);
|
|
1815
|
+
root.setAttribute("data-segmented-ready", "");
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const initAll = () => document.querySelectorAll("[data-segmented]").forEach(place);
|
|
1819
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initAll);
|
|
1820
|
+
else initAll();
|
|
1821
|
+
addEventListener("load", initAll);
|
|
1822
|
+
addEventListener("resize", initAll);
|
|
1823
|
+
document.addEventListener("rapid:swapped", initAll);
|
|
1824
|
+
document.addEventListener("change", (event) => {
|
|
1825
|
+
const root = event.target.closest?.("[data-segmented]");
|
|
1826
|
+
if (root) place(root);
|
|
1827
|
+
});
|
|
1828
|
+
})();
|
|
1829
|
+
|
|
1830
|
+
/* components/select/select.js */
|
|
1831
|
+
/* Select: keeps the native <select> (the submitted value) and the
|
|
1832
|
+
* combobox UI (what the user sees) in step. A pick in the UI sets the
|
|
1833
|
+
* native value and fires `change` on it, so anything listening to the
|
|
1834
|
+
* native control (filter.js, a form's own script) sees a normal select;
|
|
1835
|
+
* a programmatic/autofill change on the native control repaints the UI.
|
|
1836
|
+
* Typing in the read-only field jumps to the first option starting with
|
|
1837
|
+
* those letters, like a native select does. */
|
|
1838
|
+
(() => {
|
|
1839
|
+
function ui(select) {
|
|
1840
|
+
const root = select.querySelector("[data-select-ui]");
|
|
1841
|
+
return root
|
|
1842
|
+
? {
|
|
1843
|
+
root,
|
|
1844
|
+
input: root.querySelector("input[role='combobox']"),
|
|
1845
|
+
hidden: root.querySelector("[data-combobox-value]"),
|
|
1846
|
+
options: Array.from(root.querySelectorAll("[role='option']")),
|
|
1847
|
+
}
|
|
1848
|
+
: null;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// UI → native
|
|
1852
|
+
document.addEventListener("combobox:pick", (event) => {
|
|
1853
|
+
const select = event.target.closest?.("[data-select]");
|
|
1854
|
+
const native = select?.querySelector(".select__native");
|
|
1855
|
+
if (!native) return;
|
|
1856
|
+
const value = event.detail?.value ?? "";
|
|
1857
|
+
if (native.value !== value) {
|
|
1858
|
+
native.value = value;
|
|
1859
|
+
native.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1860
|
+
native.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
// native → UI
|
|
1865
|
+
document.addEventListener("change", (event) => {
|
|
1866
|
+
if (!event.target.matches?.(".select__native")) return;
|
|
1867
|
+
const select = event.target.closest("[data-select]");
|
|
1868
|
+
const u = ui(select);
|
|
1869
|
+
if (!u) return;
|
|
1870
|
+
const value = event.target.value;
|
|
1871
|
+
const picked = u.options.find((o) => o.getAttribute("data-value") === value);
|
|
1872
|
+
const label = picked
|
|
1873
|
+
? (picked.querySelector(".combobox__option-label")?.textContent ?? picked.textContent).trim()
|
|
1874
|
+
: "";
|
|
1875
|
+
if (u.hidden) u.hidden.value = value;
|
|
1876
|
+
if (u.input && u.input.value !== label) u.input.value = label;
|
|
1877
|
+
u.options.forEach((o) => o.setAttribute("aria-selected", String(o === picked)));
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
// Type-ahead on the read-only field.
|
|
1881
|
+
let buffer = "";
|
|
1882
|
+
let lastKey = 0;
|
|
1883
|
+
document.addEventListener("keydown", (event) => {
|
|
1884
|
+
const input = event.target;
|
|
1885
|
+
if (!input.matches?.("[data-select-ui] input[role='combobox']")) return;
|
|
1886
|
+
if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
1887
|
+
const now = Date.now();
|
|
1888
|
+
buffer = now - lastKey > 600 ? event.key : buffer + event.key;
|
|
1889
|
+
lastKey = now;
|
|
1890
|
+
const select = input.closest("[data-select]");
|
|
1891
|
+
const u = ui(select);
|
|
1892
|
+
const hit = u?.options.find((o) =>
|
|
1893
|
+
o.getAttribute("aria-disabled") !== "true" &&
|
|
1894
|
+
(o.querySelector(".combobox__option-label")?.textContent ?? o.textContent).trim().toLowerCase().startsWith(
|
|
1895
|
+
buffer.toLowerCase(),
|
|
1896
|
+
)
|
|
1897
|
+
);
|
|
1898
|
+
if (hit) hit.click();
|
|
1899
|
+
event.preventDefault();
|
|
1900
|
+
});
|
|
1901
|
+
})();
|
|
1902
|
+
|
|
1903
|
+
/* components/slider/slider.js */
|
|
1904
|
+
/*
|
|
1905
|
+
* Slider: sync the painted track to the native <input type="range">.
|
|
1906
|
+
* --slider-pct is the only channel; the CSS owns everything visual. Runs
|
|
1907
|
+
* on load and after every rAPId swap, because a swapped-in fragment
|
|
1908
|
+
* arrives with no inline state (CSP: the template never emits one).
|
|
1909
|
+
*
|
|
1910
|
+
* The <output> text is server-rendered (possibly via a `format()` the
|
|
1911
|
+
* browser cannot run), so it is only rewritten on user input — using
|
|
1912
|
+
* `data-slider-labels` for stepped sliders or `data-slider-unit` for
|
|
1913
|
+
* counted ones.
|
|
1914
|
+
*/
|
|
1915
|
+
(() => {
|
|
1916
|
+
function paint(input) {
|
|
1917
|
+
const root = input.closest("[data-slider]");
|
|
1918
|
+
if (!root) return null;
|
|
1919
|
+
const min = Number(input.min || 0);
|
|
1920
|
+
const max = Number(input.max || 100);
|
|
1921
|
+
const pct = max === min ? 0 : ((Number(input.value) - min) / (max - min)) * 100;
|
|
1922
|
+
root.style.setProperty("--slider-pct", `${pct.toFixed(2)}%`);
|
|
1923
|
+
return root;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function label(root, input) {
|
|
1927
|
+
const out = root.querySelector("[data-slider-output]");
|
|
1928
|
+
if (!out) return;
|
|
1929
|
+
const value = Number(input.value);
|
|
1930
|
+
const labels = root.getAttribute("data-slider-labels");
|
|
1931
|
+
if (labels) {
|
|
1932
|
+
const step = Number(input.step || 1);
|
|
1933
|
+
const i = Math.round((value - Number(input.min || 0)) / step);
|
|
1934
|
+
let parsed = [];
|
|
1935
|
+
try {
|
|
1936
|
+
parsed = JSON.parse(labels);
|
|
1937
|
+
} catch { /* malformed — fall through to the number */ }
|
|
1938
|
+
out.textContent = parsed[i] ?? input.value;
|
|
1939
|
+
root.querySelectorAll(".slider__scale-item").forEach((el, n) => {
|
|
1940
|
+
el.classList.toggle("slider__scale-item--active", n === i);
|
|
1941
|
+
});
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const unit = root.getAttribute("data-slider-unit");
|
|
1945
|
+
const one = root.getAttribute("data-slider-unit-one") || unit;
|
|
1946
|
+
out.textContent = unit ? `${input.value} ${value === 1 ? one : unit}` : input.value;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function initAll() {
|
|
1950
|
+
document.querySelectorAll("[data-slider] .slider__input").forEach(paint);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
document.addEventListener("input", (event) => {
|
|
1954
|
+
if (!event.target.matches?.("[data-slider] .slider__input")) return;
|
|
1955
|
+
const root = paint(event.target);
|
|
1956
|
+
if (root) label(root, event.target);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
if (document.readyState === "loading") {
|
|
1960
|
+
document.addEventListener("DOMContentLoaded", initAll);
|
|
1961
|
+
} else {
|
|
1962
|
+
initAll();
|
|
1963
|
+
}
|
|
1964
|
+
document.addEventListener("rapid:swapped", initAll);
|
|
1965
|
+
})();
|
|
1966
|
+
|
|
1967
|
+
/* components/tabs/tabs.js */
|
|
1968
|
+
/*
|
|
1969
|
+
* Tabs: click or arrow-key (Left/Right/Home/End) selection with a roving
|
|
1970
|
+
* tabindex, and a `#tab-<id>` deep link honoured on load, on hashchange
|
|
1971
|
+
* and after a rAPId swap.
|
|
1972
|
+
*/
|
|
1973
|
+
(() => {
|
|
1974
|
+
document.addEventListener("click", (event) => {
|
|
1975
|
+
const tab = event.target.closest?.(".tabs__tab");
|
|
1976
|
+
if (tab) activate(tab);
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
document.addEventListener("keydown", (event) => {
|
|
1980
|
+
const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
|
|
1981
|
+
if (!keys.includes(event.key)) return;
|
|
1982
|
+
const tab = event.target.closest?.(".tabs__tab");
|
|
1983
|
+
if (!tab) return;
|
|
1984
|
+
const list = [...tab.parentElement.querySelectorAll(".tabs__tab")];
|
|
1985
|
+
const index = list.indexOf(tab);
|
|
1986
|
+
let next;
|
|
1987
|
+
if (event.key === "Home") next = list[0];
|
|
1988
|
+
else if (event.key === "End") next = list[list.length - 1];
|
|
1989
|
+
else if (event.key === "ArrowRight") next = list[(index + 1) % list.length];
|
|
1990
|
+
else next = list[(index - 1 + list.length) % list.length];
|
|
1991
|
+
event.preventDefault();
|
|
1992
|
+
next.focus();
|
|
1993
|
+
activate(next);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
function deepLink() {
|
|
1997
|
+
if (location.hash.length < 2) return;
|
|
1998
|
+
const target = document.querySelector(`.tabs__tab#${CSS.escape(location.hash.slice(1))}`);
|
|
1999
|
+
if (!target) return;
|
|
2000
|
+
activate(target);
|
|
2001
|
+
// Fragment navigation focuses the target element, and since that is
|
|
2002
|
+
// not a pointer interaction the tab paints its :focus-visible ring —
|
|
2003
|
+
// which reads as a stray border to someone who just clicked a
|
|
2004
|
+
// sidebar link. Drop that programmatic focus; keyboard users who
|
|
2005
|
+
// Tab into the list still get the ring.
|
|
2006
|
+
const unfocus = () => {
|
|
2007
|
+
if (document.activeElement === target) target.blur();
|
|
2008
|
+
};
|
|
2009
|
+
unfocus();
|
|
2010
|
+
setTimeout(unfocus, 50);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
deepLink();
|
|
2014
|
+
addEventListener("load", deepLink);
|
|
2015
|
+
addEventListener("hashchange", deepLink);
|
|
2016
|
+
document.addEventListener("rapid:swapped", deepLink);
|
|
2017
|
+
|
|
2018
|
+
function activate(tab) {
|
|
2019
|
+
const tabs = tab.closest(".tabs");
|
|
2020
|
+
if (!tabs) return;
|
|
2021
|
+
tabs.querySelectorAll(":scope > .tabs__list > .tabs__tab").forEach((candidate) => {
|
|
2022
|
+
const selected = candidate === tab;
|
|
2023
|
+
candidate.setAttribute("aria-selected", String(selected));
|
|
2024
|
+
candidate.setAttribute("tabindex", selected ? "0" : "-1");
|
|
2025
|
+
});
|
|
2026
|
+
tabs.querySelectorAll(":scope > .tabs__panel").forEach((panel) => {
|
|
2027
|
+
panel.toggleAttribute("hidden", panel.id !== tab.getAttribute("aria-controls"));
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
})();
|
|
2031
|
+
|
|
2032
|
+
/* components/toast/toast.js */
|
|
2033
|
+
/*
|
|
2034
|
+
* Toasts. Three things live here:
|
|
2035
|
+
*
|
|
2036
|
+
* 1. Auto-dismiss: a `.toast[data-toast-autodismiss="<ms>"]` removes
|
|
2037
|
+
* itself after that many ms — whether it was in the page at load or
|
|
2038
|
+
* appended anywhere later (a rAPId swap into any region, or 2.).
|
|
2039
|
+
*
|
|
2040
|
+
* 2. Client-side trigger: `[data-toast-open="#template-id"]` clones that
|
|
2041
|
+
* <template>'s content into the region (`data-toast-region`, default
|
|
2042
|
+
* `#toast-region`). For rAPId apps the server usually pushes toasts via
|
|
2043
|
+
* `data-target="#toast-region" data-swap="append"`; this is the
|
|
2044
|
+
* no-server path (plain HTML sites, optimistic UI).
|
|
2045
|
+
*
|
|
2046
|
+
* 3. Stack cap: a region keeps at most `data-toast-max` (default 4)
|
|
2047
|
+
* toasts; the oldest are dropped so a burst never buries the page.
|
|
2048
|
+
*/
|
|
2049
|
+
(() => {
|
|
2050
|
+
const timers = new WeakMap();
|
|
2051
|
+
|
|
2052
|
+
function autoDismiss(el) {
|
|
2053
|
+
const ms = Number(el.dataset.toastAutodismiss);
|
|
2054
|
+
if (!ms || timers.has(el)) return;
|
|
2055
|
+
timers.set(el, setTimeout(() => el.remove(), ms));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function cap(region) {
|
|
2059
|
+
const max = Number(region.dataset.toastMax) || 4;
|
|
2060
|
+
const toasts = region.querySelectorAll(":scope > .toast");
|
|
2061
|
+
for (let i = 0; i < toasts.length - max; i++) toasts[i].remove();
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function scan(root) {
|
|
2065
|
+
root.querySelectorAll?.(".toast[data-toast-autodismiss]").forEach(autoDismiss);
|
|
2066
|
+
if (root.matches?.(".toast[data-toast-autodismiss]")) autoDismiss(root);
|
|
2067
|
+
root.querySelectorAll?.(".toast-region").forEach(cap);
|
|
2068
|
+
root.closest?.(".toast-region") && cap(root.closest(".toast-region"));
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
scan(document);
|
|
2072
|
+
|
|
2073
|
+
new MutationObserver((mutations) => {
|
|
2074
|
+
for (const mutation of mutations) {
|
|
2075
|
+
for (const node of mutation.addedNodes) {
|
|
2076
|
+
if (node.nodeType === 1) scan(node);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
2080
|
+
|
|
2081
|
+
document.addEventListener("click", (event) => {
|
|
2082
|
+
const trigger = event.target.closest?.("[data-toast-open]");
|
|
2083
|
+
if (!trigger) return;
|
|
2084
|
+
const template = document.querySelector(trigger.getAttribute("data-toast-open") ?? "");
|
|
2085
|
+
const target = document.querySelector(trigger.getAttribute("data-toast-region") ?? "#toast-region");
|
|
2086
|
+
if (!template?.content || !target) return;
|
|
2087
|
+
target.append(template.content.cloneNode(true));
|
|
2088
|
+
});
|
|
2089
|
+
})();
|