Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useId, useRef } from "react"; | |
| import { createPortal } from "react-dom"; | |
| import { ParamHelpEntry } from "./paramHelpContent"; | |
| interface Props { | |
| entry: ParamHelpEntry; | |
| onClose: () => void; | |
| } | |
| const FOCUSABLE = | |
| 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; | |
| /** | |
| * Centered dialog with backdrop. Closes on X, Esc, or backdrop click. | |
| * Focus is trapped inside the modal and returns to the trigger on | |
| * close (the provider handles the return). | |
| */ | |
| export default function ParamHelpModal({ entry, onClose }: Props) { | |
| const titleId = useId(); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| // Lock body scroll while open. | |
| useEffect(() => { | |
| const prev = document.body.style.overflow; | |
| document.body.style.overflow = "hidden"; | |
| return () => { | |
| document.body.style.overflow = prev; | |
| }; | |
| }, []); | |
| // Esc + focus trap. | |
| useEffect(() => { | |
| function onKey(e: KeyboardEvent) { | |
| if (e.key === "Escape") { | |
| e.stopPropagation(); | |
| onClose(); | |
| return; | |
| } | |
| if (e.key === "Tab" && containerRef.current) { | |
| const focusables = containerRef.current.querySelectorAll<HTMLElement>( | |
| FOCUSABLE, | |
| ); | |
| if (focusables.length === 0) return; | |
| const first = focusables[0]; | |
| const last = focusables[focusables.length - 1]; | |
| const active = document.activeElement as HTMLElement | null; | |
| if (e.shiftKey) { | |
| if (active === first || !containerRef.current.contains(active)) { | |
| e.preventDefault(); | |
| last.focus(); | |
| } | |
| } else { | |
| if (active === last) { | |
| e.preventDefault(); | |
| first.focus(); | |
| } | |
| } | |
| } | |
| } | |
| document.addEventListener("keydown", onKey); | |
| return () => document.removeEventListener("keydown", onKey); | |
| }, [onClose]); | |
| // Focus the close button once mounted. | |
| useEffect(() => { | |
| const f = containerRef.current?.querySelector<HTMLElement>(FOCUSABLE); | |
| f?.focus(); | |
| }, []); | |
| if (typeof document === "undefined") return null; | |
| const node = ( | |
| <div | |
| role="presentation" | |
| onClick={(e) => { | |
| if (e.target === e.currentTarget) onClose(); | |
| }} | |
| style={{ | |
| position: "fixed", | |
| inset: 0, | |
| zIndex: 100, | |
| background: "rgba(35,48,58,0.32)", | |
| display: "flex", | |
| alignItems: "flex-start", | |
| justifyContent: "center", | |
| padding: "min(8vh,72px) 16px", | |
| overflowY: "auto", | |
| }} | |
| > | |
| <div | |
| ref={containerRef} | |
| role="dialog" | |
| aria-modal="true" | |
| aria-labelledby={titleId} | |
| style={{ | |
| background: "#FCFBF8", | |
| border: "1px solid #ECEAE4", | |
| borderRadius: 14, | |
| width: "min(640px, 100%)", | |
| maxHeight: "calc(100vh - 24px)", | |
| overflowY: "auto", | |
| boxShadow: "0 24px 50px -16px rgba(35,48,58,0.30)", | |
| padding: "20px 22px 22px", | |
| fontFamily: | |
| '"Helvetica Neue", Helvetica, Arial, sans-serif', | |
| }} | |
| > | |
| <div className="mb-3 flex items-start justify-between gap-4"> | |
| <h2 | |
| id={titleId} | |
| style={{ | |
| color: "#23303A", | |
| fontSize: 17, | |
| fontWeight: 700, | |
| margin: 0, | |
| lineHeight: 1.3, | |
| }} | |
| > | |
| {entry.title} | |
| </h2> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| aria-label="Close" | |
| className="-mr-2 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-bg hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" | |
| > | |
| <span aria-hidden style={{ fontSize: 18, lineHeight: 1 }}> | |
| × | |
| </span> | |
| </button> | |
| </div> | |
| <div style={{ color: "#23303A", fontSize: 14, lineHeight: 1.55 }}> | |
| {entry.detailed} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| return createPortal(node, document.body); | |
| } | |