Files
paliad/frontend/src/client/components/modal.ts
mAi 760a0de931 feat(modals): t-paliad-217 Slice A + content additions — unified modal primitive
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.)
2026-05-20 13:05:59 +02:00

201 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
});
}