From cdd063f7c942f985eba0bdfb9476d8d5ae3f0553 Mon Sep 17 00:00:00 2001 From: Ivy Olamit Date: Mon, 1 Jun 2026 08:42:21 -0700 Subject: [PATCH 1/2] [LEMS-3995/poc-option-b-use-point-aria-label] [Interactive Graph] POC Refactor buildPointAriaLabel to use custom hook usePointAriaLabel --- .../graphs/absolute-value.tsx | 7 +- .../interactive-graphs/graphs/angle.tsx | 4 +- .../interactive-graphs/graphs/circle.tsx | 4 +- .../components/build-point-aria-label.test.ts | 49 +++++------ .../components/build-point-aria-label.ts | 59 +++++-------- .../interactive-graphs/graphs/exponential.tsx | 4 +- .../graphs/linear-system.tsx | 5 -- .../interactive-graphs/graphs/linear.tsx | 4 +- .../interactive-graphs/graphs/logarithm.tsx | 4 +- .../interactive-graphs/graphs/point.test.ts | 80 ++++++++++++++---- .../interactive-graphs/graphs/point.tsx | 18 +--- .../interactive-graphs/graphs/polygon.tsx | 24 ++---- .../interactive-graphs/graphs/quadratic.tsx | 7 +- .../widgets/interactive-graphs/graphs/ray.tsx | 7 +- .../interactive-graphs/graphs/segment.tsx | 2 - .../interactive-graphs/graphs/sinusoid.tsx | 4 +- .../interactive-graphs/graphs/tangent.tsx | 4 +- .../interactive-graphs/graphs/vector.tsx | 4 +- .../widgets/interactive-graphs/mafs-graph.tsx | 50 +++++------ .../src/widgets/interactive-graphs/types.ts | 1 - .../use-interactive-elements-description.ts | 82 +++++++++++++++++++ 21 files changed, 236 insertions(+), 187 deletions(-) create mode 100644 packages/perseus/src/widgets/interactive-graphs/use-interactive-elements-description.ts diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/absolute-value.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/absolute-value.tsx index eadcad8da40..cc43bb3bdd4 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/absolute-value.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/absolute-value.tsx @@ -25,14 +25,9 @@ import type {Coord} from "@khanacademy/perseus-core"; export function renderAbsoluteValueGraph( state: AbsoluteValueGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getAbsoluteValueDescription( - state, - i18n, - ), }; } @@ -173,7 +168,7 @@ export const getAbsoluteValueKeyboardConstraint = ( }; }; -function getAbsoluteValueDescription( +export function getAbsoluteValueDescription( state: AbsoluteValueGraphState, i18n: I18nContextType, ): string { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx index 4de7482eaaa..1530f2feaef 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx @@ -35,11 +35,9 @@ type AngleGraphProps = MafsGraphProps; export function renderAngleGraph( state: AngleGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getAngleGraphDescription(state, i18n), }; } @@ -167,7 +165,7 @@ function AngleGraph(props: AngleGraphProps) { ); } -function getAngleGraphDescription( +export function getAngleGraphDescription( state: AngleGraphState, i18n: I18nContextType, ): string { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx index ad4727af7d8..2865a7eb4fa 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx @@ -34,11 +34,9 @@ import type { export function renderCircleGraph( state: CircleGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getCircleGraphDescription(state, i18n), }; } @@ -256,7 +254,7 @@ function crossProduct(as: A[], bs: B[]): [A, B][] { return result; } -function getCircleGraphDescription( +export function getCircleGraphDescription( state: CircleGraphState, i18n: I18nContextType, ) { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.test.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.test.ts index b9f00c56216..fa6b77ccc9d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.test.ts @@ -1,8 +1,6 @@ -import {mockPerseusI18nContext} from "../../../../components/i18n-context"; +import {renderHook} from "@testing-library/react"; -import {buildPointAriaLabel, resolvePointLabel} from "./build-point-aria-label"; - -const {strings, locale} = mockPerseusI18nContext; +import {resolvePointLabel, usePointAriaLabel} from "./build-point-aria-label"; describe("resolvePointLabel", () => { it("returns the 1-indexed default when pointLabels is undefined", () => { @@ -36,35 +34,33 @@ describe("resolvePointLabel", () => { }); }); -describe("buildPointAriaLabel", () => { +describe("usePointAriaLabel", () => { + const renderBuildLabel = (pointLabels?: ReadonlyArray) => + renderHook(() => usePointAriaLabel(pointLabels)).result.current; + it("returns undefined when pointLabels is undefined", () => { - expect( - buildPointAriaLabel(undefined, 0, [0, 0], strings, locale), - ).toBeUndefined(); + const buildLabel = renderBuildLabel(undefined); + expect(buildLabel(0, [0, 0])).toBeUndefined(); }); it("returns undefined when there is no label at the given index", () => { - expect( - buildPointAriaLabel(["T"], 1, [3, 5], strings, locale), - ).toBeUndefined(); + const buildLabel = renderBuildLabel(["T"]); + expect(buildLabel(1, [3, 5])).toBeUndefined(); }); it("returns undefined when the label at the index is an empty string", () => { - expect( - buildPointAriaLabel(["", "B"], 0, [3, 5], strings, locale), - ).toBeUndefined(); + const buildLabel = renderBuildLabel(["", "B"]); + expect(buildLabel(0, [3, 5])).toBeUndefined(); }); it("returns the formatted aria-label when a custom label is set", () => { - expect(buildPointAriaLabel(["T"], 0, [0, 0], strings, locale)).toBe( - "Point T at 0 comma 0.", - ); + const buildLabel = renderBuildLabel(["T"]); + expect(buildLabel(0, [0, 0])).toBe("Point T at 0 comma 0."); }); it("uses the label at the matching index for multi-point graphs", () => { - expect( - buildPointAriaLabel(["A", "B"], 1, [-1, 2], strings, locale), - ).toBe("Point B at -1 comma 2."); + const buildLabel = renderBuildLabel(["A", "B"]); + expect(buildLabel(1, [-1, 2])).toBe("Point B at -1 comma 2."); }); it("returns undefined for non-string entries (null, undefined, number) — defensive against malformed hand-authored JSON bypassing the parser", () => { @@ -74,14 +70,9 @@ describe("buildPointAriaLabel", () => { undefined, 42, ] as unknown as ReadonlyArray; - expect( - buildPointAriaLabel(labels, 0, [0, 0], strings, locale), - ).toBeUndefined(); - expect( - buildPointAriaLabel(labels, 1, [0, 0], strings, locale), - ).toBeUndefined(); - expect( - buildPointAriaLabel(labels, 2, [0, 0], strings, locale), - ).toBeUndefined(); + const buildLabel = renderBuildLabel(labels); + expect(buildLabel(0, [0, 0])).toBeUndefined(); + expect(buildLabel(1, [0, 0])).toBeUndefined(); + expect(buildLabel(2, [0, 0])).toBeUndefined(); }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.ts index 2fac281236d..93ad085937f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/build-point-aria-label.ts @@ -1,7 +1,6 @@ import {usePerseusI18n} from "../../../../components/i18n-context"; import {srFormatNumber} from "../screenreader-text"; -import type {PerseusStrings} from "../../../../strings"; import type {vec} from "mafs"; // Returns the custom label from `pointLabels`, or the 1-indexed sequence @@ -21,46 +20,32 @@ export function resolvePointLabel( } /** - * Returns a screen-reader aria-label for an interactive point using a - * custom label (e.g. "T"). Returns `undefined` when no custom label - * is set so callers can fall back to the default label (e.g. "Point 1", - * "Point 2", ...) built by `useControlPoint`. + * Hook that returns the canonical screen-reader aria-label builder for an + * interactive point, bound to the current locale and the given `pointLabels`. * - * Prefer `usePointAriaLabel` in React components — it binds `strings` and - * `locale` from `usePerseusI18n()` so call sites read `buildLabel(i, point)`. - * Use `buildPointAriaLabel` directly only from non-React functions (e.g. - * graph-description helpers) that already receive `strings` / `locale` as - * parameters and therefore can't use a hook. - */ -export function buildPointAriaLabel( - pointLabels: ReadonlyArray | undefined, - index: number, - point: vec.Vector2, - strings: PerseusStrings, - locale: string, -): string | undefined { - const label = resolvePointLabel(pointLabels, index); - // When the resolved label is the numeric default, return undefined so - // `useControlPoint` keeps its existing fallback behavior. - if (typeof label === "number") { - return undefined; - } - return strings.srPointAtCoordinates({ - num: label, - x: srFormatNumber(point[0], locale), - y: srFormatNumber(point[1], locale), - }); -} - -/** - * Hook that returns a point aria-label builder bound to the current locale - * and the given `pointLabels`. Returns `undefined` for points without a - * custom label so callers can fall back to default labels. + * Returns `undefined` for points without a custom label so callers can fall + * back to the default label (e.g. "Point 1", "Point 2", ...) built by + * `useControlPoint`. + * + * Non-React callers (e.g. graph-description helpers) should accept the + * builder as a parameter from a React-component ancestor rather than calling + * the hook themselves. */ export function usePointAriaLabel( pointLabels: ReadonlyArray | undefined, ) { const {strings, locale} = usePerseusI18n(); - return (index: number, point: vec.Vector2) => - buildPointAriaLabel(pointLabels, index, point, strings, locale); + return (index: number, point: vec.Vector2): string | undefined => { + const label = resolvePointLabel(pointLabels, index); + // When the resolved label is the numeric default, return undefined so + // `useControlPoint` keeps its existing fallback behavior. + if (typeof label === "number") { + return undefined; + } + return strings.srPointAtCoordinates({ + num: label, + x: srFormatNumber(point[0], locale), + y: srFormatNumber(point[1], locale), + }); + }; } diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx index 8651085e5c8..69ab286eab4 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx @@ -38,11 +38,9 @@ const {getExponentialCoefficients} = kmathCoefficients; export function renderExponentialGraph( state: ExponentialGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getExponentialDescription(state, i18n), }; } @@ -209,7 +207,7 @@ const computeExponential = function ( return a * Math.exp(b * x) + c; }; -function getExponentialDescription( +export function getExponentialDescription( state: ExponentialGraphState, i18n: I18nContextType, ): string { 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 cff80fcbbe1..413f7dfcf43 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx @@ -22,14 +22,9 @@ import type {vec} from "mafs"; export function renderLinearSystemGraph( state: LinearSystemGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getLinearSystemGraphDescription( - state, - i18n, - ), }; } diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx index 3e2467d971f..321c30db049 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx @@ -21,11 +21,9 @@ import type {vec} from "mafs"; export function renderLinearGraph( state: LinearGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getLinearGraphDescription(state, i18n), }; } @@ -98,7 +96,7 @@ const LinearGraph = (props: LinearGraphProps, key: number) => { ); }; -function getLinearGraphDescription( +export function getLinearGraphDescription( state: LinearGraphState, i18n: I18nContextType, ) { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx index 52863cb654b..3933fae856b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx @@ -38,11 +38,9 @@ const {getLogarithmCoefficients} = kmathCoefficients; export function renderLogarithmGraph( state: LogarithmGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getLogarithmDescription(state, i18n), }; } @@ -266,7 +264,7 @@ function renderLogarithmCurve({ ); } -function getLogarithmDescription( +export function getLogarithmDescription( state: LogarithmGraphState, i18n: I18nContextType, ): string { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.test.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/point.test.ts index a35d3506e6f..026b37951d1 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.test.ts @@ -1,9 +1,17 @@ +import {renderHook} from "@testing-library/react"; + import {mockPerseusI18nContext} from "../../../components/i18n-context"; +import {usePointAriaLabel} from "./components/build-point-aria-label"; import {getPointGraphDescription} from "./point"; import type {PointGraphState} from "../types"; +// Resolves `usePointAriaLabel` via renderHook so non-React description tests +// can pass a `buildLabel` to `getPointGraphDescription`. +const makeBuildLabel = (pointLabels?: ReadonlyArray) => + renderHook(() => usePointAriaLabel(pointLabels)).result.current; + describe("getPointGraphDescription", () => { const baseState: PointGraphState = { type: "point", @@ -22,16 +30,24 @@ describe("getPointGraphDescription", () => { it(`returns "No interactive elements" for a graph with no points`, () => { const state: PointGraphState = {...baseState, coords: []}; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( - "No interactive elements", - ); + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe("No interactive elements"); }); it("describes one point", () => { const state: PointGraphState = {...baseState, coords: [[3, 5]]}; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( - "Interactive elements: Point 1 at 3 comma 5.", - ); + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe("Interactive elements: Point 1 at 3 comma 5."); }); it("separates multiple point descriptions with spaces", () => { @@ -42,7 +58,13 @@ describe("getPointGraphDescription", () => { [2, 4], ], }; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe( "Interactive elements: Point 1 at 3 comma 5. Point 2 at 2 comma 4.", ); }); @@ -52,9 +74,13 @@ describe("getPointGraphDescription", () => { ...baseState, coords: [[-1.1234, 3.5678]], }; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( - "Interactive elements: Point 1 at -1.123 comma 3.568.", - ); + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe("Interactive elements: Point 1 at -1.123 comma 3.568."); }); it("uses the custom point label when pointLabels is set", () => { @@ -63,9 +89,13 @@ describe("getPointGraphDescription", () => { coords: [[0, 0]], pointLabels: ["T"], }; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( - "Interactive elements: Point T at 0 comma 0.", - ); + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe("Interactive elements: Point T at 0 comma 0."); }); it("falls back to numeric defaults for indices without a custom label", () => { @@ -77,7 +107,13 @@ describe("getPointGraphDescription", () => { ], pointLabels: ["T"], }; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe( "Interactive elements: Point T at 0 comma 0. Point 2 at 1 comma 1.", ); }); @@ -91,7 +127,13 @@ describe("getPointGraphDescription", () => { ], pointLabels: ["", "T"], }; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe( "Interactive elements: Point 1 at 0 comma 0. Point T at 1 comma 1.", ); }); @@ -106,7 +148,13 @@ describe("getPointGraphDescription", () => { // eslint-disable-next-line no-restricted-syntax -- cast simulates malformed JSON the parser would reject pointLabels: [42, "T"] as unknown as string[], }; - expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe( + expect( + getPointGraphDescription( + state, + mockPerseusI18nContext, + makeBuildLabel(state.pointLabels), + ), + ).toBe( "Interactive elements: Point 1 at 0 comma 0. Point T at 1 comma 1.", ); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index 1b72fa103c7..468a3940d10 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -5,15 +5,11 @@ import {actions} from "../reducer/interactive-graph-action"; import useGraphConfig from "../reducer/use-graph-config"; import {getCSSZoomFactor} from "../utils"; -import { - buildPointAriaLabel, - usePointAriaLabel, -} from "./components/build-point-aria-label"; +import {usePointAriaLabel} from "./components/build-point-aria-label"; import {MovablePoint} from "./components/movable-point"; import {srFormatNumber} from "./screenreader-text"; import {useTransformVectorsToPixels, pixelsToVectors} from "./use-transform"; -import type {I18nContextType} from "../../../components/i18n-context"; import type {PerseusStrings} from "../../../strings"; import type {GraphConfig} from "../reducer/use-graph-config"; import type { @@ -22,15 +18,14 @@ import type { Dispatch, InteractiveGraphElementSuite, } from "../types"; +import type {vec} from "mafs"; export function renderPointGraph( state: PointGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getPointGraphDescription(state, i18n), }; } @@ -205,6 +200,7 @@ function UnlimitedPointGraph(statefulProps: StatefulProps) { export function getPointGraphDescription( state: PointGraphState, i18n: {strings: PerseusStrings; locale: string}, + buildLabel: (index: number, point: vec.Vector2) => string | undefined, ): string { const {strings, locale} = i18n; @@ -214,13 +210,7 @@ export function getPointGraphDescription( const pointDescriptions = state.coords.map( (point, index) => - buildPointAriaLabel( - state.pointLabels, - index, - point, - strings, - locale, - ) ?? + buildLabel(index, point) ?? strings.srPointAtCoordinates({ num: index + 1, x: srFormatNumber(point[0], locale), diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index bd5cbb5d047..bf647f3a00f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -20,10 +20,7 @@ import useGraphConfig from "../reducer/use-graph-config"; import {bound, getCSSZoomFactor, TARGET_SIZE} from "../utils"; import {PolygonAngle} from "./components/angle-indicators"; -import { - buildPointAriaLabel, - usePointAriaLabel, -} from "./components/build-point-aria-label"; +import {usePointAriaLabel} from "./components/build-point-aria-label"; import {MovablePoint} from "./components/movable-point"; import SRDescInSVG from "./components/sr-description-within-svg"; import {TextLabel} from "./components/text-label"; @@ -58,16 +55,9 @@ const {convertRadiansToDegrees} = angles; export function renderPolygonGraph( state: PolygonGraphState, dispatch: Dispatch, - i18n: I18nContextType, - markings: InteractiveGraphProps["markings"], ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getPolygonGraphDescription( - state, - i18n, - markings, - ), }; } @@ -221,6 +211,7 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => { statefulProps.graphState, {strings, locale}, statefulProps.graphConfig.markings, + buildLabel, ); return ( @@ -470,6 +461,7 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { statefulProps.graphState, {strings, locale}, statefulProps.graphConfig.markings, + buildLabel, ); return ( @@ -665,12 +657,13 @@ export const hasFocusVisible = ( } }; -function getPolygonGraphDescription( +export function getPolygonGraphDescription( state: PolygonGraphState, i18n: I18nContextType, markings: InteractiveGraphProps["markings"], + buildLabel: (index: number, point: vec.Vector2) => string | undefined, ): string | null { - const strings = describePolygonGraph(state, i18n, markings); + const strings = describePolygonGraph(state, i18n, markings, buildLabel); return strings.srPolygonInteractiveElements; } @@ -686,11 +679,10 @@ function describePolygonGraph( state: PolygonGraphState, i18n: I18nContextType, markings: InteractiveGraphProps["markings"], + buildLabel: (index: number, point: vec.Vector2) => string | undefined, ): PolygonGraphDescriptionStrings { const {strings, locale} = i18n; - const {coords, pointLabels} = state; - const buildLabel = (index: number, point: vec.Vector2) => - buildPointAriaLabel(pointLabels, index, point, strings, locale); + const {coords} = state; const isCoordinatePlane = markings === "axes" || markings === "graph"; const hasOnePoint = coords.length === 1; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx index 4efb982b095..84951ce4c6e 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx @@ -28,14 +28,9 @@ import type {QuadraticCoefficient, QuadraticCoords} from "@khanacademy/kmath"; export function renderQuadraticGraph( state: QuadraticGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getQuadraticGraphDescription( - state, - i18n, - ), }; } @@ -183,7 +178,7 @@ export const getQuadraticCoefficients = ( return [a, b, c]; }; -function getQuadraticGraphDescription( +export function getQuadraticGraphDescription( state: QuadraticGraphState, i18n: I18nContextType, ): string { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx index ed9a0d4c557..9beb82f1c56 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx @@ -20,11 +20,9 @@ import type {vec} from "mafs"; export function renderRayGraph( state: RayGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getRayGraphDescription(state, i18n), }; } @@ -84,7 +82,10 @@ const RayGraph = (props: Props) => { ); }; -function getRayGraphDescription(state: RayGraphState, i18n: I18nContextType) { +export function getRayGraphDescription( + state: RayGraphState, + i18n: I18nContextType, +) { const strings = describeRayGraph(state, i18n); return strings.srRayInteractiveElement; } diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx index 982fc9761a0..e34de950fb0 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/segment.tsx @@ -23,11 +23,9 @@ import type {vec} from "mafs"; export function renderSegmentGraph( state: SegmentGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getSegmentGraphDescription(state, i18n), }; } diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx index d308f6fefd1..7ba36974b68 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/sinusoid.tsx @@ -26,11 +26,9 @@ import type {Coord} from "@khanacademy/perseus-core"; export function renderSinusoidGraph( state: SinusoidGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getSinusoidDescription(state, i18n), }; } @@ -199,7 +197,7 @@ export const getSinusoidCoefficients = ( return {amplitude, angularFrequency, phase, verticalOffset}; }; -function getSinusoidDescription( +export function getSinusoidDescription( state: SinusoidGraphState, i18n: I18nContextType, ): string { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/tangent.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/tangent.tsx index f66ca00d839..e3a29ad3944 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/tangent.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/tangent.tsx @@ -26,11 +26,9 @@ import type {Coord} from "@khanacademy/perseus-core"; export function renderTangentGraph( state: TangentGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getTangentDescription(state, i18n), }; } @@ -294,7 +292,7 @@ function getPlotSegments( return segments; } -function getTangentDescription( +export function getTangentDescription( state: TangentGraphState, i18n: I18nContextType, ): string { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/vector.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/vector.tsx index 4b28650747c..4eec79cdce0 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/vector.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/vector.tsx @@ -38,11 +38,9 @@ const TAIL_DOT_RADIUS = 6; export function renderVectorGraph( state: VectorGraphState, dispatch: Dispatch, - i18n: I18nContextType, ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: getVectorGraphDescription(state, i18n), }; } @@ -277,7 +275,7 @@ export const getVectorTipKeyboardConstraint = ( }; }; -function getVectorGraphDescription( +export function getVectorGraphDescription( state: VectorGraphState, i18n: I18nContextType, ) { diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx index 273e511dbcf..92721cf1207 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx @@ -55,6 +55,7 @@ import {X, Y} from "./math"; import {Protractor} from "./protractor"; import {actions} from "./reducer/interactive-graph-action"; import {GraphConfigContext} from "./reducer/use-graph-config"; +import {useInteractiveElementsDescription} from "./use-interactive-elements-description"; import {isUnlimitedGraphState, REMOVE_BUTTON_ID} from "./utils"; import type {InteractiveGraphAction} from "./reducer/interactive-graph-action"; @@ -66,7 +67,6 @@ import type { InteractiveGraphElementSuite, GraphDimensions, } from "./types"; -import type {I18nContextType} from "../../components/i18n-context"; import type {PerseusStrings} from "../../strings"; import type {vec} from "mafs"; @@ -139,12 +139,11 @@ export const MafsGraph = (props: MafsGraphProps) => { }); }); - const {graph, interactiveElementsDescription} = renderGraphElements({ + const {graph} = renderGraphElements({state, dispatch}); + const interactiveElementsDescription = useInteractiveElementsDescription( state, - dispatch, - i18n, - markings: props.markings, - }); + props.markings, + ); const disableInteraction = readOnly || !!props.static; @@ -730,47 +729,42 @@ function handleKeyboardEvent( const renderGraphElements = (props: { state: InteractiveGraphState; dispatch: (action: InteractiveGraphAction) => unknown; - i18n: I18nContextType; - // Used to determine if the graph description should specify the - // coordinates of the graph elements. We don't want to mention the - // coordinates if the graph is not on a coordinate plane (no axes). - markings: InteractiveGraphProps["markings"]; }): InteractiveGraphElementSuite => { - const {state, dispatch, i18n, markings} = props; + const {state, dispatch} = props; const {type} = state; switch (type) { case "angle": - return renderAngleGraph(state, dispatch, i18n); + return renderAngleGraph(state, dispatch); case "segment": - return renderSegmentGraph(state, dispatch, i18n); + return renderSegmentGraph(state, dispatch); case "linear-system": - return renderLinearSystemGraph(state, dispatch, i18n); + return renderLinearSystemGraph(state, dispatch); case "linear": - return renderLinearGraph(state, dispatch, i18n); + return renderLinearGraph(state, dispatch); case "ray": - return renderRayGraph(state, dispatch, i18n); + return renderRayGraph(state, dispatch); case "polygon": - return renderPolygonGraph(state, dispatch, i18n, markings); + return renderPolygonGraph(state, dispatch); case "point": - return renderPointGraph(state, dispatch, i18n); + return renderPointGraph(state, dispatch); case "circle": - return renderCircleGraph(state, dispatch, i18n); + return renderCircleGraph(state, dispatch); case "quadratic": - return renderQuadraticGraph(state, dispatch, i18n); + return renderQuadraticGraph(state, dispatch); case "sinusoid": - return renderSinusoidGraph(state, dispatch, i18n); + return renderSinusoidGraph(state, dispatch); case "exponential": - return renderExponentialGraph(state, dispatch, i18n); + return renderExponentialGraph(state, dispatch); case "none": - return {graph: null, interactiveElementsDescription: null}; + return {graph: null}; case "absolute-value": - return renderAbsoluteValueGraph(state, dispatch, i18n); + return renderAbsoluteValueGraph(state, dispatch); case "tangent": - return renderTangentGraph(state, dispatch, i18n); + return renderTangentGraph(state, dispatch); case "logarithm": - return renderLogarithmGraph(state, dispatch, i18n); + return renderLogarithmGraph(state, dispatch); case "vector": - return renderVectorGraph(state, dispatch, i18n); + return renderVectorGraph(state, dispatch); default: throw new UnreachableCaseError(type); } diff --git a/packages/perseus/src/widgets/interactive-graphs/types.ts b/packages/perseus/src/widgets/interactive-graphs/types.ts index 901037816ae..3995ecf22e3 100644 --- a/packages/perseus/src/widgets/interactive-graphs/types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/types.ts @@ -25,7 +25,6 @@ export type MafsGraphProps = { // end up in different sections of the DOM. export type InteractiveGraphElementSuite = { graph: ReactNode; - interactiveElementsDescription: ReactNode; }; export type InteractiveGraphState = diff --git a/packages/perseus/src/widgets/interactive-graphs/use-interactive-elements-description.ts b/packages/perseus/src/widgets/interactive-graphs/use-interactive-elements-description.ts new file mode 100644 index 00000000000..0280e7701e8 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/use-interactive-elements-description.ts @@ -0,0 +1,82 @@ +import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; + +import {usePerseusI18n} from "../../components/i18n-context"; + +import {getAbsoluteValueDescription} from "./graphs/absolute-value"; +import {getAngleGraphDescription} from "./graphs/angle"; +import {getCircleGraphDescription} from "./graphs/circle"; +import {usePointAriaLabel} from "./graphs/components/build-point-aria-label"; +import {getExponentialDescription} from "./graphs/exponential"; +import {getLinearGraphDescription} from "./graphs/linear"; +import {getLinearSystemGraphDescription} from "./graphs/linear-system"; +import {getLogarithmDescription} from "./graphs/logarithm"; +import {getPointGraphDescription} from "./graphs/point"; +import {getPolygonGraphDescription} from "./graphs/polygon"; +import {getQuadraticGraphDescription} from "./graphs/quadratic"; +import {getRayGraphDescription} from "./graphs/ray"; +import {getSegmentGraphDescription} from "./graphs/segment"; +import {getSinusoidDescription} from "./graphs/sinusoid"; +import {getTangentDescription} from "./graphs/tangent"; +import {getVectorGraphDescription} from "./graphs/vector"; + +import type {InteractiveGraphProps, InteractiveGraphState} from "./types"; +import type {ReactNode} from "react"; + +/** + * Returns the screen-reader description string for the interactive elements + * of the given graph state. Keeps the "build the SR description" path in + * React-land so it can use the `usePointAriaLabel` hook directly. + */ +export function useInteractiveElementsDescription( + state: InteractiveGraphState, + markings: InteractiveGraphProps["markings"], +): ReactNode { + const i18n = usePerseusI18n(); + // Hook must be called unconditionally; states without `pointLabels` pass + // `undefined` and the hook returns a no-op builder. + const pointLabels = "pointLabels" in state ? state.pointLabels : undefined; + const buildLabel = usePointAriaLabel(pointLabels); + + const {type} = state; + switch (type) { + case "angle": + return getAngleGraphDescription(state, i18n); + case "segment": + return getSegmentGraphDescription(state, i18n); + case "linear-system": + return getLinearSystemGraphDescription(state, i18n); + case "linear": + return getLinearGraphDescription(state, i18n); + case "ray": + return getRayGraphDescription(state, i18n); + case "polygon": + return getPolygonGraphDescription( + state, + i18n, + markings, + buildLabel, + ); + case "point": + return getPointGraphDescription(state, i18n, buildLabel); + case "circle": + return getCircleGraphDescription(state, i18n); + case "quadratic": + return getQuadraticGraphDescription(state, i18n); + case "sinusoid": + return getSinusoidDescription(state, i18n); + case "exponential": + return getExponentialDescription(state, i18n); + case "none": + return null; + case "absolute-value": + return getAbsoluteValueDescription(state, i18n); + case "tangent": + return getTangentDescription(state, i18n); + case "logarithm": + return getLogarithmDescription(state, i18n); + case "vector": + return getVectorGraphDescription(state, i18n); + default: + throw new UnreachableCaseError(type); + } +} From e79c1fea318a5bdccf8c186aef8661a062d76d1c Mon Sep 17 00:00:00 2001 From: Ivy Olamit Date: Mon, 1 Jun 2026 16:55:02 -0700 Subject: [PATCH 2/2] [LEMS-3995/poc-option-b-use-point-aria-label] docs(changeset): --- .changeset/lovely-countries-brake.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/lovely-countries-brake.md diff --git a/.changeset/lovely-countries-brake.md b/.changeset/lovely-countries-brake.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/lovely-countries-brake.md @@ -0,0 +1,2 @@ +--- +---