diff --git a/.changeset/every-worlds-push.md b/.changeset/every-worlds-push.md new file mode 100644 index 00000000000..938db1eb553 --- /dev/null +++ b/.changeset/every-worlds-push.md @@ -0,0 +1,11 @@ +--- +'@spectrum-web-components/tray': minor +--- + +**Added**: Automatic dismiss button detection and visually-hidden helpers for screen reader accessibility + +- **Added**: `` now automatically detects keyboard-accessible dismiss buttons (like ``, ``, or HTML ` + + `; + } + + /** + * Internal state tracking whether dismiss helpers are needed. + * Automatically updated when slotted content changes. + */ + @state() + private needsDismissHelper = true; + + /** + * Check if slotted content has keyboard-accessible dismiss buttons. + * Looks for buttons in light DOM and checks for known components with built-in dismiss. + */ + private checkForDismissButtons(): void { + if (!this.contentSlot) { + this.needsDismissHelper = true; + return; + } + + const slottedElements = this.contentSlot.assignedElements({ + flatten: true, + }); + + if (slottedElements.length === 0) { + this.needsDismissHelper = true; + return; + } + + const hasDismissButton = slottedElements.some((element) => { + // Check if element is a button itself + if ( + element.tagName === 'SP-BUTTON' || + element.tagName === 'SP-CLOSE-BUTTON' || + element.tagName === 'BUTTON' + ) { + return true; + } + + // Check for dismissable dialog (has built-in dismiss button in shadow DOM) + if ( + element.tagName === 'SP-DIALOG' && + element.hasAttribute('dismissable') + ) { + return true; + } + + // Check for dismissable dialog-wrapper + if ( + element.tagName === 'SP-DIALOG-WRAPPER' && + element.hasAttribute('dismissable') + ) { + return true; + } + + // Check for buttons in light DOM (won't see shadow DOM) + const buttons = element.querySelectorAll( + 'sp-button, sp-close-button, button' + ); + if (buttons.length > 0) { + return true; + } + + return false; + }); + + this.needsDismissHelper = !hasDismissButton; + } + + private handleSlotChange(): void { + this.checkForDismissButtons(); + } + private dispatchClosed(): void { this.dispatchEvent( new Event('close', { @@ -102,6 +200,12 @@ export class Tray extends SpectrumElement { } } + protected override firstUpdated(changes: PropertyValues): void { + super.firstUpdated(changes); + // Run initial button detection + this.checkForDismissButtons(); + } + protected override update(changes: PropertyValues): void { if ( changes.has('open') && @@ -131,7 +235,13 @@ export class Tray extends SpectrumElement { tabindex="-1" @transitionend=${this.handleTrayTransitionend} > - + ${!this.hasKeyboardDismissButton && this.needsDismissHelper + ? this.dismissHelper + : nothing} + + ${!this.hasKeyboardDismissButton && this.needsDismissHelper + ? this.dismissHelper + : nothing} `; } diff --git a/packages/tray/src/tray.css b/packages/tray/src/tray.css index d6bd646b275..33ec07f30b4 100644 --- a/packages/tray/src/tray.css +++ b/packages/tray/src/tray.css @@ -30,6 +30,7 @@ sp-underlay { overscroll-behavior: contain; } +.visually-hidden, ::slotted(.visually-hidden) { border: 0; clip: rect(0, 0, 0, 0); diff --git a/packages/tray/test/tray.test.ts b/packages/tray/test/tray.test.ts index 9e457932f8f..e8057320ed4 100644 --- a/packages/tray/test/tray.test.ts +++ b/packages/tray/test/tray.test.ts @@ -23,6 +23,10 @@ import '@spectrum-web-components/tray/sp-tray.js'; import { Tray } from '@spectrum-web-components/tray'; import '@spectrum-web-components/theme/sp-theme.js'; import '@spectrum-web-components/theme/src/themes.js'; +import '@spectrum-web-components/dialog/sp-dialog.js'; +import '@spectrum-web-components/button/sp-button.js'; +import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/menu/sp-menu-item.js'; import { testForLitDevWarnings } from '../../../test/testing-helpers.js'; describe('Tray', () => { @@ -100,4 +104,222 @@ describe('Tray', () => { expect(el.open).to.be.false; }); + + describe('Dismiss helpers', () => { + it('renders visually-hidden dismiss helpers when no buttons detected', async () => { + const el = await fixture(html` + + + Item 1 + Item 2 + + + `); + await elementUpdated(el); + + const helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(2); + + const buttons = el.shadowRoot.querySelectorAll( + '.visually-hidden button' + ); + expect(buttons.length).to.equal(2); + expect(buttons[0].getAttribute('aria-label')).to.equal('Dismiss'); + expect(buttons[0].getAttribute('tabindex')).to.be.null; + }); + + it('allows focusing dismiss helper buttons', async () => { + const el = await fixture(html` + +

Content without buttons

+
+ `); + await elementUpdated(el); + + const dismissButton = el.shadowRoot.querySelector( + '.visually-hidden button' + ) as HTMLButtonElement; + expect(dismissButton).to.exist; + + dismissButton.focus(); + await elementUpdated(el); + + expect(document.activeElement).to.equal(el); + expect( + el.shadowRoot.activeElement, + 'dismiss button is focused in shadow root' + ).to.equal(dismissButton); + }); + + it('closes tray when Enter key is pressed on dismiss button', async () => { + const test = await fixture(html` + + +

Content without buttons

+
+
+ `); + + const el = test.querySelector('sp-tray') as Tray; + await nextFrame(); // allows for animation to complete + await nextFrame(); + await elementUpdated(el); + expect(el.open).to.be.true; + + const dismissButton = el.shadowRoot.querySelector( + '.visually-hidden button' + ) as HTMLButtonElement; + + dismissButton.focus(); + await elementUpdated(el); + + const closed = oneEvent(el, 'close'); + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + }); + dismissButton.dispatchEvent(enterEvent); + // Trigger the default button behavior + dismissButton.click(); + await closed; + + expect(el.open).to.be.false; + }); + + it('does not render dismiss helpers when sp-button is detected', async () => { + const el = await fixture(html` + +
+ Close +
+
+ `); + await elementUpdated(el); + + const helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(0); + }); + + it('does not render dismiss helpers when dismissable dialog is detected', async () => { + const el = await fixture(html` + + +

New messages

+ You have 5 new messages. +
+
+ `); + await elementUpdated(el); + + const helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(0); + }); + + it('does not render dismiss helpers when native button is detected', async () => { + const el = await fixture(html` + +
+ +
+
+ `); + await elementUpdated(el); + + const helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(0); + }); + + it('does not render dismiss helpers with has-keyboard-dismiss attribute', async () => { + const el = await fixture(html` + +

Custom content with custom dismiss handling

+
+ `); + await elementUpdated(el); + + expect(el.hasKeyboardDismissButton).to.be.true; + + const helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(0); + }); + + it('renders dismiss helpers after slot content changes to remove buttons', async () => { + const el = await fixture(html` + + Close + + `); + await elementUpdated(el); + + // Should not have helpers initially + let helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(0); + + // Remove the button + const button = el.querySelector('sp-button'); + button?.remove(); + await elementUpdated(el); + + // Should now have helpers + helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(2); + }); + + it('removes dismiss helpers after slot content changes to add buttons', async () => { + const el = await fixture(html` + +

Some content

+
+ `); + await elementUpdated(el); + + // Should have helpers initially + let helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(2); + + // Add a button + const button = document.createElement('sp-button'); + button.textContent = 'Close'; + el.appendChild(button); + await elementUpdated(el); + + // Should no longer have helpers + helpers = el.shadowRoot.querySelectorAll('.visually-hidden'); + expect(helpers.length).to.equal(0); + }); + + it('dismiss helper buttons trigger close when clicked', async () => { + const test = await fixture(html` + + +

Content without buttons

+
+
+ `); + + const el = test.querySelector('sp-tray') as Tray; + // Ensure closed styles are set before opening so that + // the `transitionend` event will be met below. + await nextFrame(); + await nextFrame(); + await elementUpdated(el); + expect(el.open).to.be.true; + + const root = el.shadowRoot ? el.shadowRoot : el; + const dismissButton = root.querySelector( + '.visually-hidden button' + ) as HTMLButtonElement; + expect(dismissButton.getAttribute('aria-label')).to.equal( + 'Dismiss' + ); + + const closed = oneEvent(el, 'close'); + dismissButton.click(); + await closed; + + expect(el.open).to.be.false; + }); + }); });