Skip to content

Commit

Permalink
[test] Assert accessible name (#18609)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored and oliviertassinari committed Nov 29, 2019
1 parent 4c6fd41 commit ac5c1c1
Show file tree
Hide file tree
Showing 10 changed files with 120 additions and 22 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"confusing-browser-globals": "^1.0.9",
"cross-env": "^6.0.0",
"danger": "^9.1.8",
"dom-accessibility-api": "^0.2.0",
"dtslint": "^2.0.0",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.14.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ describe('<Autocomplete />', () => {

const buttons = getAllByRole('button');
expect(buttons).to.have.length(2);
// TODO: computeAccessibleName
expect(buttons[0]).to.have.accessibleName('Clear');
expect(buttons[0]).to.have.attribute('title', 'Clear');
// TODO: computeAccessibleName
expect(buttons[1]).to.have.accessibleName('Open');
expect(buttons[1]).to.have.attribute('title', 'Open');
buttons.forEach(button => {
expect(button, 'button is not in tab order').to.have.property('tabIndex', -1);
Expand Down Expand Up @@ -160,9 +160,9 @@ describe('<Autocomplete />', () => {

const buttons = getAllByRole('button');
expect(buttons).to.have.length(2);
// TODO: computeAccessibleName
expect(buttons[0]).to.have.accessibleName('Clear');
expect(buttons[0]).to.have.attribute('title', 'Clear');
// TODO: computeAccessibleName
expect(buttons[1]).to.have.accessibleName('Close');
expect(buttons[1]).to.have.attribute('title', 'Close');
buttons.forEach(button => {
expect(button, 'button is not in tab order').to.have.property('tabIndex', -1);
Expand Down
3 changes: 1 addition & 2 deletions packages/material-ui/src/Chip/Chip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ describe('<Chip />', () => {

const button = getByRole('button');
expect(button).to.have.property('tabIndex', 0);
// TODO: accessible name computation
expect(button).to.have.text('My Chip');
expect(button).to.have.accessibleName('My Chip');
});

it('should apply user value of tabIndex', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/material-ui/src/Select/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ describe('<Select />', () => {
it('it will fallback to its content for the accessible name when it has no name', () => {
const { getByRole } = render(<Select value="" />);

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

Expand Down
9 changes: 1 addition & 8 deletions packages/material-ui/src/TextField/TextField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,7 @@ describe('<TextField />', () => {
</TextField>,
);

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

it('creates an input[hidden] that has no accessible properties', () => {
Expand Down
26 changes: 19 additions & 7 deletions packages/material-ui/test/integration/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ describe('<Select> integration', () => {
const { getAllByRole, getByRole, queryByRole } = render(<SelectAndDialog />);

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

describe('with label', () => {
it('requires `id` and `labelId` for a proper accessible name', () => {
const { getByRole } = render(
<FormControl>
<InputLabel id="label">Age</InputLabel>
<Select id="input" labelId="label" value="10">
<MenuItem value="">none</MenuItem>
<MenuItem value="10">Ten</MenuItem>
</Select>
</FormControl>,
);

expect(getByRole('button')).to.have.accessibleName('Age Ten');
});

// we're somewhat abusing "focus" here. What we're actually interested in is
// displaying it as "active". WAI-ARIA authoring practices do not consider the
// the trigger part of the widget while a native <select /> will outline the trigger
// as well
it('is displayed as focused while open', () => {
const { container, getByRole } = render(
const { getByTestId, getByRole } = render(
<FormControl>
<InputLabel classes={{ focused: 'focused-label' }} htmlFor="age-simple">
<InputLabel classes={{ focused: 'focused-label' }} data-testid="label">
Age
</InputLabel>
<Select inputProps={{ id: 'age' }} value="">
<Select value="">
<MenuItem value="">none</MenuItem>
<MenuItem value={10}>Ten</MenuItem>
</Select>
Expand All @@ -117,7 +129,7 @@ describe('<Select> integration', () => {
trigger.focus();
fireEvent.keyDown(document.activeElement, { key: 'Enter' });

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

it('does not stays in an active state if an open action did not actually open', () => {
Expand Down
9 changes: 8 additions & 1 deletion test/utils/createDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ const { JSDOM } = require('jsdom');
const Node = require('jsdom/lib/jsdom/living/node-document-position');

// We can use jsdom-global at some point if maintaining these lists is a burden.
const whitelist = ['Element', 'HTMLElement', 'HTMLInputElement', 'Performance'];
const whitelist = [
// required for fake getComputedStyle
'CSSStyleDeclaration',
'Element',
'HTMLElement',
'HTMLInputElement',
'Performance',
];
const blacklist = ['sessionStorage', 'localStorage'];

function createDOM() {
Expand Down
7 changes: 7 additions & 0 deletions test/utils/init.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

declare namespace Chai {
interface Assertion {
/**
* checks if the accessible name computation (according to `accname` spec)
* matches the expectation.
* @see https://www.w3.org/TR/accname-1.2/
* @param name
*/
accessibleName(name: string): Assertion;
/**
* checks if the element in question is considered aria-hidden
* Does not replace accessibility check as that requires display/visibility/layout
Expand Down
73 changes: 73 additions & 0 deletions test/utils/initMatchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import chai from 'chai';
import chaiDom from 'chai-dom';
import { isInaccessible } from '@testing-library/dom';
import { prettyDOM } from '@testing-library/react/pure';
import { computeAccessibleName } from 'dom-accessibility-api';

chai.use(chaiDom);
chai.use((chaiAPI, utils) => {
Expand Down Expand Up @@ -62,4 +63,76 @@ chai.use((chaiAPI, utils) => {
`expected ${utils.elToString(element)} to be accessible but it was inaccessible`,
);
});

chai.Assertion.addMethod('accessibleName', function hasAccessibleName(expectedName) {
const root = utils.flag(this, 'object');
// make sure it's an Element
new chai.Assertion(root.nodeType, `Expected an Element but got '${String(root)}'`).to.equal(1);

const blockElements = new Set(
'html',
'address',
'blockquote',
'body',
'dd',
'div',
'dl',
'dt',
'fieldset',
'form',
'frame',
'frameset',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'noframes',
'ol',
'p',
'ul',
'center',
'dir',
'hr',
'menu',
'pre',
);
/**
*
* @param {Element} element
* @returns {CSSStyleDeclaration}
*/
function pretendVisibleGetComputedStyle(element) {
// `CSSStyleDeclaration` is not constructable
// https://stackoverflow.com/a/52732909/3406963
// this is not equivalent to the declaration from `getComputedStyle`
// e.g `getComputedStyle` would return a readonly declaration
// let's hope this doesn't get passed around until it's no longer clear where it comes from
const declaration = document.createElement('span').style;

// initial values
declaration.content = '';
// technically it's `inline`. We partially apply the default user agent sheet (chrome) here
// we're only interested in elements that use block
declaration.display = blockElements.has(element.tagName) ? 'block' : 'inline';
declaration.visibility = 'visible';

return declaration;
}

const actualName = computeAccessibleName(root, {
// in local development we pretend to be visible. full getComputedStyle is
// expensive and reserved for CI
getComputedStyle: process.env.CI ? undefined : pretendVisibleGetComputedStyle,
});

this.assert(
actualName === expectedName,
`expected ${utils.elToString(
root,
)} to have accessible name '${expectedName}' but got '${actualName}' instead.`,
`expected ${utils.elToString(root)} not to have accessible name '${expectedName}'.`,
);
});
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5751,6 +5751,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"

dom-accessibility-api@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.2.0.tgz#2890ce677bd7b2172778ed979ab2ff4967c3085d"
integrity sha512-afrHGxXpS5C2jUC5hquPb3GWytNKHI+wJLKr/jvri95sZpLYpEJi3CtI/yBPEJ+/R9/CXaWXifadz94tsDcotg==

dom-helpers@^3.2.1, dom-helpers@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
Expand Down

0 comments on commit ac5c1c1

Please sign in to comment.