A window grows out of the button that opens it and flows back into it, like a macOS window to the Dock. Skipped with reduced motion or when the browser has no Element.animate. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6LjCrZ8KMcHGZyRWjkPA5
221 lines
8.9 KiB
JavaScript
221 lines
8.9 KiB
JavaScript
// Modals.
|
|
//
|
|
// v4 built this shell by hand in eleven files, each repeating its own backdrop
|
|
// click, close button and Escape handling -- and getting Escape wrong when one
|
|
// opened inside another. Here the shell is one thing, and the stacking and
|
|
// dismissal are the internals' problem.
|
|
//
|
|
// Every overlay handle carries isOverlay, because it mounts itself into the
|
|
// portal instead of being appended by its caller.
|
|
|
|
import { define } from "../internals/register.js";
|
|
import { el } from "../internals/el.js";
|
|
import { setText } from "../internals/text.js";
|
|
import { uid } from "../internals/ids.js";
|
|
import { mount, unmount } from "../internals/portal.js";
|
|
import { claimFor } from "../internals/zlayer.js";
|
|
import { push } from "../internals/dismiss.js";
|
|
import { trap } from "../internals/focus.js";
|
|
import { genieIn, genieOut } from "../internals/genie.js";
|
|
|
|
const SIZES = { sm: "360px", md: "520px", lg: "720px", xl: "960px" };
|
|
|
|
function nodeOf(control, where) {
|
|
if (!control) return null;
|
|
if (control.node && control.node.nodeType === 1) return control.node;
|
|
throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
|
|
}
|
|
|
|
/**
|
|
* open({ title, subtitle, size, body, actions, onClose, closeOnOutside, stacked })
|
|
* Shared by every modal variant below.
|
|
*/
|
|
function open({
|
|
title, subtitle, size = "md", body, actions = [], onClose,
|
|
closeOnOutside = true, closeOnEsc = true, stacked = false, describedBy, centered = false, origin,
|
|
} = {}) {
|
|
const titleId = uid("modal-title");
|
|
|
|
const card = el("div", {
|
|
cls: ["cx-modal", `cx-modal-${size}`, stacked ? "cx-modal-stacked" : null, centered ? "cx-modal-centered" : null],
|
|
attrs: { role: "dialog", "aria-modal": "true", tabindex: "-1", "aria-labelledby": titleId,
|
|
"aria-describedby": describedBy },
|
|
});
|
|
card.style.maxWidth = SIZES[size] || SIZES.md;
|
|
|
|
const close = el("button", {
|
|
cls: ["cx-icon-btn", "cx-icon-btn-md", "cx-focusable"],
|
|
text: "✕", attrs: { type: "button", "aria-label": "Close" },
|
|
on: { click: () => handle.close("close-button") },
|
|
});
|
|
|
|
card.append(el("header", { cls: "cx-modal-head", children: [
|
|
el("div", { cls: "cx-modal-titles", children: [
|
|
el("h2", { cls: "cx-modal-title", text: title, attrs: { id: titleId } }),
|
|
subtitle ? el("p", { cls: "cx-hint", text: subtitle }) : null,
|
|
].filter(Boolean) }),
|
|
closeOnEsc ? close : null, // a card that must not be dismissed has no way out but its own work
|
|
].filter(Boolean) }));
|
|
|
|
const content = el("div", { cls: "cx-modal-body", children: nodeOf(body, "modal") });
|
|
card.append(content);
|
|
|
|
// Always present, empty until there is something to put in it.
|
|
//
|
|
// A modal whose body changes needs its buttons to change with it, and the
|
|
// footer is the only row that sits OUTSIDE the scrolling body -- a bar built
|
|
// inside the body instead sticks to the bottom of the scroller, which is
|
|
// above the card's own edge by the body's padding, so the content scrolled
|
|
// through the gap underneath it.
|
|
const buttons = el("div", { cls: "cx-modal-foot-buttons" });
|
|
const noteText = el("p", { cls: "cx-hint" });
|
|
const foot = el("footer", { cls: "cx-modal-foot", children: [noteText, buttons] });
|
|
|
|
function setFooter({ actions: next = [], note = "" } = {}) {
|
|
buttons.replaceChildren(...next.map((a) => nodeOf(a, "modal actions")));
|
|
setText(noteText, note);
|
|
foot.toggleAttribute("hidden", !next.length && !note);
|
|
}
|
|
card.append(foot);
|
|
setFooter({ actions });
|
|
|
|
const backdrop = el("div", { cls: "cx-backdrop" });
|
|
const root = el("div", { cls: "cx-modal-layer", children: [backdrop, card] });
|
|
// A stacked modal shares the modal rung with the one beneath it: peers stack
|
|
// by claim order, so it lands above without needing a rung of its own.
|
|
const layer = claimFor("modal", root);
|
|
mount(root);
|
|
|
|
// Trap after mounting: focusables() must be able to see the card.
|
|
const release = trap(card, { modal: true });
|
|
if (origin) genieIn(card, origin);
|
|
const dismissal = push({
|
|
nodes: card,
|
|
closeOnOutside,
|
|
closeOnEsc,
|
|
onDismiss: (reason) => handle.close(reason),
|
|
});
|
|
|
|
let closed = false;
|
|
const handle = {
|
|
node: card,
|
|
isOverlay: true,
|
|
setFooter,
|
|
close(reason = "manual") {
|
|
if (closed) return;
|
|
closed = true;
|
|
dismissal.release();
|
|
release();
|
|
layer.release();
|
|
// With an origin the card flows back into its button before it is removed;
|
|
// everything above (focus, dismissal, the z rung) is already let go.
|
|
if (origin) { backdrop.style.opacity = "0"; genieOut(card, origin, () => unmount(root)); } else unmount(root);
|
|
if (onClose) onClose(reason);
|
|
},
|
|
destroy() { handle.close("destroy"); },
|
|
};
|
|
return handle;
|
|
}
|
|
|
|
define("modal", "generic", (props) => open(props));
|
|
|
|
// Opened above an existing modal without closing it. The z rung and the focus
|
|
// handoff are the kit's problem; v4 made each caller compute a z-index from its
|
|
// parent's.
|
|
define("modal", "stacked", (props) => open({ ...props, stacked: true }));
|
|
|
|
/**
|
|
* A yes/no question. `result` resolves true or false -- including on Escape and
|
|
* on a backdrop click, which both mean "no" rather than leaving the promise
|
|
* pending forever.
|
|
*/
|
|
define("modal", "dialogue", ({ title, message, tone = "neutral", confirmLabel = "Confirm", cancelLabel = "Cancel", onResult } = {}) => {
|
|
let settle;
|
|
const result = new Promise((resolve) => { settle = resolve; });
|
|
const finish = (value) => { settle(value); if (onResult) onResult(value); };
|
|
|
|
const bodyId = uid("modal-msg");
|
|
const handle = open({
|
|
title,
|
|
describedBy: bodyId,
|
|
body: { node: el("p", { cls: "cx-t cx-t-sm", text: message, attrs: { id: bodyId } }) },
|
|
onClose: (reason) => { if (reason !== "confirmed") finish(false); },
|
|
actions: [
|
|
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-neutral", "cx-focusable"],
|
|
text: cancelLabel, attrs: { type: "button" },
|
|
on: { click: () => handle.close("cancelled") } }) },
|
|
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", tone === "danger" ? "cx-btn-danger" : "cx-btn-primary", "cx-focusable"],
|
|
text: confirmLabel, attrs: { type: "button" },
|
|
on: { click: () => { finish(true); handle.close("confirmed"); } } }) },
|
|
],
|
|
});
|
|
handle.result = result;
|
|
return handle;
|
|
});
|
|
|
|
/** Ask for one value. Resolves the string, or null if dismissed. */
|
|
define("modal", "prompt", ({ title, label, value = "", placeholder, confirmLabel = "Create", cancelLabel = "Cancel", validate, onResult } = {}) => {
|
|
let settle;
|
|
const result = new Promise((resolve) => { settle = resolve; });
|
|
const finish = (v) => { settle(v); if (onResult) onResult(v); };
|
|
|
|
const input = el("input", { cls: ["cx-input", "cx-input-md", "cx-focusable"],
|
|
attrs: { type: "text", placeholder, id: uid("prompt") } });
|
|
input.value = value;
|
|
const error = el("p", { cls: "cx-field-error" });
|
|
|
|
const submit = () => {
|
|
const problem = validate ? validate(input.value) : null;
|
|
if (problem) { error.textContent = problem; input.focus(); return; }
|
|
finish(input.value);
|
|
handle.close("confirmed");
|
|
};
|
|
|
|
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } });
|
|
|
|
const handle = open({
|
|
title,
|
|
body: { node: el("div", { cls: "cx-field", children: [
|
|
label ? el("label", { cls: "cx-label", text: label, attrs: { for: input.id } }) : null,
|
|
input, error,
|
|
].filter(Boolean) }) },
|
|
onClose: (reason) => { if (reason !== "confirmed") finish(null); },
|
|
actions: [
|
|
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-neutral", "cx-focusable"],
|
|
text: cancelLabel, attrs: { type: "button" }, on: { click: () => handle.close("cancelled") } }) },
|
|
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-primary", "cx-focusable"],
|
|
text: confirmLabel, attrs: { type: "button" }, on: { click: submit } }) },
|
|
],
|
|
});
|
|
handle.result = result;
|
|
queueMicrotask(() => { input.focus(); input.select(); });
|
|
return handle;
|
|
});
|
|
|
|
/** Pick one of a list. Resolves the chosen id, or null. */
|
|
define("modal", "choice", ({ title, subtitle, items = [], onPick } = {}) => {
|
|
let settle;
|
|
const result = new Promise((resolve) => { settle = resolve; });
|
|
|
|
const list = el("div", { cls: "cx-choice-list" });
|
|
for (const item of items) {
|
|
list.append(el("button", {
|
|
cls: ["cx-choice-row", "cx-focusable"],
|
|
attrs: { type: "button", disabled: item.disabled },
|
|
children: [
|
|
el("span", { cls: "cx-choice-label", text: item.label }),
|
|
item.hint ? el("span", { cls: "cx-hint", text: item.hint }) : null,
|
|
].filter(Boolean),
|
|
on: { click: () => { settle(item.id); if (onPick) onPick(item.id); handle.close("picked"); } },
|
|
}));
|
|
}
|
|
|
|
const handle = open({
|
|
title, subtitle, size: "sm",
|
|
body: { node: list },
|
|
onClose: (reason) => { if (reason !== "picked") settle(null); },
|
|
});
|
|
handle.result = result;
|
|
return handle;
|
|
});
|