Skip to content
239 changes: 128 additions & 111 deletions CHANGELOG.md

Large diffs are not rendered by default.

83 changes: 42 additions & 41 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions __mocks__/genInteractives.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import JSXElementMock from './JSXElementMock';
import type { JSXAttributeMockType } from './JSXAttributeMock';
import type { JSXElementMockType } from './JSXElementMock';

const domElements = [...dom.keys()];
const roleNames = [...roles.keys()];
const domElements = dom.keys();
const roleNames = roles.keys();

const interactiveElementsMap = {
a: [{ prop: 'href', value: '#' }],
Expand Down
2 changes: 1 addition & 1 deletion __tests__/src/rules/aria-props-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import getSuggestion from '../../../src/util/getSuggestion';
// -----------------------------------------------------------------------------

const ruleTester = new RuleTester();
const ariaAttributes = [...aria.keys()];
const ariaAttributes = aria.keys();

const errorMessage = (name) => {
const suggestions = getSuggestion(name, ariaAttributes);
Expand Down
7 changes: 3 additions & 4 deletions __tests__/src/rules/aria-role-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const errorMessage = {
type: 'JSXAttribute',
};

const roleKeys = [...roles.keys()];
const roleKeys = roles.keys();

const validRoles = roleKeys.filter((role) => roles.get(role).abstract === false);
const invalidRoles = roleKeys.filter((role) => roles.get(role).abstract === true);
Expand Down Expand Up @@ -84,9 +84,8 @@ ruleTester.run('aria-role', rule, {
code: '<Box asChild="div" role="button" />',
settings: customDivSettings,
},
{
code: '<svg role="graphics-document document" />',
},
{ code: '<svg role="graphics-document document" />' },
{ code: '<svg role="img" />' },
)).concat(validTests).map(parserOptionsMapper),

invalid: parsers.all([].concat(
Expand Down
2 changes: 1 addition & 1 deletion __tests__/src/rules/aria-unsupported-elements-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Try removing the prop '${invalidProp}'.`,
type: 'JSXOpeningElement',
});

const domElements = [...dom.keys()];
const domElements = dom.keys();
// Generate valid test cases
const roleValidityTests = domElements.map((element) => {
const isReserved = dom.get(element).reserved || false;
Expand Down
17 changes: 16 additions & 1 deletion __tests__/src/rules/interactive-supports-focus-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function template(strings, ...keys) {
const ruleName = 'interactive-supports-focus';
const type = 'JSXOpeningElement';
const codeTemplate = template`<${0} role="${1}" ${2}={() => void 0} />`;
const fixedTemplate = template`<${0} tabIndex={${1}} role="${2}" ${3}={() => void 0} />`;
const tabindexTemplate = template`<${0} role="${1}" ${2}={() => void 0} tabIndex="0" />`;
const tabbableTemplate = template`Elements with the '${0}' interactive role must be tabbable.`;
const focusableTemplate = template`Elements with the '${0}' interactive role must be focusable.`;
Expand All @@ -47,7 +48,14 @@ const componentsSettings = {
},
};

const buttonError = { message: tabbableTemplate('button'), type };
const buttonError = {
message: tabbableTemplate('button'),
suggestions: [{
desc: 'Add `tabIndex={0}` to make the element focusable in sequential keyboard navigation.',
output: '<Div tabIndex={0} onClick={() => void 0} role="button" />',
}],
type,
};

const recommendedOptions = configs.recommended.rules[`jsx-a11y/${ruleName}`][1] || {};

Expand Down Expand Up @@ -202,6 +210,13 @@ const failReducer = (roles, handlers, messageTemplate) => (
errors: [{
type,
message: messageTemplate(role),
suggestions: [{
desc: 'Add `tabIndex={0}` to make the element focusable in sequential keyboard navigation.',
output: fixedTemplate(element, '0', role, handler),
}].concat(messageTemplate === focusableTemplate ? [{
desc: 'Add `tabIndex={-1}` to make the element focusable but not reachable via sequential keyboard navigation.',
output: fixedTemplate(element, '-1', role, handler),
}] : []),
}],
})))
), []))
Expand Down
5 changes: 5 additions & 0 deletions __tests__/src/rules/label-has-associated-control-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const htmlForValid = [
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }] },
{ code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', settings: componentsSettings },
{ code: '<MUILabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['*Label'] }] },
{ code: '<LabelCustom htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['Label*'] }] },
// Custom label attributes.
{ code: '<label htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'] }] },
// Glob support for controlComponents option.
Expand Down Expand Up @@ -94,6 +96,7 @@ const nestingValid = [
// Glob support for controlComponents option.
{ code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['Custom*'] }] },
{ code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['*Input'] }] },
{ code: '<label><span>A label<TextInput /></span></label>', options: [{ controlComponents: ['????Input'] }] },
// Rule does not error if presence of accessible label cannot be determined
{ code: '<label><CustomText /><input /></label>' },
];
Expand All @@ -106,6 +109,7 @@ const bothValid = [
// Custom label component.
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label"><input /></CustomLabel>', options: [{ labelComponents: ['CustomLabel'] }] },
{ code: '<CustomLabel htmlFor="js_id" label="A label"><input /></CustomLabel>', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
{ code: '<CustomLabel htmlFor="js_id" label="A label"><input /></CustomLabel>', options: [{ labelAttributes: ['label'], labelComponents: ['*Label'] }] },
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label"><input /></CustomLabel>', settings: componentsSettings },
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label"><CustomInput /></CustomLabel>', settings: componentsSettings },
// Custom label attributes.
Expand Down Expand Up @@ -160,6 +164,7 @@ const neverValid = [
{ code: '<div><label>A label</label><input /></div>', errors: [expectedError] },
// Custom label component.
{ code: '<CustomLabel aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
{ code: '<MUILabel aria-label="A label" />', options: [{ labelComponents: ['???Label'] }], errors: [expectedError] },
{ code: '<CustomLabel label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
{ code: '<CustomLabel aria-label="A label" />', settings: componentsSettings, errors: [expectedError] },
// Custom label attributes.
Expand Down
25 changes: 24 additions & 1 deletion __tests__/src/rules/no-redundant-roles-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,31 @@ const alwaysValid = [
{ code: '<MyComponent role="button" />' },
{ code: '<button role={`${foo}button`} />' },
{ code: '<Button role={`${foo}button`} />', settings: componentsSettings },
{ code: '<select role="menu"><option>1</option><option>2</option></select>' },
{ code: '<select role="menu" size={2}><option>1</option><option>2</option></select>' },
{ code: '<select role="menu" multiple><option>1</option><option>2</option></select>' },
];

const neverValid = [
{ code: '<button role="button" />', errors: [expectedError('button', 'button')] },
{ code: '<body role="DOCUMENT" />', errors: [expectedError('body', 'document')] },
// button - treated as button by default
{ code: '<button role="button" />', errors: [expectedError('button', 'button')] },
{ code: '<Button role="button" />', settings: componentsSettings, errors: [expectedError('button', 'button')] },
// select - treated as combobox by default
{ code: '<select role="combobox"><option>1</option><option>2</option></select>', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" size="" />', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" size={1} />', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" size="1" />', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" size={null}></select>', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" size={undefined}></select>', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" multiple={undefined}></select>', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" multiple={false}></select>', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" multiple=""></select>', errors: [expectedError('select', 'combobox')] },
// select - treated as listbox when multiple OR size > 1
{ code: '<select role="listbox" size="3" />', errors: [expectedError('select', 'listbox')] },
{ code: '<select role="listbox" size={2} />', errors: [expectedError('select', 'listbox')] },
{ code: '<select role="listbox" multiple><option>1</option><option>2</option></select>', errors: [expectedError('select', 'listbox')] },
{ code: '<select role="listbox" multiple={true}></select>', errors: [expectedError('select', 'listbox')] },
];

ruleTester.run(`${ruleName}:recommended`, rule, {
Expand Down Expand Up @@ -83,12 +102,16 @@ ruleTester.run(`${ruleName}:recommended (valid list role override)`, rule, {
{ code: '<ul role="list" />' },
{ code: '<ol role="list" />' },
{ code: '<dl role="list" />' },
{ code: '<img src="example.svg" role="img" />' },
{ code: '<svg role="img" />' },
))
.map(ruleOptionsMapperFactory(listException))
.map(parserOptionsMapper),
invalid: parsers.all([].concat(
{ code: '<ul role="list" />', errors: [expectedError('ul', 'list')] },
{ code: '<ol role="list" />', errors: [expectedError('ol', 'list')] },
{ code: '<img role="img" />', errors: [expectedError('img', 'img')] },
{ code: '<img src={someVariable} role="img" />', errors: [expectedError('img', 'img')] },
))
.map(parserOptionsMapper),
});
158 changes: 158 additions & 0 deletions __tests__/src/util/implicitRoles/select-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import test from 'tape';

import JSXAttributeMock from '../../../../__mocks__/JSXAttributeMock';
import getImplicitRoleForSelect from '../../../../src/util/implicitRoles/select';

test('isAbstractRole', (t) => {
t.test('works for combobox', (st) => {
st.equal(
getImplicitRoleForSelect([]),
'combobox',
'defaults to combobox',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', null)]),
'combobox',
'is combobox when multiple attribute is set to not be present',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', undefined)]),
'combobox',
'is combobox when multiple attribute is set to not be present',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', false)]),
'combobox',
'is combobox when multiple attribute is set to boolean false',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', '')]),
'combobox',
'is listbox when multiple attribute is falsey (empty string)',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', '1')]),
'combobox',
'is combobox when size is not greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', 1)]),
'combobox',
'is combobox when size is not greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', 0)]),
'combobox',
'is combobox when size is not greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', '0')]),
'combobox',
'is combobox when size is not greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', '-1')]),
'combobox',
'is combobox when size is not greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', '')]),
'combobox',
'is combobox when size is a valid number',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', 'true')]),
'combobox',
'is combobox when size is a valid number',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', true)]),
'combobox',
'is combobox when size is a valid number',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', NaN)]),
'combobox',
'is combobox when size is a valid number',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', '')]),
'combobox',
'is combobox when size is a valid number',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', undefined)]),
'combobox',
'is combobox when size is a valid number',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', false)]),
'combobox',
'is combobox when size is a valid number',
);

st.end();
});

t.test('works for listbox based on multiple attribute', (st) => {
st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', true)]),
'listbox',
'is listbox when multiple is boolean true',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', 'multiple')]),
'listbox',
'is listbox when multiple is truthy (string)',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('multiple', 'true')]),
'listbox',
'is listbox when multiple is truthy (string) - React will warn about this',
);

st.end();
});

t.test('works for listbox based on size attribute', (st) => {
st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', 2)]),
'listbox',
'is listbox when size is greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', '3')]),
'listbox',
'is listbox when size is greater than 1',
);

st.equal(
getImplicitRoleForSelect([JSXAttributeMock('size', 40)]),
'listbox',
'is listbox when size is greater than 1',
);

st.end();
});

t.end();
});
2 changes: 2 additions & 0 deletions docs/rules/interactive-supports-focus.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

💼 This rule is enabled in the following configs: ☑️ `recommended`, 🔒 `strict`.

💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).

<!-- end auto-generated rule header -->

Elements with an interactive role and interaction handlers (mouse or key press) must be focusable.
Expand Down
4 changes: 2 additions & 2 deletions docs/rules/label-has-associated-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ This rule takes one optional object argument of type object:
}
```

`labelComponents` is a list of custom React Component names that should be checked for an associated control.
`labelComponents` is a list of custom React Component names that should be checked for an associated control. [Glob format](https://linuxhint.com/bash_globbing_tutorial/) is also supported for specifying names (e.g., `Label*` matches `LabelComponent` but not `CustomLabel`, `????Label` matches `LinkLabel` but not `CustomLabel`).

`labelAttributes` is a list of attributes to check on the label component and its children for a label. Use this if you have a custom component that uses a string passed on a prop to render an HTML `label`, for example.

`controlComponents` is a list of custom React Components names that will output an input element. [Glob format](https://linuxhint.com/bash_globbing_tutorial/) is also supported for specifying names (e.g., `Label*` matches `LabelComponent` but not `CustomLabel`, `????Label` matches `LinkLabel` but not `CustomLabel`).
`controlComponents` is a list of custom React Components names that will output an input element. [Glob format](https://linuxhint.com/bash_globbing_tutorial/) is also supported for specifying names (e.g., `Input*` matches `InputCustom` but not `CustomInput`, `????Input` matches `TextInput` but not `CustomInput`).

`assert` asserts that the label has htmlFor, a nested label, both or either. Available options: `'htmlFor', 'nesting', 'both', 'either'`.

Expand Down
1 change: 1 addition & 0 deletions docs/rules/no-redundant-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ General best practice (reference resources)
### Resources

- [ARIA Spec, ARIA Adds Nothing to Default Semantics of Most HTML Elements](https://www.w3.org/TR/using-aria/#aria-does-nothing)
- [Identifying SVG as an image](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#identifying_svg_as_an_image)
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-jsx-a11y",
"version": "6.10.1",
"version": "6.10.2",
"description": "Static AST checker for accessibility rules on JSX elements.",
"keywords": [
"eslint",
Expand Down Expand Up @@ -41,11 +41,11 @@
"postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\""
},
"devDependencies": {
"@babel/cli": "^7.25.7",
"@babel/core": "^7.25.8",
"@babel/eslint-parser": "^7.25.8",
"@babel/plugin-transform-flow-strip-types": "^7.25.7",
"@babel/register": "^7.25.7",
"@babel/cli": "^7.25.9",
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
"@babel/plugin-transform-flow-strip-types": "^7.25.9",
"@babel/register": "^7.25.9",
"auto-changelog": "^2.5.0",
"babel-plugin-add-module-exports": "^1.0.4",
"babel-preset-airbnb": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/rules/aria-props.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
import getSuggestion from '../util/getSuggestion';

const ariaAttributes = [...aria.keys()];
const ariaAttributes = aria.keys();

const errorMessage = (name) => {
const suggestions = getSuggestion(name, ariaAttributes);
Expand Down
Loading
Loading