Make the canvas keyboard-reachable + trap focus in dialogs (#92)
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
Closes #84 (scoped first slice). Objects are focusable role=button with an aria-label and Enter/Space selection — which makes the existing arrow-key nudge reachable by keyboard for the first time — with a :focus-visible ring. The <svg> gets role=application + label + title. Modal traps Tab (robustly: pulls back focus fallen to <body> when a control is removed/disabled) and restores focus to the opener if it's still in the DOM. Verified live with real keyboard. Gadfly round addressed (canonical kind label, aria-current, trap robustness).
This commit was merged in pull request #92.
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react'
|
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
|
* 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
|
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
||||||
@@ -27,12 +34,57 @@ export function Modal({
|
|||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cardRef.current?.focus()
|
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) {
|
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
|
||||||
|
// <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)
|
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 <body>, so fall through to
|
||||||
|
// that case explicitly rather than pretend it worked.
|
||||||
|
if (opener && opener.isConnected) opener.focus()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -235,7 +235,13 @@ export function GardenCanvas({
|
|||||||
className="h-full w-full select-none"
|
className="h-full w-full select-none"
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
onPointerDown={onCanvasPointerDown}
|
onPointerDown={onCanvasPointerDown}
|
||||||
|
// role="application" tells a screen reader this is an interactive canvas
|
||||||
|
// to operate, not a document to read linearly. The <title> 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</title>
|
||||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||||
{drawnGridCm != null && (
|
{drawnGridCm != null && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, type PointerEvent } from 'react'
|
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||||
import { objectTransform } from './shared'
|
import { objectTransform } from './shared'
|
||||||
|
import { kindDef, objectDisplayName } from './kinds'
|
||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
|
|
||||||
const DEFAULT_FILL = '#8a8a8a'
|
const DEFAULT_FILL = '#8a8a8a'
|
||||||
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
|
|||||||
onSelect(object.id)
|
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 stroke = selected ? '#2f7a3e' : '#00000033'
|
||||||
const strokeWidth = selected ? 2 : 1
|
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 <g> is unreliable and a
|
||||||
|
// presentation attribute is overridden by any CSS rule.
|
||||||
return (
|
return (
|
||||||
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
|
<g
|
||||||
|
className="object-shape"
|
||||||
|
transform={objectTransform(object)}
|
||||||
|
onPointerDown={handleDown}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={label}
|
||||||
|
// 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' ? (
|
{object.shape === 'circle' ? (
|
||||||
<ellipse
|
<ellipse
|
||||||
cx={0}
|
cx={0}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
|
||||||
|
keyboard focus but not a mouse click; the dashed accent ring distinguishes
|
||||||
|
"focused" from the solid ring that marks "selected". A CSS rule overrides
|
||||||
|
the shape's inline stroke presentation attributes. */
|
||||||
|
.object-shape {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.object-shape:focus-visible :is(rect, ellipse) {
|
||||||
|
stroke: var(--color-accent-strong);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||||
|
|||||||
Reference in New Issue
Block a user