diff --git a/.changeset/new-tomatoes-fly.md b/.changeset/new-tomatoes-fly.md new file mode 100644 index 0000000000..eacbb409e2 --- /dev/null +++ b/.changeset/new-tomatoes-fly.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: Enable the exhaustive test tool for parsePerseusItem to test articles. diff --git a/.changeset/tidy-baboons-tie.md b/.changeset/tidy-baboons-tie.md new file mode 100644 index 0000000000..20d14a409b --- /dev/null +++ b/.changeset/tidy-baboons-tie.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[SR] Linear System - add screen reader support for Linear System interactive graph diff --git a/.changeset/wild-keys-sit.md b/.changeset/wild-keys-sit.md new file mode 100644 index 0000000000..6e9702456f --- /dev/null +++ b/.changeset/wild-keys-sit.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +[SR] Ray graph - Add screen reader support for Ray interactive graph diff --git a/.changeset/young-beers-wave.md b/.changeset/young-beers-wave.md new file mode 100644 index 0000000000..7c45fc6d91 --- /dev/null +++ b/.changeset/young-beers-wave.md @@ -0,0 +1,8 @@ +--- +"@khanacademy/perseus-dev-ui": patch +"@khanacademy/math-input": patch +"@khanacademy/perseus": patch +"@khanacademy/perseus-editor": patch +--- + +Updating our wonder-blocks packages with the latest versions. diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 9b5b340db4..d28a288b24 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -1,5 +1,16 @@ # @khanacademy/perseus-dev-ui +## 5.1.0 + +### Minor Changes + +- [#2103](https://github.com/Khan/perseus/pull/2103) [`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - revert wb versions + +### Patch Changes + +- Updated dependencies [[`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1)]: + - @khanacademy/math-input@22.2.0 + ## 5.0.12 ### Patch Changes diff --git a/dev/package.json b/dev/package.json index 8d5fd4ac92..676b1a523c 100644 --- a/dev/package.json +++ b/dev/package.json @@ -3,7 +3,7 @@ "description": "Perseus dev UI", "author": "Khan Academy", "license": "MIT", - "version": "5.0.12", + "version": "5.1.0", "private": true, "repository": { "type": "git", @@ -16,7 +16,7 @@ "dependencies": { "@khanacademy/kas": "^0.4.10", "@khanacademy/kmath": "^0.2.0", - "@khanacademy/math-input": "^22.1.2", + "@khanacademy/math-input": "^22.2.0", "@khanacademy/perseus-core": "3.1.0", "@khanacademy/perseus-linter": "^1.2.12", "@khanacademy/pure-markdown": "^0.3.21", diff --git a/packages/math-input/CHANGELOG.md b/packages/math-input/CHANGELOG.md index 98c9afea6e..57006885dc 100644 --- a/packages/math-input/CHANGELOG.md +++ b/packages/math-input/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/math-input +## 22.2.0 + +### Minor Changes + +- [#2103](https://github.com/Khan/perseus/pull/2103) [`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - revert wb versions + ## 22.1.2 ### Patch Changes diff --git a/packages/math-input/package.json b/packages/math-input/package.json index f58a3a7115..cb344aea8c 100644 --- a/packages/math-input/package.json +++ b/packages/math-input/package.json @@ -3,7 +3,7 @@ "description": "Khan Academy's new expression editor for the mobile web.", "author": "Khan Academy", "license": "MIT", - "version": "22.1.2", + "version": "22.2.0", "publishConfig": { "access": "public" }, @@ -80,4 +80,4 @@ "react-transition-group": "^4.4.1" }, "keywords": [] -} \ No newline at end of file +} diff --git a/packages/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md index 4bd5c6d353..d13f5b558e 100644 --- a/packages/perseus-editor/CHANGELOG.md +++ b/packages/perseus-editor/CHANGELOG.md @@ -1,5 +1,17 @@ # @khanacademy/perseus-editor +## 17.3.0 + +### Minor Changes + +- [#2103](https://github.com/Khan/perseus/pull/2103) [`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - revert wb versions + +### Patch Changes + +- Updated dependencies [[`600bf6acb`](https://github.com/Khan/perseus/commit/600bf6acbbf76817e3bf7893f8f85188a538bd6a), [`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1), [`7ed21f49e`](https://github.com/Khan/perseus/commit/7ed21f49ee0cccbb40f200903a7fdfb9c2c0389b), [`ce67b0f0a`](https://github.com/Khan/perseus/commit/ce67b0f0a823c09c1c942220d93eca20aa8a963f)]: + - @khanacademy/perseus@50.1.0 + - @khanacademy/math-input@22.2.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index 7a738f3cce..4bdfc90859 100644 --- a/packages/perseus-editor/package.json +++ b/packages/perseus-editor/package.json @@ -3,7 +3,7 @@ "description": "Perseus editors", "author": "Khan Academy", "license": "MIT", - "version": "17.2.0", + "version": "17.3.0", "publishConfig": { "access": "public" }, @@ -38,8 +38,8 @@ "@khanacademy/kas": "^0.4.10", "@khanacademy/keypad-context": "^1.0.13", "@khanacademy/kmath": "^0.2.0", - "@khanacademy/math-input": "^22.1.2", - "@khanacademy/perseus": "^50.0.0", + "@khanacademy/math-input": "^22.2.0", + "@khanacademy/perseus": "^50.1.0", "@khanacademy/perseus-core": "3.1.0", "@khanacademy/pure-markdown": "^0.3.21", "mafs": "^0.19.0" @@ -100,4 +100,4 @@ "underscore": "^1.4.4" }, "keywords": [] -} \ No newline at end of file +} diff --git a/packages/perseus/CHANGELOG.md b/packages/perseus/CHANGELOG.md index 399b8b54a3..da1eb63325 100644 --- a/packages/perseus/CHANGELOG.md +++ b/packages/perseus/CHANGELOG.md @@ -1,5 +1,22 @@ # @khanacademy/perseus +## 50.1.0 + +### Minor Changes + +- [#2092](https://github.com/Khan/perseus/pull/2092) [`600bf6acb`](https://github.com/Khan/perseus/commit/600bf6acbbf76817e3bf7893f8f85188a538bd6a) Thanks [@Myranae](https://github.com/Myranae)! - Introduce a widget export function to filter out scoring data from widget options. Implement this function for the categorizer widget. + +* [#2103](https://github.com/Khan/perseus/pull/2103) [`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1) Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - revert wb versions + +### Patch Changes + +- [#2097](https://github.com/Khan/perseus/pull/2097) [`7ed21f49e`](https://github.com/Khan/perseus/commit/7ed21f49ee0cccbb40f200903a7fdfb9c2c0389b) Thanks [@nishasy](https://github.com/nishasy)! - [SR][locked figures] Give all locked figures "img" role + +* [#2104](https://github.com/Khan/perseus/pull/2104) [`ce67b0f0a`](https://github.com/Khan/perseus/commit/ce67b0f0a823c09c1c942220d93eca20aa8a963f) Thanks [@handeyeco](https://github.com/handeyeco)! - Handle error codes better in Graded Group + +* Updated dependencies [[`01caf5f31`](https://github.com/Khan/perseus/commit/01caf5f3111d84cf37dffc45012f21860d1648b1)]: + - @khanacademy/math-input@22.2.0 + ## 50.0.0 ### Major Changes diff --git a/packages/perseus/package.json b/packages/perseus/package.json index 207bc496e6..bd63970ba6 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -3,7 +3,7 @@ "description": "Core Perseus API (includes renderers and widgets)", "author": "Khan Academy", "license": "MIT", - "version": "50.0.0", + "version": "50.1.0", "publishConfig": { "access": "public" }, @@ -44,7 +44,7 @@ "@khanacademy/kas": "^0.4.10", "@khanacademy/keypad-context": "^1.0.13", "@khanacademy/kmath": "^0.2.0", - "@khanacademy/math-input": "^22.1.2", + "@khanacademy/math-input": "^22.2.0", "@khanacademy/perseus-core": "3.1.0", "@khanacademy/perseus-linter": "^1.2.12", "@khanacademy/perseus-score": "^1.0.0", @@ -124,4 +124,4 @@ "underscore": "^1.4.4" }, "keywords": [] -} \ No newline at end of file +} diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index eca427e236..723053ad02 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -260,6 +260,56 @@ export type PerseusStrings = { endingSideX: string; endingSideY: string; }) => string; + srLinearSystemGraph: string; + srLinearSystemPoints: ({ + lineNumber, + point1X, + point1Y, + point2X, + point2Y, + }: { + lineNumber: number; + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; + srLinearSystemPoint({ + lineNumber, + pointSequence, + x, + y, + }: { + lineNumber: number; + pointSequence: number; + x: string; + y: string; + }): string; + srRayGraph: string; + srRayPoints: ({ + point1X, + point1Y, + point2X, + point2Y, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; + srRayEndpoint: ({x, y}: {x: string; y: string}) => string; + srRayTerminalPoint: ({x, y}: {x: string; y: string}) => string; + srRayGrabHandle: ({ + point1X, + point1Y, + point2X, + point2Y, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + }) => string; srSinusoidGraphAriaLabel: string; srSinusoidExtremumPoint: ({x, y}: {x: string; y: string}) => string; srSinusoidMidlineIntersection: ({x, y}: {x: string; y: string}) => string; @@ -483,6 +533,18 @@ export const strings: { srAngleGraphAriaLabel: "An angle on a coordinate plane.", srAngleGraphAriaDescription: "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the starting side at %(startingSideX)s comma %(startingSideY)s and a point on the ending side at %(endingSideX)s comma %(endingSideY)s", + srLinearSystemGraph: "Two lines on a coordinate plane.", + srLinearSystemPoints: + "Line %(lineNumber)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.", + srLinearSystemPoint: + "Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.", + srRayGraph: "A ray on a coordinate plane.", + srRayPoints: + "The endpoint is at %(point1X)s comma %(point1Y)s and the ray goes through point %(point2X)s comma %(point2Y)s.", + srRayGrabHandle: + "Ray with endpoint %(point1X)s comma %(point1Y)s going through point %(point2X)s comma %(point2Y)s.", + srRayEndpoint: "Endpoint at %(point1X)s comma %(point1Y)s.", + srRayTerminalPoint: "Through point at %(point2X)s comma %(point2Y)s.", srSinusoidGraphAriaLabel: "A sinusoid function on a coordinate plane.", srSinusoidExtremumPoint: "Extremum Point at %(x)s comma %(y)s.", srSinusoidMidlineIntersection: "Midline Intersection at %(x)s comma %(y)s.", @@ -703,6 +765,18 @@ export const mockStrings: PerseusStrings = { endingSideY, }) => `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the starting side at ${startingSideX} comma ${startingSideY} and a point on the ending side at ${endingSideX} comma ${endingSideY}.`, + srLinearSystemGraph: "Two lines on a coordinate plane.", + srLinearSystemPoints: ({lineNumber, point1X, point1Y, point2X, point2Y}) => + `Line ${lineNumber} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`, + srLinearSystemPoint: ({lineNumber, pointSequence, x, y}) => + `Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`, + srRayGraph: "A ray on a coordinate plane.", + srRayPoints: ({point1X, point1Y, point2X, point2Y}) => + `The endpoint is at ${point1X} comma ${point1Y} and the ray goes through point ${point2X} comma ${point2Y}.`, + srRayGrabHandle: ({point1X, point1Y, point2X, point2Y}) => + `Ray with endpoint ${point1X} comma ${point1Y} going through point ${point2X} comma ${point2Y}.`, + srRayEndpoint: ({x, y}) => `Endpoint at ${x} comma ${y}.`, + srRayTerminalPoint: ({x, y}) => `Through point at ${x} comma ${y}.`, srSinusoidGraphAriaLabel: "A sinusoid function on a coordinate plane.", srSinusoidExtremumPoint: ({x, y}) => `Extremum Point at ${x} comma ${y}.`, srSinusoidMidlineIntersection: ({x, y}) => diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 8d1a904d03..bcc52693c8 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -8,6 +8,7 @@ import type { UserInputMap, } from "./validation.types"; import type {WidgetPromptJSON} from "./widget-ai-utils/prompt-types"; +import type getCategorizerPublicWidgetOptions from "./widgets/categorizer/categorizer.util"; import type {KeypadAPI} from "@khanacademy/math-input"; import type { Hint, @@ -542,6 +543,12 @@ export type WidgetScorerFunction = ( locale?: string, ) => PerseusScore; +/** + * A union type of all the functions that provide public widget options. + */ +export type PublicWidgetOptionsFunction = + typeof getCategorizerPublicWidgetOptions; + export type WidgetExports< T extends React.ComponentType & Widget = React.ComponentType, > = Readonly<{ @@ -589,6 +596,12 @@ export type WidgetExports< */ scorer?: WidgetScorerFunction; + /** + * A function that provides a public version of the widget options that can + * be shared with the client. + */ + getPublicWidgetOptions?: PublicWidgetOptionsFunction; + getOneCorrectAnswerFromRubric?: ( rubric: Rubric, ) => string | null | undefined; diff --git a/packages/perseus/src/util/parse-perseus-json/exhaustive-test-tool/index.ts b/packages/perseus/src/util/parse-perseus-json/exhaustive-test-tool/index.ts index 439fd32989..4414eb322f 100755 --- a/packages/perseus/src/util/parse-perseus-json/exhaustive-test-tool/index.ts +++ b/packages/perseus/src/util/parse-perseus-json/exhaustive-test-tool/index.ts @@ -26,8 +26,10 @@ import * as fs from "fs/promises"; import {join} from "path"; import {ErrorTrackingParseContext} from "../error-tracking-parse-context"; +import {isObject} from "../general-purpose-parsers"; import {formatPath} from "../object-path"; import {parsePerseusItem} from "../perseus-parsers/perseus-item"; +import {parsePerseusRenderer} from "../perseus-parsers/perseus-renderer"; import {isSuccess} from "../result"; import type {Mismatch} from "../parser-types"; @@ -61,20 +63,8 @@ async function testFile(path: string, outputDir: string) { return; } const json = await fs.readFile(path, "utf-8"); - let assessmentItems: null | unknown[] = null; - try { - assessmentItems = JSON.parse(json); - } catch { - // eslint-disable-next-line no-console - console.warn("Failed to parse JSON file: " + path); - return; - } - if (!Array.isArray(assessmentItems)) { - return; - } - - for (const rawItem of assessmentItems.map(getAssessmentItemData)) { - for (const mismatch of getMismatches(rawItem)) { + for (const tester of createContentItemTesters(json, path)) { + for (const mismatch of tester.getMismatches()) { const desc = describeMismatch(mismatch); const hash = sha256(desc); await fs.mkdir(join(outputDir, hash), {recursive: true}); @@ -89,27 +79,93 @@ async function testFile(path: string, outputDir: string) { // current item is shorter than the one already on disk. await writeFileIfShorterThanExisting( join(outputDir, hash, "item.json"), - String(JSON.stringify(rawItem)), + String(JSON.stringify(tester.rawData)), "utf-8", ); } } } -function getAssessmentItemData(raw: unknown): unknown { - if (raw && typeof raw === "object" && "item_data" in raw) { - return raw.item_data; - } else { - return raw; - } +interface ContentItemTester { + readonly rawData: unknown; + getMismatches(): Mismatch[]; } -function getMismatches(rawItem: unknown): Mismatch[] { - const result = parsePerseusItem(rawItem, new ErrorTrackingParseContext([])); - if (isSuccess(result)) { +function createContentItemTesters( + json: string, + path: string, +): ContentItemTester[] { + let contentItems: unknown; + try { + contentItems = JSON.parse(json); + } catch { + // eslint-disable-next-line no-console + console.warn("Failed to parse JSON file: " + path); return []; } - return result.detail; + if (!Array.isArray(contentItems)) { + return []; + } + + const testers: ContentItemTester[] = []; + for (const item of contentItems) { + const tester = createContentItemTester(item, path); + if (tester != null) { + testers.push(tester); + } + } + + return testers; +} + +function createContentItemTester( + item: unknown, + path: string, +): ContentItemTester | undefined { + if (isObject(item)) { + if ("item_data" in item) { + // We're looking at an exercise. + return new AssessmentItemTester(item.item_data); + } + + if ("content" in item) { + // We're looking at an article. + return new ArticleTester(item); + } + } + + // eslint-disable-next-line no-console + console.warn("Cannot classify content as an article or exercise: ", path); +} + +class AssessmentItemTester implements ContentItemTester { + constructor(readonly rawData: unknown) {} + + getMismatches(): Mismatch[] { + const result = parsePerseusItem( + this.rawData, + new ErrorTrackingParseContext([]), + ); + if (isSuccess(result)) { + return []; + } + return result.detail; + } +} + +class ArticleTester implements ContentItemTester { + constructor(readonly rawData: unknown) {} + + getMismatches(): Mismatch[] { + const result = parsePerseusRenderer( + this.rawData, + new ErrorTrackingParseContext([]), + ); + if (isSuccess(result)) { + return []; + } + return result.detail; + } } function describeMismatch(mismatch: Mismatch): string { diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index 74dccba384..958acfd31a 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -11,6 +11,7 @@ import type { WidgetExports, WidgetTransform, WidgetScorerFunction, + PublicWidgetOptionsFunction, } from "./types"; import type {PerseusWidget} from "@khanacademy/perseus-core"; import type * as React from "react"; @@ -141,6 +142,12 @@ export const getWidgetScorer = (name: string): WidgetScorerFunction | null => { return widgets[name]?.scorer ?? null; }; +export const getPublicWidgetOptionsFunction = ( + name: string, +): PublicWidgetOptionsFunction => { + return widgets[name]?.getPublicWidgetOptions ?? ((i) => i); +}; + export const getEditor = (name: string): Editor | null | undefined => { return _.has(editors, name) ? editors[name] : null; }; diff --git a/packages/perseus/src/widgets/categorizer/categorizer.tsx b/packages/perseus/src/widgets/categorizer/categorizer.tsx index 4d18004162..ab4c47e366 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.tsx +++ b/packages/perseus/src/widgets/categorizer/categorizer.tsx @@ -16,6 +16,7 @@ import sharedStyles from "../../styles/shared"; import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/categorizer/categorizer-ai-utils"; +import getCategorizerPublicWidgetOptions from "./categorizer.util"; import scoreCategorizer from "./score-categorizer"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -328,4 +329,5 @@ export default { // TODO(LEMS-2656): remove TS suppression // @ts-expect-error: Type 'UserInput' is not assignable to type 'PerseusCSProgramUserInput'. scorer: scoreCategorizer, + getPublicWidgetOptions: getCategorizerPublicWidgetOptions, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/categorizer/categorizer.util.test.ts b/packages/perseus/src/widgets/categorizer/categorizer.util.test.ts new file mode 100644 index 0000000000..3b95496540 --- /dev/null +++ b/packages/perseus/src/widgets/categorizer/categorizer.util.test.ts @@ -0,0 +1,32 @@ +import getCategorizerPublicWidgetOptions from "./categorizer.util"; + +import type {PerseusCategorizerWidgetOptions} from "@khanacademy/perseus-core"; + +describe("getCategorizerPublicWidgetOptions", () => { + it("returns an object without the answer data", () => { + const categorizerTestWidgetOptions: PerseusCategorizerWidgetOptions = { + values: [0, 1], + items: ["apples", "oranges"], + categories: ["citrus", "non-citrus"], + randomizeItems: true, + static: false, + highlightLint: false, + linterContext: { + contentType: "type", + paths: ["paths"], + stack: ["stack"], + }, + }; + + const publicWidgetOptions = getCategorizerPublicWidgetOptions( + categorizerTestWidgetOptions, + ); + + expect(publicWidgetOptions).toEqual({ + items: ["apples", "oranges"], + categories: ["citrus", "non-citrus"], + randomizeItems: true, + static: false, + }); + }); +}); diff --git a/packages/perseus/src/widgets/categorizer/categorizer.util.ts b/packages/perseus/src/widgets/categorizer/categorizer.util.ts new file mode 100644 index 0000000000..9e64059479 --- /dev/null +++ b/packages/perseus/src/widgets/categorizer/categorizer.util.ts @@ -0,0 +1,29 @@ +import type {PerseusCategorizerWidgetOptions} from "@khanacademy/perseus-core"; + +/** + * For details on the individual options, see the + * PerseusCategorizerWidgetOptions type + */ +type CategorizerPublicWidgetOptions = { + items: PerseusCategorizerWidgetOptions["items"]; + categories: PerseusCategorizerWidgetOptions["categories"]; + randomizeItems: PerseusCategorizerWidgetOptions["randomizeItems"]; + static: PerseusCategorizerWidgetOptions["static"]; +}; + +/** + * Given a PerseusCategorizerWidgetOptions object, return a new object with only + * the public options that should be exposed to the client. + */ +function getCategorizerPublicWidgetOptions( + options: PerseusCategorizerWidgetOptions, +): CategorizerPublicWidgetOptions { + return { + items: options.items, + categories: options.categories, + randomizeItems: options.randomizeItems, + static: options.static, + }; +} + +export default getCategorizerPublicWidgetOptions; diff --git a/packages/perseus/src/widgets/graded-group/graded-group.tsx b/packages/perseus/src/widgets/graded-group/graded-group.tsx index c0fbbb11b3..97052b5365 100644 --- a/packages/perseus/src/widgets/graded-group/graded-group.tsx +++ b/packages/perseus/src/widgets/graded-group/graded-group.tsx @@ -13,6 +13,7 @@ import {iconOk, iconRemove} from "../../icon-paths"; import * as Changeable from "../../mixins/changeable"; import {ApiOptions} from "../../perseus-api"; import Renderer from "../../renderer"; +import {mapErrorToString} from "../../strings"; import { gray68, gray76, @@ -189,7 +190,7 @@ export class GradedGroup score.type === "points" ? score.message || "" : score.message - ? `${INVALID_MESSAGE_PREFIX} ${score.message}` + ? `${INVALID_MESSAGE_PREFIX} ${mapErrorToString(score.message)}` : `${INVALID_MESSAGE_PREFIX} ${DEFAULT_INVALID_MESSAGE_1}${DEFAULT_INVALID_MESSAGE_2}`; this.setState({ diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx index 9960185e23..eda9dce47d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx @@ -2,6 +2,7 @@ import {vec} from "mafs"; import * as React from "react"; import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; import {X, Y, calculateAngleInDegrees, getClockwiseAngle, polar} from "../math"; import {findIntersectionOfRays} from "../math/geometry"; import {actions} from "../reducer/interactive-graph-action"; @@ -215,7 +216,7 @@ function AngleGraph(props: AngleGraphProps) { } ariaLabel={initialSideAriaLabel} /> - + {wholeAngleDescription} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx index 4fbef30be6..805d14a148 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import {useRef} from "react"; import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; import {snap, X, Y} from "../math"; import {actions} from "../reducer/interactive-graph-action"; import {getRadius} from "../reducer/interactive-graph-state"; @@ -103,10 +104,10 @@ function CircleGraph(props: CircleGraphProps) { /> {/* Hidden elements to provide the descriptions for the circle and radius point's `aria-describedby` properties. */} - + {srCircleRadius} - + {srCircleOuterPoints} 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..2bda019d36 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx @@ -0,0 +1,301 @@ +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 {mockPerseusI18nContext} from "../../../components/i18n-context"; +import {MafsGraph} from "../mafs-graph"; +import {getBaseMafsGraphPropsForTests} from "../utils"; + +import {describeLinearSystemGraph} from "./linear-system"; + +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).toHaveAccessibleDescription( + "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. The line crosses the Y-axis at 0 comma 5. Its slope is zero. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5. The line crosses the Y-axis at 0 comma -5. Its slope is zero.", + ); + }); + + // Test each line in the linear system graph separately. + describe.each` + lineNumber + ${1} + ${2} + `(`Line $lineNumber`, ({lineNumber}) => { + 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."} + ${"overlaps y-axis"} | ${[[0, 5], [0, 2]]} | ${"The line crosses the X-axis at 0 comma 0."} + ${"overlaps x-axis"} | ${[[5, 0], [2, 0]]} | ${"The line crosses the Y-axis at 0 comma 0."} + `( + "slope description should include slope info for $case", + ({coords, interceptDescription}) => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineNumber - 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.`} + ${"overlaps x-axis"} | ${[[1, 0], [3, 0]]} | ${`Its slope is zero.`} + ${"overlaps y-axis"} | ${[[0, 1], [0, 3]]} | ${`Its slope is undefined.`} + `( + "slope description should include slope info for $case", + ({coords, slopeDescription}) => { + // Arrange + const newCoords = [...baseLinearSystemState.coords]; + newCoords[lineNumber - 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[lineNumber - 1] = [ + [-2, 3], + [3, 3], + ]; + + // Act + render( + , + ); + + const interactiveElements = screen.getAllByRole("button"); + + // Get interactive elements for this line. + const point1 = interactiveElements[0 + (lineNumber - 1) * 3]; + const grabHandle = interactiveElements[1 + (lineNumber - 1) * 3]; + const point2 = interactiveElements[2 + (lineNumber - 1) * 3]; + + // Assert + // Check updated aria-label for the linear graph. + expect(point1).toHaveAttribute( + "aria-label", + `Point 1 on line ${lineNumber} at -2 comma 3.`, + ); + expect(grabHandle).toHaveAttribute( + "aria-label", + `The line crosses the Y-axis at 0 comma 3. Its slope is zero.`, + ); + expect(point2).toHaveAttribute( + "aria-label", + `Point 2 on line ${lineNumber} at 3 comma 3.`, + ); + }); + + test.each` + element | index + ${"point1"} | ${0} + ${"grabHandle"} | ${1} + ${"point2"} | ${2} + `("should have describedby on all interactive elements", ({index}) => { + // Arrange + render( + , + ); + + // Act + const interactiveElements = screen.getAllByRole("button"); + const element = interactiveElements[index + (lineNumber - 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( + , + ); + 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], + ); + }, + ); + }); +}); + +describe(describeLinearSystemGraph, () => { + test("describes a default linear system graph", () => { + // Arrange + + // Act + const linearSystemGraphDescription = describeLinearSystemGraph( + baseLinearSystemState, + mockPerseusI18nContext, + ); + + // Assert + expect(linearSystemGraphDescription).toEqual( + "Interactive elements: Two lines on a coordinate plane. Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.", + ); + }); + + test("describes a linear system graph with updated points", () => { + // Arrange + + // Act + const linearSystemGraphDescription = describeLinearSystemGraph( + { + ...baseLinearSystemState, + coords: [ + [ + [-2, 3], + [3, 3], + ], + [ + [-2, -3], + [3, -3], + ], + ], + }, + mockPerseusI18nContext, + ); + + // Assert + expect(linearSystemGraphDescription).toEqual( + "Interactive elements: Two lines on a coordinate plane. Line 1 has two points, point 1 at -2 comma 3 and point 2 at 3 comma 3. Line 2 has two points, point 1 at -2 comma -3 and point 2 at 3 comma -3.", + ); + }); +}); 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..d6a38cf885 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx @@ -1,9 +1,14 @@ import * as React from "react"; +import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; 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 {I18nContextType} from "../../../components/i18n-context"; import type { MafsGraphProps, LinearSystemGraphState, @@ -18,7 +23,9 @@ export function renderLinearSystemGraph( ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: null, + interactiveElementsDescription: ( + + ), }; } @@ -28,12 +35,64 @@ 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({ + lineNumber: 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 +115,82 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => { color="var(--movable-line-stroke-color)" /> ))} - ; - + {linesAriaInfo.map( + ({ + pointsDescriptionId, + interceptDescriptionId, + slopeDescriptionId, + pointsDescription, + interceptDescription, + slopeDescription, + }) => ( + <> + + {pointsDescription} + + + {interceptDescription} + + + {slopeDescription} + + + ), + )} + ); }; + +function LinearSystemGraphDescription({ + state, +}: { + state: LinearSystemGraphState; +}) { + // The reason that LinearSystemGraphDescription is a component (rather + // than a function that returns a string) is because it needs to use a + // hook: `usePerseusI18n`. + const i18n = usePerseusI18n(); + + return describeLinearSystemGraph(state, i18n); +} + +// Exported for testing +export function describeLinearSystemGraph( + state: LinearSystemGraphState, + i18n: I18nContextType, +): string { + const {strings, locale} = i18n; + const {coords: lines} = state; + + const graphDescription = strings.srLinearSystemGraph; + + const lineDescriptions = lines.map((line, i) => { + const point1 = line[0]; + const point2 = line[1]; + return strings.srLinearSystemPoints({ + lineNumber: i + 1, + point1X: srFormatNumber(point1[0], locale), + point1Y: srFormatNumber(point1[1], locale), + point2X: srFormatNumber(point2[0], locale), + point2Y: srFormatNumber(point2[1], locale), + }); + }); + + const allDescriptions = [graphDescription, ...lineDescriptions]; + + return strings.srInteractiveElements({ + elements: allDescriptions.join(" "), + }); +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx index 915084532d..f0b0f429df 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx @@ -1,10 +1,12 @@ import * as React from "react"; import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; 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,57 +51,17 @@ 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 ( { /> {/* Hidden elements to provide the descriptions for the circle and radius point's `aria-describedby` properties. */} - + {linearGraphPointsDescription} - + {interceptString} - + {slopeString} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx new file mode 100644 index 0000000000..f87795306f --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx @@ -0,0 +1,212 @@ +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 {mockPerseusI18nContext} from "../../../components/i18n-context"; +import {MafsGraph} from "../mafs-graph"; +import {getBaseMafsGraphPropsForTests} from "../utils"; + +import {describeRayGraph} from "./ray"; + +import type {InteractiveGraphState} from "../types"; +import type {UserEvent} from "@testing-library/user-event"; + +const baseMafsGraphProps = getBaseMafsGraphPropsForTests(); +const baseRayState: InteractiveGraphState = { + type: "ray", + coords: [ + [-5, 5], + [5, 5], + ], + hasBeenInteractedWith: false, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], +}; + +const overallGraphLabel = "A ray on a coordinate plane."; + +describe("Linear 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 graph", () => { + // Arrange + render(); + + // Act + const linearGraph = screen.getByLabelText( + "A ray on a coordinate plane.", + ); + + // Assert + expect(linearGraph).toBeInTheDocument(); + expect(linearGraph).toHaveAccessibleDescription( + "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.", + ); + }); + + test.each` + element | index | expectedValue + ${"point1"} | ${0} | ${"Endpoint at -5 comma 5."} + ${"grabHandle"} | ${1} | ${"Ray with endpoint -5 comma 5 going through point 5 comma 5."} + ${"point2"} | ${2} | ${"Through point at 5 comma 5."} + `( + "should have aria label for $element on the line", + ({index, expectedValue}) => { + // Arrange + render(); + + // Act + // Moveable elements: point 1, grab handle, point 2 + const movableElements = screen.getAllByRole("button"); + const element = movableElements[index]; + + // Assert + expect(element).toHaveAttribute("aria-label", expectedValue); + }, + ); + + test("points description should include points info", () => { + // Arrange + render(); + + // Act + const linearGraph = screen.getByLabelText(overallGraphLabel); + + // Assert + expect(linearGraph).toHaveTextContent( + "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.", + ); + }); + + test("aria label reflects updated values", async () => { + // Arrange + + // Act + render( + , + ); + + const interactiveElements = screen.getAllByRole("button"); + const [point1, grabHandle, point2] = interactiveElements; + + // Assert + // Check updated aria-label for the linear graph. + expect(point1).toHaveAttribute("aria-label", "Endpoint at -2 comma 3."); + expect(grabHandle).toHaveAttribute( + "aria-label", + "Ray with endpoint -2 comma 3 going through point 3 comma 3.", + ); + expect(point2).toHaveAttribute( + "aria-label", + "Through point 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]); + }, + ); +}); + +describe("describeRayGraph", () => { + test("describes a default ray", () => { + // Arrange + + // Act + const strings = describeRayGraph(baseRayState, mockPerseusI18nContext); + + // Assert + expect(strings.srRayGraph).toBe("A ray on a coordinate plane."); + expect(strings.srRayPoints).toBe( + "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.", + ); + expect(strings.srRayEndpoint).toBe("Endpoint at -5 comma 5."); + expect(strings.srRayTerminalPoint).toBe("Through point at 5 comma 5."); + expect(strings.srRayGrabHandle).toBe( + "Ray with endpoint -5 comma 5 going through point 5 comma 5.", + ); + expect(strings.srRayInteractiveElement).toBe( + "Interactive elements: A ray on a coordinate plane. The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.", + ); + }); + + test("describes a ray with updated points", () => { + // Arrange + + // Act + const strings = describeRayGraph( + { + ...baseRayState, + coords: [ + [-1, 2], + [3, 4], + ], + }, + mockPerseusI18nContext, + ); + + // Assert + expect(strings.srRayGraph).toBe("A ray on a coordinate plane."); + expect(strings.srRayPoints).toBe( + "The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.", + ); + expect(strings.srRayEndpoint).toBe("Endpoint at -1 comma 2."); + expect(strings.srRayTerminalPoint).toBe("Through point at 3 comma 4."); + expect(strings.srRayGrabHandle).toBe( + "Ray with endpoint -1 comma 2 going through point 3 comma 4.", + ); + expect(strings.srRayInteractiveElement).toBe( + "Interactive elements: A ray on a coordinate plane. The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.", + ); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx index 2880e7c5b0..fc9aa3e48d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx @@ -1,9 +1,13 @@ import * as React from "react"; +import {usePerseusI18n} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; import {actions} from "../reducer/interactive-graph-action"; import {MovableLine} from "./components/movable-line"; +import {srFormatNumber} from "./screenreader-text"; +import type {I18nContextType} from "../../../components/i18n-context"; import type { Dispatch, InteractiveGraphElementSuite, @@ -18,7 +22,7 @@ export function renderRayGraph( ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: null, + interactiveElementsDescription: , }; } @@ -33,16 +37,100 @@ const RayGraph = (props: Props) => { const handleMovePoint = (pointIndex: number, newPoint: vec.Vector2) => dispatch(actions.ray.movePoint(pointIndex, newPoint)); + const {strings, locale} = usePerseusI18n(); + const id = React.useId(); + const pointsDescriptionId = id + "-points"; + + // Aria label strings + const { + srRayGraph, + srRayPoints, + srRayEndpoint, + srRayTerminalPoint, + srRayGrabHandle, + } = describeRayGraph(props.graphState, {strings, locale}); + // Ray graphs only have one line return ( - + + + {/* Hidden elements to provide the descriptions for the + `aria-describedby` properties. */} + + {srRayPoints} + + ); }; + +function RayGraphDescription({state}: {state: RayGraphState}) { + // The reason that RayGraphDescription is a component (rather than a + // function that returns a string) is because it needs to use a + // hook: `usePerseusI18n`. + const i18n = usePerseusI18n(); + const strings = describeRayGraph(state, i18n); + + return strings.srRayInteractiveElement; +} + +// Exported for testing +export function describeRayGraph( + state: RayGraphState, + i18n: I18nContextType, +): Record { + const {coords: line} = state; + const {strings, locale} = i18n; + + // Aria label strings + const srRayGraph = strings.srRayGraph; + const srRayPoints = strings.srRayPoints({ + point1X: srFormatNumber(line[0][0], locale), + point1Y: srFormatNumber(line[0][1], locale), + point2X: srFormatNumber(line[1][0], locale), + point2Y: srFormatNumber(line[1][1], locale), + }); + const srRayEndpoint = strings.srRayEndpoint({ + x: srFormatNumber(line[0][0], locale), + y: srFormatNumber(line[0][1], locale), + }); + const srRayTerminalPoint = strings.srRayTerminalPoint({ + x: srFormatNumber(line[1][0], locale), + y: srFormatNumber(line[1][1], locale), + }); + const srRayGrabHandle = strings.srRayGrabHandle({ + point1X: srFormatNumber(line[0][0], locale), + point1Y: srFormatNumber(line[0][1], locale), + point2X: srFormatNumber(line[1][0], locale), + point2Y: srFormatNumber(line[1][1], locale), + }); + + const srRayInteractiveElement = strings.srInteractiveElements({ + elements: [srRayGraph, srRayPoints].join(" "), + }); + + return { + srRayGraph, + srRayPoints, + srRayEndpoint, + srRayTerminalPoint, + srRayGrabHandle, + srRayInteractiveElement, + }; +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts index d42852ec91..c023e5d7f6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts @@ -1,3 +1,7 @@ +import {srFormatNumber} from "./screenreader-text"; + +import type {PerseusStrings} from "../../../strings"; +import type {PairOfPoints} from "../types"; import type {Coord} from "@khanacademy/perseus"; import type {Interval, vec} from "mafs"; @@ -62,3 +66,57 @@ export function getArrayWithoutDuplicates(array: Array): Array { return returnArray; } + +export function getSlopeStringForLine( + line: PairOfPoints, + strings: PerseusStrings, +): string { + const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]); + if (!Number.isFinite(slope)) { + return strings.srLinearGraphSlopeVertical; + } + + if (slope === 0) { + return strings.srLinearGraphSlopeHorizontal; + } + + return slope > 0 + ? strings.srLinearGraphSlopeIncreasing + : strings.srLinearGraphSlopeDecreasing; +} + +export function getInterceptStringForLine( + line: PairOfPoints, + strings: PerseusStrings, + locale: string, +): 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]; + + // Check if the line fully overlaps with an axis. + const overlapsXAxis = line[0][1] === 0 && line[1][1] === 0; + const overlapsYAxis = line[0][0] === 0 && line[1][0] === 0; + + const hasXIntercept = Number.isFinite(xIntercept) && !overlapsXAxis; + const hasYIntercept = Number.isFinite(yIntercept) && !overlapsYAxis; + + 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/locked-figures/locked-ellipse.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-ellipse.tsx index 62c3ed3044..a987e7e99c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-ellipse.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-ellipse.tsx @@ -18,6 +18,7 @@ const LockedEllipse = (props: LockedEllipseType) => { className="locked-ellipse" aria-label={hasAria ? ariaLabel : undefined} aria-hidden={!hasAria} + role="img" > { className="locked-function" aria-label={hasAria ? props.ariaLabel : undefined} aria-hidden={!hasAria} + role="img" > {directionalAxis === "x" && ( equation.eval({x})} {...plotProps} /> diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx index ee16d9f152..36fcf12e48 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-line.tsx @@ -115,6 +115,7 @@ const LockedLine = (props: Props) => { className={kind === "ray" ? "locked-ray" : "locked-line"} aria-label={hasAria ? ariaLabel : undefined} aria-hidden={!hasAria} + role="img" > {line} {showPoint1 && ( diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx index 15f8b57629..2b06129960 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-point.tsx @@ -17,6 +17,7 @@ const LockedPoint = (props: LockedPointType) => { className="locked-point" aria-label={hasAria ? ariaLabel : undefined} aria-hidden={!hasAria} + role="img" > { className="locked-polygon" aria-label={hasAria ? props.ariaLabel : undefined} aria-hidden={!hasAria} + role="img" > { className="locked-vector" aria-label={hasAria ? ariaLabel : undefined} aria-hidden={!hasAria} + role="img" > 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 5c01b19373..44ba46ea08 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)", () => { @@ -278,8 +278,8 @@ describe("MafsGraph", () => { />, ); - expectLabelInDoc("Point 1 at 0 comma 0"); - expectLabelInDoc("Point 2 at -7 comma 0.5"); + expectLabelInDoc("Endpoint at 0 comma 0."); + expectLabelInDoc("Through point at -7 comma 0.5."); }); it("renders ARIA labels for each point (circle)", () => {