From b80d3d933351bfbf5a5856f7808f4b74f7bd0e66 Mon Sep 17 00:00:00 2001 From: Rachel Bitting Date: Tue, 16 Sep 2025 16:02:05 -0400 Subject: [PATCH 1/8] Fixed create rule script --- scripts/boilerplate/doc.js | 7 +++++-- scripts/boilerplate/rule.js | 7 +++++-- scripts/boilerplate/test.js | 7 +++++-- scripts/boilerplate/util.js | 9 +++++++++ scripts/create-rule.js | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 scripts/boilerplate/util.js diff --git a/scripts/boilerplate/doc.js b/scripts/boilerplate/doc.js index ad1ffde..0b77624 100644 --- a/scripts/boilerplate/doc.js +++ b/scripts/boilerplate/doc.js @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -const docBoilerplateGenerator = (name, description) => `# ${description} (@microsoft/fluentui-jsx-a11y/${name}) +const { withCRLF } = require("./util"); + +const docBoilerplateGenerator = (name, description) => + withCRLF(`# ${description} (@microsoft/fluentui-jsx-a11y/${name}) Write a useful explanation here! @@ -18,5 +21,5 @@ Write more details here! \`\`\` ## Further Reading -`; +`); module.exports = docBoilerplateGenerator; diff --git a/scripts/boilerplate/rule.js b/scripts/boilerplate/rule.js index 3dbd687..35623a5 100644 --- a/scripts/boilerplate/rule.js +++ b/scripts/boilerplate/rule.js @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -const ruleBoilerplate = (name, description) => `// Copyright (c) Microsoft Corporation. +const { withCRLF } = require("./util"); + +const ruleBoilerplate = (name, description) => + withCRLF(`// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; @@ -41,5 +44,5 @@ const rule = createRule({ }); export default rule; -`; +`); module.exports = ruleBoilerplate; diff --git a/scripts/boilerplate/test.js b/scripts/boilerplate/test.js index 996f3ba..9f01bb3 100644 --- a/scripts/boilerplate/test.js +++ b/scripts/boilerplate/test.js @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -const testBoilerplate = name => `// Copyright (c) Microsoft Corporation. +const { withCRLF } = require("./util"); + +const testBoilerplate = name => + withCRLF(`// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { Rule } from "eslint"; @@ -20,5 +23,5 @@ ruleTester.run("${name}", rule as unknown as Rule.RuleModule, { /* ... */ ] }); -`; +`); module.exports = testBoilerplate; diff --git a/scripts/boilerplate/util.js b/scripts/boilerplate/util.js new file mode 100644 index 0000000..b67f502 --- /dev/null +++ b/scripts/boilerplate/util.js @@ -0,0 +1,9 @@ +/** + * Helper method to convert LF line endings to CRLF line endings + * @remarks This is needed to avoid prettier formatting issues on generation of the files + * @param text The text to convert + * @returns The converted text + */ +const withCRLF = text => text.replace(/\n/g, "\r\n"); + +module.exports = { withCRLF }; diff --git a/scripts/create-rule.js b/scripts/create-rule.js index 215171e..83641d8 100644 --- a/scripts/create-rule.js +++ b/scripts/create-rule.js @@ -34,7 +34,7 @@ const author = argv.author || "$AUTHOR"; const description = argv.description || "$DESCRIPTION"; const rulePath = resolve(`lib/rules/${ruleName}.ts`); -const testPath = resolve(`tests/lib/rules/${ruleName}-test.ts`); +const testPath = resolve(`tests/lib/rules/${ruleName}.test.ts`); const docsPath = resolve(`docs/rules/${ruleName}.md`); // Validate From 653be71ed7b786fccac4d1c03fe05929ece441e2 Mon Sep 17 00:00:00 2001 From: Rachel Bitting Date: Tue, 16 Sep 2025 18:20:20 -0400 Subject: [PATCH 2/8] Added rule to require alt text on image --- README.md | 1 + docs/rules/image-needs-alt.md | 34 ++++++++++++++++++ lib/index.ts | 2 ++ lib/rules/image-needs-alt.ts | 48 +++++++++++++++++++++++++ lib/rules/index.ts | 1 + tests/lib/rules/image-needs-alt.test.ts | 36 +++++++++++++++++++ 6 files changed, 122 insertions(+) create mode 100644 docs/rules/image-needs-alt.md create mode 100644 lib/rules/image-needs-alt.ts create mode 100644 tests/lib/rules/image-needs-alt.test.ts diff --git a/README.md b/README.md index 22c9d86..67b8b32 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ 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 | ✅ | | | | [imageswatch-needs-labelling](docs/rules/imageswatch-needs-labelling.md) | Accessibility: ImageSwatch must have an accessible name via aria-label, Tooltip, 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. | ✅ | | 🔧 | diff --git a/docs/rules/image-needs-alt.md b/docs/rules/image-needs-alt.md new file mode 100644 index 0000000..5b221f8 --- /dev/null +++ b/docs/rules/image-needs-alt.md @@ -0,0 +1,34 @@ +# Accessibility: Image must have alt attribute (`@microsoft/fluentui-jsx-a11y/image-needs-alt`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +## Rule details + +This rule requires all `` components have non-empty alternative text. The `alt` attribute should provide a clear and concise text replacement for the image's content. It should *not* describe the presence of the image itself or the file name of the image. + + +Examples of **incorrect** code for this rule: + +```jsx + +``` + +```jsx + +``` + +```jsx +{null} +``` + +Examples of **correct** code for this rule: + +```jsx +A dog playing in a park. +``` + +## Further Reading + +- [`` Accessibility](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#accessibility) diff --git a/lib/index.ts b/lib/index.ts index 1a49a29..8fd98c1 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -33,6 +33,7 @@ module.exports = { "@microsoft/fluentui-jsx-a11y/emptyswatch-needs-labelling": "error", "@microsoft/fluentui-jsx-a11y/field-needs-labelling": "error", "@microsoft/fluentui-jsx-a11y/image-button-missing-aria": "error", + "@microsoft/fluentui-jsx-a11y/image-needs-alt": "error", "@microsoft/fluentui-jsx-a11y/imageswatch-needs-labelling": "error", "@microsoft/fluentui-jsx-a11y/input-components-require-accessible-name": "error", "@microsoft/fluentui-jsx-a11y/link-missing-labelling": "error", @@ -74,6 +75,7 @@ module.exports = { "emptyswatch-needs-labelling": rules.emptySwatchNeedsLabelling, "field-needs-labelling": rules.fieldNeedsLabelling, "image-button-missing-aria": rules.imageButtonMissingAria, + "image-needs-alt": rules.imageNeedsAlt, "imageswatch-needs-labelling": rules.imageSwatchNeedsLabelling, "input-components-require-accessible-name": rules.inputComponentsRequireAccessibleName, "link-missing-labelling": rules.linkMissingLabelling, diff --git a/lib/rules/image-needs-alt.ts b/lib/rules/image-needs-alt.ts new file mode 100644 index 0000000..8a0b657 --- /dev/null +++ b/lib/rules/image-needs-alt.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const elementType = require("jsx-ast-utils").elementType; +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +const rule = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: "problem", + docs: { + description: "Accessibility: Image must have alt attribute", + recommended: "error" + }, + messages: { + imageNeedsAlt: "Accessibility: Image must have alt attribute with a meaningful description of the image" + }, + schema: [] + }, + defaultOptions: [], // no options needed + create(context) { + return { + // Listen for variable declarations + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { + // No error if the element is not an Image + if (elementType(node) !== "Image") { + return; + } + + // No error if alt prop exists and is non-empty + if (hasNonEmptyProp(node.attributes, "alt")) { + return; + } + + context.report({ + node, + messageId: "imageNeedsAlt" + }); + } + }; + } +}); + +export default rule; diff --git a/lib/rules/index.ts b/lib/rules/index.ts index d246932..d0db540 100644 --- a/lib/rules/index.ts +++ b/lib/rules/index.ts @@ -16,6 +16,7 @@ export { default as dialogsurfaceNeedsAria } from "./dialogsurface-needs-aria"; export { default as dropdownNeedsLabelling } from "./dropdown-needs-labelling"; export { default as fieldNeedsLabelling } from "./field-needs-labelling"; export { default as imageButtonMissingAria } from "./buttons/image-button-missing-aria"; +export { default as imageNeedsAlt } from "./image-needs-alt"; export { default as inputComponentsRequireAccessibleName } from "./input-components-require-accessible-name"; export { default as linkMissingLabelling } from "./link-missing-labelling"; export { default as menuItemNeedsLabelling } from "./menu-item-needs-labelling"; diff --git a/tests/lib/rules/image-needs-alt.test.ts b/tests/lib/rules/image-needs-alt.test.ts new file mode 100644 index 0000000..4223183 --- /dev/null +++ b/tests/lib/rules/image-needs-alt.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Rule } from "eslint"; +import ruleTester from "./helper/ruleTester"; +import rule from "../../../lib/rules/image-needs-alt"; + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +ruleTester.run("image-needs-alt", rule as unknown as Rule.RuleModule, { + valid: ['Description of image', '{altText}'], + invalid: [ + { + // No alt attribute + code: '', + errors: [{ messageId: "imageNeedsAlt" }] + }, + { + // Empty alt attribute + code: '', + errors: [{ messageId: "imageNeedsAlt" }] + }, + { + // Null alt attribute + code: '{null}', + errors: [{ messageId: "imageNeedsAlt" }] + }, + { + // Undefined alt attribute + code: '{undefined}', + errors: [{ messageId: "imageNeedsAlt" }] + } + ] +}); From e6f06e2ebb146e63d6db41025c0972340be52fc3 Mon Sep 17 00:00:00 2001 From: Rachel Bitting Date: Wed, 17 Sep 2025 10:08:44 -0400 Subject: [PATCH 3/8] Increased coverage for image alt rule --- tests/lib/rules/image-needs-alt.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/lib/rules/image-needs-alt.test.ts b/tests/lib/rules/image-needs-alt.test.ts index 4223183..9a1ce06 100644 --- a/tests/lib/rules/image-needs-alt.test.ts +++ b/tests/lib/rules/image-needs-alt.test.ts @@ -10,7 +10,14 @@ import rule from "../../../lib/rules/image-needs-alt"; // ----------------------------------------------------------------------------- ruleTester.run("image-needs-alt", rule as unknown as Rule.RuleModule, { - valid: ['Description of image', '{altText}'], + valid: [ + // Not an Image + "
", + // Valid string test + 'Description of image', + // Valid expression test + '{altText}' + ], invalid: [ { // No alt attribute From dd4e747653221074c1c9dda6d37555f20f23f5a5 Mon Sep 17 00:00:00 2001 From: Rachel Bitting Date: Wed, 17 Sep 2025 15:04:56 -0400 Subject: [PATCH 4/8] Allow empty string as Image alt --- README.md | 2 +- docs/rules/image-needs-alt.md | 2 +- lib/rules/image-needs-alt.ts | 55 ++++++------------ lib/rules/swatchpicker-needs-labelling.ts | 2 +- lib/util/hasDefinedProp.ts | 43 ++++++++++++++ lib/util/ruleFactory.ts | 55 +++++++++++++----- package.json | 2 + tests/lib/rules/image-needs-alt.test.ts | 9 +-- tests/lib/rules/utils/hasDefinedProp.test.ts | 61 ++++++++++++++++++++ tests/lib/rules/utils/ruleFactory.test.ts | 52 +++++++++++++++-- 10 files changed, 214 insertions(+), 69 deletions(-) create mode 100644 lib/util/hasDefinedProp.ts create mode 100644 tests/lib/rules/utils/hasDefinedProp.test.ts diff --git a/README.md b/README.md index 67b8b32..5f439ef 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ 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 | ✅ | | | +| [image-needs-alt](docs/rules/image-needs-alt.md) | Accessibility: Image must have alt attribute with a meaningful description of the image | ✅ | | | | [imageswatch-needs-labelling](docs/rules/imageswatch-needs-labelling.md) | Accessibility: ImageSwatch must have an accessible name via aria-label, Tooltip, 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. | ✅ | | 🔧 | diff --git a/docs/rules/image-needs-alt.md b/docs/rules/image-needs-alt.md index 5b221f8..0f6f46e 100644 --- a/docs/rules/image-needs-alt.md +++ b/docs/rules/image-needs-alt.md @@ -1,4 +1,4 @@ -# Accessibility: Image must have alt attribute (`@microsoft/fluentui-jsx-a11y/image-needs-alt`) +# Accessibility: Image must have alt attribute with a meaningful description of the image (`@microsoft/fluentui-jsx-a11y/image-needs-alt`) 💼 This rule is enabled in the ✅ `recommended` config. diff --git a/lib/rules/image-needs-alt.ts b/lib/rules/image-needs-alt.ts index 8a0b657..bbe06ff 100644 --- a/lib/rules/image-needs-alt.ts +++ b/lib/rules/image-needs-alt.ts @@ -1,48 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -const elementType = require("jsx-ast-utils").elementType; -import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; -import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; +import { ESLintUtils } from "@typescript-eslint/utils"; +import { makeLabeledControlRule } from "../util/ruleFactory"; //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ -const rule = ESLintUtils.RuleCreator.withoutDocs({ - meta: { - type: "problem", - docs: { - description: "Accessibility: Image must have alt attribute", - recommended: "error" - }, - messages: { - imageNeedsAlt: "Accessibility: Image must have alt attribute with a meaningful description of the image" - }, - schema: [] - }, - defaultOptions: [], // no options needed - create(context) { - return { - // Listen for variable declarations - JSXOpeningElement(node: TSESTree.JSXOpeningElement) { - // No error if the element is not an Image - if (elementType(node) !== "Image") { - return; - } - - // No error if alt prop exists and is non-empty - if (hasNonEmptyProp(node.attributes, "alt")) { - return; - } - - context.report({ - node, - messageId: "imageNeedsAlt" - }); - } - }; - } -}); +const rule = ESLintUtils.RuleCreator.withoutDocs( + makeLabeledControlRule({ + component: "Image", + messageId: "imageNeedsAlt", + description: "Accessibility: Image must have alt attribute with a meaningful description of the image", + requiredProps: ["alt"], + allowFieldParent: false, + allowHtmlFor: false, + allowLabelledBy: false, + allowWrappingLabel: false, + allowTooltipParent: false, + allowDescribedBy: false, + allowLabeledChild: false + }) +); export default rule; diff --git a/lib/rules/swatchpicker-needs-labelling.ts b/lib/rules/swatchpicker-needs-labelling.ts index b581366..2b98b48 100644 --- a/lib/rules/swatchpicker-needs-labelling.ts +++ b/lib/rules/swatchpicker-needs-labelling.ts @@ -13,7 +13,7 @@ export default ESLintUtils.RuleCreator.withoutDocs( component: "SwatchPicker", messageId: "noUnlabeledSwatchPicker", description: "Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc..", - labelProps: ["aria-label"], + requiredNonEmptyProps: ["aria-label"], allowFieldParent: true, allowHtmlFor: false, allowLabelledBy: true, diff --git a/lib/util/hasDefinedProp.ts b/lib/util/hasDefinedProp.ts new file mode 100644 index 0000000..43fd81e --- /dev/null +++ b/lib/util/hasDefinedProp.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TSESTree } from "@typescript-eslint/utils"; +import { JSXOpeningElement } from "estree-jsx"; +import { hasProp, getPropValue, getProp } from "jsx-ast-utils"; + +/** + * Determines if the property exists and has a non-nullish value. + * @param attributes The attributes on the visited node + * @param name The name of the prop to check + * @returns Whether the specified prop exists and is not null or undefined + * @example + * // + * hasDefinedProp(attributes, 'src') // true + * // + * hasDefinedProp(attributes, 'src') // true + * // + * hasDefinedProp(attributes, 'src') // false + * // + * hasDefinedProp(attributes, 'src') // false + * // + * hasDefinedProp(attributes, 'src') // false + * // + * hasDefinedProp(attributes, 'src') // false + * // + * hasDefinedProp(attributes, 'src') // false + */ +const hasDefinedProp = (attributes: TSESTree.JSXOpeningElement["attributes"], name: string): boolean => { + if (!hasProp(attributes as JSXOpeningElement["attributes"], name)) { + return false; + } + + const prop = getProp(attributes as JSXOpeningElement["attributes"], name); + + // Safely get the value of the prop, handling potential undefined or null values + const propValue = prop ? getPropValue(prop) : undefined; + + // Return true if the prop value is not null or undefined + return propValue !== null && propValue !== undefined; +}; + +export { hasDefinedProp }; diff --git a/lib/util/ruleFactory.ts b/lib/util/ruleFactory.ts index 17352ce..3f9a854 100644 --- a/lib/util/ruleFactory.ts +++ b/lib/util/ruleFactory.ts @@ -14,26 +14,43 @@ import { elementType } from "jsx-ast-utils"; import { JSXOpeningElement } from "estree-jsx"; import { hasToolTipParent } from "./hasTooltipParent"; import { hasLabeledChild } from "./hasLabeledChild"; +import { hasDefinedProp } from "./hasDefinedProp"; import { hasTextContentChild } from "./hasTextContentChild"; +/** + * Configuration options for a rule created via the `ruleFactory` + */ export type LabeledControlConfig = { + /** The name of the component that the rule applies to. @example 'Image', /Image|Icon/ */ component: string | RegExp; + /** The unique id of the problem message. @example 'itemNeedsLabel' */ messageId: string; + /** A short description of the rule. */ description: string; - labelProps: string[]; // e.g. ["aria-label", "title", "label"] - allowFieldParent: boolean; // Accept a parent wrapper as providing the label. - allowHtmlFor: boolean; // Accept