Skip to content

Commit

Permalink
[New] add polymorphicPropName setting for polymorphic components
Browse files Browse the repository at this point in the history
  • Loading branch information
kendallgassner authored and ljharb committed Jul 14, 2023
1 parent 3d1d26d commit fffb05b
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 9 deletions.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:

`<Box as="h3">Configurations </Box>`

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

<!-- begin auto-generated rules list -->
Expand Down
9 changes: 9 additions & 0 deletions __tests__/src/rules/accessible-emoji-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ ruleTester.run('accessible-emoji', rule, {
code: '<CustomInput type="hidden">🐼</CustomInput>',
settings: { 'jsx-a11y': { components: { CustomInput: 'input' } } },
},
{
code: '<Box as="input" type="hidden">🐼</Box>',
settings: { 'jsx-a11y': { polymorphicPropName: 'as' } },
},
)).map(parserOptionsMapper),
invalid: parsers.all([].concat(
{ code: '<span>🐼</span>', errors: [expectedError] },
Expand All @@ -53,5 +57,10 @@ ruleTester.run('accessible-emoji', rule, {
{ code: '<Foo>🐼</Foo>', errors: [expectedError] },
{ code: '<span aria-hidden="false">🐼</span>', errors: [expectedError] },
{ code: '<CustomInput type="hidden">🐼</CustomInput>', errors: [expectedError] },
{
code: '<Box as="span">🐼</Box>',
settings: { 'jsx-a11y': { polymorphicPropName: 'as' } },
errors: [expectedError],
},
)).map(parserOptionsMapper),
});
3 changes: 3 additions & 0 deletions __tests__/src/rules/alt-text-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const inputImageError = {

const componentsSettings = {
'jsx-a11y': {
polymorphicPropName: 'as',
components: {
Input: 'input',
},
Expand Down Expand Up @@ -132,6 +133,7 @@ ruleTester.run('alt-text', rule, {
{ code: '<input type="image" alt={altText} />' },
{ code: '<InputImage />' },
{ code: '<Input type="image" alt="" />', settings: componentsSettings },
{ code: '<SomeComponent as="input" type="image" alt="" />', settings: componentsSettings },

// CUSTOM ELEMENT TESTS FOR ARRAY OPTION TESTS
{ code: '<Thumbnail alt="foo" />;', options: array },
Expand Down Expand Up @@ -195,6 +197,7 @@ ruleTester.run('alt-text', rule, {
{ code: '<img aria-labelledby={undefined} />', errors: [ariaLabelledbyValueError] },
{ code: '<img aria-label="" />', errors: [ariaLabelValueError] },
{ code: '<img aria-labelledby="" />', errors: [ariaLabelledbyValueError] },
{ code: '<SomeComponent as="img" aria-label="" />', settings: componentsSettings, errors: [ariaLabelValueError] },

// DEFAULT ELEMENT 'object' TESTS
{ code: '<object />', errors: [objectError] },
Expand Down
10 changes: 10 additions & 0 deletions __tests__/src/rules/aria-role-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const ignoreNonDOMSchema = [{

const customDivSettings = {
'jsx-a11y': {
polymorphicPropName: 'asChild',
components: {
Div: 'div',
},
Expand Down Expand Up @@ -79,6 +80,10 @@ ruleTester.run('aria-role', rule, {
code: '<Div role="button" />',
settings: customDivSettings,
},
{
code: '<Box asChild="div" role="button" />',
settings: customDivSettings,
},
{
code: '<svg role="graphics-document document" />',
},
Expand All @@ -105,5 +110,10 @@ ruleTester.run('aria-role', rule, {
options: ignoreNonDOMSchema,
settings: customDivSettings,
},
{
code: '<Box asChild="div" role="Button" />',
settings: customDivSettings,
errors: [errorMessage],
},
)).concat(invalidTests).map(parserOptionsMapper),
});
3 changes: 3 additions & 0 deletions __tests__/src/rules/lang-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const expectedError = {

const componentsSettings = {
'jsx-a11y': {
polymorphicPropName: 'as',
components: {
Foo: 'html',
},
Expand All @@ -46,11 +47,13 @@ ruleTester.run('lang', rule, {
{ code: '<HTML lang="foo" />' },
{ code: '<Foo lang={undefined} />' },
{ code: '<Foo lang="en" />', settings: componentsSettings },
{ code: '<Box as="html" lang="en" />', settings: componentsSettings },
)).map(parserOptionsMapper),
invalid: parsers.all([].concat(
{ code: '<html lang="foo" />', errors: [expectedError] },
{ code: '<html lang="zz-LL" />', errors: [expectedError] },
{ code: '<html lang={undefined} />', errors: [expectedError] },
{ code: '<Foo lang={undefined} />', settings: componentsSettings, errors: [expectedError] },
{ code: '<Box as="html" lang="foo" />', settings: componentsSettings, errors: [expectedError] },
)).map(parserOptionsMapper),
});
10 changes: 10 additions & 0 deletions __tests__/src/rules/media-has-caption-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const customSchema = [

const componentsSettings = {
'jsx-a11y': {
polymorphicPropName: 'as',
components: {
Audio: 'audio',
Video: 'video',
Expand Down Expand Up @@ -144,6 +145,10 @@ ruleTester.run('media-has-caption', rule, {
code: '<Audio muted={true}></Audio>',
settings: componentsSettings,
},
{
code: '<Box as="audio" muted={true}></Box>',
settings: componentsSettings,
},
)).map(parserOptionsMapper),
invalid: parsers.all([].concat(
{ code: '<audio><track /></audio>', errors: [expectedError] },
Expand Down Expand Up @@ -206,5 +211,10 @@ ruleTester.run('media-has-caption', rule, {
settings: componentsSettings,
errors: [expectedError],
},
{
code: '<Box as="audio"><Track kind="subtitles" /></Box>',
settings: componentsSettings,
errors: [expectedError],
},
)).map(parserOptionsMapper),
});
34 changes: 34 additions & 0 deletions __tests__/src/util/getElementType-test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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');
});
});
});
3 changes: 2 additions & 1 deletion flow/eslint.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export type ESLintReport = {
export type ESLintSettings = {
[string]: mixed,
'jsx-a11y'?: {
components: {[string]: string},
polymorphicPropName?: string,
components?: {[string]: string},
},
}

Expand Down
15 changes: 10 additions & 5 deletions src/util/getElementType.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
Expand Down

0 comments on commit fffb05b

Please sign in to comment.