| 
16 | 16 | ## Table of Contents  | 
17 | 17 | 
 
  | 
18 | 18 | [Dev Environment](#dev-environment)  | 
 | 19 | +[Rule Factory System](#rule-factory-system)  | 
 | 20 | +[Creating New Rules](#creating-new-rules)  | 
 | 21 | +[Utility Functions](#utility-functions)  | 
19 | 22 | [Pull requests](#pull-requests)  | 
20 | 23 | 
 
  | 
21 | 24 | ## Dev Environment  | 
@@ -61,6 +64,205 @@ To ensure a consistent and productive development environment, install the follo  | 
61 | 64 | - [Prettier ESLint](https://marketplace.visualstudio.com/items?itemName=rvest.vs-code-prettier-eslint) — Format code with Prettier and ESLint integration.  | 
62 | 65 | - [markdownlint](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) — Linting and style checks for Markdown files.  | 
63 | 66 | 
 
  | 
 | 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 | +
  | 
64 | 266 | ## To create a new ESLint rule  | 
65 | 267 | 
  | 
66 | 268 | If you want to create a new ESLint rule:  | 
 | 
0 commit comments