"use client"; import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import type { FocusEvent, KeyboardEvent, Ref } from "react"; import { FRAME_TYPE_LABELS } from "@/lib/hive/labels"; import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots"; import type { SlotRange } from "@/lib/hive/feeder-slots"; import { partDisplayName } from "@/lib/hive/names"; import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types"; import { useHiveView } from "./use-hive-view"; // Accessible outline of the hive (WAI-ARIA tree, spec 9.6). It offers everything // the 3D view does: hover, open a box, edit a frame or a part with state. const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 }); interface OutlineNode { key: string; kind: "part" | "frame" | "action"; /** Part id, or the box id for a frame. */ id: string; slot?: number; level: number; parentKey: string | null; childCount: number; name: string; hint: string | null; isBox: boolean; isFloor: boolean; hasState: boolean; /** Which add action an `action` node stands for. */ action?: "evaporator" | "frameFeeder"; setSize: number; position: number; } function fieldText(field: StateField, value: number | string | undefined): string { if (field.kind === "choice") { const index = field.options.indexOf(String(value)); return index >= 0 ? field.optionLabels[index] : String(value ?? ""); } const text = numberFormat.format(Number(value ?? 0)); return field.unit ? `${text} ${field.unit}` : text; } function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] { return (part.inserts ?? []).filter((insert) => catalog.partTypes.get(insert.partTypeId)?.kind !== "FLOOR_INSERT"); } function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null { if (!type) return null; // Fields without a value (a floor's unset choices) add nothing; no stray separator. const fieldTexts = type.stateSchema .map((field) => fieldText(field, part.state[field.key])) .filter((text) => text !== ""); if (fieldTexts.length > 0) return fieldTexts.join(" / "); if (type.kind === "BOX") { const count = part.frames?.length ?? 0; return count > 0 ? `${count} Rähmchen` : null; } const hasLock = (part.inserts ?? []).some( (insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT", ); return hasLock ? "Bausperre drin" : null; } const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …"; const ADD_FRAME_FEEDER_LABEL = "Futtertasche einsetzen …"; type Child = | { type: "insert"; part: HivePart; range: SlotRange | null } | { type: "frame"; frame: NonNullable[number] }; /** * Children of a box in outline order: other inserts (evaporator), then the * frames and frame feeders by slot. Frames a pocket covers are not listed. */ function childrenOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): Child[] { const inserts = visibleInserts(part, catalog).map((insert) => ({ part: insert, range: feederRange(insert, catalog.partTypes.get(insert.partTypeId)), })); const others: Child[] = inserts.filter((i) => !i.range).map((i) => ({ type: "insert", ...i })); if (type?.kind !== "BOX") return inserts.map((i) => ({ type: "insert", ...i })); const pockets = inserts.filter((i) => i.range); const bySlot: Child[] = []; for (const frame of part.frames ?? []) { const pocket = pockets.find((p) => p.range && frame.slot >= p.range.from && frame.slot <= p.range.to); if (!pocket) bySlot.push({ type: "frame", frame }); else if (pocket.range?.from === frame.slot) bySlot.push({ type: "insert", ...pocket }); } // A pocket whose slot lies outside the frames still shows up, after the frames. for (const pocket of pockets) { if (!bySlot.some((child) => child.type === "insert" && child.part.id === pocket.part.id)) { bySlot.push({ type: "insert", ...pocket }); } } return [...others, ...bySlot]; } function firstActiveType(catalog: HiveCatalog, kind: CatalogPartType["kind"]): CatalogPartType | undefined { return [...catalog.partTypes.values()].find((t) => t.kind === kind && t.active); } function addActions(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog, canEdit: boolean) { if (type?.kind !== "BOX" || !canEdit) return []; const actions: { action: "evaporator" | "frameFeeder"; label: string }[] = []; const hasEvaporator = (part.inserts ?? []).some( (insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR", ); if (!hasEvaporator && firstActiveType(catalog, "EVAPORATOR")) { actions.push({ action: "evaporator", label: ADD_EVAPORATOR_LABEL }); } const feederType = firstActiveType(catalog, "FRAME_FEEDER"); if (feederType && freeFeederStarts(part, type, feederType, catalog).length > 0) { actions.push({ action: "frameFeeder", label: ADD_FRAME_FEEDER_LABEL }); } return actions; } function buildNodes( state: HiveState, catalog: HiveCatalog, expanded: ReadonlySet, canEdit: boolean, ): OutlineNode[] { const nodes: OutlineNode[] = []; const topDown = [...state.stack].reverse(); const addPart = ( part: HivePart, level: number, parentKey: string | null, position: number, setSize: number, range: SlotRange | null = null, ) => { const type = catalog.partTypes.get(part.partTypeId); const children = childrenOf(part, type, catalog); const actions = addActions(part, type, catalog, canEdit); const childCount = children.length + actions.length; const displayName = partDisplayName(state, catalog, part.id); nodes.push({ key: part.id, kind: "part", id: part.id, level, parentKey, childCount, name: range ? `F${rangeText(range)} · ${displayName}` : displayName, hint: hintOf(part, type, catalog), isBox: type?.kind === "BOX", isFloor: type?.kind === "FLOOR", hasState: (type?.stateSchema.length ?? 0) > 0, setSize, position, }); if (!expanded.has(part.id)) return; children.forEach((child, index) => { if (child.type === "insert") { addPart(child.part, level + 1, part.id, index + 1, childCount, child.range); return; } const label = [`R${child.frame.slot}`, FRAME_TYPE_LABELS[child.frame.type], child.frame.name] .filter(Boolean) .join(" · "); nodes.push({ key: `${part.id}#${child.frame.slot}`, kind: "frame", id: part.id, slot: child.frame.slot, level: level + 1, parentKey: part.id, childCount: 0, name: label, hint: null, isBox: false, isFloor: false, hasState: false, setSize: childCount, position: index + 1, }); }); actions.forEach((action, index) => { nodes.push({ key: `${part.id}#add-${action.action}`, kind: "action", id: part.id, action: action.action, level: level + 1, parentKey: part.id, childCount: 0, name: action.label, hint: null, isBox: false, isFloor: false, hasState: false, setSize: childCount, position: children.length + index + 1, }); }); }; topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length)); return nodes; } /** Moves focus to an item, e.g. back to the edited one when a sheet closes. */ export interface HiveOutlineHandle { focusPart(partId: string): void; focusFrame(boxId: string, slot: number): void; } /** Outline key and the part that must be expanded to show it; null when the part is unknown. */ function focusTargetOf( state: HiveState, catalog: HiveCatalog, partId: string, ): { key: string; expand: string | null } | null { for (const part of state.stack) { if (part.id === partId) return { key: partId, expand: null }; const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId); if (!insert) continue; // Floor inserts have no item of their own; their floor stands for them. const hidden = catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT"; return hidden ? { key: part.id, expand: null } : { key: partId, expand: part.id }; } return null; } export interface HiveOutlineProps { ref?: Ref; state: HiveState; catalog: HiveCatalog; canEdit: boolean; /** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */ announcement: string; /** `intent` is set by the box's „Futtertasche einsetzen“ action. */ onEditPart: (id: string, intent?: "frameFeeder") => void; onEditFrame: (boxId: string, slot: number) => void; } export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEditPart, onEditFrame }: HiveOutlineProps) { const view = useHiveView(); const [expanded, setExpanded] = useState>(() => new Set()); const [seenOpenBox, setSeenOpenBox] = useState(null); const [activeKey, setActiveKey] = useState(null); const itemRefs = useRef(new Map()); // A box opened elsewhere (3D, SVG) shows its frames here as well. if (view.openBoxId !== seenOpenBox) { setSeenOpenBox(view.openBoxId); if (view.openBoxId && !expanded.has(view.openBoxId)) setExpanded(new Set(expanded).add(view.openBoxId)); } const nodes = buildNodes(state, catalog, expanded, canEdit); const keySignature = nodes.map((node) => node.key).join("|"); const previousNodes = useRef(nodes); const focusWithin = useRef(false); // When the focused item disappears, focus moves to the nearest remaining // sibling, else its parent, else the first item. useLayoutEffect(() => { const before = previousNodes.current; previousNodes.current = nodes; if (!focusWithin.current || !activeKey) return; if (nodes.some((node) => node.key === activeKey)) return; const gone = before.findIndex((node) => node.key === activeKey); if (gone < 0) return; const present = new Set(nodes.map((node) => node.key)); const parentKey = before[gone].parentKey; const siblings = (list: OutlineNode[]) => list.filter((n) => n.parentKey === parentKey && present.has(n.key)); const next = siblings(before.slice(gone + 1))[0] ?? siblings(before.slice(0, gone)).reverse()[0]; const target = next?.key ?? (parentKey && present.has(parentKey) ? parentKey : nodes[0]?.key); if (target) itemRefs.current.get(target)?.focus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [keySignature]); // Focus requested for an item that only exists after its parent expands. const pendingFocus = useRef(null); useLayoutEffect(() => { const key = pendingFocus.current; pendingFocus.current = null; if (key) itemRefs.current.get(key)?.focus(); }); /** Expands a collapsed parent and focuses the item once it is rendered; otherwise does nothing. */ const requestFocusAfterExpand = (itemKey: string, parentKey: string | null): void => { if (!parentKey || expanded.has(parentKey)) return; pendingFocus.current = itemKey; setExpanded(new Set(expanded).add(parentKey)); }; useImperativeHandle(ref, () => ({ focusPart(partId) { const target = focusTargetOf(state, catalog, partId); if (!target) return; const element = itemRefs.current.get(target.key); if (element) { element.focus(); return; } requestFocusAfterExpand(target.key, target.expand); }, focusFrame(boxId, slot) { const key = `${boxId}#${slot}`; const element = itemRefs.current.get(key); if (element) { element.focus(); return; } requestFocusAfterExpand(key, boxId); }, })); const tabbableKey = nodes.some((node) => node.key === activeKey) ? activeKey : (nodes[0]?.key ?? null); const toggle = (key: string, open: boolean): void => { const next = new Set(expanded); if (open) next.add(key); else next.delete(key); setExpanded(next); }; const focusKey = (key: string | undefined): void => { if (key) itemRefs.current.get(key)?.focus(); }; const handleFocus = (node: OutlineNode): void => { focusWithin.current = true; setActiveKey(node.key); if (node.kind === "frame") { view.setHovered(null); view.setHoveredFrame({ boxId: node.id, slot: node.slot ?? 0 }); } else { view.setHovered(node.id); view.setHoveredFrame(null); } }; const handleBlur = (event: FocusEvent): void => { // A removed item also loses focus; the microtask lets the layout effect see the flag first. const tree = event.currentTarget.parentElement; queueMicrotask(() => { if (!tree?.contains(document.activeElement)) focusWithin.current = false; }); if (event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) return; view.setHovered(null); view.setHoveredFrame(null); }; const activateNode = (node: OutlineNode): void => { if (node.kind === "action") { if (!canEdit) return; if (node.action === "frameFeeder") onEditPart(node.id, "frameFeeder"); else onEditPart(node.id); return; } if (node.kind === "frame") { if (canEdit) onEditFrame(node.id, node.slot ?? 0); return; } if (node.isBox) { view.openBox(node.id); toggle(node.id, true); return; } if (node.hasState || node.isFloor) { if (canEdit) onEditPart(node.id); return; } if (node.childCount > 0) toggle(node.key, !expanded.has(node.key)); }; const handleKeyDown = (event: KeyboardEvent, node: OutlineNode, index: number): void => { const isOpen = expanded.has(node.key); switch (event.key) { case "ArrowDown": focusKey(nodes[index + 1]?.key); break; case "ArrowUp": focusKey(nodes[index - 1]?.key); break; case "Home": focusKey(nodes[0]?.key); break; case "End": focusKey(nodes[nodes.length - 1]?.key); break; case "ArrowRight": if (node.childCount === 0) break; if (!isOpen) toggle(node.key, true); else focusKey(nodes[index + 1]?.key); break; case "ArrowLeft": if (node.childCount > 0 && isOpen) toggle(node.key, false); else if (node.parentKey) focusKey(node.parentKey); break; case "Enter": case " ": activateNode(node); break; default: return; } event.preventDefault(); }; return (
{nodes.map((node, index) => { const isOpen = expanded.has(node.key); const isFrame = node.kind === "frame"; const isCurrentBox = node.isBox && view.openBoxId === node.id; return (
{ if (element) itemRefs.current.set(node.key, element); else itemRefs.current.delete(node.key); }} // aria-current marks the open box; aria-selected would suggest a selection model. // eslint-disable-next-line jsx-a11y/role-has-required-aria-props role="treeitem" aria-level={node.level} aria-setsize={node.setSize} aria-posinset={node.position} aria-expanded={node.childCount > 0 ? isOpen : undefined} aria-current={isCurrentBox ? "true" : undefined} tabIndex={node.key === tabbableKey ? 0 : -1} onFocus={() => handleFocus(node)} onBlur={handleBlur} onKeyDown={(event) => handleKeyDown(event, node, index)} onClick={() => { focusKey(node.key); activateNode(node); }} style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }} className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium" > {node.hint ? `${node.name} · ${node.hint}` : node.name}
); })}
{announcement}
); }