Skip to content

Commit 31f8fac

Browse files
authored
fix: make ProxyMenu more accessible to screen readers (#11312)
* wip: commit progress on latency update * chore: add stories and clean up tests * refactor: clean up code * fix: make sure headers aren't treated as interactive elements * refactor: clean up tests * fix: clean up stories * docs: add clarifying comment * fix: update stories again * fix: clean up/extend prop definitions * refactor: quick cleanup * fix: apply Kira's feedback * refactor: clean up abbr markup to account for pronunciation * fix: more cleanup * fix: refine screen reader output for VoiceOver * refactor: clean up and redefine tests * feature: add finishing touches
1 parent 8a9fe2b commit 31f8fac

File tree

6 files changed

+301
-29
lines changed

6 files changed

+301
-29
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"idtoken",
6161
"Iflag",
6262
"incpatch",
63+
"initialisms",
6364
"ipnstate",
6465
"isatty",
6566
"Jobf",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { type PropsWithChildren } from "react";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { Abbr } from "./Abbr";
4+
5+
// Just here to make the abbreviated part more obvious in the component library
6+
const Underline = ({ children }: PropsWithChildren) => (
7+
<span css={{ textDecoration: "underline dotted" }}>{children}</span>
8+
);
9+
10+
const meta: Meta<typeof Abbr> = {
11+
title: "components/Abbr",
12+
component: Abbr,
13+
decorators: [
14+
(Story) => (
15+
<>
16+
<p>Try the following text out in a screen reader!</p>
17+
<Story />
18+
</>
19+
),
20+
],
21+
};
22+
23+
export default meta;
24+
type Story = StoryObj<typeof Abbr>;
25+
26+
export const InlinedShorthand: Story = {
27+
args: {
28+
pronunciation: "shorthand",
29+
children: "ms",
30+
title: "milliseconds",
31+
},
32+
decorators: [
33+
(Story) => (
34+
<p css={{ maxWidth: "40em" }}>
35+
The physical pain of getting bonked on the head with a cartoon mallet
36+
lasts precisely 593{" "}
37+
<Underline>
38+
<Story />
39+
</Underline>
40+
. The emotional turmoil and complete embarrassment lasts forever.
41+
</p>
42+
),
43+
],
44+
};
45+
46+
export const Acronym: Story = {
47+
args: {
48+
pronunciation: "acronym",
49+
children: "NASA",
50+
title: "National Aeronautics and Space Administration",
51+
},
52+
decorators: [
53+
(Story) => (
54+
<Underline>
55+
<Story />
56+
</Underline>
57+
),
58+
],
59+
};
60+
61+
export const Initialism: Story = {
62+
args: {
63+
pronunciation: "initialism",
64+
children: "CLI",
65+
title: "Command-Line Interface",
66+
},
67+
decorators: [
68+
(Story) => (
69+
<Underline>
70+
<Story />
71+
</Underline>
72+
),
73+
],
74+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { Abbr, type Pronunciation } from "./Abbr";
3+
4+
type AbbreviationData = {
5+
abbreviation: string;
6+
title: string;
7+
expectedLabel: string;
8+
};
9+
10+
type AssertionInput = AbbreviationData & {
11+
pronunciation: Pronunciation;
12+
};
13+
14+
function assertAccessibleLabel({
15+
abbreviation,
16+
title,
17+
expectedLabel,
18+
pronunciation,
19+
}: AssertionInput) {
20+
const { unmount } = render(
21+
<Abbr title={title} pronunciation={pronunciation}>
22+
{abbreviation}
23+
</Abbr>,
24+
);
25+
26+
screen.getByLabelText(expectedLabel, { selector: "abbr" });
27+
unmount();
28+
}
29+
30+
describe(Abbr.name, () => {
31+
it("Has an aria-label that equals the title if the abbreviation is shorthand", () => {
32+
const sampleShorthands: AbbreviationData[] = [
33+
{
34+
abbreviation: "ms",
35+
title: "milliseconds",
36+
expectedLabel: "milliseconds",
37+
},
38+
{
39+
abbreviation: "g",
40+
title: "grams",
41+
expectedLabel: "grams",
42+
},
43+
];
44+
45+
for (const shorthand of sampleShorthands) {
46+
assertAccessibleLabel({ ...shorthand, pronunciation: "shorthand" });
47+
}
48+
});
49+
50+
it("Has an aria label with title and 'flattened' pronunciation if abbreviation is acronym", () => {
51+
const sampleAcronyms: AbbreviationData[] = [
52+
{
53+
abbreviation: "NASA",
54+
title: "National Aeronautics and Space Administration",
55+
expectedLabel: "Nasa (National Aeronautics and Space Administration)",
56+
},
57+
{
58+
abbreviation: "AWOL",
59+
title: "Absent without Official Leave",
60+
expectedLabel: "Awol (Absent without Official Leave)",
61+
},
62+
{
63+
abbreviation: "YOLO",
64+
title: "You Only Live Once",
65+
expectedLabel: "Yolo (You Only Live Once)",
66+
},
67+
];
68+
69+
for (const acronym of sampleAcronyms) {
70+
assertAccessibleLabel({ ...acronym, pronunciation: "acronym" });
71+
}
72+
});
73+
74+
it("Has an aria label with title and initialized pronunciation if abbreviation is initialism", () => {
75+
const sampleInitialisms: AbbreviationData[] = [
76+
{
77+
abbreviation: "FBI",
78+
title: "Federal Bureau of Investigation",
79+
expectedLabel: "F.B.I. (Federal Bureau of Investigation)",
80+
},
81+
{
82+
abbreviation: "YMCA",
83+
title: "Young Men's Christian Association",
84+
expectedLabel: "Y.M.C.A. (Young Men's Christian Association)",
85+
},
86+
{
87+
abbreviation: "CLI",
88+
title: "Command-Line Interface",
89+
expectedLabel: "C.L.I. (Command-Line Interface)",
90+
},
91+
];
92+
93+
for (const initialism of sampleInitialisms) {
94+
assertAccessibleLabel({ ...initialism, pronunciation: "initialism" });
95+
}
96+
});
97+
});

site/src/components/Abbr/Abbr.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { type FC, type HTMLAttributes } from "react";
2+
3+
export type Pronunciation = "shorthand" | "acronym" | "initialism";
4+
5+
type AbbrProps = HTMLAttributes<HTMLElement> & {
6+
children: string;
7+
title: string;
8+
pronunciation?: Pronunciation;
9+
};
10+
11+
/**
12+
* A more sophisticated version of the native <abbr> element.
13+
*
14+
* Features:
15+
* - Better type-safety (requiring you to include certain properties)
16+
* - All built-in HTML styling is stripped away by default
17+
* - Better integration with screen readers (like exposing the title prop to
18+
* them), with more options for influencing how they pronounce text
19+
*/
20+
export const Abbr: FC<AbbrProps> = ({
21+
children,
22+
title,
23+
pronunciation = "shorthand",
24+
...delegatedProps
25+
}) => {
26+
return (
27+
<abbr
28+
// Title attributes usually aren't natively available to screen readers;
29+
// always have to supplement with aria-label
30+
title={title}
31+
aria-label={getAccessibleLabel(children, title, pronunciation)}
32+
css={{
33+
textDecoration: "inherit",
34+
letterSpacing: children === children.toUpperCase() ? "0.02em" : "0",
35+
}}
36+
{...delegatedProps}
37+
>
38+
<span aria-hidden>{children}</span>
39+
</abbr>
40+
);
41+
};
42+
43+
function getAccessibleLabel(
44+
abbreviation: string,
45+
title: string,
46+
pronunciation: Pronunciation,
47+
): string {
48+
if (pronunciation === "initialism") {
49+
return `${initializeText(abbreviation)} (${title})`;
50+
}
51+
52+
if (pronunciation === "acronym") {
53+
return `${flattenPronunciation(abbreviation)} (${title})`;
54+
}
55+
56+
return title;
57+
}
58+
59+
function initializeText(text: string): string {
60+
return text.trim().toUpperCase().replaceAll(/\B/g, ".") + ".";
61+
}
62+
63+
function flattenPronunciation(text: string): string {
64+
const trimmed = text.trim();
65+
return (trimmed[0] ?? "").toUpperCase() + trimmed.slice(1).toLowerCase();
66+
}

0 commit comments

Comments
 (0)