From 3284059aba158151ea5ace72076e587e1c5baf37 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 15:12:36 +0200 Subject: [PATCH] feat(web): reusable accessible Modal (portal, focus trap, scroll lock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/app/_ui/modal.tsx | 75 +++++++++++++++++++++++++++++++++++++++ web/src/app/design.css | 32 +++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 web/src/app/_ui/modal.tsx diff --git a/web/src/app/_ui/modal.tsx b/web/src/app/_ui/modal.tsx new file mode 100644 index 0000000..ae8678d --- /dev/null +++ b/web/src/app/_ui/modal.tsx @@ -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 , 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(null); + const lastFocus = useRef(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(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( +
e.target === e.currentTarget && onClose()}> +
+
+
{title}
+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index 4150580..2287035 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -242,3 +242,35 @@ nav.contents .sep { flex: 1; } } .save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; } .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; } }