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

[Interactive Graph + Editor] Add aria label and description to entire interactive graph and its UI to the editor #1568

Merged
merged 19 commits into from
Sep 6, 2024
Merged
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
6 changes: 6 additions & 0 deletions .changeset/new-pigs-begin.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {EditorPage} from "..";
import {
angleWithStartingCoordsQuestion,
circleWithStartingCoordsQuestion,
interactiveGraphWithAriaLabel,
linearSystemWithStartingCoordsQuestion,
linearWithStartingCoordsQuestion,
pointQuestionWithStartingCoords,
Expand Down Expand Up @@ -41,6 +42,10 @@ export default {

const onChangeAction = action("onChange");

export const InteractiveGraphWithAriaLabel = (): React.ReactElement => (
<EditorPageWithStorybookPreview question={interactiveGraphWithAriaLabel} />
);

export const InteractiveGraphSegment = (): React.ReactElement => {
return (
<EditorPageWithStorybookPreview
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {Dependencies} from "@khanacademy/perseus";
import {render, screen} from "@testing-library/react";
import {userEvent as userEventLib} from "@testing-library/user-event";
import * as React from "react";

import {testDependencies} from "../../../../../testing/test-dependencies";
import InteractiveGraphDescription from "../interactive-graph-description";

import type {UserEvent} from "@testing-library/user-event";

import "@testing-library/jest-dom"; // Imports custom matchers

function userEventForFakeTimers() {
return userEventLib.setup({
advanceTimers: jest.advanceTimersByTime,
});
}

describe("InteractiveGraphSettings", () => {
let userEvent: UserEvent;
beforeEach(() => {
userEvent = userEventForFakeTimers();
jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
testDependencies,
);
});

test("renders", () => {
// Arrange
render(
<InteractiveGraphDescription
ariaLabelValue="Graph Title"
ariaDescriptionValue="Graph Description"
onChange={jest.fn()}
/>,
);

// 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(
<InteractiveGraphDescription
ariaLabelValue=""
ariaDescriptionValue=""
onChange={onChange}
/>,
);

// 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(
<InteractiveGraphDescription
ariaLabelValue=""
ariaDescriptionValue=""
onChange={onChange}
/>,
);

// 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"}],
]);
});
});
Original file line number Diff line number Diff line change
@@ -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<EditorProps>) => void;
};

export default function InteractiveGraphDescription(props: Props) {
const {ariaLabelValue, ariaDescriptionValue, onChange} = props;

const [isOpen, setIsOpen] = React.useState(true);

return (
<>
<Heading
title="Description"
isCollapsible={true}
isOpen={isOpen}
onToggle={setIsOpen}
/>
{isOpen && (
<View>
<LabelXSmall style={styles.caption}>
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.
</LabelXSmall>
<LabelLarge tag="label">
Title
<TextField
value={ariaLabelValue}
onChange={(newValue) =>
onChange({fullGraphAriaLabel: newValue})
}
/>
</LabelLarge>
<Strut size={spacing.xSmall_8} />
<LabelLarge
tag="label"
// TODO(LEMS-2332): Remove this style prop after
// switching to WB TextArea
style={{
display: "flex",
flexDirection: "column",
}}
>
Description
{/* TODO(LEMS-2332): Change this to a WB TextArea */}
<textarea
rows={8}
value={ariaDescriptionValue}
onChange={(e) =>
onChange({
fullGraphAriaDescription: e.target.value,
})
}
/>
</LabelLarge>
</View>
)}
</>
);
}

const styles = StyleSheet.create({
caption: {
color: color.offBlack64,
paddingTop: spacing.xxSmall_6,
paddingBottom: spacing.xxSmall_6,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -1066,4 +1066,28 @@ describe("InteractiveGraphEditor", () => {
}),
).toBeNull();
});

test("shows description section", () => {
// Arrange

// Act
render(
<InteractiveGraphEditor
{...mafsProps}
graph={{type: "segment"}}
correct={{type: "segment"}}
/>,
{
wrapper: RenderStateRoot,
},
);

// Assert
expect(
screen.getByRole("textbox", {name: "Title"}),
).toBeInTheDocument();
expect(
screen.getByRole("textbox", {name: "Description"}),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -126,6 +127,13 @@ export type Props = {
* etc.) that are locked in place and not interactive.
*/
lockedFigures?: Array<LockedFigure>;
// 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.
Expand Down Expand Up @@ -205,6 +213,8 @@ class InteractiveGraphEditor extends React.Component<Props> {
"gridStep",
"snapStep",
"lockedFigures",
"fullGraphAriaLabel",
"fullGraphAriaDescription",
);

// eslint-disable-next-line react/no-string-refs
Expand Down Expand Up @@ -293,6 +303,8 @@ class InteractiveGraphEditor extends React.Component<Props> {
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;
Expand Down Expand Up @@ -353,6 +365,18 @@ class InteractiveGraphEditor extends React.Component<Props> {
}}
/>
</LabeledRow>
{this.props.graph &&
this.props.apiOptions?.flags?.mafs?.[
this.props.graph.type
] && (
<InteractiveGraphDescription
ariaLabelValue={this.props.fullGraphAriaLabel ?? ""}
ariaDescriptionValue={
this.props.fullGraphAriaDescription ?? ""
}
onChange={this.props.onChange}
/>
)}
<InteractiveGraphCorrectAnswer equationString={equationString}>
{graph}
</InteractiveGraphCorrectAnswer>
Expand Down
4 changes: 4 additions & 0 deletions packages/perseus/src/perseus-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,10 @@ export type PerseusInteractiveGraphWidgetOptions = {
// Shapes (points, chords, etc) displayed on the graph that cannot
// be moved by the user.
lockedFigures?: ReadonlyArray<LockedFigure>;
// Aria label that applies to the entire graph.
fullGraphAriaLabel?: string;
// Aria description that applies to the entire graph.
fullGraphAriaDescription?: string;
};

const lockedFigureColorNames = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading