diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx
index e671552..67313cd 100644
--- a/web/src/components/ui/Modal.tsx
+++ b/web/src/components/ui/Modal.tsx
@@ -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
@@ -27,12 +34,57 @@ 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()
+
+ const focusable = () =>
+ Array.from(card?.querySelectorAll(FOCUSABLE_SELECTOR) ?? [])
+
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
+ 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)
+ 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 (
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.`}
>
+ {garden.name} garden layout
{drawnGridCm != null && (
<>
diff --git a/web/src/editor/ObjectShape.tsx b/web/src/editor/ObjectShape.tsx
index 4ba32bd..2bf7204 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 { kindDef, objectDisplayName } from './kinds'
import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a'
@@ -57,11 +58,50 @@ 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'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
+ // (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' ? (