Skip to content

Commit 7be7a42

Browse files
authored
[SR] Update graph elements so their aria-describedby is read in non-Chrome browsers (#2309)
## Summary: Descriptions are not working in Safari or Firefox. After a number of Slack discussions, it was determined that this was caused by the description blocks being defined within SVGs. With a small amount of refactoring, we can make is so that the screen reader reads as expected across browsers. Note: This does not include any changes for the outer graph descriptions, just the interactive elements and their surrounding container inside the graph SVG. Issue: https://khanacademy.atlassian.net/browse/LEMS-2914 ## Test plan: `yarn jest` Storybook - http://localhost:6006/?path=/story/perseuseditor-widgets-interactive-graph--interactive-graph-segment - Go through every graph type in Storybook - Confirm that the screen reader experience matches what's in the SR tree Author: nishasy Reviewers: mark-fitzgerald, catandthemachines Required Reviewers: Approved By: mark-fitzgerald Checks: ✅ 8 checks were successful Pull Request URL: #2309
1 parent b87f7a1 commit 7be7a42

File tree

12 files changed

+437
-285
lines changed

12 files changed

+437
-285
lines changed

.changeset/early-dodos-wink.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": patch
3+
---
4+
5+
[SR] Update graph elements so their aria-describedby is read in non-Chrome browsers

packages/perseus/src/widgets/interactive-graphs/__snapshots__/interactive-graph.test.tsx.snap

+320-200
Large diffs are not rendered by default.

packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {vec} from "mafs";
33
import * as React from "react";
44

55
import {usePerseusI18n} from "../../../components/i18n-context";
6-
import a11y from "../../../util/a11y";
76
import {X, Y} from "../math";
87
import {findIntersectionOfRays} from "../math/geometry";
98
import {actions} from "../reducer/interactive-graph-action";
@@ -12,6 +11,7 @@ import useGraphConfig from "../reducer/use-graph-config";
1211
import {Angle} from "./components/angle-indicators";
1312
import {trimRange} from "./components/movable-line";
1413
import {MovablePoint} from "./components/movable-point";
14+
import SRDescInSVG from "./components/sr-description-within-svg";
1515
import {SVGLine} from "./components/svg-line";
1616
import {Vector} from "./components/vector";
1717
import {srFormatNumber} from "./screenreader-text";
@@ -159,9 +159,9 @@ function AngleGraph(props: AngleGraphProps) {
159159
}
160160
ariaLabel={srAngleStartingSide}
161161
/>
162-
<g id={descriptionId} style={a11y.srOnly}>
162+
<SRDescInSVG id={descriptionId}>
163163
{srAngleGraphAriaDescription}
164-
</g>
164+
</SRDescInSVG>
165165
</g>
166166
);
167167
}

packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx

+3-7
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import * as React from "react";
33
import {useRef} from "react";
44

55
import {usePerseusI18n} from "../../../components/i18n-context";
6-
import a11y from "../../../util/a11y";
76
import {snap, X, Y} from "../math";
87
import {actions} from "../reducer/interactive-graph-action";
98
import {getRadius} from "../reducer/interactive-graph-state";
109
import useGraphConfig from "../reducer/use-graph-config";
1110

1211
import Hairlines from "./components/hairlines";
1312
import {MovablePoint} from "./components/movable-point";
13+
import SRDescInSVG from "./components/sr-description-within-svg";
1414
import {srFormatNumber} from "./screenreader-text";
1515
import {useDraggable} from "./use-draggable";
1616
import {
@@ -110,12 +110,8 @@ export function CircleGraph(props: CircleGraphProps) {
110110
/>
111111
{/* Hidden elements to provide the descriptions for the
112112
circle and radius point's `aria-describedby` properties. */}
113-
<g id={radiusId} style={a11y.srOnly}>
114-
{srCircleRadius}
115-
</g>
116-
<g id={outerPointsId} style={a11y.srOnly}>
117-
{srCircleOuterPoints}
118-
</g>
113+
<SRDescInSVG id={radiusId}>{srCircleRadius}</SRDescInSVG>
114+
<SRDescInSVG id={outerPointsId}>{srCircleOuterPoints}</SRDescInSVG>
119115
</g>
120116
);
121117
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as React from "react";
2+
3+
import a11y from "../../../../util/a11y";
4+
5+
type Props = {
6+
id: string;
7+
children: React.ReactNode | string;
8+
};
9+
10+
/**
11+
* If an element has an `aria-describedby` attribute, it needs to point to
12+
* a separate description element that contains the description for that
13+
* element.
14+
*
15+
* If the element is within an SVG, the description element must be a
16+
* `<span>` within a `<foreignObject>` in order for some browser/SR
17+
* combos to be able to access it.
18+
*
19+
* Use this SRDescInSVG component to create the <foreignObject>
20+
* and styling for this description element.
21+
*/
22+
function SRDescInSVG(props: Props) {
23+
const {id, children} = props;
24+
25+
return (
26+
<foreignObject>
27+
<span
28+
id={id}
29+
// Hidden visually
30+
style={a11y.srOnly}
31+
// Hidden from screen readers so they don't read this
32+
// again outside of the element that it describes.
33+
aria-hidden={true}
34+
>
35+
{children}
36+
</span>
37+
</foreignObject>
38+
);
39+
}
40+
41+
export default SRDescInSVG;

packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx

+22-31
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import {geometry} from "@khanacademy/kmath";
22
import * as React from "react";
33

44
import {usePerseusI18n} from "../../../components/i18n-context";
5-
import a11y from "../../../util/a11y";
65
import {actions} from "../reducer/interactive-graph-action";
76

87
import {MovableLine} from "./components/movable-line";
8+
import SRDescInSVG from "./components/sr-description-within-svg";
99
import {srFormatNumber} from "./screenreader-text";
1010
import {getInterceptStringForLine, getSlopeStringForLine} from "./utils";
1111

@@ -134,42 +134,33 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
134134
/>
135135
))}
136136
{linesAriaInfo.map(
137-
({
138-
pointsDescriptionId,
139-
interceptDescriptionId,
140-
slopeDescriptionId,
141-
pointsDescription,
142-
interceptDescription,
143-
slopeDescription,
144-
}) => (
145-
<>
146-
<g
147-
key={pointsDescriptionId}
148-
id={pointsDescriptionId}
149-
style={a11y.srOnly}
150-
>
137+
(
138+
{
139+
pointsDescriptionId,
140+
interceptDescriptionId,
141+
slopeDescriptionId,
142+
pointsDescription,
143+
interceptDescription,
144+
slopeDescription,
145+
},
146+
i,
147+
) => (
148+
<span key={`line-descriptions-${i}`}>
149+
<SRDescInSVG id={pointsDescriptionId}>
151150
{pointsDescription}
152-
</g>
153-
<g
154-
key={interceptDescriptionId}
155-
id={interceptDescriptionId}
156-
style={a11y.srOnly}
157-
>
151+
</SRDescInSVG>
152+
<SRDescInSVG id={interceptDescriptionId}>
158153
{interceptDescription}
159-
</g>
160-
<g
161-
key={slopeDescriptionId}
162-
id={slopeDescriptionId}
163-
style={a11y.srOnly}
164-
>
154+
</SRDescInSVG>
155+
<SRDescInSVG id={slopeDescriptionId}>
165156
{slopeDescription}
166-
</g>
167-
</>
157+
</SRDescInSVG>
158+
</span>
168159
),
169160
)}
170-
<g id={intersectionId} style={a11y.srOnly}>
161+
<SRDescInSVG id={intersectionId}>
171162
{intersectionDescription}
172-
</g>
163+
</SRDescInSVG>
173164
</g>
174165
);
175166
};

packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx

+6-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import * as React from "react";
22

33
import {usePerseusI18n} from "../../../components/i18n-context";
4-
import a11y from "../../../util/a11y";
54
import {actions} from "../reducer/interactive-graph-action";
65

76
import {MovableLine} from "./components/movable-line";
7+
import SRDescInSVG from "./components/sr-description-within-svg";
88
import {srFormatNumber} from "./screenreader-text";
99
import {getInterceptStringForLine, getSlopeStringForLine} from "./utils";
1010

@@ -81,15 +81,13 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
8181
/>
8282
{/* Hidden elements to provide the descriptions for the
8383
circle and radius point's `aria-describedby` properties. */}
84-
<g id={pointsDescriptionId} style={a11y.srOnly}>
84+
<SRDescInSVG id={pointsDescriptionId}>
8585
{srLinearGraphPoints}
86-
</g>
87-
<g id={interceptDescriptionId} style={a11y.srOnly}>
86+
</SRDescInSVG>
87+
<SRDescInSVG id={interceptDescriptionId}>
8888
{interceptString}
89-
</g>
90-
<g id={slopeDescriptionId} style={a11y.srOnly}>
91-
{slopeString}
92-
</g>
89+
</SRDescInSVG>
90+
<SRDescInSVG id={slopeDescriptionId}>{slopeString}</SRDescInSVG>
9391
</g>
9492
);
9593
};

packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx

+19-19
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
usePerseusI18n,
88
type I18nContextType,
99
} from "../../../components/i18n-context";
10-
import a11y from "../../../util/a11y";
1110
import {snap} from "../math";
1211
import {isInBound} from "../math/box";
1312
import {actions} from "../reducer/interactive-graph-action";
@@ -20,6 +19,7 @@ import {bound, TARGET_SIZE} from "../utils";
2019

2120
import {PolygonAngle} from "./components/angle-indicators";
2221
import {MovablePoint} from "./components/movable-point";
22+
import SRDescInSVG from "./components/sr-description-within-svg";
2323
import {TextLabel} from "./components/text-label";
2424
import {srFormatNumber} from "./screenreader-text";
2525
import {useDraggable} from "./use-draggable";
@@ -372,7 +372,7 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => {
372372
}}
373373
/>
374374
{angleDegree && (
375-
<g id={angleId}>
375+
<SRDescInSVG id={angleId}>
376376
{Number.isInteger(angleDegree)
377377
? strings.srPolygonPointAngle({
378378
angle: angleDegree,
@@ -384,36 +384,36 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => {
384384
1,
385385
),
386386
})}
387-
</g>
387+
</SRDescInSVG>
388388
)}
389-
<g id={side1Id}>
389+
<SRDescInSVG id={side1Id}>
390390
{getPolygonSideString(
391391
side1Length,
392392
point1Index,
393393
strings,
394394
locale,
395395
)}
396-
</g>
397-
<g id={side2Id}>
396+
</SRDescInSVG>
397+
<SRDescInSVG id={side2Id}>
398398
{getPolygonSideString(
399399
side2Length,
400400
point2Index,
401401
strings,
402402
locale,
403403
)}
404-
</g>
404+
</SRDescInSVG>
405405
</g>
406406
);
407407
})}
408408
{/* Hidden elements to provide the descriptions for the
409409
`aria-describedby` properties */}
410-
<g id={polygonPointsNumId} style={a11y.srOnly}>
410+
<SRDescInSVG id={polygonPointsNumId}>
411411
{srPolygonGraphPointsNum}
412-
</g>
412+
</SRDescInSVG>
413413
{srPolygonGraphPoints && (
414-
<g id={polygonPointsId} style={a11y.srOnly}>
414+
<SRDescInSVG id={polygonPointsId}>
415415
{srPolygonGraphPoints}
416-
</g>
416+
</SRDescInSVG>
417417
)}
418418
</g>
419419
);
@@ -566,7 +566,7 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => {
566566
}}
567567
/>
568568
{angleDegree && (
569-
<g id={angleId}>
569+
<SRDescInSVG id={angleId}>
570570
{Number.isInteger(angleDegree)
571571
? strings.srPolygonPointAngle({
572572
angle: angleDegree,
@@ -578,10 +578,10 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => {
578578
1,
579579
),
580580
})}
581-
</g>
581+
</SRDescInSVG>
582582
)}
583583
{sidesArray.map(({pointIndex, sideLength}, j) => (
584-
<g
584+
<SRDescInSVG
585585
key={`${id}-point-${i}-side-${j}`}
586586
id={`${id}-point-${i}-side-${j}`}
587587
>
@@ -591,22 +591,22 @@ const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => {
591591
strings,
592592
locale,
593593
)}
594-
</g>
594+
</SRDescInSVG>
595595
))}
596596
</g>
597597
);
598598
})}
599599
{/* Hidden elements to provide the descriptions for the
600600
`aria-describedby` properties */}
601601
{coords.length > 0 && (
602-
<g id={polygonPointsNumId} style={a11y.srOnly}>
602+
<SRDescInSVG id={polygonPointsNumId}>
603603
{srPolygonGraphPointsNum}
604-
</g>
604+
</SRDescInSVG>
605605
)}
606606
{srPolygonGraphPoints && (
607-
<g id={polygonPointsId} style={a11y.srOnly}>
607+
<SRDescInSVG id={polygonPointsId}>
608608
{srPolygonGraphPoints}
609-
</g>
609+
</SRDescInSVG>
610610
)}
611611
</g>
612612
);

packages/perseus/src/widgets/interactive-graphs/graphs/quadratic.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import {Plot, vec} from "mafs";
22
import * as React from "react";
33

44
import {usePerseusI18n} from "../../../components/i18n-context";
5-
import a11y from "../../../util/a11y";
65
import {actions} from "../reducer/interactive-graph-action";
76
import useGraphConfig from "../reducer/use-graph-config";
87

98
import {MovablePoint} from "./components/movable-point";
9+
import SRDescInSVG from "./components/sr-description-within-svg";
1010
import {srFormatNumber} from "./screenreader-text";
1111
import {
1212
getQuadraticPointString,
@@ -126,20 +126,20 @@ function QuadraticGraph(props: QuadraticGraphProps) {
126126
{/* Hidden elements to provide the descriptions for the
127127
`aria-describedby` properties */}
128128
{srQuadraticDirection && (
129-
<g id={quadraticDirectionId} style={a11y.srOnly}>
129+
<SRDescInSVG id={quadraticDirectionId}>
130130
{srQuadraticDirection}
131-
</g>
131+
</SRDescInSVG>
132132
)}
133133
{srQuadraticVertex && (
134-
<g id={quadraticVertexId} style={a11y.srOnly}>
134+
<SRDescInSVG id={quadraticVertexId}>
135135
{srQuadraticVertex}
136-
</g>
136+
</SRDescInSVG>
137137
)}
138-
<g id={quadraticInterceptsId} style={a11y.srOnly}>
138+
<SRDescInSVG id={quadraticInterceptsId}>
139139
{srQuadraticXIntercepts
140140
? `${srQuadraticXIntercepts} ${srQuadraticYIntercept}`
141141
: `${srQuadraticYIntercept}`}
142-
</g>
142+
</SRDescInSVG>
143143
</g>
144144
);
145145
}

packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import * as React from "react";
22

33
import {usePerseusI18n} from "../../../components/i18n-context";
4-
import a11y from "../../../util/a11y";
54
import {actions} from "../reducer/interactive-graph-action";
65

76
import {MovableLine} from "./components/movable-line";
7+
import SRDescInSVG from "./components/sr-description-within-svg";
88
import {srFormatNumber} from "./screenreader-text";
99

1010
import type {I18nContextType} from "../../../components/i18n-context";
@@ -74,9 +74,7 @@ const RayGraph = (props: Props) => {
7474
/>
7575
{/* Hidden elements to provide the descriptions for the
7676
`aria-describedby` properties. */}
77-
<g id={pointsDescriptionId} style={a11y.srOnly}>
78-
{srRayPoints}
79-
</g>
77+
<SRDescInSVG id={pointsDescriptionId}>{srRayPoints}</SRDescInSVG>
8078
</g>
8179
);
8280
};

0 commit comments

Comments
 (0)