From 64dcac616d82a3c1184158a717140ebbf2105272 Mon Sep 17 00:00:00 2001 From: Pascal Corpet Date: Fri, 8 Apr 2022 20:53:59 +0200 Subject: [PATCH] [New] Introduce a plugin-wide setting for custom components. For #174 --- README.md | 18 +++ __tests__/__util__/parserOptionsMapper.js | 2 + .../__util__/ruleOptionsMapperFactory.js | 6 +- __tests__/src/rules/accessible-emoji-test.js | 6 + __tests__/src/rules/alt-text-test.js | 10 ++ .../src/rules/anchor-has-content-test.js | 10 ++ __tests__/src/rules/anchor-is-valid-test.js | 17 +- ...aria-activedescendant-has-tabindex-test.js | 9 ++ __tests__/src/rules/aria-role-test.js | 20 +++ .../rules/aria-unsupported-elements-test.js | 6 +- .../src/rules/autocomplete-valid-test.js | 11 ++ .../click-events-have-key-events-test.js | 2 + .../control-has-associated-label-test.js | 2 + .../src/rules/heading-has-content-test.js | 18 +++ __tests__/src/rules/html-has-lang-test.js | 2 + __tests__/src/rules/iframe-has-title-test.js | 10 ++ __tests__/src/rules/img-redundant-alt-test.js | 11 ++ .../rules/interactive-supports-focus-test.js | 17 ++ .../label-has-associated-control-test.js | 19 +++ __tests__/src/rules/lang-test.js | 12 +- __tests__/src/rules/media-has-caption-test.js | 72 +++++++++ __tests__/src/rules/no-autofocus-test.js | 17 ++ .../src/rules/no-distracting-elements-test.js | 9 ++ ...ive-element-to-noninteractive-role-test.js | 14 ++ ...oninteractive-element-interactions-test.js | 11 ++ ...active-element-to-interactive-role-test.js | 14 ++ .../rules/no-noninteractive-tabindex-test.js | 12 ++ __tests__/src/rules/no-onchange-test.js | 12 ++ .../src/rules/no-redundant-roles-test.js | 10 ++ .../no-static-element-interactions-test.js | 12 ++ .../role-has-required-aria-props-test.js | 11 ++ .../rules/role-supports-aria-props-test.js | 14 ++ __tests__/src/rules/scope-test.js | 11 ++ __tests__/src/util/getElementType-test.js | 45 ++++++ __tests__/src/util/hasAccessibleChild-test.js | 39 +++-- .../src/util/mayContainChildComponent-test.js | 23 +++ flow/eslint.js | 8 + src/rules/accessible-emoji.js | 54 ++++--- src/rules/alt-text.js | 15 +- src/rules/anchor-has-content.js | 47 +++--- src/rules/anchor-is-valid.js | 150 +++++++++--------- .../aria-activedescendant-has-tabindex.js | 68 ++++---- src/rules/aria-role.js | 4 +- src/rules/aria-unsupported-elements.js | 58 +++---- src/rules/autocomplete-valid.js | 72 +++++---- src/rules/click-events-have-key-events.js | 70 ++++---- src/rules/control-has-associated-label.js | 4 +- src/rules/heading-has-content.js | 53 ++++--- src/rules/html-has-lang.js | 38 +++-- src/rules/iframe-has-title.js | 38 +++-- src/rules/img-redundant-alt.js | 68 ++++---- src/rules/interactive-supports-focus.js | 103 ++++++------ src/rules/label-has-associated-control.js | 5 +- src/rules/label-has-for.js | 60 +++---- src/rules/lang.js | 68 ++++---- src/rules/media-has-caption.js | 80 +++++----- src/rules/no-autofocus.js | 50 +++--- src/rules/no-distracting-elements.js | 35 ++-- ...eractive-element-to-noninteractive-role.js | 3 +- .../no-noninteractive-element-interactions.js | 3 +- ...interactive-element-to-interactive-role.js | 3 +- src/rules/no-noninteractive-tabindex.js | 3 +- src/rules/no-onchange.js | 44 ++--- src/rules/no-redundant-roles.js | 3 +- src/rules/no-static-element-interactions.js | 3 +- src/rules/role-has-required-aria-props.js | 93 +++++------ src/rules/role-supports-aria-props.js | 81 +++++----- src/rules/scope.js | 56 ++++--- src/util/getElementType.js | 23 +++ src/util/hasAccessibleChild.js | 6 +- src/util/mayContainChildComponent.js | 5 +- 71 files changed, 1317 insertions(+), 691 deletions(-) create mode 100644 __tests__/src/util/getElementType-test.js create mode 100644 src/util/getElementType.js diff --git a/README.md b/README.md index c75e78dad..214dcc7d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/__util__/parserOptionsMapper.js b/__tests__/__util__/parserOptionsMapper.js index c3d2f77bd..74db94a78 100644 --- a/__tests__/__util__/parserOptionsMapper.js +++ b/__tests__/__util__/parserOptionsMapper.js @@ -11,6 +11,7 @@ export default function parserOptionsMapper({ errors, options = [], parserOptions = {}, + settings, }) { return { code, @@ -20,5 +21,6 @@ export default function parserOptionsMapper({ ...defaultParserOptions, ...parserOptions, }, + settings, }; } diff --git a/__tests__/__util__/ruleOptionsMapperFactory.js b/__tests__/__util__/ruleOptionsMapperFactory.js index 7175bf91b..1e1b628b0 100644 --- a/__tests__/__util__/ruleOptionsMapperFactory.js +++ b/__tests__/__util__/ruleOptionsMapperFactory.js @@ -6,7 +6,8 @@ type ESLintTestRunnerTestCase = { code: string, errors: ?Array<{ message: string, type: string }>, options: ?Array, - parserOptions: ?Array + parserOptions: ?Array, + settings?: {[string]: mixed}, }; type RuleOptionsMapperFactoryType = ( @@ -15,7 +16,7 @@ type RuleOptionsMapperFactoryType = ( export default function ruleOptionsMapperFactory(ruleOptions: Array = []): RuleOptionsMapperFactoryType { // eslint-disable-next-line - return ({ code, errors, options, parserOptions }: ESLintTestRunnerTestCase): ESLintTestRunnerTestCase => { + return ({ code, errors, options, parserOptions, settings }: ESLintTestRunnerTestCase): ESLintTestRunnerTestCase => { return { code, errors, @@ -25,6 +26,7 @@ export default function ruleOptionsMapperFactory(ruleOptions: Array = []) ...item, }], [{}]), parserOptions, + settings, }; }; } diff --git a/__tests__/src/rules/accessible-emoji-test.js b/__tests__/src/rules/accessible-emoji-test.js index bcdb42964..dc3d19683 100644 --- a/__tests__/src/rules/accessible-emoji-test.js +++ b/__tests__/src/rules/accessible-emoji-test.js @@ -37,6 +37,11 @@ ruleTester.run('accessible-emoji', rule, { { code: '' }, { code: '🐼' }, { code: '' }, + { code: '🐼' }, + { + code: '🐼', + settings: { 'jsx-a11y': { components: { CustomInput: 'input' } } }, + }, ].map(parserOptionsMapper), invalid: [ { code: '🐼', errors: [expectedError] }, @@ -46,5 +51,6 @@ ruleTester.run('accessible-emoji', rule, { { code: '🐼', errors: [expectedError] }, { code: '🐼', errors: [expectedError] }, { code: '🐼', errors: [expectedError] }, + { code: '🐼', errors: [expectedError] }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/alt-text-test.js b/__tests__/src/rules/alt-text-test.js index 5379376fc..bd2463d73 100644 --- a/__tests__/src/rules/alt-text-test.js +++ b/__tests__/src/rules/alt-text-test.js @@ -42,6 +42,14 @@ const areaError = 'Each area of an image map must have a text alternative throug const inputImageError = ' 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'], @@ -112,6 +120,7 @@ ruleTester.run('alt-text', rule, { { code: '' }, { code: '' }, { code: '' }, + { code: '', settings: componentsSettings }, // CUSTOM ELEMENT TESTS FOR ARRAY OPTION TESTS { code: ';', options: array }, @@ -263,5 +272,6 @@ ruleTester.run('alt-text', rule, { { code: '', errors: [inputImageError], options: array }, { code: 'Foo', errors: [inputImageError], options: array }, { code: '', errors: [inputImageError], options: array }, + { code: '', errors: [inputImageError], settings: componentsSettings }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/anchor-has-content-test.js b/__tests__/src/rules/anchor-has-content-test.js index 77559da31..0935395e0 100644 --- a/__tests__/src/rules/anchor-has-content-test.js +++ b/__tests__/src/rules/anchor-has-content-test.js @@ -31,10 +31,20 @@ ruleTester.run('anchor-has-content', rule, { { code: '{foo.bar}' }, { code: '' }, { code: '' }, + { code: '' }, + { + code: 'foo', + settings: { 'jsx-a11y': { components: { Link: 'a' } } }, + }, ].map(parserOptionsMapper), invalid: [ { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, { code: '{undefined}', errors: [expectedError] }, + { + code: '', + errors: [expectedError], + settings: { 'jsx-a11y': { components: { Link: 'a' } } }, + }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/anchor-is-valid-test.js b/__tests__/src/rules/anchor-is-valid-test.js index fd7f44940..5b368d9e7 100644 --- a/__tests__/src/rules/anchor-is-valid-test.js +++ b/__tests__/src/rules/anchor-is-valid-test.js @@ -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: ';' }, + { code: '' }, { code: '' }, { code: '' }, { code: '' }, @@ -119,6 +128,7 @@ ruleTester.run('anchor-is-valid', rule, { { code: '', options: components }, { code: '', options: components }, { code: '', options: components }, + { code: '', settings: componentsSettings }, // CUSTOM PROP TESTS { code: '', options: specialLink }, @@ -332,6 +342,11 @@ ruleTester.run('anchor-is-valid', rule, { errors: [preferButtonexpectedError], options: components, }, + { + code: ' void 0} />', + errors: [preferButtonexpectedError], + settings: componentsSettings, + }, // CUSTOM PROP TESTS // NO HREF diff --git a/__tests__/src/rules/aria-activedescendant-has-tabindex-test.js b/__tests__/src/rules/aria-activedescendant-has-tabindex-test.js index 917898e80..6c251cf23 100644 --- a/__tests__/src/rules/aria-activedescendant-has-tabindex-test.js +++ b/__tests__/src/rules/aria-activedescendant-has-tabindex-test.js @@ -36,6 +36,10 @@ ruleTester.run('aria-activedescendant-has-tabindex', rule, { { code: ';', }, + { + code: ';', + settings: { 'jsx-a11y': { components: { CustomComponent: 'div' } } }, + }, { code: '
;', }, @@ -81,5 +85,10 @@ ruleTester.run('aria-activedescendant-has-tabindex', rule, { code: '
;', errors: [expectedError], }, + { + code: ';', + errors: [expectedError], + settings: { 'jsx-a11y': { components: { CustomComponent: 'div' } } }, + }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/aria-role-test.js b/__tests__/src/rules/aria-role-test.js index a9cdfeb51..9ccca139d 100644 --- a/__tests__/src/rules/aria-role-test.js +++ b/__tests__/src/rules/aria-role-test.js @@ -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. @@ -66,6 +74,11 @@ ruleTester.run('aria-role', rule, { { code: '', options: ignoreNonDOMSchema }, { code: '', options: ignoreNonDOMSchema }, { code: '', options: ignoreNonDOMSchema }, + { + code: '
', + errors: [errorMessage], + settings: customDivSettings, + }, ].concat(validTests).map(parserOptionsMapper), invalid: [ @@ -82,5 +95,12 @@ ruleTester.run('aria-role', rule, { { code: '
', errors: [errorMessage] }, { code: '', errors: [errorMessage] }, { code: '', errors: [errorMessage] }, + { code: '
', errors: [errorMessage], settings: customDivSettings }, + { + code: '
', + errors: [errorMessage], + options: ignoreNonDOMSchema, + settings: customDivSettings, + }, ].concat(invalidTests).map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/aria-unsupported-elements-test.js b/__tests__/src/rules/aria-unsupported-elements-test.js index d34b517eb..b29a617c5 100644 --- a/__tests__/src/rules/aria-unsupported-elements-test.js +++ b/__tests__/src/rules/aria-unsupported-elements-test.js @@ -54,7 +54,11 @@ const invalidRoleValidityTests = domElements .map((reservedElem) => ({ code: `<${reservedElem} role {...props} />`, errors: [errorMessage('role')], - })); + })).concat({ + code: '', + errors: [errorMessage('aria-hidden')], + settings: { 'jsx-a11y': { components: { Meta: 'meta' } } }, + }); const invalidAriaValidityTests = domElements .filter((element) => Boolean(dom.get(element).reserved)) diff --git a/__tests__/src/rules/autocomplete-valid-test.js b/__tests__/src/rules/autocomplete-valid-test.js index ef731b19c..49f194067 100644 --- a/__tests__/src/rules/autocomplete-valid-test.js +++ b/__tests__/src/rules/autocomplete-valid-test.js @@ -28,6 +28,14 @@ const inappropriateAutocomplete = [{ type: 'JSXOpeningElement', }]; +const componentsSettings = { + 'jsx-a11y': { + components: { + Input: 'input', + }, + }, +}; + ruleTester.run('autocomplete-valid', rule, { valid: [ // INAPPLICABLE @@ -46,6 +54,8 @@ ruleTester.run('autocomplete-valid', rule, { { code: ';' }, { code: ';' }, { code: ';' }, + { code: '', settings: componentsSettings }, + { code: '' }, // PASSED "autocomplete-appropriate" // see also: https://github.com/dequelabs/axe-core/issues/2912 @@ -61,5 +71,6 @@ ruleTester.run('autocomplete-valid', rule, { { code: ';', errors: invalidAutocomplete }, { code: ';', errors: invalidAutocomplete }, { code: ';', errors: invalidAutocomplete, options: [{ inputComponents: ['Bar'] }] }, + { code: '', errors: invalidAutocomplete, settings: componentsSettings }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/click-events-have-key-events-test.js b/__tests__/src/rules/click-events-have-key-events-test.js index 89965a57b..5460504f6 100644 --- a/__tests__/src/rules/click-events-have-key-events-test.js +++ b/__tests__/src/rules/click-events-have-key-events-test.js @@ -51,6 +51,7 @@ ruleTester.run('click-events-have-key-events', rule, { { code: '
void 0} role="none" />;' }, { code: '' }, { code: '' }, { code: '' }, @@ -255,6 +256,7 @@ const neverValid = [ { code: '', errors: [expectedError] }, { code: '', options: [{ depth: 3 }], errors: [expectedError] }, { code: '', options: [{ depth: 3, controlComponents: ['CustomControl'] }], errors: [expectedError] }, + { code: '', errors: [expectedError], settings: { 'jsx-a11y': { components: { CustomControl: 'button' } } } }, { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, diff --git a/__tests__/src/rules/heading-has-content-test.js b/__tests__/src/rules/heading-has-content-test.js index e52930452..a2a201be0 100644 --- a/__tests__/src/rules/heading-has-content-test.js +++ b/__tests__/src/rules/heading-has-content-test.js @@ -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 @@ -51,16 +61,24 @@ ruleTester.run('heading-has-content', rule, { { code: '', options: components }, { code: '', options: components }, { code: '

' }, + // CUSTOM ELEMENT TESTS FOR COMPONENTS SETTINGS + { code: 'Foo', settings: componentsSettings }, + { code: '

' }, ].map(parserOptionsMapper), invalid: [ // DEFAULT ELEMENT TESTS { code: '

', errors: [expectedError] }, { code: '

', errors: [expectedError] }, { code: '

{undefined}

', errors: [expectedError] }, + { code: '

', errors: [expectedError] }, // CUSTOM ELEMENT TESTS FOR COMPONENTS OPTION { code: '', errors: [expectedError], options: components }, { code: '', errors: [expectedError], options: components }, { code: '{undefined}', errors: [expectedError], options: components }, + + // CUSTOM ELEMENT TESTS FOR COMPONENTS SETTINGS + { code: '', errors: [expectedError], settings: componentsSettings }, + { code: '

', errors: [expectedError], settings: componentsSettings }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/html-has-lang-test.js b/__tests__/src/rules/html-has-lang-test.js index 61348f676..ba9e58d0a 100644 --- a/__tests__/src/rules/html-has-lang-test.js +++ b/__tests__/src/rules/html-has-lang-test.js @@ -30,10 +30,12 @@ ruleTester.run('html-has-lang', rule, { { code: '' }, { code: '' }, { code: '' }, + { code: '', errors: [expectedError], settings: { 'jsx-a11y': { components: { HTMLTop: 'html' } } } }, ].map(parserOptionsMapper), invalid: [ { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, + { code: '', errors: [expectedError], settings: { 'jsx-a11y': { components: { HTMLTop: 'html' } } } }, ].map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/iframe-has-title-test.js b/__tests__/src/rules/iframe-has-title-test.js index 195fdba44..716be75e0 100644 --- a/__tests__/src/rules/iframe-has-title-test.js +++ b/__tests__/src/rules/iframe-has-title-test.js @@ -22,12 +22,21 @@ const expectedError = { type: 'JSXOpeningElement', }; +const componentsSettings = { + 'jsx-a11y': { + components: { + FooComponent: 'iframe', + }, + }, +}; + ruleTester.run('html-has-lang', rule, { valid: [ { code: '
;' }, { code: '