Skip to content

Commit ac5c1c1

Browse files
eps1lonoliviertassinari
authored andcommitted
[test] Assert accessible name (#18609)
1 parent 4c6fd41 commit ac5c1c1

File tree

10 files changed

+120
-22
lines changed

10 files changed

+120
-22
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"confusing-browser-globals": "^1.0.9",
8484
"cross-env": "^6.0.0",
8585
"danger": "^9.1.8",
86+
"dom-accessibility-api": "^0.2.0",
8687
"dtslint": "^2.0.0",
8788
"enzyme": "^3.9.0",
8889
"enzyme-adapter-react-16": "^1.14.0",

packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ describe('<Autocomplete />', () => {
119119

120120
const buttons = getAllByRole('button');
121121
expect(buttons).to.have.length(2);
122-
// TODO: computeAccessibleName
122+
expect(buttons[0]).to.have.accessibleName('Clear');
123123
expect(buttons[0]).to.have.attribute('title', 'Clear');
124-
// TODO: computeAccessibleName
124+
expect(buttons[1]).to.have.accessibleName('Open');
125125
expect(buttons[1]).to.have.attribute('title', 'Open');
126126
buttons.forEach(button => {
127127
expect(button, 'button is not in tab order').to.have.property('tabIndex', -1);
@@ -160,9 +160,9 @@ describe('<Autocomplete />', () => {
160160

161161
const buttons = getAllByRole('button');
162162
expect(buttons).to.have.length(2);
163-
// TODO: computeAccessibleName
163+
expect(buttons[0]).to.have.accessibleName('Clear');
164164
expect(buttons[0]).to.have.attribute('title', 'Clear');
165-
// TODO: computeAccessibleName
165+
expect(buttons[1]).to.have.accessibleName('Close');
166166
expect(buttons[1]).to.have.attribute('title', 'Close');
167167
buttons.forEach(button => {
168168
expect(button, 'button is not in tab order').to.have.property('tabIndex', -1);

packages/material-ui/src/Chip/Chip.test.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ describe('<Chip />', () => {
7575

7676
const button = getByRole('button');
7777
expect(button).to.have.property('tabIndex', 0);
78-
// TODO: accessible name computation
79-
expect(button).to.have.text('My Chip');
78+
expect(button).to.have.accessibleName('My Chip');
8079
});
8180

8281
it('should apply user value of tabIndex', () => {

packages/material-ui/src/Select/Select.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ describe('<Select />', () => {
406406
it('it will fallback to its content for the accessible name when it has no name', () => {
407407
const { getByRole } = render(<Select value="" />);
408408

409+
// TODO what is the accessible name actually?
409410
expect(getByRole('button')).to.have.attribute('aria-labelledby', ' ');
410411
});
411412

packages/material-ui/src/TextField/TextField.test.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,7 @@ describe('<TextField />', () => {
160160
</TextField>,
161161
);
162162

163-
const label = getByRole('button')
164-
.getAttribute('aria-labelledby')
165-
.split(' ')
166-
.map(idref => document.getElementById(idref))
167-
.reduce((partial, element) => `${partial} ${element.textContent}`, '');
168-
// this whitespace is ok since actual AT will only use so called "flat strings"
169-
// https://w3c.github.io/accname/#mapping_additional_nd_te
170-
expect(label).to.equal(' Release: Stable');
163+
expect(getByRole('button')).to.have.accessibleName('Release: Stable');
171164
});
172165

173166
it('creates an input[hidden] that has no accessible properties', () => {

packages/material-ui/test/integration/Select.test.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ describe('<Select> integration', () => {
7575
const { getAllByRole, getByRole, queryByRole } = render(<SelectAndDialog />);
7676

7777
const trigger = getByRole('button');
78-
// basically this is a combined query getByRole('button', { name: 'Ten' })
79-
// but we arent' there yet
80-
expect(trigger).to.have.text('Ten');
78+
expect(trigger).to.have.accessibleName('Ten');
8179
// Let's open the select component
8280
// in the browser user click also focuses
8381
fireEvent.mouseDown(trigger);
@@ -96,17 +94,31 @@ describe('<Select> integration', () => {
9694
});
9795

9896
describe('with label', () => {
97+
it('requires `id` and `labelId` for a proper accessible name', () => {
98+
const { getByRole } = render(
99+
<FormControl>
100+
<InputLabel id="label">Age</InputLabel>
101+
<Select id="input" labelId="label" value="10">
102+
<MenuItem value="">none</MenuItem>
103+
<MenuItem value="10">Ten</MenuItem>
104+
</Select>
105+
</FormControl>,
106+
);
107+
108+
expect(getByRole('button')).to.have.accessibleName('Age Ten');
109+
});
110+
99111
// we're somewhat abusing "focus" here. What we're actually interested in is
100112
// displaying it as "active". WAI-ARIA authoring practices do not consider the
101113
// the trigger part of the widget while a native <select /> will outline the trigger
102114
// as well
103115
it('is displayed as focused while open', () => {
104-
const { container, getByRole } = render(
116+
const { getByTestId, getByRole } = render(
105117
<FormControl>
106-
<InputLabel classes={{ focused: 'focused-label' }} htmlFor="age-simple">
118+
<InputLabel classes={{ focused: 'focused-label' }} data-testid="label">
107119
Age
108120
</InputLabel>
109-
<Select inputProps={{ id: 'age' }} value="">
121+
<Select value="">
110122
<MenuItem value="">none</MenuItem>
111123
<MenuItem value={10}>Ten</MenuItem>
112124
</Select>
@@ -117,7 +129,7 @@ describe('<Select> integration', () => {
117129
trigger.focus();
118130
fireEvent.keyDown(document.activeElement, { key: 'Enter' });
119131

120-
expect(container.querySelector('[for="age-simple"]')).to.have.class('focused-label');
132+
expect(getByTestId('label')).to.have.class('focused-label');
121133
});
122134

123135
it('does not stays in an active state if an open action did not actually open', () => {

test/utils/createDOM.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ const { JSDOM } = require('jsdom');
22
const Node = require('jsdom/lib/jsdom/living/node-document-position');
33

44
// We can use jsdom-global at some point if maintaining these lists is a burden.
5-
const whitelist = ['Element', 'HTMLElement', 'HTMLInputElement', 'Performance'];
5+
const whitelist = [
6+
// required for fake getComputedStyle
7+
'CSSStyleDeclaration',
8+
'Element',
9+
'HTMLElement',
10+
'HTMLInputElement',
11+
'Performance',
12+
];
613
const blacklist = ['sessionStorage', 'localStorage'];
714

815
function createDOM() {

test/utils/init.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
declare namespace Chai {
44
interface Assertion {
5+
/**
6+
* checks if the accessible name computation (according to `accname` spec)
7+
* matches the expectation.
8+
* @see https://www.w3.org/TR/accname-1.2/
9+
* @param name
10+
*/
11+
accessibleName(name: string): Assertion;
512
/**
613
* checks if the element in question is considered aria-hidden
714
* Does not replace accessibility check as that requires display/visibility/layout

test/utils/initMatchers.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import chai from 'chai';
22
import chaiDom from 'chai-dom';
33
import { isInaccessible } from '@testing-library/dom';
44
import { prettyDOM } from '@testing-library/react/pure';
5+
import { computeAccessibleName } from 'dom-accessibility-api';
56

67
chai.use(chaiDom);
78
chai.use((chaiAPI, utils) => {
@@ -62,4 +63,76 @@ chai.use((chaiAPI, utils) => {
6263
`expected ${utils.elToString(element)} to be accessible but it was inaccessible`,
6364
);
6465
});
66+
67+
chai.Assertion.addMethod('accessibleName', function hasAccessibleName(expectedName) {
68+
const root = utils.flag(this, 'object');
69+
// make sure it's an Element
70+
new chai.Assertion(root.nodeType, `Expected an Element but got '${String(root)}'`).to.equal(1);
71+
72+
const blockElements = new Set(
73+
'html',
74+
'address',
75+
'blockquote',
76+
'body',
77+
'dd',
78+
'div',
79+
'dl',
80+
'dt',
81+
'fieldset',
82+
'form',
83+
'frame',
84+
'frameset',
85+
'h1',
86+
'h2',
87+
'h3',
88+
'h4',
89+
'h5',
90+
'h6',
91+
'noframes',
92+
'ol',
93+
'p',
94+
'ul',
95+
'center',
96+
'dir',
97+
'hr',
98+
'menu',
99+
'pre',
100+
);
101+
/**
102+
*
103+
* @param {Element} element
104+
* @returns {CSSStyleDeclaration}
105+
*/
106+
function pretendVisibleGetComputedStyle(element) {
107+
// `CSSStyleDeclaration` is not constructable
108+
// https://stackoverflow.com/a/52732909/3406963
109+
// this is not equivalent to the declaration from `getComputedStyle`
110+
// e.g `getComputedStyle` would return a readonly declaration
111+
// let's hope this doesn't get passed around until it's no longer clear where it comes from
112+
const declaration = document.createElement('span').style;
113+
114+
// initial values
115+
declaration.content = '';
116+
// technically it's `inline`. We partially apply the default user agent sheet (chrome) here
117+
// we're only interested in elements that use block
118+
declaration.display = blockElements.has(element.tagName) ? 'block' : 'inline';
119+
declaration.visibility = 'visible';
120+
121+
return declaration;
122+
}
123+
124+
const actualName = computeAccessibleName(root, {
125+
// in local development we pretend to be visible. full getComputedStyle is
126+
// expensive and reserved for CI
127+
getComputedStyle: process.env.CI ? undefined : pretendVisibleGetComputedStyle,
128+
});
129+
130+
this.assert(
131+
actualName === expectedName,
132+
`expected ${utils.elToString(
133+
root,
134+
)} to have accessible name '${expectedName}' but got '${actualName}' instead.`,
135+
`expected ${utils.elToString(root)} not to have accessible name '${expectedName}'.`,
136+
);
137+
});
65138
});

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5751,6 +5751,11 @@ doctrine@^3.0.0:
57515751
dependencies:
57525752
esutils "^2.0.2"
57535753

5754+
dom-accessibility-api@^0.2.0:
5755+
version "0.2.0"
5756+
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.2.0.tgz#2890ce677bd7b2172778ed979ab2ff4967c3085d"
5757+
integrity sha512-afrHGxXpS5C2jUC5hquPb3GWytNKHI+wJLKr/jvri95sZpLYpEJi3CtI/yBPEJ+/R9/CXaWXifadz94tsDcotg==
5758+
57545759
dom-helpers@^3.2.1, dom-helpers@^3.4.0:
57555760
version "3.4.0"
57565761
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"

0 commit comments

Comments
 (0)