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" }] + } + ] +});