Skip to content

Account for polymorphic components in getElementType #945

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

Merged
merged 1 commit into from
Sep 5, 2023
Merged
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
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