Make the canvas keyboard-reachable + trap focus in dialogs #92
@@ -1,5 +1,12 @@
|
||||
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
|
||||
@@ -33,13 +40,8 @@ export function Modal({
|
||||
const opener = document.activeElement as HTMLElement | null
|
||||
|
|
||||
card?.focus()
|
||||
|
||||
// The dialog's own focusable controls, in DOM order, skipping disabled ones.
|
||||
const focusable = () =>
|
||||
Array.from(
|
||||
card?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [],
|
||||
)
|
||||
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !busyRef.current) {
|
||||
@@ -47,9 +49,6 @@ export function Modal({
|
||||
return
|
||||
|
gitea-actions
commented
🟠 Focus trap doesn't handle the focused element being removed from the DOM (e.g. ShareGardenModal's remove-share button), letting Tab escape the dialog correctness · flagged by 1 model
🪰 Gadfly · advisory 🟠 **Focus trap doesn't handle the focused element being removed from the DOM (e.g. ShareGardenModal's remove-share button), letting Tab escape the dialog**
_correctness · flagged by 1 model_
- `web/src/components/ui/Modal.tsx:49` — The Tab-trap checks `active === first/last/card` but not the case where the previously-focused element was removed from the DOM (focus reverts to `<body>` per browser default). `ShareGardenModal.tsx:117-127`'s per-share "✕ Remove" button lives inside the trapped dialog; activating it via keyboard removes it from the list, so the next Tab press finds `document.activeElement === body`, which matches none of the checked branches — no `preventDefault()` fires…
<sub>🪰 Gadfly · advisory</sub>
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
// Trap Tab inside the dialog: wrap at the ends, and pull a stray focus
|
||||
// (e.g. starting from the card itself) back to a real control. Without
|
||||
// this, Tab walks straight out into the page behind the backdrop.
|
||||
const items = focusable()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
@@ -59,6 +58,15 @@ export function Modal({
|
||||
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()
|
||||
@@ -70,7 +78,12 @@ export function Modal({
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
opener?.focus?.()
|
||||
// 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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { objectTransform } from './shared'
|
||||
import { objectDisplayName } from './kinds'
|
||||
import { kindDef, objectDisplayName } from './kinds'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_FILL = '#8a8a8a'
|
||||
@@ -73,10 +73,13 @@ export const ObjectShape = memo(function ObjectShape({
|
||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||
const strokeWidth = selected ? 2 : 1
|
||||
|
||||
// A concise accessible name: the object's label plus its kind, e.g.
|
||||
// "North Bed, raised bed". The dimensions aren't included — they need the
|
||||
// garden's unit context this component doesn't hold — so they're a follow-up.
|
||||
const label = `${objectDisplayName(object)}, ${object.kind.replace(/_/g, ' ')}`
|
||||
// A concise accessible name: the object's label plus its kind's canonical
|
||||
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||
// dimensions aren't included; they need the garden's unit context this
|
||||
// component doesn't hold, so they're a follow-up.
|
||||
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||
|
||||
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||
@@ -93,7 +96,10 @@ export const ObjectShape = memo(function ObjectShape({
|
||||
role="button"
|
||||
|
gitea-actions
commented
🟠 aria-pressed misused on non-toggle role="button"; object selection is not a toggle action correctness · flagged by 2 models
🪰 Gadfly · advisory 🟠 **aria-pressed misused on non-toggle role="button"; object selection is not a toggle action**
_correctness · flagged by 2 models_
- **`web/src/editor/ObjectShape.tsx:96`** — `aria-pressed={selected}` on `role="button"` advertises a toggle button to screen readers (pressed / not pressed). The keyboard action (`handleKey`) always calls `onSelect(object.id)`; there is no path that deselects the object when Enter/Space is pressed again on an already-selected item, so the toggle semantics are misleading. A screen reader user will expect the state to flip on second activation, which it does not. **Fix:** remove `aria-pressed`. I…
<sub>🪰 Gadfly · advisory</sub>
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-pressed={selected}
|
||||
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||
// item among the objects. Omitted, not "false", when unselected.
|
||||
aria-current={selected || undefined}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
|
||||
Reference in New Issue
Block a user
🟠 focusable() selector includes input[type="hidden"], breaking tab trap boundary when hidden inputs are at modal edges
correctness, maintainability · flagged by 2 models
web/src/components/ui/Modal.tsx:40— Thefocusable()selector usesinput:not([disabled]), which includesinput[type="hidden"]. Hidden inputs are not keyboard-focusable. If a modal ever places a hidden input at the start or end of its content (e.g., a form with a CSRF token), the focus trap’s boundary checks (active === first/active === last) will compare against the hidden input instead of the real last tabbable control. Tab (or Shift+Tab) from the actual last visible button…🪰 Gadfly · advisory