Relationship Inspector

Right-dock panel listing inbound + outbound edges of the focused canvas object.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/relationship-inspector.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, } from "react"; import { cn } from "../../lib/utils"; /** * Direction of an edge relative to the focused object. * * @public */ export type RelationshipDirection = "inbound" | "outbound"; /** * One edge in the relationship list. * * @public */ export type RelationshipEdge = { /** Direction of the edge relative to the focused object. */ direction: RelationshipDirection; /** Stable identifier — used as the React key. */ id: string; /** Optional click handler — when provided, the row becomes a button. */ onActivate?: () => void; /** Relation kind (e.g. `"calls"`, `"emits"`, `"depends-on"`). */ relation: string; /** Target id (when outbound) or source id (when inbound). */ target: ReactNode; /** Optional secondary line beneath the row. */ targetSublabel?: ReactNode; }; /** * Localizable strings. * * @public */ export type RelationshipInspectorLabels = { /** Empty-state copy. Defaults to `"No relationships"`. */ empty?: string; /** Header for inbound edges. Defaults to `"Inbound"`. */ inbound?: string; /** Header for outbound edges. Defaults to `"Outbound"`. */ outbound?: string; /** Aria-label for the inspector. Defaults to `"Relationship inspector"`. */ region?: string; }; const DEFAULT_LABELS = { empty: "No relationships", inbound: "Inbound", outbound: "Outbound", region: "Relationship inspector", } as const satisfies Required<RelationshipInspectorLabels>; /** * Props for {@link RelationshipInspector}. * * @public */ export type RelationshipInspectorProps = { /** Edges to render. Empty list shows the empty state. */ edges: RelationshipEdge[]; /** Localizable strings. */ labels?: RelationshipInspectorLabels; /** Optional inspector title. Defaults to `"Relationships"`. */ title?: ReactNode; } & ComponentPropsWithoutRef<"section">; const ARROW_GLYPH: Record<RelationshipDirection, string> = { inbound: "←", outbound: "→", }; const RowBody = (props: { edge: RelationshipEdge }): React.ReactElement => { const { edge } = props; return ( <span className="flex flex-1 items-center gap-2"> <span aria-hidden="true" className="text-muted-foreground"> {ARROW_GLYPH[edge.direction]} </span> <span className="flex flex-1 flex-col text-left"> <span className="truncate text-xs text-foreground">{edge.target}</span> {edge.targetSublabel ? ( <span className="truncate text-[10px] text-muted-foreground"> {edge.targetSublabel} </span> ) : null} </span> <span className="rounded-full border border-border bg-background px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"> {edge.relation} </span> </span> ); }; const Row = (props: { edge: RelationshipEdge }): React.ReactElement => { const { edge } = props; if (edge.onActivate) { const handleActivateRelationship = (): void => { edge.onActivate?.(); }; return ( <button className="flex w-full items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-left transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-relationship-direction={edge.direction} data-relationship-row onClick={handleActivateRelationship} type="button" > <RowBody edge={edge} /> </button> ); } return ( <div className="flex w-full items-center gap-2 rounded-md px-2 py-1.5" data-relationship-direction={edge.direction} data-relationship-row > <RowBody edge={edge} /> </div> ); }; const Group = (props: { edges: RelationshipEdge[]; heading: string; }): null | React.ReactElement => { const { edges, heading } = props; if (edges.length === 0) { return null; } return ( <div className="space-y-1" data-relationship-group={heading.toLowerCase()}> <h4 className="px-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> {heading} </h4> <ul className="space-y-0.5"> {edges.map((edge) => ( <li key={edge.id}> <Row edge={edge} /> </li> ))} </ul> </div> ); }; /** * Inspector panel listing inbound + outbound edges of the focused * object. Each row shows direction arrow, target id, optional sublabel, * and the relation kind chip. Pure presentation; the host computes * edges from the runtime graph and supplies an optional click handler * to jump to the related object. * * @example * ```tsx * <RelationshipInspector * edges={[ * { id: "1", direction: "inbound", target: "research-2025", relation: "spawned-by" }, * { id: "2", direction: "outbound", target: "summary.md", relation: "emits" }, * ]} * /> * ``` * * @public */ export const RelationshipInspector = forwardRef< HTMLElement, RelationshipInspectorProps >((props, ref) => { const { className, edges, labels, title = "Relationships", ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const inbound = edges.filter((edge) => edge.direction === "inbound"); const outbound = edges.filter((edge) => edge.direction === "outbound"); return ( <section aria-label={resolvedLabels.region} className={cn( "flex w-full flex-col gap-2 rounded-lg border bg-background p-3 text-foreground", className, )} data-relationship-inspector ref={ref} {...rest} > <header> <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> {title} </h3> </header> {edges.length === 0 ? ( <p className="px-2 py-3 text-center text-xs text-muted-foreground" data-relationship-state="empty" > {resolvedLabels.empty} </p> ) : ( <div className="space-y-2"> <Group edges={inbound} heading={resolvedLabels.inbound} /> <Group edges={outbound} heading={resolvedLabels.outbound} /> </div> )} </section> ); }); RelationshipInspector.displayName = "RelationshipInspector";

Dependencies

  • @vllnt/ui@^0.2.1