feat(web): reusable accessible Modal (portal, focus trap, scroll lock)
Pressing Plant styled modal — ESC + backdrop close, Tab focus trap, body scroll lock, focus restore, reduced-motion. Foundation for album/artist modals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
"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);
|
||||||
|
|
||||||
|
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") {
|
||||||
|
onClose();
|
||||||
|
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, onClose]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,3 +242,35 @@ nav.contents .sep { flex: 1; }
|
|||||||
}
|
}
|
||||||
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||||
.settings-section { margin-bottom: 30px; }
|
.settings-section { margin-bottom: 30px; }
|
||||||
|
|
||||||
|
/* ── Modal ────────────────────────────────────────────── */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
padding: 6vh 20px; overflow-y: auto;
|
||||||
|
animation: modal-fade 0.14s ease-out;
|
||||||
|
}
|
||||||
|
.modal-panel {
|
||||||
|
background: var(--paper-2); border: 1.5px solid var(--rule-2); width: 100%; max-width: 560px;
|
||||||
|
box-shadow: 0 24px 70px -24px rgba(0, 0, 0, 0.6); margin: auto 0;
|
||||||
|
animation: modal-rise 0.16s ease-out;
|
||||||
|
}
|
||||||
|
.modal-panel.wide { max-width: 780px; }
|
||||||
|
.modal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||||
|
padding: 15px 20px; border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase;
|
||||||
|
color: var(--graphite); min-width: 0;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
background: transparent; border: 0; color: var(--graphite); cursor: pointer;
|
||||||
|
font-size: 0.95rem; line-height: 1; padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--ink); }
|
||||||
|
.modal-body { padding: 20px; }
|
||||||
|
@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes modal-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .modal-backdrop, .modal-panel { animation: none; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user