diff --git a/README.md b/README.md index 67bdfb84..ac491744 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,16 @@ _Note: This is experimental and subject to change._ 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. -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. +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`. ```json { "settings": { "github": { + "polymorphicPropName": "asChild", "components": { - "Box": {"default": "p"}, - "Link": {"props": {"as": {"undefined": "a", "a": "a", "button": "button"}}} + "Box": "p", + "Link": "a" } } } @@ -66,9 +67,7 @@ This config will be interpreted in the following way: - All `` elements will be treated as a `p` element type. - `` without a defined `as` prop will be treated as a `a`. -- `` will treated as an `a` element type. - `` will be treated as a `button` element type. -- `` will be treated as the raw `Link` type because there is no configuration set for `as='summary'`. ### Rules @@ -82,29 +81,29 @@ This config will be interpreted in the following way: 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ ❌ Deprecated. -| Name                                        | Description | 💼 | 🔧 | ❌ | -| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :- | :- | :- | -| [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. | ⚛️ | | | -| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | | ❌ | -| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | | -| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` | ✅ | | | -| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | | -| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | | -| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | | -| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | | -| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | | -| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | | -| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | | -| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | | -| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags | ✅ | | | -| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables | ✅ | | | -| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | | -| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | | -| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises | ✅ | | | -| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | | -| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | | -| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | | -| [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`. | ⚛️ | | | -| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | | +| Name                                        | Description | 💼 | 🔧 | ❌ | +| :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | :-- | :-- | :-- | +| [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. | ⚛️ | | | +| [a11y-no-generic-link-text](docs/rules/a11y-no-generic-link-text.md) | disallow generic link text | | | ❌ | +| [a11y-no-visually-hidden-interactive-element](docs/rules/a11y-no-visually-hidden-interactive-element.md) | Ensures that interactive elements are not visually hidden | ⚛️ | | | +| [array-foreach](docs/rules/array-foreach.md) | enforce `for..of` loops over `Array.forEach` | ✅ | | | +| [async-currenttarget](docs/rules/async-currenttarget.md) | disallow `event.currentTarget` calls inside of async functions | 🔍 | | | +| [async-preventdefault](docs/rules/async-preventdefault.md) | disallow `event.preventDefault` calls inside of async functions | 🔍 | | | +| [authenticity-token](docs/rules/authenticity-token.md) | disallow usage of CSRF tokens in JavaScript | 🔐 | | | +| [get-attribute](docs/rules/get-attribute.md) | disallow wrong usage of attribute names | 🔍 | 🔧 | | +| [js-class-name](docs/rules/js-class-name.md) | enforce a naming convention for js- prefixed classes | 🔐 | | | +| [no-blur](docs/rules/no-blur.md) | disallow usage of `Element.prototype.blur()` | 🔍 | | | +| [no-d-none](docs/rules/no-d-none.md) | disallow usage the `d-none` CSS class | 🔐 | | | +| [no-dataset](docs/rules/no-dataset.md) | enforce usage of `Element.prototype.getAttribute` instead of `Element.prototype.datalist` | 🔍 | | | +| [no-dynamic-script-tag](docs/rules/no-dynamic-script-tag.md) | disallow creating dynamic script tags | ✅ | | | +| [no-implicit-buggy-globals](docs/rules/no-implicit-buggy-globals.md) | disallow implicit global variables | ✅ | | | +| [no-inner-html](docs/rules/no-inner-html.md) | disallow `Element.prototype.innerHTML` in favor of `Element.prototype.textContent` | 🔍 | | | +| [no-innerText](docs/rules/no-innerText.md) | disallow `Element.prototype.innerText` in favor of `Element.prototype.textContent` | 🔍 | 🔧 | | +| [no-then](docs/rules/no-then.md) | enforce using `async/await` syntax over Promises | ✅ | | | +| [no-useless-passive](docs/rules/no-useless-passive.md) | disallow marking a event handler as passive when it has no effect | 🔍 | 🔧 | | +| [prefer-observers](docs/rules/prefer-observers.md) | disallow poorly performing event listeners | 🔍 | | | +| [require-passive-events](docs/rules/require-passive-events.md) | enforce marking high frequency event handlers as passive | 🔍 | | | +| [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`. | ⚛️ | | | +| [unescaped-html-literal](docs/rules/unescaped-html-literal.md) | disallow unescaped HTML literals | 🔍 | | | diff --git a/lib/rules/a11y-no-visually-hidden-interactive-element.js b/lib/rules/a11y-no-visually-hidden-interactive-element.js index bb2c1c42..d5f70319 100644 --- a/lib/rules/a11y-no-visually-hidden-interactive-element.js +++ b/lib/rules/a11y-no-visually-hidden-interactive-element.js @@ -4,7 +4,6 @@ const {generateObjSchema} = require('eslint-plugin-jsx-a11y/lib/util/schemas') const defaultClassName = 'sr-only' const defaultcomponentName = 'VisuallyHidden' -const defaultHtmlPropName = 'as' const schema = generateObjSchema({ className: {type: 'string'}, @@ -18,12 +17,11 @@ const schema = generateObjSchema({ */ const INTERACTIVELEMENTS = ['a', 'button', 'summary', 'select', 'option', 'textarea'] -const checkIfInteractiveElement = (context, htmlPropName, node) => { +const checkIfInteractiveElement = (context, node) => { const elementType = getElementType(context, node.openingElement) - const asProp = getPropValue(getProp(node.openingElement.attributes, htmlPropName)) for (const interactiveElement of INTERACTIVELEMENTS) { - if ((asProp ?? elementType) === interactiveElement) { + if (elementType === interactiveElement) { return true } } @@ -32,14 +30,14 @@ const checkIfInteractiveElement = (context, htmlPropName, node) => { // if the node is visually hidden recursively check if it has interactive children const checkIfVisuallyHiddenAndInteractive = (context, options, node, isParentVisuallyHidden) => { - const {className, componentName, htmlPropName} = options + const {className, componentName} = options if (node.type === 'JSXElement') { const classes = getPropValue(getProp(node.openingElement.attributes, 'className')) const isVisuallyHiddenElement = node.openingElement.name.name === componentName const hasSROnlyClass = typeof classes !== 'undefined' && classes.includes(className) let isHidden = false if (hasSROnlyClass || isVisuallyHiddenElement || !!isParentVisuallyHidden) { - if (checkIfInteractiveElement(context, htmlPropName, node)) { + if (checkIfInteractiveElement(context, node)) { return true } isHidden = true @@ -69,11 +67,10 @@ module.exports = { const config = options[0] || {} const className = config.className || defaultClassName const componentName = config.componentName || defaultcomponentName - const htmlPropName = config.htmlPropName || defaultHtmlPropName return { JSXElement: node => { - if (checkIfVisuallyHiddenAndInteractive(context, {className, componentName, htmlPropName}, node, false)) { + if (checkIfVisuallyHiddenAndInteractive(context, {className, componentName}, node, false)) { context.report({ node, message: diff --git a/lib/utils/get-element-type.js b/lib/utils/get-element-type.js index 60ce6f68..d53b893d 100644 --- a/lib/utils/get-element-type.js +++ b/lib/utils/get-element-type.js @@ -9,28 +9,18 @@ For now, we only support the mapping of one prop type to an element type, rather */ function getElementType(context, node) { const {settings} = context - const rawElement = elementType(node) - if (!settings) return rawElement - const componentMap = settings.github && settings.github.components - if (!componentMap) return rawElement - const component = componentMap[rawElement] - if (!component) return rawElement - let element = component.default ? component.default : rawElement + // check if the node contains a polymorphic prop + const polymorphicPropName = settings?.github?.polymorphicPropName ?? 'as' + const rawElement = getPropValue(getProp(node.attributes, polymorphicPropName)) ?? elementType(node) - if (component.props) { - const props = Object.entries(component.props) - for (const [key, value] of props) { - const propMap = value - const propValue = getPropValue(getProp(node.attributes, key)) - const mapValue = propMap[propValue] + // if a component configuration does not exists, return the raw element + if (!settings?.github?.components?.[rawElement]) return rawElement - if (mapValue) { - element = mapValue - } - } - } - return element + const defaultComponent = settings.github.components[rawElement] + + // check if the default component is also defined in the configuration + return defaultComponent ? defaultComponent : defaultComponent } module.exports = {getElementType} diff --git a/package.json b/package.json index c8f8e0d1..653bc80c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint:eslint-docs": "npm run update:eslint-docs -- --check", "lint:js": "eslint .", "pretest": "mkdir -p node_modules/ && ln -fs $(pwd) node_modules/", - "test": "npm run eslint-check && npm run lint && mocha tests/**/*.js tests/", + "test": "mocha tests/**/*.js tests/", "update:eslint-docs": "eslint-doc-generator" }, "repository": { @@ -65,4 +65,4 @@ "mocha": "^10.0.0", "npm-run-all": "^4.1.5" } -} +} \ No newline at end of file diff --git a/tests/a11y-no-generic-link-text.js b/tests/a11y-no-generic-link-text.js index be1ebece..d59d2034 100644 --- a/tests/a11y-no-generic-link-text.js +++ b/tests/a11y-no-generic-link-text.js @@ -26,9 +26,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, { settings: { github: { components: { - Link: { - props: {as: {undefined: 'a'}}, - }, + Link: 'a', }, }, }, @@ -41,9 +39,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, { settings: { github: { components: { - ButtonLink: { - default: 'a', - }, + ButtonLink: 'a', }, }, }, @@ -54,9 +50,7 @@ ruleTester.run('a11y-no-generic-link-text', rule, { settings: { github: { components: { - Link: { - props: {as: {undefined: 'a'}}, - }, + Link: 'a', }, }, }, @@ -64,15 +58,6 @@ ruleTester.run('a11y-no-generic-link-text', rule, { { code: 'Read more', errors: [{message: errorMessage}], - settings: { - github: { - components: { - Test: { - props: {as: {a: 'a'}}, - }, - }, - }, - }, }, { code: "Click here;", diff --git a/tests/a11y-no-visually-hidden-interactive-element.js b/tests/a11y-no-visually-hidden-interactive-element.js index 83c54401..7db8bb4a 100644 --- a/tests/a11y-no-visually-hidden-interactive-element.js +++ b/tests/a11y-no-visually-hidden-interactive-element.js @@ -44,11 +44,11 @@ ruleTester.run('a11y-no-visually-hidden-interactive-element', rule, { }, { code: "Submit", - options: [ - { - htmlPropName: 'html', + settings: { + github: { + polymorphicPropName: 'html', }, - ], + }, }, ], invalid: [ @@ -86,12 +86,12 @@ ruleTester.run('a11y-no-visually-hidden-interactive-element', rule, { }, { code: "Submit", - options: [ - { - htmlPropName: 'html', - }, - ], errors: [{message: errorMessage}], + settings: { + github: { + polymorphicPropName: 'html', + }, + }, }, ], }) diff --git a/tests/utils/get-element-type.js b/tests/utils/get-element-type.js index e1baf698..91720588 100644 --- a/tests/utils/get-element-type.js +++ b/tests/utils/get-element-type.js @@ -34,6 +34,7 @@ function mockSetting(componentSetting = {}) { settings: { github: { components: componentSetting, + polymorphicPropName: 'as', }, }, } @@ -45,64 +46,36 @@ describe('getElementType', function () { expect(getElementType({}, node)).to.equal('a') }) - it('returns element type from default if set', function () { - const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'summary')]) + it('returns polymorphic element type', function () { + const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'button')]) const setting = mockSetting({ - Link: { - default: 'button', - }, + Link: 'a', }) expect(getElementType(setting, node)).to.equal('button') }) - it('returns element type from matching props setting if set', function () { - const setting = mockSetting({ - Link: { - default: 'a', - props: { - as: {summary: 'summary'}, - }, - }, - }) - - const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'summary')]) - expect(getElementType(setting, node)).to.equal('summary') - }) - it('returns raw type if no default or matching prop setting', function () { - const setting = mockSetting({ - Link: { - props: { - as: {summary: 'summary'}, - }, - }, - }) - const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'p')]) + const setting = mockSetting({}) + + const node = mockJSXOpeningElement('Link') expect(getElementType(setting, node)).to.equal('Link') }) - it('allows undefined prop to be mapped to a type', function () { + it('returns default type if no polymorphic prop is passed in', function () { const setting = mockSetting({ - Link: { - props: { - as: {undefined: 'a'}, - }, - }, + Link: 'a', }) const node = mockJSXOpeningElement('Link') expect(getElementType(setting, node)).to.equal('a') }) - it('returns raw type if prop does not match props setting and no default type', function () { + it('if rendered as another component check its default type', function () { const setting = mockSetting({ - Link: { - props: { - as: {undefined: 'a'}, - }, - }, + Link: 'a', + Button: 'button', }) - const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'p')]) - expect(getElementType(setting, node)).to.equal('Link') + const node = mockJSXOpeningElement('Link', [mockJSXAttribute('as', 'Button')]) + expect(getElementType(setting, node)).to.equal('button') }) })