Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SR] Linear System - add screen reader support for Linear System interactive graph #2030

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-baboons-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

[SR] Linear System - add screen reader support for Linear System interactive graph
76 changes: 76 additions & 0 deletions packages/perseus/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};

Expand Down Expand Up @@ -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.
};

Expand Down Expand Up @@ -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.
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
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(
<MafsGraph {...baseMafsGraphProps} state={baseLinearSystemState} />,
);

// 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 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(
<MafsGraph
{...baseMafsGraphProps}
state={{
...baseLinearSystemState,
coords: newCoords,
}}
/>,
);

// 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(
<MafsGraph
{...baseMafsGraphProps}
state={{
...baseLinearSystemState,
coords: newCoords,
}}
/>,
);

// 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(
<MafsGraph
{...baseMafsGraphProps}
state={{
...baseLinearSystemState,
// Different points than default (-5, 5) and (5, 5)
coords: newCoords,
}}
/>,
);

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`
element | index
${"point1"} | ${0}
${"grabHandle"} | ${1}
${"point2"} | ${2}
`("should have describedby on all interactive elements", ({index}) => {
// Arrange
render(
<MafsGraph
{...baseMafsGraphProps}
state={baseLinearSystemState}
/>,
);

// Act
const interactiveElements = screen.getAllByRole("button");
const element = interactiveElements[index + (lineSequence - 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(
<MafsGraph
{...baseMafsGraphProps}
state={baseLinearSystemState}
/>,
);
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],
);
},
);
});
});
Loading
Loading