Skip to content

Commit d1fa087

Browse files
committed
[Feat]: add attributes option to the global components option
1 parent 7a4a9c5 commit d1fa087

File tree

7 files changed

+86
-89
lines changed

7 files changed

+86
-89
lines changed

__tests__/src/rules/no-static-element-interactions-test.js

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,22 @@ const expectedError = {
2929

3030
const ruleName = 'no-static-element-interactions';
3131

32-
const customOptions = [
33-
{
34-
attributes: [
35-
{
36-
components: ['a', 'Link'],
32+
const componentsSettings = {
33+
'jsx-a11y': {
34+
components: {
35+
Button: 'button',
36+
TestComponent: 'div',
37+
Link: {
38+
components: 'a',
3739
attributes: {
3840
href: ['to', 'href'],
3941
},
4042
},
41-
{
42-
components: ['button', 'Button'],
43-
attributes: {
44-
onClick: ['onClick', 'handleClick'],
45-
},
46-
},
47-
],
43+
},
4844
},
49-
];
45+
};
5046

51-
const componentsSettings = {
47+
const componentsSettingNoAttributes = {
5248
'jsx-a11y': {
5349
components: {
5450
Button: 'button',
@@ -102,10 +98,8 @@ const alwaysValid = [
10298
{ code: '<textarea onClick={() => void 0} className="foo" />' },
10399
{ code: '<a onClick={() => void 0} href="http://x.y.z" />' },
104100
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />' },
105-
{ code: '<a onClick={() => void 0} to="path/to/page" />', options: customOptions },
106-
{ code: '<button handleClick={() => void 0} />', options: customOptions },
107-
{ code: '<Link onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, options: customOptions },
108-
{ code: '<Button handleClick={() => void 0} />', settings: componentsSettings, options: customOptions },
101+
{ code: '<Link onClick={() => void 0} to="path/to/page" />', settings: componentsSettings },
102+
{ code: '<Link onClick={() => void 0} href="http://x.y.z" />', settings: componentsSettings },
109103
{ code: '<audio onClick={() => {}} />;' },
110104
{ code: '<form onClick={() => {}} />;' },
111105
{ code: '<form onSubmit={() => {}} />;' },
@@ -379,10 +373,10 @@ const neverValid = [
379373
{ code: '<div onMouseDown={() => {}} />;', errors: [expectedError] },
380374
{ code: '<div onMouseUp={() => {}} />;', errors: [expectedError] },
381375
// Custom components
382-
{ code: '<TestComponent onClick={doFoo} />', settings: componentsSettings, errors: [expectedError] },
383-
// Components without custom options
384-
{ code: '<a onClick={() => void 0} to="path/to/page" />', errors: [expectedError] },
385376
{ code: '<TestComponent onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, errors: [expectedError] },
377+
{ code: '<Link onClick={() => void 0} to="path/to/page" />', settings: componentsSettingNoAttributes, errors: [expectedError] },
378+
// `a` with a `to` is not valid, only custom components listed in `components`
379+
{ code: '<a onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, errors: [expectedError] },
386380
];
387381

388382
const recommendedOptions = configs.recommended.rules[`jsx-a11y/${ruleName}`][1] || {};

docs/rules/no-static-element-interactions.md

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -79,23 +79,11 @@ You may configure which handler props should be taken into account when applying
7979
'onKeyUp',
8080
],
8181
allowExpressionValues: true,
82-
attributes: [
83-
{
84-
components: ['a', 'Link'],
85-
attributes: {
86-
href: ['to', 'href'],
87-
},
88-
},
89-
],
9082
},
9183
],
9284
```
9385

94-
### `handlers`
95-
96-
Adjust the list of handler prop names in the `handlers` array to increase or decrease the coverage surface of this rule in your codebase.
97-
98-
### `allowExpressionValues`
86+
Adjust the list of handler prop names in the handlers array to increase or decrease the coverage surface of this rule in your codebase.
9987

10088
The `allowExpressionValues` option determines whether the `role` attribute is allowed to be assigned using an expression. For example, the following would pass in recommended mode if `allowExpressionValues` is set to be `true`:
10189

@@ -105,16 +93,6 @@ The `allowExpressionValues` option determines whether the `role` attribute is al
10593
<div role={isButton ? "button" : "link"} onClick={() => {}} />;
10694
```
10795

108-
### `attributes`
109-
110-
The `attributes` array allows to set custom attributes for a given list of components. This is useful in cases where you are utilizing libraries that may have different prop names mapped to a native attribute (e.g., `to` being used for `href`)
111-
112-
```jsx
113-
// these will be valid given the `attributes` option above
114-
<a onClick={() => {}} className="foo" to="some/path" />
115-
<Link onClick={() => {}} className="foo" to="some/path" />
116-
```
117-
11896
### Succeed
11997

12098
```jsx

flow/eslint.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export type ESLintReport = {
99
export type ESLintSettings = {
1010
[string]: mixed,
1111
'jsx-a11y'?: {
12-
components?: { [string]: string },
12+
components?: { [string]: string | { component: string, attributes: { [string]: Array<string> } } },
1313
attributes?: { for?: string[] },
1414
polymorphicPropName?: string,
1515
polymorphicAllowList?: Array<string>,

src/rules/no-static-element-interactions.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import isNonInteractiveElement from '../util/isNonInteractiveElement';
2727
import isNonInteractiveRole from '../util/isNonInteractiveRole';
2828
import isNonLiteralProperty from '../util/isNonLiteralProperty';
2929
import isPresentationRole from '../util/isPresentationRole';
30+
import getAttributes from '../util/getAttributes';
3031

3132
const errorMessage = 'Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element.';
3233

@@ -53,13 +54,12 @@ export default ({
5354
const elementType = getElementType(context);
5455
return {
5556
JSXOpeningElement: (node: JSXOpeningElement) => {
56-
const { attributes } = node;
5757
const type = elementType(node);
58+
const attributes = getAttributes(node, type, context);
5859

5960
const {
6061
allowExpressionValues,
6162
handlers = defaultInteractiveProps,
62-
...elementOptions
6363
} = (options[0] || {});
6464

6565
const hasInteractiveProps = handlers
@@ -84,7 +84,7 @@ export default ({
8484
return;
8585
}
8686
if (
87-
isInteractiveElement(type, attributes, elementOptions)
87+
isInteractiveElement(type, attributes)
8888
|| isInteractiveRole(type, attributes)
8989
|| isNonInteractiveElement(type, attributes)
9090
|| isNonInteractiveRole(type, attributes)

src/util/getAttributes.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { getProp } from 'jsx-ast-utils';
2+
3+
const getAttributes = (node, type, context) => {
4+
const { settings } = context;
5+
const { attributes } = node;
6+
const components = settings['jsx-a11y']?.components;
7+
8+
if (!components || !type) {
9+
return attributes;
10+
}
11+
12+
const componentConfig = Object.entries(components).find(([, config]) => (
13+
config && typeof config === 'object' && config.component === type
14+
));
15+
16+
if (!componentConfig) {
17+
return attributes;
18+
}
19+
20+
const [, config] = componentConfig;
21+
const attributeMap = config && typeof config === 'object' ? config.attributes : null;
22+
23+
if (!attributeMap || typeof attributeMap !== 'object') {
24+
return attributes;
25+
}
26+
27+
const mappedAttributes = [...attributes];
28+
29+
Object.entries(attributeMap).forEach(([originalAttr, mappedAttrs]) => {
30+
if (Array.isArray(mappedAttrs)) {
31+
mappedAttrs.forEach((mappedAttr) => {
32+
const mappedProp = getProp(attributes, mappedAttr);
33+
if (mappedProp) {
34+
const newAttribute = {
35+
...mappedProp,
36+
name: {
37+
...mappedProp.name,
38+
name: originalAttr,
39+
},
40+
};
41+
mappedAttributes.push(newAttribute);
42+
}
43+
});
44+
}
45+
});
46+
47+
return mappedAttributes;
48+
};
49+
50+
export default getAttributes;

src/util/getElementType.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,20 @@ const getElementType = (context: ESLintContext): ((node: JSXOpeningElement) => s
3131
return rawType;
3232
}
3333

34-
return hasOwn(componentMap, rawType) ? componentMap[rawType] : rawType;
34+
const componentType = componentMap[rawType];
35+
36+
if (typeof componentType === 'object') {
37+
const customComponent = Object.entries(componentType).find(([key]) => key === rawType);
38+
39+
if (customComponent) {
40+
[rawType] = customComponent;
41+
return hasOwn(componentMap, rawType) ? rawType : rawType;
42+
}
43+
} else if (typeof componentType === 'string') {
44+
return hasOwn(componentMap, rawType) ? componentType : rawType;
45+
}
46+
47+
return rawType;
3548
};
3649
};
3750

src/util/isInteractiveElement.js

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
import includes from 'array-includes';
1515
import flatMap from 'array.prototype.flatmap';
1616

17-
import { getProp, getPropValue } from 'jsx-ast-utils';
1817
import attributesComparator from './attributesComparator';
1918

2019
const roleKeys = roles.keys();
@@ -69,50 +68,14 @@ const interactiveElementAXObjectSchemas = flatMap(
6968
([elementSchema, AXObjectsArr]) => (AXObjectsArr.every((role): boolean => interactiveAXObjects.has(role)) ? [elementSchema] : []),
7069
);
7170

72-
function checkIsInteractiveElement(tagName, attributes, options = {}): boolean {
71+
function checkIsInteractiveElement(tagName, attributes): boolean {
7372
function elementSchemaMatcher(elementSchema) {
7473
return (
7574
tagName === elementSchema.name
7675
&& attributesComparator(elementSchema.attributes, attributes)
7776
);
7877
}
7978

80-
function isInteractiveElementWithCustomOptions() {
81-
const matchingConfig = options.attributes.find((config) => config.components && config.components.includes(tagName));
82-
83-
if (!matchingConfig) return false;
84-
85-
return Object.keys(matchingConfig.attributes).some((standardAttr) => {
86-
const customAttrs = matchingConfig.attributes[standardAttr];
87-
88-
const validCustomAttr = customAttrs.find((customAttr) => {
89-
if (customAttr === standardAttr) return false;
90-
const customProp = getProp(attributes, customAttr);
91-
return customProp && getPropValue(customProp) != null;
92-
});
93-
94-
if (validCustomAttr) {
95-
const originalProp = getProp(attributes, validCustomAttr);
96-
const standardProp = {
97-
...originalProp,
98-
name: {
99-
...originalProp.name,
100-
name: standardAttr,
101-
},
102-
};
103-
104-
return checkIsInteractiveElement(tagName, [...attributes, standardProp], {});
105-
}
106-
107-
return false;
108-
});
109-
}
110-
111-
// Checks if there are custom options for this element
112-
if (options && options.attributes && options.attributes.length > 0) {
113-
return isInteractiveElementWithCustomOptions();
114-
}
115-
11679
// Check in elementRoles for inherent interactive role associations for
11780
// this element.
11881
const isInherentInteractiveElement = interactiveElementRoleSchemas.some(elementSchemaMatcher);
@@ -143,15 +106,14 @@ function checkIsInteractiveElement(tagName, attributes, options = {}): boolean {
143106
const isInteractiveElement = (
144107
tagName: string,
145108
attributes: Array<Node>,
146-
options: Object = {},
147109
): boolean => {
148110
// Do not test higher level JSX components, as we do not know what
149111
// low-level DOM element this maps to.
150112
if (!dom.has(tagName)) {
151113
return false;
152114
}
153115

154-
return checkIsInteractiveElement(tagName, attributes, options);
116+
return checkIsInteractiveElement(tagName, attributes);
155117
};
156118

157119
export default isInteractiveElement;

0 commit comments

Comments
 (0)