Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any addi
## Table of Contents

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

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

## Rule Factory System

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.

### Core Concept

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.

### Architecture

```sh
ruleFactory(config) → ESLint Rule
hasAccessibleLabel(opening, element, context, config) → boolean
Utility Functions:
├── hasAssociatedLabelViaAriaLabelledBy(opening, context)
├── hasAssociatedLabelViaHtmlFor(opening, context)
├── hasAssociatedLabelViaAriaDescribedby(opening, context)
├── hasLabeledChild(opening, context)
├── hasTextContentChild(element)
└── isInsideLabelTag(context)
```

## Creating New Rules

### Using the Rule Factory

For most accessibility rules, use the rule factory:

```typescript
import { ruleFactory, LabeledControlConfig } from '../util/ruleFactory';

const rule = ruleFactory({
component: 'YourComponent', // string or regex pattern
message: 'YourComponent needs accessible labeling',

// Validation options (all optional, default false)
allowTextContentChild: true, // Allow text content in children
allowLabeledChild: true, // Allow images with alt, icons, etc.
allowHtmlFor: true, // Allow htmlFor/id label association
allowLabelledBy: true, // Allow aria-labelledby
allowDescribedBy: false, // Allow aria-describedby (discouraged as primary)
allowWrappingLabel: true, // Allow wrapping in <Label> tag
allowTooltipParent: false, // Allow parent <Tooltip>
allowFieldParent: true, // Allow parent <Field>

// Property validation
labelProps: ['aria-label'], // Props that provide labeling
requiredProps: ['role'], // Props that must be present
});

export default rule;
```

### Configuration Options

| Option | Description | Example Use Cases |
|--------|-------------|-------------------|
| `allowTextContentChild` | Allows text content in child elements | Buttons, links with text |
| `allowLabeledChild` | Allows accessible child content (images with alt, icons, aria-labeled elements) | Icon buttons, image buttons |
| `allowHtmlFor` | Allows label association via `htmlFor`/`id` | Form inputs, interactive controls |
| `allowLabelledBy` | Allows `aria-labelledby` references | Complex components referencing external labels |
| `allowDescribedBy` | Allows `aria-describedby` (discouraged for primary labeling) | Rare cases where description suffices |
| `allowWrappingLabel` | Allows element to be wrapped in `<Label>` | Form controls |
| `allowTooltipParent` | Allows parent `<Tooltip>` as accessible name | Simple tooltips (use sparingly) |
| `allowFieldParent` | Allows parent `<Field>` component | FluentUI form fields |

### Custom Rules

For complex validation that doesn't fit the factory pattern:

```typescript
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import { JSXOpeningElement } from "estree-jsx";

const rule = ESLintUtils.RuleCreator.withoutDocs({
defaultOptions: [],
meta: {
messages: {
customMessage: "Custom validation message"
},
type: "problem",
schema: []
},
create(context) {
return {
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
// Custom validation logic
if (needsValidation(node)) {
context.report({
node,
messageId: "customMessage"
});
}
}
};
}
});
```

## Utility Functions

### hasLabeledChild

The `hasLabeledChild` utility detects accessible child content and is one of the most powerful validation functions:

```typescript
import { hasLabeledChild } from '../util/hasLabeledChild';

// Usage in rules
if (hasLabeledChild(openingElement, context)) {
return; // Element has accessible child content
}
```

**Detects:**

1. **Images with alt text:**
```jsx
<Button><img alt="Save document" /></Button>
<Button><Image alt="User profile" /></Button>
```

2. **SVG elements with accessible attributes:**
```jsx
<Button><svg title="Close" /></Button>
<Button><svg aria-label="Menu" /></Button>
<Button><svg aria-labelledby="icon-label" /></Button>
```

3. **Elements with role="img" and labeling:**
```jsx
<Button><span role="img" aria-label="Celebration">🎉</span></Button>
```

4. **FluentUI Icon components:**
```jsx
<Button><SaveIcon /></Button>
<Button><Icon iconName="Save" /></Button>
<Button><MyCustomIcon /></Button>
```

5. **Any element with aria-label or title:**
```jsx
<Button><div aria-label="Status indicator" /></Button>
<Button><span title="Tooltip text" /></Button>
```

6. **Elements with validated aria-labelledby:**
```jsx
<Button><span aria-labelledby="save-label" /></Button>
<Label id="save-label">Save Document</Label>
```

**Key Features:**

- **Source code validation:** Validates that `aria-labelledby` references point to actual elements with matching IDs
- **Deep traversal:** Uses `flattenChildren` to find labeled content in nested structures
- **Case insensitive:** Handles variations like `IMG`, `SVG`, `CLOSEICON`
- **Error handling:** Gracefully handles malformed JSX and missing context

### Other Utility Functions

- **`hasAssociatedLabelViaAriaLabelledBy(opening, context)`** - Validates `aria-labelledby` references
- **`hasAssociatedLabelViaHtmlFor(opening, context)`** - Validates `htmlFor`/`id` label associations
- **`hasAssociatedLabelViaAriaDescribedby(opening, context)`** - Validates `aria-describedby` references
- **`hasTextContentChild(element)`** - Checks for meaningful text content in children
- **`isInsideLabelTag(context)`** - Checks if element is wrapped in a `<Label>` tag
- **`hasNonEmptyProp(attributes, propName)`** - Validates non-empty attribute values
- **`hasDefinedProp(attributes, propName)`** - Checks if attribute is present

### Writing Tests

Use the comprehensive test patterns established in the codebase:

```typescript
import { hasLabeledChild } from "../../../../lib/util/hasLabeledChild";
import { TSESLint } from "@typescript-eslint/utils";

describe("hasLabeledChild", () => {
const mockContext = (sourceText = ""): TSESLint.RuleContext<string, unknown[]> => ({
getSourceCode: () => ({
getText: () => sourceText,
text: sourceText
})
} as unknown as TSESLint.RuleContext<string, unknown[]>);

it("validates aria-labelledby references", () => {
const element = createElementWithChild("div", [
createJSXAttribute("aria-labelledby", "existing-label")
]);
const contextWithLabel = mockContext('<Label id="existing-label">Label Text</Label>');

expect(hasLabeledChild(element, contextWithLabel)).toBe(true);
});
});
```

## To create a new ESLint rule

If you want to create a new ESLint rule:
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,69 @@ This plugin does a static code analysis of the React JSX to spot accessibility i

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.

## Architecture & Development

### Rule Factory System

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:

- **`hasAssociatedLabelViaAriaLabelledBy`** - Validates `aria-labelledby` references
- **`hasAssociatedLabelViaHtmlFor`** - Validates `htmlFor`/`id` label associations
- **`hasAssociatedLabelViaAriaDescribedby`** - Validates `aria-describedby` references
- **`hasLabeledChild`** - Detects accessible child content (images with alt, icons, labeled elements)
- **`hasTextContentChild`** - Validates text content in child elements
- **`isInsideLabelTag`** - Checks if element is wrapped in a label

#### Labeled Child Detection

The `hasLabeledChild` utility is particularly powerful, detecting multiple forms of accessible child content:

```tsx
// Image elements with alt text
<Button><img alt="Save document" /></Button>

// SVG elements with accessible attributes
<Button><svg title="Close" /></Button>
<Button><svg aria-label="Menu" /></Button>

// Elements with role="img" and labeling
<Button><span role="img" aria-label="Celebration">🎉</span></Button>

// FluentUI Icon components
<Button><SaveIcon /></Button>
<Button><Icon iconName="Save" /></Button>

// Any element with aria-label or title
<Button><div aria-label="Status indicator" /></Button>

// Elements with aria-labelledby (validates references exist)
<Button><span aria-labelledby="save-label" /></Button>
<Label id="save-label">Save Document</Label>
```

The utility performs source code analysis to validate that `aria-labelledby` references point to actual elements with matching IDs, ensuring robust accessibility validation.

### Creating New Rules

To create a new accessibility rule, use the rule factory system:

```typescript
import { ruleFactory, LabeledControlConfig } from '../util/ruleFactory';

const rule = ruleFactory({
component: 'YourComponent', // or /RegexPattern/
message: 'Your component needs accessible labeling',
allowTextContentChild: true,
allowLabeledChild: true,
allowHtmlFor: true,
allowLabelledBy: true,
labelProps: ['aria-label'],
requiredProps: ['role']
});
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development guidelines.

## Trademarks

This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
Expand Down
86 changes: 82 additions & 4 deletions lib/util/hasLabeledChild.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,88 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { TSESLint, TSESTree } from "@typescript-eslint/utils";
import { TSESTree } from "@typescript-eslint/utils";
import { TSESLint } from "@typescript-eslint/utils";
import { flattenChildren } from "./flattenChildren";
import { hasNonEmptyProp } from "./hasNonEmptyProp";
import { hasAssociatedAriaText } from "./labelUtils";

// eslint-disable-next-line no-unused-vars
/**
* Checks if a JSX element has properly labeled child elements that can serve as an accessible name.
* This includes child elements with alt text, aria-label, title attributes, or other accessibility attributes.
*
* Examples of labeled children:
* - <img alt="User profile" />
* - <svg title="Close icon" />
* - <Icon aria-label="Settings" />
* - <span role="img" aria-label="Emoji">🎉</span>
* - <div aria-labelledby="existingId">Content</div>
*
* @param openingElement - The JSX opening element to check
* @param context - ESLint rule context for accessing source code and validating references
* @returns true if the element has accessible labeled children
*/
export const hasLabeledChild = (openingElement: TSESTree.JSXOpeningElement, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
// TODO: function not yet implemented
return false;
try {
let node: TSESTree.JSXElement | null = null;

if (openingElement.parent && openingElement.parent.type === "JSXElement") {
node = openingElement.parent as TSESTree.JSXElement;
}

if (!node?.children || node.children.length === 0) {
return false;
}

const allChildren = flattenChildren(node);

return allChildren.some(child => {
if (child.type === "JSXElement") {
const childOpeningElement = child.openingElement;
const childName = childOpeningElement.name;

if (childName.type === "JSXIdentifier") {
const tagName = childName.name.toLowerCase();

if ((tagName === "img" || tagName === "image") && hasNonEmptyProp(childOpeningElement.attributes, "alt")) {
return true;
}

if (tagName === "svg") {
return (
hasNonEmptyProp(childOpeningElement.attributes, "title") ||
hasNonEmptyProp(childOpeningElement.attributes, "aria-label") ||
hasAssociatedAriaText(childOpeningElement, context, "aria-labelledby")
);
}

if (hasNonEmptyProp(childOpeningElement.attributes, "role")) {
const roleProp = childOpeningElement.attributes.find(
attr => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "role"
);

if (roleProp?.type === "JSXAttribute" && roleProp.value?.type === "Literal" && roleProp.value.value === "img") {
return (
hasNonEmptyProp(childOpeningElement.attributes, "aria-label") ||
hasAssociatedAriaText(childOpeningElement, context, "aria-labelledby")
);
}
}

if (
tagName.toLowerCase().includes("icon") ||
hasNonEmptyProp(childOpeningElement.attributes, "aria-label") ||
hasNonEmptyProp(childOpeningElement.attributes, "title") ||
hasAssociatedAriaText(childOpeningElement, context, "aria-labelledby")
) {
return true;
}
}
}

return false;
});
} catch (error) {
return false;
}
};
Loading
Loading