diff --git a/.changeset/gold-pigs-carry.md b/.changeset/gold-pigs-carry.md new file mode 100644 index 00000000..6320c376 --- /dev/null +++ b/.changeset/gold-pigs-carry.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-primer-react': minor +--- + +Add `a11y-no-duplicate-form-labels` rule to prevent duplicate labels on TextInput components. diff --git a/docs/rules/a11y-no-duplicate-form-labels.md b/docs/rules/a11y-no-duplicate-form-labels.md new file mode 100644 index 00000000..7dc9411b --- /dev/null +++ b/docs/rules/a11y-no-duplicate-form-labels.md @@ -0,0 +1,48 @@ +## Rule Details + +This rule prevents accessibility issues by ensuring form controls have only one label. When a `FormControl` contains both a `FormControl.Label` and a `TextInput` with an `aria-label`, it creates duplicate labels which can confuse screen readers and other assistive technologies. + +👎 Examples of **incorrect** code for this rule: + +```jsx +import {FormControl, TextInput} from '@primer/react' + +function ExampleComponent() { + return ( + // TextInput has aria-label when FormControl.Label is present + + Form Input Label + + + ) +} +``` + +👍 Examples of **correct** code for this rule: + +```jsx +import {FormControl, TextInput} from '@primer/react' + +function ExampleComponent() { + return ( + <> + {/* TextInput without aria-label when FormControl.Label is present */} + + Form Input Label + + + + {/* TextInput with aria-label when no FormControl.Label is present */} + + + + + {/* Using visuallyHidden FormControl.Label without aria-label */} + + Form Input Label + + + + ) +} +``` diff --git a/src/configs/recommended.js b/src/configs/recommended.js index 61cd07cc..28b26b96 100644 --- a/src/configs/recommended.js +++ b/src/configs/recommended.js @@ -17,6 +17,7 @@ module.exports = { 'primer-react/new-color-css-vars': 'error', 'primer-react/a11y-explicit-heading': 'error', 'primer-react/a11y-no-title-usage': 'error', + 'primer-react/a11y-no-duplicate-form-labels': 'error', 'primer-react/no-deprecated-props': 'warn', 'primer-react/a11y-remove-disable-tooltip': 'error', 'primer-react/a11y-use-accessible-tooltip': 'error', diff --git a/src/index.js b/src/index.js index 1cb3a431..68de6f20 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ module.exports = { 'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'), 'a11y-use-accessible-tooltip': require('./rules/a11y-use-accessible-tooltip'), 'a11y-no-title-usage': require('./rules/a11y-no-title-usage'), + 'a11y-no-duplicate-form-labels': require('./rules/a11y-no-duplicate-form-labels'), 'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'), 'no-wildcard-imports': require('./rules/no-wildcard-imports'), 'no-unnecessary-components': require('./rules/no-unnecessary-components'), diff --git a/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js b/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js new file mode 100644 index 00000000..a54f7cc6 --- /dev/null +++ b/src/rules/__tests__/a11y-no-duplicate-form-labels.test.js @@ -0,0 +1,112 @@ +const rule = require('../a11y-no-duplicate-form-labels') +const {RuleTester} = require('eslint') + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +ruleTester.run('a11y-no-duplicate-form-labels', rule, { + valid: [ + // TextInput without aria-label is valid + `import {FormControl, TextInput} from '@primer/react'; + + Form Input Label + + `, + + // TextInput with aria-label but no FormControl.Label is valid + `import {FormControl, TextInput} from '@primer/react'; + + + `, + + // TextInput with aria-label outside FormControl is valid + `import {TextInput} from '@primer/react'; + `, + + // TextInput with visuallyHidden FormControl.Label is valid + `import {FormControl, TextInput} from '@primer/react'; + + Form Input Label + + `, + + // FormControl without FormControl.Label but with aria-label is valid + `import {FormControl, TextInput} from '@primer/react'; + + + `, + + // Multiple TextInputs with different approaches + `import {FormControl, TextInput} from '@primer/react'; +
+ + Visible Label + + + + + +
`, + ], + invalid: [ + { + code: `import {FormControl, TextInput} from '@primer/react'; + + Form Input Label + + `, + errors: [ + { + messageId: 'duplicateLabel', + }, + ], + }, + { + code: `import {FormControl, TextInput} from '@primer/react'; + + Username + + `, + errors: [ + { + messageId: 'duplicateLabel', + }, + ], + }, + { + code: `import {FormControl, TextInput} from '@primer/react'; + + Password + + `, + errors: [ + { + messageId: 'duplicateLabel', + }, + ], + }, + { + code: `import {FormControl, TextInput} from '@primer/react'; +
+ + Email +
+ +
+
+
`, + errors: [ + { + messageId: 'duplicateLabel', + }, + ], + }, + ], +}) diff --git a/src/rules/a11y-no-duplicate-form-labels.js b/src/rules/a11y-no-duplicate-form-labels.js new file mode 100644 index 00000000..7dc4f2c4 --- /dev/null +++ b/src/rules/a11y-no-duplicate-form-labels.js @@ -0,0 +1,69 @@ +const {isPrimerComponent} = require('../utils/is-primer-component') +const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name') +const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute') + +const isFormControl = node => getJSXOpeningElementName(node) === 'FormControl' +const isFormControlLabel = node => getJSXOpeningElementName(node) === 'FormControl.Label' +const isTextInput = node => getJSXOpeningElementName(node) === 'TextInput' + +const hasAriaLabel = node => { + const ariaLabel = getJSXOpeningElementAttribute(node, 'aria-label') + return !!ariaLabel +} + +const findFormControlLabel = (node, sourceCode) => { + // Traverse up the parent chain to find FormControl + let current = node.parent + while (current) { + if ( + current.type === 'JSXElement' && + isFormControl(current.openingElement) && + isPrimerComponent(current.openingElement.name, sourceCode.getScope(current)) + ) { + // Found FormControl, now check if it has a FormControl.Label child + return current.children.some( + child => + child.type === 'JSXElement' && + isFormControlLabel(child.openingElement) && + isPrimerComponent(child.openingElement.name, sourceCode.getScope(child)), + ) + } + current = current.parent + } + return false +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Prevent duplicate labels on form inputs by disallowing aria-label on TextInput when FormControl.Label is present.', + url: require('../url')(module), + }, + schema: [], + messages: { + duplicateLabel: + 'TextInput should not have aria-label when FormControl.Label is present. Use FormControl.Label with visuallyHidden prop if needed.', + }, + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode() + return { + JSXOpeningElement(jsxNode) { + if (isPrimerComponent(jsxNode.name, sourceCode.getScope(jsxNode)) && isTextInput(jsxNode)) { + // Check if TextInput has aria-label + if (hasAriaLabel(jsxNode)) { + // Check if there's a FormControl.Label in the parent FormControl + if (findFormControlLabel(jsxNode, sourceCode)) { + context.report({ + node: jsxNode, + messageId: 'duplicateLabel', + }) + } + } + } + }, + } + }, +}