diff --git a/.changeset/tidy-baboons-tie.md b/.changeset/tidy-baboons-tie.md new file mode 100644 index 0000000000..20d14a409b --- /dev/null +++ b/.changeset/tidy-baboons-tie.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[SR] Linear System - add screen reader support for Linear System interactive graph diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 725bdaa858..0723bdec92 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -258,6 +258,31 @@ export type PerseusStrings = { endingSideX: string; endingSideY: string; }) => string; + srLinearSystemGraph: string; + srLinearSystemPoints: ({ + lineNumber, + point1X, + point1Y, + point2X, + point2Y, + }: { + lineNumber: number; + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; + srLinearSystemPoint({ + lineNumber, + pointSequence, + x, + y, + }: { + lineNumber: number; + pointSequence: number; + x: string; + y: string; + }): string; // The above strings are used for interactive graph SR descriptions. }; @@ -478,6 +503,11 @@ 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", + 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.", + srLinearSystemPoint: + "Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.", // The above strings are used for interactive graph SR descriptions. }; @@ -695,5 +725,10 @@ 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}.`, + 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}.`, + srLinearSystemPoint: ({lineNumber, pointSequence, x, y}) => + `Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`, // The above strings are used for interactive graph SR descriptions. }; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx index 0ecb3f8e88..62afcaca5f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx @@ -2,6 +2,7 @@ import {vec} from "mafs"; import * as React from "react"; import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; import {X, Y, calculateAngleInDegrees, getClockwiseAngle, polar} from "../math"; import {findIntersectionOfRays} from "../math/geometry"; import {actions} from "../reducer/interactive-graph-action"; @@ -215,7 +216,7 @@ function AngleGraph(props: AngleGraphProps) { } ariaLabel={initialSideAriaLabel} /> - + {wholeAngleDescription} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx index 4fbef30be6..805d14a148 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import {useRef} from "react"; import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; import {snap, X, Y} from "../math"; import {actions} from "../reducer/interactive-graph-action"; import {getRadius} from "../reducer/interactive-graph-state"; @@ -103,10 +104,10 @@ function CircleGraph(props: CircleGraphProps) { /> {/* Hidden elements to provide the descriptions for the circle and radius point's `aria-describedby` properties. */} - + {srCircleRadius} - + {srCircleOuterPoints} 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..2bda019d36 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx @@ -0,0 +1,301 @@ +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 {describeLinearSystemGraph} from "./linear-system"; + +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).toHaveAccessibleDescription( + "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. The line crosses the Y-axis at 0 comma 5. Its slope is zero. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5. The line crosses the Y-axis at 0 comma -5. Its slope is zero.", + ); + }); + + // Test each line in the linear system graph separately. + describe.each` + lineNumber + ${1} + ${2} + `(`Line $lineNumber`, ({lineNumber}) => { + 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."} + ${"overlaps y-axis"} | ${[[0, 5], [0, 2]]} | ${"The line crosses the X-axis at 0 comma 0."} + ${"overlaps x-axis"} | ${[[5, 0], [2, 0]]} | ${"The line crosses the Y-axis at 0 comma 0."} + `( + "slope description should include slope info for $case", + ({coords, interceptDescription}) => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineNumber - 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.`} + ${"overlaps x-axis"} | ${[[1, 0], [3, 0]]} | ${`Its slope is zero.`} + ${"overlaps y-axis"} | ${[[0, 1], [0, 3]]} | ${`Its slope is undefined.`} + `( + "slope description should include slope info for $case", + ({coords, slopeDescription}) => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineNumber - 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[lineNumber - 1] = [ + [-2, 3], + [3, 3], + ]; + + // Act + render( + , + ); + + const interactiveElements = screen.getAllByRole("button"); + + // Get interactive elements for this line. + const point1 = interactiveElements[0 + (lineNumber - 1) * 3]; + const grabHandle = interactiveElements[1 + (lineNumber - 1) * 3]; + const point2 = interactiveElements[2 + (lineNumber - 1) * 3]; + + // Assert + // Check updated aria-label for the linear graph. + expect(point1).toHaveAttribute( + "aria-label", + `Point 1 on line ${lineNumber} at -2 comma 3.`, + ); + expect(grabHandle).toHaveAttribute( + "aria-label", + `The line crosses the Y-axis at 0 comma 3. Its slope is zero.`, + ); + expect(point2).toHaveAttribute( + "aria-label", + `Point 2 on line ${lineNumber} at 3 comma 3.`, + ); + }); + + test.each` + element | index + ${"point1"} | ${0} + ${"grabHandle"} | ${1} + ${"point2"} | ${2} + `("should have describedby on all interactive elements", ({index}) => { + // Arrange + render( + , + ); + + // Act + const interactiveElements = screen.getAllByRole("button"); + const element = interactiveElements[index + (lineNumber - 1) * 3]; + + // Assert + expect(element.getAttribute("aria-describedby")).toContain( + "-slope", + ); + expect(element.getAttribute("aria-describedby")).toContain( + "-intercept", + ); + }); + + 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(describeLinearSystemGraph, () => { + test("describes a default linear system graph", () => { + // Arrange + + // Act + const linearSystemGraphDescription = describeLinearSystemGraph( + baseLinearSystemState, + mockPerseusI18nContext, + ); + + // Assert + expect(linearSystemGraphDescription).toEqual( + "Interactive elements: Two lines on a coordinate plane. Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.", + ); + }); + + test("describes a linear system graph with updated points", () => { + // Arrange + + // Act + const linearSystemGraphDescription = describeLinearSystemGraph( + { + ...baseLinearSystemState, + coords: [ + [ + [-2, 3], + [3, 3], + ], + [ + [-2, -3], + [3, -3], + ], + ], + }, + mockPerseusI18nContext, + ); + + // Assert + expect(linearSystemGraphDescription).toEqual( + "Interactive elements: Two lines on a coordinate plane. Line 1 has two points, point 1 at -2 comma 3 and point 2 at 3 comma 3. Line 2 has two points, point 1 at -2 comma -3 and point 2 at 3 comma -3.", + ); + }); +}); 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..d6a38cf885 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx @@ -1,9 +1,14 @@ 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 {getInterceptStringForLine, getSlopeStringForLine} from "./utils"; +import type {I18nContextType} from "../../../components/i18n-context"; import type { MafsGraphProps, LinearSystemGraphState, @@ -18,7 +23,9 @@ export function renderLinearSystemGraph( ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: null, + interactiveElementsDescription: ( + + ), }; } @@ -28,12 +35,64 @@ 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({ + lineNumber: 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 +115,82 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => { color="var(--movable-line-stroke-color)" /> ))} - ; - + {linesAriaInfo.map( + ({ + pointsDescriptionId, + interceptDescriptionId, + slopeDescriptionId, + pointsDescription, + interceptDescription, + slopeDescription, + }) => ( + <> + + {pointsDescription} + + + {interceptDescription} + + + {slopeDescription} + + + ), + )} + ); }; + +function LinearSystemGraphDescription({ + state, +}: { + state: LinearSystemGraphState; +}) { + // The reason that LinearSystemGraphDescription is a component (rather + // than a function that returns a string) is because it needs to use a + // hook: `usePerseusI18n`. + const i18n = usePerseusI18n(); + + return describeLinearSystemGraph(state, i18n); +} + +// Exported for testing +export function describeLinearSystemGraph( + state: LinearSystemGraphState, + i18n: I18nContextType, +): string { + const {strings, locale} = i18n; + const {coords: lines} = state; + + const graphDescription = strings.srLinearSystemGraph; + + const lineDescriptions = lines.map((line, i) => { + const point1 = line[0]; + const point2 = line[1]; + return strings.srLinearSystemPoints({ + lineNumber: i + 1, + point1X: srFormatNumber(point1[0], locale), + point1Y: srFormatNumber(point1[1], locale), + point2X: srFormatNumber(point2[0], locale), + point2Y: srFormatNumber(point2[1], locale), + }); + }); + + const allDescriptions = [graphDescription, ...lineDescriptions]; + + return strings.srInteractiveElements({ + elements: allDescriptions.join(" "), + }); +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx index 915084532d..f0b0f429df 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx @@ -1,10 +1,12 @@ 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 {getInterceptStringForLine, getSlopeStringForLine} from "./utils"; import type { MafsGraphProps, @@ -49,57 +51,17 @@ 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 ( { /> {/* Hidden elements to provide the descriptions for the circle and radius point's `aria-describedby` properties. */} - + {linearGraphPointsDescription} - + {interceptString} - + {slopeString} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts index d42852ec91..c023e5d7f6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts @@ -1,3 +1,7 @@ +import {srFormatNumber} from "./screenreader-text"; + +import type {PerseusStrings} from "../../../strings"; +import type {PairOfPoints} from "../types"; import type {Coord} from "@khanacademy/perseus"; import type {Interval, vec} from "mafs"; @@ -62,3 +66,57 @@ export function getArrayWithoutDuplicates(array: Array): Array { return returnArray; } + +export function getSlopeStringForLine( + line: PairOfPoints, + strings: PerseusStrings, +): string { + const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]); + if (!Number.isFinite(slope)) { + return strings.srLinearGraphSlopeVertical; + } + + if (slope === 0) { + return strings.srLinearGraphSlopeHorizontal; + } + + return slope > 0 + ? strings.srLinearGraphSlopeIncreasing + : strings.srLinearGraphSlopeDecreasing; +} + +export function getInterceptStringForLine( + line: PairOfPoints, + strings: PerseusStrings, + locale: string, +): 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]; + + // Check if the line fully overlaps with an axis. + const overlapsXAxis = line[0][1] === 0 && line[1][1] === 0; + const overlapsYAxis = line[0][0] === 0 && line[1][0] === 0; + + const hasXIntercept = Number.isFinite(xIntercept) && !overlapsXAxis; + const hasYIntercept = Number.isFinite(yIntercept) && !overlapsYAxis; + + 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 6acf462f4b..a7774a8c12 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)", () => {