Skip to content

Commit 80724a6

Browse files
authored
Merge pull request #155 from microsoft/users/rbitting/image-rule
Added rule enforcing Image alt text
2 parents d7dcddd + 7754859 commit 80724a6

File tree

16 files changed

+382
-35
lines changed

16 files changed

+382
-35
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po
126126
| [emptyswatch-needs-labelling](docs/rules/emptyswatch-needs-labelling.md) | Accessibility: EmptySwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | |
127127
| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | ✅ | | |
128128
| [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 | ✅ | | |
129+
| [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="". | ✅ | | |
129130
| [imageswatch-needs-labelling](docs/rules/imageswatch-needs-labelling.md) | Accessibility: ImageSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | |
130131
| [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 | ✅ | | |
131132
| [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. | ✅ | | 🔧 |

docs/rules/image-needs-alt.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Accessibility: Image must have alt attribute with a meaningful description of the image. If the image is decorative, use alt="" (`@microsoft/fluentui-jsx-a11y/image-needs-alt`)
2+
3+
💼 This rule is enabled in the ✅ `recommended` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
## Rule details
8+
9+
This rule requires all `<Image>` 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. Purely decorative images should have empty `alt` text (`alt=""`).
10+
11+
12+
Examples of **incorrect** code for this rule:
13+
14+
```jsx
15+
<Image src="image.png" />
16+
```
17+
18+
```jsx
19+
<Image src="image.png" alt={null} />
20+
```
21+
22+
Examples of **correct** code for this rule:
23+
24+
```jsx
25+
<Image src="image.png" alt="A dog playing in a park." />
26+
```
27+
28+
```jsx
29+
<Image src="decorative-image.png" alt="" />
30+
```
31+
32+
## Further Reading
33+
34+
- [`<img>` Accessibility](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#accessibility)

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module.exports = {
3333
"@microsoft/fluentui-jsx-a11y/emptyswatch-needs-labelling": "error",
3434
"@microsoft/fluentui-jsx-a11y/field-needs-labelling": "error",
3535
"@microsoft/fluentui-jsx-a11y/image-button-missing-aria": "error",
36+
"@microsoft/fluentui-jsx-a11y/image-needs-alt": "error",
3637
"@microsoft/fluentui-jsx-a11y/imageswatch-needs-labelling": "error",
3738
"@microsoft/fluentui-jsx-a11y/input-components-require-accessible-name": "error",
3839
"@microsoft/fluentui-jsx-a11y/link-missing-labelling": "error",
@@ -74,6 +75,7 @@ module.exports = {
7475
"emptyswatch-needs-labelling": rules.emptySwatchNeedsLabelling,
7576
"field-needs-labelling": rules.fieldNeedsLabelling,
7677
"image-button-missing-aria": rules.imageButtonMissingAria,
78+
"image-needs-alt": rules.imageNeedsAlt,
7779
"imageswatch-needs-labelling": rules.imageSwatchNeedsLabelling,
7880
"input-components-require-accessible-name": rules.inputComponentsRequireAccessibleName,
7981
"link-missing-labelling": rules.linkMissingLabelling,

lib/rules/image-needs-alt.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { ESLintUtils } from "@typescript-eslint/utils";
5+
import { makeLabeledControlRule } from "../util/ruleFactory";
6+
7+
//------------------------------------------------------------------------------
8+
// Rule Definition
9+
//------------------------------------------------------------------------------
10+
11+
const rule = ESLintUtils.RuleCreator.withoutDocs(
12+
makeLabeledControlRule({
13+
component: "Image",
14+
messageId: "imageNeedsAlt",
15+
description:
16+
'Accessibility: Image must have alt attribute with a meaningful description of the image. If the image is decorative, use alt="".',
17+
requiredProps: ["alt"],
18+
allowFieldParent: false,
19+
allowHtmlFor: false,
20+
allowLabelledBy: false,
21+
allowWrappingLabel: false,
22+
allowTooltipParent: false,
23+
allowDescribedBy: false,
24+
allowLabeledChild: false
25+
})
26+
);
27+
28+
export default rule;

lib/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export { default as dialogsurfaceNeedsAria } from "./dialogsurface-needs-aria";
1616
export { default as dropdownNeedsLabelling } from "./dropdown-needs-labelling";
1717
export { default as fieldNeedsLabelling } from "./field-needs-labelling";
1818
export { default as imageButtonMissingAria } from "./buttons/image-button-missing-aria";
19+
export { default as imageNeedsAlt } from "./image-needs-alt";
1920
export { default as inputComponentsRequireAccessibleName } from "./input-components-require-accessible-name";
2021
export { default as linkMissingLabelling } from "./link-missing-labelling";
2122
export { default as menuItemNeedsLabelling } from "./menu-item-needs-labelling";

lib/util/hasDefinedProp.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { TSESTree } from "@typescript-eslint/utils";
5+
import { JSXOpeningElement } from "estree-jsx";
6+
import { hasProp, getPropValue, getProp } from "jsx-ast-utils";
7+
8+
/**
9+
* Determines if the property exists and has a non-nullish value.
10+
* @param attributes The attributes on the visited node
11+
* @param name The name of the prop to check
12+
* @returns Whether the specified prop exists and is not null or undefined
13+
* @example
14+
* // <img src="image.png" />
15+
* hasDefinedProp(attributes, 'src') // true
16+
* // <img src="" />
17+
* hasDefinedProp(attributes, 'src') // true
18+
* // <img src={null} />
19+
* hasDefinedProp(attributes, 'src') // false
20+
* // <img src={undefined} />
21+
* hasDefinedProp(attributes, 'src') // false
22+
* // <img src={1} />
23+
* hasDefinedProp(attributes, 'src') // false
24+
* // <img src={true} />
25+
* hasDefinedProp(attributes, 'src') // false
26+
* // <img />
27+
* hasDefinedProp(attributes, 'src') // false
28+
*/
29+
const hasDefinedProp = (attributes: TSESTree.JSXOpeningElement["attributes"], name: string): boolean => {
30+
if (!hasProp(attributes as JSXOpeningElement["attributes"], name)) {
31+
return false;
32+
}
33+
34+
const prop = getProp(attributes as JSXOpeningElement["attributes"], name);
35+
36+
// Safely get the value of the prop, handling potential undefined or null values
37+
const propValue = prop ? getPropValue(prop) : undefined;
38+
39+
// Return true if the prop value is not null or undefined
40+
return propValue !== null && propValue !== undefined;
41+
};
42+
43+
export { hasDefinedProp };

lib/util/ruleFactory.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,43 @@ import { elementType } from "jsx-ast-utils";
1414
import { JSXOpeningElement } from "estree-jsx";
1515
import { hasToolTipParent } from "./hasTooltipParent";
1616
import { hasLabeledChild } from "./hasLabeledChild";
17+
import { hasDefinedProp } from "./hasDefinedProp";
1718
import { hasTextContentChild } from "./hasTextContentChild";
1819

20+
/**
21+
* Configuration options for a rule created via the `ruleFactory`
22+
*/
1923
export type LabeledControlConfig = {
24+
/** The name of the component that the rule applies to. @example 'Image', /Image|Icon/ */
2025
component: string | RegExp;
26+
/** The unique id of the problem message. @example 'itemNeedsLabel' */
2127
messageId: string;
28+
/** A short description of the rule. */
2229
description: string;
23-
labelProps: string[]; // e.g. ["aria-label", "title", "label"]
24-
allowFieldParent: boolean; // Accept a parent <Field label="..."> wrapper as providing the label.
25-
allowHtmlFor: boolean; // Accept <label htmlFor="..."> association.
26-
allowLabelledBy: boolean; // Accept aria-labelledby association.
27-
allowWrappingLabel: boolean; // Accept being wrapped in a <label> element.
28-
allowTooltipParent: boolean; // Accept a parent <Tooltip content="..."> wrapper as providing the label.
30+
/** Properties that are required to have a non-`null` and non-`undefined` value. @example ["alt"] */
31+
requiredProps?: string[];
32+
/** Labeling properties that are required to have at least one non-empty value. @example ["aria-label", "title", "label"] */
33+
labelProps?: string[];
34+
/** Accept a parent `<Field label="...">` wrapper as providing the label. */
35+
allowFieldParent: boolean;
36+
/** Accept `<label htmlFor="...">` association. */
37+
allowHtmlFor: boolean;
38+
/** Accept aria-labelledby association. */
39+
allowLabelledBy: boolean;
40+
/** Accept being wrapped in a `<label>` element. */
41+
allowWrappingLabel: boolean;
42+
/** Accept a parent `<Tooltip content="...">` wrapper as providing the label. */
43+
allowTooltipParent: boolean;
2944
/**
3045
* Accept aria-describedby as a labeling strategy.
3146
* NOTE: This is discouraged for *primary* labeling; prefer text/aria-label/labelledby.
3247
* Keep this off unless a specific component (e.g., Icon-only buttons) intentionally uses it.
3348
*/
3449
allowDescribedBy: boolean;
35-
allowLabeledChild: boolean; // Accept labeled child elements to provide the label e.g. <Button><img alt="..." /></Button>
36-
allowTextContentChild?: boolean; // Accept text children to provide the label e.g. <Button>Click me</Button>
50+
/** Treat labeled child content (img `alt`, svg `title`, `aria-label` on `role="img"`) as the name */
51+
allowLabeledChild: boolean;
52+
/** Accept text children to provide the label e.g. <Button>Click me</Button> */
53+
allowTextContentChild?: boolean;
3754
};
3855

3956
/**
@@ -58,16 +75,22 @@ export function hasAccessibleLabel(
5875
context: TSESLint.RuleContext<string, []>,
5976
config: LabeledControlConfig
6077
): boolean {
61-
const allowFieldParent = !!config.allowFieldParent;
62-
const allowWrappingLabel = !!config.allowWrappingLabel;
63-
const allowHtmlFor = !!config.allowHtmlFor;
64-
const allowLabelledBy = !!config.allowLabelledBy;
65-
const allowTooltipParent = !!config.allowTooltipParent;
66-
const allowDescribedBy = !!config.allowDescribedBy;
67-
const allowLabeledChild = !!config.allowLabeledChild;
78+
const {
79+
allowFieldParent,
80+
allowWrappingLabel,
81+
allowHtmlFor,
82+
allowLabelledBy,
83+
allowTooltipParent,
84+
allowDescribedBy,
85+
allowLabeledChild,
86+
requiredProps,
87+
labelProps
88+
} = config;
6889
const allowTextContentChild = !!config.allowTextContentChild;
90+
6991
if (allowFieldParent && hasFieldParent(context)) return true;
70-
if (config.labelProps?.some(p => hasNonEmptyProp(opening.attributes, p))) return true;
92+
if (requiredProps?.every(p => hasDefinedProp(opening.attributes, p))) return true;
93+
if (labelProps?.some(p => hasNonEmptyProp(opening.attributes, p))) return true;
7194
if (allowWrappingLabel && isInsideLabelTag(context)) return true;
7295
if (allowHtmlFor && hasAssociatedLabelViaHtmlFor(opening, context)) return true;
7396
if (allowLabelledBy && hasAssociatedLabelViaAriaLabelledBy(opening, context)) return true;

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"lint:eslint-docs": "npm-run-all \"update:eslint-docs -- --check\"",
4343
"lint:js": "eslint .",
4444
"test": "jest",
45+
"test:branch": "npm run test -- -o",
46+
"test:watch": "npm run test -- --watch",
4547
"lint:docs": "markdownlint **/*.md",
4648
"update:eslint-docs": "eslint-doc-generator",
4749
"fix:md": "npm run lint:docs -- --fix",

scripts/boilerplate/doc.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
const docBoilerplateGenerator = (name, description) => `# ${description} (@microsoft/fluentui-jsx-a11y/${name})
4+
const { withCRLF } = require("./util");
5+
6+
const docBoilerplateGenerator = (name, description) =>
7+
withCRLF(`# ${description} (@microsoft/fluentui-jsx-a11y/${name})
58
69
Write a useful explanation here!
710
@@ -18,5 +21,5 @@ Write more details here!
1821
\`\`\`
1922
2023
## Further Reading
21-
`;
24+
`);
2225
module.exports = docBoilerplateGenerator;

scripts/boilerplate/rule.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
const ruleBoilerplate = (name, description) => `// Copyright (c) Microsoft Corporation.
4+
const { withCRLF } = require("./util");
5+
6+
const ruleBoilerplate = (name, description) =>
7+
withCRLF(`// Copyright (c) Microsoft Corporation.
58
// Licensed under the MIT License.
69
710
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
@@ -41,5 +44,5 @@ const rule = createRule({
4144
});
4245
4346
export default rule;
44-
`;
47+
`);
4548
module.exports = ruleBoilerplate;

0 commit comments

Comments
 (0)