Skip to content
Open
13 changes: 13 additions & 0 deletions .changeset/overlay-trigger-aria-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@spectrum-web-components/overlay': patch
'@spectrum-web-components/dialog': patch
---

**Fixed**: Added automatic ARIA attribute management to `<overlay-trigger>` for screen reader accessibility (WCAG 1.3.2 Meaningful Sequence):

- `aria-expanded` is now automatically set on the trigger element, reflecting the overlay's open/closed state
- `aria-controls` is set on the trigger element, pointing to the overlay content's `id` (generated if not provided)
- `aria-haspopup` is set to `"dialog"` by default (respects consumer overrides; component tracks its own values to allow type changes)
- ARIA attributes are cleaned up from the trigger element when the overlay-trigger is disconnected, the trigger element changes, or content is removed
- Updated dialog README behaviors example to use `<overlay-trigger>` for automatic ARIA management
- Added comprehensive accessibility documentation to overlay-trigger covering ARIA attributes, focus management, keyboard navigation, and screen reader considerations
21 changes: 6 additions & 15 deletions 1st-gen/packages/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,23 +166,14 @@ Use the dialog with an overlay to create a dialog that appears over the current
2. Focus management when the dialog opens
3. Event handling for closing the dialog

The `<overlay-trigger>` element automatically manages `aria-expanded`, `aria-haspopup`, and `aria-controls` on the trigger element. When using `<sp-overlay>` directly, you must manage these attributes yourself via JavaScript (see the [overlay accessibility documentation](../overlay/#accessibility) for details).

```html
<sp-button id="trigger">Overlay Trigger</sp-button>
<sp-overlay trigger="trigger@click" placement="bottom">
<sp-popover>
<sp-dialog>
<h2 slot="heading">Overlay 1</h2>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Auctor augue mauris
augue neque gravida. Libero volutpat sed ornare arcu.
</sp-dialog>
</sp-popover>
</sp-overlay>
<overlay-trigger placement="top" type="replace">
<sp-button slot="trigger">Overlay Trigger 2</sp-button>
<sp-popover slot="click-content" open>
<overlay-trigger placement="top" type="auto" triggered-by="click">
<sp-button slot="trigger">Overlay Trigger</sp-button>
<sp-popover slot="click-content">
<sp-dialog size="s">
<h2 slot="heading">Overlay 2</h2>
<h2 slot="heading">Overlay content</h2>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Auctor augue mauris
augue neque gravida. Libero volutpat sed ornare arcu.
Expand Down
90 changes: 90 additions & 0 deletions 1st-gen/packages/overlay/overlay-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,93 @@ When not specified, the component will automatically detect which content types
### Accessibility

When using an `<overlay-trigger>` element, it is important to be sure the that content you project into `slot="trigger"` is "interactive". This means that an element within that branch of DOM will be able to receive focus, and said element will appropriately convert keyboard interactions to `click` events, similar to what you'd find with `<a href="#">Anchors</a>`, `<button>Buttons</button>`, etc. You can find further reading on the subject of accessible keyboard interactions at [https://www.w3.org/WAI/WCAG21/Understanding/keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard).

#### ARIA attributes

The `<overlay-trigger>` element automatically manages several ARIA attributes on the trigger element to ensure screen readers can announce the overlay relationship and state:

- **`aria-expanded`**: Set to `"true"` when the overlay is open and `"false"` when closed. This tells assistive technologies whether the controlled content is currently visible.
- **`aria-controls`**: Points to the `id` of the overlay content element, establishing the relationship between the trigger and the content it controls.
- **`aria-haspopup`**: Automatically resolved from the content element's role. If the content (or its first child with a `role` attribute) has a recognized popup role (`menu`, `listbox`, `tree`, `grid`, or `dialog`), that value is used. Otherwise, defaults to `"dialog"`. You can override this by setting `aria-haspopup` directly on the trigger element before the overlay-trigger initializes (e.g., `aria-haspopup="listbox"` for picker overlays). Once set by the consumer, the component will not overwrite it.

These attributes are managed automatically for click and longpress interactions. Hover interactions (tooltips) are excluded since they use a different accessibility pattern (`aria-describedby`).

When the trigger element changes or the overlay-trigger is removed from the DOM, all managed ARIA attributes are cleaned up from the previous trigger element.

```html
<overlay-trigger type="modal" triggered-by="click">
<sp-button slot="trigger">Open dialog</sp-button>
<sp-dialog-wrapper
slot="click-content"
headline="Confirmation"
dismissable
underlay
>
<p>Are you sure you want to proceed?</p>
</sp-dialog-wrapper>
</overlay-trigger>
```

In this example, the `<overlay-trigger>` will automatically set `aria-expanded="false"`, `aria-haspopup="dialog"`, and `aria-controls` on the `<sp-button>` trigger element. When the overlay opens, `aria-expanded` updates to `"true"`. If the content element does not have an `id`, one is auto-generated.

When using `<sp-overlay>` directly (without `<overlay-trigger>`), you must manage ARIA attributes yourself via JavaScript, including toggling `aria-expanded` when the overlay opens and closes. See the [overlay accessibility documentation](../overlay/#accessibility) for guidance and examples.

#### Focus management

The overlay system manages focus to ensure keyboard users can interact with overlay content:

1. **On open**: Focus is transferred to the first focusable element within the overlay content. For `modal` and `page` types, focus is trapped within the overlay so that tabbing cycles through the overlay's interactive elements.
2. **On close**: Focus is returned to the trigger element that opened the overlay.

The `receives-focus` attribute controls this behavior:

- `auto` (default): Focus moves to the first focusable element in the overlay
- `true`: Forces focus to move to the overlay content
- `false`: Prevents focus from moving to the overlay (use with caution, as this may create accessibility issues)

For dialogs, always use `type="modal"` or `type="page"` to ensure proper focus trapping:

```html
<overlay-trigger type="modal" triggered-by="click">
<sp-button slot="trigger">Open modal dialog</sp-button>
<sp-dialog-wrapper
slot="click-content"
headline="Settings"
dismissable
underlay
>
<sp-field-label for="email-setting">Email notifications</sp-field-label>
<sp-switch id="email-setting">Enable notifications</sp-switch>
</sp-dialog-wrapper>
</overlay-trigger>
```

#### Keyboard navigation

<sp-table>
<sp-table-head>
<sp-table-head-cell>Key</sp-table-head-cell>
<sp-table-head-cell>Action</sp-table-head-cell>
</sp-table-head>
<sp-table-body>
<sp-table-row>
<sp-table-cell><kbd>Enter</kbd> / <kbd>Space</kbd></sp-table-cell>
<sp-table-cell>Activates the trigger element to open the overlay</sp-table-cell>
</sp-table-row>
<sp-table-row>
<sp-table-cell><kbd>Escape</kbd></sp-table-cell>
<sp-table-cell>Closes the topmost overlay and returns focus to its trigger</sp-table-cell>
</sp-table-row>
<sp-table-row>
<sp-table-cell><kbd>Tab</kbd> / <kbd>Shift+Tab</kbd></sp-table-cell>
<sp-table-cell>Navigates between focusable elements; trapped within modal/page overlays</sp-table-cell>
</sp-table-row>
</sp-table-body>
</sp-table>

#### Screen reader considerations

- Ensure overlay content uses proper heading structure (`<h2 slot="heading">`)
- For dialogs, the `<sp-dialog>` element automatically sets `role="dialog"` and manages `aria-labelledby` via the heading slot
- Provide descriptive labels on trigger elements that indicate what action will occur (e.g., "Open settings" rather than just "Open")
- Use meaningful text for buttons within dialogs (e.g., "Save changes" rather than just "OK")
114 changes: 114 additions & 0 deletions 1st-gen/packages/overlay/src/OverlayTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
query,
state,
} from '@spectrum-web-components/base/src/decorators.js';
import { randomID } from '@spectrum-web-components/shared/src/random-id.js';

/* eslint-disable import/no-extraneous-dependencies */
import '@spectrum-web-components/overlay/sp-overlay.js';
Expand Down Expand Up @@ -133,6 +134,14 @@ export class OverlayTrigger extends SpectrumElement {
@query('#hover-overlay', true)
hoverOverlayElement!: Overlay;

/**
* Tracks elements where this component has taken ownership
* of ARIA attributes, so consumer-set values are never removed.
*/
private ariaManagedElements = new WeakSet<HTMLElement>();

private previousTriggerElement?: HTMLElement;

private getAssignedElementsFromSlot(slot: HTMLSlotElement): HTMLElement[] {
return slot.assignedElements({ flatten: true }) as HTMLElement[];
}
Expand Down Expand Up @@ -177,6 +186,101 @@ export class OverlayTrigger extends SpectrumElement {
}
}

private static readonly VALID_HASPOPUP_ROLES = new Set([
'menu',
'listbox',
'tree',
'grid',
'dialog',
]);

private resolveHaspopupValue(): string {
const content = this.clickContent[0] || this.longpressContent[0];
if (!content) {
return 'dialog';
}
const role = content.getAttribute('role');
if (role && OverlayTrigger.VALID_HASPOPUP_ROLES.has(role)) {
return role;
}
const firstChild = content.querySelector('[role]') as HTMLElement | null;
if (
firstChild &&
OverlayTrigger.VALID_HASPOPUP_ROLES.has(firstChild.getAttribute('role')!)
) {
return firstChild.getAttribute('role')!;
}
return 'dialog';
}

private removeAriaFromTrigger(element: HTMLElement): void {
if (!this.ariaManagedElements.has(element)) {
return;
}
element.removeAttribute('aria-expanded');
element.removeAttribute('aria-controls');
Comment on lines +220 to +221
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;
    }

element.removeAttribute('aria-haspopup');
this.ariaManagedElements.delete(element);
}
Comment on lines 216 to 224
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.


private manageAriaOnTrigger(): void {
const triggerElement = this.targetContent[0];

if (
this.previousTriggerElement &&
this.previousTriggerElement !== triggerElement
) {
this.removeAriaFromTrigger(this.previousTriggerElement);
}
this.previousTriggerElement = triggerElement;

if (!triggerElement) {
return;
}

const hasClickContent = this.clickContent.length > 0;
const hasLongpressContent = this.longpressContent.length > 0;

if (!hasClickContent && !hasLongpressContent) {
this.removeAriaFromTrigger(triggerElement);
return;
}

const isExpanded = this.open === 'click' || this.open === 'longpress';
triggerElement.setAttribute('aria-expanded', String(isExpanded));

if (
this.ariaManagedElements.has(triggerElement) ||
!triggerElement.hasAttribute('aria-haspopup')
) {
triggerElement.setAttribute('aria-haspopup', this.resolveHaspopupValue());
}
this.ariaManagedElements.add(triggerElement);

const content =
this.open === 'longpress'
? this.longpressContent[0]
: this.open === 'click'
? this.clickContent[0]
: this.clickContent[0] || this.longpressContent[0];
if (content) {
if (!content.id) {
content.id = `sp-overlay-content-${randomID()}`;
}
triggerElement.setAttribute('aria-controls', content.id);
} else {
triggerElement.removeAttribute('aria-controls');
}
}

override disconnectedCallback(): void {
if (this.previousTriggerElement) {
this.removeAriaFromTrigger(this.previousTriggerElement);
this.previousTriggerElement = undefined;
}
super.disconnectedCallback();
}

protected override update(changes: PropertyValues): void {
if (changes.has('clickContent')) {
this.clickPlacement =
Expand Down Expand Up @@ -339,6 +443,16 @@ export class OverlayTrigger extends SpectrumElement {
this.open = undefined;
return;
}

if (
changedProperties.has('open') ||
changedProperties.has('type') ||
changedProperties.has('targetContent') ||
changedProperties.has('clickContent') ||
changedProperties.has('longpressContent')
) {
this.manageAriaOnTrigger();
}
}

protected override async getUpdateComplete(): Promise<boolean> {
Expand Down
Loading
Loading