Skip to content
13 changes: 12 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,12 @@ const componentsSettings = {
components: {
Button: 'button',
TestComponent: 'div',
Link: {
component: 'a',
attributes: {
href: ['to', 'href'],
},
},
},
},
};
Expand Down Expand Up @@ -82,6 +88,8 @@ 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} href="http://x.y.z" />', settings: componentsSettings },
{ code: '<audio onClick={() => {}} />;' },
{ code: '<form onClick={() => {}} />;' },
{ code: '<form onSubmit={() => {}} />;' },
Expand Down Expand Up @@ -355,7 +363,10 @@ 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] },
// `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
15 changes: 14 additions & 1 deletion src/util/getElementType.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,20 @@ const getElementType = (context: ESLintContext): ((node: JSXOpeningElement) => s
return rawType;
}

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

if (typeof componentType === 'object') {
const customComponent = Object.entries(componentType).find(([key]) => key === rawType);

if (customComponent) {
[rawType] = customComponent;
return hasOwn(componentMap, rawType) ? rawType : rawType;
}
} else if (typeof componentType === 'string') {
return hasOwn(componentMap, rawType) ? componentType : 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