From d1adcd526f93c3ea4ed9d45b88b5401a74844c6e Mon Sep 17 00:00:00 2001
From: Kris McGinnes
Date: Mon, 22 Jun 2026 14:08:12 -0500
Subject: [PATCH] Add tooltips and refine clickable schema type affordances
Explain the click action with a tooltip on hover or keyboard focus, give
edge types a hover underline so they read as interactive, and fix the
edge button accessible name to drop the decorative arrows.
Extract a shared ClickableTypeText primitive and a toSelectedElements
helper alongside toSchemaGraphSelection, and align the focus ring with
the rest of the codebase.
---
.../src/modules/SchemaGraph/SchemaGraph.tsx | 25 ++-
.../SchemaGraph/Sidebar/Details.test.tsx | 68 ++++++-
.../modules/SchemaGraph/Sidebar/Details.tsx | 171 ++++++++++--------
.../toSchemaGraphSelection.test.ts | 25 ++-
4 files changed, 196 insertions(+), 93 deletions(-)
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]));
+ });
+});