Files
pansy/web/src/editor/ObjectShape.tsx
T
steveandClaude Opus 4.8 4b348dcbc0
Build image / build-and-push (push) Successful in 10s
Address Gadfly findings on #84
- 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
2026-07-21 23:33:38 -04:00

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>
)
})