import { useEffect, useRef, type ReactNode } from 'react' // Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are // excluded — they'd match `input:not([disabled])` and, sitting at a boundary, // break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab. const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), ' + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' /** * A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop * click, unless `busy` (a mutation is in flight) — then it stays put so the * action can finish and report. The caller owns open/closed state (render only * when open). */ export function Modal({ title, onClose, busy = false, children, }: { title: string onClose: () => void busy?: boolean children: ReactNode }) { const cardRef = useRef(null) // Keep the latest onClose/busy in refs so the mount-only effect below never // re-runs (which would re-attach the listener and steal focus on every parent // re-render, e.g. during a background refetch). const onCloseRef = useRef(onClose) onCloseRef.current = onClose const busyRef = useRef(busy) busyRef.current = busy useEffect(() => { const card = cardRef.current // Remember who opened the dialog so focus can return there on close — // otherwise it lands on and a keyboard user loses their place. const opener = document.activeElement as HTMLElement | null card?.focus() const focusable = () => Array.from(card?.querySelectorAll(FOCUSABLE_SELECTOR) ?? []) function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && !busyRef.current) { onCloseRef.current() return } if (e.key !== 'Tab') return const items = focusable() if (items.length === 0) { e.preventDefault() card?.focus() return } const first = items[0] const last = items[items.length - 1] const active = document.activeElement // If focus is NOT inside the dialog, pull it back in rather than let Tab // escape. This is the robust case that covers focus having fallen to // — a control that was removed (ShareGardenModal's remove-share // button) or disabled while busy — as well as any externally-stolen focus. if (!card || !card.contains(active)) { e.preventDefault() ;(e.shiftKey ? last : first).focus() return } if (e.shiftKey && (active === first || active === card)) { e.preventDefault() last.focus() } else if (!e.shiftKey && active === last) { e.preventDefault() first.focus() } } document.addEventListener('keydown', onKey) return () => { document.removeEventListener('keydown', onKey) // Restore focus to the opener only if it's still in the document — the // delete/clear flows this trap targets often remove the element that // opened the dialog (a garden card, a plop row). A disconnected node's // focus() silently no-ops and leaves focus on , so fall through to // that case explicitly rather than pretend it worked. if (opener && opener.isConnected) opener.focus() } }, []) return (
{ if (e.target === e.currentTarget && !busy) onClose() }} >

{title}

{children}
) }