Skip to content

fix(overlay): add ARIA attribute management to overlay-trigger for screen reader accessibility#6044

Open
Rajdeepc wants to merge 10 commits intomainfrom
rajdeepchandra/fix-overlay-trigger-aria-attributes
Open

fix(overlay): add ARIA attribute management to overlay-trigger for screen reader accessibility#6044
Rajdeepc wants to merge 10 commits intomainfrom
rajdeepchandra/fix-overlay-trigger-aria-attributes

Conversation

@Rajdeepc
Copy link
Contributor

@Rajdeepc Rajdeepc commented Feb 23, 2026

Description

The <overlay-trigger> component did not set any ARIA attributes on the trigger element for click/longpress overlays. This caused screen readers (e.g., NVDA on Chrome/Windows) to not announce the overlay relationship or read the overlay content when expanded, violating WCAG 1.3.2 (Meaningful Sequence, Level A).

Motivation and context

A screen reader user navigating the dialog behaviors documentation could not hear the overlay content after opening it via the trigger button. The root cause was that the trigger element lacked aria-expanded, aria-controls, and aria-haspopup attributes, so the screen reader had no programmatic way to discover or announce the controlled content.

This fix follows the same pattern used by other component libraries (Shoelace, Radix, Headless UI) where the component owns the ARIA contract for its trigger-content relationship.

This PR adds automatic ARIA attribute management to <overlay-trigger>:

  • aria-expanded is set on the trigger element, toggling between "false" (closed) and "true" (open)
  • aria-controls is set on the trigger, pointing to the overlay content element's id (auto-generated with sp-overlay-content- prefix if not provided)
  • aria-haspopup is set to "dialog" by default (consumer-set values are never overwritten)
  • ARIA attributes are managed only for click and longpress overlays; hover-only overlays (tooltips) are excluded since they use aria-describedby instead
  • All managed ARIA attributes are cleaned up when:
    • The overlay-trigger is disconnected from the DOM
    • The trigger element is swapped (ARIA removed from old trigger, applied to new)
    • Click/longpress content is removed

Related issue(s)

  • fixes [Issue Number]

Screenshots (if appropriate)


Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published.
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

ARIA attribute verification

  1. Navigate to a page with overlay-trigger containing click content
  2. Inspect the trigger element in DevTools:
    • Confirm aria-expanded="false" is set initially
    • Confirm aria-haspopup="dialog" is set
    • Confirm aria-controls points to the content element's id
  3. Click the trigger to open the overlay:
    • Confirm aria-expanded updates to "true"
  4. Close the overlay:
    • Confirm aria-expanded returns to "false"

Screen reader testing (NVDA + Chrome on Windows)

  1. Turn on NVDA and navigate to the overlay-trigger button using Tab
  2. Confirm NVDA announces the button with "collapsed" state
  3. Press Enter/Space to open the overlay
  4. Confirm NVDA announces "expanded" state
  5. Navigate within the overlay content using arrow keys
  6. Confirm the dialog heading and body text are announced
  7. Press Escape to close
  8. Confirm focus returns to the trigger and NVDA announces "collapsed"

Device review

  • Did it pass in Desktop?
  • Did it pass in (emulated) Mobile?
  • Did it pass in (emulated) iPad?

Accessibility testing checklist

Required: Complete each applicable item and document your testing steps (replace the placeholders with your component-specific instructions).

  • Keyboard (required — document steps below) — What to test for: Focus order is logical; Tab reaches the component and all interactive descendants; Enter/Space activate where appropriate; arrow keys work for tabs, menus, sliders, etc.; no focus traps; Escape dismisses when applicable; focus indicator is visible.

    1. Go here
    2. Do this action
    3. Expect this result
  • Screen reader (required — document steps below) — What to test for: Role and name are announced correctly; state changes (e.g. expanded, selected) are announced; labels and relationships are clear; no unnecessary or duplicate announcements.

    1. Go here
    2. Do this action
    3. Expect this result

…reen reader accessibility

The overlay-trigger component now automatically manages aria-expanded,
aria-controls, and aria-haspopup on the trigger element for click and
longpress interactions. This ensures screen readers can announce the
overlay relationship and state, fixing WCAG 1.3.2 (Meaningful Sequence)
compliance.

Also fixes the dialog README behaviors example to use valid overlay types
and proper ARIA attributes, and adds comprehensive accessibility
documentation to overlay-trigger covering ARIA attributes, focus
management, keyboard navigation, and screen reader considerations.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Rajdeepc Rajdeepc self-assigned this Feb 23, 2026
@changeset-bot
Copy link

changeset-bot bot commented Feb 23, 2026

🦋 Changeset detected

Latest commit: 4b9d2f1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 83 packages
Name Type
@spectrum-web-components/overlay Patch
@spectrum-web-components/dialog Patch
@spectrum-web-components/action-menu Patch
@spectrum-web-components/combobox Patch
@spectrum-web-components/contextual-help Patch
@spectrum-web-components/menu Patch
@spectrum-web-components/picker Patch
@spectrum-web-components/popover Patch
@spectrum-web-components/tooltip Patch
@spectrum-web-components/story-decorator Patch
@spectrum-web-components/bundle Patch
@spectrum-web-components/truncated Patch
@spectrum-web-components/breadcrumbs Patch
@spectrum-web-components/custom-vars-viewer Patch
@spectrum-web-components/action-bar Patch
@spectrum-web-components/card Patch
@spectrum-web-components/coachmark Patch
documentation Patch
@spectrum-web-components/vrt-compare Patch
@spectrum-web-components/accordion Patch
@spectrum-web-components/action-button Patch
@spectrum-web-components/action-group Patch
@spectrum-web-components/alert-banner Patch
@spectrum-web-components/alert-dialog Patch
@spectrum-web-components/asset Patch
@spectrum-web-components/avatar Patch
@spectrum-web-components/badge Patch
@spectrum-web-components/button-group Patch
@spectrum-web-components/button Patch
@spectrum-web-components/checkbox Patch
@spectrum-web-components/clear-button Patch
@spectrum-web-components/close-button Patch
@spectrum-web-components/color-area Patch
@spectrum-web-components/color-field Patch
@spectrum-web-components/color-handle Patch
@spectrum-web-components/color-loupe Patch
@spectrum-web-components/color-slider Patch
@spectrum-web-components/color-wheel Patch
@spectrum-web-components/divider Patch
@spectrum-web-components/dropzone Patch
@spectrum-web-components/field-group Patch
@spectrum-web-components/field-label Patch
@spectrum-web-components/help-text Patch
@spectrum-web-components/icon Patch
@spectrum-web-components/icons-ui Patch
@spectrum-web-components/icons-workflow Patch
@spectrum-web-components/icons Patch
@spectrum-web-components/iconset Patch
@spectrum-web-components/illustrated-message Patch
@spectrum-web-components/infield-button Patch
@spectrum-web-components/link Patch
@spectrum-web-components/meter Patch
@spectrum-web-components/modal Patch
@spectrum-web-components/number-field Patch
@spectrum-web-components/picker-button Patch
@spectrum-web-components/progress-bar Patch
@spectrum-web-components/progress-circle Patch
@spectrum-web-components/radio Patch
@spectrum-web-components/search Patch
@spectrum-web-components/sidenav Patch
@spectrum-web-components/slider Patch
@spectrum-web-components/split-view Patch
@spectrum-web-components/status-light Patch
@spectrum-web-components/swatch Patch
@spectrum-web-components/switch Patch
@spectrum-web-components/table Patch
@spectrum-web-components/tabs Patch
@spectrum-web-components/tags Patch
@spectrum-web-components/textfield Patch
@spectrum-web-components/thumbnail Patch
@spectrum-web-components/toast Patch
@spectrum-web-components/top-nav Patch
@spectrum-web-components/tray Patch
@spectrum-web-components/underlay Patch
@spectrum-web-components/base Patch
@spectrum-web-components/grid Patch
@spectrum-web-components/opacity-checkerboard Patch
@spectrum-web-components/reactive-controllers Patch
@spectrum-web-components/shared Patch
@spectrum-web-components/styles Patch
@spectrum-web-components/theme Patch
@spectrum-web-components/eslint-plugin Patch
@spectrum-web-components/stylelint-header-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Rajdeepc Rajdeepc added the Status:WIP PR is a work in progress or draft label Feb 23, 2026
@Rajdeepc Rajdeepc changed the title fix(overlay): add ARIA attribute management to overlay-trigger for sc… fix(overlay): add ARIA attribute management to overlay-trigger for screen reader accessibility Feb 23, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 23, 2026

📚 Branch Preview Links

🔍 First Generation Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6044

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

…ment

- Add disconnectedCallback to clean up ARIA attributes from trigger
  element when overlay-trigger is removed from the DOM
- Track previous trigger element and clean up stale ARIA attributes
  when the trigger slot content changes
- Clean up aria-controls when content is removed from click/longpress
  slots (prevents stale ID references)
- Use WeakSet to track component-managed aria-haspopup values so
  consumer overrides are respected while still allowing type changes
  to update the value
- Default aria-haspopup to "dialog" for all overlay types (previously
  "true" for non-modal, which maps to "menu" per ARIA spec)
- Consolidate manageAriaOnTrigger calls to updated() lifecycle only,
  removing redundant calls from slot change and beforetoggle handlers
- Remove misleading static-attribute sp-overlay example from dialog
  README (aria-expanded would never toggle); use overlay-trigger
  pattern exclusively since it handles ARIA automatically
- Add comprehensive test suite covering: aria-expanded toggling,
  aria-haspopup defaults and consumer overrides, aria-controls with
  auto-generated and pre-existing IDs, hover-only exclusion, cleanup
  on content removal, disconnect, and trigger swap

Co-authored-by: Cursor <cursoragent@cursor.com>
@Rajdeepc
Copy link
Contributor Author

Design decisions for team review

This PR adds automatic ARIA attribute management to <overlay-trigger> for accessibility. Before merging, I'd like the team's input on a few design decisions:

1. Automatic ARIA management vs. opt-in

The component now automatically sets aria-expanded, aria-controls, and aria-haspopup on the trigger element for click/longpress overlays. This follows the pattern used by most component libraries (e.g., Shoelace, Radix, Headless UI) where the component owns the ARIA contract.

Question: Are we comfortable with OverlayTrigger taking ownership of these attributes, or should this be opt-in?

2. aria-haspopup default value

The implementation defaults aria-haspopup to "dialog" for modal/page overlay types, and "true" for auto/manual types. Per the ARIA spec, "true" maps to "menu", which may not always be accurate.

Question: Is "dialog" the right default for modal overlays? Should the value be configurable via an attribute, or is the current type-based mapping sufficient?

3. Consumer override behavior

A WeakSet tracks which elements have had aria-haspopup set by the component. If a consumer has already set aria-haspopup before the component manages it, the consumer's value is preserved. However, once the component sets it, subsequent changes by the component will update it.

Question: Is this override-respecting approach correct, or should consumers always be able to override after initial render too?

4. Cleanup on disconnect and trigger swap

The PR adds cleanup in disconnectedCallback (removes all managed ARIA attributes) and handles trigger element swaps (removes ARIA from old trigger, applies to new one). This adds some complexity but prevents stale attributes.

Question: Any concerns about the cleanup scope or approach?


These are all non-breaking, additive changes following standard WAI-ARIA patterns. No RFC is needed per our contribution guidelines, but I wanted to get alignment on these decisions before requesting formal review. Feedback welcome async here or in Slack.

@Rajdeepc Rajdeepc added a11y Issues or PRs related to accessibility Status:Ready for review PR ready for review or re-review. and removed Status:WIP PR is a work in progress or draft labels Feb 24, 2026
@Rajdeepc Rajdeepc marked this pull request as ready for review February 24, 2026 07:13
@Rajdeepc Rajdeepc requested a review from a team as a code owner February 24, 2026 07:13
@Rajdeepc Rajdeepc removed the Status:Ready for review PR ready for review or re-review. label Feb 24, 2026
Comment on lines 189 to 196
private removeAriaFromTrigger(element: HTMLElement): void {
element.removeAttribute('aria-expanded');
element.removeAttribute('aria-controls');
if (this.ariaHaspopupManagedElements.has(element)) {
element.removeAttribute('aria-haspopup');
this.ariaHaspopupManagedElements.delete(element);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

removeAriaFromTrigger() always removes aria-expanded and aria-controls, and manageAriaOnTrigger() calls it whenever there is no click/longpress content. That means hover-only usage can strip consumer-authored ARIA state even though the docs say ARIA management is for click/longpress interactions.

this.ariaHaspopupManagedElements.add(triggerElement);
}

const content = this.clickContent[0] || this.longpressContent[0];
Copy link
Contributor

Choose a reason for hiding this comment

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

when both click + longpress exist, aria-controls should switch to longpress content id when open='longpress' and back for click.

Suggested change
const content = this.clickContent[0] || this.longpressContent[0];
const content =
this.open === 'longpress'
? this.longpressContent[0]
: this.open === 'click'
? this.clickContent[0]
: this.clickContent[0] || this.longpressContent[0];

Comment on lines +190 to +191
element.removeAttribute('aria-expanded');
element.removeAttribute('aria-controls');
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are we not doing like this so that hover-only does not remove consumer-provided aria-expanded/aria-controls

Suggested change
element.removeAttribute('aria-expanded');
element.removeAttribute('aria-controls');
if (this.ariaExpandedManagedElements.has(element)) {
element.removeAttribute('aria-expanded');
this.ariaExpandedManagedElements.delete(element);
}
if (this.ariaControlsManagedElements.has(element)) {
element.removeAttribute('aria-controls');
this.ariaControlsManagedElements.delete(element);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. I would rather do an early return here if ariaManageElements has no element

if (!this.ariaManagedElements.has(element)) {
      return;
    }

Rajdeep Chandra and others added 2 commits February 24, 2026 15:40
- Add curly braces to early return (eslint curly rule)
- Remove unused waitUntil import from test file
- Replace per-attribute WeakSet with single ariaManagedElements set so
  removeAriaFromTrigger is a no-op for elements the component never
  managed (prevents hover-only triggers from stripping consumer-authored
  aria-expanded/aria-controls)
- Switch aria-controls to point at the active content element when both
  click and longpress content exist, based on the current open state
- Add tests for hover-only consumer ARIA preservation and click/longpress
  aria-controls switching

Co-authored-by: Cursor <cursoragent@cursor.com>
@nikkimk
Copy link
Contributor

nikkimk commented Feb 24, 2026

The implementation defaults aria-haspopup to "dialog" for modal/page overlay types, and "true" for auto/manual types. Per the ARIA spec, "true" maps to "menu", which may not always be accurate.

Question: Is "dialog" the right default for modal overlays? Should the value be configurable via an attribute, or is the current type-based mapping sufficient?

What about listbox, [tree],(https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/tree_role), and [grid]?

Pickers and comboboxes should be listbox.

And what about tooltip which is not interactive and should not have aria-haspopup?

Copy link
Contributor

@nikkimk nikkimk left a comment

Choose a reason for hiding this comment

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

While I like the idea of setting aria-controls and aria-expanded via the component, we have overloaded overlay to so many use cases that I don't think it's a good idea to set a default aria-haspopup.

It would be better to have consumers be intentional about this attribute. We should make sure our documentation has examples of each of the possible values, and that we explicitly mention this as a requirement in the accessibility section of the docs.

Comment on lines 225 to 229
if (
this.ariaManagedElements.has(triggerElement) ||
!triggerElement.hasAttribute('aria-haspopup')
) {
triggerElement.setAttribute('aria-haspopup', 'dialog');
Copy link
Contributor

Choose a reason for hiding this comment

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

See my comment here: #6044 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed -- aria-haspopup now auto-resolves from the content element's role instead of defaulting to "dialog". See my reply to your issue-level comment for the full breakdown. Commit: 322006b.

Rajdeep Chandra and others added 3 commits February 25, 2026 17:26
…ulting to dialog

Address nikkimk's review: aria-haspopup should reflect the actual
content type, not always default to "dialog". Pickers/comboboxes need
"listbox", action-menus need "menu", etc.

- Add resolveHaspopupValue() that inspects the content element's role
  attribute (and falls back to first child with [role]) to determine
  the correct aria-haspopup value (menu, listbox, tree, grid, dialog)
- Default remains "dialog" when no recognized role is found
- Consumer overrides are still respected (unchanged behavior)
- Tooltips (hover-only) remain unaffected (unchanged behavior)
- Add 5 tests: role="dialog", role="menu", role="listbox", nested role
  detection through wrapper, and fallback to "dialog" for unknown roles
- Update overlay-trigger.md docs to describe role-based detection

Made-with: Cursor
@Rajdeepc
Copy link
Contributor Author

@nikkimk Great feedback -- both points addressed in 322006b:

1. listbox, tree, grid, and menu support

aria-haspopup is no longer hardcoded to "dialog". It now auto-resolves from the content element's role using a new resolveHaspopupValue() method:

  • Checks the content element's role attribute first (e.g., <div slot="click-content" role="menu">)
  • Falls back to the first descendant with a [role] attribute (covers the common pattern of <sp-popover> wrapping an <sp-dialog> or <sp-menu>)
  • Recognized values: menu, listbox, tree, grid, dialog
  • Defaults to "dialog" only when no recognized role is found

So a picker/combobox using overlay-trigger with a role="listbox" content element will correctly get aria-haspopup="listbox", and an action-menu will get aria-haspopup="menu".

Consumer overrides are still respected -- if someone sets aria-haspopup directly on the trigger element before the component manages it, the component won't overwrite it.

2. Tooltips

Tooltips were already handled correctly -- the ARIA management only runs for click and longpress content. Hover-only overlays (tooltips) never get aria-expanded, aria-controls, or aria-haspopup set on the trigger. Added an explicit test to confirm this behavior is preserved.

5 new tests were added covering: role="dialog", role="menu", role="listbox", nested role detection through a wrapper element, and fallback to "dialog" for unrecognized roles. Docs updated as well.

const changed = oneEvent(el, 'change');
menuItem.click();
await closed;
await changed;
Copy link
Contributor

Choose a reason for hiding this comment

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

good stuff!

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

Labels

a11y Issues or PRs related to accessibility

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants