diff --git a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx index bef096a76..ee1784df3 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx @@ -77,19 +77,7 @@ export default function SchemaGraph({ className, ...props }: SchemaGraphProps) { const handleSidebarSelectionChange = (item: SchemaGraphSelectionItem) => { setSelection(item); - setGraphSelection( - item.type === "vertex-type" - ? { - nodeIds: new Set([item.id]), - edgeIds: new Set(), - groupIds: new Set(), - } - : { - nodeIds: new Set(), - edgeIds: new Set([item.id]), - groupIds: new Set(), - }, - ); + setGraphSelection(toSelectedElements(item)); }; const hasSchemaData = nodes.length > 0; @@ -130,6 +118,17 @@ export default function SchemaGraph({ className, ...props }: SchemaGraphProps) { ); } +/** Maps a single schema graph selection item to the graph view's selected elements. */ +export function toSelectedElements( + item: SchemaGraphSelectionItem, +): SelectedElements { + return { + nodeIds: new Set(item.type === "vertex-type" ? [item.id] : []), + edgeIds: new Set(item.type === "edge-connection" ? [item.id] : []), + groupIds: new Set(), + }; +} + /** Maps raw graph selection elements to a SchemaGraphSelection. */ export function toSchemaGraphSelection( selected: SelectedElements, diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx index 1e2df592b..043801e7f 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + // @vitest-environment happy-dom import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -5,6 +7,7 @@ import { describe, expect, test, vi } from "vitest"; import type * as Core from "@/core"; +import { TooltipProvider } from "@/components"; import { createEdgeType, createVertexType, @@ -14,6 +17,10 @@ import { import { EdgeConnectionRow, PropertiesDetails } from "./Details"; +function renderWithTooltips(ui: ReactNode) { + return render({ui}); +} + vi.mock("@/hooks", async () => { const actual = await vi.importActual("@/hooks"); return { @@ -110,21 +117,38 @@ describe("EdgeConnectionRow", () => { expect(screen.queryAllByRole("button")).toHaveLength(0); }); - test("renders three buttons (source, edge, target) when onSelectionChange is provided", () => { - render( + test("renders the unselected vertex types as buttons", () => { + renderWithTooltips( + {}} + />, + ); + + expect(screen.getByRole("button", { name: "Person" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Company" })).toBeInTheDocument(); + expect(screen.getAllByRole("button")).toHaveLength(2); + }); + + test("does not make the selected vertex type clickable", () => { + renderWithTooltips( {}} />, ); - expect(screen.getAllByRole("button")).toHaveLength(3); + expect( + screen.queryByRole("button", { name: "Person" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Company" })).toBeInTheDocument(); }); test("calls onSelectionChange with the source vertex when source button is clicked", async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); - render( + renderWithTooltips( { test("calls onSelectionChange with the target vertex when target button is clicked", async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); - render( + renderWithTooltips( { test("calls onSelectionChange with the edge connection id when edge button is clicked", async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); - render( + renderWithTooltips( , ); - await user.click(screen.getByRole("button", { name: /knows/ })); + await user.click(screen.getByRole("button", { name: "knows" })); expect(onSelectionChange).toHaveBeenCalledWith({ type: "edge-connection", id: "Person-[knows]->Company", }); }); + + test("names the edge button by its label without the decorative arrows", () => { + renderWithTooltips( + {}} + />, + ); + + expect(screen.getByRole("button", { name: "knows" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /→/ })).not.toBeInTheDocument(); + }); + + test("explains via tooltip that activating a type changes the selection", async () => { + const user = userEvent.setup(); + renderWithTooltips( + {}} + />, + ); + + await user.tab(); + + expect( + await screen.findAllByText("Change selection to Person"), + ).not.toHaveLength(0); + }); }); diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx index cb4cfa897..4e201326e 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx @@ -1,4 +1,4 @@ -import type { ComponentPropsWithRef } from "react"; +import type { ComponentPropsWithRef, CSSProperties, ReactNode } from "react"; import { ListIcon } from "lucide-react"; @@ -10,6 +10,9 @@ import { EmptyStateIcon, EmptyStateTitle, toHumanString, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components"; import { createEdgeConnectionId, @@ -125,6 +128,14 @@ export function PropertiesDetails({ ); } +/** + * Decorative arrow between the parts of an edge connection. Hidden from assistive + * technology so the type buttons keep clean accessible names. + */ +function EdgeConnectionSeparator() { + return {`${ASCII.NBSP}${ASCII.RARR} `}; +} + /** * Renders an edge connection as "SourceType → EdgeType → TargetType" with the * selected vertex type highlighted. @@ -144,29 +155,9 @@ export function EdgeConnectionRow({ edgeConnection: EdgeConnection; onSelectionChange?: (item: SchemaGraphSelectionItem) => void; }) { - const handleSourceClick = onSelectionChange - ? () => - onSelectionChange({ - type: "vertex-type", - id: edgeConnection.sourceVertexType, - }) - : undefined; - - const handleTargetClick = onSelectionChange - ? () => - onSelectionChange({ - type: "vertex-type", - id: edgeConnection.targetVertexType, - }) - : undefined; - - const handleEdgeClick = onSelectionChange - ? () => - onSelectionChange({ - type: "edge-connection", - id: createEdgeConnectionId(edgeConnection), - }) - : undefined; + function selectionHandlerFor(item: SchemaGraphSelectionItem) { + return onSelectionChange ? () => onSelectionChange(item) : undefined; + } return (

+ +

); } -const interactiveTextStyles = - "cursor-pointer bg-transparent p-0 font-[inherit] hover:text-text-primary focus-visible:ring-ring focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"; +/** + * Renders a type label. It becomes a tooltip-wrapped button when an `onClick` is + * provided and the type is not already selected; the currently selected type is + * never clickable and renders as a plain span. Decorative separators are rendered + * by the parent as `aria-hidden` siblings, so the button text is the accessible name. + * + * The `data-selected` styling lives only on the span branch: a button is rendered + * only when the type is not selected, so it never needs the selected variants. + */ +function ClickableTypeText({ + label, + selected, + onClick, + className, + style, + children, +}: { + label: string; + selected: boolean; + onClick?: () => void; + className?: string; + style?: CSSProperties; + children: ReactNode; +}) { + if (selected || !onClick) { + return ( + + {children} + + ); + } + + return ( + + + + + Change selection to {label} + + ); +} function EdgeTypeText({ selected, @@ -205,28 +260,16 @@ function EdgeTypeText({ onClick?: () => void; }) { const { displayLabel } = useDisplayEdgeTypeConfig(edgeType); - const labelText = `${ASCII.NBSP}${ASCII.RARR} ${displayLabel}${ASCII.NBSP}${ASCII.RARR} `; - const baseClass = - "data-selected:text-text-primary italic data-selected:font-bold"; - const dataSelected = selected ? true : undefined; - - if (onClick) { - return ( - - ); - } return ( - - {labelText} - + + {displayLabel} + ); } @@ -241,32 +284,16 @@ function VertexTypeText({ }) { const style = useVertexPreferences(vertexType); const { displayLabel } = useDisplayVertexTypeConfig(vertexType); - const baseClass = - "data-selected:text-text-primary underline decoration-2 underline-offset-4 data-selected:font-bold"; - const dataSelected = selected ? true : undefined; - const inlineStyle = { textDecorationColor: style.color }; - - if (onClick) { - return ( - - ); - } return ( - {displayLabel} - + ); } diff --git a/packages/graph-explorer/src/modules/SchemaGraph/toSchemaGraphSelection.test.ts b/packages/graph-explorer/src/modules/SchemaGraph/toSchemaGraphSelection.test.ts index acb19a751..28c33fc99 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/toSchemaGraphSelection.test.ts +++ b/packages/graph-explorer/src/modules/SchemaGraph/toSchemaGraphSelection.test.ts @@ -1,7 +1,7 @@ import { createEdgeType, createVertexType } from "@/core"; import { createEdgeConnectionId } from "@/core/StateProvider/edgeConnectionId"; -import { toSchemaGraphSelection } from "./SchemaGraph"; +import { toSchemaGraphSelection, toSelectedElements } from "./SchemaGraph"; function createSelectedElements( nodeIds: string[] = [], @@ -74,3 +74,26 @@ describe("toSchemaGraphSelection", () => { }); }); }); + +describe("toSelectedElements", () => { + test("maps a vertex-type item to a single selected node", () => { + const result = toSelectedElements({ + type: "vertex-type", + id: createVertexType("Person"), + }); + + expect(result).toStrictEqual(createSelectedElements(["Person"])); + }); + + test("maps an edge-connection item to a single selected edge", () => { + const edgeId = createEdgeConnectionId({ + sourceVertexType: createVertexType("Person"), + edgeType: createEdgeType("knows"), + targetVertexType: createVertexType("Company"), + }); + + const result = toSelectedElements({ type: "edge-connection", id: edgeId }); + + expect(result).toStrictEqual(createSelectedElements([], [edgeId])); + }); +});