Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/lovely-countries-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,9 @@ import type {Coord} from "@khanacademy/perseus-core";
export function renderAbsoluteValueGraph(
state: AbsoluteValueGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <AbsoluteValueGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getAbsoluteValueDescription(
state,
i18n,
),
};
}

Expand Down Expand Up @@ -173,7 +168,7 @@ export const getAbsoluteValueKeyboardConstraint = (
};
};

function getAbsoluteValueDescription(
export function getAbsoluteValueDescription(
state: AbsoluteValueGraphState,
i18n: I18nContextType,
): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@ type AngleGraphProps = MafsGraphProps<AngleGraphState>;
export function renderAngleGraph(
state: AngleGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <AngleGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getAngleGraphDescription(state, i18n),
};
}

Expand Down Expand Up @@ -167,7 +165,7 @@ function AngleGraph(props: AngleGraphProps) {
);
}

function getAngleGraphDescription(
export function getAngleGraphDescription(
state: AngleGraphState,
i18n: I18nContextType,
): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,9 @@ import type {
export function renderCircleGraph(
state: CircleGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <CircleGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getCircleGraphDescription(state, i18n),
};
}

Expand Down Expand Up @@ -256,7 +254,7 @@ function crossProduct<A, B>(as: A[], bs: B[]): [A, B][] {
return result;
}

function getCircleGraphDescription(
export function getCircleGraphDescription(
state: CircleGraphState,
i18n: I18nContextType,
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -36,35 +34,33 @@ describe("resolvePointLabel", () => {
});
});

describe("buildPointAriaLabel", () => {
describe("usePointAriaLabel", () => {
const renderBuildLabel = (pointLabels?: ReadonlyArray<string>) =>
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", () => {
Expand All @@ -74,14 +70,9 @@ describe("buildPointAriaLabel", () => {
undefined,
42,
] as unknown as ReadonlyArray<string>;
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();
});
});
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string> | 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<string> | 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),
});
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,9 @@ const {getExponentialCoefficients} = kmathCoefficients;
export function renderExponentialGraph(
state: ExponentialGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <ExponentialGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getExponentialDescription(state, i18n),
};
}

Expand Down Expand Up @@ -209,7 +207,7 @@ const computeExponential = function (
return a * Math.exp(b * x) + c;
};

function getExponentialDescription(
export function getExponentialDescription(
state: ExponentialGraphState,
i18n: I18nContextType,
): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,9 @@ import type {vec} from "mafs";
export function renderLinearSystemGraph(
state: LinearSystemGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <LinearSystemGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getLinearSystemGraphDescription(
state,
i18n,
),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ import type {vec} from "mafs";
export function renderLinearGraph(
state: LinearGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <LinearGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getLinearGraphDescription(state, i18n),
};
}

Expand Down Expand Up @@ -98,7 +96,7 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
);
};

function getLinearGraphDescription(
export function getLinearGraphDescription(
state: LinearGraphState,
i18n: I18nContextType,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,9 @@ const {getLogarithmCoefficients} = kmathCoefficients;
export function renderLogarithmGraph(
state: LogarithmGraphState,
dispatch: Dispatch,
i18n: I18nContextType,
): InteractiveGraphElementSuite {
return {
graph: <LogarithmGraph graphState={state} dispatch={dispatch} />,
interactiveElementsDescription: getLogarithmDescription(state, i18n),
};
}

Expand Down Expand Up @@ -266,7 +264,7 @@ function renderLogarithmCurve({
);
}

function getLogarithmDescription(
export function getLogarithmDescription(
state: LogarithmGraphState,
i18n: I18nContextType,
): string {
Expand Down
Loading
Loading