diff --git a/.changeset/new-pigs-begin.md b/.changeset/new-pigs-begin.md
new file mode 100644
index 0000000000..4b03940984
--- /dev/null
+++ b/.changeset/new-pigs-begin.md
@@ -0,0 +1,6 @@
+---
+"@khanacademy/perseus": minor
+"@khanacademy/perseus-editor": minor
+---
+
+[Interactive Graph + Editor] Add a full graph aria-label and aria-description/describedby to interactive graphs, as well as the UI for content authors to add this in the interactive graph editor
diff --git a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx
index a84888f5fc..75a6a5d5ab 100644
--- a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx
+++ b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx
@@ -10,6 +10,7 @@ import {EditorPage} from "..";
import {
angleWithStartingCoordsQuestion,
circleWithStartingCoordsQuestion,
+ interactiveGraphWithAriaLabel,
linearSystemWithStartingCoordsQuestion,
linearWithStartingCoordsQuestion,
pointQuestionWithStartingCoords,
@@ -41,6 +42,10 @@ export default {
const onChangeAction = action("onChange");
+export const InteractiveGraphWithAriaLabel = (): React.ReactElement => (
+
+);
+
export const InteractiveGraphSegment = (): React.ReactElement => {
return (
{
+ let userEvent: UserEvent;
+ beforeEach(() => {
+ userEvent = userEventForFakeTimers();
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+ testDependencies,
+ );
+ });
+
+ test("renders", () => {
+ // Arrange
+ render(
+ ,
+ );
+
+ // Act
+ const titleInput = screen.getByRole("textbox", {name: "Title"});
+ const descriptionInput = screen.getByRole("textbox", {
+ name: "Description",
+ });
+
+ // Assert
+ expect(titleInput).toBeInTheDocument();
+ expect(titleInput).toHaveValue("Graph Title");
+ expect(descriptionInput).toBeInTheDocument();
+ expect(descriptionInput).toHaveValue("Graph Description");
+ });
+
+ test("calls onChange when the title is changed", async () => {
+ // Arrange
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ // Act
+ const titleInput = screen.getByRole("textbox", {name: "Title"});
+ await userEvent.clear(titleInput);
+ await userEvent.type(titleInput, "Zot");
+
+ // Assert
+ // Calls are not being accumulated because they're mocked.
+ expect(onChange.mock.calls).toEqual([
+ [{fullGraphAriaLabel: "Z"}],
+ [{fullGraphAriaLabel: "o"}],
+ [{fullGraphAriaLabel: "t"}],
+ ]);
+ });
+
+ test("calls onChange when the description is changed", async () => {
+ // Arrange
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ // Act
+ const descriptionInput = screen.getByRole("textbox", {
+ name: "Description",
+ });
+ await userEvent.clear(descriptionInput);
+ await userEvent.type(descriptionInput, "Zot");
+
+ // Assert
+ // Calls are not being accumulated because they're mocked.
+ expect(onChange.mock.calls).toEqual([
+ [{fullGraphAriaDescription: "Z"}],
+ [{fullGraphAriaDescription: "o"}],
+ [{fullGraphAriaDescription: "t"}],
+ ]);
+ });
+});
diff --git a/packages/perseus-editor/src/components/interactive-graph-description.tsx b/packages/perseus-editor/src/components/interactive-graph-description.tsx
new file mode 100644
index 0000000000..d84924c0c2
--- /dev/null
+++ b/packages/perseus-editor/src/components/interactive-graph-description.tsx
@@ -0,0 +1,82 @@
+import {View} from "@khanacademy/wonder-blocks-core";
+import {TextField} from "@khanacademy/wonder-blocks-form";
+import {Strut} from "@khanacademy/wonder-blocks-layout";
+import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
+import {LabelLarge, LabelXSmall} from "@khanacademy/wonder-blocks-typography";
+import {StyleSheet} from "aphrodite";
+import * as React from "react";
+
+import Heading from "./heading";
+
+import type {Props as EditorProps} from "../widgets/interactive-graph-editor/interactive-graph-editor";
+
+type Props = {
+ ariaLabelValue: string;
+ ariaDescriptionValue: string;
+ onChange: (graphProps: Partial) => void;
+};
+
+export default function InteractiveGraphDescription(props: Props) {
+ const {ariaLabelValue, ariaDescriptionValue, onChange} = props;
+
+ const [isOpen, setIsOpen] = React.useState(true);
+
+ return (
+ <>
+
+ {isOpen && (
+
+
+ Use these fields to describe the graph as a whole. These
+ are used by screen readers to describe content to users
+ who are visually impaired.
+
+
+ Title
+
+ onChange({fullGraphAriaLabel: newValue})
+ }
+ />
+
+
+
+ Description
+ {/* TODO(LEMS-2332): Change this to a WB TextArea */}
+
+
+ )}
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ caption: {
+ color: color.offBlack64,
+ paddingTop: spacing.xxSmall_6,
+ paddingBottom: spacing.xxSmall_6,
+ },
+});
diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx
index 3a1433db39..8de1b9fc3f 100644
--- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx
+++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx
@@ -1066,4 +1066,28 @@ describe("InteractiveGraphEditor", () => {
}),
).toBeNull();
});
+
+ test("shows description section", () => {
+ // Arrange
+
+ // Act
+ render(
+ ,
+ {
+ wrapper: RenderStateRoot,
+ },
+ );
+
+ // Assert
+ expect(
+ screen.getByRole("textbox", {name: "Title"}),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("textbox", {name: "Description"}),
+ ).toBeInTheDocument();
+ });
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx
index cc0bd596c9..b7eafc091e 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx
@@ -20,6 +20,7 @@ import LockedFiguresSection from "../../components/graph-locked-figures/locked-f
import GraphPointsCountSelector from "../../components/graph-points-count-selector";
import GraphTypeSelector from "../../components/graph-type-selector";
import {InteractiveGraphCorrectAnswer} from "../../components/interactive-graph-correct-answer";
+import InteractiveGraphDescription from "../../components/interactive-graph-description";
import InteractiveGraphSettings from "../../components/interactive-graph-settings";
import SegmentCountSelector from "../../components/segment-count-selector";
import StartCoordsSettings from "../../components/start-coords-settings";
@@ -126,6 +127,13 @@ export type Props = {
* etc.) that are locked in place and not interactive.
*/
lockedFigures?: Array;
+ // Aria-label for the full graph area. Short title for the graph.
+ fullGraphAriaLabel?: string;
+ // Aria-description for the graph area. Longer description of the graph.
+ // Note that the `aria-description` property is not supported well,
+ // so this description will be hidden in a DOM element whose ID will
+ // then be referenced by the graph's `aria-describedby` property.
+ fullGraphAriaDescription?: string;
/**
* The graph to display in the graph area.
@@ -205,6 +213,8 @@ class InteractiveGraphEditor extends React.Component {
"gridStep",
"snapStep",
"lockedFigures",
+ "fullGraphAriaLabel",
+ "fullGraphAriaDescription",
);
// eslint-disable-next-line react/no-string-refs
@@ -293,6 +303,8 @@ class InteractiveGraphEditor extends React.Component {
showProtractor: this.props.showProtractor,
showTooltips: this.props.showTooltips,
lockedFigures: this.props.lockedFigures,
+ fullGraphAriaLabel: this.props.fullGraphAriaLabel,
+ fullGraphAriaDescription: this.props.fullGraphAriaDescription,
trackInteraction: function () {},
onChange: (newProps: InteractiveGraphProps) => {
let correct = this.props.correct;
@@ -353,6 +365,18 @@ class InteractiveGraphEditor extends React.Component {
}}
/>
+ {this.props.graph &&
+ this.props.apiOptions?.flags?.mafs?.[
+ this.props.graph.type
+ ] && (
+
+ )}
{graph}
diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts
index baa1aacc51..f171c6652d 100644
--- a/packages/perseus/src/perseus-types.ts
+++ b/packages/perseus/src/perseus-types.ts
@@ -678,6 +678,10 @@ export type PerseusInteractiveGraphWidgetOptions = {
// Shapes (points, chords, etc) displayed on the graph that cannot
// be moved by the user.
lockedFigures?: ReadonlyArray;
+ // Aria label that applies to the entire graph.
+ fullGraphAriaLabel?: string;
+ // Aria description that applies to the entire graph.
+ fullGraphAriaDescription?: string;
};
const lockedFigureColorNames = [
diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts
index e87624e54d..b7ffa1fb4b 100644
--- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts
@@ -27,6 +27,8 @@ export function interactiveGraphQuestionBuilder(): InteractiveGraphQuestionBuild
class InteractiveGraphQuestionBuilder {
private content: string = "[[☃ interactive-graph 1]]";
+ private fullGraphAriaLabel?: string;
+ private fullGraphAriaDescription?: string;
private backgroundImage?: {
url: string;
height: number;
@@ -61,6 +63,8 @@ class InteractiveGraphQuestionBuilder {
static: this.staticMode,
options: {
correct: this.interactiveFigureConfig.correct(),
+ fullGraphAriaLabel: this.fullGraphAriaLabel,
+ fullGraphAriaDescription: this.fullGraphAriaDescription,
backgroundImage: this.backgroundImage,
graph: this.interactiveFigureConfig.graph(),
gridStep: this.gridStep,
@@ -87,6 +91,18 @@ class InteractiveGraphQuestionBuilder {
return this;
}
+ withFullGraphAriaLabel(label: string): InteractiveGraphQuestionBuilder {
+ this.fullGraphAriaLabel = label;
+ return this;
+ }
+
+ withFullGraphAriaDescription(
+ description: string,
+ ): InteractiveGraphQuestionBuilder {
+ this.fullGraphAriaDescription = description;
+ return this;
+ }
+
withStaticMode(staticMode: boolean): InteractiveGraphQuestionBuilder {
this.staticMode = staticMode;
return this;
diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx
index 1e2c9c90dc..8ff861a1db 100644
--- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx
@@ -19,6 +19,7 @@ import {
angleQuestionWithDefaultCorrect,
circleQuestion,
circleQuestionWithDefaultCorrect,
+ interactiveGraphWithAriaLabel,
linearQuestion,
linearQuestionWithDefaultCorrect,
linearSystemQuestion,
@@ -911,4 +912,47 @@ describe("locked layer", () => {
top: "240px",
});
});
+
+ it("should have an aria-label and description if they are provided", async () => {
+ // Arrange
+ const {container} = renderQuestion(interactiveGraphWithAriaLabel, {
+ flags: {
+ mafs: {
+ segment: true,
+ "interactive-graph-locked-features-labels": true,
+ },
+ },
+ });
+
+ // Act
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const graph = container.querySelector(".mafs-graph");
+
+ // Assert
+ expect(graph).toHaveAttribute("aria-label", "Segment Graph Title");
+ // The aria-describedby attribute is set to the description
+ // element's ID. This ID is unique to the graph instance, so
+ // we can't predict it in this test.
+ expect(graph).toHaveAttribute("aria-describedby");
+ });
+
+ it("should not have an aria-label or description if they are not provided", async () => {
+ // Arrange
+ const {container} = renderQuestion(segmentQuestion, {
+ flags: {
+ mafs: {
+ segment: true,
+ "interactive-graph-locked-features-labels": true,
+ },
+ },
+ });
+
+ // Act
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const graph = container.querySelector(".mafs-graph");
+
+ // Assert
+ expect(graph).not.toHaveAttribute("aria-label");
+ expect(graph).not.toHaveAttribute("aria-describedby");
+ });
});
diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts
index e4eebf19b1..622e089085 100644
--- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts
@@ -934,3 +934,11 @@ export const staticGraphQuestionWithAnotherWidget: () => PerseusRenderer =
result["content"] = "[[\u2603 radio 1]]\n\n" + result["content"];
return result;
};
+
+export const interactiveGraphWithAriaLabel: PerseusRenderer =
+ interactiveGraphQuestionBuilder()
+ .withFullGraphAriaLabel("Segment Graph Title")
+ .withFullGraphAriaDescription(
+ "There is a segment on the graph that runs from five units left and five units up to five units right and five units up.",
+ )
+ .build();
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
index 15bbaac3b7..e42191b636 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
@@ -47,6 +47,8 @@ export type MafsGraphProps = {
showTooltips: Required;
showProtractor: boolean;
labels: InteractiveGraphProps["labels"];
+ fullGraphAriaLabel?: InteractiveGraphProps["fullGraphAriaLabel"];
+ fullGraphAriaDescription?: InteractiveGraphProps["fullGraphAriaDescription"];
state: InteractiveGraphState;
dispatch: React.Dispatch;
readOnly: boolean;
@@ -54,10 +56,19 @@ export type MafsGraphProps = {
};
export const MafsGraph = (props: MafsGraphProps) => {
- const {state, dispatch, labels, readOnly} = props;
+ const {
+ state,
+ dispatch,
+ labels,
+ readOnly,
+ fullGraphAriaLabel,
+ fullGraphAriaDescription,
+ } = props;
const [width, height] = props.box;
const tickStep = props.step as vec.Vector2;
+ const uniqueId = React.useId();
+ const descriptionId = `interactive-graph-description-${uniqueId}`;
const graphRef = React.useRef(null);
return (
@@ -96,9 +107,26 @@ export const MafsGraph = (props: MafsGraphProps) => {
graphRef.current?.focus();
}
}}
+ aria-label={fullGraphAriaLabel}
+ aria-describedby={
+ fullGraphAriaDescription ? descriptionId : undefined
+ }
ref={graphRef}
tabIndex={0}
>
+ {fullGraphAriaDescription && (
+
+ {fullGraphAriaDescription}
+
+ )}
;
showProtractor: boolean;
labels: InteractiveGraphProps["labels"];
+ fullGraphAriaLabel?: InteractiveGraphProps["fullGraphAriaLabel"];
+ fullGraphAriaDescription?: InteractiveGraphProps["fullGraphAriaDescription"];
readOnly: boolean;
static: InteractiveGraphProps["static"];
};