components/dialog/dialog.ts
392 lines and 44 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| rememberOpener | 15 to 17 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
| fallbackFor | 22 to 30 | 6 | 0% | CriticalCRAP 42 | No data: mutation testing has not run on this repo yet |
| <anonymous:29> | 29 to 29 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| returnFocus | 36 to 51 | 1 | 25% | OkCRAP 1.4 | No data: mutation testing has not run on this repo yet |
| <anonymous:39> | 39 to 50 | 10 | 0% | CriticalCRAP 110 | No data: mutation testing has not run on this repo yet |
| isBackdropClick | 54 to 58 | 5 | 20% | WatchCRAP 17.8 | No data: mutation testing has not run on this repo yet |
| errorText | 61 to 65 | 5 | 20% | WatchCRAP 17.8 | No data: mutation testing has not run on this repo yet |
| uid | 68 to 71 | 1 | 25% | OkCRAP 1.4 | No data: mutation testing has not run on this repo yet |
| isBusy | 75 to 77 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
| setDialogBusy | 81 to 88 | 4 | 13% | WatchCRAP 14.7 | No data: mutation testing has not run on this repo yet |
| isLocked | 92 to 94 | 3 | 33% | WatchCRAP 5.7 | No data: mutation testing has not run on this repo yet |
| dragDismisses | 104 to 106 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| wireDrag | 108 to 173 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| place | 110 to 113 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| onDown | 114 to 125 | 13 | 0% | CriticalCRAP 182 | No data: mutation testing has not run on this repo yet |
| onMove | 126 to 139 | 6 | 0% | CriticalCRAP 42 | No data: mutation testing has not run on this repo yet |
| onUp | 140 to 162 | 10 | 0% | CriticalCRAP 110 | No data: mutation testing has not run on this repo yet |
| ease | 148 to 148 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:157> | 157 to 157 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:167> | 167 to 172 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| stateOf | 197 to 204 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| historyHash | 209 to 212 | 4 | 25% | WatchCRAP 10.8 | No data: mutation testing has not run on this repo yet |
| canFocus | 216 to 220 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| focusInitial | 226 to 241 | 12 | 7% | CriticalCRAP 129 | No data: mutation testing has not run on this repo yet |
| <anonymous:230> | 230 to 230 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| syncScroll | 245 to 256 | 6 | 8% | CriticalCRAP 33.8 | No data: mutation testing has not run on this repo yet |
| wireDialog | 261 to 352 | 5 | 5% | WatchCRAP 26.6 | No data: mutation testing has not run on this repo yet |
| <anonymous:264> | 264 to 264 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| onDown | 271 to 275 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| onClick | 276 to 288 | 9 | 0% | CriticalCRAP 90 | No data: mutation testing has not run on this repo yet |
| onCancel | 291 to 293 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| onKey | 294 to 311 | 14 | 0% | CriticalCRAP 210 | No data: mutation testing has not run on this repo yet |
| onSubmit | 313 to 315 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| onClose | 316 to 330 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| <anonymous:339> | 339 to 351 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| openDialog | 356 to 384 | 10 | 4% | CriticalCRAP 97.7 | No data: mutation testing has not run on this repo yet |
| <anonymous:369> | 369 to 369 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:378> | 378 to 382 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| closeDialog | 386 to 388 | 2 | 33% | OkCRAP 3.2 | No data: mutation testing has not run on this repo yet |
| enhance | 395 to 421 | 9 | 7% | CriticalCRAP 74.8 | No data: mutation testing has not run on this repo yet |
| onClick | 407 to 413 | 7 | 0% | CriticalCRAP 56 | No data: mutation testing has not run on this repo yet |
| <anonymous:415> | 415 to 418 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:420> | 420 to 420 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:420> | 420 to 420 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. Imported by components/approval-sheet/approval-sheet.ts, components/command-menu/command-menu.ts, components/confirm-dialog/confirm-dialog.ts, components/detail-panel/detail-panel.ts, components/dialog/dialog.react.tsx, components/media/media.react.tsx, components/media/media.ts, components/shell/shell.ts, components/shortcuts/shortcuts.ts, components/site-header/site-header.ts.
Source
// The dialog: one native <dialog> opened with showModal(), shared by every Capsomer overlay
// (confirm dialog, detail panel, command menu, shortcut sheet, the phone's More sheet). The
// platform gives the top layer, the inert page behind it, the focus trap and Esc; this module
// adds what it does not: focus placed on open and handed back on close, a click on the
// backdrop that closes (unless the dialog is locked or busy), Esc held while a request runs,
// an address entry that Back takes away, and a keyboard stop on a body that scrolls.
// No framework; the React wrapper and the other overlays call the same functions.
// ---------------------------------------------------------------------------------------
// Helpers every overlay shares (confirm-dialog.ts re-exports the first six for 0.1 imports).
const openers = new WeakMap<HTMLDialogElement, Element | null>();
// Remembers what had focus before the dialog opened, to hand focus back on close.
export function rememberOpener(dialog: HTMLDialogElement, opener: Element | null = document.activeElement): void {
openers.set(dialog, opener);
}
// Where focus goes when the opener has gone (a revoked agent loses its Revoke button): the
// dialog's data-cap-return selector, the given fallback, the Close button of another dialog
// that is still open behind this one (a detail panel), then the page's main region.
function fallbackFor(dialog: HTMLDialogElement, fallback?: HTMLElement | null): HTMLElement[] {
const sel = dialog.dataset.capReturn;
const named = sel ? document.querySelector<HTMLElement>(sel) : null;
let behind: HTMLElement | null = null;
for (const d of document.querySelectorAll<HTMLDialogElement>("dialog[open]")) {
if (d !== dialog) behind = d.querySelector<HTMLElement>("[data-cap-part='close']") ?? behind;
}
return [named, fallback ?? null, behind, document.querySelector<HTMLElement>("main")].filter((x): x is HTMLElement => !!x);
}
// Hands focus back after a dialog closes. After the next frame, so whatever the action
// changed has rendered first (a revoked agent's row, and its button, may be gone). Only when
// focus is lost: if the browser already restored it, or a command opened another dialog,
// that focus stands.
export function returnFocus(dialog: HTMLDialogElement, fallback?: HTMLElement | null): void {
const from = openers.get(dialog);
openers.delete(dialog);
requestAnimationFrame(() => {
const now = document.activeElement;
const lost = !now || now === document.body || !now.isConnected || dialog.contains(now);
if (!lost) return;
const candidates: HTMLElement[] = [];
if (from instanceof HTMLElement && from.isConnected && from !== document.body) candidates.push(from);
candidates.push(...fallbackFor(dialog, fallback));
for (const el of candidates) {
el.focus();
if (document.activeElement === el) return;
}
});
}
// True when a mouse event landed on the dialog's backdrop rather than its content.
export function isBackdropClick(dialog: HTMLDialogElement, e: MouseEvent): boolean {
if (e.target !== dialog) return false;
const r = dialog.getBoundingClientRect();
return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
}
// The text of a thrown value, for the error shown inside a dialog.
export function errorText(e: unknown): string {
if (e instanceof Error && e.message) return e.message;
if (typeof e === "string" && e) return e;
return "Something went wrong and nothing says what.";
}
let seq = 0;
export function uid(prefix: string): string {
seq += 1;
return `${prefix}-${seq}`;
}
// A dialog is busy while its action runs: aria-busy on the dialog. Esc, Close, Cancel and the
// backdrop all refuse until it is cleared.
export function isBusy(dialog: HTMLDialogElement): boolean {
return dialog.getAttribute("aria-busy") === "true";
}
// Sets or clears busy. The Close and Cancel parts say so with aria-disabled (not disabled, so
// focus stays where it was).
export function setDialogBusy(dialog: HTMLDialogElement, on: boolean): void {
for (const el of dialog.querySelectorAll<HTMLElement>("[data-cap-part='close'], [data-cap-part='cancel']")) {
if (on) el.setAttribute("aria-disabled", "true");
else el.removeAttribute("aria-disabled");
}
if (on) dialog.setAttribute("aria-busy", "true");
else dialog.removeAttribute("aria-busy");
}
// True when a click outside the dialog must not close it: it is locked, it is an alert
// dialog (one that asks a question the person must answer), or it is busy.
export function isLocked(dialog: HTMLDialogElement): boolean {
return dialog.hasAttribute("data-cap-modal-lock") || dialog.getAttribute("role") === "alertdialog" || isBusy(dialog);
}
// ---------------------------------------------------------------------------------------
// Drag to dismiss a sheet (Vaul). The header of a bottom, right or left sheet is its handle:
// it follows the pointer toward the sheet's edge, and on release the sheet closes, as Close
// would, when it went past a third of its size or was flung (faster than 0.5 px a ms);
// otherwise it springs back. Not while the dialog is locked or busy, nor under reduced motion;
// Esc, Close and focus return are unchanged.
// Whether a released drag dismisses: past a third of the sheet, or flung toward its edge.
function dragDismisses(moved: number, size: number, velocity: number): boolean {
return moved > size / 3 || (moved > 16 && velocity > 0.5);
}
function wireDrag(dialog: HTMLDialogElement): () => void {
let drag: { id: number; head: HTMLElement; x: number; y: number; dir: [number, number]; size: number; moved: number; t: number; v: number; on: boolean } | null = null;
const place = (px: number, dir: [number, number]) => {
dialog.style.translate = `${px * dir[0]}px ${px * dir[1]}px`;
dialog.style.setProperty("--cap-drag", String(drag ? Math.min(1, px / drag.size) : 0));
};
const onDown = (e: PointerEvent) => {
const placement = dialog.dataset.placement;
const head = (e.target as Element).closest?.<HTMLElement>(".cap-dialog-header");
if (e.button !== 0 || !head || head.closest("dialog") !== dialog || !dialog.open) return;
if (!["bottom", "right", "left"].includes(placement ?? "") || isLocked(dialog) || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if ((e.target as Element).closest("a, button, input, select, textarea, [contenteditable]")) return;
const r = dialog.getBoundingClientRect();
// Toward the edge it sits on, whatever the writing direction.
const dir: [number, number] = placement === "bottom" ? [0, 1] : r.left < innerWidth - r.right ? [-1, 0] : [1, 0];
drag = { id: e.pointerId, head, x: e.clientX, y: e.clientY, dir, size: placement === "bottom" ? r.height : r.width, moved: 0, t: e.timeStamp, v: 0, on: false };
head.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!drag || e.pointerId !== drag.id) return;
const along = (e.clientX - drag.x) * drag.dir[0] + (e.clientY - drag.y) * drag.dir[1];
// A press that has not moved 4 px is still a click.
if (!drag.on && Math.abs(along) < 4) return;
drag.on = true;
dialog.dataset.capDragging = "";
const moved = Math.max(0, along);
const dt = e.timeStamp - drag.t;
if (dt > 0) drag.v = (moved - drag.moved) / dt;
drag.moved = moved;
drag.t = e.timeStamp;
place(moved, drag.dir);
};
const onUp = (e: PointerEvent) => {
if (!drag || e.pointerId !== drag.id) return;
const d = drag;
drag = null;
if (d.head.hasPointerCapture(e.pointerId)) d.head.releasePointerCapture(e.pointerId);
if (!d.on) return;
delete dialog.dataset.capDragging;
const css = getComputedStyle(dialog);
const ease = (name: string) => css.getPropertyValue(name).trim() || "ease-out";
const from = `${d.moved * d.dir[0]}px ${d.moved * d.dir[1]}px`;
dialog.style.removeProperty("translate");
dialog.style.removeProperty("--cap-drag");
// A pointer that stopped before letting go was not flung.
const v = e.timeStamp - d.t > 100 ? 0 : d.v;
if (e.type === "pointerup" && dragDismisses(d.moved, d.size, v)) {
// On out of the edge while it fades, then gone; the close is Close's, so focus returns.
const off = dialog.animate({ translate: [from, `${d.size * d.dir[0]}px ${d.size * d.dir[1]}px`] }, { duration: parseFloat(css.getPropertyValue("--dur-fast")) || 100, easing: ease("--ease-out"), fill: "forwards" });
off.onfinish = () => off.cancel();
dialog.close("drag");
} else {
dialog.animate({ translate: [from, "0px 0px"] }, { duration: parseFloat(css.getPropertyValue("--dur-slow")) || 400, easing: ease("--ease-spring") });
}
};
dialog.addEventListener("pointerdown", onDown);
dialog.addEventListener("pointermove", onMove);
dialog.addEventListener("pointerup", onUp);
dialog.addEventListener("pointercancel", onUp);
return () => {
dialog.removeEventListener("pointerdown", onDown);
dialog.removeEventListener("pointermove", onMove);
dialog.removeEventListener("pointerup", onUp);
dialog.removeEventListener("pointercancel", onUp);
};
}
// ---------------------------------------------------------------------------------------
// Opening and closing.
export interface DialogOptions {
// Pushes "#<prefix>-<id>" so Back closes the dialog. Default: the dialog's data-cap-history.
history?: boolean;
// Where focus goes on open. Default: the [autofocus] element; for an alert or destructive
// dialog, Cancel; otherwise the first control.
focus?: HTMLElement | null;
// Where focus goes on close when the opener has gone.
returnTo?: HTMLElement | null;
}
interface State {
opts: DialogOptions;
pushed: boolean;
onPop: (() => void) | null;
observer: ResizeObserver | null;
down: boolean;
}
const states = new WeakMap<HTMLDialogElement, State>();
function stateOf(dialog: HTMLDialogElement): State {
let s = states.get(dialog);
if (!s) {
s = { opts: {}, pushed: false, onPop: null, observer: null, down: false };
states.set(dialog, s);
}
return s;
}
// The address a history-entry dialog pushes: data-cap-history may name the prefix
// ("detail" gives #detail-<id>); the id is data-cap-history-id, data-cap-detail-id or the
// dialog's id.
export function historyHash(dialog: HTMLDialogElement): string {
const prefix = dialog.dataset.capHistory || "dialog";
return `#${prefix}-${dialog.dataset.capHistoryId ?? dialog.dataset.capDetailId ?? dialog.id}`;
}
const FOCUSABLE = "a[href], button, input:not([type='hidden']), select, textarea, summary, [tabindex]";
function canFocus(el: HTMLElement): boolean {
if (el.matches(":disabled") || el.closest("[hidden], [inert]")) return false;
if (el.getAttribute("tabindex") === "-1") return false;
return el.getClientRects().length > 0;
}
// Puts focus where a person starts: the given element, the [autofocus] one, Cancel when the
// dialog is an alert or marked destructive (Enter on arrival must not do the damage), else
// the first control that is not the Close button or the scrolling body, else Close, the body,
// the dialog itself.
export function focusInitial(dialog: HTMLDialogElement, target?: HTMLElement | null): void {
const cancel = dialog.querySelector<HTMLElement>("[data-cap-part='cancel']");
const destructive = dialog.getAttribute("role") === "alertdialog" || dialog.hasAttribute("data-cap-destructive");
const all = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(canFocus);
const plain = all.filter((el) => el.dataset.capPart !== "close" && !el.hasAttribute("data-cap-scroll"));
const choice =
target ??
dialog.querySelector<HTMLElement>("[autofocus]") ??
(destructive && cancel && canFocus(cancel) ? cancel : null) ??
plain[0] ??
dialog.querySelector<HTMLElement>("[data-cap-part='close']") ??
all[0] ??
dialog;
choice.focus();
if (document.activeElement !== choice) dialog.focus();
}
// A body that scrolls must be reachable by keyboard: it takes tabindex="0" while it overflows
// (and gives it back when it does not), so arrow keys scroll it.
export function syncScroll(dialog: HTMLDialogElement): void {
const body = dialog.querySelector<HTMLElement>(".cap-dialog-body");
if (!body) return;
const over = body.scrollHeight > body.clientHeight + 1;
if (over && !body.hasAttribute("tabindex")) {
body.tabIndex = 0;
body.dataset.capScroll = "";
} else if (!over && body.hasAttribute("data-cap-scroll")) {
body.removeAttribute("tabindex");
delete body.dataset.capScroll;
}
}
// Wires one dialog: a click on a Close or Cancel part, a click on the backdrop, Esc held
// while busy, and what happens on close (the address entry, focus back to the opener).
// Returns a function that unwires it. Idempotent for a dialog that is already wired.
export function wireDialog(dialog: HTMLDialogElement, opts: DialogOptions = {}): () => void {
const s = stateOf(dialog);
s.opts = { ...s.opts, ...opts };
if (dialog.dataset.capReady !== undefined && dialog.dataset.capWired !== undefined) return () => undefined;
dialog.dataset.capReady = "";
dialog.dataset.capWired = "";
if (dialog.hasAttribute("data-cap-alert") && !dialog.hasAttribute("role")) dialog.setAttribute("role", "alertdialog");
// A press that starts inside the dialog and ends on the backdrop (selecting text) is not a
// click on the backdrop.
const onDown = (e: MouseEvent) => {
s.down = isBackdropClick(dialog, e);
// A locked dialog keeps focus where it was: the browser would move it to the dialog.
if (s.down && isLocked(dialog)) e.preventDefault();
};
const onClick = (e: MouseEvent) => {
const t = e.target;
const closer = t instanceof Element ? t.closest<HTMLElement>("[data-cap-part='close'], [data-cap-part='cancel']") : null;
if (closer && dialog.contains(closer)) {
if (isBusy(dialog)) return;
// A Cancel that is a link (so it works with no script) closes the modal instead of leaving.
if (closer instanceof HTMLAnchorElement) e.preventDefault();
return dialog.close(closer.dataset.capPart);
}
const wasDown = s.down;
s.down = false;
if (wasDown && isBackdropClick(dialog, e) && !isLocked(dialog)) dialog.close("backdrop");
};
// Esc while a request is in flight would hide its answer. The keydown is held as well as the
// cancel event, because a browser may skip a cancel event that the page refused once.
const onCancel = (e: Event) => {
if (isBusy(dialog)) e.preventDefault();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isBusy(dialog)) e.preventDefault();
if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) return;
// Tab past the last control comes back to the first (and the reverse), so focus never
// leaves for the browser's own controls: the page behind is inert.
const stops = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(canFocus);
const first = stops[0];
const last = stops[stops.length - 1];
if (!first || !last) return;
const at = document.activeElement;
if (e.shiftKey && (at === first || at === dialog)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && at === last) {
e.preventDefault();
first.focus();
}
};
// A <form method="dialog"> closes natively; while busy it must not.
const onSubmit = (e: SubmitEvent) => {
if (isBusy(dialog) && (e.target as HTMLFormElement | null)?.method === "dialog") e.preventDefault();
};
const onClose = () => {
// A close event queued before the dialog was opened again is stale.
if (dialog.open) return;
s.observer?.disconnect();
s.observer = null;
if (s.onPop) window.removeEventListener("popstate", s.onPop);
s.onPop = null;
// Closed by Esc or Close: take back the entry the dialog pushed, so Back goes where it
// went before.
if (s.pushed) {
s.pushed = false;
history.back();
}
returnFocus(dialog, s.opts.returnTo);
};
dialog.addEventListener("mousedown", onDown);
dialog.addEventListener("click", onClick);
dialog.addEventListener("cancel", onCancel);
dialog.addEventListener("keydown", onKey);
dialog.addEventListener("submit", onSubmit);
dialog.addEventListener("close", onClose);
const unDrag = wireDrag(dialog);
return () => {
unDrag();
dialog.removeEventListener("mousedown", onDown);
dialog.removeEventListener("click", onClick);
dialog.removeEventListener("cancel", onCancel);
dialog.removeEventListener("keydown", onKey);
dialog.removeEventListener("submit", onSubmit);
dialog.removeEventListener("close", onClose);
s.observer?.disconnect();
s.observer = null;
delete dialog.dataset.capReady;
delete dialog.dataset.capWired;
};
}
// Opens a dialog from the control that asked for it, remembering that control. Focus is
// placed (see focusInitial); with history, "#<prefix>-<id>" is pushed so Back closes it.
export function openDialog(dialog: HTMLDialogElement, opener: Element | null = document.activeElement, opts: DialogOptions = {}): void {
if (dialog.open && dialog.matches(":modal")) return;
wireDialog(dialog, opts);
const s = stateOf(dialog);
rememberOpener(dialog, opener);
// A dialog left open without showModal() (written with the open attribute) is not modal:
// drop the attribute so showModal() can run.
if (dialog.open) dialog.removeAttribute("open");
dialog.showModal();
focusInitial(dialog, s.opts.focus);
syncScroll(dialog);
s.observer?.disconnect();
if (typeof ResizeObserver !== "undefined") {
s.observer = new ResizeObserver(() => syncScroll(dialog));
s.observer.observe(dialog);
const body = dialog.querySelector(".cap-dialog-body");
if (body) s.observer.observe(body);
}
const useHistory = s.opts.history ?? dialog.hasAttribute("data-cap-history");
if (!useHistory) return;
history.pushState({ capDialog: dialog.dataset.capHistoryId ?? dialog.dataset.capDetailId ?? dialog.id }, "", historyHash(dialog));
s.pushed = true;
s.onPop = () => {
// Back: the entry is already gone, so closing must not go back again.
s.pushed = false;
if (dialog.open) dialog.close();
};
window.addEventListener("popstate", s.onPop);
}
export function closeDialog(dialog: HTMLDialogElement, returnValue?: string): void {
if (dialog.open) dialog.close(returnValue);
}
// Wires every dialog under root ([data-cap="dialog"]) and every trigger
// [data-cap-dialog-open="<dialog id>"]. A dialog with data-cap-open (or the specimens'
// data-specimen-open) opens now. A dialog with data-cap-inline and `open` is in the page until
// this runs, then closed (see the markup on the doc page). A trigger that is a link keeps its href for a new tab; a plain
// click opens the dialog. Returns a function that detaches them all.
export function enhance(root: ParentNode = document): () => void {
const undo: Array<() => void> = [];
for (const dialog of root.querySelectorAll<HTMLDialogElement>("dialog[data-cap='dialog']:not([data-cap-ready])")) {
// Shown in the page with no script (`open`, `data-cap-inline`): script takes it over, so it
// is closed until a trigger opens it as the modal.
if (dialog.hasAttribute("data-cap-inline") && dialog.open && !dialog.matches(":modal")) dialog.removeAttribute("open");
undo.push(wireDialog(dialog));
if (dialog.hasAttribute("data-cap-open") || dialog.hasAttribute("data-specimen-open")) openDialog(dialog, null);
}
for (const trigger of root.querySelectorAll<HTMLElement>("[data-cap-dialog-open]:not([data-cap-ready])")) {
trigger.dataset.capReady = "";
if (!trigger.hasAttribute("aria-haspopup")) trigger.setAttribute("aria-haspopup", "dialog");
const onClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
const dialog = document.getElementById(trigger.dataset.capDialogOpen ?? "");
if (!(dialog instanceof HTMLDialogElement)) return;
e.preventDefault();
openDialog(dialog, trigger);
};
trigger.addEventListener("click", onClick);
undo.push(() => {
trigger.removeEventListener("click", onClick);
delete trigger.dataset.capReady;
});
}
return () => undo.forEach((f) => f());
}