diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx index e671552..6a4874a 100644 --- a/web/src/components/ui/Modal.tsx +++ b/web/src/components/ui/Modal.tsx @@ -27,12 +27,51 @@ export function Modal({ busyRef.current = busy useEffect(() => { - cardRef.current?.focus() + 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() + + // The dialog's own focusable controls, in DOM order, skipping disabled ones. + const focusable = () => + Array.from( + card?.querySelectorAll( + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? [], + ) + function onKey(e: KeyboardEvent) { - if (e.key === 'Escape' && !busyRef.current) onCloseRef.current() + if (e.key === 'Escape' && !busyRef.current) { + onCloseRef.current() + return + } + 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() + card?.focus() + return + } + const first = items[0] + const last = items[items.length - 1] + const active = document.activeElement + 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) + return () => { + document.removeEventListener('keydown', onKey) + opener?.focus?.() + } }, []) return ( diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index 78ba6f1..94ed51b 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -235,7 +235,13 @@ export function GardenCanvas({ className="h-full w-full select-none" style={{ touchAction: 'none' }} onPointerDown={onCanvasPointerDown} + // role="application" tells a screen reader this is an interactive canvas + // to operate, not a document to read linearly. The names it, and + // objects inside are individually focusable buttons (see ObjectShape). + role="application" + aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`} > + <title>{garden.name} garden layout {drawnGridCm != null && ( <> diff --git a/web/src/editor/ObjectShape.tsx b/web/src/editor/ObjectShape.tsx index 4ba32bd..a51ddb9 100644 --- a/web/src/editor/ObjectShape.tsx +++ b/web/src/editor/ObjectShape.tsx @@ -1,5 +1,6 @@ -import { memo, type PointerEvent } from 'react' +import { memo, type KeyboardEvent, type PointerEvent } from 'react' import { objectTransform } from './shared' +import { objectDisplayName } from './kinds' import type { EditorObject } from './types' const DEFAULT_FILL = '#8a8a8a' @@ -57,11 +58,44 @@ export const ObjectShape = memo(function ObjectShape({ onSelect(object.id) } + // Keyboard path into selection (#84): the arrow-key nudge handler already + // exists but only ever acted on a pointer selection, so it was unreachable + // without a mouse. Enter/Space on a focused object selects it, which is the + // step that was missing. + function handleKey(e: KeyboardEvent) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onSelect(object.id) + } + } + 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, ' ')}` + + // 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 + // (styles/index.css) that draws a dashed ring; :focus-visible means it shows + // for keyboard focus but NOT a mouse click, which is exactly what we want. CSS + // rather than React state because onFocus on an SVG is unreliable and a + // presentation attribute is overridden by any CSS rule. return ( - + {object.shape === 'circle' ? (