fix: address presentation review findings

This commit is contained in:
lda
2026-07-08 06:23:58 +07:00 Verified
parent eb40c951d0
commit 20bf11dde1
29 changed files with 717 additions and 122 deletions
@@ -56,7 +56,7 @@ export const FigureNodeView = ({
<span className="figure-node__kind">{kindLabel[node.kind] ?? node.kind}</span>
<strong className="figure-node__label">{node.label}</strong>
<span className="figure-node__summary">{node.summary}</span>
{expandable && <span className="figure-node__expand-affance" aria-hidden="true"></span>}
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true"></span>}
{isActive && <span className="figure-node__current-marker">Current</span>}
</button>
);
@@ -107,6 +107,14 @@ describe("InteractiveFigure", () => {
expect(container.querySelector(".react-flow__handle-bottom")).toBeInTheDocument();
});
it("keeps one node tabbable when no node is marked current", () => {
renderFigure({ focusPath: [], activeNodeId: null });
expect(figureNode("client")).toHaveAttribute("tabindex", "0");
expect(figureNode("runtime")).toHaveAttribute("tabindex", "-1");
expect(figureNode("leaf")).toHaveAttribute("tabindex", "-1");
});
it("uses left and right handles for flow figures", () => {
const flowCatalog: FigureCatalogDefinition = {
...validCatalog,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
@@ -29,6 +29,7 @@ type FigureNodeData = {
readonly kind: FigureNodeKind;
readonly orientation: "horizontal" | "vertical";
readonly isActive: boolean;
readonly isFocused: boolean;
readonly isExpandable: boolean;
readonly onActivate: (nodeId: string) => void;
readonly onExpand: (nodeId: string) => void;
@@ -51,7 +52,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
data-expandable={expandable}
data-testid={`figure-node-${data.nodeId}`}
aria-label={accessibleName}
tabIndex={data.isActive ? 0 : -1}
tabIndex={data.isFocused ? 0 : -1}
onClick={() => {
data.onActivate(data.nodeId);
if (expandable) data.onExpand(data.nodeId);
@@ -66,7 +67,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
<span className="figure-node__kind">{data.kind}</span>
<strong className="figure-node__label">{data.label}</strong>
<span className="figure-node__summary">{data.summary}</span>
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>}
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">&#9656;</span>}
{data.isActive && <span className="figure-node__current-marker">Current</span>}
</button>
<Handle type="source" position={sourcePosition} id="source" />
@@ -100,12 +101,18 @@ const InteractiveFigureInner = ({
);
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
const containerRef = useRef<HTMLDivElement>(null);
const focusedNodeIdRef = useRef(activeNodeId ?? focus.figure.nodes[0]?.id ?? "");
const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId);
const focusedNodeIdRef = useRef(initialFocusedNodeId);
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
focusedNodeIdRef.current = fallbackFocusedNodeId;
}
useEffect(() => {
if (!fallbackFocusedNodeId) return;
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
focusedNodeIdRef.current = fallbackFocusedNodeId;
setFocusedNodeId(fallbackFocusedNodeId);
}
}, [activeNodeId, fallbackFocusedNodeId, layout.nodes]);
const handleExpand = useCallback(
(nodeId: string) => {
@@ -150,6 +157,7 @@ const InteractiveFigureInner = ({
event.stopPropagation();
const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
focusedNodeIdRef.current = nextId;
setFocusedNodeId(nextId);
const nextNode = containerRef.current?.querySelector(
`[data-testid="figure-node-${nextId}"]`,
);
@@ -161,6 +169,7 @@ const InteractiveFigureInner = ({
const handleActivateNode = useCallback((nodeId: string) => {
focusedNodeIdRef.current = nodeId;
setFocusedNodeId(nodeId);
}, []);
const rfNodes: Node[] = useMemo(
@@ -176,12 +185,13 @@ const InteractiveFigureInner = ({
kind: node.kind,
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
isActive: node.id === activeNodeId,
isFocused: node.id === focusedNodeId,
isExpandable: node.childFigureId !== undefined,
onActivate: handleActivateNode,
onExpand: handleExpand,
},
})),
[layout.definition.layout.kind, layout.nodes, activeNodeId, handleActivateNode, handleExpand],
[layout.definition.layout.kind, layout.nodes, activeNodeId, focusedNodeId, handleActivateNode, handleExpand],
);
const rfEdges: Edge[] = useMemo(
@@ -5,6 +5,7 @@ import {
disconnectedCyclicCatalog,
duplicateFigureCatalog,
duplicateNodeCatalog,
explicitFigureMissingPosition,
unknownChildCatalog,
unknownEdgeCatalog,
unknownRootCatalog,
@@ -27,4 +28,11 @@ describe("defineFigureCatalog", () => {
])("rejects %s", (_label, catalog, code) => {
expect(() => defineFigureCatalog(catalog)).toThrow(code);
});
it("rejects explicit layouts missing a node position", () => {
expect(() => defineFigureCatalog({
rootFigureId: explicitFigureMissingPosition.id,
figures: [explicitFigureMissingPosition],
})).toThrow("missing_explicit_position:explicit-missing:runtime");
});
});
@@ -9,6 +9,7 @@ export type FigureCatalogIssue =
| { readonly code: "unknown_root_figure"; readonly figureId: string }
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: string }
| { readonly code: "unknown_child_figure"; readonly figureId: string; readonly childFigureId: string }
| { readonly code: "missing_explicit_position"; readonly figureId: string; readonly nodeId: string }
| { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
const issueToCode = (issue: FigureCatalogIssue): string => {
@@ -23,6 +24,8 @@ const issueToCode = (issue: FigureCatalogIssue): string => {
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
case "unknown_child_figure":
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
case "missing_explicit_position":
return `missing_explicit_position:${issue.figureId}:${issue.nodeId}`;
case "child_cycle":
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`;
}
@@ -87,6 +90,15 @@ export const defineFigureCatalog = (
}
}
for (const figure of catalog.figures) {
if (figure.layout.kind !== "explicit") continue;
for (const node of figure.nodes) {
if (figure.layout.positions[node.id] === undefined) {
issues.push({ code: "missing_explicit_position", figureId: figure.id, nodeId: node.id });
}
}
}
// Detect child-figure cycles from every figure, not just the root, so
// disconnected subgraphs with cycles are also caught.
const edgeVisited = new Set<string>();
@@ -116,7 +116,7 @@
max-width: 100%;
}
.figure-node__expand-affance {
.figure-node__expand-affordance {
position: absolute;
top: 8px;
right: 8px;