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
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
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'
|
|
|
|
// Default fills by kind (overridable per object via object.color). Muted, earthy
|
|
// tones so plops (added in #15) read clearly on top.
|
|
const kindColors: Record<string, string> = {
|
|
bed: '#8a6d4b',
|
|
grow_bag: '#9c7a52',
|
|
container: '#6b7f8a',
|
|
in_ground: '#7a6a4a',
|
|
tree: '#4f7a4f',
|
|
path: '#b8b0a0',
|
|
structure: DEFAULT_FILL,
|
|
}
|
|
|
|
// Label font size = this fraction of the object's smaller side, clamped to a
|
|
// readable cm range. Corner radius = this fraction of the smaller half-side.
|
|
const LABEL_FONT_FACTOR = 0.28
|
|
const LABEL_FONT_MIN_CM = 8
|
|
const LABEL_FONT_MAX_CM = 40
|
|
const CORNER_RADIUS_FACTOR = 0.06
|
|
|
|
function fillFor(o: EditorObject): string {
|
|
return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL
|
|
}
|
|
|
|
/**
|
|
* One garden object in world (cm) space: a centered rect or ellipse (a circle
|
|
* when width == height), rotated about its center, with the object's name. The
|
|
* parent world <g> applies the viewport scale, so geometry is authored in cm and
|
|
* strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at
|
|
* any zoom. memo'd so a pan/zoom (which only changes the world <g> transform)
|
|
* doesn't re-render every object. Full move/resize/rotate come in #11.
|
|
*/
|
|
export const ObjectShape = memo(function ObjectShape({
|
|
object,
|
|
selected,
|
|
onSelect,
|
|
}: {
|
|
object: EditorObject
|
|
selected: boolean
|
|
onSelect: (id: number) => void
|
|
}) {
|
|
const fill = fillFor(object)
|
|
const halfW = Math.max(0, object.widthCm / 2)
|
|
const halfH = Math.max(0, object.heightCm / 2)
|
|
const fontCm = Math.max(
|
|
LABEL_FONT_MIN_CM,
|
|
Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR),
|
|
)
|
|
|
|
function handleDown(e: PointerEvent) {
|
|
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
|
|
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 <g> is unreliable and a
|
|
// presentation attribute is overridden by any CSS rule.
|
|
return (
|
|
<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' ? (
|
|
<ellipse
|
|
cx={0}
|
|
cy={0}
|
|
rx={halfW}
|
|
ry={halfH}
|
|
fill={fill}
|
|
fillOpacity={0.85}
|
|
stroke={stroke}
|
|
strokeWidth={strokeWidth}
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
) : (
|
|
<rect
|
|
x={-halfW}
|
|
y={-halfH}
|
|
width={halfW * 2}
|
|
height={halfH * 2}
|
|
rx={Math.min(halfW, halfH) * CORNER_RADIUS_FACTOR}
|
|
fill={fill}
|
|
fillOpacity={0.85}
|
|
stroke={stroke}
|
|
strokeWidth={strokeWidth}
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
)}
|
|
|
|
{object.name && (
|
|
<text
|
|
x={0}
|
|
y={0}
|
|
fontSize={fontCm}
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
fill="#ffffff"
|
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
>
|
|
{object.name}
|
|
</text>
|
|
)}
|
|
</g>
|
|
)
|
|
})
|