Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aria-required-attr): require aria-controls on combobox and aria-valuenow on focusable separator #3786

Merged
merged 2 commits into from
Nov 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 43 additions & 40 deletions lib/checks/aria/aria-required-attr-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { requiredAttr, getExplicitRole } from '../../commons/aria';
import {
requiredAttr as getRequiredAttrs,
getExplicitRole
} from '../../commons/aria';
import { getElementSpec } from '../../commons/standards';
import { uniqueArray } from '../../core/utils';
import { isFocusable } from '../../commons/dom';

/**
* Check that the element has all required attributes for its explicit role.
Expand All @@ -26,53 +30,52 @@ import { uniqueArray } from '../../core/utils';
* @memberof checks
* @return {Boolean} True if all required attributes are present. False otherwise.
*/
function ariaRequiredAttrEvaluate(node, options = {}, virtualNode) {
const missing = [];
const attrs = virtualNode.attrNames;
export default function ariaRequiredAttrEvaluate(
node,
options = {},
virtualNode
) {
const role = getExplicitRole(virtualNode);
if (attrs.length) {
let required = requiredAttr(role);
const elmSpec = getElementSpec(virtualNode);

// @deprecated: required attr options to pass more attrs.
// configure the standards spec instead
if (Array.isArray(options[role])) {
required = uniqueArray(options[role], required);
}
if (role && required) {
for (let i = 0, l = required.length; i < l; i++) {
const attr = required[i];
if (
!virtualNode.attr(attr) &&
!(
elmSpec.implicitAttrs &&
typeof elmSpec.implicitAttrs[attr] !== 'undefined'
)
) {
missing.push(attr);
}
}
}
const attrs = virtualNode.attrNames;
// @deprecated: required attr options to pass more attrs.
// configure the standards spec instead
let requiredAttrs = getRequiredAttrs(role);
if (Array.isArray(options[role])) {
requiredAttrs = uniqueArray(options[role], requiredAttrs);
}

// aria 1.2 combobox requires aria-controls, but aria-owns is acceptable instead in earlier versions of the guidelines. also either is only required if the element has aria-expanded=true
// https://github.com/dequelabs/axe-core/issues/2505#issuecomment-788703942
// https://github.com/dequelabs/axe-core/issues/2505#issuecomment-881947373
const comboboxMissingControls =
role === 'combobox' && missing.includes('aria-controls');
// Nothing to test
if (!role || !attrs.length || !requiredAttrs.length) {
return true;
}
// Some required props are conditional:
if (
comboboxMissingControls &&
(virtualNode.hasAttr('aria-owns') ||
virtualNode.attr('aria-expanded') !== 'true')
isStaticSeparator(virtualNode, role) ||
isClosedCombobox(virtualNode, role)
) {
missing.splice(missing.indexOf('aria-controls', 1));
return true;
}

if (missing.length) {
this.data(missing);
const elmSpec = getElementSpec(virtualNode);
const missingAttrs = requiredAttrs.filter(
requiredAttr =>
!virtualNode.attr(requiredAttr) && !hasImplicitAttr(elmSpec, requiredAttr)
);

if (missingAttrs.length) {
this.data(missingAttrs);
return false;
}
return true;
}

export default ariaRequiredAttrEvaluate;
function isStaticSeparator(vNode, role) {
return role === 'separator' && !isFocusable(vNode);
}

function hasImplicitAttr(elmSpec, attr) {
return elmSpec.implicitAttrs?.[attr] !== undefined;
}

function isClosedCombobox(vNode, role) {
return role === 'combobox' && vNode.attr('aria-expanded') === 'false';
}
8 changes: 4 additions & 4 deletions lib/standards/aria-roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,8 @@ const ariaRoles = {
},
meter: {
type: 'structure',
allowedAttrs: ['aria-valuetext'],
requiredAttrs: ['aria-valuemax', 'aria-valuemin', 'aria-valuenow'],
requiredAttrs: ['aria-valuenow'],
allowedAttrs: ['aria-valuemax', 'aria-valuemin', 'aria-valuetext'],
superclassRole: ['range'],
accessibleNameRequired: true,
childrenPresentational: true
Expand Down Expand Up @@ -610,14 +610,14 @@ const ariaRoles = {
},
separator: {
type: 'structure',
requiredAttrs: ['aria-valuenow'],
// Note: since the separator role has implicit
// aria-orientation, aria-valuemax, aria-valuemin, and
// aria-valuenow values it is not required to be added by
// values it is not required to be added by
// the user
allowedAttrs: [
'aria-valuemax',
'aria-valuemin',
'aria-valuenow',
'aria-orientation',
'aria-valuetext'
],
Expand Down
158 changes: 70 additions & 88 deletions test/checks/aria/aria-required-attr.js
Original file line number Diff line number Diff line change
@@ -1,136 +1,120 @@
describe('aria-required-attr', function () {
'use strict';
describe('aria-required-attr', () => {
const { queryFixture, checkSetup } = axe.testUtils;
const checkContext = axe.testUtils.MockCheckContext();
const requiredAttrCheck =
axe.testUtils.getCheckEvaluate('aria-required-attr');

var queryFixture = axe.testUtils.queryFixture;
var checkContext = axe.testUtils.MockCheckContext();
var options = undefined;
var requiredAttrCheck = axe.testUtils.getCheckEvaluate('aria-required-attr');

afterEach(function () {
afterEach(() => {
checkContext.reset();
axe.reset();
});

it('returns true for valid attributes', function () {
var vNode = queryFixture(
it('returns true for valid attributes', () => {
const params = checkSetup(
'<div id="target" role="switch" tabindex="1" aria-checked="false">'
);
assert.isTrue(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
assert.isNull(checkContext._data);
});

it('returns false for missing attributes', function () {
var vNode = queryFixture('<div id="target" role="switch" tabindex="1">');
assert.isFalse(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
it('returns false for missing attributes', () => {
const params = checkSetup('<div id="target" role="switch" tabindex="1">');
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
assert.deepEqual(checkContext._data, ['aria-checked']);
});

it('returns false for null attributes', function () {
var vNode = queryFixture(
it('returns false for null attributes', () => {
const params = checkSetup(
'<div id="target" role="switch" tabindex="1" aria-checked>'
);
assert.isFalse(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
assert.deepEqual(checkContext._data, ['aria-checked']);
});

it('returns false for empty attributes', function () {
var vNode = queryFixture(
it('returns false for empty attributes', () => {
const params = checkSetup(
'<div id="target" role="switch" tabindex="1" aria-checked="">'
);
assert.isFalse(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
assert.deepEqual(checkContext._data, ['aria-checked']);
});

it('should return true if there is no role', function () {
var vNode = queryFixture('<div id="target">');

assert.isTrue(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
it('returns true if there is no role', () => {
const params = checkSetup('<div id="target"></div>');
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
assert.isNull(checkContext._data);
});

it('should pass aria-valuenow if element has value property', function () {
var vNode = queryFixture('<input id="target" type="range" role="slider">');

assert.isTrue(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
it('passes aria-valuenow if element has value property', () => {
const params = checkSetup('<input id="target" type="range" role="slider">');
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
});

it('should pass aria-checkbox if element has checked property', function () {
var vNode = queryFixture(
it('passes aria-checkbox if element has checked property', () => {
const params = checkSetup(
'<input id="target" type="checkbox" role="switch">'
);
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
});

assert.isTrue(
checks['aria-required-attr'].evaluate.call(
checkContext,
vNode.actualNode,
options,
vNode
)
);
describe('separator', () => {
it('fails a focusable separator', () => {
const params = checkSetup(
'<div id="target" role="separator" tabindex="0"></div>'
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
});

it('passes a non-focusable separator', () => {
const params = checkSetup('<div id="target" role="separator"></div>');
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
});
});

describe('combobox special case', function () {
it('should pass comboboxes that have aria-expanded="false"', function () {
var vNode = queryFixture(
describe('combobox', () => {
it('passes comboboxes that have aria-expanded="false"', () => {
const params = checkSetup(
'<div id="target" role="combobox" aria-expanded="false"></div>'
);
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
});

assert.isTrue(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
it('fails comboboxes without aria-controls and with an invalid aria-expanded', () => {
const params = checkSetup(
'<div id="target" role="combobox" aria-expanded="invalid-value"></div>'
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
});

it('should pass comboboxes that have aria-owns and aria-expanded', function () {
var vNode = queryFixture(
it('fails comboboxes that has aria-owns without aria-controls', () => {
const params = checkSetup(
'<div id="target" role="combobox" aria-expanded="true" aria-owns="ownedchild"></div>'
);

assert.isTrue(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
});

it('should pass comboboxes that have aria-controls and aria-expanded', function () {
var vNode = queryFixture(
it('passes comboboxes that have aria-controls and aria-expanded', () => {
const params = checkSetup(
'<div id="target" role="combobox" aria-expanded="true" aria-controls="test"></div>'
);

assert.isTrue(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isTrue(requiredAttrCheck.apply(checkContext, params));
});

it('should fail comboboxes that have no required attributes', function () {
var vNode = queryFixture('<div id="target" role="combobox"></div>');
it('fails comboboxes that have no required attributes', () => {
const params = checkSetup('<div id="target" role="combobox"></div>');

assert.isFalse(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
});

it('should fail comboboxes that have aria-expanded only', function () {
var vNode = queryFixture(
it('fails comboboxes that have aria-expanded only', () => {
const params = checkSetup(
'<div id="target" role="combobox" aria-expanded="true"></div>'
);

assert.isFalse(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
});

it('should report missing of multiple attributes correctly', function () {
it('reports missing of multiple attributes correctly', () => {
axe.configure({
standards: {
ariaRoles: {
Expand All @@ -141,18 +125,16 @@ describe('aria-required-attr', function () {
}
});

var vNode = queryFixture(
'<div id="target" role="combobox" aria-expanded="false"></div>'
);
assert.isFalse(
requiredAttrCheck.call(checkContext, vNode.actualNode, options, vNode)
const params = checkSetup(
'<div id="target" role="combobox" aria-expanded="true"></div>'
);
assert.deepEqual(checkContext._data, ['aria-label']);
assert.isFalse(requiredAttrCheck.apply(checkContext, params));
assert.deepEqual(checkContext._data, ['aria-label', 'aria-controls']);
});
});

describe('options', function () {
it('should require provided attribute names for a role', function () {
describe('options', () => {
it('requires provided attribute names for a role', () => {
axe.configure({
standards: {
ariaRoles: {
Expand All @@ -163,8 +145,8 @@ describe('aria-required-attr', function () {
}
});

var vNode = queryFixture('<div role="mccheddarton" id="target"></div>');
var options = {
const vNode = queryFixture('<div role="mccheddarton" id="target"></div>');
const options = {
mccheddarton: ['aria-snuggles']
};
assert.isFalse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@
<input type="number" role="spinbutton" id="pass9" value="10" />
<input type="number" role="spinbutton" id="pass10" />
<div role="spinbutton" id="pass11">fail</div>
<div role="separator" id="pass12"></div>
<div role="separator" id="pass13" tabindex="0" aria-valuenow="foo"></div>

<div role="scrollbar" id="violation1">fail</div>
<div role="slider" id="violation2">fail</div>
<div role="heading" id="violation3">fail</div>
<div role="combobox" id="violation4">fail</div>
<div role="separator" id="violation5" tabindex="0"></div>
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
["#violation1"],
["#violation2"],
["#violation3"],
["#violation4"]
["#violation4"],
["#violation5"],
["#comboboxWithOwns"]
],
"passes": [
["#pass1"],
Expand All @@ -19,6 +21,7 @@
["#pass9"],
["#pass10"],
["#pass11"],
["#comboboxWithOwns"]
["#pass12"],
["#pass13"]
]
}