From c1e47c1be9ad02bd86dc15c251bfa5cd4784e375 Mon Sep 17 00:00:00 2001
From: LB
Date: Wed, 23 Oct 2024 14:48:47 +1000
Subject: [PATCH 1/2] [fix] Refine implicit role of `select` to include
`combobox` scenarios
Encode implicit roles for `select` elements based on roles defined in https://www.w3.org/TR/html-aria/#el-select
- `select` (with a multiple attribute or a size attribute having value greater than 1) will have the implicit role 'listbox'
- `select` (with NO multiple attribute and NO size attribute having value greater than 1) will have the implicit role 'combobox'
Fixes #949
---
__tests__/src/rules/no-redundant-roles-test.js | 7 +++++++
src/util/implicitRoles/select.js | 17 ++++++++++++++---
2 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/__tests__/src/rules/no-redundant-roles-test.js b/__tests__/src/rules/no-redundant-roles-test.js
index 60cc2185e..bdcb57bb8 100644
--- a/__tests__/src/rules/no-redundant-roles-test.js
+++ b/__tests__/src/rules/no-redundant-roles-test.js
@@ -41,12 +41,19 @@ const alwaysValid = [
{ code: '' },
{ code: '' },
{ code: '', settings: componentsSettings },
+ { code: '' },
];
const neverValid = [
{ code: '', errors: [expectedError('button', 'button')] },
{ code: '', errors: [expectedError('body', 'document')] },
{ code: '', settings: componentsSettings, errors: [expectedError('button', 'button')] },
+ { code: '', errors: [expectedError('select', 'combobox')] },
+ { code: '', errors: [expectedError('select', 'combobox')] },
+ { code: '', errors: [expectedError('select', 'combobox')] },
+ { code: '', errors: [expectedError('select', 'listbox')] },
+ { code: '', errors: [expectedError('select', 'listbox')] },
+ { code: '', errors: [expectedError('select', 'listbox')] },
];
ruleTester.run(`${ruleName}:recommended`, rule, {
diff --git a/src/util/implicitRoles/select.js b/src/util/implicitRoles/select.js
index 9f23296c0..51fb48483 100644
--- a/src/util/implicitRoles/select.js
+++ b/src/util/implicitRoles/select.js
@@ -1,6 +1,17 @@
+import { getProp, getLiteralPropValue } from 'jsx-ast-utils';
+
/**
- * Returns the implicit role for a select tag.
+ * Returns the implicit role for a select tag depending on attributes.
+ *
+ * @see https://www.w3.org/TR/html-aria/#el-select
*/
-export default function getImplicitRoleForSelect() {
- return 'listbox';
+export default function getImplicitRoleForSelect(attributes) {
+ const multiple = getProp(attributes, 'multiple');
+ if (multiple) return 'listbox';
+
+ const size = getProp(attributes, 'size');
+ const sizeValue = size && getLiteralPropValue(size);
+ if (sizeValue && (Number(sizeValue) > 1)) return 'listbox';
+
+ return 'combobox';
}
From ee9f8095f23c6c5ee68863bfaceda777ea3e90d6 Mon Sep 17 00:00:00 2001
From: LB
Date: Wed, 23 Oct 2024 15:08:42 +1000
Subject: [PATCH 2/2] [feature] Improve implicit roles for `input` elements
Adopt the latest ARIA standards (1.2) for input elements' implicit roles.
- Add support for input type="number" with implicit role "spinbutton"
- Add better support for `input` types including checks for when list attribute is used
- Correctly set role of search input type to searchbox (or combobox for list)
- Ensure that input types with no corresponding role are correctly catered for & tested
See https://www.w3.org/TR/html-aria/#el-input-text-list
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#technical_summary
Fixes #686
---
.../src/rules/no-redundant-roles-test.js | 8 ++
.../rules/role-supports-aria-props-test.js | 19 ++-
.../src/util/implicitRoles/input-test.js | 118 +++++++++++++++++-
src/util/implicitRoles/input.js | 31 ++++-
4 files changed, 164 insertions(+), 12 deletions(-)
diff --git a/__tests__/src/rules/no-redundant-roles-test.js b/__tests__/src/rules/no-redundant-roles-test.js
index bdcb57bb8..76661925a 100644
--- a/__tests__/src/rules/no-redundant-roles-test.js
+++ b/__tests__/src/rules/no-redundant-roles-test.js
@@ -42,6 +42,10 @@ const alwaysValid = [
{ code: '' },
{ code: '', settings: componentsSettings },
{ code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
];
const neverValid = [
@@ -54,6 +58,10 @@ const neverValid = [
{ code: '', errors: [expectedError('select', 'listbox')] },
{ code: '', errors: [expectedError('select', 'listbox')] },
{ code: '', errors: [expectedError('select', 'listbox')] },
+ { code: '', errors: [expectedError('input', 'spinbutton')] },
+ { code: '', errors: [expectedError('input', 'searchbox')] },
+ { code: '', errors: [expectedError('input', 'combobox')] },
+ { code: '', errors: [expectedError('input', 'combobox')] },
];
ruleTester.run(`${ruleName}:recommended`, rule, {
diff --git a/__tests__/src/rules/role-supports-aria-props-test.js b/__tests__/src/rules/role-supports-aria-props-test.js
index 339b94054..971350a13 100644
--- a/__tests__/src/rules/role-supports-aria-props-test.js
+++ b/__tests__/src/rules/role-supports-aria-props-test.js
@@ -367,15 +367,24 @@ ruleTester.run('role-supports-aria-props', rule, {
{ code: '' },
{ code: '' },
{ code: '' },
+ // when `type="number"`, the implicit role is `spinbutton`
+ { code: '' },
+ { code: '' },
// these will have role of `textbox`,
{ code: '' },
{ code: '' },
- { code: '' },
{ code: '' },
{ code: '' },
{ code: '' },
+ // when `type="search"`, the implicit role is `searchbox`
+ { code: '' },
+
+ // when list attribute is present, the implicit role is `combobox`
+ { code: '' },
+ { code: '' },
+
// Allow null/undefined values regardless of role
{ code: '' },
{ code: '' },
@@ -534,6 +543,14 @@ ruleTester.run('role-supports-aria-props', rule, {
code: '',
errors: [errorMessage('aria-invalid', 'button', 'input', true)],
},
+ {
+ code: '',
+ errors: [errorMessage('aria-autocomplete', 'spinbutton', 'input', true)],
+ },
+ {
+ code: '',
+ errors: [errorMessage('aria-expanded', 'searchbox', 'input', true)],
+ },
{
code: '',
errors: [errorMessage('aria-invalid', 'menuitem', 'menuitem', true)],
diff --git a/__tests__/src/util/implicitRoles/input-test.js b/__tests__/src/util/implicitRoles/input-test.js
index a0c4277f6..12a8cd7ee 100644
--- a/__tests__/src/util/implicitRoles/input-test.js
+++ b/__tests__/src/util/implicitRoles/input-test.js
@@ -4,6 +4,55 @@ import JSXAttributeMock from '../../../../__mocks__/JSXAttributeMock';
import getImplicitRoleForInput from '../../../../src/util/implicitRoles/input';
test('isAbstractRole', (t) => {
+ t.test('works for inputs with no corresponding role', (st) => {
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'color')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'date')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'datetime-local')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'file')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'hidden')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'month')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'password')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'time')]),
+ '',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'week')]),
+ '',
+ );
+
+ st.end();
+ });
+
t.test('works for buttons', (st) => {
st.equal(
getImplicitRoleForInput([JSXAttributeMock('type', 'button')]),
@@ -46,17 +95,25 @@ test('isAbstractRole', (t) => {
'works for ranges',
);
+ t.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'number')]),
+ 'spinbutton',
+ 'works for number inputs',
+ );
+
+ t.equal(
+ getImplicitRoleForInput([JSXAttributeMock('type', 'search')]),
+ 'searchbox',
+ 'works for search inputs',
+ );
+
t.test('works for textboxes', (st) => {
st.equal(
getImplicitRoleForInput([JSXAttributeMock('type', 'email')]),
'textbox',
);
st.equal(
- getImplicitRoleForInput([JSXAttributeMock('type', 'password')]),
- 'textbox',
- );
- st.equal(
- getImplicitRoleForInput([JSXAttributeMock('type', 'search')]),
+ getImplicitRoleForInput([JSXAttributeMock('type', 'text')]),
'textbox',
);
st.equal(
@@ -71,6 +128,57 @@ test('isAbstractRole', (t) => {
st.end();
});
+ t.test('works for inputs with list attribute', (st) => {
+ st.equal(
+ getImplicitRoleForInput([
+ JSXAttributeMock('type', 'search'),
+ JSXAttributeMock('list', 'example'),
+ ]),
+ 'combobox',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([
+ JSXAttributeMock('type', 'email'),
+ JSXAttributeMock('list', 'example'),
+ ]),
+ 'combobox',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([
+ JSXAttributeMock('type', 'tel'),
+ JSXAttributeMock('list', 'example'),
+ ]),
+ 'combobox',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([
+ JSXAttributeMock('type', 'url'),
+ JSXAttributeMock('list', 'example'),
+ ]),
+ 'combobox',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([
+ JSXAttributeMock('type', 'invalid'),
+ JSXAttributeMock('list', 'example'),
+ ]),
+ 'combobox',
+ );
+
+ st.equal(
+ getImplicitRoleForInput([
+ JSXAttributeMock('list', 'example'),
+ ]),
+ 'combobox',
+ );
+
+ st.end();
+ });
+
t.equal(
getImplicitRoleForInput([JSXAttributeMock('type', '')]),
'textbox',
diff --git a/src/util/implicitRoles/input.js b/src/util/implicitRoles/input.js
index bd2b452c1..7865b4de6 100644
--- a/src/util/implicitRoles/input.js
+++ b/src/util/implicitRoles/input.js
@@ -2,14 +2,30 @@ import { getProp, getLiteralPropValue } from 'jsx-ast-utils';
/**
* Returns the implicit role for an input tag.
+ *
+ * @see https://www.w3.org/TR/html-aria/#el-input-text-list
+ * `input` with type = text, search, tel, url, email, or with a missing or invalid type
+ * with a list attribute will have an implicit role=combobox.
*/
export default function getImplicitRoleForInput(attributes) {
const type = getProp(attributes, 'type');
+ const hasListAttribute = !!getProp(attributes, 'list');
if (type) {
const value = getLiteralPropValue(type) || '';
switch (typeof value === 'string' && value.toUpperCase()) {
+ case 'COLOR':
+ case 'DATE':
+ case 'DATETIME-LOCAL':
+ case 'FILE':
+ case 'HIDDEN':
+ case 'MONTH':
+ case 'PASSWORD':
+ case 'TIME':
+ case 'WEEK':
+ /** No corresponding role */
+ return '';
case 'BUTTON':
case 'IMAGE':
case 'RESET':
@@ -21,15 +37,18 @@ export default function getImplicitRoleForInput(attributes) {
return 'radio';
case 'RANGE':
return 'slider';
+ case 'NUMBER':
+ return 'spinbutton';
+ case 'SEARCH':
+ return hasListAttribute ? 'combobox' : 'searchbox';
case 'EMAIL':
- case 'PASSWORD':
- case 'SEARCH': // with [list] selector it's combobox
- case 'TEL': // with [list] selector it's combobox
- case 'URL': // with [list] selector it's combobox
+ case 'TEL':
+ case 'TEXT':
+ case 'URL':
default:
- return 'textbox';
+ return hasListAttribute ? 'combobox' : 'textbox';
}
}
- return 'textbox';
+ return hasListAttribute ? 'combobox' : 'textbox';
}