Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions packages/memory-graph/src/__tests__/controls-a11y.e2e.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Mounted-render accessibility checks for the graph control surfaces:
* accessible names on icon-only zoom buttons, disclosure state on the legend,
* and a live region on the loading indicator.
*/

// @vitest-environment happy-dom

import { cleanup, fireEvent, render } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"

afterEach(cleanup)

import { Legend } from "../components/legend"
import { LoadingIndicator } from "../components/loading-indicator"
import { NavigationControls } from "../components/navigation-controls"
import { DEFAULT_COLORS, DEFAULT_LABELS } from "../constants"
import type { GraphNode } from "../types"

const node: GraphNode = {
id: "doc-1",
type: "document",
x: 0,
y: 0,
data: {
id: "doc-1",
title: "Doc",
summary: null,
type: "text",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
memories: [],
},
size: 40,
borderColor: "#fff",
isHovered: false,
isDragging: false,
}

const noop = () => {}

describe("NavigationControls accessibility", () => {
it("gives the icon-only zoom buttons accessible names", () => {
const { getByLabelText } = render(
<NavigationControls
onCenter={noop}
onZoomIn={noop}
onZoomOut={noop}
onAutoFit={noop}
nodes={[node]}
zoomLevel={100}
colors={DEFAULT_COLORS}
/>,
)

expect(getByLabelText("Zoom in")).toBeTruthy()
expect(getByLabelText("Zoom out")).toBeTruthy()
})
})

describe("Legend accessibility", () => {
it("exposes disclosure state via aria-expanded and toggles it", () => {
const { getByRole } = render(
<Legend
nodes={[node]}
edges={[]}
colors={DEFAULT_COLORS}
labels={DEFAULT_LABELS}
/>,
)

const toggle = getByRole("button", { name: "Legend" })
expect(toggle.getAttribute("aria-expanded")).toBe("false")

fireEvent.click(toggle)
expect(toggle.getAttribute("aria-expanded")).toBe("true")
})
})

describe("LoadingIndicator accessibility", () => {
it("announces loading through a status live region", () => {
const { getByRole } = render(
<LoadingIndicator
isLoading
isLoadingMore={false}
totalLoaded={0}
colors={DEFAULT_COLORS}
labels={DEFAULT_LABELS}
/>,
)

const status = getByRole("status")
expect(status.getAttribute("aria-live")).toBe("polite")
})
})
2 changes: 2 additions & 0 deletions packages/memory-graph/src/components/legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function StatRow({
onClick={expandable ? onToggle : undefined}
style={buttonStyle}
type="button"
aria-expanded={expandable ? expanded : undefined}
>
<div style={leftStyle}>
{icon}
Expand Down Expand Up @@ -428,6 +429,7 @@ export const Legend = memo(function Legend({
onClick={() => setIsExpanded(!isExpanded)}
style={headerBtnStyle}
type="button"
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDownIcon color={colors.textPrimary} />
Expand Down
4 changes: 2 additions & 2 deletions packages/memory-graph/src/components/loading-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const LoadingIndicator = memo<
}

return (
<div style={containerStyle}>
<output style={containerStyle} aria-live="polite">
<div style={flexStyle}>
<svg
aria-hidden="true"
Expand All @@ -96,7 +96,7 @@ export const LoadingIndicator = memo<
: labels.loadingMoreDocuments(totalLoaded)}
</span>
</div>
</div>
</output>
)
},
)
Expand Down
10 changes: 8 additions & 2 deletions packages/memory-graph/src/components/navigation-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,27 +168,33 @@ export const NavigationControls = memo<NavigationControlsProps>(
onClick={onZoomOut}
style={zoomBtnStyle}
type="button"
aria-label="Zoom out"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>−</span>
<span aria-hidden="true" style={{ fontSize: 12 }}>
</span>
</button>
<button
onClick={onZoomIn}
style={zoomBtnStyle}
type="button"
aria-label="Zoom in"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>+</span>
<span aria-hidden="true" style={{ fontSize: 12 }}>
+
</span>
</button>
</div>
</div>
Expand Down
Loading