Skip to content

Add rule to prevent duplicate labels on TextInput #366

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

Merged
merged 3 commits into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gold-pigs-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-primer-react': patch
Copy link
Member

Choose a reason for hiding this comment

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

I think we could make this minor

---

Add `a11y-no-duplicate-form-labels` rule to prevent duplicate labels on TextInput components.
48 changes: 48 additions & 0 deletions docs/rules/a11y-no-duplicate-form-labels.md
Original file line number Diff line number Diff line change
@@ -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
<FormControl>
<FormControl.Label>Form Input Label</FormControl.Label>
<TextInput aria-label="Form Input Label" />
</FormControl>
)
}
```

👍 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 */}
<FormControl>
<FormControl.Label>Form Input Label</FormControl.Label>
<TextInput />
</FormControl>

{/* TextInput with aria-label when no FormControl.Label is present */}
<FormControl>
<TextInput aria-label="Form Input Label" />
</FormControl>

{/* Using visuallyHidden FormControl.Label without aria-label */}
<FormControl>
<FormControl.Label visuallyHidden>Form Input Label</FormControl.Label>
<TextInput />
</FormControl>
</>
)
}
```
1 change: 1 addition & 0 deletions src/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
112 changes: 112 additions & 0 deletions src/rules/__tests__/a11y-no-duplicate-form-labels.test.js
Original file line number Diff line number Diff line change
@@ -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';
<FormControl>
<FormControl.Label>Form Input Label</FormControl.Label>
<TextInput />
</FormControl>`,

// TextInput with aria-label but no FormControl.Label is valid
`import {FormControl, TextInput} from '@primer/react';
<FormControl>
<TextInput aria-label="Form Input Label" />
</FormControl>`,

// TextInput with aria-label outside FormControl is valid
`import {TextInput} from '@primer/react';
<TextInput aria-label="Form Input Label" />`,

// TextInput with visuallyHidden FormControl.Label is valid
`import {FormControl, TextInput} from '@primer/react';
<FormControl>
<FormControl.Label visuallyHidden>Form Input Label</FormControl.Label>
<TextInput />
</FormControl>`,

// FormControl without FormControl.Label but with aria-label is valid
`import {FormControl, TextInput} from '@primer/react';
<FormControl>
<TextInput aria-label="Form Input Label" />
</FormControl>`,

// Multiple TextInputs with different approaches
`import {FormControl, TextInput} from '@primer/react';
<div>
<FormControl>
<FormControl.Label>Visible Label</FormControl.Label>
<TextInput />
</FormControl>
<FormControl>
<TextInput aria-label="Standalone Input" />
</FormControl>
</div>`,
],
invalid: [
{
code: `import {FormControl, TextInput} from '@primer/react';
<FormControl>
<FormControl.Label>Form Input Label</FormControl.Label>
<TextInput aria-label="Form Input Label" />
</FormControl>`,
errors: [
{
messageId: 'duplicateLabel',
},
],
},
{
code: `import {FormControl, TextInput} from '@primer/react';
<FormControl>
<FormControl.Label>Username</FormControl.Label>
<TextInput aria-label="Enter your username" />
</FormControl>`,
errors: [
{
messageId: 'duplicateLabel',
},
],
},
{
code: `import {FormControl, TextInput} from '@primer/react';
<FormControl>
<FormControl.Label visuallyHidden>Password</FormControl.Label>
<TextInput aria-label="Enter password" />
</FormControl>`,
errors: [
{
messageId: 'duplicateLabel',
},
],
},
{
code: `import {FormControl, TextInput} from '@primer/react';
<div>
<FormControl>
<FormControl.Label>Email</FormControl.Label>
<div>
<TextInput aria-label="Email address" />
</div>
</FormControl>
</div>`,
errors: [
{
messageId: 'duplicateLabel',
},
],
},
],
})
69 changes: 69 additions & 0 deletions src/rules/a11y-no-duplicate-form-labels.js
Original file line number Diff line number Diff line change
@@ -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',
})
}
}
}
},
}
},
}