diff --git a/.changeset/wild-keys-sit.md b/.changeset/wild-keys-sit.md
new file mode 100644
index 0000000000..6e9702456f
--- /dev/null
+++ b/.changeset/wild-keys-sit.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+[SR] Ray graph - Add screen reader support for Ray interactive graph
diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index 635556ecaf..8a7f81e329 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -285,6 +285,31 @@ export type PerseusStrings = {
x: string;
y: string;
}): string;
+ srRayGraph: string;
+ srRayPoints: ({
+ point1X,
+ point1Y,
+ point2X,
+ point2Y,
+ }: {
+ point1X: string;
+ point1Y: string;
+ point2X: string;
+ point2Y: string;
+ }) => string;
+ srRayEndpoint: ({x, y}: {x: string; y: string}) => string;
+ srRayTerminalPoint: ({x, y}: {x: string; y: string}) => string;
+ srRayGrabHandle: ({
+ point1X,
+ point1Y,
+ point2X,
+ point2Y,
+ }: {
+ point1X: string;
+ point1Y: string;
+ point2X: string;
+ point2Y: string;
+ }) => string;
// The above strings are used for interactive graph SR descriptions.
};
@@ -510,6 +535,13 @@ export const strings: {
"Line %(lineNumber)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
srLinearSystemPoint:
"Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.",
+ srRayGraph: "A ray on a coordinate plane.",
+ srRayPoints:
+ "The endpoint is at %(point1X)s comma %(point1Y)s and the ray goes through point %(point2X)s comma %(point2Y)s.",
+ srRayGrabHandle:
+ "Ray with endpoint %(point1X)s comma %(point1Y)s going through point %(point2X)s comma %(point2Y)s.",
+ srRayEndpoint: "Endpoint at %(point1X)s comma %(point1Y)s.",
+ srRayTerminalPoint: "Through point at %(point2X)s comma %(point2Y)s.",
// The above strings are used for interactive graph SR descriptions.
};
@@ -732,6 +764,13 @@ export const mockStrings: PerseusStrings = {
`Line ${lineNumber} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`,
srLinearSystemPoint: ({lineNumber, pointSequence, x, y}) =>
`Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`,
+ srRayGraph: "A ray on a coordinate plane.",
+ srRayPoints: ({point1X, point1Y, point2X, point2Y}) =>
+ `The endpoint is at ${point1X} comma ${point1Y} and the ray goes through point ${point2X} comma ${point2Y}.`,
+ srRayGrabHandle: ({point1X, point1Y, point2X, point2Y}) =>
+ `Ray with endpoint ${point1X} comma ${point1Y} going through point ${point2X} comma ${point2Y}.`,
+ srRayEndpoint: ({x, y}) => `Endpoint at ${x} comma ${y}.`,
+ srRayTerminalPoint: ({x, y}) => `Through point at ${x} comma ${y}.`,
// The above strings are used for interactive graph SR descriptions.
};
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
new file mode 100644
index 0000000000..f87795306f
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
@@ -0,0 +1,212 @@
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {Dependencies} from "@khanacademy/perseus";
+
+import {testDependencies} from "../../../../../../testing/test-dependencies";
+import {mockPerseusI18nContext} from "../../../components/i18n-context";
+import {MafsGraph} from "../mafs-graph";
+import {getBaseMafsGraphPropsForTests} from "../utils";
+
+import {describeRayGraph} from "./ray";
+
+import type {InteractiveGraphState} from "../types";
+import type {UserEvent} from "@testing-library/user-event";
+
+const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
+const baseRayState: InteractiveGraphState = {
+ type: "ray",
+ coords: [
+ [-5, 5],
+ [5, 5],
+ ],
+ hasBeenInteractedWith: false,
+ range: [
+ [-10, 10],
+ [-10, 10],
+ ],
+ snapStep: [1, 1],
+};
+
+const overallGraphLabel = "A ray on a coordinate plane.";
+
+describe("Linear graph screen reader", () => {
+ let userEvent: UserEvent;
+ beforeEach(() => {
+ userEvent = userEventLib.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ });
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+ testDependencies,
+ );
+ });
+
+ test("should have aria label and describedby for overall linear graph", () => {
+ // Arrange
+ render();
+
+ // Act
+ const linearGraph = screen.getByLabelText(
+ "A ray on a coordinate plane.",
+ );
+
+ // Assert
+ expect(linearGraph).toBeInTheDocument();
+ expect(linearGraph).toHaveAccessibleDescription(
+ "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
+ );
+ });
+
+ test.each`
+ element | index | expectedValue
+ ${"point1"} | ${0} | ${"Endpoint at -5 comma 5."}
+ ${"grabHandle"} | ${1} | ${"Ray with endpoint -5 comma 5 going through point 5 comma 5."}
+ ${"point2"} | ${2} | ${"Through point at 5 comma 5."}
+ `(
+ "should have aria label for $element on the line",
+ ({index, expectedValue}) => {
+ // Arrange
+ render();
+
+ // Act
+ // Moveable elements: point 1, grab handle, point 2
+ const movableElements = screen.getAllByRole("button");
+ const element = movableElements[index];
+
+ // Assert
+ expect(element).toHaveAttribute("aria-label", expectedValue);
+ },
+ );
+
+ test("points description should include points info", () => {
+ // Arrange
+ render();
+
+ // Act
+ const linearGraph = screen.getByLabelText(overallGraphLabel);
+
+ // Assert
+ expect(linearGraph).toHaveTextContent(
+ "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
+ );
+ });
+
+ test("aria label reflects updated values", async () => {
+ // Arrange
+
+ // Act
+ render(
+ ,
+ );
+
+ const interactiveElements = screen.getAllByRole("button");
+ const [point1, grabHandle, point2] = interactiveElements;
+
+ // Assert
+ // Check updated aria-label for the linear graph.
+ expect(point1).toHaveAttribute("aria-label", "Endpoint at -2 comma 3.");
+ expect(grabHandle).toHaveAttribute(
+ "aria-label",
+ "Ray with endpoint -2 comma 3 going through point 3 comma 3.",
+ );
+ expect(point2).toHaveAttribute(
+ "aria-label",
+ "Through point at 3 comma 3.",
+ );
+ });
+
+ test.each`
+ elementName | index
+ ${"point1"} | ${0}
+ ${"grabHandle"} | ${1}
+ ${"point2"} | ${2}
+ `(
+ "Should update the aria-live when $elementName is moved",
+ async ({index}) => {
+ // Arrange
+ render();
+ const interactiveElements = screen.getAllByRole("button");
+ const [point1, grabHandle, point2] = interactiveElements;
+ const movingElement = interactiveElements[index];
+
+ // Act - Move the element
+ movingElement.focus();
+ await userEvent.keyboard("{ArrowRight}");
+
+ const expectedAriaLive = ["off", "off", "off"];
+ expectedAriaLive[index] = "polite";
+
+ // Assert
+ expect(point1).toHaveAttribute("aria-live", expectedAriaLive[0]);
+ expect(grabHandle).toHaveAttribute(
+ "aria-live",
+ expectedAriaLive[1],
+ );
+ expect(point2).toHaveAttribute("aria-live", expectedAriaLive[2]);
+ },
+ );
+});
+
+describe("describeRayGraph", () => {
+ test("describes a default ray", () => {
+ // Arrange
+
+ // Act
+ const strings = describeRayGraph(baseRayState, mockPerseusI18nContext);
+
+ // Assert
+ expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
+ expect(strings.srRayPoints).toBe(
+ "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
+ );
+ expect(strings.srRayEndpoint).toBe("Endpoint at -5 comma 5.");
+ expect(strings.srRayTerminalPoint).toBe("Through point at 5 comma 5.");
+ expect(strings.srRayGrabHandle).toBe(
+ "Ray with endpoint -5 comma 5 going through point 5 comma 5.",
+ );
+ expect(strings.srRayInteractiveElement).toBe(
+ "Interactive elements: A ray on a coordinate plane. The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
+ );
+ });
+
+ test("describes a ray with updated points", () => {
+ // Arrange
+
+ // Act
+ const strings = describeRayGraph(
+ {
+ ...baseRayState,
+ coords: [
+ [-1, 2],
+ [3, 4],
+ ],
+ },
+ mockPerseusI18nContext,
+ );
+
+ // Assert
+ expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
+ expect(strings.srRayPoints).toBe(
+ "The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.",
+ );
+ expect(strings.srRayEndpoint).toBe("Endpoint at -1 comma 2.");
+ expect(strings.srRayTerminalPoint).toBe("Through point at 3 comma 4.");
+ expect(strings.srRayGrabHandle).toBe(
+ "Ray with endpoint -1 comma 2 going through point 3 comma 4.",
+ );
+ expect(strings.srRayInteractiveElement).toBe(
+ "Interactive elements: A ray on a coordinate plane. The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.",
+ );
+ });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
index 2880e7c5b0..fc9aa3e48d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
@@ -1,9 +1,13 @@
import * as React from "react";
+import {usePerseusI18n} from "../../../components/i18n-context";
+import a11y from "../../../util/a11y";
import {actions} from "../reducer/interactive-graph-action";
import {MovableLine} from "./components/movable-line";
+import {srFormatNumber} from "./screenreader-text";
+import type {I18nContextType} from "../../../components/i18n-context";
import type {
Dispatch,
InteractiveGraphElementSuite,
@@ -18,7 +22,7 @@ export function renderRayGraph(
): InteractiveGraphElementSuite {
return {
graph: ,
- interactiveElementsDescription: null,
+ interactiveElementsDescription: ,
};
}
@@ -33,16 +37,100 @@ const RayGraph = (props: Props) => {
const handleMovePoint = (pointIndex: number, newPoint: vec.Vector2) =>
dispatch(actions.ray.movePoint(pointIndex, newPoint));
+ const {strings, locale} = usePerseusI18n();
+ const id = React.useId();
+ const pointsDescriptionId = id + "-points";
+
+ // Aria label strings
+ const {
+ srRayGraph,
+ srRayPoints,
+ srRayEndpoint,
+ srRayTerminalPoint,
+ srRayGrabHandle,
+ } = describeRayGraph(props.graphState, {strings, locale});
+
// Ray graphs only have one line
return (
-
+
+
+ {/* Hidden elements to provide the descriptions for the
+ `aria-describedby` properties. */}
+
+ {srRayPoints}
+
+
);
};
+
+function RayGraphDescription({state}: {state: RayGraphState}) {
+ // The reason that RayGraphDescription is a component (rather than a
+ // function that returns a string) is because it needs to use a
+ // hook: `usePerseusI18n`.
+ const i18n = usePerseusI18n();
+ const strings = describeRayGraph(state, i18n);
+
+ return strings.srRayInteractiveElement;
+}
+
+// Exported for testing
+export function describeRayGraph(
+ state: RayGraphState,
+ i18n: I18nContextType,
+): Record {
+ const {coords: line} = state;
+ const {strings, locale} = i18n;
+
+ // Aria label strings
+ const srRayGraph = strings.srRayGraph;
+ const srRayPoints = strings.srRayPoints({
+ point1X: srFormatNumber(line[0][0], locale),
+ point1Y: srFormatNumber(line[0][1], locale),
+ point2X: srFormatNumber(line[1][0], locale),
+ point2Y: srFormatNumber(line[1][1], locale),
+ });
+ const srRayEndpoint = strings.srRayEndpoint({
+ x: srFormatNumber(line[0][0], locale),
+ y: srFormatNumber(line[0][1], locale),
+ });
+ const srRayTerminalPoint = strings.srRayTerminalPoint({
+ x: srFormatNumber(line[1][0], locale),
+ y: srFormatNumber(line[1][1], locale),
+ });
+ const srRayGrabHandle = strings.srRayGrabHandle({
+ point1X: srFormatNumber(line[0][0], locale),
+ point1Y: srFormatNumber(line[0][1], locale),
+ point2X: srFormatNumber(line[1][0], locale),
+ point2Y: srFormatNumber(line[1][1], locale),
+ });
+
+ const srRayInteractiveElement = strings.srInteractiveElements({
+ elements: [srRayGraph, srRayPoints].join(" "),
+ });
+
+ return {
+ srRayGraph,
+ srRayPoints,
+ srRayEndpoint,
+ srRayTerminalPoint,
+ srRayGrabHandle,
+ srRayInteractiveElement,
+ };
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
index 538d65ad13..0f3ecfbe69 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
@@ -278,8 +278,8 @@ describe("MafsGraph", () => {
/>,
);
- expectLabelInDoc("Point 1 at 0 comma 0");
- expectLabelInDoc("Point 2 at -7 comma 0.5");
+ expectLabelInDoc("Endpoint at 0 comma 0.");
+ expectLabelInDoc("Through point at -7 comma 0.5.");
});
it("renders ARIA labels for each point (circle)", () => {