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)", () => {