Skip to content

Commit 24dd3de

Browse files
Implement Auto-fix capabilities for multiple high-value rules
1 parent b89f94d commit 24dd3de

13 files changed

+235
-39
lines changed

lib/rules/buttons/menu-button-needs-labelling.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export default ESLintUtils.RuleCreator.withoutDocs(
2121
allowTooltipParent: true,
2222
allowDescribedBy: false,
2323
allowLabeledChild: true,
24-
allowTextContentChild: true
24+
allowTextContentChild: true,
25+
autoFix: {
26+
strategy: "aria-label-suggestion",
27+
suggestedLabel: "Open menu"
28+
}
2529
})
2630
);

lib/rules/card-needs-accessible-name.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export default ESLintUtils.RuleCreator.withoutDocs(
2121
allowTooltipParent: true,
2222
allowDescribedBy: false,
2323
allowLabeledChild: true,
24-
allowTextContentChild: true
24+
allowTextContentChild: true,
25+
autoFix: {
26+
strategy: "aria-label-suggestion",
27+
suggestedLabel: "Card"
28+
}
2529
})
2630
);

lib/rules/image-needs-alt.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ const rule = ESLintUtils.RuleCreator.withoutDocs(
2121
allowWrappingLabel: false,
2222
allowTooltipParent: false,
2323
allowDescribedBy: false,
24-
allowLabeledChild: false
24+
allowLabeledChild: false,
25+
autoFix: {
26+
strategy: "add-required-prop",
27+
propName: "alt",
28+
propValue: '""'
29+
}
2530
})
2631
);
2732

lib/rules/infolabel-needs-labelling.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export default ESLintUtils.RuleCreator.withoutDocs(
2121
allowTooltipParent: true,
2222
allowDescribedBy: false,
2323
allowLabeledChild: true,
24-
allowTextContentChild: true
24+
allowTextContentChild: true,
25+
autoFix: {
26+
strategy: "aria-label-suggestion",
27+
suggestedLabel: "Info"
28+
}
2529
})
2630
);

lib/rules/progressbar-needs-labelling.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({
2828
recommended: "strict",
2929
url: "https://www.w3.org/TR/html-aria/" // URL to the documentation page for this rule
3030
},
31+
fixable: "code",
3132
schema: []
3233
},
3334
// create (function) returns an object with methods that ESLint calls to “visit” nodes while traversing the abstract syntax tree
@@ -69,7 +70,39 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({
6970
// if it has no visual labelling, report error
7071
context.report({
7172
node,
72-
messageId: `noUnlabelledProgressbar`
73+
messageId: `noUnlabelledProgressbar`,
74+
fix(fixer) {
75+
const fixes = [];
76+
77+
// Add aria-label if neither aria-label nor aria-labelledby exist (and no Field parent)
78+
if (
79+
!hasFieldParentCheck &&
80+
!hasNonEmptyProp(node.attributes, "aria-label") &&
81+
!hasNonEmptyProp(node.attributes, "aria-labelledby")
82+
) {
83+
fixes.push(fixer.insertTextAfter(node.name, ' aria-label="Progress"'));
84+
}
85+
86+
// Add aria-describedby if missing
87+
if (!hasNonEmptyProp(node.attributes, "aria-describedby")) {
88+
fixes.push(fixer.insertTextAfter(node.name, ' aria-describedby=""'));
89+
}
90+
91+
// Add missing ARIA value attributes if max prop is not present
92+
if (!hasMaxProp) {
93+
if (!hasNonEmptyProp(node.attributes, "aria-valuemin")) {
94+
fixes.push(fixer.insertTextAfter(node.name, ' aria-valuemin="0"'));
95+
}
96+
if (!hasNonEmptyProp(node.attributes, "aria-valuemax")) {
97+
fixes.push(fixer.insertTextAfter(node.name, ' aria-valuemax="100"'));
98+
}
99+
if (!hasNonEmptyProp(node.attributes, "aria-valuenow")) {
100+
fixes.push(fixer.insertTextAfter(node.name, ' aria-valuenow="0"'));
101+
}
102+
}
103+
104+
return fixes;
105+
}
73106
});
74107
}
75108
};

lib/rules/spinner-needs-labelling.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({
2525
recommended: "strict",
2626
url: "https://www.w3.org/TR/html-aria/" // URL to the documentation page for this rule
2727
},
28+
fixable: "code",
2829
schema: []
2930
},
3031
// create (function) returns an object with methods that ESLint calls to “visit” nodes while traversing the abstract syntax tree
@@ -48,7 +49,27 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({
4849
// if it has no visual labelling, report error
4950
context.report({
5051
node,
51-
messageId: `noUnlabelledSpinner`
52+
messageId: `noUnlabelledSpinner`,
53+
fix(fixer) {
54+
const fixes = [];
55+
56+
// Add missing aria-label if neither label nor aria-label exist
57+
if (!hasNonEmptyProp(node.attributes, "label") && !hasNonEmptyProp(node.attributes, "aria-label")) {
58+
fixes.push(fixer.insertTextAfter(node.name, ' aria-label="Loading"'));
59+
}
60+
61+
// Add missing aria-live
62+
if (!hasNonEmptyProp(node.attributes, "aria-live")) {
63+
fixes.push(fixer.insertTextAfter(node.name, ' aria-live="polite"'));
64+
}
65+
66+
// Add missing aria-busy
67+
if (!hasNonEmptyProp(node.attributes, "aria-busy")) {
68+
fixes.push(fixer.insertTextAfter(node.name, ' aria-busy="true"'));
69+
}
70+
71+
return fixes;
72+
}
5273
});
5374
}
5475
};

lib/util/ruleFactory.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@ import { hasDefinedProp } from "./hasDefinedProp";
1818
import { hasTextContentChild } from "./hasTextContentChild";
1919
import { hasTriggerProp } from "./hasTriggerProp";
2020

21+
/**
22+
* Auto-fix strategy types for accessibility rules
23+
*/
24+
export type AutoFixStrategy =
25+
| "aria-label-placeholder" // Add aria-label=""
26+
| "aria-label-suggestion" // Add aria-label="[Component description]"
27+
| "add-required-prop" // Add specific required prop
28+
| "custom"; // Custom fix logic
29+
30+
/**
31+
* Auto-fix configuration for accessibility rules
32+
*/
33+
export type AutoFixConfig = {
34+
/** The auto-fix strategy to use */
35+
strategy: AutoFixStrategy;
36+
/** For add-required-prop: the prop name to add */
37+
propName?: string;
38+
/** For add-required-prop: the default prop value */
39+
propValue?: string;
40+
/** For aria-label-suggestion: the suggested label text */
41+
suggestedLabel?: string;
42+
/** For custom: custom fix function */
43+
// eslint-disable-next-line no-unused-vars
44+
customFix?: (opening: TSESTree.JSXOpeningElement) => string;
45+
};
46+
2147
/**
2248
* Configuration options for a rule created via the `ruleFactory`
2349
*/
@@ -52,6 +78,7 @@ export type LabeledControlConfig = {
5278
allowTextContentChild?: boolean; // Accept text children to provide the label e.g. <Button>Click me</Button>
5379
triggerProp?: string; // Only apply rule when this trigger prop is present (e.g., "dismissible", "disabled")
5480
customValidator?: Function; // Custom validation logic
81+
autoFix?: AutoFixConfig; // Auto-fix configuration for the rule
5582
};
5683

5784
/**
@@ -104,6 +131,41 @@ export function hasAccessibleLabel(
104131
return false;
105132
}
106133

134+
/**
135+
* Generate auto-fix for accessibility rules based on configuration
136+
*/
137+
export function generateAutoFix(opening: TSESTree.JSXOpeningElement, config: AutoFixConfig): TSESLint.ReportFixFunction | null {
138+
if (!config) return null;
139+
140+
return (fixer: TSESLint.RuleFixer) => {
141+
switch (config.strategy) {
142+
case "aria-label-placeholder": {
143+
return fixer.insertTextAfter(opening.name, ' aria-label=""');
144+
}
145+
146+
case "aria-label-suggestion": {
147+
const label = config.suggestedLabel || "Provide accessible name";
148+
return fixer.insertTextAfter(opening.name, ` aria-label="${label}"`);
149+
}
150+
151+
case "add-required-prop": {
152+
if (!config.propName) return null;
153+
const value = config.propValue || '""';
154+
return fixer.insertTextAfter(opening.name, ` ${config.propName}=${value}`);
155+
}
156+
157+
case "custom": {
158+
if (!config.customFix) return null;
159+
const fixText = config.customFix(opening);
160+
return fixer.insertTextAfter(opening.name, fixText);
161+
}
162+
163+
default:
164+
return null;
165+
}
166+
};
167+
}
168+
107169
/**
108170
* Factory for a minimal, strongly-configurable ESLint rule that enforces
109171
* accessible labeling on a specific JSX element/component.
@@ -118,6 +180,7 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R
118180
recommended: "strict",
119181
url: "https://www.w3.org/TR/html-aria/"
120182
},
183+
fixable: config.autoFix ? "code" : undefined,
121184
schema: []
122185
},
123186
defaultOptions: [],
@@ -142,8 +205,15 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R
142205
: (isValid = hasAccessibleLabel(opening, node, context, config));
143206

144207
if (!isValid) {
208+
// Generate auto-fix if configuration is provided
209+
const autoFix = config.autoFix ? generateAutoFix(opening, config.autoFix) : undefined;
210+
145211
// report on the opening tag for better location
146-
context.report({ node: opening, messageId: config.messageId });
212+
context.report({
213+
node: opening,
214+
messageId: config.messageId,
215+
fix: autoFix
216+
});
147217
}
148218
}
149219
};

tests/lib/rules/buttons/menu-button-needs-labelling.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,23 @@ ruleTester.run("menu-button-needs-labelling", rule as unknown as Rule.RuleModule
2525
invalid: [
2626
{
2727
code: `<MenuButton />`,
28-
errors: [{ messageId: "menuButtonNeedsLabelling" }]
28+
errors: [{ messageId: "menuButtonNeedsLabelling" }],
29+
output: `<MenuButton aria-label="Open menu" />`
2930
},
3031
{
3132
code: `<MenuButton></MenuButton>`,
32-
errors: [{ messageId: "menuButtonNeedsLabelling" }]
33+
errors: [{ messageId: "menuButtonNeedsLabelling" }],
34+
output: `<MenuButton aria-label="Open menu"></MenuButton>`
3335
},
3436
{
3537
code: `<MenuButton aria-label="" />`,
36-
errors: [{ messageId: "menuButtonNeedsLabelling" }]
38+
errors: [{ messageId: "menuButtonNeedsLabelling" }],
39+
output: `<MenuButton aria-label="Open menu" aria-label="" />`
3740
},
3841
{
3942
code: `<><Label id="wrong-id">Options</Label><MenuButton aria-labelledby="menu-label" /></>`,
40-
errors: [{ messageId: "menuButtonNeedsLabelling" }]
43+
errors: [{ messageId: "menuButtonNeedsLabelling" }],
44+
output: `<><Label id="wrong-id">Options</Label><MenuButton aria-label="Open menu" aria-labelledby="menu-label" /></>`
4145
}
4246
]
4347
});

tests/lib/rules/card-needs-accessible-name.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,23 @@ ruleTester.run("card-needs-accessible-name", rule as unknown as Rule.RuleModule,
2323
invalid: [
2424
{
2525
code: `<Card />`,
26-
errors: [{ messageId: "cardNeedsAccessibleName" }]
26+
errors: [{ messageId: "cardNeedsAccessibleName" }],
27+
output: `<Card aria-label="Card" />`
2728
},
2829
{
2930
code: `<Card></Card>`,
30-
errors: [{ messageId: "cardNeedsAccessibleName" }]
31+
errors: [{ messageId: "cardNeedsAccessibleName" }],
32+
output: `<Card aria-label="Card"></Card>`
3133
},
3234
{
3335
code: `<Card aria-label="" />`,
34-
errors: [{ messageId: "cardNeedsAccessibleName" }]
36+
errors: [{ messageId: "cardNeedsAccessibleName" }],
37+
output: `<Card aria-label="Card" aria-label="" />`
3538
},
3639
{
3740
code: `<><Label id="wrong-id">Product</Label><Card aria-labelledby="card-label" /></>`,
38-
errors: [{ messageId: "cardNeedsAccessibleName" }]
41+
errors: [{ messageId: "cardNeedsAccessibleName" }],
42+
output: `<><Label id="wrong-id">Product</Label><Card aria-label="Card" aria-labelledby="card-label" /></>`
3943
}
4044
]
4145
});

tests/lib/rules/image-needs-alt.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,20 @@ ruleTester.run("image-needs-alt", rule as unknown as Rule.RuleModule, {
2424
{
2525
// No alt attribute
2626
code: '<Image src="image.png" />',
27-
errors: [{ messageId: "imageNeedsAlt" }]
27+
errors: [{ messageId: "imageNeedsAlt" }],
28+
output: '<Image alt="" src="image.png" />'
2829
},
2930
{
3031
// Null alt attribute
3132
code: '<Image src="image.png" alt={null} />',
32-
errors: [{ messageId: "imageNeedsAlt" }]
33+
errors: [{ messageId: "imageNeedsAlt" }],
34+
output: '<Image alt="" src="image.png" alt={null} />'
3335
},
3436
{
3537
// Undefined alt attribute
3638
code: '<Image src="image.png" alt={undefined} />',
37-
errors: [{ messageId: "imageNeedsAlt" }]
39+
errors: [{ messageId: "imageNeedsAlt" }],
40+
output: '<Image alt="" src="image.png" alt={undefined} />'
3841
}
3942
]
4043
});

0 commit comments

Comments
 (0)