From 039e0a360e56044ce2b4a1decfd82e6c01841ea9 Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Mon, 30 Sep 2024 14:48:37 -0700 Subject: [PATCH] [Locked Figure Aria] Locked point aria label editor UI (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: - Create a new `locked-figure-aria.tsx` file that can be reused for each locked figure's settings. - Use this `locked-figure-aria.tsx` component within LockedPointSettings. - Write the function to auto-generate the locked point aria label with its coordinates and visible labels. Issue: https://khanacademy.atlassian.net/browse/LEMS-2375 ## Test plan: - `yarn jest packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx` - `yarn jest packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.test.tsx` Storybook - http://localhost:6006/?path=/story/perseuseditor-widgets-interactive-graph--mafs-with-locked-figure-labels-all-flags - Confirm that the locked point aria label field is already populated with "Point A" (passed in via builder) - Confirm that pressing "Auto-generate" works with no labels, one label, and multiple labels - Confirm that updateing the aria label in the editor also updates the aria label on the point - Check the web inspector to confirm the aria-label text is updated - Use a screen reader to confirm the new label is read out image Author: nishasy Reviewers: catandthemachines, #perseus, benchristel, anakaren-rojas Required Reviewers: Approved By: catandthemachines Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/perseus/pull/1682 --- .changeset/strong-spoons-talk.md | 6 + .../interactive-graph-description.tsx | 2 +- .../locked-figure-aria.test.tsx | 109 +++++++++++++++ .../locked-figures/locked-figure-aria.tsx | 88 ++++++++++++ .../locked-point-settings.test.tsx | 130 ++++++++++++++++++ .../locked-figures/locked-point-settings.tsx | 56 +++++++- 6 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 .changeset/strong-spoons-talk.md create mode 100644 packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.test.tsx create mode 100644 packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.tsx diff --git a/.changeset/strong-spoons-talk.md b/.changeset/strong-spoons-talk.md new file mode 100644 index 0000000000..c0ee97b4ba --- /dev/null +++ b/.changeset/strong-spoons-talk.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Locked Figure Aria] Locked point aria label editor UI diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-description.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-description.tsx index 8678c18158..34611bb0e8 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-description.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/interactive-graph-description.tsx @@ -34,7 +34,7 @@ export default function InteractiveGraphDescription(props: Props) { 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. + who may be visually impaired. Title diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.test.tsx new file mode 100644 index 0000000000..dc5d60275d --- /dev/null +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.test.tsx @@ -0,0 +1,109 @@ +import {render, screen} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import * as React from "react"; + +import LockedFigureAria from "./locked-figure-aria"; + +import type {UserEvent} from "@testing-library/user-event"; + +describe("LockedFigureAria", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); + + test("renders", () => { + // Arrange + + // Act + render( + {}} + />, + ); + + const titleText = screen.getByText("Aria label"); + const descriptionText = screen.getByText( + "The figure is hidden from screen readers if this field is left blank.", + ); + const input = screen.getByRole("textbox"); + const autoGenButton = screen.getByRole("button", { + name: "Auto-generate", + }); + + // Assert + expect(titleText).toBeInTheDocument(); + expect(descriptionText).toBeInTheDocument(); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(""); + expect(autoGenButton).toBeInTheDocument(); + }); + + test("renders with aria label", () => { + // Arrange + + // Act + render( + {}} + />, + ); + + const input = screen.getByRole("textbox"); + + // Assert + expect(input).toHaveValue("Point at (x, y)"); + }); + + test("auto-generate button calls onChange with the prepopulated label", async () => { + // Arrange + const onChangeProps = jest.fn(); + + // Act + render( + , + ); + + const autoGenButton = screen.getByRole("button", { + name: "Auto-generate", + }); + + await userEvent.click(autoGenButton); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + ariaLabel: "Pre-populated aria label", + }); + }); + + test("calls onChange with undefined when input is cleared", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + ); + + // Act + const input = screen.getByRole("textbox"); + await userEvent.clear(input); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + ariaLabel: undefined, + }); + }); +}); diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.tsx new file mode 100644 index 0000000000..9d33e494bb --- /dev/null +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-figure-aria.tsx @@ -0,0 +1,88 @@ +import {components} from "@khanacademy/perseus"; +import Button from "@khanacademy/wonder-blocks-button"; +import {View} from "@khanacademy/wonder-blocks-core"; +import {LabeledTextField} from "@khanacademy/wonder-blocks-form"; +import {Spring} from "@khanacademy/wonder-blocks-layout"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; +import pencilCircle from "@phosphor-icons/core/regular/pencil-circle.svg"; +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +const {InfoTip} = components; + +type Props = { + ariaLabel: string | undefined; + prePopulatedAriaLabel: string; + onChangeProps: (props: {ariaLabel?: string | undefined}) => void; +}; + +function LockedFigureAria(props: Props) { + const {ariaLabel, prePopulatedAriaLabel, onChangeProps} = props; + + return ( + + + Aria label + + + Aria label is used by screen readers to describe + content to users who may be visually impaired.{" "} +
+
+ Populating this field will make it so that users can + use a screen reader to navigate to this point and + hear the description. +
+
+ If you leave this field blank, the point will be + hidden from screen readers. Users will not be able + to navigate to this point using a screen reader. +
+
+ } + description={`The figure is hidden from screen readers + if this field is left blank.`} + value={ariaLabel ?? ""} + onChange={(newValue) => { + onChangeProps({ + // Save as undefined if the field is empty. + ariaLabel: newValue || undefined, + }); + }} + placeholder="Ex. Point at (x, y)" + style={styles.ariaLabelTextField} + /> + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + alignItems: "center", + }, + button: { + alignSelf: "start", + }, + ariaLabelTextField: { + marginTop: spacing.xSmall_8, + }, +}); + +export default LockedFigureAria; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx index 75767f18fe..5aceb30f69 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.test.tsx @@ -347,4 +347,134 @@ describe("LockedPointSettings", () => { ], }); }); + + test("Renders with aria label", () => { + // Arrange + + // Act + render( + , + {wrapper: RenderStateRoot}, + ); + + const input = screen.getByRole("textbox", {name: "Aria label"}); + + // Assert + expect(input).toHaveValue("Point at (x, y)"); + }); + + test("calls onChangeProps when the aria label is updated", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const input = screen.getByRole("textbox", {name: "Aria label"}); + await userEvent.clear(input); + await userEvent.type(input, "A"); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + ariaLabel: "A", + }); + }); + + test("aria label auto-generates (no labels)", async () => { + // Arrange + const onChangeProps = jest.fn(); + + // Act + render( + , + {wrapper: RenderStateRoot}, + ); + + const autoGenButton = screen.getByRole("button", { + name: "Auto-generate", + }); + await userEvent.click(autoGenButton); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + ariaLabel: "Point at (0, 0)", + }); + }); + + test("aria label auto-generates (one label)", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const autoGenButton = screen.getByRole("button", { + name: "Auto-generate", + }); + await userEvent.click(autoGenButton); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + ariaLabel: "Point at (0, 0) with label A", + }); + }); + + test("aria label auto-generates (multiple labels)", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const autoGenButton = screen.getByRole("button", { + name: "Auto-generate", + }); + await userEvent.click(autoGenButton); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + ariaLabel: "Point at (0, 0) with labels A, B", + }); + }); }); diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx index ba445d94e9..07f09eaea2 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-point-settings.tsx @@ -8,7 +8,7 @@ import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing, color as wbColor} from "@khanacademy/wonder-blocks-tokens"; -import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; +import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; import plusCircle from "@phosphor-icons/core/regular/plus-circle.svg"; import {StyleSheet} from "aphrodite"; import * as React from "react"; @@ -19,6 +19,7 @@ import PerseusEditorAccordion from "../../../components/perseus-editor-accordion import ColorSelect from "./color-select"; import ColorSwatch from "./color-swatch"; import LabeledSwitch from "./labeled-switch"; +import LockedFigureAria from "./locked-figure-aria"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; import LockedLabelSettings from "./locked-label-settings"; import {getDefaultFigureForType} from "./util"; @@ -87,6 +88,7 @@ const LockedPointSettings = (props: Props) => { color: pointColor, filled = true, labels, + ariaLabel, onChangeProps, onMove, onRemove, @@ -99,6 +101,33 @@ const LockedPointSettings = (props: Props) => { const isDefiningPoint = !onMove && !onRemove; + /** + * Get a prepopulated aria label for the point. + * + * If the point has no labels, the aria label will just be + * "Point at (x, y)". + * + * If the point has labels, the aria label will be + * "Point at (x, y) with label1, label2, label3". + */ + function getPrepopulatedAriaLabel() { + let str = `Point at (${coord[0]}, ${coord[1]})`; + + if (labels && labels.length > 0) { + str += " with label"; + // Make it "with labels" instead of "with label" if there are + // multiple labels. + if (labels.length > 1) { + str += "s"; + } + + // Separate additional labels with commas. + str += ` ${labels.map((l) => l.text).join(", ")}`; + } + + return str; + } + function handleColorChange(newValue) { const newProps: Partial = { color: newValue, @@ -212,10 +241,31 @@ const LockedPointSettings = (props: Props) => { )} + {!isDefiningPoint && flags?.["mafs"]?.["locked-figures-aria"] && ( + <> + + + + { + onChangeProps(newProps); + }} + /> + + )} + {((!isDefiningPoint && flags?.["mafs"]?.["locked-point-labels"]) || (isDefiningPoint && flags?.["mafs"]?.["locked-line-labels"])) && ( <> + + + + + Visible labels + {labels?.map((label, labelIndex) => (