Skip to content
Open
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
2 changes: 1 addition & 1 deletion COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ We currently cover the following components:
- [X] CompoundButton
- [] MenuButton
- [X] MenuItem
- [] SplitButton
- [x] SplitButton
- [x] ToggleButton
- [] ToolbarToggleButton
- [] Card
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | ✅ | | |
Expand Down
49 changes: 49 additions & 0 deletions docs/rules/split-button-needs-labelling.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- end auto-generated rule header -->

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.

<https://www.w3.org/WAI/standards-guidelines/act/rules/97a4e1/>

## Rule Details

This rule aims to...

Example of **incorrect** code for this rule:

```jsx
<SplitButton
menuButton={triggerProps}
primaryActionButton={primaryActionButtonProps}
icon={<CalendarMonthRegular />}
/>
```

Examples of **correct** code for this rule:

```jsx
<SplitButton
menuButton={triggerProps}
primaryActionButton={primaryActionButtonProps}
>
Example
</SplitButton>
```

```jsx
<SplitButton
menuButton={triggerProps}
primaryActionButton={{
ref: setPrimaryActionButtonRef,
"aria-label": "With calendar icon only",
}}
icon={<CalendarMonthRegular />}
/>
```
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
65 changes: 65 additions & 0 deletions lib/rules/split-button-needs-labelling.ts
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 8 additions & 3 deletions lib/util/ruleFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, []> {

// eslint-disable-next-line no-unused-vars
type CustomChecker = (node: TSESTree.JSXElement, context: TSESLint.RuleContext<string, []>) => boolean;

export function makeLabeledControlRule(config: LabeledControlConfig, customChecker?: CustomChecker): TSESLint.RuleModule<string, []> {
return {
meta: {
type: "problem",
Expand All @@ -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 });
}
}
Expand Down
86 changes: 86 additions & 0 deletions tests/lib/rules/split-button-needs-labelling.test.ts
Original file line number Diff line number Diff line change
@@ -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

`<SplitButton
menuButton={triggerProps}
primaryActionButton={primaryActionButtonProps}
>
Example
</SplitButton>
`,
// 2) with primaryActionButton prop having aria-label
`
<SplitButton
menuButton={triggerProps}
primaryActionButton={{
ref: setPrimaryActionButtonRef,
"aria-label": "With calendar icon only",
}}
icon={<CalendarMonthRegular />}
/>
`
],

invalid: [
// Unlabeled SplitButton
{
code: `
<SplitButton
menuButton={triggerProps}
primaryActionButton={primaryActionButtonProps}
/>
`,
errors: [{ messageId: "noUnlabeledSplitButton" }]
},
// SplitButton empty aria-label
{
code: `
<SplitButton
menuButton={triggerProps}
primaryActionButton={{
ref: setPrimaryActionButtonRef,
"aria-label": "",
}}
/>
`,
errors: [{ messageId: "noUnlabeledSplitButton" }]
},
// SplitButton with aria-label null
{
code: `
<SplitButton
menuButton={triggerProps}
primaryActionButton={{
ref: setPrimaryActionButtonRef,
"aria-label": null,
}}
/>
`,
errors: [{ messageId: "noUnlabeledSplitButton" }]
},
// SplitButton with aria-label undefined
{
code: `
<SplitButton
menuButton={triggerProps}
primaryActionButton={{
ref: setPrimaryActionButtonRef,
"aria-label": undefined,
}}
/>
`,
errors: [{ messageId: "noUnlabeledSplitButton" }]
}
]
});
Loading