diff --git a/.changeset/overlay-trigger-aria-attributes.md b/.changeset/overlay-trigger-aria-attributes.md new file mode 100644 index 0000000000..0ca1df6d5e --- /dev/null +++ b/.changeset/overlay-trigger-aria-attributes.md @@ -0,0 +1,13 @@ +--- +'@spectrum-web-components/overlay': patch +'@spectrum-web-components/dialog': patch +--- + +**Fixed**: Added automatic ARIA attribute management to `` 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 `` for automatic ARIA management +- Added comprehensive accessibility documentation to overlay-trigger covering ARIA attributes, focus management, keyboard navigation, and screen reader considerations diff --git a/1st-gen/packages/dialog/README.md b/1st-gen/packages/dialog/README.md index 7210432431..fbaf7a5047 100644 --- a/1st-gen/packages/dialog/README.md +++ b/1st-gen/packages/dialog/README.md @@ -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 `` element automatically manages `aria-expanded`, `aria-haspopup`, and `aria-controls` on the trigger element. When using `` directly, you must manage these attributes yourself via JavaScript (see the [overlay accessibility documentation](../overlay/#accessibility) for details). + ```html -Overlay Trigger - - - -

Overlay 1

- 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. -
-
-
- - Overlay Trigger 2 - + + Overlay Trigger + -

Overlay 2

+

Overlay content

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. diff --git a/1st-gen/packages/overlay/overlay-trigger.md b/1st-gen/packages/overlay/overlay-trigger.md index 575bbad42b..7a7f5224d1 100644 --- a/1st-gen/packages/overlay/overlay-trigger.md +++ b/1st-gen/packages/overlay/overlay-trigger.md @@ -244,3 +244,93 @@ When not specified, the component will automatically detect which content types ### Accessibility When using an `` 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 `Anchors`, ``, 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 `` 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 + + Open dialog + +

Are you sure you want to proceed?

+
+
+``` + +In this example, the `` will automatically set `aria-expanded="false"`, `aria-haspopup="dialog"`, and `aria-controls` on the `` 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 `` directly (without ``), 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 + + Open modal dialog + + Email notifications + Enable notifications + + +``` + +#### Keyboard navigation + + + + Key + Action + + + + Enter / Space + Activates the trigger element to open the overlay + + + Escape + Closes the topmost overlay and returns focus to its trigger + + + Tab / Shift+Tab + Navigates between focusable elements; trapped within modal/page overlays + + + + +#### Screen reader considerations + +- Ensure overlay content uses proper heading structure (`

`) +- For dialogs, the `` 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") diff --git a/1st-gen/packages/overlay/src/OverlayTrigger.ts b/1st-gen/packages/overlay/src/OverlayTrigger.ts index ccbc210d6a..032412440f 100644 --- a/1st-gen/packages/overlay/src/OverlayTrigger.ts +++ b/1st-gen/packages/overlay/src/OverlayTrigger.ts @@ -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'; @@ -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(); + + private previousTriggerElement?: HTMLElement; + private getAssignedElementsFromSlot(slot: HTMLSlotElement): HTMLElement[] { return slot.assignedElements({ flatten: true }) as HTMLElement[]; } @@ -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'); + element.removeAttribute('aria-haspopup'); + this.ariaManagedElements.delete(element); + } + + 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 = @@ -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 { diff --git a/1st-gen/packages/overlay/test/overlay-trigger-a11y.test.ts b/1st-gen/packages/overlay/test/overlay-trigger-a11y.test.ts new file mode 100644 index 0000000000..81038da0e9 --- /dev/null +++ b/1st-gen/packages/overlay/test/overlay-trigger-a11y.test.ts @@ -0,0 +1,411 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { + elementUpdated, + expect, + html, + nextFrame, + oneEvent, +} from '@open-wc/testing'; + +import { OverlayTrigger } from '@spectrum-web-components/overlay'; + +import '@spectrum-web-components/button/sp-button.js'; +import '@spectrum-web-components/popover/sp-popover.js'; +import '@spectrum-web-components/tooltip/sp-tooltip.js'; +import '@spectrum-web-components/overlay/overlay-trigger.js'; + +import { fixture } from '../../../test/testing-helpers.js'; + +describe('Overlay Trigger - ARIA attributes', () => { + it('sets aria-expanded="false" when closed', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + }); + + it('sets aria-expanded="true" when opened and back to "false" on close', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + + const opened = oneEvent(el, 'sp-opened'); + trigger.click(); + await opened; + + expect(trigger.getAttribute('aria-expanded')).to.equal('true'); + + const closed = oneEvent(el, 'sp-closed'); + el.removeAttribute('open'); + await elementUpdated(el); + await closed; + + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + }); + + it('sets aria-haspopup="dialog" by default', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + }); + + it('does not overwrite consumer-set aria-haspopup', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('menu'); + }); + + it('updates aria-haspopup when type changes and component manages it', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + + el.type = 'modal'; + await elementUpdated(el); + + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + }); + + it('sets aria-controls pointing to the content element id', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-controls')).to.equal('my-popover'); + }); + + it('auto-generates an id on the content element when missing', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + const content = el.querySelector('[slot="click-content"]') as HTMLElement; + + expect(content.id).to.match(/^sp-overlay-content-/); + expect(trigger.getAttribute('aria-controls')).to.equal(content.id); + }); + + it('preserves existing id on the content element', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const content = el.querySelector('[slot="click-content"]') as HTMLElement; + expect(content.id).to.equal('custom-id'); + }); + + it('does not set ARIA attributes for hover-only overlays', async () => { + const el = await fixture(html` + + Hover me + Tooltip text + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.hasAttribute('aria-expanded')).to.be.false; + expect(trigger.hasAttribute('aria-controls')).to.be.false; + }); + + it('cleans up ARIA attributes when content is removed', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + expect(trigger.hasAttribute('aria-controls')).to.be.true; + + const popover = el.querySelector('[slot="click-content"]') as HTMLElement; + popover.remove(); + await elementUpdated(el); + await nextFrame(); + + expect(trigger.hasAttribute('aria-expanded')).to.be.false; + expect(trigger.hasAttribute('aria-controls')).to.be.false; + }); + + it('cleans up ARIA attributes on disconnect', async () => { + const el = await fixture(html` + + Open + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + + el.remove(); + + expect(trigger.hasAttribute('aria-expanded')).to.be.false; + expect(trigger.hasAttribute('aria-haspopup')).to.be.false; + expect(trigger.hasAttribute('aria-controls')).to.be.false; + }); + + it('cleans up old trigger when trigger element changes', async () => { + const el = await fixture(html` + + First + Content + + `); + await elementUpdated(el); + await nextFrame(); + + const firstTrigger = el.querySelector('#first-trigger') as HTMLElement; + expect(firstTrigger.getAttribute('aria-expanded')).to.equal('false'); + + firstTrigger.remove(); + const newButton = document.createElement('sp-button'); + newButton.id = 'second-trigger'; + newButton.slot = 'trigger'; + newButton.textContent = 'Second'; + el.prepend(newButton); + await elementUpdated(el); + await nextFrame(); + await nextFrame(); + + expect(firstTrigger.hasAttribute('aria-expanded')).to.be.false; + expect(firstTrigger.hasAttribute('aria-haspopup')).to.be.false; + + expect(newButton.getAttribute('aria-expanded')).to.equal('false'); + expect(newButton.getAttribute('aria-haspopup')).to.equal('dialog'); + }); + + it('works with longpress content', async () => { + const el = await fixture(html` + + Long press me + Longpress content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + expect(trigger.hasAttribute('aria-controls')).to.be.true; + }); + + it('sets aria-expanded for modal type overlays', async () => { + const el = await fixture(html` + + Open modal + Modal content + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('false'); + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + + const opened = oneEvent(el, 'sp-opened'); + trigger.click(); + await opened; + + expect(trigger.getAttribute('aria-expanded')).to.equal('true'); + }); + + it('does not strip consumer-authored ARIA on hover-only triggers', async () => { + const el = await fixture(html` + + + Hover me + + Tooltip text + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-expanded')).to.equal('true'); + expect(trigger.getAttribute('aria-controls')).to.equal('external-panel'); + }); + + it('switches aria-controls to longpress content when longpress is open', async () => { + const el = await fixture(html` + + Trigger + + Click content + + + Longpress content + + + `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-controls')).to.equal('click-panel'); + + el.open = 'longpress'; + await elementUpdated(el); + + expect(trigger.getAttribute('aria-controls')).to.equal('longpress-panel'); + + el.open = 'click'; + await elementUpdated(el); + + expect(trigger.getAttribute('aria-controls')).to.equal('click-panel'); + }); + + it('sets aria-haspopup="dialog" when content has role="dialog"', async () => { + const el = await fixture(html` + + Open dialog +
Dialog content
+
+ `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + }); + + it('sets aria-haspopup="menu" when content has role="menu"', async () => { + const el = await fixture(html` + + Open menu +
Menu content
+
+ `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('menu'); + }); + + it('sets aria-haspopup="listbox" when content has role="listbox"', async () => { + const el = await fixture(html` + + Open listbox +
Listbox content
+
+ `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('listbox'); + }); + + it('detects role from first child when content wrapper has no role', async () => { + const el = await fixture(html` + + Open + +
Menu inside popover
+
+
+ `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('menu'); + }); + + it('defaults to "dialog" when content has no recognized role', async () => { + const el = await fixture(html` + + Open + +
Plain content with no role
+
+
+ `); + await elementUpdated(el); + await nextFrame(); + + const trigger = el.querySelector('[slot="trigger"]') as HTMLElement; + expect(trigger.getAttribute('aria-haspopup')).to.equal('dialog'); + }); +});