fix: address presentation review findings
This commit is contained in:
@@ -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">▸</span>}
|
||||
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">▸</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;
|
||||
|
||||
Reference in New Issue
Block a user