diff --git a/.changeset/fuzzy-cheetahs-develop.md b/.changeset/fuzzy-cheetahs-develop.md new file mode 100644 index 0000000000..392b529878 --- /dev/null +++ b/.changeset/fuzzy-cheetahs-develop.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +adds aria labels to line segment diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index cfe30d3d43..eba783c769 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -260,6 +260,68 @@ export type PerseusStrings = { endingSideX: string; endingSideY: string; }) => string; + srSingleSegmentGraphAriaLabel: string; + srMultipleSegmentGraphAriaLabel: ({ + countOfSegments, + }: { + countOfSegments: number; + }) => string; + srMultipleSegmentIndividualLabel: ({ + point1X, + point1Y, + point2X, + point2Y, + indexOfSegment, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + indexOfSegment: number; + }) => string; + srSingleSegmentLabel: ({ + point1X, + point1Y, + point2X, + point2Y, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; + srSegmentLength: ({length}: {length: string}) => string; + srSingleSegmentGraphEndpointAriaLabel: ({ + endpointNumber, + x, + y, + }: { + endpointNumber: number; + x: string; + y: string; + }) => string; + srMultipleSegmentGraphEndpointAriaLabel: ({ + endpointNumber, + x, + y, + indexOfSegment, + }: { + endpointNumber: number; + x: string; + y: string; + indexOfSegment: number; + }) => string; + srSegmentGrabHandle: ({ + point1X, + point1Y, + point2X, + point2Y, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; srLinearSystemGraph: string; srLinearSystemPoints: ({ lineNumber, @@ -525,6 +587,20 @@ export const strings = { srAngleGraphAriaLabel: "An angle on a coordinate plane.", srAngleGraphAriaDescription: "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the starting side at %(startingSideX)s comma %(startingSideY)s and a point on the ending side at %(endingSideX)s comma %(endingSideY)s", + srSingleSegmentGraphAriaLabel: "A line segment on a coordinate plane.", + srMultipleSegmentGraphAriaLabel: + "%(countOfSegments)s line segments on a coordinate plane.", + srMultipleSegmentIndividualLabel: + "Segment %(indexOfSegment)s: Endpoint 1 at %(point1X)s comma %(point1Y)s. Endpoint 2 %(point2X)s comma %(point2Y)s.", + srSingleSegmentLabel: + "Endpoint 1 at %(point1X)s comma %(point1Y)s. Endpoint 2 %(point2X)s comma %(point2Y)s.", + srSegmentLength: "Segment length %(length)s units.", + srSingleSegmentGraphEndpointAriaLabel: + "Endpoint %(endpointNumber)s at %(x)s comma %(y)s.", + srMultipleSegmentGraphEndpointAriaLabel: + "Endpoint %(endpointNumber)s on segment %(indexOfSegment)s at %(x)s comma %(y)s.", + srSegmentGrabHandle: + "Segment from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.", srLinearSystemGraph: "Two lines on a coordinate plane.", srLinearSystemPoints: "Line %(lineNumber)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.", @@ -759,6 +835,31 @@ export const mockStrings: PerseusStrings = { endingSideY, }) => `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the starting side at ${startingSideX} comma ${startingSideY} and a point on the ending side at ${endingSideX} comma ${endingSideY}.`, + srSingleSegmentGraphAriaLabel: "A line segment on a coordinate plane.", + srMultipleSegmentGraphAriaLabel: ({countOfSegments}) => + `${countOfSegments} segments on a coordinate plane.`, + srMultipleSegmentIndividualLabel: ({ + point1X, + point1Y, + point2X, + point2Y, + indexOfSegment, + }) => + `Segment ${indexOfSegment}: Endpoint 1 at ${point1X} comma ${point1Y}. Endpoint 2 at ${point2X} comma ${point2Y}.`, + srSingleSegmentLabel: ({point1X, point1Y, point2X, point2Y}) => + `Endpoint 1 at ${point1X} comma ${point1Y}. Endpoint 2 at ${point2X} comma ${point2Y}.`, + srSegmentLength: ({length}) => `Segment length ${length} units.`, + srSingleSegmentGraphEndpointAriaLabel: ({endpointNumber, x, y}) => + `Endpoint ${endpointNumber} at ${x} comma ${y}.`, + srMultipleSegmentGraphEndpointAriaLabel: ({ + endpointNumber, + x, + y, + indexOfSegment, + }) => + `Endpoint ${endpointNumber} on segment ${indexOfSegment} at ${x} comma ${y}.`, + srSegmentGrabHandle: ({point1X, point1Y, point2X, point2Y}) => + `Segment from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`, srLinearSystemGraph: "Two lines on a coordinate plane.", srLinearSystemPoints: ({lineNumber, point1X, point1Y, point2X, point2Y}) => `Line ${lineNumber} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`, diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/segment.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/segment.test.tsx new file mode 100644 index 0000000000..58b2b93a13 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/segment.test.tsx @@ -0,0 +1,421 @@ +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 {describeSegmentGraph} from "./segment"; + +import type {InteractiveGraphState} from "../types"; +import type {UserEvent} from "@testing-library/user-event"; + +const baseMafsGraphProps = getBaseMafsGraphPropsForTests(); +const baseSingleSegmentState: InteractiveGraphState = { + type: "segment", + coords: [ + [ + [-5, 5], + [5, 5], + ], + ], + hasBeenInteractedWith: false, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], +}; + +const baseMultipleSegmentState: InteractiveGraphState = { + type: "segment", + coords: [ + [ + [-5, 5], + [5, 5], + ], + [ + [-5, -5], + [5, -5], + ], + ], + hasBeenInteractedWith: false, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], +}; + +const singleGraphOverallLabel = "A line segment on a coordinate plane."; +const multipleGraphOverallLabel = "2 segments on a coordinate plane."; + +describe("Segment 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 single segment graph", () => { + // Arrange + render( + , + ); + + // Act + const segmentGraph = screen.getByLabelText(singleGraphOverallLabel); + + // Assert + expect(segmentGraph).toBeInTheDocument(); + expect(segmentGraph).toHaveAccessibleName( + "A line segment on a coordinate plane.", + ); + expect(segmentGraph).toHaveAccessibleDescription( + "Endpoint 1 at -5 comma 5. Endpoint 2 at 5 comma 5.", + ); + }); + + test("should have aria label and describedby for overall multiple segments graph", () => { + // Arrange + render( + , + ); + + // Act + const segmentGraph = screen.getByLabelText(multipleGraphOverallLabel); + + // Assert + expect(segmentGraph).toBeInTheDocument(); + expect(segmentGraph).toHaveAccessibleName( + "2 segments on a coordinate plane.", + ); + expect(segmentGraph).toHaveAccessibleDescription( + "Segment 1: Endpoint 1 at -5 comma 5. Endpoint 2 at 5 comma 5. Segment 2: Endpoint 1 at -5 comma -5. Endpoint 2 at 5 comma -5.", + ); + }); + + test.each` + element | index | expectedValue + ${"point1"} | ${0} | ${"Endpoint 1 at -5 comma 5."} + ${"grabHandle"} | ${1} | ${"Segment from -5 comma 5 to 5 comma 5."} + ${"point2"} | ${2} | ${"Endpoint 2 at 5 comma 5."} + `( + "should have aria label for $element on a single segment", + ({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.each` + element | index | expectedValue + ${"segment 1 point1"} | ${0} | ${"Endpoint 1 on segment 1 at -5 comma 5."} + ${"segment 1 grabHandle"} | ${1} | ${"Segment from -5 comma 5 to 5 comma 5."} + ${"segment 1 point2"} | ${2} | ${"Endpoint 2 on segment 1 at 5 comma 5."} + ${"segment 2 point1"} | ${3} | ${"Endpoint 1 on segment 2 at -5 comma -5."} + ${"segment 2 grabHandle"} | ${4} | ${"Segment from -5 comma -5 to 5 comma -5."} + ${"segment 2 point2"} | ${5} | ${"Endpoint 2 on segment 2 at 5 comma -5."} + `( + "should have aria label for $element on multiple segments", + ({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("Single segment points description should include points info", () => { + // Arrange + render( + , + ); + + // Act + const linearGraph = screen.getByLabelText(singleGraphOverallLabel); + + // Assert + expect(linearGraph).toHaveTextContent( + "Endpoint 1 at -5 comma 5. Endpoint 2 at 5 comma 5.", + ); + }); + + test("Multiple segments points description should include points info", () => { + // Arrange + render( + , + ); + + // Act + const linearGraph = screen.getByLabelText(multipleGraphOverallLabel); + + // Assert + expect(linearGraph).toHaveTextContent( + "Segment 1: Endpoint 1 at -5 comma 5. Endpoint 2 at 5 comma 5. Segment 2: Endpoint 1 at -5 comma -5. Endpoint 2 at 5 comma -5.", + ); + }); + + test("Single segment 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 segment graph. + expect(point1).toHaveAttribute( + "aria-label", + "Endpoint 1 at -2 comma 3.", + ); + expect(grabHandle).toHaveAttribute( + "aria-label", + "Segment from -2 comma 3 to 3 comma 3.", + ); + expect(point2).toHaveAttribute( + "aria-label", + "Endpoint 2 at 3 comma 3.", + ); + }); + + test("Multiple segment aria label reflects updated values", async () => { + // Arrange + + // Act + render( + , + ); + + const interactiveElements = screen.getAllByRole("button"); + const [ + seg1Point1, + seg1GrabHandle, + seg1Point2, + seg2Point1, + seg2GrabHandle, + seg2Point2, + ] = interactiveElements; + + // Assert + // Check updated aria-label for the segment graph. + expect(seg1Point1).toHaveAttribute( + "aria-label", + "Endpoint 1 on segment 1 at -2 comma 3.", + ); + expect(seg1GrabHandle).toHaveAttribute( + "aria-label", + "Segment from -2 comma 3 to 3 comma 3.", + ); + expect(seg1Point2).toHaveAttribute( + "aria-label", + "Endpoint 2 on segment 1 at 3 comma 3.", + ); + expect(seg2Point1).toHaveAttribute( + "aria-label", + "Endpoint 1 on segment 2 at -2 comma -3.", + ); + expect(seg2GrabHandle).toHaveAttribute( + "aria-label", + "Segment from -2 comma -3 to 3 comma -3.", + ); + expect(seg2Point2).toHaveAttribute( + "aria-label", + "Endpoint 2 on segment 2 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("describeSegmentGraph", () => { + test("describes a single segment", () => { + // Arrange + + // Act + const interactiveElementsString = describeSegmentGraph( + baseSingleSegmentState, + mockPerseusI18nContext, + ); + + // Assert + expect(interactiveElementsString).toBe( + "Interactive elements: Segment 1: Endpoint 1 at -5 comma 5. Endpoint 2 at 5 comma 5.", + ); + }); + + test("describes multiple segments", () => { + // Arrange + + // Act + const interactiveElementsString = describeSegmentGraph( + baseMultipleSegmentState, + mockPerseusI18nContext, + ); + + // Assert + expect(interactiveElementsString).toBe( + "Interactive elements: Segment 1: Endpoint 1 at -5 comma 5. Endpoint 2 at 5 comma 5. Segment 2: Endpoint 1 at -5 comma -5. Endpoint 2 at 5 comma -5.", + ); + }); + + test("describes a segment graph with updated points", () => { + // Arrange + + // Act + const interactiveElementsString = describeSegmentGraph( + { + ...baseSingleSegmentState, + coords: [ + [ + [-1, 2], + [3, 4], + ], + ], + }, + mockPerseusI18nContext, + ); + + // Assert + expect(interactiveElementsString).toBe( + "Interactive elements: Segment 1: Endpoint 1 at -1 comma 2. Endpoint 2 at 3 comma 4.", + ); + }); + + test("describes a segment graph with multiple segments and updated points", () => { + // Arrange + + // Act + const interactiveElementsString = describeSegmentGraph( + { + ...baseMultipleSegmentState, + coords: [ + [ + [-1, 2], + [3, 4], + ], + [ + [-1, -2], + [3, -4], + ], + ], + }, + mockPerseusI18nContext, + ); + + // Assert + expect(interactiveElementsString).toBe( + "Interactive elements: Segment 1: Endpoint 1 at -1 comma 2. Endpoint 2 at 3 comma 4. Segment 2: Endpoint 1 at -1 comma -2. Endpoint 2 at 3 comma -4.", + ); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx index b8e5b964d1..9fd7faa2d6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx @@ -1,13 +1,20 @@ +import {point as kpoint} from "@khanacademy/kmath"; import * as React from "react"; +import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; +import {X, Y} from "../math"; 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, MafsGraphProps, + PairOfPoints, SegmentGraphState, } from "../types"; import type {vec} from "mafs"; @@ -18,39 +25,178 @@ export function renderSegmentGraph( ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: null, + interactiveElementsDescription: ( + + ), }; } type SegmentProps = MafsGraphProps; -const SegmentGraph = (props: SegmentProps) => { - const {dispatch} = props; - const {coords: segments} = props.graphState; +const SegmentGraph = ({dispatch, graphState}: SegmentProps) => { + const {coords: segments} = graphState; + const {strings, locale} = usePerseusI18n(); + const segmentUniqueId = React.useId(); + const lengthDescriptionId = segmentUniqueId + "-length"; + const wholeGraphDescriptionId = segmentUniqueId + "-whole-graph"; + + function getWholeSegmentGraphAriaLabel(): string { + return segments?.length > 1 + ? strings.srMultipleSegmentGraphAriaLabel({ + countOfSegments: segments.length, + }) + : strings.srSingleSegmentGraphAriaLabel; + } + + const wholeSegmentGraphAriaLabel = getWholeSegmentGraphAriaLabel(); + + function getIndividualSegmentAriaLabel( + segment: PairOfPoints, + index: number, + ) { + if (segments.length === 1) { + return strings.srSingleSegmentLabel({ + point1X: srFormatNumber(segments[0][0][X], locale), + point1Y: srFormatNumber(segments[0][0][Y], locale), + point2X: srFormatNumber(segments[0][1][X], locale), + point2Y: srFormatNumber(segments[0][1][Y], locale), + }); + } + + return strings.srMultipleSegmentIndividualLabel({ + point1X: srFormatNumber(segment[0][X], locale), + point1Y: srFormatNumber(segment[0][Y], locale), + point2X: srFormatNumber(segment[1][X], locale), + point2Y: srFormatNumber(segment[1][Y], locale), + indexOfSegment: index + 1, + }); + } + + function getWholeSegmentGraphAriaDescription() { + return segments + .map((segment, index) => + getIndividualSegmentAriaLabel(segment, index), + ) + .join(" "); + } + + function formatSegment( + endpointNumber: number, + x: number, + y: number, + index: number, + ) { + const segObj = { + endpointNumber: endpointNumber, + x: srFormatNumber(x, locale), + y: srFormatNumber(y, locale), + }; + + return segments.length > 1 + ? strings.srMultipleSegmentGraphEndpointAriaLabel({ + ...segObj, + indexOfSegment: index, + }) + : strings.srSingleSegmentGraphEndpointAriaLabel(segObj); + } return ( - <> + {segments?.map((segment, i) => ( - { - dispatch(actions.segment.moveLine(i, delta)); - }} - onMovePoint={( - endpointIndex: number, - destination: vec.Vector2, - ) => { - dispatch( - actions.segment.movePointInFigure( - i, - endpointIndex, - destination, + + { + dispatch(actions.segment.moveLine(i, delta)); + }} + onMovePoint={( + endpointIndex: number, + destination: vec.Vector2, + ) => { + dispatch( + actions.segment.movePointInFigure( + i, + endpointIndex, + destination, + ), + ); + }} + ariaLabels={{ + point1AriaLabel: formatSegment( + 1, + segment[0][X], + segment[0][Y], + i + 1, + ), + point2AriaLabel: formatSegment( + 2, + segment[1][X], + segment[1][Y], + i + 1, ), - ); - }} - /> + grabHandleAriaLabel: strings.srSegmentGrabHandle({ + point1X: srFormatNumber(segment[0][X], locale), + point1Y: srFormatNumber(segment[0][Y], locale), + point2X: srFormatNumber(segment[1][X], locale), + point2Y: srFormatNumber(segment[1][Y], locale), + }), + }} + /> + + {strings.srSegmentLength({ + length: srFormatNumber( + getLengthOfSegment(segment), + locale, + ), + })} + + ))} - + + {getWholeSegmentGraphAriaDescription()} + + ); }; + +function getLengthOfSegment(segment: PairOfPoints) { + return kpoint.distanceToPoint(...segment); +} + +function SegmentGraphDescription({state}: {state: SegmentGraphState}) { + // The reason that SegmentGraphDescription is a component (rather than a + // function that returns a string) is because it needs to use a + // hook: `usePerseusI18n`. + const i18n = usePerseusI18n(); + return describeSegmentGraph(state, i18n); +} + +// Exported for testing +export function describeSegmentGraph( + state: SegmentGraphState, + i18n: I18nContextType, +): string { + const {strings, locale} = i18n; + + const segmentDescriptions = state.coords.map(([point1, point2], index) => + strings.srMultipleSegmentIndividualLabel({ + point1X: srFormatNumber(point1[X], locale), + point1Y: srFormatNumber(point1[Y], locale), + point2X: srFormatNumber(point2[X], locale), + point2Y: srFormatNumber(point2[Y], locale), + indexOfSegment: index + 1, + }), + ); + + return strings.srInteractiveElements({ + elements: segmentDescriptions.join(" "), + }); +} diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx index 1ce049aa1d..3ab059b049 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx @@ -34,6 +34,7 @@ import { linearQuestionWithDefaultCorrect, linearSystemQuestion, linearSystemQuestionWithDefaultCorrect, + noneQuestion, pointQuestion, pointQuestionWithDefaultCorrect, polygonQuestion, @@ -1382,7 +1383,7 @@ describe("Interactive Graph", function () { it("should not have an aria-label or description if they are not provided", async () => { // Arrange - const {container} = renderQuestion(segmentQuestion, apiOptions); + const {container} = renderQuestion(noneQuestion, apiOptions); // Act // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access 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 0f3ecfbe69..a097ce4fdc 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx @@ -154,8 +154,8 @@ describe("MafsGraph", () => { />, ); - expectLabelInDoc("Point 1 at 0 comma 0"); - expectLabelInDoc("Point 2 at -7 comma 0.5"); + expectLabelInDoc("Endpoint 1 at 0 comma 0."); + expectLabelInDoc("Endpoint 2 at -7 comma 0.5."); }); it("renders ARIA labels for each point (multiple segments)", () => { @@ -187,10 +187,10 @@ describe("MafsGraph", () => { />, ); - expectLabelInDoc("Point 1 at 0 comma 0"); - expectLabelInDoc("Point 2 at -7 comma 0.5"); - expectLabelInDoc("Point 1 at 1 comma 1"); - expectLabelInDoc("Point 2 at 7 comma 0.5"); + expectLabelInDoc("Endpoint 1 on segment 1 at 0 comma 0."); + expectLabelInDoc("Endpoint 2 on segment 1 at -7 comma 0.5."); + expectLabelInDoc("Endpoint 1 on segment 2 at 1 comma 1."); + expectLabelInDoc("Endpoint 2 on segment 2 at 7 comma 0.5."); }); it("renders ARIA labels for each point (linear)", () => {