Skip to content

Commit c7bf278

Browse files
authored
Add rule to prevent duplicate labels on TextInput (#366)
* add rule to prevent duplicate labels * fix formating issue * patch->minor
1 parent ec701d2 commit c7bf278

File tree

6 files changed

+236
-0
lines changed

6 files changed

+236
-0
lines changed

.changeset/gold-pigs-carry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-primer-react': minor
3+
---
4+
5+
Add `a11y-no-duplicate-form-labels` rule to prevent duplicate labels on TextInput components.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
## Rule Details
2+
3+
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.
4+
5+
👎 Examples of **incorrect** code for this rule:
6+
7+
```jsx
8+
import {FormControl, TextInput} from '@primer/react'
9+
10+
function ExampleComponent() {
11+
return (
12+
// TextInput has aria-label when FormControl.Label is present
13+
<FormControl>
14+
<FormControl.Label>Form Input Label</FormControl.Label>
15+
<TextInput aria-label="Form Input Label" />
16+
</FormControl>
17+
)
18+
}
19+
```
20+
21+
👍 Examples of **correct** code for this rule:
22+
23+
```jsx
24+
import {FormControl, TextInput} from '@primer/react'
25+
26+
function ExampleComponent() {
27+
return (
28+
<>
29+
{/* TextInput without aria-label when FormControl.Label is present */}
30+
<FormControl>
31+
<FormControl.Label>Form Input Label</FormControl.Label>
32+
<TextInput />
33+
</FormControl>
34+
35+
{/* TextInput with aria-label when no FormControl.Label is present */}
36+
<FormControl>
37+
<TextInput aria-label="Form Input Label" />
38+
</FormControl>
39+
40+
{/* Using visuallyHidden FormControl.Label without aria-label */}
41+
<FormControl>
42+
<FormControl.Label visuallyHidden>Form Input Label</FormControl.Label>
43+
<TextInput />
44+
</FormControl>
45+
</>
46+
)
47+
}
48+
```

src/configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module.exports = {
1717
'primer-react/new-color-css-vars': 'error',
1818
'primer-react/a11y-explicit-heading': 'error',
1919
'primer-react/a11y-no-title-usage': 'error',
20+
'primer-react/a11y-no-duplicate-form-labels': 'error',
2021
'primer-react/no-deprecated-props': 'warn',
2122
'primer-react/a11y-remove-disable-tooltip': 'error',
2223
'primer-react/a11y-use-accessible-tooltip': 'error',

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = {
1212
'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'),
1313
'a11y-use-accessible-tooltip': require('./rules/a11y-use-accessible-tooltip'),
1414
'a11y-no-title-usage': require('./rules/a11y-no-title-usage'),
15+
'a11y-no-duplicate-form-labels': require('./rules/a11y-no-duplicate-form-labels'),
1516
'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'),
1617
'no-wildcard-imports': require('./rules/no-wildcard-imports'),
1718
'no-unnecessary-components': require('./rules/no-unnecessary-components'),
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
const rule = require('../a11y-no-duplicate-form-labels')
2+
const {RuleTester} = require('eslint')
3+
4+
const ruleTester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 'latest',
7+
sourceType: 'module',
8+
ecmaFeatures: {
9+
jsx: true,
10+
},
11+
},
12+
})
13+
14+
ruleTester.run('a11y-no-duplicate-form-labels', rule, {
15+
valid: [
16+
// TextInput without aria-label is valid
17+
`import {FormControl, TextInput} from '@primer/react';
18+
<FormControl>
19+
<FormControl.Label>Form Input Label</FormControl.Label>
20+
<TextInput />
21+
</FormControl>`,
22+
23+
// TextInput with aria-label but no FormControl.Label is valid
24+
`import {FormControl, TextInput} from '@primer/react';
25+
<FormControl>
26+
<TextInput aria-label="Form Input Label" />
27+
</FormControl>`,
28+
29+
// TextInput with aria-label outside FormControl is valid
30+
`import {TextInput} from '@primer/react';
31+
<TextInput aria-label="Form Input Label" />`,
32+
33+
// TextInput with visuallyHidden FormControl.Label is valid
34+
`import {FormControl, TextInput} from '@primer/react';
35+
<FormControl>
36+
<FormControl.Label visuallyHidden>Form Input Label</FormControl.Label>
37+
<TextInput />
38+
</FormControl>`,
39+
40+
// FormControl without FormControl.Label but with aria-label is valid
41+
`import {FormControl, TextInput} from '@primer/react';
42+
<FormControl>
43+
<TextInput aria-label="Form Input Label" />
44+
</FormControl>`,
45+
46+
// Multiple TextInputs with different approaches
47+
`import {FormControl, TextInput} from '@primer/react';
48+
<div>
49+
<FormControl>
50+
<FormControl.Label>Visible Label</FormControl.Label>
51+
<TextInput />
52+
</FormControl>
53+
<FormControl>
54+
<TextInput aria-label="Standalone Input" />
55+
</FormControl>
56+
</div>`,
57+
],
58+
invalid: [
59+
{
60+
code: `import {FormControl, TextInput} from '@primer/react';
61+
<FormControl>
62+
<FormControl.Label>Form Input Label</FormControl.Label>
63+
<TextInput aria-label="Form Input Label" />
64+
</FormControl>`,
65+
errors: [
66+
{
67+
messageId: 'duplicateLabel',
68+
},
69+
],
70+
},
71+
{
72+
code: `import {FormControl, TextInput} from '@primer/react';
73+
<FormControl>
74+
<FormControl.Label>Username</FormControl.Label>
75+
<TextInput aria-label="Enter your username" />
76+
</FormControl>`,
77+
errors: [
78+
{
79+
messageId: 'duplicateLabel',
80+
},
81+
],
82+
},
83+
{
84+
code: `import {FormControl, TextInput} from '@primer/react';
85+
<FormControl>
86+
<FormControl.Label visuallyHidden>Password</FormControl.Label>
87+
<TextInput aria-label="Enter password" />
88+
</FormControl>`,
89+
errors: [
90+
{
91+
messageId: 'duplicateLabel',
92+
},
93+
],
94+
},
95+
{
96+
code: `import {FormControl, TextInput} from '@primer/react';
97+
<div>
98+
<FormControl>
99+
<FormControl.Label>Email</FormControl.Label>
100+
<div>
101+
<TextInput aria-label="Email address" />
102+
</div>
103+
</FormControl>
104+
</div>`,
105+
errors: [
106+
{
107+
messageId: 'duplicateLabel',
108+
},
109+
],
110+
},
111+
],
112+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const {isPrimerComponent} = require('../utils/is-primer-component')
2+
const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name')
3+
const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute')
4+
5+
const isFormControl = node => getJSXOpeningElementName(node) === 'FormControl'
6+
const isFormControlLabel = node => getJSXOpeningElementName(node) === 'FormControl.Label'
7+
const isTextInput = node => getJSXOpeningElementName(node) === 'TextInput'
8+
9+
const hasAriaLabel = node => {
10+
const ariaLabel = getJSXOpeningElementAttribute(node, 'aria-label')
11+
return !!ariaLabel
12+
}
13+
14+
const findFormControlLabel = (node, sourceCode) => {
15+
// Traverse up the parent chain to find FormControl
16+
let current = node.parent
17+
while (current) {
18+
if (
19+
current.type === 'JSXElement' &&
20+
isFormControl(current.openingElement) &&
21+
isPrimerComponent(current.openingElement.name, sourceCode.getScope(current))
22+
) {
23+
// Found FormControl, now check if it has a FormControl.Label child
24+
return current.children.some(
25+
child =>
26+
child.type === 'JSXElement' &&
27+
isFormControlLabel(child.openingElement) &&
28+
isPrimerComponent(child.openingElement.name, sourceCode.getScope(child)),
29+
)
30+
}
31+
current = current.parent
32+
}
33+
return false
34+
}
35+
36+
module.exports = {
37+
meta: {
38+
type: 'problem',
39+
docs: {
40+
description:
41+
'Prevent duplicate labels on form inputs by disallowing aria-label on TextInput when FormControl.Label is present.',
42+
url: require('../url')(module),
43+
},
44+
schema: [],
45+
messages: {
46+
duplicateLabel:
47+
'TextInput should not have aria-label when FormControl.Label is present. Use FormControl.Label with visuallyHidden prop if needed.',
48+
},
49+
},
50+
create(context) {
51+
const sourceCode = context.sourceCode ?? context.getSourceCode()
52+
return {
53+
JSXOpeningElement(jsxNode) {
54+
if (isPrimerComponent(jsxNode.name, sourceCode.getScope(jsxNode)) && isTextInput(jsxNode)) {
55+
// Check if TextInput has aria-label
56+
if (hasAriaLabel(jsxNode)) {
57+
// Check if there's a FormControl.Label in the parent FormControl
58+
if (findFormControlLabel(jsxNode, sourceCode)) {
59+
context.report({
60+
node: jsxNode,
61+
messageId: 'duplicateLabel',
62+
})
63+
}
64+
}
65+
}
66+
},
67+
}
68+
},
69+
}

0 commit comments

Comments
 (0)