Skip to content

Conversation

WioletaKolodziej
Copy link

@WioletaKolodziej WioletaKolodziej commented Aug 23, 2025

Closes #31678

What I did

Replaced native disabled with aria-disabled for accessibility.
This keeps the button focusable and prevents conflicts with native behavior while preserving styling and click handling.

Changes made:

Replaced native disabled attribute with aria-disabled to preserve focusability and accessibility.

Updated handleClick to prevent clicks when disabled is true, instead of conditionally removing the onClick handler.

Styled button still uses disabled for cursor and opacity styling.

Reasoning:

Using native disabled removes the button from keyboard navigation and makes it invisible to screen readers.
By using aria-disabled, the button remains focusable and perceivable by assistive technologies, while still preventing interactions.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=<PR_NUMBER>

Greptile Summary

Updated On: 2025-09-10 14:51:07 UTC

This PR implements accessibility improvements by replacing the native HTML disabled attribute with aria-disabled in the Button component. The change addresses the issue where disabled buttons are removed from the accessibility tree and cannot be navigated via keyboard, making them invisible to screen readers.

The PR updates the Button component to use aria-disabled="true" while maintaining visual styling through CSS that still references the disabled prop. The implementation adds click prevention logic in the handleClick method to prevent interactions when disabled is true, rather than relying on the native HTML disabled behavior. This approach keeps buttons focusable and perceivable by assistive technologies while still preventing user interactions.

Corresponding test files have been updated across the codebase to check for aria-disabled attributes instead of using DOM testing library matchers like toBeDisabled(). The changes affect E2E tests for addon-toolbars, addon-viewport, component testing, and IntentSurvey stories. The Button stories have been enhanced with proper TypeScript types and a custom render function for the disabled state demonstration.

Confidence score: 1/5

  • This PR contains critical syntax errors that will cause immediate compilation failures in production
  • Score reflects severe code quality issues including duplicate property declarations and malformed function signatures in the core Button component
  • Multiple files still contain both disabled and aria-disabled attributes simultaneously, creating conflicting implementations

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Bot Settings | Greptile

@Sidnioulz Sidnioulz changed the title fix(a11y): replace native disabled with isDisabled and aria-disabled in Button - #31678 A11y: Replace disabled attr with aria-disabled in Button Aug 29, 2025
@Sidnioulz Sidnioulz self-assigned this Aug 29, 2025
@Sidnioulz Sidnioulz self-requested a review August 29, 2025 07:41
Copy link
Member

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution, @WioletaKolodziej!

Let's first start with addressing the feedback from Greptile, and with fixing the PR description. Please copy the PR template that you deleted and please fill it in carefully. It helps us speed up reviews.

You'll also need to address the following:

  • Keep the Button API stable, it's a public component and there's no need for a breaking change here; map the disabled prop to aria-disabled without changing the public API
  • Update Button.stories.tsx to ensure a disabled story exists; in that story, add a play function that proves the button has the aria-disabled attribute and that clicking it does not trigger onClick
  • Add a story where the button is rendered with two sibling buttons, and add a play function showing that pressing Tab after focusing the previous button moves focus to the aria-disabled button (which is what we want to fix here)
  • Check if any existing test or story uses toBeDisabled for this button, and if so, switch to toHaveAttribute('aria-disabled', true)

PR template

Closes #

<!-- If your PR is related to an issue, provide the number(s) above; if it resolves multiple issues, be sure to break them up (e.g. "closes #1000, closes #1001"). -->

<!--

Thank you for contributing to Storybook! Please submit all PRs to the `next` branch unless they are specific to the current release. Storybook maintainers cherry-pick bug and documentation fixes into the `main` branch as part of the release process, so you shouldn't need to worry about this. For additional guidance: https://storybook.js.org/docs/contribute

-->

## What I did

<!-- Briefly describe what your PR does -->

## Checklist for Contributors

### Testing

<!-- Please check (put an "x" inside the "[ ]") the applicable items below to communicate how to test your changes -->

#### The changes in this PR are covered in the following automated tests:

- [ ] stories
- [ ] unit tests
- [ ] integration tests
- [ ] end-to-end tests

#### Manual testing

_This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!_

<!-- Please include the steps to test your changes here. For example:

1. Run a sandbox for template, e.g. `yarn task --task sandbox --start-from auto --template react-vite/default-ts`
2. Open Storybook in your browser
3. Access X story

-->

### Documentation

<!-- Please check (put an "x" inside the "[ ]") the applicable items below to indicate which documentation has been updated. -->

- [ ] Add or update documentation reflecting your changes
- [ ] If you are deprecating/removing a feature, make sure to update
      [MIGRATION.MD](https://github.com/storybookjs/storybook/blob/next/MIGRATION.md)

## Checklist for Maintainers

- [ ] When this PR is ready for testing, make sure to add `ci:normal`, `ci:merged` or `ci:daily` GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in `code/lib/cli-storybook/src/sandbox-templates.ts`
- [ ] Make sure this PR contains **one** of the labels below:
   <details>
     <summary>Available labels</summary>

  - `bug`: Internal changes that fixes incorrect behavior.
  - `maintenance`: User-facing maintenance tasks.
  - `dependencies`: Upgrading (sometimes downgrading) dependencies.
  - `build`: Internal-facing build tooling & test updates. Will not show up in release changelog.
  - `cleanup`: Minor cleanup style change. Will not show up in release changelog.
  - `documentation`: Documentation **only** changes. Will not show up in release changelog.
  - `feature request`: Introducing a new feature.
  - `BREAKING CHANGE`: Changes that break compatibility in some way with current major version.
  - `other`: Changes that don't fit in the above categories.

   </details>

### 🦋 Canary release

<!-- CANARY_RELEASE_SECTION -->

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the `@storybookjs/core` team here.

_core team members can create a canary release [here](https://github.com/storybookjs/storybook/actions/workflows/canary-release-pr.yml) or locally with `gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=<PR_NUMBER>`_

<!-- CANARY_RELEASE_SECTION -->

<!-- BENCHMARK_SECTION -->
<!-- BENCHMARK_SECTION -->

Thanks!

@WioletaKolodziej WioletaKolodziej force-pushed the fix/31678-aria-disabled-button branch from b2b6297 to 9b937f8 Compare September 3, 2025 13:27
@storybook-pr-benchmarking
Copy link

storybook-pr-benchmarking bot commented Sep 3, 2025

Package Benchmarks

Commit: 2824a75, ran on 10 September 2025 at 14:26:17 UTC

The following packages have significant changes to their size or dependencies:

storybook

Before After Difference
Dependency count 48 48 0
Self size 30.74 MB 30.62 MB 🎉 -121 KB 🎉
Dependency size 17.61 MB 17.61 MB 0 B
Bundle Size Analyzer Link Link

@storybook/cli

Before After Difference
Dependency count 204 219 🚨 +15 🚨
Self size 879 KB 879 KB 0 B
Dependency size 81.72 MB 81.82 MB 🚨 +98 KB 🚨
Bundle Size Analyzer Link Link

@storybook/codemod

Before After Difference
Dependency count 173 188 🚨 +15 🚨
Self size 35 KB 35 KB 🎉 -54 B 🎉
Dependency size 76.79 MB 76.89 MB 🚨 +98 KB 🚨
Bundle Size Analyzer Link Link

create-storybook

Before After Difference
Dependency count 49 49 0
Self size 1.52 MB 1.52 MB 🎉 -22 B 🎉
Dependency size 48.35 MB 48.23 MB 🎉 -121 KB 🎉
Bundle Size Analyzer node node

@WioletaKolodziej
Copy link
Author

WioletaKolodziej commented Sep 3, 2025

@Sidnioulz It’s important to note that even if our Button Interface handles disabled, this does not guarantee that the native HTML disabled attribute won’t end up in the DOM. Since disabled is a standard HTML attribute, React will always forward it – whether through ...props, styled-components, or simply if someone uses a plain button somewhere in the code. As a result, our Button.tsx is not resilient to such mistakes, because disabled can still leak directly into the DOM. Approach is to rely on aria-disabled, but instead of exposing disabled in the API, we should consider a custom prop name like isDisabled (or similar) that prevents the native attribute from being accidentally passed through anyway.

@Sidnioulz
Copy link
Member

@Sidnioulz It’s important to note that even if our Button Interface handles disabled, this does not guarantee that the native HTML disabled attribute won’t end up in the DOM. Since disabled is a standard HTML attribute, React will always forward it – whether through ...props, styled-components, or simply if someone uses a plain button somewhere in the code. As a result, our Button.tsx is not resilient to such mistakes, because disabled can still leak directly into the DOM. Approach is to rely on aria-disabled, but instead of exposing disabled in the API, we should consider a custom prop name like isDisabled (or similar) that prevents the native attribute from being accidentally passed through anyway.

That's a legitimate concern, but I don't share your analysis. The way I see it, you're arguing in favour of the disabled prop name.

Button with isDisabled prop receives a disabled attribute?
It'll be forwarded as part of {...props} if not actively identified and removed.

Button with disabled prop receives a disabled attribute?
It will be destructured as a prop and has no chance of leaking into the underlying HTML button element.

I'm not a fan of disabled as a name, and also prefer is* naming for stateful boolean props. But in that specific instance:

  • It's public API so we don't want to change it unless necessary
  • It actually helps us avoid the issue you described

@WioletaKolodziej WioletaKolodziej force-pushed the fix/31678-aria-disabled-button branch from 9b937f8 to 2824a75 Compare September 10, 2025 14:16
@WioletaKolodziej WioletaKolodziej deleted the fix/31678-aria-disabled-button branch September 10, 2025 14:17
@WioletaKolodziej WioletaKolodziej restored the fix/31678-aria-disabled-button branch September 10, 2025 14:24
@WioletaKolodziej WioletaKolodziej deleted the fix/31678-aria-disabled-button branch September 10, 2025 14:24
@WioletaKolodziej WioletaKolodziej restored the fix/31678-aria-disabled-button branch September 10, 2025 14:37
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing changes made in this pull request


const toolbar = page.getByTitle('Change the size of the preview');

await expect(toolbar).toBeDisabled();
await expect(toolbar).toHaveAttribute('aria-disabled');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider adding a more specific assertion to check that the attribute value is 'true' rather than just checking for its presence

Suggested change
await expect(toolbar).toHaveAttribute('aria-disabled');
await expect(toolbar).toHaveAttribute('aria-disabled', 'true');


await userEvent.selectOptions(screen.getByRole('combobox'), ['We use it at work']);
await expect(button).not.toBeDisabled();
await expect(button).not.toHaveAttribute('aria-disabled', true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The assertion not.toHaveAttribute('aria-disabled', true) may not work as expected. When aria-disabled is false, the attribute might be present with value 'false' rather than absent. Consider using toHaveAttribute('aria-disabled', 'false') or checking if the attribute is absent entirely.

Suggested change
await expect(button).not.toHaveAttribute('aria-disabled', true);
await expect(button).toHaveAttribute('aria-disabled', 'false');

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: Buttons should be aria-disabled instead of disabled
2 participants