Skip to content

Commit 52cfef1

Browse files
authored
Merge branch 'main' into shiprag/fix-touch-submenu-interactions
2 parents 19a9280 + 2a78924 commit 52cfef1

File tree

5 files changed

+425
-4
lines changed

5 files changed

+425
-4
lines changed

.changeset/every-worlds-push.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@spectrum-web-components/tray': minor
3+
---
4+
5+
**Added**: Automatic dismiss button detection and visually-hidden helpers for screen reader accessibility
6+
7+
- **Added**: `<sp-tray>` now automatically detects keyboard-accessible dismiss buttons (like `<sp-button>`, `<sp-close-button>`, or HTML `<button>` elements) in slotted content
8+
- **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)
9+
- **Added**: New `has-keyboard-dismiss` boolean attribute to manually override auto-detection when slotted content has custom dismiss functionality that cannot be automatically detected
10+
- **Added**: Auto-detection recognizes `<sp-dialog dismissable>` and `<sp-dialog-wrapper dismissable>` components with built-in dismiss functionality in shadow DOM
11+
- **Enhanced**: Improved mobile screen reader accessibility by ensuring dismissal options are always available when appropriate

1st-gen/packages/tray/README.md

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@
77
[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/tray?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/tray)
88
[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/tray?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/tray)
99

10-
```
10+
```zsh
1111
yarn add @spectrum-web-components/tray
1212
```
1313

1414
Import the side effectful registration of `<sp-tray>` via:
1515

16-
```
16+
```js
1717
import '@spectrum-web-components/tray/sp-tray.js';
1818
```
1919

2020
When looking to leverage the `Tray` base class as a type and/or for extension purposes, do so via:
2121

22-
```
22+
```js
2323
import { Tray } from '@spectrum-web-components/tray';
2424
```
2525

@@ -70,3 +70,80 @@ A tray has a single default `slot`.
7070
### Accessibility
7171

7272
`<sp-tray>` presents a page blocking experience and should be opened with the `Overlay` API using the `modal` interaction to ensure that the content appropriately manages the presence of other content in the tab order of the page and the availability of that content for a screen reader.
73+
74+
#### Auto-detection behavior
75+
76+
By default, `<sp-tray>` automatically detects whether its slotted content includes keyboard-accessible dismiss buttons (like `<sp-button>`, `<sp-close-button>`, or HTML `<button>` elements). When no dismiss buttons are found, the tray renders visually hidden dismiss buttons before and after its content to support mobile screen readers, particularly VoiceOver on iOS where users navigate through interactive elements sequentially.
77+
78+
These built-in dismiss buttons:
79+
80+
- Are visually hidden but accessible to screen readers
81+
- Allow mobile screen reader users to easily dismiss the tray from either the beginning or end of the content
82+
- Are labeled "Dismiss" for clear screen reader announcements
83+
84+
This dismiss helper pattern is also implemented in the [`<sp-picker>`](https://opensource.adobe.com/spectrum-web-components/components/picker/) component, which uses the same approach when rendering menu content in a tray on mobile devices.
85+
86+
<sp-tabs selected="auto" auto label="Dismiss helper examples">
87+
<sp-tab value="auto">Content has no buttons</sp-tab>
88+
<sp-tab-panel value="auto">
89+
90+
This example shows the default behavior where the tray automatically detects that the menu content lacks dismiss buttons and renders visually hidden helpers. Screen readers will announce them as "Dismiss, button" and these helpers are keyboard accessible.
91+
92+
```html
93+
<overlay-trigger type="modal">
94+
<sp-button slot="trigger" variant="secondary">
95+
Toggle menu content
96+
</sp-button>
97+
<sp-tray slot="click-content">
98+
<sp-menu style="width: 100%">
99+
<sp-menu-item>Deselect</sp-menu-item>
100+
<sp-menu-item>Select Inverse</sp-menu-item>
101+
<sp-menu-item>Feather...</sp-menu-item>
102+
<sp-menu-item>Select and Mask...</sp-menu-item>
103+
</sp-menu>
104+
</sp-tray>
105+
</overlay-trigger>
106+
```
107+
108+
</sp-tab-panel>
109+
<sp-tab value="with-buttons">Content has buttons</sp-tab>
110+
<sp-tab-panel value="with-buttons">
111+
112+
This example shows auto-detection recognizing that the dialog has its own dismiss functionality, so no additional helpers are rendered.
113+
114+
```html
115+
<overlay-trigger type="modal">
116+
<sp-button slot="trigger" variant="secondary">
117+
Toggle dialog content
118+
</sp-button>
119+
<sp-tray slot="click-content">
120+
<sp-dialog size="s" dismissable>
121+
<h2 slot="heading">New messages</h2>
122+
You have 5 new messages.
123+
</sp-dialog>
124+
</sp-tray>
125+
</overlay-trigger>
126+
```
127+
128+
</sp-tab-panel>
129+
<sp-tab value="force-hide">Manual override</sp-tab>
130+
<sp-tab-panel value="force-hide">
131+
132+
Set `has-keyboard-dismiss` (or `has-keyboard-dismiss="true"`) to prevent the tray from rendering visually hidden dismiss helpers, even when no buttons are detected. You are then responsible for ensuring that your tray content has keyboard-accessible dismiss functionality.
133+
134+
```html
135+
<overlay-trigger type="modal">
136+
<sp-button slot="trigger" variant="secondary">
137+
Toggle without helpers
138+
</sp-button>
139+
<sp-tray slot="click-content" has-keyboard-dismiss>
140+
<p>
141+
Custom content that should have custom dismiss functionality, even
142+
though the tray didn't detect buttons in this slot.
143+
</p>
144+
</sp-tray>
145+
</overlay-trigger>
146+
```
147+
148+
</sp-tab-panel>
149+
</sp-tabs>

1st-gen/packages/tray/src/Tray.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
import {
1414
CSSResultArray,
1515
html,
16+
nothing,
1617
PropertyValues,
1718
SpectrumElement,
1819
TemplateResult,
1920
} from '@spectrum-web-components/base';
2021
import {
2122
property,
2223
query,
24+
state,
2325
} from '@spectrum-web-components/base/src/decorators.js';
2426
import '@spectrum-web-components/underlay/sp-underlay.js';
2527
import { firstFocusableIn } from '@spectrum-web-components/shared/src/first-focusable-in.js';
@@ -55,6 +57,9 @@ export class Tray extends SpectrumElement {
5557
@query('.tray')
5658
private tray!: HTMLDivElement;
5759

60+
@query('slot')
61+
private contentSlot!: HTMLSlotElement;
62+
5863
public override focus(): void {
5964
const firstFocusable = firstFocusableIn(this);
6065
if (firstFocusable) {
@@ -81,6 +86,99 @@ export class Tray extends SpectrumElement {
8186
}
8287
}
8388

89+
/**
90+
* When set, prevents the tray from rendering visually-hidden dismiss helpers.
91+
* Use this if your slotted content has custom keyboard-accessible dismiss functionality
92+
* that the auto-detection doesn't recognize.
93+
*
94+
* By default, the tray automatically detects buttons in slotted content.
95+
*/
96+
@property({ type: Boolean, attribute: 'has-keyboard-dismiss' })
97+
public hasKeyboardDismissButton = false;
98+
99+
/**
100+
* Returns a visually hidden dismiss button for mobile screen reader accessibility.
101+
* This button is placed before and after tray content to allow mobile screen reader
102+
* users (particularly VoiceOver on iOS) to easily dismiss the overlay.
103+
*/
104+
protected get dismissHelper(): TemplateResult {
105+
return html`
106+
<div class="visually-hidden">
107+
<button aria-label="Dismiss" @click=${this.close}></button>
108+
</div>
109+
`;
110+
}
111+
112+
/**
113+
* Internal state tracking whether dismiss helpers are needed.
114+
* Automatically updated when slotted content changes.
115+
*/
116+
@state()
117+
private needsDismissHelper = true;
118+
119+
/**
120+
* Check if slotted content has keyboard-accessible dismiss buttons.
121+
* Looks for buttons in light DOM and checks for known components with built-in dismiss.
122+
*/
123+
private checkForDismissButtons(): void {
124+
if (!this.contentSlot) {
125+
this.needsDismissHelper = true;
126+
return;
127+
}
128+
129+
const slottedElements = this.contentSlot.assignedElements({
130+
flatten: true,
131+
});
132+
133+
if (slottedElements.length === 0) {
134+
this.needsDismissHelper = true;
135+
return;
136+
}
137+
138+
const hasDismissButton = slottedElements.some((element) => {
139+
// Check if element is a button itself
140+
if (
141+
element.tagName === 'SP-BUTTON' ||
142+
element.tagName === 'SP-CLOSE-BUTTON' ||
143+
element.tagName === 'BUTTON'
144+
) {
145+
return true;
146+
}
147+
148+
// Check for dismissable dialog (has built-in dismiss button in shadow DOM)
149+
if (
150+
element.tagName === 'SP-DIALOG' &&
151+
element.hasAttribute('dismissable')
152+
) {
153+
return true;
154+
}
155+
156+
// Check for dismissable dialog-wrapper
157+
if (
158+
element.tagName === 'SP-DIALOG-WRAPPER' &&
159+
element.hasAttribute('dismissable')
160+
) {
161+
return true;
162+
}
163+
164+
// Check for buttons in light DOM (won't see shadow DOM)
165+
const buttons = element.querySelectorAll(
166+
'sp-button, sp-close-button, button'
167+
);
168+
if (buttons.length > 0) {
169+
return true;
170+
}
171+
172+
return false;
173+
});
174+
175+
this.needsDismissHelper = !hasDismissButton;
176+
}
177+
178+
private handleSlotChange(): void {
179+
this.checkForDismissButtons();
180+
}
181+
84182
private dispatchClosed(): void {
85183
this.dispatchEvent(
86184
new Event('close', {
@@ -102,6 +200,12 @@ export class Tray extends SpectrumElement {
102200
}
103201
}
104202

203+
protected override firstUpdated(changes: PropertyValues<this>): void {
204+
super.firstUpdated(changes);
205+
// Run initial button detection
206+
this.checkForDismissButtons();
207+
}
208+
105209
protected override update(changes: PropertyValues<this>): void {
106210
if (
107211
changes.has('open') &&
@@ -131,7 +235,13 @@ export class Tray extends SpectrumElement {
131235
tabindex="-1"
132236
@transitionend=${this.handleTrayTransitionend}
133237
>
134-
<slot></slot>
238+
${!this.hasKeyboardDismissButton && this.needsDismissHelper
239+
? this.dismissHelper
240+
: nothing}
241+
<slot @slotchange=${this.handleSlotChange}></slot>
242+
${!this.hasKeyboardDismissButton && this.needsDismissHelper
243+
? this.dismissHelper
244+
: nothing}
135245
</div>
136246
`;
137247
}

1st-gen/packages/tray/src/tray.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ sp-underlay {
3030
overscroll-behavior: contain;
3131
}
3232

33+
.visually-hidden,
3334
::slotted(.visually-hidden) {
3435
border: 0;
3536
clip: rect(0, 0, 0, 0);

0 commit comments

Comments
 (0)