Skip to content

Commit f754743

Browse files
authored
Merge pull request #161 from microsoft/users/sidhshar/complete-haslabeledchild-implementation
Complete hasLabeledChild utility implementation with source code validation
2 parents 5542bcf + 56944a8 commit f754743

File tree

5 files changed

+966
-4
lines changed

5 files changed

+966
-4
lines changed

CONTRIBUTING.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ or contact [[email protected]](mailto:[email protected]) with any addi
1616
## Table of Contents
1717

1818
[Dev Environment](#dev-environment)
19+
[Rule Factory System](#rule-factory-system)
20+
[Creating New Rules](#creating-new-rules)
21+
[Utility Functions](#utility-functions)
1922
[Pull requests](#pull-requests)
2023

2124
## Dev Environment
@@ -61,6 +64,205 @@ To ensure a consistent and productive development environment, install the follo
6164
- [Prettier ESLint](https://marketplace.visualstudio.com/items?itemName=rvest.vs-code-prettier-eslint) — Format code with Prettier and ESLint integration.
6265
- [markdownlint](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) — Linting and style checks for Markdown files.
6366

67+
## Rule Factory System
68+
69+
This plugin uses a powerful rule factory system that provides consistent behavior across accessibility rules. The factory system is built around the `ruleFactory` function in `lib/util/ruleFactory.ts` and several utility functions for validating accessible labeling.
70+
71+
### Core Concept
72+
73+
The rule factory centralizes common accessibility validation patterns, making it easy to create new rules with consistent behavior. Instead of implementing validation logic from scratch, rules can leverage the factory's built-in utilities.
74+
75+
### Architecture
76+
77+
```sh
78+
ruleFactory(config) → ESLint Rule
79+
80+
hasAccessibleLabel(opening, element, context, config) → boolean
81+
82+
Utility Functions:
83+
├── hasAssociatedLabelViaAriaLabelledBy(opening, context)
84+
├── hasAssociatedLabelViaHtmlFor(opening, context)
85+
├── hasAssociatedLabelViaAriaDescribedby(opening, context)
86+
├── hasLabeledChild(opening, context)
87+
├── hasTextContentChild(element)
88+
└── isInsideLabelTag(context)
89+
```
90+
91+
## Creating New Rules
92+
93+
### Using the Rule Factory
94+
95+
For most accessibility rules, use the rule factory:
96+
97+
```typescript
98+
import { ruleFactory, LabeledControlConfig } from '../util/ruleFactory';
99+
100+
const rule = ruleFactory({
101+
component: 'YourComponent', // string or regex pattern
102+
message: 'YourComponent needs accessible labeling',
103+
104+
// Validation options (all optional, default false)
105+
allowTextContentChild: true, // Allow text content in children
106+
allowLabeledChild: true, // Allow images with alt, icons, etc.
107+
allowHtmlFor: true, // Allow htmlFor/id label association
108+
allowLabelledBy: true, // Allow aria-labelledby
109+
allowDescribedBy: false, // Allow aria-describedby (discouraged as primary)
110+
allowWrappingLabel: true, // Allow wrapping in <Label> tag
111+
allowTooltipParent: false, // Allow parent <Tooltip>
112+
allowFieldParent: true, // Allow parent <Field>
113+
114+
// Property validation
115+
labelProps: ['aria-label'], // Props that provide labeling
116+
requiredProps: ['role'], // Props that must be present
117+
});
118+
119+
export default rule;
120+
```
121+
122+
### Configuration Options
123+
124+
| Option | Description | Example Use Cases |
125+
|--------|-------------|-------------------|
126+
| `allowTextContentChild` | Allows text content in child elements | Buttons, links with text |
127+
| `allowLabeledChild` | Allows accessible child content (images with alt, icons, aria-labeled elements) | Icon buttons, image buttons |
128+
| `allowHtmlFor` | Allows label association via `htmlFor`/`id` | Form inputs, interactive controls |
129+
| `allowLabelledBy` | Allows `aria-labelledby` references | Complex components referencing external labels |
130+
| `allowDescribedBy` | Allows `aria-describedby` (discouraged for primary labeling) | Rare cases where description suffices |
131+
| `allowWrappingLabel` | Allows element to be wrapped in `<Label>` | Form controls |
132+
| `allowTooltipParent` | Allows parent `<Tooltip>` as accessible name | Simple tooltips (use sparingly) |
133+
| `allowFieldParent` | Allows parent `<Field>` component | FluentUI form fields |
134+
135+
### Custom Rules
136+
137+
For complex validation that doesn't fit the factory pattern:
138+
139+
```typescript
140+
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
141+
import { JSXOpeningElement } from "estree-jsx";
142+
143+
const rule = ESLintUtils.RuleCreator.withoutDocs({
144+
defaultOptions: [],
145+
meta: {
146+
messages: {
147+
customMessage: "Custom validation message"
148+
},
149+
type: "problem",
150+
schema: []
151+
},
152+
create(context) {
153+
return {
154+
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
155+
// Custom validation logic
156+
if (needsValidation(node)) {
157+
context.report({
158+
node,
159+
messageId: "customMessage"
160+
});
161+
}
162+
}
163+
};
164+
}
165+
});
166+
```
167+
168+
## Utility Functions
169+
170+
### hasLabeledChild
171+
172+
The `hasLabeledChild` utility detects accessible child content and is one of the most powerful validation functions:
173+
174+
```typescript
175+
import { hasLabeledChild } from '../util/hasLabeledChild';
176+
177+
// Usage in rules
178+
if (hasLabeledChild(openingElement, context)) {
179+
return; // Element has accessible child content
180+
}
181+
```
182+
183+
**Detects:**
184+
185+
1. **Images with alt text:**
186+
```jsx
187+
<Button><img alt="Save document" /></Button>
188+
<Button><Image alt="User profile" /></Button>
189+
```
190+
191+
2. **SVG elements with accessible attributes:**
192+
```jsx
193+
<Button><svg title="Close" /></Button>
194+
<Button><svg aria-label="Menu" /></Button>
195+
<Button><svg aria-labelledby="icon-label" /></Button>
196+
```
197+
198+
3. **Elements with role="img" and labeling:**
199+
```jsx
200+
<Button><span role="img" aria-label="Celebration">🎉</span></Button>
201+
```
202+
203+
4. **FluentUI Icon components:**
204+
```jsx
205+
<Button><SaveIcon /></Button>
206+
<Button><Icon iconName="Save" /></Button>
207+
<Button><MyCustomIcon /></Button>
208+
```
209+
210+
5. **Any element with aria-label or title:**
211+
```jsx
212+
<Button><div aria-label="Status indicator" /></Button>
213+
<Button><span title="Tooltip text" /></Button>
214+
```
215+
216+
6. **Elements with validated aria-labelledby:**
217+
```jsx
218+
<Button><span aria-labelledby="save-label" /></Button>
219+
<Label id="save-label">Save Document</Label>
220+
```
221+
222+
**Key Features:**
223+
224+
- **Source code validation:** Validates that `aria-labelledby` references point to actual elements with matching IDs
225+
- **Deep traversal:** Uses `flattenChildren` to find labeled content in nested structures
226+
- **Case insensitive:** Handles variations like `IMG`, `SVG`, `CLOSEICON`
227+
- **Error handling:** Gracefully handles malformed JSX and missing context
228+
229+
### Other Utility Functions
230+
231+
- **`hasAssociatedLabelViaAriaLabelledBy(opening, context)`** - Validates `aria-labelledby` references
232+
- **`hasAssociatedLabelViaHtmlFor(opening, context)`** - Validates `htmlFor`/`id` label associations
233+
- **`hasAssociatedLabelViaAriaDescribedby(opening, context)`** - Validates `aria-describedby` references
234+
- **`hasTextContentChild(element)`** - Checks for meaningful text content in children
235+
- **`isInsideLabelTag(context)`** - Checks if element is wrapped in a `<Label>` tag
236+
- **`hasNonEmptyProp(attributes, propName)`** - Validates non-empty attribute values
237+
- **`hasDefinedProp(attributes, propName)`** - Checks if attribute is present
238+
239+
### Writing Tests
240+
241+
Use the comprehensive test patterns established in the codebase:
242+
243+
```typescript
244+
import { hasLabeledChild } from "../../../../lib/util/hasLabeledChild";
245+
import { TSESLint } from "@typescript-eslint/utils";
246+
247+
describe("hasLabeledChild", () => {
248+
const mockContext = (sourceText = ""): TSESLint.RuleContext<string, unknown[]> => ({
249+
getSourceCode: () => ({
250+
getText: () => sourceText,
251+
text: sourceText
252+
})
253+
} as unknown as TSESLint.RuleContext<string, unknown[]>);
254+
255+
it("validates aria-labelledby references", () => {
256+
const element = createElementWithChild("div", [
257+
createJSXAttribute("aria-labelledby", "existing-label")
258+
]);
259+
const contextWithLabel = mockContext('<Label id="existing-label">Label Text</Label>');
260+
261+
expect(hasLabeledChild(element, contextWithLabel)).toBe(true);
262+
});
263+
});
264+
```
265+
64266
## To create a new ESLint rule
65267
66268
If you want to create a new ESLint rule:

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,69 @@ This plugin does a static code analysis of the React JSX to spot accessibility i
9090

9191
As the plugin can only catch errors in static source code, please use it in combination with [@axe-core/react](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/react) to test the accessibility of the rendered DOM. Consider these tools just as one step of a larger a11y testing process and always test your apps with assistive technology.
9292

93+
## Architecture & Development
94+
95+
### Rule Factory System
96+
97+
This plugin leverages a powerful rule factory system that provides consistent behavior across accessibility rules. The factory system includes several utility functions for validating accessible labeling:
98+
99+
- **`hasAssociatedLabelViaAriaLabelledBy`** - Validates `aria-labelledby` references
100+
- **`hasAssociatedLabelViaHtmlFor`** - Validates `htmlFor`/`id` label associations
101+
- **`hasAssociatedLabelViaAriaDescribedby`** - Validates `aria-describedby` references
102+
- **`hasLabeledChild`** - Detects accessible child content (images with alt, icons, labeled elements)
103+
- **`hasTextContentChild`** - Validates text content in child elements
104+
- **`isInsideLabelTag`** - Checks if element is wrapped in a label
105+
106+
#### Labeled Child Detection
107+
108+
The `hasLabeledChild` utility is particularly powerful, detecting multiple forms of accessible child content:
109+
110+
```tsx
111+
// Image elements with alt text
112+
<Button><img alt="Save document" /></Button>
113+
114+
// SVG elements with accessible attributes
115+
<Button><svg title="Close" /></Button>
116+
<Button><svg aria-label="Menu" /></Button>
117+
118+
// Elements with role="img" and labeling
119+
<Button><span role="img" aria-label="Celebration">🎉</span></Button>
120+
121+
// FluentUI Icon components
122+
<Button><SaveIcon /></Button>
123+
<Button><Icon iconName="Save" /></Button>
124+
125+
// Any element with aria-label or title
126+
<Button><div aria-label="Status indicator" /></Button>
127+
128+
// Elements with aria-labelledby (validates references exist)
129+
<Button><span aria-labelledby="save-label" /></Button>
130+
<Label id="save-label">Save Document</Label>
131+
```
132+
133+
The utility performs source code analysis to validate that `aria-labelledby` references point to actual elements with matching IDs, ensuring robust accessibility validation.
134+
135+
### Creating New Rules
136+
137+
To create a new accessibility rule, use the rule factory system:
138+
139+
```typescript
140+
import { ruleFactory, LabeledControlConfig } from '../util/ruleFactory';
141+
142+
const rule = ruleFactory({
143+
component: 'YourComponent', // or /RegexPattern/
144+
message: 'Your component needs accessible labeling',
145+
allowTextContentChild: true,
146+
allowLabeledChild: true,
147+
allowHtmlFor: true,
148+
allowLabelledBy: true,
149+
labelProps: ['aria-label'],
150+
requiredProps: ['role']
151+
});
152+
```
153+
154+
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development guidelines.
155+
93156
## Trademarks
94157

95158
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft

lib/util/hasLabeledChild.ts

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

4-
import { TSESLint, TSESTree } from "@typescript-eslint/utils";
4+
import { TSESTree } from "@typescript-eslint/utils";
5+
import { TSESLint } from "@typescript-eslint/utils";
6+
import { flattenChildren } from "./flattenChildren";
7+
import { hasNonEmptyProp } from "./hasNonEmptyProp";
8+
import { hasAssociatedAriaText } from "./labelUtils";
59

6-
// eslint-disable-next-line no-unused-vars
10+
/**
11+
* Checks if a JSX element has properly labeled child elements that can serve as an accessible name.
12+
* This includes child elements with alt text, aria-label, title attributes, or other accessibility attributes.
13+
*
14+
* Examples of labeled children:
15+
* - <img alt="User profile" />
16+
* - <svg title="Close icon" />
17+
* - <Icon aria-label="Settings" />
18+
* - <span role="img" aria-label="Emoji">🎉</span>
19+
* - <div aria-labelledby="existingId">Content</div>
20+
*
21+
* @param openingElement - The JSX opening element to check
22+
* @param context - ESLint rule context for accessing source code and validating references
23+
* @returns true if the element has accessible labeled children
24+
*/
725
export const hasLabeledChild = (openingElement: TSESTree.JSXOpeningElement, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
8-
// TODO: function not yet implemented
9-
return false;
26+
try {
27+
let node: TSESTree.JSXElement | null = null;
28+
29+
if (openingElement.parent && openingElement.parent.type === "JSXElement") {
30+
node = openingElement.parent as TSESTree.JSXElement;
31+
}
32+
33+
if (!node?.children || node.children.length === 0) {
34+
return false;
35+
}
36+
37+
const allChildren = flattenChildren(node);
38+
39+
return allChildren.some(child => {
40+
if (child.type === "JSXElement") {
41+
const childOpeningElement = child.openingElement;
42+
const childName = childOpeningElement.name;
43+
44+
if (childName.type === "JSXIdentifier") {
45+
const tagName = childName.name.toLowerCase();
46+
47+
if ((tagName === "img" || tagName === "image") && hasNonEmptyProp(childOpeningElement.attributes, "alt")) {
48+
return true;
49+
}
50+
51+
if (tagName === "svg") {
52+
return (
53+
hasNonEmptyProp(childOpeningElement.attributes, "title") ||
54+
hasNonEmptyProp(childOpeningElement.attributes, "aria-label") ||
55+
hasAssociatedAriaText(childOpeningElement, context, "aria-labelledby")
56+
);
57+
}
58+
59+
if (hasNonEmptyProp(childOpeningElement.attributes, "role")) {
60+
const roleProp = childOpeningElement.attributes.find(
61+
attr => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "role"
62+
);
63+
64+
if (roleProp?.type === "JSXAttribute" && roleProp.value?.type === "Literal" && roleProp.value.value === "img") {
65+
return (
66+
hasNonEmptyProp(childOpeningElement.attributes, "aria-label") ||
67+
hasAssociatedAriaText(childOpeningElement, context, "aria-labelledby")
68+
);
69+
}
70+
}
71+
72+
if (
73+
tagName.toLowerCase().includes("icon") ||
74+
hasNonEmptyProp(childOpeningElement.attributes, "aria-label") ||
75+
hasNonEmptyProp(childOpeningElement.attributes, "title") ||
76+
hasAssociatedAriaText(childOpeningElement, context, "aria-labelledby")
77+
) {
78+
return true;
79+
}
80+
}
81+
}
82+
83+
return false;
84+
});
85+
} catch (error) {
86+
return false;
87+
}
1088
};

0 commit comments

Comments
 (0)