From 595ffd2a88b160f2750819a753fd0936ec2fc112 Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Thu, 30 Jan 2025 01:08:58 -0800 Subject: [PATCH] [sr-polygon] [SR] Polygon - Add screen reader experience --- packages/perseus/src/strings.ts | 35 +- .../graphs/components/angle-indicators.tsx | 1 + .../interactive-graphs/graphs/polygon.tsx | 360 +++++++++++++++--- .../graphs/screenreader-text.ts | 8 +- .../interactive-graphs/graphs/utils.ts | 75 +++- .../widgets/interactive-graphs/mafs-graph.tsx | 9 +- 6 files changed, 421 insertions(+), 67 deletions(-) diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index a7fda5025a..b7aabf0fa3 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -424,6 +424,19 @@ export type PerseusStrings = { point3X: string; point3Y: string; }) => string; + srPolygonGraph: string; + srPolygonGraphCoordinatePlane: string; + srPolygonGraphPointsNum: ({num}: {num: number}) => string; + srPolygonElementsNum: ({num}: {num: number}) => string; + srPolygonPointAngleApprox: ({angle}: {angle: string}) => string; + srPolygonPointAngle: ({angle}: {angle: number}) => string; + srPolygonSideLength: ({ + pointNum, + length, + }: { + pointNum: number; + length: string; + }) => string; // The above strings are used for interactive graph SR descriptions. }; @@ -607,7 +620,7 @@ export const strings = { // translation tickets after all interactive graph SR strings have // been finalized. Remove this comment after the tickets have been // created. - srPointAtCoordinates: "Point %(num)s at %(x)s comma %(y)s", + srPointAtCoordinates: "Point %(num)s at %(x)s comma %(y)s.", srCircleGraph: "A circle on a coordinate plane.", srCircleShape: "Circle. The center point is at %(centerX)s comma %(centerY)s.", @@ -683,6 +696,15 @@ export const strings = { "Point %(pointNumber)s on parabola in quadrant %(quadrant)s at %(x)s comma %(y)s.", srQuadraticInteractiveElements: "Parabola with points at %(point1X)s comma %(point1Y)s, %(point2X)s comma %(point2Y)s, and %(point3X)s comma %(point3Y)s.", + srPolygonGraph: "A polygon.", + srPolygonGraphCoordinatePlane: "A polygon on a coordinate plane.", + srPolygonGraphPointsNum: "The polygon has %(num)s points.", + srPolygonElementsNum: "A polygon with %(num)s points.", + srPolygonPointAngleApprox: + "Angle approximately equal to %(angle)s degrees.", + srPolygonPointAngle: "Angle equal to %(angle)s degrees.", + srPolygonSideLength: + "Connected to point %(pointNum)s at a distance of %(length)s.", // The above strings are used for interactive graph SR descriptions. } satisfies { [key in keyof PerseusStrings]: @@ -853,7 +875,7 @@ export const mockStrings: PerseusStrings = { removePoint: "Remove Point", closePolygon: "Close shape", openPolygon: "Re-open shape", - srPointAtCoordinates: ({num, x, y}) => `Point ${num} at ${x} comma ${y}`, + srPointAtCoordinates: ({num, x, y}) => `Point ${num} at ${x} comma ${y}.`, srInteractiveElements: ({elements}) => `Interactive elements: ${elements}`, srNoInteractiveElements: "No interactive elements", srCircleGraph: "A circle on a coordinate plane.", @@ -971,6 +993,15 @@ export const mockStrings: PerseusStrings = { point3Y, }) => `Parabola with points at ${point1X} comma ${point1Y}, ${point2X} comma ${point2Y}, and ${point3X} comma ${point3Y}.`, + srPolygonGraph: "A polygon.", + srPolygonGraphCoordinatePlane: "A polygon on a coordinate plane.", + srPolygonGraphPointsNum: ({num}) => `The polygon has ${num} points.`, + srPolygonElementsNum: ({num}) => `A polygon with ${num} points.`, + srPolygonPointAngleApprox: ({angle}) => + `Angle approximately equal to ${angle} degrees.`, + srPolygonPointAngle: ({angle}) => `Angle equal to ${angle} degrees.`, + srPolygonSideLength: ({pointNum, length}) => + `Connected to point ${pointNum} at a distance of ${length}.`, // The above strings are used for interactive graph SR descriptions. }; diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx index 6df6d4f865..1a0d9e16c5 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx @@ -32,6 +32,7 @@ export const PolygonAngle = ({ showAngles, snapTo, }: PolygonAngleProps) => { + // TODO: USE THE UTIL FUCNTION HERE!~!!!!!!!!!! const [centerX, centerY] = centerPoint; const areClockwise = clockwise([centerPoint, ...endPoints]); const [[startX, startY], [endX, endY]] = areClockwise diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index f4e26f7215..625684199e 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -1,6 +1,11 @@ import {Polygon, Polyline, vec} from "mafs"; import * as React from "react"; +import { + usePerseusI18n, + type I18nContextType, +} from "../../../components/i18n-context"; +import a11y from "../../../util/a11y"; import {snap} from "../math"; import {actions} from "../reducer/interactive-graph-action"; import useGraphConfig from "../reducer/use-graph-config"; @@ -9,15 +14,22 @@ import {TARGET_SIZE} from "../utils"; import {PolygonAngle} from "./components/angle-indicators"; import {MovablePoint} from "./components/movable-point"; import {TextLabel} from "./components/text-label"; +import {srFormatNumber} from "./screenreader-text"; import {useDraggable} from "./use-draggable"; import {pixelsToVectors, useTransformVectorsToPixels} from "./use-transform"; -import {getArrayWithoutDuplicates} from "./utils"; +import { + getAngleFromPoints, + getArrayWithoutDuplicates, + getSideLengthsFromPoints, + radianToDegree, +} from "./utils"; import type {Coord} from "../../../interactive2/types"; import type {GraphConfig} from "../reducer/use-graph-config"; import type { Dispatch, InteractiveGraphElementSuite, + InteractiveGraphProps, MafsGraphProps, PolygonGraphState, } from "../types"; @@ -26,10 +38,16 @@ import type {CollinearTuple} from "@khanacademy/perseus-core"; export function renderPolygonGraph( state: PolygonGraphState, dispatch: Dispatch, + i18n: I18nContextType, + markings: InteractiveGraphProps["markings"], ): InteractiveGraphElementSuite { return { graph: , - interactiveElementsDescription: null, + interactiveElementsDescription: getPolygonGraphDescription( + state, + i18n, + markings, + ), }; } @@ -154,11 +172,29 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => { snapTo = "grid", } = statefulProps.graphState; const {disableKeyboardInteraction} = graphConfig; + const {strings, locale} = usePerseusI18n(); + const uniqueId = React.useId(); const lines = getLines(points); + const id = React.useId(); + const polygonPointsNumId = id + "-points-num"; + const polygonPointsId = id + "-points"; + + // Aria label srings + const {srPolygonGraph, srPolygonGraphPointsNum, srPolygonGraphPoints} = + describePolygonGraph( + statefulProps.graphState, + {strings, locale}, + statefulProps.graphConfig.markings, + ); + return ( - <> + { })} {showSides && lines.map(([start, end], i) => { + // Use x and y to find the position of the label const [x, y] = vec.midpoint(start, end); - const length = parseFloat( - vec - .dist(start, end) - .toFixed(snapTo === "sides" ? 0 : 1), - ); + const length = vec.dist(start, end); + // Check if the length needs to indicate + // that it's an approximation. + const isApprox = !Number.isInteger(length); return ( - {!Number.isInteger(length) && "≈ "} - {length} + {isApprox + ? `≈ ${length.toFixed(snapTo === "sides" ? 0 : 1)}` + : length} ); })} @@ -234,31 +271,100 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => { className: "movable-polygon", }} /> - {points.map((point, i) => ( - { - const now = Date.now(); - const targetFPS = 40; - const moveThresholdTime = 1000 / targetFPS; - - if (now - lastMoveTimeRef.current > moveThresholdTime) { - dispatch(actions.polygon.movePoint(i, destination)); - lastMoveTimeRef.current = now; - } - }} - /> - ))} - + {points.map((point, i) => { + const angleId = `${uniqueId}-angle-${i}`; + let sideIds = ""; + + const angle = getAngleFromPoints(points, i); + const angleDegree = angle ? radianToDegree(angle) : null; + + const sidesArray = getSideLengthsFromPoints(points, i); + for ( + let sideIndex = 0; + sideIndex < sidesArray.length; + sideIndex++ + ) { + sideIds += `${uniqueId}-point-${i}-side-${sideIndex} `; + } + + return ( + + { + const now = Date.now(); + const targetFPS = 40; + const moveThresholdTime = 1000 / targetFPS; + + if ( + now - lastMoveTimeRef.current > + moveThresholdTime + ) { + dispatch( + actions.polygon.movePoint( + i, + destination, + ), + ); + lastMoveTimeRef.current = now; + } + }} + /> + {angleDegree && ( + + {Number.isInteger(angleDegree) + ? strings.srPolygonPointAngle({ + angle: angleDegree, + }) + : strings.srPolygonPointAngleApprox({ + angle: srFormatNumber( + angleDegree, + locale, + 1, + ), + })} + + )} + {sidesArray.map(({pointIndex, sideLength}, j) => ( + + {strings.srPolygonSideLength({ + pointNum: pointIndex + 1, + length: srFormatNumber(sideLength, locale), + })} + + ))} + + ); + })} + {/* Hidden elements to provide the descriptions for the + `aria-describedby` properties */} + + {srPolygonGraphPointsNum} + + {srPolygonGraphPoints && ( + + {srPolygonGraphPoints} + + )} + ); }; const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { const {dispatch, graphConfig, left, top, pointsRef, points} = statefulProps; const {coords, closedPolygon} = statefulProps.graphState; + const {strings, locale} = usePerseusI18n(); + const uniqueId = React.useId(); + + const id = React.useId(); + const polygonPointsNumId = id + "-points-num"; + const polygonPointsId = id + "-points"; // If the polygon is closed, return a LimitedPolygon component. if (closedPolygon) { @@ -271,8 +377,20 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { const widthPx = graphDimensionsInPixels[0]; const heightPx = graphDimensionsInPixels[1]; + // Aria label srings + const {srPolygonGraph, srPolygonGraphPointsNum, srPolygonGraphPoints} = + describePolygonGraph( + statefulProps.graphState, + {strings, locale}, + statefulProps.graphConfig.markings, + ); + return ( - <> + { dispatch(actions.polygon.addPoint(graphCoordinates[0])); }} /> - {coords.map((point, i) => ( - - dispatch(actions.polygon.movePoint(i, destination)) - } - ref={(ref) => { - pointsRef.current[i] = ref; - }} - onFocus={() => { - dispatch(actions.polygon.focusPoint(i)); - }} - onClick={() => { - // If the point being clicked is the first point and - // there's enough non-duplicated points to form - // a polygon (3 or more), close the shape before - // setting focus. - if ( - i === 0 && - getArrayWithoutDuplicates(coords).length >= 3 - ) { - dispatch(actions.polygon.closePolygon()); - } - dispatch(actions.polygon.clickPoint(i)); - }} - /> - ))} - + {coords.map((point, i) => { + const angleId = `${uniqueId}-angle-${i}`; + let sideIds = ""; + + const angle = getAngleFromPoints(points, i); + const angleDegree = angle ? radianToDegree(angle) : null; + + const sidesArray = getSideLengthsFromPoints(points, i); + for ( + let sideIndex = 0; + sideIndex < sidesArray.length; + sideIndex++ + ) { + sideIds += `${uniqueId}-point-${i}-side-${sideIndex} `; + } + + return ( + + + dispatch( + actions.polygon.movePoint(i, destination), + ) + } + ref={(ref) => { + pointsRef.current[i] = ref; + }} + onFocus={() => { + dispatch(actions.polygon.focusPoint(i)); + }} + onClick={() => { + // If the point being clicked is the first point and + // there's enough non-duplicated points to form + // a polygon (3 or more), close the shape before + // setting focus. + if ( + i === 0 && + getArrayWithoutDuplicates(coords).length >= + 3 + ) { + dispatch(actions.polygon.closePolygon()); + } + dispatch(actions.polygon.clickPoint(i)); + }} + /> + {angleDegree && ( + + {Number.isInteger(angleDegree) + ? strings.srPolygonPointAngle({ + angle: angleDegree, + }) + : strings.srPolygonPointAngleApprox({ + angle: srFormatNumber( + angleDegree, + locale, + 1, + ), + })} + + )} + {sidesArray.map(({pointIndex, sideLength}, j) => ( + + {strings.srPolygonSideLength({ + pointNum: pointIndex + 1, + length: srFormatNumber(sideLength, locale), + })} + + ))} + + ); + })} + {/* Hidden elements to provide the descriptions for the + `aria-describedby` properties */} + + {srPolygonGraphPointsNum} + + {srPolygonGraphPoints && ( + + {srPolygonGraphPoints} + + )} + ); }; @@ -360,3 +536,67 @@ export const hasFocusVisible = ( return matches(":focus"); } }; + +function getPolygonGraphDescription( + state: PolygonGraphState, + i18n: I18nContextType, + markings: InteractiveGraphProps["markings"], +): string { + const strings = describePolygonGraph(state, i18n, markings); + return strings.srPolygonInteractiveElements; +} + +type PolygonGraphDescriptionStrings = { + srPolygonGraph: string; + srPolygonGraphPointsNum: string; + srPolygonGraphPoints?: string; + srPolygonInteractiveElements: string; +}; + +// Exported for testing +export function describePolygonGraph( + state: PolygonGraphState, + i18n: I18nContextType, + markings: InteractiveGraphProps["markings"], +): PolygonGraphDescriptionStrings { + const {strings, locale} = i18n; + const {coords} = state; + const isCoordinatePlane = markings === "axes" || markings === "graph"; + + // Figure out graph aria label based on markings. + const srPolygonGraph = isCoordinatePlane + ? strings.srPolygonGraphCoordinatePlane + : strings.srPolygonGraph; + + // Figure out graph description based on markings. + // If the graph is not on a coordinate plane, we should not include + // the points' coordinates in the description. + const srPolygonGraphPointsNum = strings.srPolygonGraphPointsNum({ + num: coords.length, + }); + let srPolygonGraphPoints; + if (isCoordinatePlane) { + const pointsString = coords.map((coord, i) => { + return strings.srPointAtCoordinates({ + num: i + 1, + x: srFormatNumber(coord[0], locale), + y: srFormatNumber(coord[1], locale), + }); + }); + srPolygonGraphPoints = pointsString.join(" "); + } + + const srPolygonInteractiveElements = strings.srInteractiveElements({ + elements: [ + strings.srPolygonElementsNum({num: coords.length}), + srPolygonGraphPoints, + ].join(" "), + }); + + return { + srPolygonGraph, + srPolygonGraphPointsNum, + srPolygonGraphPoints, + srPolygonInteractiveElements, + }; +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/screenreader-text.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/screenreader-text.ts index 3c1a3f371d..743bdb7d0a 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/screenreader-text.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/screenreader-text.ts @@ -1,7 +1,11 @@ -export function srFormatNumber(a: number, locale: string): string { +export function srFormatNumber( + a: number, + locale: string, + maximumFractionDigits?: number, +): string { // adding zero here converts negative zero to positive zero. return (0 + a).toLocaleString(locale, { - maximumFractionDigits: 3, + maximumFractionDigits: maximumFractionDigits ?? 3, useGrouping: false, // no thousands separators }); } diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts index bfcedbbe3d..b1b9366ffe 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts @@ -1,9 +1,11 @@ +import {vec} from "mafs"; + 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"; +import type {Interval} from "mafs"; /** * Given a ray and a rectangular box, find the point where the ray intersects @@ -237,3 +239,74 @@ export function getQuadraticXIntercepts( return [x1, x2]; } + +export function getAngleFromPoints(points: Coord[], i: number) { + if (points.length < 3) { + return null; + } + + const point = points.at(i); + const pt1 = points.at(i - 1); + const pt2 = points[(i + 1) % points.length]; + if (!point || !pt1 || !pt2) { + return null; + } + + const a = vec.dist(point, pt1); + const b = vec.dist(point, pt2); + const c = vec.dist(pt1, pt2); + + // Law of cosines + const angle = Math.acos((a ** 2 + b ** 2 - c ** 2) / (2 * a * b)); + + return angle; +} + +export function radianToDegree(radians: number) { + const degree = (radians / Math.PI) * 180; + // Account for floating point errors. + return Number(degree.toPrecision(15)); +} + +export function getSideLengthsFromPoints( + points: Coord[], + i: number, +): Array<{ + pointIndex: number; + sideLength: number; +}> { + if (points.length < 2) { + return []; + } + + const returnArray: Array<{ + pointIndex: number; + sideLength: number; + }> = []; + const point = points[i]; + // If this point is the first point, then side 1 is the + // last point in the list. + const point1Index = i === 0 ? points.length - 1 : i - 1; + const point1 = points[point1Index]; + // Make sure the previous point is not the same + // as the current point. + const side1 = i !== point1Index ? vec.dist(point, point1) : null; + if (side1) { + returnArray.push({pointIndex: point1Index, sideLength: side1}); + } + + // See if there is a side 2. + const point2Index = (i + 1) % points.length; + const point2 = points[point2Index]; + // Make sure that the next point is not the same as the + // current point, and don't repeat the first point. + const side2 = + i !== point2Index && point2Index !== point1Index + ? vec.dist(point, point2) + : null; + if (side2) { + returnArray.push({pointIndex: point2Index, sideLength: side2}); + } + + return returnArray; +} diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx index 6afa9587a9..b3b0f0a561 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx @@ -138,6 +138,7 @@ export const MafsGraph = (props: MafsGraphProps) => { state, dispatch, i18n, + markings: props.markings, }); return ( @@ -680,8 +681,12 @@ 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} = props; + const {state, dispatch, i18n, markings} = props; const {type} = state; switch (type) { case "angle": @@ -695,7 +700,7 @@ const renderGraphElements = (props: { case "ray": return renderRayGraph(state, dispatch); case "polygon": - return renderPolygonGraph(state, dispatch); + return renderPolygonGraph(state, dispatch, i18n, markings); case "point": return renderPointGraph(state, dispatch); case "circle":