dequelabs / dequelabs/cauldron

Bug: Toast does not restore focus when dismissed

Open
#2,345 1 comment 0 reactions 0 assignees View on GitHub
accessibility bug WCAG 2.4.3
Dominant language
TypeScript
Stars
127
Forks
31
Avg merge
2d 12h
Merged PRs (30d)
8

Description

## Steps to reproduce

1. Render a `` with default props (`focus={true}`, `dismissible={true}`) — for example any of the dismissible toasts on https://cauldron.dequelabs.com/components/Toast.
2. Trigger the toast to show. Focus moves to the toast container (this is the documented behavior of `focus={true}`).
3. Press Tab to move focus into the toast and onto the **Dismiss** button, then press Enter or Space to activate it.

## Current behavior

After dismiss, the Toast is set to `display: none` (via the `is--hidden` class). Because the focused element (the dismiss button) is inside the now-hidden subtree, the browser drops `document.activeElement` to ``. The Toast does **not** restore focus to where it was before show.

> Note: on the docs site, pressing Tab after dismissing returns focus to the button that triggered the toast, but that's a coincidence of DOM layout (the trigger is the trigger's nearest focusable sibling to the toast). The browser's "sequential focus navigation starting point" persists near the toast's DOM position, so Tab resumes from there. As soon as the toast is consumed in a more realistic layout — portaled to `document.body`, rendered in a global notification stack, or used after the user has navigated/scrolled away from the trigger — Tab does not return to the trigger and the user lands somewhere arbitrary or at the start of the document.

This happens because the Toast component takes focus on show but never restores it on dismiss:

- `showToast` ([packages/react/src/components/Toast/index.tsx#L62](https://github.com/dequelabs/cauldron/blob/develop/packages/react/src/components/Toast/index.tsx#L62)) calls `el.focus()` on the toast container.
- `dismissToast` ([packages/react/src/components/Toast/index.tsx#L66-L84](https://github.com/dequelabs/cauldron/blob/develop/packages/react/src/components/Toast/index.tsx#L66-L84)) hides the toast and (for `action-needed`) deactivates the `AriaIsolate`, but never restores focus to the previously-focused element.
- `AriaIsolate.deactivate()` ([packages/react/src/utils/aria-isolate/index.ts#L48-L53](https://github.com/dequelabs/cauldron/blob/develop/packages/react/src/utils/aria-isolate/index.ts#L48-L53)) only removes `aria-hidden` attributes; it does not track or restore focus.

## Expected behavior

The Toast should follow the standard "save → steal → restore" focus pattern that other focus-stealing components (modals, dialogs) use:

1. Capture `document.activeElement` *before* calling `el.focus()` in `showToast`.
2. On dismiss (and on unmount while shown), call `previouslyFocused.focus()` after the dismiss animation but before `display: none` lands — so focus returns to the trigger that opened the toast (or the closest sensible element if that trigger is gone).

This matches WCAG 2.4.3 (Focus Order) and the established APG pattern for transient focus-stealing UI.

## Impact

- [ ] Blocker
- [x] Critical
- [ ] Serious
- [ ] Moderate
- [ ] Minor

Keyboard and screen-reader users lose their place in the document every time a toast they triggered is dismissed in a layout where the trigger isn't adjacent to the toast in DOM order. The docs-site demo masks this because of where the trigger and the Toast sit in the demo block; consumers who portal the Toast or render it in a global notification stack will see focus dropped to `` and Tab resuming from arbitrary points. For an `action-needed` toast (which traps focus via `AriaIsolate`) this is especially disorienting because the prior focus context is what the user would expect to return to.

## Version

v6.27.0

## Success Criteria

### 2.4 Navigable
- [x] 2.4.3 Focus Order

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.