Skip to content

no-static-element-interactions custom attribute mapping #1052

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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
31 changes: 30 additions & 1 deletion __tests__/src/rules/no-static-element-interactions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ const componentsSettings = {
components: {
Button: 'button',
TestComponent: 'div',
// Custom component with mapped attributes
Link: {
component: 'a',
attributes: {
href: ['to', 'href', 'foo'],
},
},
// Custom component with a redundant attribute
TestComponent2: {
attributes: {
href: ['href'],
},
component: 'a',
},
// Custom component with empty attributes object
TestComponent3: {
component: 'a',
},
},
},
};
Expand Down Expand Up @@ -82,6 +100,10 @@ const alwaysValid = [
{ code: '<textarea onClick={() => void 0} className="foo" />' },
{ code: '<a onClick={() => void 0} href="http://x.y.z" />' },
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />' },
{ code: '<Link onClick={() => void 0} to="path/to/page" />', settings: componentsSettings },
{ code: '<Link onClick={() => void 0} foo="path/to/page" />', settings: componentsSettings },
{ code: '<Link onClick={() => void 0} href="http://x.y.z" />', settings: componentsSettings },
{ code: '<TestComponent2 onClick={() => void 0} href="http://x.y.z" />;', settings: componentsSettings },
{ code: '<audio onClick={() => {}} />;' },
{ code: '<form onClick={() => {}} />;' },
{ code: '<form onSubmit={() => {}} />;' },
Expand Down Expand Up @@ -355,7 +377,14 @@ const neverValid = [
{ code: '<div onMouseDown={() => {}} />;', errors: [expectedError] },
{ code: '<div onMouseUp={() => {}} />;', errors: [expectedError] },
// Custom components
{ code: '<TestComponent onClick={doFoo} />', settings: componentsSettings, errors: [expectedError] },
{ code: '<Link onClick={() => void 0} to="path/to/page" />', settings: { 'jsx-a11y': { components: { Link: 'a' } } }, errors: [expectedError] },
{ code: '<TestComponent onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, errors: [expectedError] },
// Custom component with a redundant attribute
{ code: '<TestComponent2 onClick={() => void 0} to="path/to/page" />;', settings: componentsSettings, errors: [expectedError] },
// Custom component with empty attributes object
{ code: '<TestComponent3 onClick={() => void 0} to="path/to/page" />;', settings: componentsSettings, errors: [expectedError] },
// `a` with a `to` is not valid, only custom components listed in `components`
{ code: '<a onClick={() => void 0} to="path/to/page" />', settings: componentsSettings, errors: [expectedError] },
];

const recommendedOptions = configs.recommended.rules[`jsx-a11y/${ruleName}`][1] || {};
Expand Down
119 changes: 119 additions & 0 deletions __tests__/src/util/getSettingsAttributes-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import test from 'tape';

import getSettingsAttributes from '../../../src/util/getSettingsAttributes';
import JSXElementMock from '../../../__mocks__/JSXElementMock';
import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock';

test('getSettingsAttributes', (t) => {
t.test('no settings in context', (st) => {
st.deepEqual(
getSettingsAttributes(JSXElementMock('a', [JSXAttributeMock('foo', 'path/to/page')]).openingElement, {}),
[JSXAttributeMock('foo', 'path/to/page')],
'returns existing attributes when no component settings exist',
);

st.deepEqual(
getSettingsAttributes(JSXElementMock('Link', [JSXAttributeMock('foo', 'path/to/page')]).openingElement, {}),
[JSXAttributeMock('foo', 'path/to/page')],
'returns existing attributes when no component settings exist',
);

st.deepEqual(
getSettingsAttributes(JSXElementMock('Link', [JSXAttributeMock('foo', 'path/to/page')]).openingElement, {
'jsx-a11y': {},
}),
[JSXAttributeMock('foo', 'path/to/page')],
'returns existing attributes when `components` is empty',
);

st.end();
});

t.test('with component settings mapping', (st) => {
st.deepEqual(
getSettingsAttributes(JSXElementMock('Link', [JSXAttributeMock('foo', 'path/to/page')]).openingElement, {
'jsx-a11y': {
components: {
Link: {
component: 'a',
attributes: {
href: ['foo'],
},
},
},
},
}),
[JSXAttributeMock('href', 'path/to/page')],
'returns the exisiting attributes and the mapped attributes',
);

st.deepEqual(
getSettingsAttributes(JSXElementMock('Link', [JSXAttributeMock('bar', 'path/to/page')]).openingElement, {
'jsx-a11y': {
components: {
Link: {
component: 'a',
attributes: {
href: ['foo', 'bar'],
},
},
},
},
}),
[JSXAttributeMock('href', 'path/to/page')],
'returns the exisiting attributes and the mapped attributes',
);

st.deepEqual(
getSettingsAttributes(JSXElementMock('button', [JSXAttributeMock('foo', 'path/to/page')]).openingElement, {
'jsx-a11y': {
components: {
Link: {
component: 'a',
attributes: {
href: ['foo'],
},
},
},
},
}),
[JSXAttributeMock('foo', 'path/to/page')],
'should return the existing attributes when no mapping exists',
);

st.deepEqual(
getSettingsAttributes(JSXElementMock('Link', [JSXAttributeMock('bar', 'path/to/page')]).openingElement, {
'jsx-a11y': {
components: {
Link: {
component: 'a',
attributes: {
href: ['foo'],
},
},
},
},
}),
[JSXAttributeMock('bar', 'path/to/page')],
'returns the exisiting attributes when no mapping exists',
);

st.deepEqual(
getSettingsAttributes(JSXElementMock('Link', [JSXAttributeMock('foo', 'path/to/page')]).openingElement, {
settings: {
'jsx-a11y': {
components: {
Link: 'a',
},
},
},
}),
[JSXAttributeMock('foo', 'path/to/page')],
'returns existing attributes when components mapping to the component does not have attributes',
);

st.end();
});

t.end();
});
2 changes: 1 addition & 1 deletion flow/eslint.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type ESLintReport = {
export type ESLintSettings = {
[string]: mixed,
'jsx-a11y'?: {
components?: { [string]: string },
components?: { [string]: string | { component: string, attributes: { [string]: Array<string> } } },
attributes?: { for?: string[] },
polymorphicPropName?: string,
polymorphicAllowList?: Array<string>,
Expand Down
10 changes: 6 additions & 4 deletions src/rules/no-static-element-interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import isNonInteractiveElement from '../util/isNonInteractiveElement';
import isNonInteractiveRole from '../util/isNonInteractiveRole';
import isNonLiteralProperty from '../util/isNonLiteralProperty';
import isPresentationRole from '../util/isPresentationRole';
import getSettingsAttributes from '../util/getSettingsAttributes';

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.';

Expand All @@ -49,12 +50,13 @@ export default ({
},

create: (context: ESLintContext): ESLintVisitorSelectorConfig => {
const { options } = context;
const { options, settings } = context;
const elementType = getElementType(context);
return {
JSXOpeningElement: (node: JSXOpeningElement) => {
const { attributes } = node;
const type = elementType(node);
// checking global settings for attribute mappings
const attributes = getSettingsAttributes(node, settings);

const {
allowExpressionValues,
Expand All @@ -63,8 +65,8 @@ export default ({

const hasInteractiveProps = handlers
.some((prop) => (
hasProp(attributes, prop)
&& getPropValue(getProp(attributes, prop)) != null
(hasProp(attributes, prop)
&& getPropValue(getProp(attributes, prop)) != null)
));

if (!dom.has(type)) {
Expand Down
19 changes: 18 additions & 1 deletion src/util/getElementType.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,24 @@ const getElementType = (context: ESLintContext): ((node: JSXOpeningElement) => s
return rawType;
}

return hasOwn(componentMap, rawType) ? componentMap[rawType] : rawType;
const componentType = componentMap[rawType];

if (typeof componentType === 'string') {
return hasOwn(componentMap, rawType) ? componentType : rawType;
}

if (typeof componentType === 'object') {
if (componentType.component) return componentType.component;

const customComponent = Object.entries(componentType).find(([key]) => key === rawType);

if (customComponent) {
[rawType] = customComponent;
return hasOwn(componentMap, rawType) ? rawType : rawType;
}
}

return rawType;
};
};

Expand Down
68 changes: 68 additions & 0 deletions src/util/getSettingsAttributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @flow

import type { Node, JSXOpeningElement } from 'ast-types-flow';
import { elementType, getProp, propName } from 'jsx-ast-utils';
import type { ESLintSettings } from '../../flow/eslint';

/**
* Looks at the `settings` global config for custom component attribute mappings
* This works for use cases where a custom component uses a prop name that renders to a specific attribute on the DOM element
*
* @example
* {
* 'jsx-a11y': {
* components: {
* Link: {
* component: 'a',
* attributes: {
* href: ['foo'],
* },
* },
* },
* },
* }
*
* <Link foo="path/to/page" /> // will be checked as if <a href="path/to/page" />
*/
const getSettingsAttributes = (node: JSXOpeningElement, settings: ESLintSettings): Node[] => {
const { attributes: rawAttributes } = node;
const { components } = settings?.['jsx-a11y'] || {};

if (!components || typeof components !== 'object') return rawAttributes;

const jsxElementName = elementType(node);
const componentConfig = components[jsxElementName];

const { attributes: settingsAttributes } = typeof componentConfig === 'object' ? componentConfig : {};

if (!settingsAttributes || typeof settingsAttributes !== 'object') return rawAttributes;

const mappedRawAttrNames = new Set();

const mappedAttributes = Object.entries(settingsAttributes).flatMap(([originalAttr, mappedAttrs]) => {
Copy link
Author

@sarahvharris sarahvharris Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ljharb Object.entires and flatMap failing on older node versions - should I be pulling from object.*?

if (!Array.isArray(mappedAttrs)) return [];

return mappedAttrs.flatMap((mappedAttr) => {
const originalProp = getProp(rawAttributes, mappedAttr);

if (originalProp) {
mappedRawAttrNames.add(mappedAttr);
return [{
...originalProp,
name: {
...originalProp.name,
name: originalAttr,
},
}];
}
return [];
});
});

// raw attributes that don't have mappings
const unmappedAttributes = rawAttributes.filter((attr) => !mappedRawAttrNames.has(propName(attr)));

return [...unmappedAttributes, ...mappedAttributes];
};

export default getSettingsAttributes;
Loading