Skip to content

Commit cc2c3d1

Browse files
committed
Adds custom config option for no-static-element-interactions to allow attributes that render down to specific native ones
1 parent a7d1a12 commit cc2c3d1

File tree

3 files changed

+63
-3
lines changed

3 files changed

+63
-3
lines changed

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,25 @@ const expectedError = {
2929

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

32+
const customOptions = [{
33+
a: {
34+
attributes: {
35+
href: ['to', 'href'],
36+
},
37+
},
38+
button: {
39+
attributes: {
40+
onClick: ['onClick', 'handleClick'],
41+
},
42+
},
43+
}];
44+
3245
const componentsSettings = {
3346
'jsx-a11y': {
3447
components: {
3548
Button: 'button',
3649
TestComponent: 'div',
50+
Link: 'a',
3751
},
3852
},
3953
};
@@ -82,6 +96,10 @@ const alwaysValid = [
8296
{ code: '<textarea onClick={() => void 0} className="foo" />' },
8397
{ code: '<a onClick={() => void 0} href="http://x.y.z" />' },
8498
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />' },
99+
{ code: '<a onClick={() => void 0} to="path/to/page" />', options: customOptions },
100+
{ code: '<button handleClick={() => void 0} />', options: customOptions },
101+
{ code: '<Link onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, options: customOptions },
102+
{ code: '<Button handleClick={() => void 0} />', settings: componentsSettings, options: customOptions },
85103
{ code: '<audio onClick={() => {}} />;' },
86104
{ code: '<form onClick={() => {}} />;' },
87105
{ code: '<form onSubmit={() => {}} />;' },
@@ -356,6 +374,9 @@ const neverValid = [
356374
{ code: '<div onMouseUp={() => {}} />;', errors: [expectedError] },
357375
// Custom components
358376
{ code: '<TestComponent onClick={doFoo} />', settings: componentsSettings, errors: [expectedError] },
377+
// Components without custom options
378+
{ code: '<a onClick={() => void 0} to="path/to/page" />', errors: [expectedError] },
379+
{ code: '<TestComponent onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, errors: [expectedError] },
359380
];
360381

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

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export default ({
5959
const {
6060
allowExpressionValues,
6161
handlers = defaultInteractiveProps,
62+
...elementOptions
6263
} = (options[0] || {});
6364

6465
const hasInteractiveProps = handlers
@@ -83,7 +84,7 @@ export default ({
8384
return;
8485
}
8586
if (
86-
isInteractiveElement(type, attributes)
87+
isInteractiveElement(type, attributes, elementOptions)
8788
|| isInteractiveRole(type, attributes)
8889
|| isNonInteractiveElement(type, attributes)
8990
|| isNonInteractiveRole(type, attributes)

src/util/isInteractiveElement.js

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

17+
import { getProp, getPropValue } from 'jsx-ast-utils';
1718
import attributesComparator from './attributesComparator';
1819

1920
const roleKeys = roles.keys();
@@ -68,14 +69,50 @@ const interactiveElementAXObjectSchemas = flatMap(
6869
([elementSchema, AXObjectsArr]) => (AXObjectsArr.every((role): boolean => interactiveAXObjects.has(role)) ? [elementSchema] : []),
6970
);
7071

71-
function checkIsInteractiveElement(tagName, attributes): boolean {
72+
function checkIsInteractiveElement(tagName, attributes, options = {}): boolean {
7273
function elementSchemaMatcher(elementSchema) {
7374
return (
7475
tagName === elementSchema.name
7576
&& attributesComparator(elementSchema.attributes, attributes)
7677
);
7778
}
7879

80+
function isInteractiveElementWithCustomOptions() {
81+
const elementConfig = options[tagName];
82+
83+
if (!elementConfig) return false;
84+
85+
return Object.keys(elementConfig.attributes).some((standardAttr) => {
86+
const customAttrs = elementConfig.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 && Object.keys(options).length > 0) {
113+
return isInteractiveElementWithCustomOptions();
114+
}
115+
79116
// Check in elementRoles for inherent interactive role associations for
80117
// this element.
81118
const isInherentInteractiveElement = interactiveElementRoleSchemas.some(elementSchemaMatcher);
@@ -106,14 +143,15 @@ function checkIsInteractiveElement(tagName, attributes): boolean {
106143
const isInteractiveElement = (
107144
tagName: string,
108145
attributes: Array<Node>,
146+
options: Object = {},
109147
): boolean => {
110148
// Do not test higher level JSX components, as we do not know what
111149
// low-level DOM element this maps to.
112150
if (!dom.has(tagName)) {
113151
return false;
114152
}
115153

116-
return checkIsInteractiveElement(tagName, attributes);
154+
return checkIsInteractiveElement(tagName, attributes, options);
117155
};
118156

119157
export default isInteractiveElement;

0 commit comments

Comments
 (0)