- Umbauen: Fokus nach Rückgängig/Wiederholen am Ende auf den anderen Verlaufsknopf, nach Entfernen auf die Nachbarzeile (sonst Überschrift) - Umbauen: Hinweis „Beute wurde inzwischen geändert“ zuverlässig, der Stand wird bei Fokus/Sichtbarkeit und alle 30 s neu geladen, solange ein Entwurf besteht; Editor-Wurzel ist <main> - Verlauf: Futtertaschen-Füllstand als „Flüssigfutter 1,5 l“, Entfernen als „Futtertasche entfernt“; Gliederung ohne leere Werte („Boden · /“) - Durchsicht: Rückgängig der letzten Änderung behält den Schalter „Liste“ und setzt den Fokus auf die Überschrift der Änderungen Refs #21 #22 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
"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<HivePart["frames"]>[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<string>,
|
|
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<HiveOutlineHandle>;
|
|
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<ReadonlySet<string>>(() => new Set());
|
|
const [seenOpenBox, setSeenOpenBox] = useState<string | null>(null);
|
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
const itemRefs = useRef(new Map<string, HTMLDivElement>());
|
|
|
|
// 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<OutlineNode[]>(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<string | null>(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<HTMLDivElement>): 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<HTMLDivElement>, 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 (
|
|
<div>
|
|
<div role="tree" aria-label="Aufbau der Beute" className="flex flex-col gap-1">
|
|
{nodes.map((node, index) => {
|
|
const isOpen = expanded.has(node.key);
|
|
const isFrame = node.kind === "frame";
|
|
const isCurrentBox = node.isBox && view.openBoxId === node.id;
|
|
return (
|
|
<div
|
|
key={node.key}
|
|
ref={(element) => {
|
|
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"
|
|
>
|
|
<span className={isFrame ? "text-muted-foreground" : undefined}>
|
|
{node.hint ? `${node.name} · ${node.hint}` : node.name}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div role="status" aria-live="polite" className="sr-only">
|
|
{announcement}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|