import React from 'react'; import { cn } from '../../shared/utils'; import { useTokenColors } from '../../shared/token-color'; import type { ColorToken } from '../../shared/color-token'; import type { GraphEdge, GraphNode } from './graph.types'; export interface GraphNodeListProps extends Omit, 'onSelect'> { nodes: GraphNode[]; /** Used to list each node's relationships. */ edges?: GraphEdge[]; selectedId?: string | null; onSelect: (id: string | null) => void; /** Colors per node type, matching the graph's `typeTokens`. */ typeTokens?: ColorToken[]; title?: string; /** @default "Graph nodes" */ ariaLabel?: string; /** @default "No nodes to list" */ emptyLabel?: string; maxHeight?: string; /** Shows each node's relationships under its label. @default true */ showRelations?: boolean; } const DEFAULT_TYPE_TOKENS: ColorToken[] = [ '--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--primary', '--destructive', ]; /** * The graph's content as an operable list. * * @description * A canvas is one opaque element to a screen reader: it can carry a label, but * not the hundred nodes and relationships inside it. This gives the same * information and the same actions through plain keyboard and screen-reader * navigation, and shares `selectedId` with the graph in both directions. * * Relationships are listed per node in words — "é subclasse de → Documento" — * because the arrow that conveys them on the canvas conveys nothing when read * aloud. * * @ai-rules * 1. Share one `selectedId` between `` and this list. * 2. Pass the same `edges` so the relationships are readable, not just the node names. * 3. Do not hide it at small breakpoints — it is the accessible path to the same data. */ export const GraphNodeList = React.forwardRef( ( { nodes, edges = [], selectedId, onSelect, typeTokens = DEFAULT_TYPE_TOKENS, title, ariaLabel = 'Graph nodes', emptyLabel = 'No nodes to list', maxHeight, showRelations = true, className, ...props }, ref ) => { const colors = useTokenColors(typeTokens); const typeOrder = React.useMemo(() => { const seen: string[] = []; nodes.forEach(node => { if (node.type && !seen.includes(node.type)) seen.push(node.type); }); return seen; }, [nodes]); /** Outgoing and incoming relationships, phrased for reading aloud. */ const relationsByNode = React.useMemo(() => { if (!showRelations) return new Map(); const byNode = new Map(); const labelOf = (id: string) => nodes.find(n => n.id === id)?.label ?? id; edges.forEach(edge => { const predicate = edge.label ?? edge.relation ?? '—'; const out = byNode.get(edge.source) ?? []; out.push(`${predicate} → ${labelOf(edge.target)}`); byNode.set(edge.source, out); const incoming = byNode.get(edge.target) ?? []; incoming.push(`${labelOf(edge.source)} → ${predicate}`); byNode.set(edge.target, incoming); }); return byNode; }, [edges, nodes, showRelations]); const selectedIndex = nodes.findIndex(node => node.id === selectedId); const [activeIndex, setActiveIndex] = React.useState(0); React.useEffect(() => { if (selectedIndex >= 0) setActiveIndex(selectedIndex); }, [selectedIndex]); const itemRefs = React.useRef>([]); const focusItem = (index: number) => { const clamped = Math.max(0, Math.min(nodes.length - 1, index)); setActiveIndex(clamped); itemRefs.current[clamped]?.focus(); }; const handleKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case 'ArrowDown': event.preventDefault(); focusItem(activeIndex + 1); break; case 'ArrowUp': event.preventDefault(); focusItem(activeIndex - 1); break; case 'Home': event.preventDefault(); focusItem(0); break; case 'End': event.preventDefault(); focusItem(nodes.length - 1); break; case 'Enter': case ' ': event.preventDefault(); onSelect(nodes[activeIndex]?.id ?? null); break; case 'Escape': event.preventDefault(); onSelect(null); break; default: break; } }; return (
{title ? (

{title}

) : null} {nodes.length === 0 ? (

{emptyLabel}

) : (
    {nodes.map((node, index) => { const selected = node.id === selectedId; const relations = relationsByNode.get(node.id) ?? []; const token = node.colorToken ?? typeTokens[Math.max(0, typeOrder.indexOf(node.type ?? '')) % typeTokens.length]; return (
  • { itemRefs.current[index] = element; }} role="option" aria-selected={selected} tabIndex={index === activeIndex ? 0 : -1} onClick={() => onSelect(node.id)} onFocus={() => setActiveIndex(index)} className={cn( 'flex cursor-pointer items-start gap-2 px-3 py-2 text-xs outline-none', 'focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50' )} >
  • ); })}
)}
); } ); GraphNodeList.displayName = 'GraphNodeList';