diff --git a/.changeset/lovely-countries-brake.md b/.changeset/lovely-countries-brake.md
new file mode 100644
index 0000000000..a845151cc8
--- /dev/null
+++ b/.changeset/lovely-countries-brake.md
@@ -0,0 +1,2 @@
+---
+---
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 eadcad8da4..cc43bb3bdd 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 4de7482eaa..1530f2feae 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 ad4727af7d..2865a7eb4f 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 b9f00c5621..fa6b77ccc9 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 2fac281236..93ad085937 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 8651085e5c..69ab286eab 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 cff80fcbbe..413f7dfcf4 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 3e2467d971..321c30db04 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 52863cb654..3933fae856 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 a35d3506e6..026b37951d 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 1b72fa103c..468a3940d1 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 bd5cbb5d04..bf647f3a00 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 4efb982b09..84951ce4c6 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 ed9a0d4c55..9beb82f1c5 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 982fc9761a..e34de950fb 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 d308f6fefd..7ba36974b6 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 f66ca00d83..e3a29ad394 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 4b28650747..4eec79cdce 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 273e511dbc..92721cf120 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 901037816a..3995ecf22e 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 0000000000..0280e7701e
--- /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);
+ }
+}