Use shared modals and combine selected-day actions
This commit is contained in:
80
src/components/design-system/Modal.tsx
Normal file
80
src/components/design-system/Modal.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
let openModals = 0;
|
||||
let previousOverflow = "";
|
||||
|
||||
/** Native top-layer dialog supplies focus containment and an inert background. */
|
||||
export function Modal({ id, eyebrow, title, description, children, onClose, closeLabel = "Close dialog", busy = false, size = "wide", initialFocus, returnFocus }: {
|
||||
id: string; eyebrow?: string; title: ReactNode; description?: ReactNode; children: ReactNode;
|
||||
onClose?: () => void; closeLabel?: string; busy?: boolean; size?: "wide" | "compact"; initialFocus?: string; returnFocus?: () => HTMLElement | null;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const pointerOnBackdrop = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
mounted.current = true;
|
||||
const element = dialog.current!;
|
||||
const opener = returnFocus?.() ?? document.activeElement as HTMLElement | null;
|
||||
if (!openModals++) {
|
||||
previousOverflow = document.documentElement.style.overflow;
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
}
|
||||
element.showModal();
|
||||
const target = initialFocus ? element.querySelector<HTMLElement>(initialFocus) : heading.current;
|
||||
target?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
element.close();
|
||||
if (!--openModals) document.documentElement.style.overflow = previousOverflow;
|
||||
// A successful save may keep its opener disabled until the dashboard
|
||||
// refresh finishes. Restore focus when it becomes available again.
|
||||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
|
||||
// Ignore React Strict Mode's setup/cleanup rehearsal.
|
||||
if (mounted.current || !opener?.isConnected) return;
|
||||
if (!opener.matches(":disabled")) { opener.focus({ preventScroll: true }); return; }
|
||||
const observer = new window.MutationObserver(() => {
|
||||
if (opener.matches(":disabled")) return;
|
||||
observer.disconnect();
|
||||
window.clearTimeout(timeout);
|
||||
if (opener.isConnected && document.activeElement === document.body) opener.focus({ preventScroll: true });
|
||||
});
|
||||
observer.observe(opener, { attributes: true, attributeFilter: ["disabled"] });
|
||||
const timeout = window.setTimeout(() => observer.disconnect(), 5000);
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dismiss = () => { if (!busy) onClose?.(); };
|
||||
return <dialog ref={dialog} id={id} className={`ds-modal ds-modal--${size}`} aria-labelledby={`${id}-title`}
|
||||
onCancel={event => { event.preventDefault(); event.stopPropagation(); dismiss(); }}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== "Tab") return;
|
||||
event.stopPropagation();
|
||||
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
|
||||
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (!first || !last) { event.preventDefault(); heading.current?.focus(); return; }
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && (active === first || !controls.includes(active as HTMLElement))) {
|
||||
event.preventDefault(); last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
}}
|
||||
onPointerDown={event => { pointerOnBackdrop.current = event.target === event.currentTarget; }}
|
||||
onClick={event => {
|
||||
if (!pointerOnBackdrop.current || event.target !== event.currentTarget) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dismiss();
|
||||
}}>
|
||||
<header className="ds-modal-heading">
|
||||
<div>{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}<h2 id={`${id}-title`} ref={heading} tabIndex={-1}>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
|
||||
<Button variant="text" className="ds-panel-close" disabled={busy || !onClose} onClick={dismiss} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>
|
||||
</header>
|
||||
<div className="ds-modal-body">{children}</div>
|
||||
</dialog>;
|
||||
}
|
||||
Reference in New Issue
Block a user