5e10dc24a5
The Modal focus-trap effect depended on [open, onClose] and called panelRef.focus() on every run. Callers that pass a fresh onClose each render (the common case, e.g. artists-client's closeAdd) re-ran the effect on every parent re-render — so typing one char into the add-artist search (setQuery → re-render → new onClose) yanked focus off the <input> back onto the panel, making the field untypable. Hold the latest onClose in a ref and depend on [open] alone, so focus-on-open runs once per open. Fixes every Modal caller, not just add-artist. Verified live via Playwright: typing "radiohead" one key at a time keeps the value and document.activeElement on the search input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
2.7 KiB
TypeScript
82 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])';
|
|
|
|
/** Accessible modal: portal to <body>, ESC + backdrop close, focus trap, scroll lock,
|
|
* focus restore on close. Renders nothing when closed. */
|
|
export function Modal({
|
|
open,
|
|
onClose,
|
|
title,
|
|
children,
|
|
wide,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title?: ReactNode;
|
|
children: ReactNode;
|
|
wide?: boolean;
|
|
}) {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const lastFocus = useRef<HTMLElement | null>(null);
|
|
// Keep the latest onClose in a ref so the setup effect can depend on [open] alone. If it
|
|
// depended on onClose, a caller passing a fresh callback each render (the common case)
|
|
// would re-run the effect on every parent re-render — and panelRef.focus() below would
|
|
// yank focus off a just-typed <input> back onto the panel (the add-artist modal bug).
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
lastFocus.current = document.activeElement as HTMLElement | null;
|
|
const prevOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
panelRef.current?.focus();
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") {
|
|
onCloseRef.current();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab") return;
|
|
const nodes = panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);
|
|
if (!nodes || nodes.length === 0) return;
|
|
const first = nodes[0];
|
|
const last = nodes[nodes.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("keydown", onKey);
|
|
document.body.style.overflow = prevOverflow;
|
|
lastFocus.current?.focus();
|
|
};
|
|
}, [open]);
|
|
|
|
if (!open || typeof document === "undefined") return null;
|
|
|
|
return createPortal(
|
|
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
<div className={`modal-panel${wide ? " wide" : ""}`} role="dialog" aria-modal="true" ref={panelRef} tabIndex={-1}>
|
|
<div className="modal-head">
|
|
<div className="modal-title">{title}</div>
|
|
<button className="modal-close" aria-label="Close" onClick={onClose}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">{children}</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|