Skip to content

Commit

Permalink
[feature] Improve implicit roles for input elements
Browse files Browse the repository at this point in the history
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
  • Loading branch information
lb- committed Oct 23, 2024
1 parent c1e47c1 commit ee9f809
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 12 deletions.
8 changes: 8 additions & 0 deletions __tests__/src/rules/no-redundant-roles-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const alwaysValid = [
{ code: '<button role={`${foo}button`} />' },
{ code: '<Button role={`${foo}button`} />', settings: componentsSettings },
{ code: '<select role="menu"><option>1</option><option>2</option></select>' },
{ code: '<input type="number" role="textbox" />' },
{ code: '<input type="search" role="textbox" />' },
{ code: '<input type="search" list="example" role="searchbox" />' },
{ code: '<input type="email" list="example" role="textbox" />' },
];

const neverValid = [
Expand All @@ -54,6 +58,10 @@ const neverValid = [
{ 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: '<input type="number" role="spinbutton" />', errors: [expectedError('input', 'spinbutton')] },
{ code: '<input type="search" role="searchbox" />', errors: [expectedError('input', 'searchbox')] },
{ code: '<input type="search" list="example" role="combobox" />', errors: [expectedError('input', 'combobox')] },
{ code: '<input type="email" list="example" role="combobox" />', errors: [expectedError('input', 'combobox')] },
];

ruleTester.run(`${ruleName}:recommended`, rule, {
Expand Down
19 changes: 18 additions & 1 deletion __tests__/src/rules/role-supports-aria-props-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,15 +367,24 @@ ruleTester.run('role-supports-aria-props', rule, {
{ code: '<input type="range" aria-owns />' },
{ code: '<input type="range" aria-relevant />' },
{ code: '<input type="range" aria-valuetext />' },
// when `type="number"`, the implicit role is `spinbutton`
{ code: '<input type="number" aria-valuemax={12} />' },
{ code: '<input type="number" aria-valuemin={0} />' },

// these will have role of `textbox`,
{ code: '<input type="email" aria-disabled />' },
{ code: '<input type="password" aria-disabled />' },
{ code: '<input type="search" aria-disabled />' },
{ code: '<input type="tel" aria-disabled />' },
{ code: '<input type="url" aria-disabled />' },
{ code: '<input aria-disabled />' },

// when `type="search"`, the implicit role is `searchbox`
{ code: '<input type="search" aria-disabled />' },

// when list attribute is present, the implicit role is `combobox`
{ code: '<input type="search" list="example" aria-expanded />' },
{ code: '<input type="email" list="example" aria-expanded />' },

// Allow null/undefined values regardless of role
{ code: '<h2 role="presentation" aria-level={null} />' },
{ code: '<h2 role="presentation" aria-level={undefined} />' },
Expand Down Expand Up @@ -534,6 +543,14 @@ ruleTester.run('role-supports-aria-props', rule, {
code: '<input type="button" aria-invalid />',
errors: [errorMessage('aria-invalid', 'button', 'input', true)],
},
{
code: '<input type="number" aria-autocomplete />',
errors: [errorMessage('aria-autocomplete', 'spinbutton', 'input', true)],
},
{
code: '<input type="search" aria-expanded />',
errors: [errorMessage('aria-expanded', 'searchbox', 'input', true)],
},
{
code: '<menuitem type="command" aria-invalid />',
errors: [errorMessage('aria-invalid', 'menuitem', 'menuitem', true)],
Expand Down
118 changes: 113 additions & 5 deletions __tests__/src/util/implicitRoles/input-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')]),
Expand Down Expand Up @@ -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(
Expand All @@ -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',
Expand Down
31 changes: 25 additions & 6 deletions src/util/implicitRoles/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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';
}

0 comments on commit ee9f809

Please sign in to comment.