diff --git a/README.md b/README.md index 9e0d5d3ae..02f1ed5c0 100644 --- a/README.md +++ b/README.md @@ -89,15 +89,15 @@ Add `plugin:jsx-a11y/recommended` or `plugin:jsx-a11y/strict` in `extends`: } ``` -> As you are extending our configuration, you can omit `"plugins": ["jsx-a11y"]` from your `.eslintrc` configuration file. +### Configurations -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. +> As you are extending our configuration, you can omit `"plugins": ["jsx-a11y"]` from your `.eslintrc` configuration file. ```json { "settings": { "jsx-a11y": { + "polymorphicPropName": "as", "components": { "CityInput": "input", "CustomButton": "button", @@ -109,6 +109,23 @@ configuration file by mapping each custom component name to a DOM element type. } ``` +#### Component Mapping + +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. + +#### Polymorphic Components + +You can optionally use the `polymorphicPropName` setting to define the prop your code uses to create polymorphic components. +This setting will be used determine the element type in rules that require semantic context. + +For example, if you set the `polymorphicPropName` setting to `as` then this element: + +`Configurations ` + +will be evaluated as an `h3`. If no `polymorphicPropName` is set, then the component will be evaluated as `Box`. + +⚠️ Polymorphic components can make code harder to maintain; please use this feature with caution. + ## Supported Rules diff --git a/__tests__/src/rules/accessible-emoji-test.js b/__tests__/src/rules/accessible-emoji-test.js index a1d21f461..fc6e95a10 100644 --- a/__tests__/src/rules/accessible-emoji-test.js +++ b/__tests__/src/rules/accessible-emoji-test.js @@ -43,6 +43,10 @@ ruleTester.run('accessible-emoji', rule, { code: '🐼', settings: { 'jsx-a11y': { components: { CustomInput: 'input' } } }, }, + { + code: '🐼', + settings: { 'jsx-a11y': { polymorphicPropName: 'as' } }, + }, )).map(parserOptionsMapper), invalid: parsers.all([].concat( { code: '🐼', errors: [expectedError] }, @@ -53,5 +57,10 @@ ruleTester.run('accessible-emoji', rule, { { code: '🐼', errors: [expectedError] }, { code: '🐼', errors: [expectedError] }, { code: '🐼', errors: [expectedError] }, + { + code: '🐼', + settings: { 'jsx-a11y': { polymorphicPropName: 'as' } }, + errors: [expectedError], + }, )).map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/alt-text-test.js b/__tests__/src/rules/alt-text-test.js index 07b2c8259..24e36e23d 100644 --- a/__tests__/src/rules/alt-text-test.js +++ b/__tests__/src/rules/alt-text-test.js @@ -55,6 +55,7 @@ const inputImageError = { const componentsSettings = { 'jsx-a11y': { + polymorphicPropName: 'as', components: { Input: 'input', }, @@ -132,6 +133,7 @@ ruleTester.run('alt-text', rule, { { code: '' }, { code: '' }, { code: '', settings: componentsSettings }, + { code: '', settings: componentsSettings }, // CUSTOM ELEMENT TESTS FOR ARRAY OPTION TESTS { code: ';', options: array }, @@ -195,6 +197,7 @@ ruleTester.run('alt-text', rule, { { code: '', errors: [ariaLabelledbyValueError] }, { code: '', errors: [ariaLabelValueError] }, { code: '', errors: [ariaLabelledbyValueError] }, + { code: '', settings: componentsSettings, errors: [ariaLabelValueError] }, // DEFAULT ELEMENT 'object' TESTS { code: '', errors: [objectError] }, diff --git a/__tests__/src/rules/aria-role-test.js b/__tests__/src/rules/aria-role-test.js index 410749666..9a8ec3a89 100644 --- a/__tests__/src/rules/aria-role-test.js +++ b/__tests__/src/rules/aria-role-test.js @@ -50,6 +50,7 @@ const ignoreNonDOMSchema = [{ const customDivSettings = { 'jsx-a11y': { + polymorphicPropName: 'asChild', components: { Div: 'div', }, @@ -79,6 +80,10 @@ ruleTester.run('aria-role', rule, { code: '
', settings: customDivSettings, }, + { + code: '', + settings: customDivSettings, + }, { code: '', }, @@ -105,5 +110,10 @@ ruleTester.run('aria-role', rule, { options: ignoreNonDOMSchema, settings: customDivSettings, }, + { + code: '', + settings: customDivSettings, + errors: [errorMessage], + }, )).concat(invalidTests).map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/lang-test.js b/__tests__/src/rules/lang-test.js index eaab8da44..3eb406b1b 100644 --- a/__tests__/src/rules/lang-test.js +++ b/__tests__/src/rules/lang-test.js @@ -25,6 +25,7 @@ const expectedError = { const componentsSettings = { 'jsx-a11y': { + polymorphicPropName: 'as', components: { Foo: 'html', }, @@ -46,11 +47,13 @@ ruleTester.run('lang', rule, { { code: '' }, { code: '' }, { code: '', settings: componentsSettings }, + { code: '', settings: componentsSettings }, )).map(parserOptionsMapper), invalid: parsers.all([].concat( { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, { code: '', errors: [expectedError] }, { code: '', settings: componentsSettings, errors: [expectedError] }, + { code: '', settings: componentsSettings, errors: [expectedError] }, )).map(parserOptionsMapper), }); diff --git a/__tests__/src/rules/media-has-caption-test.js b/__tests__/src/rules/media-has-caption-test.js index ab08eeae6..3819d9c4b 100644 --- a/__tests__/src/rules/media-has-caption-test.js +++ b/__tests__/src/rules/media-has-caption-test.js @@ -33,6 +33,7 @@ const customSchema = [ const componentsSettings = { 'jsx-a11y': { + polymorphicPropName: 'as', components: { Audio: 'audio', Video: 'video', @@ -144,6 +145,10 @@ ruleTester.run('media-has-caption', rule, { code: '', settings: componentsSettings, }, + { + code: '', + settings: componentsSettings, + }, )).map(parserOptionsMapper), invalid: parsers.all([].concat( { code: '', errors: [expectedError] }, @@ -206,5 +211,10 @@ ruleTester.run('media-has-caption', rule, { settings: componentsSettings, errors: [expectedError], }, + { + code: '', + settings: componentsSettings, + errors: [expectedError], + }, )).map(parserOptionsMapper), }); diff --git a/__tests__/src/util/getElementType-test.js b/__tests__/src/util/getElementType-test.js index 5b5bd49c4..ab8aae728 100644 --- a/__tests__/src/util/getElementType-test.js +++ b/__tests__/src/util/getElementType-test.js @@ -1,6 +1,7 @@ import expect from 'expect'; import getElementType from '../../../src/util/getElementType'; import JSXElementMock from '../../../__mocks__/JSXElementMock'; +import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock'; describe('getElementType', () => { describe('no settings in context', () => { @@ -17,6 +18,10 @@ describe('getElementType', () => { it('should return the exact tag name for names that are in Object.prototype', () => { expect(elementType(JSXElementMock('toString').openingElement)).toBe('toString'); }); + + it('should return the default tag name provided', () => { + expect(elementType(JSXElementMock('span', [JSXAttributeMock('as', 'h1')]).openingElement)).toBe('span'); + }); }); describe('components settings in context', () => { @@ -41,5 +46,34 @@ describe('getElementType', () => { it('should return the exact tag name for a custom element not in the components map', () => { expect(elementType(JSXElementMock('CityInput').openingElement)).toBe('CityInput'); }); + + it('should return the default tag name since not polymorphicPropName was provided', () => { + expect(elementType(JSXElementMock('span', [JSXAttributeMock('as', 'h1')]).openingElement)).toBe('span'); + }); + }); + + describe('polymorphicPropName settings in context', () => { + const elementType = getElementType({ + settings: { + 'jsx-a11y': { + polymorphicPropName: 'asChild', + components: { + CustomButton: 'button', + }, + }, + }, + }); + + it('should return the tag name provided by the polymorphic prop, "asChild", defined in the settings', () => { + expect(elementType(JSXElementMock('span', [JSXAttributeMock('asChild', 'h1')]).openingElement)).toBe('h1'); + }); + + it('should return the tag name provided by the polymorphic prop, "asChild", defined in the settings instead of the component mapping tag', () => { + expect(elementType(JSXElementMock('CustomButton', [JSXAttributeMock('asChild', 'a')]).openingElement)).toBe('a'); + }); + + it('should return the tag name provided by the componnet mapping if the polymorphic prop, "asChild", defined in the settings is not set', () => { + expect(elementType(JSXElementMock('CustomButton', [JSXAttributeMock('as', 'a')]).openingElement)).toBe('button'); + }); }); }); diff --git a/flow/eslint.js b/flow/eslint.js index 02b3ff601..e91291fc0 100644 --- a/flow/eslint.js +++ b/flow/eslint.js @@ -9,7 +9,8 @@ export type ESLintReport = { export type ESLintSettings = { [string]: mixed, 'jsx-a11y'?: { - components: {[string]: string}, + polymorphicPropName?: string, + components?: {[string]: string}, }, } diff --git a/src/util/getElementType.js b/src/util/getElementType.js index 51c69b1de..85ba853ba 100644 --- a/src/util/getElementType.js +++ b/src/util/getElementType.js @@ -4,18 +4,23 @@ import type { JSXOpeningElement } from 'ast-types-flow'; import has from 'has'; -import { elementType } from 'jsx-ast-utils'; +import { elementType, getProp, getLiteralPropValue } from 'jsx-ast-utils'; import type { ESLintContext } from '../../flow/eslint'; const getElementType = (context: ESLintContext): ((node: JSXOpeningElement) => string) => { const { settings } = context; + const polymorphicPropName = settings['jsx-a11y']?.polymorphicPropName; const componentMap = settings['jsx-a11y']?.components; - if (!componentMap) { - return elementType; - } + return (node: JSXOpeningElement): string => { - const rawType = elementType(node); + const polymorphicProp = polymorphicPropName ? getLiteralPropValue(getProp(node.attributes, polymorphicPropName)) : undefined; + const rawType = polymorphicProp ?? elementType(node); + + if (!componentMap) { + return rawType; + } + return has(componentMap, rawType) ? componentMap[rawType] : rawType; }; };