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)", () => {