frontend/src/client/components/modal.ts — new openModal() primitive,
native <dialog>-backed. The browser handles top-layer stacking, ESC,
ARIA, and focus trap. We layer on top:
- browser back-button closes the modal (history.pushState on open +
popstate listener, matching m's Q5 lock-in)
- focus restoration to whatever was focused before open (the native
<dialog> doesn't do this)
- backdrop click closes
- close (×) button mandatory in the header, always rendered
CSS (global.css):
- dialog.modal + .modal__{header,title,close,body,footer} block. Sizes
sm/md/lg/full via data-size attr.
- Phone breakpoint (≤32rem): full-screen takeover sitting ABOVE the
PWA bottom-nav. max-height accounts for --bottom-nav-height (56px)
and margin-bottom keeps the nav visible.
- Legacy .modal-overlay / .modal-card / .modal-content / .modal stay
in place for the ~7 unmigrated modals — the new BEM-style .modal__*
avoids colliding with the legacy hierarchy. Cleanup is a follow-up
PR after the last legacy modal flips.
i18n keys + i18n-keys.ts regenerated:
- modal.close.label (DE/EN)
- approvals.suggest.section.editable / .context (DE/EN)
- approvals.suggest.context.{project,requester,requested_at,approval_status} (DE/EN)
- approvals.suggest.field.{original_due_date,warning_date,rule_code,description} (DE/EN)
- approvals.suggest.event_type_picker_unavailable (DE/EN)
(Slice C consumes the suggest.section/context/field keys; bundling them
here keeps the i18n.ts diff coherent.)
201 lines
7.9 KiB
TypeScript
201 lines
7.9 KiB
TypeScript
// Unified modal primitive — t-paliad-217.
|
||
//
|
||
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
|
||
// ARIA, and focus trap. We layer back-button integration and focus
|
||
// restoration on top so the modal behaves consistently on desktop and on
|
||
// the iPhone PWA (m's checking surface).
|
||
//
|
||
// API:
|
||
// const result = await openModal<MyResult>({
|
||
// title: "…",
|
||
// body: htmlStringOrElement,
|
||
// primary: { label: "Speichern", handler: (close) => { close(result); } },
|
||
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
|
||
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
|
||
// onClose: () => { /* … */ },
|
||
// classNames: "extra css classes on the <dialog>",
|
||
// });
|
||
// // result is the value passed to close(), or null if the user
|
||
// // dismissed via ESC / backdrop / secondary / browser back-button.
|
||
//
|
||
// All dismiss paths are unified: ESC, backdrop click, secondary button,
|
||
// the always-rendered close (×) button, and the browser back-button all
|
||
// resolve the promise with null. Programmatic close from the primary
|
||
// handler resolves with whatever was passed.
|
||
//
|
||
// Migration target: call sites that currently roll their own
|
||
// modal-overlay + ESC handler + focus management replace all of it with
|
||
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
|
||
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
|
||
// modals migrate in follow-up PRs.
|
||
|
||
import { t } from "../i18n";
|
||
|
||
export interface ModalConfig<T> {
|
||
title: string;
|
||
// body can be either a pre-built HTMLElement (the caller assembled the
|
||
// DOM and may have local references for read-back) or an HTML string
|
||
// (caller is responsible for escaping). Element is preferred when the
|
||
// caller needs to read form state on submit.
|
||
body: HTMLElement | string;
|
||
primary: {
|
||
label: string;
|
||
handler: (close: (result: T) => void) => void | Promise<void>;
|
||
};
|
||
// secondary defaults to a Cancel button that just dismisses. Pass null
|
||
// explicitly to suppress (rare — primary-only modals like a confirmation
|
||
// toast).
|
||
secondary?: { label: string } | null;
|
||
size?: "sm" | "md" | "lg" | "full";
|
||
// onClose fires on EVERY dismiss path (including primary handler
|
||
// resolution). Use for analytics / dirty-state warnings.
|
||
onClose?: () => void;
|
||
classNames?: string;
|
||
}
|
||
|
||
// openModal returns a promise that resolves with the value passed to
|
||
// close() inside the primary handler, or null if the user dismissed via
|
||
// any other path. Always non-throwing — the primary handler decides
|
||
// whether to surface errors via its own UI (e.g. inline form errors)
|
||
// rather than rejecting the promise.
|
||
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
|
||
return new Promise((resolve) => {
|
||
// Record + restore focus to whatever was focused before the modal
|
||
// opened. Native <dialog> does NOT do this automatically.
|
||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
|
||
dialog.dataset.size = config.size ?? "md";
|
||
|
||
const header = document.createElement("header");
|
||
header.className = "modal__header";
|
||
const titleEl = document.createElement("h2");
|
||
titleEl.className = "modal__title";
|
||
titleEl.textContent = config.title;
|
||
header.appendChild(titleEl);
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.type = "button";
|
||
closeBtn.className = "modal__close";
|
||
closeBtn.setAttribute("aria-label", t("modal.close.label"));
|
||
closeBtn.textContent = "×"; // ×
|
||
header.appendChild(closeBtn);
|
||
dialog.appendChild(header);
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "modal__body";
|
||
if (typeof config.body === "string") {
|
||
body.innerHTML = config.body;
|
||
} else {
|
||
body.appendChild(config.body);
|
||
}
|
||
dialog.appendChild(body);
|
||
|
||
const footer = document.createElement("footer");
|
||
footer.className = "modal__footer";
|
||
const secondaryCfg = config.secondary === null
|
||
? null
|
||
: config.secondary ?? { label: t("common.cancel") };
|
||
let secondaryBtn: HTMLButtonElement | null = null;
|
||
if (secondaryCfg) {
|
||
secondaryBtn = document.createElement("button");
|
||
secondaryBtn.type = "button";
|
||
secondaryBtn.className = "btn btn-ghost modal__secondary";
|
||
secondaryBtn.textContent = secondaryCfg.label;
|
||
footer.appendChild(secondaryBtn);
|
||
}
|
||
const primaryBtn = document.createElement("button");
|
||
primaryBtn.type = "button";
|
||
primaryBtn.className = "btn btn-primary modal__primary";
|
||
primaryBtn.textContent = config.primary.label;
|
||
footer.appendChild(primaryBtn);
|
||
dialog.appendChild(footer);
|
||
|
||
document.body.appendChild(dialog);
|
||
|
||
// History integration (Q5): push a synthetic history state so the
|
||
// browser back-button closes the modal instead of leaving the page.
|
||
// We pop the state in finish() unless popstate already fired it.
|
||
let historyEntryActive = false;
|
||
try {
|
||
history.pushState({ paliadModalOpen: true }, "");
|
||
historyEntryActive = true;
|
||
} catch (_e) {
|
||
// pushState may throw in obscure embedded contexts; degrade gracefully.
|
||
}
|
||
|
||
// resolved guards against double-resolution (e.g. ESC fires + then a
|
||
// microtask-deferred primary handler also calls close).
|
||
let resolved = false;
|
||
|
||
const finish = (value: T | null) => {
|
||
if (resolved) return;
|
||
resolved = true;
|
||
|
||
window.removeEventListener("popstate", onPopState);
|
||
|
||
// Pop our history entry if it's still on the stack. Skip when the
|
||
// popstate listener already fired (otherwise we'd go back twice).
|
||
if (historyEntryActive) {
|
||
historyEntryActive = false;
|
||
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
|
||
}
|
||
|
||
// Native dialog close. Use the close event's default rather than
|
||
// the cancel event so we don't fight the browser's own dismissal.
|
||
if (dialog.open) dialog.close();
|
||
dialog.remove();
|
||
|
||
// Restore focus to whatever the user was on before. The dialog
|
||
// teardown happens synchronously so the focus call lands on a
|
||
// live element.
|
||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||
previouslyFocused.focus();
|
||
}
|
||
|
||
config.onClose?.();
|
||
resolve(value);
|
||
};
|
||
|
||
const close = (result: T) => finish(result);
|
||
|
||
// Dismiss paths.
|
||
closeBtn.addEventListener("click", () => finish(null));
|
||
secondaryBtn?.addEventListener("click", () => finish(null));
|
||
dialog.addEventListener("click", (e) => {
|
||
// Backdrop click — only when the click landed on the dialog element
|
||
// itself (not on a child). Browsers report dialog.click events
|
||
// through the backdrop too because the backdrop is conceptually
|
||
// part of the dialog's box.
|
||
if (e.target === dialog) finish(null);
|
||
});
|
||
// <dialog>'s cancel event fires on ESC. preventDefault stops the
|
||
// browser's default close so we can run our finish() (history pop,
|
||
// focus restore, onClose, resolve).
|
||
dialog.addEventListener("cancel", (e) => {
|
||
e.preventDefault();
|
||
finish(null);
|
||
});
|
||
const onPopState = () => {
|
||
// Browser back-button. Our history entry is gone by the time this
|
||
// fires, so skip the history.back() in finish().
|
||
historyEntryActive = false;
|
||
finish(null);
|
||
};
|
||
window.addEventListener("popstate", onPopState);
|
||
|
||
// Primary action.
|
||
primaryBtn.addEventListener("click", () => {
|
||
const result = config.primary.handler(close);
|
||
// Allow async primary handlers (handler returns a promise) — we
|
||
// don't wait for it explicitly; the handler is responsible for
|
||
// calling close() when ready.
|
||
void result;
|
||
});
|
||
|
||
// Open the dialog in the top layer. showModal activates ARIA
|
||
// role="dialog" + aria-modal=true + focus trap + backdrop.
|
||
dialog.showModal();
|
||
});
|
||
}
|