diff --git a/.changeset/cyan-bees-appear.md b/.changeset/cyan-bees-appear.md new file mode 100644 index 0000000000..8b03f8ce2d --- /dev/null +++ b/.changeset/cyan-bees-appear.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[SR] Linear graph - add grab handle description and aria lives diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 82ffa14133..2443644c8c 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -209,6 +209,17 @@ export type PerseusStrings = { yIntercept: string; }) => string; srLinearGraphOriginIntercept: string; + srLinearGrabHandle: ({ + point1X, + point1Y, + point2X, + point2Y, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; srAngleSideAtCoordinates: ({ point, side, @@ -515,6 +526,12 @@ export const strings: { "Screenreader-only description of the line's intercept when the intercept is the graph's origin.", message: "The line crosses the x and y axes at the graph's origin.", }, + srLinearGrabHandle: { + context: + "Screenreader-only label on the grab handle for the line on a linear graph.", + message: + "Line from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.", + }, srAngleSideAtCoordinates: { context: "Screenreader-accessible description of the side / vertex of an angle graph", @@ -733,6 +750,8 @@ export const mockStrings: PerseusStrings = { `The line crosses the X-axis at ${xIntercept} comma 0 and the Y-axis at 0 comma ${yIntercept}.`, srLinearGraphOriginIntercept: "The line crosses the x and y axes at the graph's origin.", + srLinearGrabHandle: ({point1X, point1Y, point2X, point2Y}) => + `Line from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`, srAngleSideAtCoordinates: ({point, side, x, y}) => `Point ${point}, ${side} at ${x} comma ${y}`, srAngleVertexAtCoordinatesWithAngleMeasure: ({x, y, angleMeasure}) => diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap b/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap index 560d781cdf..b3e109b8af 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap @@ -16,15 +16,17 @@ exports[`Rendering Does NOT render extensions of line when option is disabled 1` > @@ -61,7 +63,7 @@ exports[`Rendering Does NOT render extensions of line when option is disabled 1` @@ -201,7 +205,7 @@ exports[`Rendering Does NOT render extensions of line when option is not provide @@ -395,7 +401,7 @@ exports[`Rendering Does render extensions of line when option is enabled 1`] = ` { const { + points: [start, end], ariaLabels, ariaDescribedBy, - onMoveLine = () => {}, - onMovePoint = () => {}, color, - points: [start, end], extend, + onMoveLine = () => {}, + onMovePoint = () => {}, } = props; + // Aria live states for (0) point 1, (1) point 2, and (2) grab handle. + // When moving an element, set its aria live to "polite" and the others + // to "off". Otherwise, other connected elements that move at the same + // time might override the currently focused element's aria live. + const [ariaLives, setAriaLives] = React.useState>([ + "off", + "off", + "off", + ]); + // We use separate focusableHandle elements, instead of letting the movable // points themselves be focusable, to allow the tab order of the points to // be different from the rendering order. We had to solve for the following @@ -59,28 +71,42 @@ export const MovableLine = (props: Props) => { useControlPoint({ ariaLabel: ariaLabels?.point1AriaLabel, ariaDescribedBy: ariaDescribedBy, + ariaLive: ariaLives[0], point: start, sequenceNumber: 1, color, - onMove: (p) => onMovePoint(0, p), + onMove: (p) => { + setAriaLives(["polite", "off", "off"]); + onMovePoint(0, p); + }, }); const {visiblePoint: visiblePoint2, focusableHandle: focusableHandle2} = useControlPoint({ ariaLabel: ariaLabels?.point2AriaLabel, ariaDescribedBy: ariaDescribedBy, + ariaLive: ariaLives[1], point: end, sequenceNumber: 2, color, - onMove: (p) => onMovePoint(1, p), + onMove: (p) => { + setAriaLives(["off", "polite", "off"]); + onMovePoint(1, p); + }, }); const line = ( { + setAriaLives(["off", "off", "polite"]); + onMoveLine(delta); + }} /> ); @@ -100,8 +126,9 @@ const defaultStroke = "var(--movable-line-stroke-color)"; type LineProps = { start: vec.Vector2; end: vec.Vector2; - onMove: (delta: vec.Vector2) => unknown; - stroke?: string | undefined; + ariaLabel?: string; + ariaDescribedBy?: string; + ariaLive?: AriaLive; /* Extends the line to the edge of the graph with an arrow */ extend?: | undefined @@ -109,10 +136,21 @@ type LineProps = { start: boolean; end: boolean; }; + stroke?: string | undefined; + onMove: (delta: vec.Vector2) => unknown; }; const Line = (props: LineProps) => { - const {start, end, onMove, extend, stroke = defaultStroke} = props; + const { + start, + end, + ariaLabel, + ariaDescribedBy, + ariaLive, + extend, + stroke = defaultStroke, + onMove, + } = props; const [startPtPx, endPtPx] = useTransformVectorsToPixels(start, end); const { @@ -150,9 +188,16 @@ const Line = (props: LineProps) => { {/** * This transparent line creates a nice big click/touch target. diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx index 6953d153b3..68b87e4a48 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx @@ -1,4 +1,5 @@ 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"; @@ -8,6 +9,7 @@ import {MafsGraph} from "../mafs-graph"; import {getBaseMafsGraphPropsForTests} from "../utils"; import type {InteractiveGraphState} from "../types"; +import type {UserEvent} from "@testing-library/user-event"; const baseMafsGraphProps = getBaseMafsGraphPropsForTests(); const baseLinearState: InteractiveGraphState = { @@ -27,7 +29,11 @@ const baseLinearState: InteractiveGraphState = { const overallGraphLabel = "A line 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, ); @@ -50,21 +56,54 @@ describe("Linear graph screen reader", () => { ); }); - test("should have aria labels for both points on the line", () => { + test.each` + element | index | expectedValue + ${"point1"} | ${0} | ${"Point 1 at -5 comma 5"} + ${"grabHandle"} | ${1} | ${"Line from -5 comma 5 to 5 comma 5."} + ${"point2"} | ${2} | ${"Point 2 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 + // Check aria-label and describedby on interactive elements. + // (The actual description text is tested separately below.) + expect(element).toHaveAttribute("aria-label", expectedValue); + }, + ); + + test.each` + element | index + ${"point1"} | ${0} + ${"grabHandle"} | ${1} + ${"point2"} | ${2} + `("should have aria describedby for $element on the line", ({index}) => { // Arrange render(); // Act - // eslint-disable-next-line testing-library/no-node-access - const points = screen.getAllByTestId("movable-point__focusable-handle"); + // Moveable elements: point 1, grab handle, point 2 + const movableElements = screen.getAllByRole("button"); + const element = movableElements[index]; // Assert - // Check aria-label for both points on the line. - expect(points[0]).toHaveAttribute( - "aria-label", - "Point 1 at -5 comma 5", + // Check aria-describedby on interactive elements. + // (The actual description text is tested separately below.) + // We don't know the exact ID because of React.useID(), but we can + // check the suffix. + expect(element.getAttribute("aria-describedby")).toContain( + "-intercept", ); - expect(points[1]).toHaveAttribute("aria-label", "Point 2 at 5 comma 5"); + expect(element.getAttribute("aria-describedby")).toContain("-slope"); }); test("points description should include points info", () => { @@ -87,7 +126,7 @@ describe("Linear graph screen reader", () => { ${"x intercept only"} | ${[[5, 5], [5, 2]]} | ${"The line crosses the X-axis at 5 comma 0."} ${"y intercept only"} | ${[[5, 5], [2, 5]]} | ${"The line crosses the Y-axis at 0 comma 5."} `( - `slope description should include slope info for $case`, + "slope description should include slope info for $case", ({coords, interceptDescription}) => { // Arrange render( @@ -115,7 +154,7 @@ describe("Linear graph screen reader", () => { ${"horizontal line"} | ${[[1, 1], [3, 1]]} | ${"Its slope is zero."} ${"vertical line"} | ${[[1, 1], [1, 3]]} | ${"Its slope is undefined."} `( - `slope description should include slope info for $case`, + "slope description should include slope info for $case", ({coords, slopeDescription}) => { // Arrange render( @@ -135,4 +174,68 @@ describe("Linear graph screen reader", () => { expect(linearGraph).toHaveTextContent(slopeDescription); }, ); + + 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", "Point 1 at -2 comma 3"); + expect(grabHandle).toHaveAttribute( + "aria-label", + "Line from -2 comma 3 to 3 comma 3.", + ); + expect(point2).toHaveAttribute("aria-label", "Point 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]); + }, + ); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx index b20f1d25c4..915084532d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx @@ -43,6 +43,12 @@ const LinearGraph = (props: LinearGraphProps, key: number) => { point2X: srFormatNumber(line[1][0], locale), point2Y: srFormatNumber(line[1][1], locale), }); + const grabHandleAriaLabel = strings.srLinearGrabHandle({ + point1X: srFormatNumber(line[0][0], locale), + point1Y: srFormatNumber(line[0][1], locale), + point2X: srFormatNumber(line[1][0], locale), + point2Y: srFormatNumber(line[1][1], locale), + }); // Slope description const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]); @@ -93,9 +99,11 @@ const LinearGraph = (props: LinearGraphProps, key: number) => { // Outer line minimal description aria-label={strings.srLinearGraph} aria-describedby={`${pointsDescriptionId} ${interceptDescriptionId} ${slopeDescriptionId}`} + role="figure" > {