Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po
| [no-empty-buttons](docs/rules/no-empty-buttons.md) | Accessibility: Button, ToggleButton, SplitButton, MenuButton, CompoundButton must either text content or icon or child component | ✅ | | |
| [no-empty-components](docs/rules/no-empty-components.md) | FluentUI components should not be empty | ✅ | | |
| [prefer-aria-over-title-attribute](docs/rules/prefer-aria-over-title-attribute.md) | The title attribute is not consistently read by screen readers, and its behavior can vary depending on the screen reader and the user's settings. | | ✅ | 🔧 |
| [prefer-disabledfocusable-over-disabled](docs/rules/prefer-disabledfocusable-over-disabled.md) | Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility | | ✅ | 🔧 |
| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | ✅ | | |
| [radio-button-missing-label](docs/rules/radio-button-missing-label.md) | Accessibility: Radio button without label must have an accessible and visual label: aria-labelledby | ✅ | | |
| [radiogroup-missing-label](docs/rules/radiogroup-missing-label.md) | Accessibility: RadioGroup without label must have an accessible and visual label: aria-labelledby | ✅ | | |
Expand Down
139 changes: 139 additions & 0 deletions docs/rules/prefer-disabledfocusable-over-disabled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility (`@microsoft/fluentui-jsx-a11y/prefer-disabledfocusable-over-disabled`)

⚠️ This rule _warns_ in the ✅ `recommended` config.

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

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

When components are in a loading state, prefer using `disabledFocusable` over `disabled` to maintain proper keyboard navigation flow and accessibility.

## Rule Details

This rule encourages the use of `disabledFocusable` instead of `disabled` when components have loading state indicators. This ensures:

1. **Keyboard Navigation**: The component remains in the keyboard tab order, allowing users to discover and navigate to it
2. **Screen Reader Compatibility**: Screen reader users can still navigate to and understand the component's state
3. **Loading State Awareness**: Users understand that the component is temporarily unavailable due to loading, not permanently disabled
4. **Consistent UX**: Provides a more predictable and accessible user experience

### Accessibility Impact

- `disabled` removes elements completely from the tab order (tabindex="-1")
- `disabledFocusable` keeps elements in the tab order while conveying disabled state via `aria-disabled="true"`
- Loading states are temporary conditions where users benefit from knowing the component exists and will become available

### Applicable Components

This rule applies to FluentUI components that support both `disabled` and `disabledFocusable` props:

**Button Components:** `Button`, `ToggleButton`, `CompoundButton`, `MenuButton`, `SplitButton`
**Form Controls:** `Checkbox`, `Radio`, `Switch`
**Input Components:** `Input`, `Textarea`, `Combobox`, `Dropdown`, `SpinButton`, `Slider`, `DatePicker`, `TimePicker`
**Other Interactive:** `Link`, `Tab`

### Loading State Indicators

The rule detects these loading-related props:
- `loading`
- `isLoading`
- `pending`
- `isPending`
- `busy`
- `isBusy`

## Examples

### ❌ Incorrect

```jsx
<Button disabled loading>Submit</Button>
<ToggleButton disabled isLoading />
<Checkbox disabled pending />
<Input disabled={true} busy={isBusy} />
<SpinButton disabled={isDisabled} loading={isSubmitting} />
<Combobox disabled pending />
```

### ✅ Correct

```jsx
<Button disabledFocusable loading>Submit</Button>
<ToggleButton disabledFocusable isLoading />
<Checkbox disabledFocusable pending />
<Input disabledFocusable={true} busy={isBusy} />
<SpinButton disabledFocusable={isDisabled} loading={isSubmitting} />
<Combobox disabledFocusable pending />

<!-- These are acceptable since no loading state is present -->
<Button disabled>Cancel</Button>
<Checkbox disabled />
<Input disabled={permanentlyDisabled} />

<!-- These are acceptable since component is not disabled -->
<Button loading>Submit</Button>
<SpinButton isLoading />
<Input busy />
```

## Edge Cases & Considerations

### Both Props Present
If both `disabled` and `disabledFocusable` are present, this rule will not trigger as it represents a different configuration issue.

```jsx
<!-- Rule will not trigger - different concern -->
<Button disabled disabledFocusable loading>Submit</Button>
```

### Non-Loading Disabled States
The rule only applies when both disabled AND loading states are present:

```jsx
<!-- ✅ Acceptable - no loading state -->
<Button disabled>Permanently Disabled Action</Button>
```

### Complex Expressions
The rule works with boolean expressions and variables:

```jsx
<!-- ❌ Will trigger -->
<Button disabled={!isEnabled} loading={isSubmitting}>Submit</Button>

<!-- ✅ Correct -->
<Button disabledFocusable={!isEnabled} loading={isSubmitting}>Submit</Button>
```

## When Not To Use It

You may want to disable this rule if:

1. **Intentional UX Decision**: You specifically want loading components removed from tab order
2. **Legacy Codebase**: Existing implementations rely on specific disabled behavior during loading
3. **Custom Loading Patterns**: Your application uses non-standard loading state management

However, disabling this rule is generally **not recommended** as it reduces accessibility.

## Automatic Fixes

The rule provides automatic fixes that replace `disabled` with `disabledFocusable` while preserving the original prop value:

```jsx
// Before fix
<Button disabled={isSubmitting} loading>Submit</Button>

// After fix
<Button disabledFocusable={isSubmitting} loading>Submit</Button>
```

## Related Rules

- [`no-empty-buttons`](./no-empty-buttons.md) - Ensures buttons have content or accessible labeling
- [`prefer-aria-over-title-attribute`](./prefer-aria-over-title-attribute.md) - Improves screen reader compatibility

## Further Reading

- [WAI-ARIA: Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [FluentUI Accessibility Guidelines](https://react.fluentui.dev/?path=/docs/concepts-developer-accessibility--page)
- [Understanding ARIA: disabled vs aria-disabled](https://css-tricks.com/making-disabled-buttons-more-inclusive/)
36 changes: 36 additions & 0 deletions lib/applicableComponents/disabledFocusableComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* FluentUI components that support both 'disabled' and 'disabledFocusable' props
* These are components where the rule should apply
*/
const disabledFocusableComponents = [
// Button components
"Button",
"ToggleButton",
"CompoundButton",
"MenuButton",
"SplitButton",

// Form controls
"Checkbox",
"Radio",
"Switch",

// Input components
"Input",
"Textarea",
"Combobox",
"Dropdown",
"SpinButton",
"Slider",
"DatePicker",
"TimePicker",

// Other interactive components
"Link",
"Tab"
] as const;

export { disabledFocusableComponents };
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = {
"@microsoft/fluentui-jsx-a11y/no-empty-buttons": "error",
"@microsoft/fluentui-jsx-a11y/no-empty-components": "error",
"@microsoft/fluentui-jsx-a11y/prefer-aria-over-title-attribute": "warn",
"@microsoft/fluentui-jsx-a11y/prefer-disabledfocusable-over-disabled": "warn",
"@microsoft/fluentui-jsx-a11y/progressbar-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/radio-button-missing-label": "error",
"@microsoft/fluentui-jsx-a11y/radiogroup-missing-label": "error",
Expand Down Expand Up @@ -81,6 +82,7 @@ module.exports = {
"no-empty-buttons": rules.noEmptyButtons,
"no-empty-components": rules.noEmptyComponents,
"prefer-aria-over-title-attribute": rules.preferAriaOverTitleAttribute,
"prefer-disabledfocusable-over-disabled": rules.preferDisabledFocusableOverDisabled,
"progressbar-needs-labelling": rules.progressbarNeedsLabelling,
"radio-button-missing-label": rules.radioButtonMissingLabel,
"radiogroup-missing-label": rules.radiogroupMissingLabel,
Expand Down
1 change: 1 addition & 0 deletions lib/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ export { default as tablistAndTabsNeedLabelling } from "./tablist-and-tabs-need-
export { default as toolbarMissingAria } from "./toolbar-missing-aria";
export { default as tooltipNotRecommended } from "./tooltip-not-recommended";
export { default as visualLabelBetterThanAriaSuggestion } from "./visual-label-better-than-aria-suggestion";
export { default as preferDisabledFocusableOverDisabled } from "./prefer-disabledfocusable-over-disabled";
88 changes: 88 additions & 0 deletions lib/rules/prefer-disabledfocusable-over-disabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import { elementType } from "jsx-ast-utils";
import { hasNonEmptyProp } from "../util/hasNonEmptyProp";
import { hasLoadingState, getLoadingStateProp } from "../util/hasLoadingState";
import { disabledFocusableComponents } from "../applicableComponents/disabledFocusableComponents";
import { JSXOpeningElement } from "estree-jsx";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

const rule = ESLintUtils.RuleCreator.withoutDocs({
defaultOptions: [],
meta: {
messages: {
preferDisabledFocusable:
"Accessibility: Prefer 'disabledFocusable={{{{loadingProp}}}}}' over 'disabled={{{{loadingProp}}}}}' when component has loading state '{{loadingProp}}' to maintain keyboard navigation accessibility",
preferDisabledFocusableGeneric:
"Accessibility: Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility"
},
type: "suggestion", // This is a suggestion for better accessibility
docs: {
description:
"Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility",
recommended: "warn",
url: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/"
},
fixable: "code", // Allow auto-fixing
schema: []
},

create(context) {
return {
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
const componentName = elementType(node as JSXOpeningElement);

// Check if this is an applicable component
if (!disabledFocusableComponents.includes(componentName as any)) {
return;
}

// Check if component has 'disabled' prop
const hasDisabled = hasNonEmptyProp(node.attributes, "disabled");
if (!hasDisabled) {
return;
}

// Check if component has loading state
const hasLoading = hasLoadingState(node.attributes);
if (!hasLoading) {
return;
}

// Check if component already has disabledFocusable (avoid conflicts)
const hasDisabledFocusable = hasNonEmptyProp(node.attributes, "disabledFocusable");
if (hasDisabledFocusable) {
return; // Don't report if both are present - that's a different issue
}

const loadingProp = getLoadingStateProp(node.attributes);

context.report({
node,
messageId: loadingProp ? "preferDisabledFocusable" : "preferDisabledFocusableGeneric",
data: {
loadingProp: loadingProp || "loading"
},
fix(fixer) {
// Find the disabled attribute and replace it with disabledFocusable
const disabledAttr = node.attributes.find(
attr => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "disabled"
);

if (disabledAttr && disabledAttr.type === "JSXAttribute" && disabledAttr.name) {
return fixer.replaceText(disabledAttr.name, "disabledFocusable");
}
return null;
}
});
}
};
}
});

export default rule;
28 changes: 28 additions & 0 deletions lib/util/hasLoadingState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { TSESTree } from "@typescript-eslint/utils";
import { hasNonEmptyProp } from "./hasNonEmptyProp";

/**
* Common prop names that indicate a loading state in FluentUI components
*/
const LOADING_STATE_PROPS = ["loading", "isLoading", "pending", "isPending", "busy", "isBusy"] as const;

/**
* Determines if the component has any loading state indicator prop
* @param attributes - JSX attributes array
* @returns boolean indicating if component has loading state
*/
export const hasLoadingState = (attributes: TSESTree.JSXOpeningElement["attributes"]): boolean => {
return LOADING_STATE_PROPS.some(prop => hasNonEmptyProp(attributes, prop));
};

/**
* Gets the specific loading prop that is present (for better error messages)
* @param attributes - JSX attributes array
* @returns string name of the loading prop found, or null if none
*/
export const getLoadingStateProp = (attributes: TSESTree.JSXOpeningElement["attributes"]): string | null => {
return LOADING_STATE_PROPS.find(prop => hasNonEmptyProp(attributes, prop)) ?? null;
};
Loading
Loading