diff --git a/.changeset/lazy-geckos-suffer.md b/.changeset/lazy-geckos-suffer.md new file mode 100644 index 0000000000..9b5853b83f --- /dev/null +++ b/.changeset/lazy-geckos-suffer.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +update moveable point component and use control point method to have optional params diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap b/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap index 4c129238f2..560d781cdf 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap @@ -16,7 +16,7 @@ exports[`Rendering Does NOT render extensions of line when option is disabled 1` > { expect(focusSpy).toHaveBeenCalledTimes(1); }); + + describe("accessibility", () => { + it("uses the default sequence number when ariaLabel and sequence number are not provided", () => { + render( + + + , + ); + + expect( + screen.getByLabelText("Point 1 at 0 comma 0"), + ).toBeInTheDocument(); + }); + + it("uses sequence number when sequence is provided and aria label is not provided", () => { + render( + + + , + ); + + expect( + screen.getByLabelText("Point 2 at 0 comma 0"), + ).toBeInTheDocument(); + }); + + it("uses the ariaLabel when both sequence and ariaLabel are provided", () => { + render( + + + , + ); + + expect( + screen.getByLabelText( + "Aria Label being used instead of sequence number", + ), + ).toBeInTheDocument(); + }); + + it("uses the ariaLabel when only ariaLabel is provided", () => { + render( + + + , + ); + + expect( + screen.getByLabelText("Custom aria label"), + ).toBeInTheDocument(); + }); + + it("uses the ariaDescribedBy when provided", () => { + render( + + +

Aria is described by me

+
, + ); + + const pointElement = screen.getByRole("button", { + name: "Point 1 at 0 comma 0", + }); + expect(pointElement).toHaveAttribute( + "aria-describedby", + "description", + ); + + const descriptionElement = screen.getByText( + "Aria is described by me", + ); + expect(descriptionElement).toBeInTheDocument(); + }); + + it("uses the ariaLive when provided", () => { + render( + + + , + ); + + expect( + screen.getByLabelText("Point 1 at 0 comma 0"), + ).toHaveAttribute("aria-live", "assertive"); + }); + + it("uses the default ariaLive when not provided", () => { + render( + + + , + ); + + expect( + screen.getByLabelText("Point 1 at 0 comma 0"), + ).toHaveAttribute("aria-live", "polite"); + }); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx index cebf76f435..b855da3e1c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx @@ -3,11 +3,18 @@ import * as React from "react"; import {useControlPoint} from "./use-control-point"; import type {CSSCursor} from "./css-cursor"; +import type {AriaLive} from "../../types"; import type {KeyboardMovementConstraint} from "../use-draggable"; import type {vec} from "mafs"; type Props = { point: vec.Vector2; + ariaDescribedBy?: string; + ariaLabel?: string; + ariaLive?: AriaLive; + color?: string; + constrain?: KeyboardMovementConstraint; + cursor?: CSSCursor | undefined; /** * Represents where this point stands in the overall point sequence. * This is used to provide screen readers with context about the point. @@ -16,14 +23,11 @@ type Props = { * Note: This number is 1-indexed, and should restart from 1 for each * interactive figure on the graph. */ - sequenceNumber: number; - onMove?: (newPoint: vec.Vector2) => unknown; + sequenceNumber?: number; + onBlur?: (event: React.FocusEvent) => unknown; onClick?: () => unknown; - color?: string; - cursor?: CSSCursor | undefined; - constrain?: KeyboardMovementConstraint; - onFocus?: ((event: React.FocusEvent) => unknown) | undefined; - onBlur?: ((event: React.FocusEvent) => unknown) | undefined; + onFocus?: (event: React.FocusEvent) => unknown; + onMove?: (newPoint: vec.Vector2) => unknown; }; export const MovablePoint = React.forwardRef( diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/use-control-point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/use-control-point.tsx index 6687b79543..0cc35e3f6b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/use-control-point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/use-control-point.tsx @@ -10,13 +10,20 @@ import {useDraggable} from "../use-draggable"; import {MovablePointView} from "./movable-point-view"; import type {CSSCursor} from "./css-cursor"; +import type {AriaLive} from "../../types"; import type {KeyboardMovementConstraint} from "../use-draggable"; import type {vec} from "mafs"; type Params = { point: vec.Vector2; + ariaDescribedBy?: string; + ariaLabel?: string; + ariaLive?: AriaLive; color?: string | undefined; + constrain?: KeyboardMovementConstraint; cursor?: CSSCursor | undefined; + // The focusableHandle element is assigned to the forwarded ref. + forwardedRef?: React.ForwardedRef | undefined; /** * Represents where this point stands in the overall point sequence. * This is used to provide screen readers with context about the point. @@ -25,14 +32,11 @@ type Params = { * Note: This number is 1-indexed, and should restart from 1 for each * interactive figure on the graph. */ - sequenceNumber: number; - constrain?: KeyboardMovementConstraint; + sequenceNumber?: number; onMove?: ((newPoint: vec.Vector2) => unknown) | undefined; onClick?: (() => unknown) | undefined; onFocus?: ((event: React.FocusEvent) => unknown) | undefined; onBlur?: ((event: React.FocusEvent) => unknown) | undefined; - // The focusableHandle element is assigned to the forwarded ref. - forwardedRef?: React.ForwardedRef | undefined; }; type Return = { @@ -46,15 +50,18 @@ export function useControlPoint(params: Params): Return { const {snapStep, disableKeyboardInteraction} = useGraphConfig(); const { point, - sequenceNumber, + ariaDescribedBy, + ariaLabel, + ariaLive = "polite", color, - cursor, constrain = (p) => snap(snapStep, p), + cursor, + forwardedRef = noop, + sequenceNumber = 1, onMove = noop, onClick = noop, onFocus = noop, onBlur = noop, - forwardedRef = noop, } = params; const {strings, locale} = usePerseusI18n(); @@ -76,6 +83,15 @@ export function useControlPoint(params: Params): Return { constrainKeyboardMovement: constrain, }); + // if custom aria label is not provided, will use default of sequence number and point coordinates + const pointAriaLabel = + ariaLabel || + strings.srPointAtCoordinates({ + num: sequenceNumber, + x: srFormatNumber(point[X], locale), + y: srFormatNumber(point[Y], locale), + }); + useLayoutEffect(() => { setForwardedRef(forwardedRef, focusableHandleRef.current); }, [forwardedRef]); @@ -87,14 +103,9 @@ export function useControlPoint(params: Params): Return { tabIndex={disableKeyboardInteraction ? -1 : 0} ref={focusableHandleRef} role="button" - aria-label={strings.srPointAtCoordinates({ - num: sequenceNumber, - x: srFormatNumber(point[X], locale), - y: srFormatNumber(point[Y], locale), - })} - // aria-live="assertive" causes the new location of the point to be - // announced immediately on move. - aria-live="assertive" + aria-describedby={ariaDescribedBy} + aria-label={pointAriaLabel} + aria-live={ariaLive} onFocus={(event) => { onFocus(event); setFocused(true); diff --git a/packages/perseus/src/widgets/interactive-graphs/types.ts b/packages/perseus/src/widgets/interactive-graphs/types.ts index 565643ca81..140cea9f02 100644 --- a/packages/perseus/src/widgets/interactive-graphs/types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/types.ts @@ -135,3 +135,5 @@ export type GraphDimensions = { width: number; // pixels height: number; // pixels }; + +export type AriaLive = "off" | "assertive" | "polite" | undefined;