diff --git a/README.md b/README.md index 75cdd6b..c73245e 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po | [avoid-using-aria-describedby-for-primary-labelling](docs/rules/avoid-using-aria-describedby-for-primary-labelling.md) | aria-describedby provides additional context and is not meant for primary labeling. | βœ… | | | | [badge-needs-accessible-name](docs/rules/badge-needs-accessible-name.md) | | βœ… | | πŸ”§ | | [breadcrumb-needs-labelling](docs/rules/breadcrumb-needs-labelling.md) | All interactive elements must have an accessible name | βœ… | | | -| [card-needs-accessible-name](docs/rules/card-needs-accessible-name.md) | Accessibility: Interactive Card must have an accessible name via aria-label, aria-labelledby, etc. | βœ… | | | +| [card-needs-accessible-name](docs/rules/card-needs-accessible-name.md) | Accessibility: Interactive Card must have an accessible name via aria-label, aria-labelledby, etc. | βœ… | | πŸ”§ | | [checkbox-needs-labelling](docs/rules/checkbox-needs-labelling.md) | Accessibility: Checkbox without label must have an accessible and visual label: aria-labelledby | βœ… | | | | [colorswatch-needs-labelling](docs/rules/colorswatch-needs-labelling.md) | Accessibility: ColorSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | βœ… | | | | [combobox-needs-labelling](docs/rules/combobox-needs-labelling.md) | All interactive elements must have an accessible name | βœ… | | | @@ -191,24 +191,24 @@ Any use of third-party trademarks or logos are subject to those third-party's po | [emptyswatch-needs-labelling](docs/rules/emptyswatch-needs-labelling.md) | Accessibility: EmptySwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | βœ… | | | | [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | βœ… | | | | [image-button-missing-aria](docs/rules/image-button-missing-aria.md) | Accessibility: Image buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | βœ… | | | -| [image-needs-alt](docs/rules/image-needs-alt.md) | Accessibility: Image must have alt attribute with a meaningful description of the image. If the image is decorative, use alt="". | βœ… | | | +| [image-needs-alt](docs/rules/image-needs-alt.md) | Accessibility: Image must have alt attribute with a meaningful description of the image. If the image is decorative, use alt="". | βœ… | | πŸ”§ | | [imageswatch-needs-labelling](docs/rules/imageswatch-needs-labelling.md) | Accessibility: ImageSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | βœ… | | | -| [infolabel-needs-labelling](docs/rules/infolabel-needs-labelling.md) | Accessibility: InfoLabel must have an accessible name via aria-label, text content, aria-labelledby, etc. | βœ… | | | +| [infolabel-needs-labelling](docs/rules/infolabel-needs-labelling.md) | Accessibility: InfoLabel must have an accessible name via aria-label, text content, aria-labelledby, etc. | βœ… | | πŸ”§ | | [input-components-require-accessible-name](docs/rules/input-components-require-accessible-name.md) | Accessibility: Input fields must have accessible labelling: aria-label, aria-labelledby or an associated label | βœ… | | | | [link-missing-labelling](docs/rules/link-missing-labelling.md) | Accessibility: Image links must have an accessible name. Add either text content, labelling to the image or labelling to the link itself. | βœ… | | πŸ”§ | -| [menu-button-needs-labelling](docs/rules/menu-button-needs-labelling.md) | Accessibility: MenuButton must have an accessible name via aria-label, text content, aria-labelledby, etc. | βœ… | | | +| [menu-button-needs-labelling](docs/rules/menu-button-needs-labelling.md) | Accessibility: MenuButton must have an accessible name via aria-label, text content, aria-labelledby, etc. | βœ… | | πŸ”§ | | [menu-item-needs-labelling](docs/rules/menu-item-needs-labelling.md) | Accessibility: MenuItem without label must have an accessible and visual label: aria-labelledby | βœ… | | | | [no-empty-buttons](docs/rules/no-empty-buttons.md) | Accessibility: Button, ToggleButton, SplitButton, MenuButton, CompoundButton must either text content or icon or child component | βœ… | | | | [no-empty-components](docs/rules/no-empty-components.md) | FluentUI components should not be empty | βœ… | | | | [prefer-aria-over-title-attribute](docs/rules/prefer-aria-over-title-attribute.md) | The title attribute is not consistently read by screen readers, and its behavior can vary depending on the screen reader and the user's settings. | | βœ… | πŸ”§ | | [prefer-disabledfocusable-over-disabled](docs/rules/prefer-disabledfocusable-over-disabled.md) | Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility | | βœ… | πŸ”§ | -| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | βœ… | | | +| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | βœ… | | πŸ”§ | | [radio-button-missing-label](docs/rules/radio-button-missing-label.md) | Accessibility: Radio button without label must have an accessible and visual label: aria-labelledby | βœ… | | | | [radiogroup-missing-label](docs/rules/radiogroup-missing-label.md) | Accessibility: RadioGroup without label must have an accessible and visual label: aria-labelledby | βœ… | | | | [rating-needs-name](docs/rules/rating-needs-name.md) | Accessibility: Ratings must have accessible labelling: name, aria-label, aria-labelledby or itemLabel which generates aria-label | βœ… | | | | [spin-button-needs-labelling](docs/rules/spin-button-needs-labelling.md) | Accessibility: SpinButtons must have an accessible label | βœ… | | | | [spin-button-unrecommended-labelling](docs/rules/spin-button-unrecommended-labelling.md) | Accessibility: Unrecommended accessibility labelling - SpinButton | βœ… | | | -| [spinner-needs-labelling](docs/rules/spinner-needs-labelling.md) | Accessibility: Spinner must have either aria-label or label, aria-live and aria-busy attributes | βœ… | | | +| [spinner-needs-labelling](docs/rules/spinner-needs-labelling.md) | Accessibility: Spinner must have either aria-label or label, aria-live and aria-busy attributes | βœ… | | πŸ”§ | | [switch-needs-labelling](docs/rules/switch-needs-labelling.md) | Accessibility: Switch must have an accessible label | βœ… | | | | [table-needs-labelling](docs/rules/table-needs-labelling.md) | Accessibility: Table must have proper labelling and semantic structure for screen readers | βœ… | | | | [tablist-and-tabs-need-labelling](docs/rules/tablist-and-tabs-need-labelling.md) | This rule aims to ensure that Tabs with icons but no text labels have an accessible name and that Tablist is properly labeled. | βœ… | | | diff --git a/docs/rules/card-needs-accessible-name.md b/docs/rules/card-needs-accessible-name.md index 25fedaf..5b4dd6a 100644 --- a/docs/rules/card-needs-accessible-name.md +++ b/docs/rules/card-needs-accessible-name.md @@ -2,6 +2,8 @@ πŸ’Ό This rule is enabled in the βœ… `recommended` config. +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + Interactive Card components must have an accessible name for screen readers. diff --git a/docs/rules/image-needs-alt.md b/docs/rules/image-needs-alt.md index 452769c..d3304b7 100644 --- a/docs/rules/image-needs-alt.md +++ b/docs/rules/image-needs-alt.md @@ -2,6 +2,8 @@ πŸ’Ό This rule is enabled in the βœ… `recommended` config. +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + ## Rule details diff --git a/docs/rules/infolabel-needs-labelling.md b/docs/rules/infolabel-needs-labelling.md index ddc01b4..1d9c845 100644 --- a/docs/rules/infolabel-needs-labelling.md +++ b/docs/rules/infolabel-needs-labelling.md @@ -2,6 +2,8 @@ πŸ’Ό This rule is enabled in the βœ… `recommended` config. +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + InfoLabel components must have accessible labelling for screen readers. diff --git a/docs/rules/menu-button-needs-labelling.md b/docs/rules/menu-button-needs-labelling.md index d2dca03..2950403 100644 --- a/docs/rules/menu-button-needs-labelling.md +++ b/docs/rules/menu-button-needs-labelling.md @@ -2,6 +2,8 @@ πŸ’Ό This rule is enabled in the βœ… `recommended` config. +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + MenuButton components must have accessible labelling for screen readers. diff --git a/docs/rules/progressbar-needs-labelling.md b/docs/rules/progressbar-needs-labelling.md index 48247ff..d41d1b7 100644 --- a/docs/rules/progressbar-needs-labelling.md +++ b/docs/rules/progressbar-needs-labelling.md @@ -2,6 +2,8 @@ πŸ’Ό This rule is enabled in the βœ… `recommended` config. +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + ProgressBar must have `max` or `aria-valuemin`, `aria-valuemax` and `aria-valuenow` attributes. It also must have an accessible `Field` parent or appropriate labelling using `aria-describedby` and `aria-label`/`aria-labelledby` . diff --git a/docs/rules/spinner-needs-labelling.md b/docs/rules/spinner-needs-labelling.md index 08ae0ea..34c85f9 100644 --- a/docs/rules/spinner-needs-labelling.md +++ b/docs/rules/spinner-needs-labelling.md @@ -2,6 +2,8 @@ πŸ’Ό This rule is enabled in the βœ… `recommended` config. +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + Spinner must have either aria-label or label, aria-live and aria-busy attributes. diff --git a/lib/rules/buttons/menu-button-needs-labelling.ts b/lib/rules/buttons/menu-button-needs-labelling.ts index e1cc841..851ae6f 100644 --- a/lib/rules/buttons/menu-button-needs-labelling.ts +++ b/lib/rules/buttons/menu-button-needs-labelling.ts @@ -21,6 +21,10 @@ export default ESLintUtils.RuleCreator.withoutDocs( allowTooltipParent: true, allowDescribedBy: false, allowLabeledChild: true, - allowTextContentChild: true + allowTextContentChild: true, + autoFix: { + strategy: "aria-label-suggestion", + suggestedLabel: "Open menu" + } }) ); diff --git a/lib/rules/card-needs-accessible-name.ts b/lib/rules/card-needs-accessible-name.ts index 6095db2..9f88d11 100644 --- a/lib/rules/card-needs-accessible-name.ts +++ b/lib/rules/card-needs-accessible-name.ts @@ -21,6 +21,10 @@ export default ESLintUtils.RuleCreator.withoutDocs( allowTooltipParent: true, allowDescribedBy: false, allowLabeledChild: true, - allowTextContentChild: true + allowTextContentChild: true, + autoFix: { + strategy: "aria-label-suggestion", + suggestedLabel: "Card" + } }) ); diff --git a/lib/rules/image-needs-alt.ts b/lib/rules/image-needs-alt.ts index 8d8f2fb..13b04ad 100644 --- a/lib/rules/image-needs-alt.ts +++ b/lib/rules/image-needs-alt.ts @@ -21,7 +21,12 @@ const rule = ESLintUtils.RuleCreator.withoutDocs( allowWrappingLabel: false, allowTooltipParent: false, allowDescribedBy: false, - allowLabeledChild: false + allowLabeledChild: false, + autoFix: { + strategy: "add-required-prop", + propName: "alt", + propValue: '""' + } }) ); diff --git a/lib/rules/infolabel-needs-labelling.ts b/lib/rules/infolabel-needs-labelling.ts index 3587e10..3721572 100644 --- a/lib/rules/infolabel-needs-labelling.ts +++ b/lib/rules/infolabel-needs-labelling.ts @@ -21,6 +21,10 @@ export default ESLintUtils.RuleCreator.withoutDocs( allowTooltipParent: true, allowDescribedBy: false, allowLabeledChild: true, - allowTextContentChild: true + allowTextContentChild: true, + autoFix: { + strategy: "aria-label-suggestion", + suggestedLabel: "Info" + } }) ); diff --git a/lib/rules/progressbar-needs-labelling.ts b/lib/rules/progressbar-needs-labelling.ts index 37ad191..da79347 100644 --- a/lib/rules/progressbar-needs-labelling.ts +++ b/lib/rules/progressbar-needs-labelling.ts @@ -28,6 +28,7 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ recommended: "strict", url: "https://www.w3.org/TR/html-aria/" // URL to the documentation page for this rule }, + fixable: "code", schema: [] }, // 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({ // if it has no visual labelling, report error context.report({ node, - messageId: `noUnlabelledProgressbar` + messageId: `noUnlabelledProgressbar`, + fix(fixer) { + const fixes = []; + + // Add aria-label if neither aria-label nor aria-labelledby exist (and no Field parent) + if ( + !hasFieldParentCheck && + !hasNonEmptyProp(node.attributes, "aria-label") && + !hasNonEmptyProp(node.attributes, "aria-labelledby") + ) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-label="Progress"')); + } + + // Add aria-describedby if missing + if (!hasNonEmptyProp(node.attributes, "aria-describedby")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-describedby=""')); + } + + // Add missing ARIA value attributes if max prop is not present + if (!hasMaxProp) { + if (!hasNonEmptyProp(node.attributes, "aria-valuemin")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-valuemin="0"')); + } + if (!hasNonEmptyProp(node.attributes, "aria-valuemax")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-valuemax="100"')); + } + if (!hasNonEmptyProp(node.attributes, "aria-valuenow")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-valuenow="0"')); + } + } + + return fixes; + } }); } }; diff --git a/lib/rules/spinner-needs-labelling.ts b/lib/rules/spinner-needs-labelling.ts index 5005cc6..c246f12 100644 --- a/lib/rules/spinner-needs-labelling.ts +++ b/lib/rules/spinner-needs-labelling.ts @@ -25,6 +25,7 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ recommended: "strict", url: "https://www.w3.org/TR/html-aria/" // URL to the documentation page for this rule }, + fixable: "code", schema: [] }, // 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({ // if it has no visual labelling, report error context.report({ node, - messageId: `noUnlabelledSpinner` + messageId: `noUnlabelledSpinner`, + fix(fixer) { + const fixes = []; + + // Add missing aria-label if neither label nor aria-label exist + if (!hasNonEmptyProp(node.attributes, "label") && !hasNonEmptyProp(node.attributes, "aria-label")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-label="Loading"')); + } + + // Add missing aria-live + if (!hasNonEmptyProp(node.attributes, "aria-live")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-live="polite"')); + } + + // Add missing aria-busy + if (!hasNonEmptyProp(node.attributes, "aria-busy")) { + fixes.push(fixer.insertTextAfter(node.name, ' aria-busy="true"')); + } + + return fixes; + } }); } }; diff --git a/lib/util/ruleFactory.ts b/lib/util/ruleFactory.ts index b245044..b6fdee9 100644 --- a/lib/util/ruleFactory.ts +++ b/lib/util/ruleFactory.ts @@ -18,6 +18,32 @@ import { hasDefinedProp } from "./hasDefinedProp"; import { hasTextContentChild } from "./hasTextContentChild"; import { hasTriggerProp } from "./hasTriggerProp"; +/** + * Auto-fix strategy types for accessibility rules + */ +export type AutoFixStrategy = + | "aria-label-placeholder" // Add aria-label="" + | "aria-label-suggestion" // Add aria-label="[Component description]" + | "add-required-prop" // Add specific required prop + | "custom"; // Custom fix logic + +/** + * Auto-fix configuration for accessibility rules + */ +export type AutoFixConfig = { + /** The auto-fix strategy to use */ + strategy: AutoFixStrategy; + /** For add-required-prop: the prop name to add */ + propName?: string; + /** For add-required-prop: the default prop value */ + propValue?: string; + /** For aria-label-suggestion: the suggested label text */ + suggestedLabel?: string; + /** For custom: custom fix function */ + // eslint-disable-next-line no-unused-vars + customFix?: (opening: TSESTree.JSXOpeningElement) => string; +}; + /** * Configuration options for a rule created via the `ruleFactory` */ @@ -52,6 +78,7 @@ export type LabeledControlConfig = { allowTextContentChild?: boolean; // Accept text children to provide the label e.g. triggerProp?: string; // Only apply rule when this trigger prop is present (e.g., "dismissible", "disabled") customValidator?: Function; // Custom validation logic + autoFix?: AutoFixConfig; // Auto-fix configuration for the rule }; /** @@ -104,6 +131,41 @@ export function hasAccessibleLabel( return false; } +/** + * Generate auto-fix for accessibility rules based on configuration + */ +export function generateAutoFix(opening: TSESTree.JSXOpeningElement, config: AutoFixConfig): TSESLint.ReportFixFunction | null { + if (!config) return null; + + return (fixer: TSESLint.RuleFixer) => { + switch (config.strategy) { + case "aria-label-placeholder": { + return fixer.insertTextAfter(opening.name, ' aria-label=""'); + } + + case "aria-label-suggestion": { + const label = config.suggestedLabel || "Provide accessible name"; + return fixer.insertTextAfter(opening.name, ` aria-label="${label}"`); + } + + case "add-required-prop": { + if (!config.propName) return null; + const value = config.propValue || '""'; + return fixer.insertTextAfter(opening.name, ` ${config.propName}=${value}`); + } + + case "custom": { + if (!config.customFix) return null; + const fixText = config.customFix(opening); + return fixer.insertTextAfter(opening.name, fixText); + } + + default: + return null; + } + }; +} + /** * Factory for a minimal, strongly-configurable ESLint rule that enforces * accessible labeling on a specific JSX element/component. @@ -118,6 +180,7 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R recommended: "strict", url: "https://www.w3.org/TR/html-aria/" }, + fixable: config.autoFix ? "code" : undefined, schema: [] }, defaultOptions: [], @@ -142,8 +205,15 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R : (isValid = hasAccessibleLabel(opening, node, context, config)); if (!isValid) { + // Generate auto-fix if configuration is provided + const autoFix = config.autoFix ? generateAutoFix(opening, config.autoFix) : undefined; + // report on the opening tag for better location - context.report({ node: opening, messageId: config.messageId }); + context.report({ + node: opening, + messageId: config.messageId, + fix: autoFix + }); } } }; diff --git a/tests/lib/rules/buttons/menu-button-needs-labelling.test.ts b/tests/lib/rules/buttons/menu-button-needs-labelling.test.ts index 113dfb0..d8d3e2f 100644 --- a/tests/lib/rules/buttons/menu-button-needs-labelling.test.ts +++ b/tests/lib/rules/buttons/menu-button-needs-labelling.test.ts @@ -25,19 +25,23 @@ ruleTester.run("menu-button-needs-labelling", rule as unknown as Rule.RuleModule invalid: [ { code: ``, - errors: [{ messageId: "menuButtonNeedsLabelling" }] + errors: [{ messageId: "menuButtonNeedsLabelling" }], + output: `` }, { code: ``, - errors: [{ messageId: "menuButtonNeedsLabelling" }] + errors: [{ messageId: "menuButtonNeedsLabelling" }], + output: `` }, { code: ``, - errors: [{ messageId: "menuButtonNeedsLabelling" }] + errors: [{ messageId: "menuButtonNeedsLabelling" }], + output: `` }, { code: `<>`, - errors: [{ messageId: "menuButtonNeedsLabelling" }] + errors: [{ messageId: "menuButtonNeedsLabelling" }], + output: `<>` } ] }); diff --git a/tests/lib/rules/card-needs-accessible-name.test.ts b/tests/lib/rules/card-needs-accessible-name.test.ts index 4cddd19..d8afda7 100644 --- a/tests/lib/rules/card-needs-accessible-name.test.ts +++ b/tests/lib/rules/card-needs-accessible-name.test.ts @@ -23,19 +23,23 @@ ruleTester.run("card-needs-accessible-name", rule as unknown as Rule.RuleModule, invalid: [ { code: ``, - errors: [{ messageId: "cardNeedsAccessibleName" }] + errors: [{ messageId: "cardNeedsAccessibleName" }], + output: `` }, { code: ``, - errors: [{ messageId: "cardNeedsAccessibleName" }] + errors: [{ messageId: "cardNeedsAccessibleName" }], + output: `` }, { code: ``, - errors: [{ messageId: "cardNeedsAccessibleName" }] + errors: [{ messageId: "cardNeedsAccessibleName" }], + output: `` }, { code: `<>`, - errors: [{ messageId: "cardNeedsAccessibleName" }] + errors: [{ messageId: "cardNeedsAccessibleName" }], + output: `<>` } ] }); diff --git a/tests/lib/rules/image-needs-alt.test.ts b/tests/lib/rules/image-needs-alt.test.ts index ad1f9b5..5a5401e 100644 --- a/tests/lib/rules/image-needs-alt.test.ts +++ b/tests/lib/rules/image-needs-alt.test.ts @@ -24,17 +24,20 @@ ruleTester.run("image-needs-alt", rule as unknown as Rule.RuleModule, { { // No alt attribute code: '', - errors: [{ messageId: "imageNeedsAlt" }] + errors: [{ messageId: "imageNeedsAlt" }], + output: '' }, { // Null alt attribute code: '{null}', - errors: [{ messageId: "imageNeedsAlt" }] + errors: [{ messageId: "imageNeedsAlt" }], + output: '' }, { // Undefined alt attribute code: '{undefined}', - errors: [{ messageId: "imageNeedsAlt" }] + errors: [{ messageId: "imageNeedsAlt" }], + output: '' } ] }); diff --git a/tests/lib/rules/infolabel-needs-labelling.test.ts b/tests/lib/rules/infolabel-needs-labelling.test.ts index ac1ee2a..09877ec 100644 --- a/tests/lib/rules/infolabel-needs-labelling.test.ts +++ b/tests/lib/rules/infolabel-needs-labelling.test.ts @@ -27,31 +27,38 @@ ruleTester.run("infolabel-needs-labelling", rule as unknown as Rule.RuleModule, invalid: [ { code: ``, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `` }, { code: ``, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `` }, { code: ``, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `` }, { code: ``, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `` }, { code: `<>`, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `<>` }, { code: ``, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `` }, { code: ``, - errors: [{ messageId: "infoLabelNeedsLabelling" }] + errors: [{ messageId: "infoLabelNeedsLabelling" }], + output: `` } ] }); diff --git a/tests/lib/rules/progressbar-needs-labelling.test.ts b/tests/lib/rules/progressbar-needs-labelling.test.ts index a46e26c..c16a65c 100644 --- a/tests/lib/rules/progressbar-needs-labelling.test.ts +++ b/tests/lib/rules/progressbar-needs-labelling.test.ts @@ -59,7 +59,14 @@ ruleTester.run("progressbar-needs-labelling", rule as unknown as Rule.RuleModule > `, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: ` + + ` }, { code: ` `, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: ` + + ` }, { code: ` `, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: ` + + ` }, { code: ` `, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: ` + + ` }, { code: ``, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledProgressbar" }] + errors: [{ messageId: "noUnlabelledProgressbar" }], + output: `` } ] }); diff --git a/tests/lib/rules/spinner-needs-labelling.test.ts b/tests/lib/rules/spinner-needs-labelling.test.ts index 2eeb622..6f1b316 100644 --- a/tests/lib/rules/spinner-needs-labelling.test.ts +++ b/tests/lib/rules/spinner-needs-labelling.test.ts @@ -23,27 +23,33 @@ ruleTester.run("spinner-needs-labelling", rule as unknown as Rule.RuleModule, { invalid: [ { code: ``, - errors: [{ messageId: "noUnlabelledSpinner" }] + errors: [{ messageId: "noUnlabelledSpinner" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledSpinner" }] + errors: [{ messageId: "noUnlabelledSpinner" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledSpinner" }] + errors: [{ messageId: "noUnlabelledSpinner" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledSpinner" }] + errors: [{ messageId: "noUnlabelledSpinner" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledSpinner" }] + errors: [{ messageId: "noUnlabelledSpinner" }], + output: `` }, { code: ``, - errors: [{ messageId: "noUnlabelledSpinner" }] + errors: [{ messageId: "noUnlabelledSpinner" }], + output: `` } ] }); diff --git a/tests/lib/rules/utils/ruleFactory.test.ts b/tests/lib/rules/utils/ruleFactory.test.ts index 178e18a..6a83141 100644 --- a/tests/lib/rules/utils/ruleFactory.test.ts +++ b/tests/lib/rules/utils/ruleFactory.test.ts @@ -17,7 +17,13 @@ import { hasAssociatedLabelViaHtmlFor, isInsideLabelTag } from "../../../../lib/util/labelUtils"; -import { hasAccessibleLabel, LabeledControlConfig, makeLabeledControlRule } from "../../../../lib/util/ruleFactory"; +import { + hasAccessibleLabel, + LabeledControlConfig, + makeLabeledControlRule, + generateAutoFix, + AutoFixConfig +} from "../../../../lib/util/ruleFactory"; jest.mock("../../../../lib/util/hasDefinedProp", () => ({ hasDefinedProp: jest.fn() @@ -526,3 +532,219 @@ describe("makeLabeledControlRule (RuleTester integration)", () => { }); }); }); + +// Tests for auto-fix functionality +describe("generateAutoFix", () => { + const mockFixer = { + insertTextAfter: jest.fn().mockReturnValue({ type: "fix" }), + insertTextBefore: jest.fn().mockReturnValue({ type: "fix" }), + replaceText: jest.fn().mockReturnValue({ type: "fix" }), + replaceTextRange: jest.fn().mockReturnValue({ type: "fix" }), + removeRange: jest.fn().mockReturnValue({ type: "fix" }) + } as unknown as TSESLint.RuleFixer; + + const mockOpening = { + type: AST_NODE_TYPES.JSXOpeningElement, + name: { + type: AST_NODE_TYPES.JSXIdentifier, + name: "Button", + range: [0, 6] as [number, number], + loc: {} as any + }, + attributes: [], + selfClosing: false, + range: [0, 8] as [number, number], + loc: {} as any + } as TSESTree.JSXOpeningElement; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("returns null when config is falsy", () => { + const result = generateAutoFix(mockOpening, null as any); + expect(result).toBeNull(); + }); + + test("aria-label-placeholder strategy", () => { + const config: AutoFixConfig = { + strategy: "aria-label-placeholder" + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' aria-label=""'); + expect(result).toEqual({ type: "fix" }); + }); + + test("aria-label-suggestion strategy with default label", () => { + const config: AutoFixConfig = { + strategy: "aria-label-suggestion" + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' aria-label="Provide accessible name"'); + expect(result).toEqual({ type: "fix" }); + }); + + test("aria-label-suggestion strategy with custom label", () => { + const config: AutoFixConfig = { + strategy: "aria-label-suggestion", + suggestedLabel: "Custom button label" + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' aria-label="Custom button label"'); + expect(result).toEqual({ type: "fix" }); + }); + + test("add-required-prop strategy with propName only", () => { + const config: AutoFixConfig = { + strategy: "add-required-prop", + propName: "alt" + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' alt=""'); + expect(result).toEqual({ type: "fix" }); + }); + + test("add-required-prop strategy with propName and propValue", () => { + const config: AutoFixConfig = { + strategy: "add-required-prop", + propName: "role", + propValue: '"button"' + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' role="button"'); + expect(result).toEqual({ type: "fix" }); + }); + + test("add-required-prop strategy returns null when propName is missing", () => { + const config: AutoFixConfig = { + strategy: "add-required-prop" + // propName is missing + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(result).toBeNull(); + expect(mockFixer.insertTextAfter).not.toHaveBeenCalled(); + }); + + test("custom strategy with customFix function", () => { + const mockCustomFix = jest.fn().mockReturnValue(' custom-attribute="value"'); + const config: AutoFixConfig = { + strategy: "custom", + customFix: mockCustomFix + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(mockCustomFix).toHaveBeenCalledWith(mockOpening); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' custom-attribute="value"'); + expect(result).toEqual({ type: "fix" }); + }); + + test("custom strategy returns null when customFix is missing", () => { + const config: AutoFixConfig = { + strategy: "custom" + // customFix is missing + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(result).toBeNull(); + expect(mockFixer.insertTextAfter).not.toHaveBeenCalled(); + }); + + test("default case returns null for unknown strategy", () => { + const config: AutoFixConfig = { + strategy: "unknown-strategy" as any + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + + const result = fixFunction!(mockFixer); + expect(result).toBeNull(); + expect(mockFixer.insertTextAfter).not.toHaveBeenCalled(); + }); +}); + +// Tests for auto-fix integration in makeLabeledControlRule +describe("makeLabeledControlRule with auto-fix", () => { + beforeEach(() => { + resetAllMocksToFalse(); + }); + + test("generateAutoFix integration - fix function is generated correctly", () => { + // Test the integration by directly calling generateAutoFix with mock data + const mockOpening = { + type: AST_NODE_TYPES.JSXOpeningElement, + name: { + type: AST_NODE_TYPES.JSXIdentifier, + name: "Button", + range: [0, 6] as [number, number], + loc: {} as any + }, + attributes: [], + selfClosing: false, + range: [0, 8] as [number, number], + loc: {} as any + } as TSESTree.JSXOpeningElement; + + const config: AutoFixConfig = { + strategy: "aria-label-placeholder" + }; + + const fixFunction = generateAutoFix(mockOpening, config); + expect(fixFunction).toBeDefined(); + expect(typeof fixFunction).toBe("function"); + + // Test that the fix function works when called + const mockFixer = { + insertTextAfter: jest.fn().mockReturnValue({ type: "fix" }) + } as unknown as TSESLint.RuleFixer; + + const result = fixFunction!(mockFixer); + expect(mockFixer.insertTextAfter).toHaveBeenCalledWith(mockOpening.name, ' aria-label=""'); + expect(result).toEqual({ type: "fix" }); + }); + + test("generateAutoFix with null config returns null", () => { + const mockOpening = { + type: AST_NODE_TYPES.JSXOpeningElement, + name: { + type: AST_NODE_TYPES.JSXIdentifier, + name: "Button", + range: [0, 6] as [number, number], + loc: {} as any + } + } as TSESTree.JSXOpeningElement; + + const result = generateAutoFix(mockOpening, null as any); + expect(result).toBeNull(); + }); +});