Skip to content

feat(a11y): add disabled label for a11y on buttons [AC-4127]#1341

Open
Stefan3002 wants to merge 1 commit intocanonical:mainfrom
Stefan3002:a11y-label-on-disabled-buttons
Open

feat(a11y): add disabled label for a11y on buttons [AC-4127]#1341
Stefan3002 wants to merge 1 commit intocanonical:mainfrom
Stefan3002:a11y-label-on-disabled-buttons

Conversation

@Stefan3002
Copy link
Copy Markdown
Contributor

@Stefan3002 Stefan3002 commented Apr 3, 2026

Done

  • Added a label visible only to assistive technology when a button is disabled to explain why it is disabled.

QA

Pinging @canonical/react-library-maintainers for a review.

Storybook

To see rendered examples of all react-components, run:

yarn start

QA in your project

from react-components run:

yarn build
npm pack

Install the resulting tarball in your project with:

yarn add <path-to-tarball>

QA steps

  • Use a ConfirmationButton
  • Make it disabled and pass the disabledReasonLabel prop
  • The button should be linked to the label via aria-describedby and the label should not be visible on the screen.

Percy steps

  • List any expected visual change in Percy, or write something like "No visual changes expected" if none is expected.

Fixes

Fixes: #AC-4127

@webteam-app
Copy link
Copy Markdown

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds support for providing an assistive-technology-only “disabled reason” description for ActionButton when it is disabled, so screen readers can announce why an action is unavailable.

Changes:

  • Introduces a new disabledReasonLabel prop on ActionButton.
  • Links the button to an off-screen description via aria-describedby using a generated id (useId).
  • Conditionally renders an off-screen element containing the disabled reason text.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +192 to +196
aria-describedby={
disabled && disabledReasonLabel
? `disabled-reason-label-${buttonId}`
: undefined
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The new aria-describedby value will be overridden if callers pass their own aria-describedby via buttonProps (because {...buttonProps} comes last). Consider merging any existing buttonProps["aria-describedby"] with the disabled-reason id so both descriptions are preserved when disabledReasonLabel is provided.

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +196
aria-describedby={
disabled && disabledReasonLabel
? `disabled-reason-label-${buttonId}`
: undefined
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

New disabledReasonLabel behavior isn’t covered by tests. Add coverage to verify (1) the off-screen description is rendered only when disabled + disabledReasonLabel are provided and (2) the button gets an aria-describedby pointing at that element.

Copilot uses AI. Check for mistakes.
@Stefan3002
Copy link
Copy Markdown
Contributor Author

Tests will be added after we agree this approach is viable and allright.

@Stefan3002 Stefan3002 force-pushed the a11y-label-on-disabled-buttons branch from e048970 to 71de6af Compare April 3, 2026 11:01
@jmuzina jmuzina self-requested a review April 3, 2026 12:04
Copy link
Copy Markdown
Contributor

@edlerd edlerd left a comment

Choose a reason for hiding this comment

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

We tend to not use the aria-describedby and a hidden div but a bit more simple: Add a title to the button with the reason. That makes the reason also discoverable to anyone on mouse over.

If you also use this approach, we probably don't need the changes proposed here. Wdyt @Stefan3002 ?

@jmuzina
Copy link
Copy Markdown
Member

jmuzina commented Apr 3, 2026

We tend to not use the aria-describedby and a hidden div but a bit more simple: Add a title to the button with the reason. That makes the reason also discoverable to anyone on mouse over.

+1, I think it'll be better to use a title here so the reason is perceptible to everyone regardless of whether they use AT.

@Stefan3002
Copy link
Copy Markdown
Contributor Author

@jmuzina and @edlerd thanks for taking the time to review this! From my research, I can see it is not enough to use title. It seems to be ignored in some cases by AT.

Refs:

  • "I noticed is that the title attribute isn’t read aloud, AT ALL." here
  • "Use of the title attribute is highly problematic for: [...] People navigating with assistive technology such as screen readers or magnifiers" - MDN here
  • "Relying on the title attribute is currently discouraged as many user agents do not expose the attribute in an accessible manner as required by this specification" HTML spec here

So, I think that if, "We tend to not use the aria-describedby and a hidden div", we should. At elast for this use case to begin with.

What are your thoughts on this @edlerd and @jmuzina ? 🤗

Thanks again!

@jmuzina
Copy link
Copy Markdown
Member

jmuzina commented Apr 6, 2026

I've referred this a11y best practices question to @paul-geoghegan for his thoughts!

I would suggest to avoid using a Tooltip as it would complicate usage if the consumer already uses a tooltip on this button, and would introduce a dependency from Button to Tooltip. I would lean towards using an aria-describedby and a hidden div then - the question then becomes how do we give this same information to users not using AT - maybe that's not something the component needs to solve at all, and we can expect the caller to create a visible label with the same or similar content as disabledReasonLabel.

@paul-geoghegan
Copy link
Copy Markdown

This is a great discussion! So yes using title can be problematic but mainly because it's an optional screen reader feature so not everyone will have it on. You should never have screen reader only information though. If you need to convay why something is disabled then you should be doing it for everyone which means you could just set that element as the describing element of the button. I have never seen this done anywhere else for that exact same reason. It just feels like we would be introducing a new prop that is pretty much doing what aria-describedby already does.
If I'm wrong please correct me as I'm open to missing something here.

@Stefan3002
Copy link
Copy Markdown
Contributor Author

@paul-geoghegan Hey there! I think we should only consider it for a11y reasons as a tooltip will clutter the UI and it might be unpredictable when the message is too long. What do you think about displaying it in a visually hidden way and linking it with describedby? CC: @jmuzina

@paul-geoghegan
Copy link
Copy Markdown

@Stefan3002 I just don't understand why we would want to tell assistive technology users something and not sighted users. If it was the other way around I would be saying we should present the info to the screen reader users so I think if it's important enough to be convayed to screen reader users then it's important enough to be convayed to everyone.
I also don't understand the point of introducing a non-standard prop here when you can easily do this yourself if you have a unique situation that requires it instead of updating a whole UI framework.

@jmuzina
Copy link
Copy Markdown
Member

jmuzina commented Apr 8, 2026

Thanks for your insights @paul-geoghegan !

After thinking on this some more - Guaranteeing the disabled label is created may not be something we can solve in the core component implementation label, but rather something we expect from our consumers with documentation on best practice on how to use the native HTML props they already have.

Could we remove disabledReasonLabel as a prop entirely, pass aria-describedby from props onto the component as we already do, then expect the consumer themselves to construct the disabled reason label outside of Button (and pass its ID to the Button component via aria-describedby)?

Example:

// Inside ActionButton.tsx
<button
  {...buttonProps}
  aria-describedby={buttonProps['aria-describedby']}
  aria-disabled={isDisabled || undefined}
>
  {children}
</button>
// inside a consumer of ActionButton

<ActionButton
  disabled={formDisabled}
  aria-describedby={formDisabled ? disabledLabelId : undefined}
  onClick={() => { /* Save logic */ }}
>
  Save Changes
</ActionButton>

{formDisabled && (
  <p 
    id={disabledLabelId} 
    role="status"
  >
    Some generic text that describes why the button is disabled....
  </p>
)}

This way, the label is presented the same to AT and visually, we don't need to introduce a nonstandard prop, and we keep the flexibility of allowing the consumer more control how the label is styled, what other aria attributes it gets, etc.

I think it'll be more approachable anyway this way because it's closer to the HTML-native way to do this, what do we think?

@paul-geoghegan
Copy link
Copy Markdown

paul-geoghegan commented Apr 8, 2026

@jmuzina sorry, I re-read it and they are not tied together. I personally think this is probably the best option. That way if it's needed then it can easily be implemented by the end user.

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.

6 participants