Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1977764
feat(tray): add dismissHelper function
marissahuysentruyt Oct 17, 2025
4183066
refactor(tray): expand visually-hidden selector for dismiss buttons
marissahuysentruyt Oct 17, 2025
1d36948
docs(tray): add tray docs with visually hidden dismiss
marissahuysentruyt Oct 17, 2025
92fb18b
feat(dialog-base): add dismissHelper for visually-hidden buttons
marissahuysentruyt Oct 21, 2025
a1132bf
feat(dialog-wrapper): implement conditional dismissHelper buttons
marissahuysentruyt Oct 21, 2025
c96463d
feat(modal): add visually-hidden styles for dialog dismissHelpers
marissahuysentruyt Oct 21, 2025
5420c21
revert: feat(modal): add visually-hidden styles for dialog dismissHel…
marissahuysentruyt Oct 22, 2025
40834f4
revert: feat(dialog-wrapper): implement conditional dismissHelper but…
marissahuysentruyt Oct 22, 2025
36dea1c
revert: feat(dialog-base): add dismissHelper for visually-hidden buttons
marissahuysentruyt Oct 22, 2025
5d1e987
feat(tray): expand API for dismissible behavior
marissahuysentruyt Oct 22, 2025
542fb4d
docs(tray): update README with new properties/examples
marissahuysentruyt Oct 23, 2025
dd07f22
test(tray): add tests for dismiss button detection
marissahuysentruyt Oct 23, 2025
1dcd561
chore(tray): add changeset
marissahuysentruyt Oct 23, 2025
8ca9b6a
chore(tray): fix type errors
marissahuysentruyt Oct 23, 2025
08ff700
test(tray): fix failing close test
marissahuysentruyt Oct 23, 2025
37c11cd
fix(tray): remove negative tabindex fromhidden buttons
marissahuysentruyt Oct 24, 2025
4321b34
test(tray): add keyboard accessible test assertion
marissahuysentruyt Oct 24, 2025
320f0d7
docs(tray): update docs with keyboard nav info
marissahuysentruyt Oct 24, 2025
661de4a
test(tray): add tab and close() keyboard tests
marissahuysentruyt Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/every-worlds-push.md
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
83 changes: 80 additions & 3 deletions packages/tray/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@
[![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)
[![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)

```
```zsh
yarn add @spectrum-web-components/tray
```

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

```
```js
import '@spectrum-web-components/tray/sp-tray.js';
```

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

```
```js
import { Tray } from '@spectrum-web-components/tray';
```

Expand Down Expand Up @@ -70,3 +70,80 @@ A tray has a single default `slot`.
### Accessibility

`<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.

#### Auto-detection behavior

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.

These built-in dismiss buttons:

- Are visually hidden but accessible to screen readers
- Allow mobile screen reader users to easily dismiss the tray from either the beginning or end of the content
- Are labeled "Dismiss" for clear screen reader announcements

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.

<sp-tabs selected="auto" auto label="Dismiss helper examples">
<sp-tab value="auto">Content has no buttons</sp-tab>
<sp-tab-panel value="auto">

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.

```html
<overlay-trigger type="modal">
<sp-button slot="trigger" variant="secondary">
Toggle menu content
</sp-button>
<sp-tray slot="click-content">
<sp-menu style="width: 100%">
<sp-menu-item>Deselect</sp-menu-item>
<sp-menu-item>Select Inverse</sp-menu-item>
<sp-menu-item>Feather...</sp-menu-item>
<sp-menu-item>Select and Mask...</sp-menu-item>
</sp-menu>
</sp-tray>
</overlay-trigger>
```

</sp-tab-panel>
<sp-tab value="with-buttons">Content has buttons</sp-tab>
<sp-tab-panel value="with-buttons">

This example shows auto-detection recognizing that the dialog has its own dismiss functionality, so no additional helpers are rendered.

```html
<overlay-trigger type="modal">
<sp-button slot="trigger" variant="secondary">
Toggle dialog content
</sp-button>
<sp-tray slot="click-content">
<sp-dialog size="s" dismissable>
<h2 slot="heading">New messages</h2>
You have 5 new messages.
</sp-dialog>
</sp-tray>
</overlay-trigger>
```

</sp-tab-panel>
<sp-tab value="force-hide">Manual override</sp-tab>
<sp-tab-panel value="force-hide">

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.

```html
<overlay-trigger type="modal">
<sp-button slot="trigger" variant="secondary">
Toggle without helpers
</sp-button>
<sp-tray slot="click-content" has-keyboard-dismiss>
<p>
Custom content that should have custom dismiss functionality, even
though the tray didn't detect buttons in this slot.
</p>
</sp-tray>
</overlay-trigger>
```

</sp-tab-panel>
</sp-tabs>
112 changes: 111 additions & 1 deletion packages/tray/src/Tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -81,6 +86,99 @@ 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 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', {
Expand All @@ -102,6 +200,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') &&
Expand Down Expand Up @@ -131,7 +235,13 @@ export class Tray extends SpectrumElement {
tabindex="-1"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because tray-modal has a tabindex="-1", I still can't get to the dismiss buttons with a keyboard

Image
Suggested change
tabindex="-1"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 tabindex="-1" on the .tray.modal, I can still tab to the buttons.

Screen.Recording.2025-10-24.at.2.11.49.PM.mov

Did we want some sort of focus indicator? What's the expectation if the button is visually hidden, but keyboard accessible? 🤔
Screenshot 2025-10-24 at 2 25 54 PM

@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>
`;
}
Expand Down
1 change: 1 addition & 0 deletions packages/tray/src/tray.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ sp-underlay {
overscroll-behavior: contain;
}

.visually-hidden,
::slotted(.visually-hidden) {
border: 0;
clip: rect(0, 0, 0, 0);
Expand Down
Loading
Loading