Skip to content

Commit 0f8d11c

Browse files
authoredJan 16, 2025··
[SR] Ray graph - Add screen reader support for Ray interactive graph (#2036)
## Summary: Add the aria label and descriptions for the full graph and the interactive elements in the Linear System graph, based on the [SRUX doc](https://khanacademy.atlassian.net/wiki/spaces/LC/pages/3460366337/Ray). Issue: https://khanacademy.atlassian.net/browse/LEMS-1734 ## Test plan: `yarn jest packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx` Storybook http://localhost:6006/iframe.html?globals=&args=&id=perseuseditor-widgets-interactive-graph--interactive-graph-ray&viewMode=story https://github.com/user-attachments/assets/fd00be9c-a8a6-42ca-af44-6f4f2bd1a0d3 Author: nishasy Reviewers: catandthemachines, anakaren-rojas, nishasy Required Reviewers: Approved By: catandthemachines, anakaren-rojas Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x) Pull Request URL: #2036
1 parent d96821e commit 0f8d11c

File tree

5 files changed

+356
-12
lines changed

5 files changed

+356
-12
lines changed
 

‎.changeset/wild-keys-sit.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": patch
3+
---
4+
5+
[SR] Ray graph - Add screen reader support for Ray interactive graph

‎packages/perseus/src/strings.ts

+39
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,31 @@ export type PerseusStrings = {
285285
x: string;
286286
y: string;
287287
}): string;
288+
srRayGraph: string;
289+
srRayPoints: ({
290+
point1X,
291+
point1Y,
292+
point2X,
293+
point2Y,
294+
}: {
295+
point1X: string;
296+
point1Y: string;
297+
point2X: string;
298+
point2Y: string;
299+
}) => string;
300+
srRayEndpoint: ({x, y}: {x: string; y: string}) => string;
301+
srRayTerminalPoint: ({x, y}: {x: string; y: string}) => string;
302+
srRayGrabHandle: ({
303+
point1X,
304+
point1Y,
305+
point2X,
306+
point2Y,
307+
}: {
308+
point1X: string;
309+
point1Y: string;
310+
point2X: string;
311+
point2Y: string;
312+
}) => string;
288313
// The above strings are used for interactive graph SR descriptions.
289314
};
290315

@@ -510,6 +535,13 @@ export const strings: {
510535
"Line %(lineNumber)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
511536
srLinearSystemPoint:
512537
"Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.",
538+
srRayGraph: "A ray on a coordinate plane.",
539+
srRayPoints:
540+
"The endpoint is at %(point1X)s comma %(point1Y)s and the ray goes through point %(point2X)s comma %(point2Y)s.",
541+
srRayGrabHandle:
542+
"Ray with endpoint %(point1X)s comma %(point1Y)s going through point %(point2X)s comma %(point2Y)s.",
543+
srRayEndpoint: "Endpoint at %(point1X)s comma %(point1Y)s.",
544+
srRayTerminalPoint: "Through point at %(point2X)s comma %(point2Y)s.",
513545
// The above strings are used for interactive graph SR descriptions.
514546
};
515547

@@ -732,6 +764,13 @@ export const mockStrings: PerseusStrings = {
732764
`Line ${lineNumber} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`,
733765
srLinearSystemPoint: ({lineNumber, pointSequence, x, y}) =>
734766
`Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`,
767+
srRayGraph: "A ray on a coordinate plane.",
768+
srRayPoints: ({point1X, point1Y, point2X, point2Y}) =>
769+
`The endpoint is at ${point1X} comma ${point1Y} and the ray goes through point ${point2X} comma ${point2Y}.`,
770+
srRayGrabHandle: ({point1X, point1Y, point2X, point2Y}) =>
771+
`Ray with endpoint ${point1X} comma ${point1Y} going through point ${point2X} comma ${point2Y}.`,
772+
srRayEndpoint: ({x, y}) => `Endpoint at ${x} comma ${y}.`,
773+
srRayTerminalPoint: ({x, y}) => `Through point at ${x} comma ${y}.`,
735774
// The above strings are used for interactive graph SR descriptions.
736775
};
737776

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import {render, screen} from "@testing-library/react";
2+
import {userEvent as userEventLib} from "@testing-library/user-event";
3+
import * as React from "react";
4+
5+
import {Dependencies} from "@khanacademy/perseus";
6+
7+
import {testDependencies} from "../../../../../../testing/test-dependencies";
8+
import {mockPerseusI18nContext} from "../../../components/i18n-context";
9+
import {MafsGraph} from "../mafs-graph";
10+
import {getBaseMafsGraphPropsForTests} from "../utils";
11+
12+
import {describeRayGraph} from "./ray";
13+
14+
import type {InteractiveGraphState} from "../types";
15+
import type {UserEvent} from "@testing-library/user-event";
16+
17+
const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
18+
const baseRayState: InteractiveGraphState = {
19+
type: "ray",
20+
coords: [
21+
[-5, 5],
22+
[5, 5],
23+
],
24+
hasBeenInteractedWith: false,
25+
range: [
26+
[-10, 10],
27+
[-10, 10],
28+
],
29+
snapStep: [1, 1],
30+
};
31+
32+
const overallGraphLabel = "A ray on a coordinate plane.";
33+
34+
describe("Linear graph screen reader", () => {
35+
let userEvent: UserEvent;
36+
beforeEach(() => {
37+
userEvent = userEventLib.setup({
38+
advanceTimers: jest.advanceTimersByTime,
39+
});
40+
jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
41+
testDependencies,
42+
);
43+
});
44+
45+
test("should have aria label and describedby for overall linear graph", () => {
46+
// Arrange
47+
render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
48+
49+
// Act
50+
const linearGraph = screen.getByLabelText(
51+
"A ray on a coordinate plane.",
52+
);
53+
54+
// Assert
55+
expect(linearGraph).toBeInTheDocument();
56+
expect(linearGraph).toHaveAccessibleDescription(
57+
"The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
58+
);
59+
});
60+
61+
test.each`
62+
element | index | expectedValue
63+
${"point1"} | ${0} | ${"Endpoint at -5 comma 5."}
64+
${"grabHandle"} | ${1} | ${"Ray with endpoint -5 comma 5 going through point 5 comma 5."}
65+
${"point2"} | ${2} | ${"Through point at 5 comma 5."}
66+
`(
67+
"should have aria label for $element on the line",
68+
({index, expectedValue}) => {
69+
// Arrange
70+
render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
71+
72+
// Act
73+
// Moveable elements: point 1, grab handle, point 2
74+
const movableElements = screen.getAllByRole("button");
75+
const element = movableElements[index];
76+
77+
// Assert
78+
expect(element).toHaveAttribute("aria-label", expectedValue);
79+
},
80+
);
81+
82+
test("points description should include points info", () => {
83+
// Arrange
84+
render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
85+
86+
// Act
87+
const linearGraph = screen.getByLabelText(overallGraphLabel);
88+
89+
// Assert
90+
expect(linearGraph).toHaveTextContent(
91+
"The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
92+
);
93+
});
94+
95+
test("aria label reflects updated values", async () => {
96+
// Arrange
97+
98+
// Act
99+
render(
100+
<MafsGraph
101+
{...baseMafsGraphProps}
102+
state={{
103+
...baseRayState,
104+
// Different points than default (-5, 5) and (5, 5)
105+
coords: [
106+
[-2, 3],
107+
[3, 3],
108+
],
109+
}}
110+
/>,
111+
);
112+
113+
const interactiveElements = screen.getAllByRole("button");
114+
const [point1, grabHandle, point2] = interactiveElements;
115+
116+
// Assert
117+
// Check updated aria-label for the linear graph.
118+
expect(point1).toHaveAttribute("aria-label", "Endpoint at -2 comma 3.");
119+
expect(grabHandle).toHaveAttribute(
120+
"aria-label",
121+
"Ray with endpoint -2 comma 3 going through point 3 comma 3.",
122+
);
123+
expect(point2).toHaveAttribute(
124+
"aria-label",
125+
"Through point at 3 comma 3.",
126+
);
127+
});
128+
129+
test.each`
130+
elementName | index
131+
${"point1"} | ${0}
132+
${"grabHandle"} | ${1}
133+
${"point2"} | ${2}
134+
`(
135+
"Should update the aria-live when $elementName is moved",
136+
async ({index}) => {
137+
// Arrange
138+
render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
139+
const interactiveElements = screen.getAllByRole("button");
140+
const [point1, grabHandle, point2] = interactiveElements;
141+
const movingElement = interactiveElements[index];
142+
143+
// Act - Move the element
144+
movingElement.focus();
145+
await userEvent.keyboard("{ArrowRight}");
146+
147+
const expectedAriaLive = ["off", "off", "off"];
148+
expectedAriaLive[index] = "polite";
149+
150+
// Assert
151+
expect(point1).toHaveAttribute("aria-live", expectedAriaLive[0]);
152+
expect(grabHandle).toHaveAttribute(
153+
"aria-live",
154+
expectedAriaLive[1],
155+
);
156+
expect(point2).toHaveAttribute("aria-live", expectedAriaLive[2]);
157+
},
158+
);
159+
});
160+
161+
describe("describeRayGraph", () => {
162+
test("describes a default ray", () => {
163+
// Arrange
164+
165+
// Act
166+
const strings = describeRayGraph(baseRayState, mockPerseusI18nContext);
167+
168+
// Assert
169+
expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
170+
expect(strings.srRayPoints).toBe(
171+
"The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
172+
);
173+
expect(strings.srRayEndpoint).toBe("Endpoint at -5 comma 5.");
174+
expect(strings.srRayTerminalPoint).toBe("Through point at 5 comma 5.");
175+
expect(strings.srRayGrabHandle).toBe(
176+
"Ray with endpoint -5 comma 5 going through point 5 comma 5.",
177+
);
178+
expect(strings.srRayInteractiveElement).toBe(
179+
"Interactive elements: A ray on a coordinate plane. The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
180+
);
181+
});
182+
183+
test("describes a ray with updated points", () => {
184+
// Arrange
185+
186+
// Act
187+
const strings = describeRayGraph(
188+
{
189+
...baseRayState,
190+
coords: [
191+
[-1, 2],
192+
[3, 4],
193+
],
194+
},
195+
mockPerseusI18nContext,
196+
);
197+
198+
// Assert
199+
expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
200+
expect(strings.srRayPoints).toBe(
201+
"The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.",
202+
);
203+
expect(strings.srRayEndpoint).toBe("Endpoint at -1 comma 2.");
204+
expect(strings.srRayTerminalPoint).toBe("Through point at 3 comma 4.");
205+
expect(strings.srRayGrabHandle).toBe(
206+
"Ray with endpoint -1 comma 2 going through point 3 comma 4.",
207+
);
208+
expect(strings.srRayInteractiveElement).toBe(
209+
"Interactive elements: A ray on a coordinate plane. The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.",
210+
);
211+
});
212+
});

‎packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx

+98-10
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import * as React from "react";
22

3+
import {usePerseusI18n} from "../../../components/i18n-context";
4+
import a11y from "../../../util/a11y";
35
import {actions} from "../reducer/interactive-graph-action";
46

57
import {MovableLine} from "./components/movable-line";
8+
import {srFormatNumber} from "./screenreader-text";
69

10+
import type {I18nContextType} from "../../../components/i18n-context";
711
import type {
812
Dispatch,
913
InteractiveGraphElementSuite,
@@ -18,7 +22,7 @@ export function renderRayGraph(
1822
): InteractiveGraphElementSuite {
1923
return {
2024
graph: <RayGraph graphState={state} dispatch={dispatch} />,
21-
interactiveElementsDescription: null,
25+
interactiveElementsDescription: <RayGraphDescription state={state} />,
2226
};
2327
}
2428

@@ -33,16 +37,100 @@ const RayGraph = (props: Props) => {
3337
const handleMovePoint = (pointIndex: number, newPoint: vec.Vector2) =>
3438
dispatch(actions.ray.movePoint(pointIndex, newPoint));
3539

40+
const {strings, locale} = usePerseusI18n();
41+
const id = React.useId();
42+
const pointsDescriptionId = id + "-points";
43+
44+
// Aria label strings
45+
const {
46+
srRayGraph,
47+
srRayPoints,
48+
srRayEndpoint,
49+
srRayTerminalPoint,
50+
srRayGrabHandle,
51+
} = describeRayGraph(props.graphState, {strings, locale});
52+
3653
// Ray graphs only have one line
3754
return (
38-
<MovableLine
39-
points={line}
40-
onMoveLine={handleMoveLine}
41-
onMovePoint={handleMovePoint}
42-
extend={{
43-
start: false,
44-
end: true,
45-
}}
46-
/>
55+
<g
56+
// Outer line minimal description
57+
aria-label={srRayGraph}
58+
aria-describedby={pointsDescriptionId}
59+
>
60+
<MovableLine
61+
points={line}
62+
ariaLabels={{
63+
point1AriaLabel: srRayEndpoint,
64+
point2AriaLabel: srRayTerminalPoint,
65+
grabHandleAriaLabel: srRayGrabHandle,
66+
}}
67+
onMoveLine={handleMoveLine}
68+
onMovePoint={handleMovePoint}
69+
extend={{
70+
start: false,
71+
end: true,
72+
}}
73+
/>
74+
{/* Hidden elements to provide the descriptions for the
75+
`aria-describedby` properties. */}
76+
<g id={pointsDescriptionId} style={a11y.srOnly}>
77+
{srRayPoints}
78+
</g>
79+
</g>
4780
);
4881
};
82+
83+
function RayGraphDescription({state}: {state: RayGraphState}) {
84+
// The reason that RayGraphDescription is a component (rather than a
85+
// function that returns a string) is because it needs to use a
86+
// hook: `usePerseusI18n`.
87+
const i18n = usePerseusI18n();
88+
const strings = describeRayGraph(state, i18n);
89+
90+
return strings.srRayInteractiveElement;
91+
}
92+
93+
// Exported for testing
94+
export function describeRayGraph(
95+
state: RayGraphState,
96+
i18n: I18nContextType,
97+
): Record<string, string> {
98+
const {coords: line} = state;
99+
const {strings, locale} = i18n;
100+
101+
// Aria label strings
102+
const srRayGraph = strings.srRayGraph;
103+
const srRayPoints = strings.srRayPoints({
104+
point1X: srFormatNumber(line[0][0], locale),
105+
point1Y: srFormatNumber(line[0][1], locale),
106+
point2X: srFormatNumber(line[1][0], locale),
107+
point2Y: srFormatNumber(line[1][1], locale),
108+
});
109+
const srRayEndpoint = strings.srRayEndpoint({
110+
x: srFormatNumber(line[0][0], locale),
111+
y: srFormatNumber(line[0][1], locale),
112+
});
113+
const srRayTerminalPoint = strings.srRayTerminalPoint({
114+
x: srFormatNumber(line[1][0], locale),
115+
y: srFormatNumber(line[1][1], locale),
116+
});
117+
const srRayGrabHandle = strings.srRayGrabHandle({
118+
point1X: srFormatNumber(line[0][0], locale),
119+
point1Y: srFormatNumber(line[0][1], locale),
120+
point2X: srFormatNumber(line[1][0], locale),
121+
point2Y: srFormatNumber(line[1][1], locale),
122+
});
123+
124+
const srRayInteractiveElement = strings.srInteractiveElements({
125+
elements: [srRayGraph, srRayPoints].join(" "),
126+
});
127+
128+
return {
129+
srRayGraph,
130+
srRayPoints,
131+
srRayEndpoint,
132+
srRayTerminalPoint,
133+
srRayGrabHandle,
134+
srRayInteractiveElement,
135+
};
136+
}

‎packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,8 @@ describe("MafsGraph", () => {
278278
/>,
279279
);
280280

281-
expectLabelInDoc("Point 1 at 0 comma 0");
282-
expectLabelInDoc("Point 2 at -7 comma 0.5");
281+
expectLabelInDoc("Endpoint at 0 comma 0.");
282+
expectLabelInDoc("Through point at -7 comma 0.5.");
283283
});
284284

285285
it("renders ARIA labels for each point (circle)", () => {

0 commit comments

Comments
 (0)
Please sign in to comment.