Build image / build-and-push (push) Successful in 10s
- Object aria-label uses kindDef().label (the canonical "In-ground") instead of an ad-hoc kind.replace() that produced "in ground" and diverged from the UI. 4+ models flagged this. - aria-current, not aria-pressed, for the selected object — selection isn't a toggle, which is what aria-pressed means; aria-current marks the active item. - Modal focus trap made robust: if focus is NOT inside the dialog (fell to <body> because the focused control was removed — ShareGardenModal's remove-share button — or disabled while busy, or externally stolen), Tab now pulls it back in instead of escaping. The previous branches only handled focus being exactly at a known boundary. - Focus restore checks opener.isConnected before calling focus(): the delete/ clear flows this targets often remove the element that opened the dialog, and a disconnected node's focus() silently no-ops. - Hoisted the focusable-element selector to a module constant, and excluded input[type="hidden"] (it matched input:not([disabled]) and, at a boundary, broke the wrap). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
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<HTMLDivElement>(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 <body> and a keyboard user loses their place.
|
|
const opener = document.activeElement as HTMLElement | null
|
|
card?.focus()
|
|
|
|
const focusable = () =>
|
|
Array.from(card?.querySelectorAll<HTMLElement>(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
|
|
// <body> — 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 <body>, so fall through to
|
|
// that case explicitly rather than pretend it worked.
|
|
if (opener && opener.isConnected) opener.focus()
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget && !busy) onClose()
|
|
}}
|
|
>
|
|
<div
|
|
ref={cardRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
tabIndex={-1}
|
|
className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-lg outline-none"
|
|
>
|
|
<h2 className="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
|
<div className="mt-4">{children}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|