Skip to content

Commit fffb05b

Browse files
kendallgassnerljharb
authored andcommittedJul 14, 2023
[New] add polymorphicPropName setting for polymorphic components
1 parent 3d1d26d commit fffb05b

9 files changed

+101
-9
lines changed
 

‎README.md

+20-3
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,15 @@ Add `plugin:jsx-a11y/recommended` or `plugin:jsx-a11y/strict` in `extends`:
8989
}
9090
```
9191

92-
> As you are extending our configuration, you can omit `"plugins": ["jsx-a11y"]` from your `.eslintrc` configuration file.
92+
### Configurations
9393

94-
To enable your custom components to be checked as DOM elements, you can set global settings in your
95-
configuration file by mapping each custom component name to a DOM element type.
94+
> As you are extending our configuration, you can omit `"plugins": ["jsx-a11y"]` from your `.eslintrc` configuration file.
9695
9796
```json
9897
{
9998
"settings": {
10099
"jsx-a11y": {
100+
"polymorphicPropName": "as",
101101
"components": {
102102
"CityInput": "input",
103103
"CustomButton": "button",
@@ -109,6 +109,23 @@ configuration file by mapping each custom component name to a DOM element type.
109109
}
110110
```
111111

112+
#### Component Mapping
113+
114+
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.
115+
116+
#### Polymorphic Components
117+
118+
You can optionally use the `polymorphicPropName` setting to define the prop your code uses to create polymorphic components.
119+
This setting will be used determine the element type in rules that require semantic context.
120+
121+
For example, if you set the `polymorphicPropName` setting to `as` then this element:
122+
123+
`<Box as="h3">Configurations </Box>`
124+
125+
will be evaluated as an `h3`. If no `polymorphicPropName` is set, then the component will be evaluated as `Box`.
126+
127+
⚠️ Polymorphic components can make code harder to maintain; please use this feature with caution.
128+
112129
## Supported Rules
113130

114131
<!-- begin auto-generated rules list -->

‎__tests__/src/rules/accessible-emoji-test.js

+9
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ ruleTester.run('accessible-emoji', rule, {
4343
code: '<CustomInput type="hidden">🐼</CustomInput>',
4444
settings: { 'jsx-a11y': { components: { CustomInput: 'input' } } },
4545
},
46+
{
47+
code: '<Box as="input" type="hidden">🐼</Box>',
48+
settings: { 'jsx-a11y': { polymorphicPropName: 'as' } },
49+
},
4650
)).map(parserOptionsMapper),
4751
invalid: parsers.all([].concat(
4852
{ code: '<span>🐼</span>', errors: [expectedError] },
@@ -53,5 +57,10 @@ ruleTester.run('accessible-emoji', rule, {
5357
{ code: '<Foo>🐼</Foo>', errors: [expectedError] },
5458
{ code: '<span aria-hidden="false">🐼</span>', errors: [expectedError] },
5559
{ code: '<CustomInput type="hidden">🐼</CustomInput>', errors: [expectedError] },
60+
{
61+
code: '<Box as="span">🐼</Box>',
62+
settings: { 'jsx-a11y': { polymorphicPropName: 'as' } },
63+
errors: [expectedError],
64+
},
5665
)).map(parserOptionsMapper),
5766
});

‎__tests__/src/rules/alt-text-test.js

+3
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const inputImageError = {
5555

5656
const componentsSettings = {
5757
'jsx-a11y': {
58+
polymorphicPropName: 'as',
5859
components: {
5960
Input: 'input',
6061
},
@@ -132,6 +133,7 @@ ruleTester.run('alt-text', rule, {
132133
{ code: '<input type="image" alt={altText} />' },
133134
{ code: '<InputImage />' },
134135
{ code: '<Input type="image" alt="" />', settings: componentsSettings },
136+
{ code: '<SomeComponent as="input" type="image" alt="" />', settings: componentsSettings },
135137

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

199202
// DEFAULT ELEMENT 'object' TESTS
200203
{ code: '<object />', errors: [objectError] },

‎__tests__/src/rules/aria-role-test.js

+10
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const ignoreNonDOMSchema = [{
5050

5151
const customDivSettings = {
5252
'jsx-a11y': {
53+
polymorphicPropName: 'asChild',
5354
components: {
5455
Div: 'div',
5556
},
@@ -79,6 +80,10 @@ ruleTester.run('aria-role', rule, {
7980
code: '<Div role="button" />',
8081
settings: customDivSettings,
8182
},
83+
{
84+
code: '<Box asChild="div" role="button" />',
85+
settings: customDivSettings,
86+
},
8287
{
8388
code: '<svg role="graphics-document document" />',
8489
},
@@ -105,5 +110,10 @@ ruleTester.run('aria-role', rule, {
105110
options: ignoreNonDOMSchema,
106111
settings: customDivSettings,
107112
},
113+
{
114+
code: '<Box asChild="div" role="Button" />',
115+
settings: customDivSettings,
116+
errors: [errorMessage],
117+
},
108118
)).concat(invalidTests).map(parserOptionsMapper),
109119
});

‎__tests__/src/rules/lang-test.js

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const expectedError = {
2525

2626
const componentsSettings = {
2727
'jsx-a11y': {
28+
polymorphicPropName: 'as',
2829
components: {
2930
Foo: 'html',
3031
},
@@ -46,11 +47,13 @@ ruleTester.run('lang', rule, {
4647
{ code: '<HTML lang="foo" />' },
4748
{ code: '<Foo lang={undefined} />' },
4849
{ code: '<Foo lang="en" />', settings: componentsSettings },
50+
{ code: '<Box as="html" lang="en" />', settings: componentsSettings },
4951
)).map(parserOptionsMapper),
5052
invalid: parsers.all([].concat(
5153
{ code: '<html lang="foo" />', errors: [expectedError] },
5254
{ code: '<html lang="zz-LL" />', errors: [expectedError] },
5355
{ code: '<html lang={undefined} />', errors: [expectedError] },
5456
{ code: '<Foo lang={undefined} />', settings: componentsSettings, errors: [expectedError] },
57+
{ code: '<Box as="html" lang="foo" />', settings: componentsSettings, errors: [expectedError] },
5558
)).map(parserOptionsMapper),
5659
});

‎__tests__/src/rules/media-has-caption-test.js

+10
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const customSchema = [
3333

3434
const componentsSettings = {
3535
'jsx-a11y': {
36+
polymorphicPropName: 'as',
3637
components: {
3738
Audio: 'audio',
3839
Video: 'video',
@@ -144,6 +145,10 @@ ruleTester.run('media-has-caption', rule, {
144145
code: '<Audio muted={true}></Audio>',
145146
settings: componentsSettings,
146147
},
148+
{
149+
code: '<Box as="audio" muted={true}></Box>',
150+
settings: componentsSettings,
151+
},
147152
)).map(parserOptionsMapper),
148153
invalid: parsers.all([].concat(
149154
{ code: '<audio><track /></audio>', errors: [expectedError] },
@@ -206,5 +211,10 @@ ruleTester.run('media-has-caption', rule, {
206211
settings: componentsSettings,
207212
errors: [expectedError],
208213
},
214+
{
215+
code: '<Box as="audio"><Track kind="subtitles" /></Box>',
216+
settings: componentsSettings,
217+
errors: [expectedError],
218+
},
209219
)).map(parserOptionsMapper),
210220
});

‎__tests__/src/util/getElementType-test.js

+34
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import expect from 'expect';
22
import getElementType from '../../../src/util/getElementType';
33
import JSXElementMock from '../../../__mocks__/JSXElementMock';
4+
import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock';
45

56
describe('getElementType', () => {
67
describe('no settings in context', () => {
@@ -17,6 +18,10 @@ describe('getElementType', () => {
1718
it('should return the exact tag name for names that are in Object.prototype', () => {
1819
expect(elementType(JSXElementMock('toString').openingElement)).toBe('toString');
1920
});
21+
22+
it('should return the default tag name provided', () => {
23+
expect(elementType(JSXElementMock('span', [JSXAttributeMock('as', 'h1')]).openingElement)).toBe('span');
24+
});
2025
});
2126

2227
describe('components settings in context', () => {
@@ -41,5 +46,34 @@ describe('getElementType', () => {
4146
it('should return the exact tag name for a custom element not in the components map', () => {
4247
expect(elementType(JSXElementMock('CityInput').openingElement)).toBe('CityInput');
4348
});
49+
50+
it('should return the default tag name since not polymorphicPropName was provided', () => {
51+
expect(elementType(JSXElementMock('span', [JSXAttributeMock('as', 'h1')]).openingElement)).toBe('span');
52+
});
53+
});
54+
55+
describe('polymorphicPropName settings in context', () => {
56+
const elementType = getElementType({
57+
settings: {
58+
'jsx-a11y': {
59+
polymorphicPropName: 'asChild',
60+
components: {
61+
CustomButton: 'button',
62+
},
63+
},
64+
},
65+
});
66+
67+
it('should return the tag name provided by the polymorphic prop, "asChild", defined in the settings', () => {
68+
expect(elementType(JSXElementMock('span', [JSXAttributeMock('asChild', 'h1')]).openingElement)).toBe('h1');
69+
});
70+
71+
it('should return the tag name provided by the polymorphic prop, "asChild", defined in the settings instead of the component mapping tag', () => {
72+
expect(elementType(JSXElementMock('CustomButton', [JSXAttributeMock('asChild', 'a')]).openingElement)).toBe('a');
73+
});
74+
75+
it('should return the tag name provided by the componnet mapping if the polymorphic prop, "asChild", defined in the settings is not set', () => {
76+
expect(elementType(JSXElementMock('CustomButton', [JSXAttributeMock('as', 'a')]).openingElement)).toBe('button');
77+
});
4478
});
4579
});

‎flow/eslint.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export type ESLintReport = {
99
export type ESLintSettings = {
1010
[string]: mixed,
1111
'jsx-a11y'?: {
12-
components: {[string]: string},
12+
polymorphicPropName?: string,
13+
components?: {[string]: string},
1314
},
1415
}
1516

‎src/util/getElementType.js

+10-5
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,23 @@
44

55
import type { JSXOpeningElement } from 'ast-types-flow';
66
import has from 'has';
7-
import { elementType } from 'jsx-ast-utils';
7+
import { elementType, getProp, getLiteralPropValue } from 'jsx-ast-utils';
88

99
import type { ESLintContext } from '../../flow/eslint';
1010

1111
const getElementType = (context: ESLintContext): ((node: JSXOpeningElement) => string) => {
1212
const { settings } = context;
13+
const polymorphicPropName = settings['jsx-a11y']?.polymorphicPropName;
1314
const componentMap = settings['jsx-a11y']?.components;
14-
if (!componentMap) {
15-
return elementType;
16-
}
15+
1716
return (node: JSXOpeningElement): string => {
18-
const rawType = elementType(node);
17+
const polymorphicProp = polymorphicPropName ? getLiteralPropValue(getProp(node.attributes, polymorphicPropName)) : undefined;
18+
const rawType = polymorphicProp ?? elementType(node);
19+
20+
if (!componentMap) {
21+
return rawType;
22+
}
23+
1924
return has(componentMap, rawType) ? componentMap[rawType] : rawType;
2025
};
2126
};

0 commit comments

Comments
 (0)
Please sign in to comment.