Use shared modals and combine selected-day actions

This commit is contained in:
syntaxbullet
2026-09-04 19:21:24 +02:00
parent 392deb9cbe
commit 892487eb4c
15 changed files with 242 additions and 113 deletions

View File

@@ -24,6 +24,7 @@ export function CalendarHeatmap({
onSelectDate,
historyLabel,
legend,
selectedDayAction,
}: {
days: CalendarDay[];
unit: string;
@@ -35,6 +36,7 @@ export function CalendarHeatmap({
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
</p>
</details>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<div className="ds-date-inspector-heading">
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
</div>
</div>
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</div>}
</div>
</div>
);

View 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>;
}

View File

@@ -1,18 +1,11 @@
import { useId, type ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "./primitives";
import { Modal } from "./Modal";
/** Shared flat panel for secondary workspace flows. */
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: {
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string;
/** Secondary workspace flows share one modal presentation. */
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
}) {
return <section id={id} className="ds-workspace-panel" aria-label={title}>
<header className="ds-panel-heading">
<div><p className="ds-eyebrow">{eyebrow}</p><h2>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
{onClose && <Button variant="text" className="ds-panel-close" onClick={onClose} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>}
</header>
{children}
</section>;
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
}
/** Consistent label, explanation and control columns for preference forms. */