diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 2443644c8c..d97c1ad712 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -258,6 +258,44 @@ export type PerseusStrings = { tsX: string; tsY: string; }) => string; + srLinearSystemGraph: string; + srLinearSystemPoints: ({ + lineSequence, + point1X, + point1Y, + point2X, + point2Y, + }: { + lineSequence: number; + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; + srLinearSystemPoint({ + lineSequence, + pointSequence, + x, + y, + }: { + lineSequence: number; + pointSequence: number; + x: string; + y: string; + }): string; + srLinearSystemGrabHandle: ({ + lineSequence, + point1X, + point1Y, + point2X, + point2Y, + }: { + lineSequence: number; + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; // The above strings are used for interactive graph SR descriptions. }; @@ -550,6 +588,25 @@ export const strings: { message: "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the initial side at %(isX)s comma %(isY)s and a point on the terminal side at %(tsX)s comma %(tsY)s", }, + srLinearSystemGraph: "Two lines on a coordinate plane.", + srLinearSystemPoints: { + context: + "Additional information about the points for a specific line within the linear system graph.", + message: + "Line %(lineSequence)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.", + }, + srLinearSystemPoint: { + context: + "Screenreader-accessible description of a point on a line within a linear system graph.", + message: + "Point %(pointSequence)s on line %(lineSequence)s at %(x)s comma %(y)s.", + }, + srLinearSystemGrabHandle: { + context: + "Screenreader-only label on the grab handle for a line within a linear system graph.", + message: + "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.", + }, // The above strings are used for interactive graph SR descriptions. }; @@ -767,5 +824,24 @@ export const mockStrings: PerseusStrings = { tsY, }) => `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the initial side at ${isX} comma ${isY} and a point on the terminal side at ${tsX} comma ${tsY}.`, + srLinearSystemGraph: "Two lines on a coordinate plane.", + srLinearSystemPoints: ({ + lineSequence, + point1X, + point1Y, + point2X, + point2Y, + }) => + `Line ${lineSequence} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`, + srLinearSystemPoint: ({lineSequence, pointSequence, x, y}) => + `Point ${pointSequence} on line ${lineSequence} at ${x} comma ${y}.`, + srLinearSystemGrabHandle: ({ + lineSequence, + point1X, + point1Y, + point2X, + point2Y, + }) => + `Line ${lineSequence} from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`, // The above strings are used for interactive graph SR descriptions. }; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx new file mode 100644 index 0000000000..d0b2183c34 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx @@ -0,0 +1,330 @@ +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 {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 baseLinearSystemState: InteractiveGraphState = { + type: "linear-system", + coords: [ + [ + [-5, 5], + [5, 5], + ], + [ + [-5, -5], + [5, -5], + ], + ], + hasBeenInteractedWith: false, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], +}; + +const overallGraphLabel = "Two lines on a coordinate plane."; + +describe("Linear System 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 system graph", () => { + // Arrange + render( + , + ); + + // Act + const linearSystemGraph = screen.getByLabelText( + "Two lines on a coordinate plane.", + ); + + // Assert + expect(linearSystemGraph).toBeInTheDocument(); + expect(linearSystemGraph).toHaveAttribute( + "aria-describedby", + ":r1:-line1-points :r1:-line1-intercept :r1:-line1-slope :r1:-line2-points :r1:-line2-intercept :r1:-line2-slope", + ); + }); + + test("should have aria labels and describedbys for both points and grab handle on the line", () => { + // Arrange + render( + , + ); + + // Act + // Moveable elements: point 1, grab handle, point 2 + const movableElements = screen.getAllByRole("button"); + const [ + line1Point1, + line1Grab, + line1Point2, + line2Point1, + line2Grab, + line2Point2, + ] = movableElements; + + // Assert + // Check aria-label and describedby on interactive elements. + // (The actual description text is tested separately below.) + expect(line1Point1).toHaveAttribute( + "aria-label", + "Point 1 on line 1 at -5 comma 5.", + ); + // We don't know the exact ID because of React.useID(), but we can + // check the suffix. + expect(line1Point1.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + expect(line1Point1.getAttribute("aria-describedby")).toContain( + "-slope", + ); + + expect(line1Grab).toHaveAttribute( + "aria-label", + "Line 1 from -5 comma 5 to 5 comma 5.", + ); + expect(line1Grab.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + expect(line1Grab.getAttribute("aria-describedby")).toContain("-slope"); + + expect(line1Point2).toHaveAttribute( + "aria-label", + "Point 2 on line 1 at 5 comma 5.", + ); + expect(line1Point2.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + expect(line1Point2.getAttribute("aria-describedby")).toContain( + "-slope", + ); + + expect(line2Point1).toHaveAttribute( + "aria-label", + "Point 1 on line 2 at -5 comma -5.", + ); + // We don't know the exact ID because of React.useID(), but we can + // check the suffix. + expect(line2Point1.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + expect(line2Point1.getAttribute("aria-describedby")).toContain( + "-slope", + ); + + expect(line2Grab).toHaveAttribute( + "aria-label", + "Line 2 from -5 comma -5 to 5 comma -5.", + ); + expect(line2Grab.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + expect(line2Grab.getAttribute("aria-describedby")).toContain("-slope"); + + expect(line2Point2).toHaveAttribute( + "aria-label", + "Point 2 on line 2 at 5 comma -5.", + ); + expect(line2Point2.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + expect(line2Point2.getAttribute("aria-describedby")).toContain( + "-slope", + ); + }); + + test("points description should include points info", () => { + // Arrange + render( + , + ); + + // Act + const linearSystemGraph = screen.getByLabelText(overallGraphLabel); + + // Assert + expect(linearSystemGraph).toHaveTextContent( + "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.", + ); + expect(linearSystemGraph).toHaveTextContent( + "Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.", + ); + }); + + // Test each line in the linear system graph separately. + describe.each` + lineSequence + ${1} + ${2} + `(`Line $lineSequence`, ({lineSequence}) => { + test.each` + case | coords | interceptDescription + ${"origin intercept"} | ${[[1, 1], [2, 2]]} | ${"The line crosses the x and y axes at the graph's origin."} + ${"both x and y intercepts"} | ${[[4, 4], [7, 1]]} | ${"The line crosses the X-axis at 8 comma 0 and the Y-axis at 0 comma 8."} + ${"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", + ({coords, interceptDescription}) => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineSequence - 1] = coords; + + render( + , + ); + + // Act + const linearSystemGraph = + screen.getByLabelText(overallGraphLabel); + + // Assert + expect(linearSystemGraph).toHaveTextContent( + interceptDescription, + ); + }, + ); + + test.each` + case | coords | slopeDescription + ${"positive slope"} | ${[[1, 1], [3, 3]]} | ${`Its slope increases from left to right.`} + ${"negative slope"} | ${[[3, 3], [1, 6]]} | ${`Its slope decreases from left to right.`} + ${"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", + ({coords, slopeDescription}) => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineSequence - 1] = coords; + + render( + , + ); + + // Act + const linearSystemGraph = + screen.getByLabelText(overallGraphLabel); + + // Assert + expect(linearSystemGraph).toHaveTextContent(slopeDescription); + }, + ); + + test("aria label reflects updated values", async () => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineSequence - 1] = [ + [-2, 3], + [3, 3], + ]; + + // Act + render( + , + ); + + const interactiveElements = screen.getAllByRole("button"); + + // Get interactive elements for this line. + const point1 = interactiveElements[0 + (lineSequence - 1) * 3]; + const grabHandle = interactiveElements[1 + (lineSequence - 1) * 3]; + const point2 = interactiveElements[2 + (lineSequence - 1) * 3]; + + // Assert + // Check updated aria-label for the linear graph. + expect(point1).toHaveAttribute( + "aria-label", + `Point 1 on line ${lineSequence} at -2 comma 3.`, + ); + expect(grabHandle).toHaveAttribute( + "aria-label", + `Line ${lineSequence} from -2 comma 3 to 3 comma 3.`, + ); + expect(point2).toHaveAttribute( + "aria-label", + `Point 2 on line ${lineSequence} 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-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx index 05ca19d704..0cd1d04c64 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx @@ -1,8 +1,11 @@ import * as React from "react"; +import {usePerseusI18n} from "../../../components/i18n-context"; import {actions} from "../reducer/interactive-graph-action"; import {MovableLine} from "./components/movable-line"; +import {srFormatNumber} from "./screenreader-text"; +import {getInterceptStringForLine, getSlopeStringForLine} from "./utils"; import type { MafsGraphProps, @@ -28,12 +31,70 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => { const {dispatch} = props; const {coords: lines} = props.graphState; + const {strings, locale} = usePerseusI18n(); + const id = React.useId(); + + const linesAriaInfo = lines.map((line, i) => { + return { + pointsDescriptionId: id + `-line${i + 1}-points`, + interceptDescriptionId: id + `-line${i + 1}-intercept`, + slopeDescriptionId: id + `-line${i + 1}-slope`, + pointsDescription: strings.srLinearSystemPoints({ + lineSequence: i + 1, + point1X: srFormatNumber(line[0][0], locale), + point1Y: srFormatNumber(line[0][1], locale), + point2X: srFormatNumber(line[1][0], locale), + point2Y: srFormatNumber(line[1][1], locale), + }), + interceptDescription: getInterceptStringForLine( + line, + strings, + locale, + ), + slopeDescription: getSlopeStringForLine(line, strings), + }; + }); + return ( - <> + + `${pointsDescriptionId} ${interceptDescriptionId} ${slopeDescriptionId}`, + ) + .join(" ")} + > {lines?.map((line, i) => ( { dispatch(actions.linearSystem.moveLine(i, delta)); }} @@ -56,7 +117,40 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => { color="var(--movable-line-stroke-color)" /> ))} - ; - + {linesAriaInfo.map( + ({ + pointsDescriptionId, + interceptDescriptionId, + slopeDescriptionId, + pointsDescription, + interceptDescription, + slopeDescription, + }) => ( + <> + + {pointsDescription} + + + {interceptDescription} + + + {slopeDescription} + + + ), + )} + ); }; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx index ccee745c4f..4cc8eff7c0 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx @@ -5,6 +5,7 @@ import {actions} from "../reducer/interactive-graph-action"; import {MovableLine} from "./components/movable-line"; import {srFormatNumber} from "./screenreader-text"; +import {getInterceptStringForLine, getSlopeStringForLine} from "./utils"; import type { MafsGraphProps, @@ -49,53 +50,14 @@ const LinearGraph = (props: LinearGraphProps, key: number) => { 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]); - let slopeString = ""; - if (slope === Infinity || slope === -Infinity) { - slopeString = strings.srLinearGraphSlopeVertical; - } else if (slope === 0) { - slopeString = strings.srLinearGraphSlopeHorizontal; - } else { - slopeString = - slope > 0 - ? strings.srLinearGraphSlopeIncreasing - : strings.srLinearGraphSlopeDecreasing; - } - - // Intersection description - const xIntercept = (0 - line[0][1]) / slope + line[0][0]; - const yIntercept = line[0][1] - slope * line[0][0]; - const hasXIntercept = xIntercept !== Infinity && xIntercept !== -Infinity; - const hasYIntercept = yIntercept !== Infinity && yIntercept !== -Infinity; - let interceptString; - if (hasXIntercept && hasYIntercept) { - // Describe both intercepts in the same sentence. - interceptString = - xIntercept === 0 && yIntercept === 0 - ? strings.srLinearGraphOriginIntercept - : strings.srLinearGraphBothIntercepts({ - xIntercept: srFormatNumber(xIntercept, locale), - yIntercept: srFormatNumber(yIntercept, locale), - }); - } else { - // Describe only one intercept. - interceptString = hasXIntercept - ? strings.srLinearGraphXOnlyIntercept({ - xIntercept: srFormatNumber(xIntercept, locale), - }) - : strings.srLinearGraphYOnlyIntercept({ - yIntercept: srFormatNumber(yIntercept, locale), - }); - } + const slopeString = getSlopeStringForLine(line, strings); + const interceptString = getInterceptStringForLine(line, strings, locale); // Linear graphs only have one line // (LEMS-2050): Update the reducer so that we have a separate action for moving one line // and another action for moving multiple lines return ( ): Array { return returnArray; } + +export function getSlopeStringForLine(line: PairOfPoints, strings): string { + const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]); + if (slope === Infinity || slope === -Infinity) { + return strings.srLinearGraphSlopeVertical; + } + + if (slope === 0) { + return strings.srLinearGraphSlopeHorizontal; + } + + return slope > 0 + ? strings.srLinearGraphSlopeIncreasing + : strings.srLinearGraphSlopeDecreasing; +} + +export function getInterceptStringForLine( + line: PairOfPoints, + strings, + locale, +): string { + const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]); + const xIntercept = (0 - line[0][1]) / slope + line[0][0]; + const yIntercept = line[0][1] - slope * line[0][0]; + const hasXIntercept = xIntercept !== Infinity && xIntercept !== -Infinity; + const hasYIntercept = yIntercept !== Infinity && yIntercept !== -Infinity; + + if (hasXIntercept && hasYIntercept) { + // Describe both intercepts in the same sentence. + return xIntercept === 0 && yIntercept === 0 + ? strings.srLinearGraphOriginIntercept + : strings.srLinearGraphBothIntercepts({ + xIntercept: srFormatNumber(xIntercept, locale), + yIntercept: srFormatNumber(yIntercept, locale), + }); + } + + // Describe only one intercept. + return hasXIntercept + ? strings.srLinearGraphXOnlyIntercept({ + xIntercept: srFormatNumber(xIntercept, locale), + }) + : strings.srLinearGraphYOnlyIntercept({ + yIntercept: srFormatNumber(yIntercept, locale), + }); +} 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 d9b4f0d03f..8a831af94c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx @@ -249,10 +249,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("Point 1 on line 1 at 0 comma 0."); + expectLabelInDoc("Point 2 on line 1 at -7 comma 0.5."); + expectLabelInDoc("Point 1 on line 2 at 1 comma 1."); + expectLabelInDoc("Point 2 on line 2 at 7 comma 0.5."); }); it("renders ARIA labels for each point (ray)", () => {