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

[feature] Improve input implicit roles #1028

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
15 changes: 15 additions & 0 deletions __tests__/src/rules/no-redundant-roles-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,27 @@ 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: '<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 = [
{ code: '<button role="button" />', errors: [expectedError('button', 'button')] },
{ code: '<body role="DOCUMENT" />', errors: [expectedError('body', 'document')] },
{ code: '<Button role="button" />', settings: componentsSettings, errors: [expectedError('button', 'button')] },
{ code: '<select role="combobox" size />', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox" size={1} />', errors: [expectedError('select', 'combobox')] },
{ code: '<select role="combobox"><option>1</option><option>2</option></select>', errors: [expectedError('select', 'combobox')] },
{ 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';
}
17 changes: 14 additions & 3 deletions src/util/implicitRoles/select.js
Original file line number Diff line number Diff line change
@@ -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';
}