feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)
- lib/hive: frontend port of the hive domain (ops, validator, facts, layout, follow-ups, describe texts, reason texts) running the backend fixtures, API client - hive card on the colony page (same height as Stammdaten): SVG view, accessible outline tree, frame/part sheets with optimistic save, conflict sheet, history with past states, init from template or another colony, inspection draft mode - follow-up toasts prefilling feeding and treatment forms (treatment end only with an open treatment) - settings page "Beutensysteme" (hive-systems:manage) - NumberStepper decimal steps, reactive motion level hook, holo tokens - spec: owner design decision 2026-10-16 (glass hologram, focus mode) The SVG view is the fallback; the 3D scene follows in L3. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0091b3d28b
commit
c608f5d405
87 files changed
+9493
-43
No files matched your search
@@ -0,0 +1,383 @@
|
||||
"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 { 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;
|
||||
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;
|
||||
if (type.stateSchema.length > 0) {
|
||||
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).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 …";
|
||||
|
||||
function buildNodes(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
expanded: ReadonlySet<string>,
|
||||
canEdit: boolean,
|
||||
): OutlineNode[] {
|
||||
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
|
||||
const nodes: OutlineNode[] = [];
|
||||
const topDown = [...state.stack].reverse();
|
||||
|
||||
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
const inserts = visibleInserts(part, catalog);
|
||||
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
|
||||
const isBox = type?.kind === "BOX";
|
||||
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
|
||||
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
|
||||
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
|
||||
nodes.push({
|
||||
key: part.id,
|
||||
kind: "part",
|
||||
id: part.id,
|
||||
level,
|
||||
parentKey,
|
||||
childCount,
|
||||
name: partDisplayName(state, catalog, part.id),
|
||||
hint: hintOf(part, type, catalog),
|
||||
isBox,
|
||||
isFloor: type?.kind === "FLOOR",
|
||||
hasState: (type?.stateSchema.length ?? 0) > 0,
|
||||
setSize,
|
||||
position,
|
||||
});
|
||||
if (!expanded.has(part.id)) return;
|
||||
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
|
||||
frames.forEach((frame, index) => {
|
||||
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
|
||||
nodes.push({
|
||||
key: `${part.id}#${frame.slot}`,
|
||||
kind: "frame",
|
||||
id: part.id,
|
||||
slot: frame.slot,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: label,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: inserts.length + index + 1,
|
||||
});
|
||||
});
|
||||
if (hasAddAction) {
|
||||
nodes.push({
|
||||
key: `${part.id}#add-evaporator`,
|
||||
kind: "action",
|
||||
id: part.id,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: ADD_EVAPORATOR_LABEL,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: childCount,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
onEditPart: (id: string) => 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) 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user