|
2 | 2 | // Licensed under the MIT License. |
3 | 3 |
|
4 | 4 | import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; |
5 | | -import { elementType } from "jsx-ast-utils"; |
6 | | -import { JSXOpeningElement } from "estree-jsx"; |
| 5 | +import { makeLabeledControlRule } from "../util/ruleFactory"; |
7 | 6 |
|
8 | | -export default ESLintUtils.RuleCreator.withoutDocs({ |
9 | | - meta: { |
10 | | - type: "problem", |
11 | | - docs: { |
| 7 | +export default ESLintUtils.RuleCreator.withoutDocs( |
| 8 | + makeLabeledControlRule( |
| 9 | + { |
| 10 | + component: "SplitButton", |
| 11 | + messageId: "noUnlabeledSplitButton", |
12 | 12 | description: "Accessibility: SplitButton must have text content or accessible name on primaryActionButton prop.", |
13 | | - recommended: "strict", |
14 | | - url: "https://www.w3.org/WAI/standards-guidelines/act/rules/97a4e1/" |
| 13 | + labelProps: [], |
| 14 | + allowFieldParent: false, |
| 15 | + allowHtmlFor: false, |
| 16 | + allowLabelledBy: false, |
| 17 | + allowWrappingLabel: false, |
| 18 | + allowTooltipParent: false, |
| 19 | + allowDescribedBy: false, |
| 20 | + allowLabeledChild: false, |
| 21 | + allowTextContentChild: false |
15 | 22 | }, |
16 | | - messages: { |
17 | | - noUnlabeledSplitButton: |
18 | | - "SplitButton must have text content, or an accessible name on the primaryActionButton prop (e.g., aria-label). Labeling the SplitButton itself is not valid." |
19 | | - }, |
20 | | - schema: [] |
21 | | - }, |
22 | | - defaultOptions: [], |
23 | | - create(context) { |
24 | | - return { |
25 | | - JSXElement(node: TSESTree.JSXElement) { |
26 | | - const opening = node.openingElement; |
27 | | - const name = elementType(opening as unknown as JSXOpeningElement); |
28 | | - if (name !== "SplitButton") return; |
29 | | - |
30 | | - // 1. Check for any non-empty text child |
31 | | - const hasTextContent = node.children.some(child => child.type === "JSXText" && child.value.trim().length > 0); |
32 | | - if (hasTextContent) return; |
| 23 | + isSplitButtonAccessiblyLabeled |
| 24 | + ) |
| 25 | +); |
33 | 26 |
|
34 | | - // 2. Check for primaryActionButton prop with aria-label |
35 | | - const primaryActionButtonProp = opening.attributes.find( |
36 | | - attr => attr.type === "JSXAttribute" && attr.name.name === "primaryActionButton" |
37 | | - ); |
38 | | - if ( |
39 | | - primaryActionButtonProp && |
40 | | - primaryActionButtonProp.type === "JSXAttribute" && |
41 | | - primaryActionButtonProp.value && |
42 | | - primaryActionButtonProp.value.type === "JSXExpressionContainer" |
43 | | - ) { |
44 | | - const expr = primaryActionButtonProp.value.expression; |
45 | | - // Only handle object literals |
46 | | - if (expr.type === "ObjectExpression") { |
47 | | - const hasAriaLabel = expr.properties.some( |
48 | | - prop => |
49 | | - prop.type === "Property" && |
50 | | - ((prop.key.type === "Identifier" && prop.key.name === "aria-label") || |
51 | | - (prop.key.type === "Literal" && prop.key.value === "aria-label")) && |
52 | | - prop.value.type === "Literal" && |
53 | | - typeof prop.value.value === "string" && |
54 | | - prop.value.value.trim().length > 0 |
55 | | - ); |
56 | | - if (hasAriaLabel) return; |
57 | | - } |
58 | | - } |
| 27 | +/** |
| 28 | + * Custom accessibility checker for SplitButton: |
| 29 | + * 1. Accessible if it has any non-empty text child. |
| 30 | + * 2. If not, must have primaryActionButton prop with aria-label. |
| 31 | + * 3. All other labeling strategies are invalid. |
| 32 | + */ |
| 33 | +export function isSplitButtonAccessiblyLabeled(node: TSESTree.JSXElement): boolean { |
| 34 | + // 1. Check for any non-empty text child |
| 35 | + const hasTextContent = node.children.some(child => child.type === "JSXText" && child.value.trim().length > 0); |
| 36 | + if (hasTextContent) return true; |
59 | 37 |
|
60 | | - // 3. Otherwise, report error |
61 | | - context.report({ node: opening, messageId: "noUnlabeledSplitButton" }); |
62 | | - } |
63 | | - }; |
| 38 | + // 2. Check for primaryActionButton prop with aria-label |
| 39 | + const opening = node.openingElement; |
| 40 | + const primaryActionButtonProp = opening.attributes.find( |
| 41 | + attr => attr.type === "JSXAttribute" && attr.name.name === "primaryActionButton" |
| 42 | + ); |
| 43 | + if ( |
| 44 | + primaryActionButtonProp && |
| 45 | + primaryActionButtonProp.type === "JSXAttribute" && |
| 46 | + primaryActionButtonProp.value && |
| 47 | + primaryActionButtonProp.value.type === "JSXExpressionContainer" |
| 48 | + ) { |
| 49 | + const expr = primaryActionButtonProp.value.expression; |
| 50 | + // Only handle object literals |
| 51 | + if (expr.type === "ObjectExpression") { |
| 52 | + const hasAriaLabel = expr.properties.some( |
| 53 | + prop => |
| 54 | + prop.type === "Property" && |
| 55 | + ((prop.key.type === "Identifier" && prop.key.name === "aria-label") || |
| 56 | + (prop.key.type === "Literal" && prop.key.value === "aria-label")) && |
| 57 | + prop.value.type === "Literal" && |
| 58 | + typeof prop.value.value === "string" && |
| 59 | + prop.value.value.trim().length > 0 |
| 60 | + ); |
| 61 | + if (hasAriaLabel) return true; |
| 62 | + } |
64 | 63 | } |
65 | | -}); |
| 64 | + return false; |
| 65 | +} |
0 commit comments