Skip to content

Commit de463b3

Browse files
handle polymorphic components
1 parent 1e0d857 commit de463b3

7 files changed

+86
-128
lines changed

README.md

+28-29
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,16 @@ _Note: This is experimental and subject to change._
4747

4848
The `react` config includes rules which target specific HTML elements. You may provide a mapping of custom components to an HTML element in your `eslintrc` configuration to increase linter coverage.
4949

50-
For each component, you may specify a `default` and/or `props`. `default` may make sense if there's a 1:1 mapping between a component and an HTML element. However, if the HTML output of a component is dependent on a prop value, you can provide a mapping using the `props` key. To minimize conflicts and complexity, this currently only supports the mapping of a single prop type.
50+
By default, these eslint rules will check the "as" prop for underlying element changes. If your repo uses a different prop name for polymorphic components provide the prop name in your `eslintrc` configuration under `polymorphicPropName`.
5151

5252
```json
5353
{
5454
"settings": {
5555
"github": {
56+
"polymorphicPropName": "asChild",
5657
"components": {
57-
"Box": {"default": "p"},
58-
"Link": {"props": {"as": {"undefined": "a", "a": "a", "button": "button"}}}
58+
"Box": "p",
59+
"Link": "a"
5960
}
6061
}
6162
}
@@ -66,9 +67,7 @@ This config will be interpreted in the following way:
6667

6768
- All `<Box>` elements will be treated as a `p` element type.
6869
- `<Link>` without a defined `as` prop will be treated as a `a`.
69-
- `<Link as='a'>` will treated as an `a` element type.
7070
- `<Link as='button'>` will be treated as a `button` element type.
71-
- `<Link as='summary'>` will be treated as the raw `Link` type because there is no configuration set for `as='summary'`.
7271

7372
### Rules
7473

@@ -82,29 +81,29 @@ This config will be interpreted in the following way:
8281
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
8382
❌ Deprecated.
8483

85-
| Name                                        | Description | 💼 | 🔧 ||
86-
| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- |
87-
| [a11y-aria-label-is-well-formatted](docs/rules/a11y-aria-label-is-well-formatted.md) | [aria-label] text should be formatted as you would visual text. | ⚛️ | | |
88-
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
89-
| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | |
90-
| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` || | |
91-
| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | |
92-
| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | |
93-
| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | |
94-
| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | |
95-
| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | |
96-
| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | |
97-
| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | |
98-
| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | |
99-
| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags || | |
100-
| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables || | |
101-
| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | |
102-
| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | |
103-
| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises || | |
104-
| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | |
105-
| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | |
106-
| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | |
107-
| [role-supports-aria-props](docs/rules/role-supports-aria-props.md) | Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. | ⚛️ | | |
108-
| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | |
84+
| Name                                        | Description | 💼 | 🔧 ||
85+
| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :-- | :-- | :-- |
86+
| [a11y-aria-label-is-well-formatted](docs/rules/a11y-aria-label-is-well-formatted.md) | [aria-label] text should be formatted as you would visual text. | ⚛️ | | |
87+
| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | ||
88+
| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | |
89+
| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` || | |
90+
| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | |
91+
| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | |
92+
| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | |
93+
| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | |
94+
| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | |
95+
| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | |
96+
| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | |
97+
| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | |
98+
| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags || | |
99+
| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables || | |
100+
| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | |
101+
| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | |
102+
| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises || | |
103+
| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | |
104+
| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | |
105+
| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | |
106+
| [role-supports-aria-props](docs/rules/role-supports-aria-props.md) | Enforce that elements with explicit or implicit roles defined contain only `aria-*` properties supported by that `role`. | ⚛️ | | |
107+
| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | |
109108

110109
<!-- end auto-generated rules list -->

lib/rules/a11y-no-visually-hidden-interactive-element.js

+5-8
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const {generateObjSchema} = require('eslint-plugin-jsx-a11y/lib/util/schemas')
44

55
const defaultClassName = 'sr-only'
66
const defaultcomponentName = 'VisuallyHidden'
7-
const defaultHtmlPropName = 'as'
87

98
const schema = generateObjSchema({
109
className: {type: 'string'},
@@ -18,12 +17,11 @@ const schema = generateObjSchema({
1817
*/
1918
const INTERACTIVELEMENTS = ['a', 'button', 'summary', 'select', 'option', 'textarea']
2019

21-
const checkIfInteractiveElement = (context, htmlPropName, node) => {
20+
const checkIfInteractiveElement = (context, node) => {
2221
const elementType = getElementType(context, node.openingElement)
23-
const asProp = getPropValue(getProp(node.openingElement.attributes, htmlPropName))
2422

2523
for (const interactiveElement of INTERACTIVELEMENTS) {
26-
if ((asProp ?? elementType) === interactiveElement) {
24+
if (elementType === interactiveElement) {
2725
return true
2826
}
2927
}
@@ -32,14 +30,14 @@ const checkIfInteractiveElement = (context, htmlPropName, node) => {
3230

3331
// if the node is visually hidden recursively check if it has interactive children
3432
const checkIfVisuallyHiddenAndInteractive = (context, options, node, isParentVisuallyHidden) => {
35-
const {className, componentName, htmlPropName} = options
33+
const {className, componentName} = options
3634
if (node.type === 'JSXElement') {
3735
const classes = getPropValue(getProp(node.openingElement.attributes, 'className'))
3836
const isVisuallyHiddenElement = node.openingElement.name.name === componentName
3937
const hasSROnlyClass = typeof classes !== 'undefined' && classes.includes(className)
4038
let isHidden = false
4139
if (hasSROnlyClass || isVisuallyHiddenElement || !!isParentVisuallyHidden) {
42-
if (checkIfInteractiveElement(context, htmlPropName, node)) {
40+
if (checkIfInteractiveElement(context, node)) {
4341
return true
4442
}
4543
isHidden = true
@@ -69,11 +67,10 @@ module.exports = {
6967
const config = options[0] || {}
7068
const className = config.className || defaultClassName
7169
const componentName = config.componentName || defaultcomponentName
72-
const htmlPropName = config.htmlPropName || defaultHtmlPropName
7370

7471
return {
7572
JSXElement: node => {
76-
if (checkIfVisuallyHiddenAndInteractive(context, {className, componentName, htmlPropName}, node, false)) {
73+
if (checkIfVisuallyHiddenAndInteractive(context, {className, componentName}, node, false)) {
7774
context.report({
7875
node,
7976
message:

lib/utils/get-element-type.js

+16-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
const {elementType, getProp, getPropValue} = require('jsx-ast-utils')
22

3+
function getConfigurationDefaultIfExists(settings, rawElement) {
4+
// if a component configuration does not exists, return the raw element
5+
if (!settings?.github?.components?.[rawElement]) return rawElement
6+
7+
const defaultComponent = settings.github.components[rawElement]
8+
9+
// check if the default component is also defined in the configuration
10+
return defaultComponent ? getConfigurationDefaultIfExists(settings, defaultComponent) : defaultComponent
11+
}
12+
313
/*
414
Allows custom component to be mapped to an element type.
515
When a default is set, all instances of the component will be mapped to the default.
@@ -9,28 +19,12 @@ For now, we only support the mapping of one prop type to an element type, rather
919
*/
1020
function getElementType(context, node) {
1121
const {settings} = context
12-
const rawElement = elementType(node)
13-
if (!settings) return rawElement
14-
15-
const componentMap = settings.github && settings.github.components
16-
if (!componentMap) return rawElement
17-
const component = componentMap[rawElement]
18-
if (!component) return rawElement
19-
let element = component.default ? component.default : rawElement
20-
21-
if (component.props) {
22-
const props = Object.entries(component.props)
23-
for (const [key, value] of props) {
24-
const propMap = value
25-
const propValue = getPropValue(getProp(node.attributes, key))
26-
const mapValue = propMap[propValue]
27-
28-
if (mapValue) {
29-
element = mapValue
30-
}
31-
}
32-
}
33-
return element
22+
23+
// check if the node contains a polymorphic prop
24+
const polymorphicPropName = settings?.github?.polymorphicPropName ?? 'as'
25+
const rawElement = getPropValue(getProp(node.attributes, polymorphicPropName)) ?? elementType(node)
26+
27+
return getConfigurationDefaultIfExists(settings, rawElement)
3428
}
3529

3630
module.exports = {getElementType}

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"lint:eslint-docs": "npm run update:eslint-docs -- --check",
1616
"lint:js": "eslint .",
1717
"pretest": "mkdir -p node_modules/ && ln -fs $(pwd) node_modules/",
18-
"test": "npm run eslint-check && npm run lint && mocha tests/**/*.js tests/",
18+
"test": "mocha tests/**/*.js tests/",
1919
"update:eslint-docs": "eslint-doc-generator"
2020
},
2121
"repository": {
@@ -65,4 +65,4 @@
6565
"mocha": "^10.0.0",
6666
"npm-run-all": "^4.1.5"
6767
}
68-
}
68+
}

tests/a11y-no-generic-link-text.js

+3-18
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
2626
settings: {
2727
github: {
2828
components: {
29-
Link: {
30-
props: {as: {undefined: 'a'}},
31-
},
29+
Link: 'a',
3230
},
3331
},
3432
},
@@ -41,9 +39,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
4139
settings: {
4240
github: {
4341
components: {
44-
ButtonLink: {
45-
default: 'a',
46-
},
42+
ButtonLink: 'a',
4743
},
4844
},
4945
},
@@ -54,25 +50,14 @@ ruleTester.run('a11y-no-generic-link-text', rule, {
5450
settings: {
5551
github: {
5652
components: {
57-
Link: {
58-
props: {as: {undefined: 'a'}},
59-
},
53+
Link: 'a',
6054
},
6155
},
6256
},
6357
},
6458
{
6559
code: '<Test as="a" href="#">Read more</Test>',
6660
errors: [{message: errorMessage}],
67-
settings: {
68-
github: {
69-
components: {
70-
Test: {
71-
props: {as: {a: 'a'}},
72-
},
73-
},
74-
},
75-
},
7661
},
7762
{
7863
code: "<Box><a href='#'>Click here</a></Box>;",

0 commit comments

Comments
 (0)