diff --git a/COVERAGE.md b/COVERAGE.md
index 872b914..e19390d 100644
--- a/COVERAGE.md
+++ b/COVERAGE.md
@@ -15,7 +15,7 @@ We currently cover the following components:
- [X] CompoundButton
- [] MenuButton
- [X] MenuItem
- - [] SplitButton
+ - [x] SplitButton
- [x] ToggleButton
- [] ToolbarToggleButton
- [] Card
diff --git a/README.md b/README.md
index 420bf64..6103795 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po
| [spin-button-needs-labelling](docs/rules/spin-button-needs-labelling.md) | Accessibility: SpinButtons must have an accessible label | ✅ | | |
| [spin-button-unrecommended-labelling](docs/rules/spin-button-unrecommended-labelling.md) | Accessibility: Unrecommended accessibility labelling - SpinButton | ✅ | | |
| [spinner-needs-labelling](docs/rules/spinner-needs-labelling.md) | Accessibility: Spinner must have either aria-label or label, aria-live and aria-busy attributes | ✅ | | |
+| [split-button-needs-labelling](docs/rules/split-button-needs-labelling.md) | Accessibility: SplitButton must have text content or accessible name on primaryActionButton prop. | ✅ | | |
| [swatchpicker-needs-labelling](docs/rules/swatchpicker-needs-labelling.md) | Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.. | ✅ | | |
| [switch-needs-labelling](docs/rules/switch-needs-labelling.md) | Accessibility: Switch must have an accessible label | ✅ | | |
| [tablist-and-tabs-need-labelling](docs/rules/tablist-and-tabs-need-labelling.md) | This rule aims to ensure that Tabs with icons but no text labels have an accessible name and that Tablist is properly labeled. | ✅ | | |
diff --git a/docs/rules/split-button-needs-labelling.md b/docs/rules/split-button-needs-labelling.md
new file mode 100644
index 0000000..ce42685
--- /dev/null
+++ b/docs/rules/split-button-needs-labelling.md
@@ -0,0 +1,49 @@
+# Accessibility: SplitButton must have text content or accessible name on primaryActionButton prop (`@microsoft/fluentui-jsx-a11y/split-button-needs-labelling`)
+
+💼 This rule is enabled in the ✅ `recommended` config.
+
+
+
+SplitButton without a label or accessible labeling lack an accessible name for assistive technology users.
+
+SplitButton components need a visual label.
+
+Please add label, or aria-labelledby.
+
+
+
+## Rule Details
+
+This rule aims to...
+
+Example of **incorrect** code for this rule:
+
+```jsx
+ }
+ />
+```
+
+Examples of **correct** code for this rule:
+
+```jsx
+
+ Example
+
+```
+
+```jsx
+}
+ />
+```
\ No newline at end of file
diff --git a/lib/index.ts b/lib/index.ts
index 1c063e5..1e11b9b 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -49,6 +49,7 @@ module.exports = {
"@microsoft/fluentui-jsx-a11y/spin-button-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/spin-button-unrecommended-labelling": "error",
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
+ "@microsoft/fluentui-jsx-a11y/split-button-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/switch-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/tablist-and-tabs-need-labelling": "error",
@@ -92,6 +93,7 @@ module.exports = {
"spin-button-needs-labelling": rules.spinButtonNeedsLabelling,
"spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling,
"spinner-needs-labelling": rules.spinnerNeedsLabelling,
+ "split-button-needs-labelling": rules.splitButtonNeedsLabelling,
"swatchpicker-needs-labelling": rules.swatchpickerNeedsLabelling,
"switch-needs-labelling": rules.switchNeedsLabelling,
"tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling,
diff --git a/lib/rules/index.ts b/lib/rules/index.ts
index c31ac56..fb7a8db 100644
--- a/lib/rules/index.ts
+++ b/lib/rules/index.ts
@@ -33,6 +33,7 @@ export { default as imageSwatchNeedsLabelling } from "./imageswatch-needs-labell
export { default as spinButtonNeedsLabelling } from "./spin-button-needs-labelling";
export { default as spinButtonUnrecommendedLabelling } from "./spin-button-unrecommended-labelling";
export { default as spinnerNeedsLabelling } from "./spinner-needs-labelling";
+export { default as splitButtonNeedsLabelling } from "./split-button-needs-labelling";
export { default as swatchpickerNeedsLabelling } from "./swatchpicker-needs-labelling";
export { default as switchNeedsLabelling } from "./switch-needs-labelling";
export { default as tablistAndTabsNeedLabelling } from "./tablist-and-tabs-need-labelling";
diff --git a/lib/rules/split-button-needs-labelling.ts b/lib/rules/split-button-needs-labelling.ts
new file mode 100644
index 0000000..139bb35
--- /dev/null
+++ b/lib/rules/split-button-needs-labelling.ts
@@ -0,0 +1,65 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
+import { makeLabeledControlRule } from "../util/ruleFactory";
+
+export default ESLintUtils.RuleCreator.withoutDocs(
+ makeLabeledControlRule(
+ {
+ component: "SplitButton",
+ messageId: "noUnlabeledSplitButton",
+ description: "Accessibility: SplitButton must have text content or accessible name on primaryActionButton prop.",
+ labelProps: [],
+ allowFieldParent: false,
+ allowHtmlFor: false,
+ allowLabelledBy: false,
+ allowWrappingLabel: false,
+ allowTooltipParent: false,
+ allowDescribedBy: false,
+ allowLabeledChild: false,
+ allowTextContentChild: false
+ },
+ isSplitButtonAccessiblyLabeled
+ )
+);
+
+/**
+ * Custom accessibility checker for SplitButton:
+ * 1. Accessible if it has any non-empty text child.
+ * 2. If not, must have primaryActionButton prop with aria-label.
+ * 3. All other labeling strategies are invalid.
+ */
+export function isSplitButtonAccessiblyLabeled(node: TSESTree.JSXElement): boolean {
+ // 1. Check for any non-empty text child
+ const hasTextContent = node.children.some(child => child.type === "JSXText" && child.value.trim().length > 0);
+ if (hasTextContent) return true;
+
+ // 2. Check for primaryActionButton prop with aria-label
+ const opening = node.openingElement;
+ const primaryActionButtonProp = opening.attributes.find(
+ attr => attr.type === "JSXAttribute" && attr.name.name === "primaryActionButton"
+ );
+ if (
+ primaryActionButtonProp &&
+ primaryActionButtonProp.type === "JSXAttribute" &&
+ primaryActionButtonProp.value &&
+ primaryActionButtonProp.value.type === "JSXExpressionContainer"
+ ) {
+ const expr = primaryActionButtonProp.value.expression;
+ // Only handle object literals
+ if (expr.type === "ObjectExpression") {
+ const hasAriaLabel = expr.properties.some(
+ prop =>
+ prop.type === "Property" &&
+ ((prop.key.type === "Identifier" && prop.key.name === "aria-label") ||
+ (prop.key.type === "Literal" && prop.key.value === "aria-label")) &&
+ prop.value.type === "Literal" &&
+ typeof prop.value.value === "string" &&
+ prop.value.value.trim().length > 0
+ );
+ if (hasAriaLabel) return true;
+ }
+ }
+ return false;
+}
diff --git a/lib/util/ruleFactory.ts b/lib/util/ruleFactory.ts
index 7e285f7..7105f81 100644
--- a/lib/util/ruleFactory.ts
+++ b/lib/util/ruleFactory.ts
@@ -105,7 +105,11 @@ export function hasAccessibleLabel(
* Factory for a minimal, strongly-configurable ESLint rule that enforces
* accessible labeling on a specific JSX element/component.
*/
-export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.RuleModule {
+
+// eslint-disable-next-line no-unused-vars
+type CustomChecker = (node: TSESTree.JSXElement, context: TSESLint.RuleContext) => boolean;
+
+export function makeLabeledControlRule(config: LabeledControlConfig, customChecker?: CustomChecker): TSESLint.RuleModule {
return {
meta: {
type: "problem",
@@ -128,8 +132,9 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R
if (!matches) return;
- if (!hasAccessibleLabel(opening, node, context, config)) {
- // report on the opening tag for better location
+ const isAccessible = customChecker ? customChecker(node, context) : hasAccessibleLabel(opening, node, context, config);
+
+ if (!isAccessible) {
context.report({ node: opening, messageId: config.messageId });
}
}
diff --git a/tests/lib/rules/split-button-needs-labelling.test.ts b/tests/lib/rules/split-button-needs-labelling.test.ts
new file mode 100644
index 0000000..a5e38ab
--- /dev/null
+++ b/tests/lib/rules/split-button-needs-labelling.test.ts
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { Rule } from "eslint";
+import ruleTester from "./helper/ruleTester";
+import rule from "../../../lib/rules/split-button-needs-labelling";
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+ruleTester.run("split-button-needs-labelling", rule as unknown as Rule.RuleModule, {
+ valid: [
+ // 1) aria-label on the SplitButton
+
+ `
+ Example
+
+ `,
+ // 2) with primaryActionButton prop having aria-label
+ `
+ }
+ />
+ `
+ ],
+
+ invalid: [
+ // Unlabeled SplitButton
+ {
+ code: `
+
+ `,
+ errors: [{ messageId: "noUnlabeledSplitButton" }]
+ },
+ // SplitButton empty aria-label
+ {
+ code: `
+
+ `,
+ errors: [{ messageId: "noUnlabeledSplitButton" }]
+ },
+ // SplitButton with aria-label null
+ {
+ code: `
+
+ `,
+ errors: [{ messageId: "noUnlabeledSplitButton" }]
+ },
+ // SplitButton with aria-label undefined
+ {
+ code: `
+
+ `,
+ errors: [{ messageId: "noUnlabeledSplitButton" }]
+ }
+ ]
+});