fix(memory-graph): accessible names and states for graph controls

The graph control surfaces had a few screen-reader gaps in the published
component:

- The zoom in/out buttons rendered only a "+"/"−" glyph with no text, so they
  had no accessible name. Added aria-label ("Zoom in"/"Zoom out") and marked the
  glyphs aria-hidden.
- The Legend header and the expandable "Connections" row are disclosure
  buttons but never exposed aria-expanded, so assistive tech could not tell
  whether the section was open. Wired aria-expanded to the actual state.
- The loading indicator was a plain div, so the loading message was not
  announced. Switched it to an output element (implicit status role) with
  aria-live="polite".

Adds a mounted test covering the accessible names, the toggling aria-expanded,
and the loading live region.
This commit is contained in:
abhay-codes07 2026-07-24 11:38:10 +05:30
parent 3487666481
commit 54241f52cb
No known key found for this signature in database
4 changed files with 107 additions and 4 deletions

View file

@ -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")
})
})

View file

@ -244,6 +244,7 @@ function StatRow({
onClick={expandable ? onToggle : undefined}
style={buttonStyle}
type="button"
aria-expanded={expandable ? expanded : undefined}
>
<div style={leftStyle}>
{icon}
@ -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} />

View file

@ -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"
@ -96,7 +96,7 @@ export const LoadingIndicator = memo<
: labels.loadingMoreDocuments(totalLoaded)}
</span>
</div>
</div>
</output>
)
},
)

View file

@ -168,6 +168,7 @@ export const NavigationControls = memo<NavigationControlsProps>(
onClick={onZoomOut}
style={zoomBtnStyle}
type="button"
aria-label="Zoom out"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
@ -175,12 +176,15 @@ export const NavigationControls = memo<NavigationControlsProps>(
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"
}}
@ -188,7 +192,9 @@ export const NavigationControls = memo<NavigationControlsProps>(
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>+</span>
<span aria-hidden="true" style={{ fontSize: 12 }}>
+
</span>
</button>
</div>
</div>