Skip to content

Commit

Permalink
[New] Introduce a plugin-wide setting for custom components.
Browse files Browse the repository at this point in the history
For #174
  • Loading branch information
pcorpet committed Apr 8, 2022
1 parent 1826628 commit 64dcac6
Show file tree
Hide file tree
Showing 71 changed files with 1,317 additions and 691 deletions.
18 changes: 18 additions & 0 deletions README.md
Expand Up @@ -89,6 +89,24 @@ Add `plugin:jsx-a11y/recommended` or `plugin:jsx-a11y/strict` in `extends`:
}
```

To enable your custom components to be checked as DOM elements, you can set global settings in your
configuration file by mapping each custom component name to a DOM element type.

```json
{
"settings": {
"jsx-a11y": {
"components": {
"CityInput": "input",
"CustomButton": "button",
"MyButton": "button",
"RoundButton": "button"
}
}
}
}
```

## Supported Rules

<!-- AUTO-GENERATED-CONTENT:START (LIST) -->
Expand Down
2 changes: 2 additions & 0 deletions __tests__/__util__/parserOptionsMapper.js
Expand Up @@ -11,6 +11,7 @@ export default function parserOptionsMapper({
errors,
options = [],
parserOptions = {},
settings,
}) {
return {
code,
Expand All @@ -20,5 +21,6 @@ export default function parserOptionsMapper({
...defaultParserOptions,
...parserOptions,
},
settings,
};
}
6 changes: 4 additions & 2 deletions __tests__/__util__/ruleOptionsMapperFactory.js
Expand Up @@ -6,7 +6,8 @@ type ESLintTestRunnerTestCase = {
code: string,
errors: ?Array<{ message: string, type: string }>,
options: ?Array<mixed>,
parserOptions: ?Array<mixed>
parserOptions: ?Array<mixed>,
settings?: {[string]: mixed},
};

type RuleOptionsMapperFactoryType = (
Expand All @@ -15,7 +16,7 @@ type RuleOptionsMapperFactoryType = (

export default function ruleOptionsMapperFactory(ruleOptions: Array<mixed> = []): RuleOptionsMapperFactoryType {
// eslint-disable-next-line
return ({ code, errors, options, parserOptions }: ESLintTestRunnerTestCase): ESLintTestRunnerTestCase => {
return ({ code, errors, options, parserOptions, settings }: ESLintTestRunnerTestCase): ESLintTestRunnerTestCase => {
return {
code,
errors,
Expand All @@ -25,6 +26,7 @@ export default function ruleOptionsMapperFactory(ruleOptions: Array<mixed> = [])
...item,
}], [{}]),
parserOptions,
settings,
};
};
}
6 changes: 6 additions & 0 deletions __tests__/src/rules/accessible-emoji-test.js
Expand Up @@ -37,6 +37,11 @@ ruleTester.run('accessible-emoji', rule, {
{ code: '<span aria-hidden="true">🐼</span>' },
{ code: '<span aria-hidden>🐼</span>' },
{ code: '<div aria-hidden="true">🐼</div>' },
{ code: '<input type="hidden">🐼</input>' },
{
code: '<CustomInput type="hidden">🐼</CustomInput>',
settings: { 'jsx-a11y': { components: { CustomInput: 'input' } } },
},
].map(parserOptionsMapper),
invalid: [
{ code: '<span>🐼</span>', errors: [expectedError] },
Expand All @@ -46,5 +51,6 @@ ruleTester.run('accessible-emoji', rule, {
{ code: '<i role="img" aria-labelledby="id1">🐼</i>', errors: [expectedError] },
{ code: '<Foo>🐼</Foo>', errors: [expectedError] },
{ code: '<span aria-hidden="false">🐼</span>', errors: [expectedError] },
{ code: '<CustomInput type="hidden">🐼</CustomInput>', errors: [expectedError] },
].map(parserOptionsMapper),
});
10 changes: 10 additions & 0 deletions __tests__/src/rules/alt-text-test.js
Expand Up @@ -42,6 +42,14 @@ const areaError = 'Each area of an image map must have a text alternative throug

const inputImageError = '<input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop.';

const componentsSettings = {
'jsx-a11y': {
components: {
Input: 'input',
},
},
};

const array = [{
img: ['Thumbnail', 'Image'],
object: ['Object'],
Expand Down Expand Up @@ -112,6 +120,7 @@ ruleTester.run('alt-text', rule, {
{ code: '<input type="image" alt="This is descriptive!" />' },
{ code: '<input type="image" alt={altText} />' },
{ code: '<InputImage />' },
{ code: '<Input type="image" alt="" />', settings: componentsSettings },

// CUSTOM ELEMENT TESTS FOR ARRAY OPTION TESTS
{ code: '<Thumbnail alt="foo" />;', options: array },
Expand Down Expand Up @@ -263,5 +272,6 @@ ruleTester.run('alt-text', rule, {
{ code: '<InputImage alt={undefined} />', errors: [inputImageError], options: array },
{ code: '<InputImage>Foo</InputImage>', errors: [inputImageError], options: array },
{ code: '<InputImage {...this.props} />', errors: [inputImageError], options: array },
{ code: '<Input type="image" />', errors: [inputImageError], settings: componentsSettings },
].map(parserOptionsMapper),
});
10 changes: 10 additions & 0 deletions __tests__/src/rules/anchor-has-content-test.js
Expand Up @@ -31,10 +31,20 @@ ruleTester.run('anchor-has-content', rule, {
{ code: '<a>{foo.bar}</a>' },
{ code: '<a dangerouslySetInnerHTML={{ __html: "foo" }} />' },
{ code: '<a children={children} />' },
{ code: '<Link />' },
{
code: '<Link>foo</Link>',
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
},
].map(parserOptionsMapper),
invalid: [
{ code: '<a />', errors: [expectedError] },
{ code: '<a><Bar aria-hidden /></a>', errors: [expectedError] },
{ code: '<a>{undefined}</a>', errors: [expectedError] },
{
code: '<Link />',
errors: [expectedError],
settings: { 'jsx-a11y': { components: { Link: 'a' } } },
},
].map(parserOptionsMapper),
});
17 changes: 16 additions & 1 deletion __tests__/src/rules/anchor-is-valid-test.js
Expand Up @@ -78,10 +78,19 @@ const componentsAndSpecialLinkAndNoHrefAspect = [{
aspects: ['noHref'],
}];

const componentsSettings = {
'jsx-a11y': {
components: {
Anchor: 'a',
Link: 'a',
},
},
};

ruleTester.run('anchor-is-valid', rule, {
valid: [
// DEFAULT ELEMENT 'a' TESTS
{ code: '<Anchor />;' },
{ code: '<Anchor />' },
{ code: '<a {...props} />' },
{ code: '<a href="foo" />' },
{ code: '<a href={foo} />' },
Expand Down Expand Up @@ -119,6 +128,7 @@ ruleTester.run('anchor-is-valid', rule, {
{ code: '<Link href={`#foo`}/>', options: components },
{ code: '<Link href={"foo"}/>', options: components },
{ code: '<Link href="#foo" />', options: components },
{ code: '<Link href="#foo" />', settings: componentsSettings },

// CUSTOM PROP TESTS
{ code: '<a {...props} />', options: specialLink },
Expand Down Expand Up @@ -332,6 +342,11 @@ ruleTester.run('anchor-is-valid', rule, {
errors: [preferButtonexpectedError],
options: components,
},
{
code: '<Link href="#" onClick={() => void 0} />',
errors: [preferButtonexpectedError],
settings: componentsSettings,
},

// CUSTOM PROP TESTS
// NO HREF
Expand Down
Expand Up @@ -36,6 +36,10 @@ ruleTester.run('aria-activedescendant-has-tabindex', rule, {
{
code: '<CustomComponent aria-activedescendant={someID} tabIndex={-1} />;',
},
{
code: '<CustomComponent aria-activedescendant={someID} tabIndex={0} />;',
settings: { 'jsx-a11y': { components: { CustomComponent: 'div' } } },
},
{
code: '<div />;',
},
Expand Down Expand Up @@ -81,5 +85,10 @@ ruleTester.run('aria-activedescendant-has-tabindex', rule, {
code: '<div aria-activedescendant={someID} />;',
errors: [expectedError],
},
{
code: '<CustomComponent aria-activedescendant={someID} />;',
errors: [expectedError],
settings: { 'jsx-a11y': { components: { CustomComponent: 'div' } } },
},
].map(parserOptionsMapper),
});
20 changes: 20 additions & 0 deletions __tests__/src/rules/aria-role-test.js
Expand Up @@ -47,6 +47,14 @@ const ignoreNonDOMSchema = [{
ignoreNonDOM: true,
}];

const customDivSettings = {
'jsx-a11y': {
components: {
Div: 'div',
},
},
};

ruleTester.run('aria-role', rule, {
valid: [
// Variables should pass, as we are only testing literals.
Expand All @@ -66,6 +74,11 @@ ruleTester.run('aria-role', rule, {
{ code: '<Foo role="bar" />', options: ignoreNonDOMSchema },
{ code: '<fakeDOM role="bar" />', options: ignoreNonDOMSchema },
{ code: '<img role="presentation" />', options: ignoreNonDOMSchema },
{
code: '<Div role="button" />',
errors: [errorMessage],
settings: customDivSettings,
},
].concat(validTests).map(parserOptionsMapper),

invalid: [
Expand All @@ -82,5 +95,12 @@ ruleTester.run('aria-role', rule, {
{ code: '<div role={null}></div>', errors: [errorMessage] },
{ code: '<Foo role="datepicker" />', errors: [errorMessage] },
{ code: '<Foo role="Button" />', errors: [errorMessage] },
{ code: '<Div role="Button" />', errors: [errorMessage], settings: customDivSettings },
{
code: '<Div role="Button" />',
errors: [errorMessage],
options: ignoreNonDOMSchema,
settings: customDivSettings,
},
].concat(invalidTests).map(parserOptionsMapper),
});
6 changes: 5 additions & 1 deletion __tests__/src/rules/aria-unsupported-elements-test.js
Expand Up @@ -54,7 +54,11 @@ const invalidRoleValidityTests = domElements
.map((reservedElem) => ({
code: `<${reservedElem} role {...props} />`,
errors: [errorMessage('role')],
}));
})).concat({
code: '<Meta aria-hidden />',
errors: [errorMessage('aria-hidden')],
settings: { 'jsx-a11y': { components: { Meta: 'meta' } } },
});

const invalidAriaValidityTests = domElements
.filter((element) => Boolean(dom.get(element).reserved))
Expand Down
11 changes: 11 additions & 0 deletions __tests__/src/rules/autocomplete-valid-test.js
Expand Up @@ -28,6 +28,14 @@ const inappropriateAutocomplete = [{
type: 'JSXOpeningElement',
}];

const componentsSettings = {
'jsx-a11y': {
components: {
Input: 'input',
},
},
};

ruleTester.run('autocomplete-valid', rule, {
valid: [
// INAPPLICABLE
Expand All @@ -46,6 +54,8 @@ ruleTester.run('autocomplete-valid', rule, {
{ code: '<input type="text" autocomplete={autocompl || "foo"} />;' },
{ code: '<Foo autocomplete="bar"></Foo>;' },
{ code: '<input type={isEmail ? "email" : "text"} autocomplete="none" />;' },
{ code: '<Input type="text" autocomplete="name" />', settings: componentsSettings },
{ code: '<Input type="text" autocomplete="baz" />' },

// PASSED "autocomplete-appropriate"
// see also: https://github.com/dequelabs/axe-core/issues/2912
Expand All @@ -61,5 +71,6 @@ ruleTester.run('autocomplete-valid', rule, {
{ code: '<input type="text" autocomplete="invalid name" />;', errors: invalidAutocomplete },
{ code: '<input type="text" autocomplete="home url" />;', errors: invalidAutocomplete },
{ code: '<Bar autocomplete="baz"></Bar>;', errors: invalidAutocomplete, options: [{ inputComponents: ['Bar'] }] },
{ code: '<Input type="text" autocomplete="baz" />', errors: invalidAutocomplete, settings: componentsSettings },
].map(parserOptionsMapper),
});
2 changes: 2 additions & 0 deletions __tests__/src/rules/click-events-have-key-events-test.js
Expand Up @@ -51,6 +51,7 @@ ruleTester.run('click-events-have-key-events', rule, {
{ code: '<div onClick={() => void 0} role="none" />;' },
{ code: '<TestComponent onClick={doFoo} />' },
{ code: '<Button onClick={doFoo} />' },
{ code: '<Footer onClick={doFoo} />' },
].map(parserOptionsMapper),
invalid: [
{ code: '<div onClick={() => void 0} />;', errors: [expectedError] },
Expand All @@ -70,5 +71,6 @@ ruleTester.run('click-events-have-key-events', rule, {
},
{ code: '<a onClick={() => void 0} />', errors: [expectedError] },
{ code: '<a tabIndex="0" onClick={() => void 0} />', errors: [expectedError] },
{ code: '<Footer onClick={doFoo} />', errors: [expectedError], settings: { 'jsx-a11y': { components: { Footer: 'footer' } } } },
].map(parserOptionsMapper),
});
2 changes: 2 additions & 0 deletions __tests__/src/rules/control-has-associated-label-test.js
Expand Up @@ -30,6 +30,7 @@ const alwaysValid = [
// Custom Control Components
{ code: '<CustomControl><span><span>Save</span></span></CustomControl>', options: [{ depth: 3, controlComponents: ['CustomControl'] }] },
{ code: '<CustomControl><span><span label="Save"></span></span></CustomControl>', options: [{ depth: 3, controlComponents: ['CustomControl'], labelAttributes: ['label'] }] },
{ code: '<CustomControl>Save</CustomControl>', settings: { 'jsx-a11y': { components: { CustomControl: 'button' } } } },
// Interactive Elements
{ code: '<button>Save</button>' },
{ code: '<button><span>Save</span></button>' },
Expand Down Expand Up @@ -255,6 +256,7 @@ const neverValid = [
{ code: '<button><span title="This is not a real label" /></button>', errors: [expectedError] },
{ code: '<button><span><span><span>Save</span></span></span></button>', options: [{ depth: 3 }], errors: [expectedError] },
{ code: '<CustomControl><span><span></span></span></CustomControl>', options: [{ depth: 3, controlComponents: ['CustomControl'] }], errors: [expectedError] },
{ code: '<CustomControl></CustomControl>', errors: [expectedError], settings: { 'jsx-a11y': { components: { CustomControl: 'button' } } } },
{ code: '<a href="#" />', errors: [expectedError] },
{ code: '<area href="#" />', errors: [expectedError] },
{ code: '<menuitem />', errors: [expectedError] },
Expand Down
18 changes: 18 additions & 0 deletions __tests__/src/rules/heading-has-content-test.js
Expand Up @@ -26,6 +26,16 @@ const components = [{
components: ['Heading', 'Title'],
}];

const componentsSettings = {
'jsx-a11y': {
components: {
CustomInput: 'input',
Title: 'h1',
Heading: 'h2',
},
},
};

ruleTester.run('heading-has-content', rule, {
valid: [
// DEFAULT ELEMENT TESTS
Expand All @@ -51,16 +61,24 @@ ruleTester.run('heading-has-content', rule, {
{ code: '<Heading dangerouslySetInnerHTML={{ __html: "foo" }} />', options: components },
{ code: '<Heading children={children} />', options: components },
{ code: '<h1 aria-hidden />' },
// CUSTOM ELEMENT TESTS FOR COMPONENTS SETTINGS
{ code: '<Heading>Foo</Heading>', settings: componentsSettings },
{ code: '<h1><CustomInput type="hidden" /></h1>' },
].map(parserOptionsMapper),
invalid: [
// DEFAULT ELEMENT TESTS
{ code: '<h1 />', errors: [expectedError] },
{ code: '<h1><Bar aria-hidden /></h1>', errors: [expectedError] },
{ code: '<h1>{undefined}</h1>', errors: [expectedError] },
{ code: '<h1><input type="hidden" /></h1>', errors: [expectedError] },

// CUSTOM ELEMENT TESTS FOR COMPONENTS OPTION
{ code: '<Heading />', errors: [expectedError], options: components },
{ code: '<Heading><Bar aria-hidden /></Heading>', errors: [expectedError], options: components },
{ code: '<Heading>{undefined}</Heading>', errors: [expectedError], options: components },

// CUSTOM ELEMENT TESTS FOR COMPONENTS SETTINGS
{ code: '<Heading />', errors: [expectedError], settings: componentsSettings },
{ code: '<h1><CustomInput type="hidden" /></h1>', errors: [expectedError], settings: componentsSettings },
].map(parserOptionsMapper),
});
2 changes: 2 additions & 0 deletions __tests__/src/rules/html-has-lang-test.js
Expand Up @@ -30,10 +30,12 @@ ruleTester.run('html-has-lang', rule, {
{ code: '<html lang={foo} />' },
{ code: '<html lang />' },
{ code: '<HTML />' },
{ code: '<HTMLTop lang="en" />', errors: [expectedError], settings: { 'jsx-a11y': { components: { HTMLTop: 'html' } } } },
].map(parserOptionsMapper),
invalid: [
{ code: '<html />', errors: [expectedError] },
{ code: '<html {...props} />', errors: [expectedError] },
{ code: '<html lang={undefined} />', errors: [expectedError] },
{ code: '<HTMLTop />', errors: [expectedError], settings: { 'jsx-a11y': { components: { HTMLTop: 'html' } } } },
].map(parserOptionsMapper),
});
10 changes: 10 additions & 0 deletions __tests__/src/rules/iframe-has-title-test.js
Expand Up @@ -22,12 +22,21 @@ const expectedError = {
type: 'JSXOpeningElement',
};

const componentsSettings = {
'jsx-a11y': {
components: {
FooComponent: 'iframe',
},
},
};

ruleTester.run('html-has-lang', rule, {
valid: [
{ code: '<div />;' },
{ code: '<iframe title="Unique title" />' },
{ code: '<iframe title={foo} />' },
{ code: '<FooComponent />' },
{ code: '<FooComponent title="Unique title" />', settings: componentsSettings },
].map(parserOptionsMapper),
invalid: [
{ code: '<iframe />', errors: [expectedError] },
Expand All @@ -40,5 +49,6 @@ ruleTester.run('html-has-lang', rule, {
{ code: '<iframe title={``} />', errors: [expectedError] },
{ code: '<iframe title={""} />', errors: [expectedError] },
{ code: '<iframe title={42} />', errors: [expectedError] },
{ code: '<FooComponent />', errors: [expectedError], settings: componentsSettings },
].map(parserOptionsMapper),
});

0 comments on commit 64dcac6

Please sign in to comment.