-
Notifications
You must be signed in to change notification settings - Fork 235
fix(tray): add visually hidden dismiss buttons #5814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 15 commits
1977764
4183066
1d36948
92fb18b
a1132bf
c96463d
5420c21
40834f4
36dea1c
5d1e987
542fb4d
dd07f22
1dcd561
8ca9b6a
08ff700
37c11cd
4321b34
320f0d7
661de4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| '@spectrum-web-components/tray': minor | ||
| --- | ||
|
|
||
| **Added**: Automatic dismiss button detection and visually-hidden helpers for screen reader accessibility | ||
|
|
||
| - **Added**: `<sp-tray>` now automatically detects keyboard-accessible dismiss buttons (like `<sp-button>`, `<sp-close-button>`, or HTML `<button>` elements) in slotted content | ||
| - **Added**: When no dismiss buttons are detected, the tray automatically renders visually-hidden dismiss buttons before and after its content to support mobile screen readers (particularly VoiceOver on iOS) | ||
| - **Added**: New `has-keyboard-dismiss` boolean attribute to manually override auto-detection when slotted content has custom dismiss functionality that cannot be automatically detected | ||
| - **Added**: Auto-detection recognizes `<sp-dialog dismissable>` and `<sp-dialog-wrapper dismissable>` components with built-in dismiss functionality in shadow DOM | ||
| - **Enhanced**: Improved mobile screen reader accessibility by ensuring dismissal options are always available when appropriate |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,13 +13,15 @@ | |
| import { | ||
| CSSResultArray, | ||
| html, | ||
| nothing, | ||
| PropertyValues, | ||
| SpectrumElement, | ||
| TemplateResult, | ||
| } from '@spectrum-web-components/base'; | ||
| import { | ||
| property, | ||
| query, | ||
| state, | ||
| } from '@spectrum-web-components/base/src/decorators.js'; | ||
| import '@spectrum-web-components/underlay/sp-underlay.js'; | ||
| import { firstFocusableIn } from '@spectrum-web-components/shared/src/first-focusable-in.js'; | ||
|
|
@@ -55,6 +57,9 @@ export class Tray extends SpectrumElement { | |
| @query('.tray') | ||
| private tray!: HTMLDivElement; | ||
|
|
||
| @query('slot') | ||
| private contentSlot!: HTMLSlotElement; | ||
|
|
||
| public override focus(): void { | ||
| const firstFocusable = firstFocusableIn(this); | ||
| if (firstFocusable) { | ||
|
|
@@ -81,6 +86,103 @@ export class Tray extends SpectrumElement { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * When set, prevents the tray from rendering visually-hidden dismiss helpers. | ||
| * Use this if your slotted content has custom keyboard-accessible dismiss functionality | ||
| * that the auto-detection doesn't recognize. | ||
| * | ||
| * By default, the tray automatically detects buttons in slotted content. | ||
| */ | ||
| @property({ type: Boolean, attribute: 'has-keyboard-dismiss' }) | ||
| public hasKeyboardDismissButton = false; | ||
|
|
||
| /** | ||
| * Returns a visually hidden dismiss button for mobile screen reader accessibility. | ||
| * This button is placed before and after tray content to allow mobile screen reader | ||
| * users (particularly VoiceOver on iOS) to easily dismiss the overlay. | ||
| */ | ||
| protected get dismissHelper(): TemplateResult { | ||
| return html` | ||
| <div class="visually-hidden"> | ||
| <button | ||
| tabindex="-1" | ||
| aria-label="Dismiss" | ||
| @click=${this.close} | ||
| ></button> | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| /** | ||
| * 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 +204,12 @@ export class Tray extends SpectrumElement { | |
| } | ||
| } | ||
|
|
||
| protected override firstUpdated(changes: PropertyValues<this>): void { | ||
| super.firstUpdated(changes); | ||
| // Run initial button detection | ||
| this.checkForDismissButtons(); | ||
| } | ||
|
|
||
| protected override update(changes: PropertyValues<this>): void { | ||
| if ( | ||
| changes.has('open') && | ||
|
|
@@ -131,7 +239,13 @@ export class Tray extends SpectrumElement { | |
| tabindex="-1" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me know if I'm still missing anything here. Without removing the Screen.Recording.2025-10-24.at.2.11.49.PM.movDid we want some sort of focus indicator? What's the expectation if the button is visually hidden, but keyboard accessible? 🤔 |
||
| @transitionend=${this.handleTrayTransitionend} | ||
| > | ||
| <slot></slot> | ||
| ${!this.hasKeyboardDismissButton && this.needsDismissHelper | ||
| ? this.dismissHelper | ||
| : nothing} | ||
| <slot @slotchange=${this.handleSlotChange}></slot> | ||
| ${!this.hasKeyboardDismissButton && this.needsDismissHelper | ||
| ? this.dismissHelper | ||
| : nothing} | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Screenreader users need to be able to access this via keyboard, so a tabindex should not exist here.