Skip to content

Commit ad6e240

Browse files
authored
Toast focus management take 2 (#6223)
* Toast focus management and announcements for all AT
1 parent b29dba0 commit ad6e240

File tree

12 files changed

+346
-117
lines changed

12 files changed

+346
-117
lines changed
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"close": "Close",
3-
"notifications": "Notifications"
3+
"notifications": "{count, plural, one {# notification} other {# notifications}}."
44
}

packages/@react-aria/toast/src/useToast.ts

+22-28
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement} from '@react-types/s
1515
// @ts-ignore
1616
import intlMessages from '../intl/*.json';
1717
import {QueuedToast, ToastState} from '@react-stately/toast';
18-
import {RefObject, useEffect, useRef} from 'react';
19-
import {useId, useLayoutEffect, useSlotId} from '@react-aria/utils';
18+
import React, {RefObject, useEffect} from 'react';
19+
import {useId, useSlotId} from '@react-aria/utils';
2020
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2121

2222
export interface AriaToastProps<T> extends AriaLabelingProps {
@@ -25,8 +25,10 @@ export interface AriaToastProps<T> extends AriaLabelingProps {
2525
}
2626

2727
export interface ToastAria {
28-
/** Props for the toast container element. */
28+
/** Props for the toast container, non-modal dialog element. */
2929
toastProps: DOMAttributes,
30+
/** Props for the toast content alert message. */
31+
contentProps: DOMAttributes,
3032
/** Props for the toast title element. */
3133
titleProps: DOMAttributes,
3234
/** Props for the toast description element, if any. */
@@ -39,6 +41,7 @@ export interface ToastAria {
3941
* Provides the behavior and accessibility implementation for a toast component.
4042
* Toasts display brief, temporary notifications of actions, errors, or other events in an application.
4143
*/
44+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4245
export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref: RefObject<FocusableElement>): ToastAria {
4346
let {
4447
key,
@@ -58,44 +61,35 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
5861
};
5962
}, [timer, timeout]);
6063

61-
// Restore focus to the toast container on unmount.
62-
// If there are no more toasts, the container will be unmounted
63-
// and will restore focus to wherever focus was before the user
64-
// focused the toast region.
65-
let focusOnUnmount = useRef(null);
66-
useLayoutEffect(() => {
67-
let container = ref.current.closest('[role=region]') as HTMLElement;
68-
return () => {
69-
if (container && container.contains(document.activeElement)) {
70-
// Focus must be delayed for focus ring to appear, but we can't wait
71-
// until useEffect cleanup to check if focus was inside the container.
72-
focusOnUnmount.current = container;
73-
}
74-
};
75-
}, [ref]);
76-
77-
// eslint-disable-next-line
64+
let [isEntered, setIsEntered] = React.useState(false);
7865
useEffect(() => {
79-
return () => {
80-
if (focusOnUnmount.current) {
81-
focusOnUnmount.current.focus();
82-
}
83-
};
84-
}, [ref]);
66+
if (animation === 'entering') {
67+
setIsEntered(true);
68+
}
69+
}, [animation]);
8570

8671
let titleId = useId();
8772
let descriptionId = useSlotId();
8873
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/toast');
8974

9075
return {
9176
toastProps: {
92-
role: 'alert',
77+
role: 'alertdialog',
78+
'aria-modal': 'false',
9379
'aria-label': props['aria-label'],
9480
'aria-labelledby': props['aria-labelledby'] || titleId,
9581
'aria-describedby': props['aria-describedby'] || descriptionId,
9682
'aria-details': props['aria-details'],
9783
// Hide toasts that are animating out so VoiceOver doesn't announce them.
98-
'aria-hidden': animation === 'exiting' ? 'true' : undefined
84+
'aria-hidden': animation === 'exiting' ? 'true' : undefined,
85+
tabIndex: 0
86+
},
87+
contentProps: {
88+
role: 'alert',
89+
'aria-atomic': 'true',
90+
style: {
91+
visibility: isEntered || animation === null ? 'visible' : 'hidden'
92+
}
9993
},
10094
titleProps: {
10195
id: titleId

packages/@react-aria/toast/src/useToastRegion.ts

+95-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {AriaLabelingProps, DOMAttributes} from '@react-types/shared';
2-
import {focusWithoutScrolling, mergeProps} from '@react-aria/utils';
2+
import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils';
33
import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions';
44
// @ts-ignore
55
import intlMessages from '../intl/*.json';
@@ -29,14 +29,80 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
2929
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/toast');
3030
let {landmarkProps} = useLandmark({
3131
role: 'region',
32-
'aria-label': props['aria-label'] || stringFormatter.format('notifications')
32+
'aria-label': props['aria-label'] || stringFormatter.format('notifications', {count: state.visibleToasts.length})
3333
}, ref);
3434

3535
let {hoverProps} = useHover({
3636
onHoverStart: state.pauseAll,
3737
onHoverEnd: state.resumeAll
3838
});
3939

40+
// Manage focus within the toast region.
41+
// If a focused containing toast is removed, move focus to the next toast, or the previous toast if there is no next toast.
42+
// We might be making an assumption with how this works if someone implements the priority queue differently, or
43+
// if they only show one toast at a time.
44+
let toasts = useRef([]);
45+
let prevVisibleToasts = useRef(state.visibleToasts);
46+
let focusedToast = useRef(null);
47+
useLayoutEffect(() => {
48+
// If no toast has focus, then don't do anything.
49+
if (focusedToast.current === -1 || state.visibleToasts.length === 0) {
50+
toasts.current = [];
51+
prevVisibleToasts.current = state.visibleToasts;
52+
return;
53+
}
54+
toasts.current = [...ref.current.querySelectorAll('[role="alertdialog"]')];
55+
// If the visible toasts haven't changed, we don't need to do anything.
56+
if (prevVisibleToasts.current.length === state.visibleToasts.length
57+
&& state.visibleToasts.every((t, i) => t.key === prevVisibleToasts.current[i].key)) {
58+
prevVisibleToasts.current = state.visibleToasts;
59+
return;
60+
}
61+
// Get a list of all toasts by index and add info if they are removed.
62+
let allToasts = prevVisibleToasts.current
63+
.map((t, i) => ({
64+
...t,
65+
i,
66+
isRemoved: !state.visibleToasts.some(t2 => t.key === t2.key)
67+
}));
68+
69+
let removedFocusedToastIndex = allToasts.findIndex(t => t.i === focusedToast.current);
70+
71+
// If the focused toast was removed, focus the next or previous toast.
72+
if (removedFocusedToastIndex > -1) {
73+
let i = 0;
74+
let nextToast;
75+
let prevToast;
76+
while (i <= removedFocusedToastIndex) {
77+
if (!allToasts[i].isRemoved) {
78+
prevToast = Math.max(0, i - 1);
79+
}
80+
i++;
81+
}
82+
while (i < allToasts.length) {
83+
if (!allToasts[i].isRemoved) {
84+
nextToast = i - 1;
85+
break;
86+
}
87+
i++;
88+
}
89+
90+
// in the case where it's one toast at a time, both will be undefined, but we know the index must be 0
91+
if (prevToast === undefined && nextToast === undefined) {
92+
prevToast = 0;
93+
}
94+
95+
// prioritize going to newer toasts
96+
if (prevToast >= 0 && prevToast < toasts.current.length) {
97+
focusWithoutScrolling(toasts.current[prevToast]);
98+
} else if (nextToast >= 0 && nextToast < toasts.current.length) {
99+
focusWithoutScrolling(toasts.current[nextToast]);
100+
}
101+
}
102+
103+
prevVisibleToasts.current = state.visibleToasts;
104+
}, [state.visibleToasts, ref]);
105+
40106
let lastFocused = useRef(null);
41107
let {focusWithinProps} = useFocusWithin({
42108
onFocusWithin: (e) => {
@@ -49,10 +115,22 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
49115
}
50116
});
51117

52-
// When the region unmounts, restore focus to the last element that had focus
53-
// before the user moved focus into the region.
54-
// TODO: handle when the element has unmounted like FocusScope does?
55-
// eslint-disable-next-line arrow-body-style
118+
// When the number of visible toasts becomes 0 or the region unmounts,
119+
// restore focus to the last element that had focus before the user moved focus
120+
// into the region. FocusScope restore focus doesn't update whenever the focus
121+
// moves in, it only happens once, so we correct it.
122+
// Because we're in a hook, we can't control if the user unmounts or not.
123+
useEffect(() => {
124+
if (state.visibleToasts.length === 0 && lastFocused.current && document.body.contains(lastFocused.current)) {
125+
if (getInteractionModality() === 'pointer') {
126+
focusWithoutScrolling(lastFocused.current);
127+
} else {
128+
lastFocused.current.focus();
129+
}
130+
lastFocused.current = null;
131+
}
132+
}, [ref, state.visibleToasts.length]);
133+
56134
useEffect(() => {
57135
return () => {
58136
if (lastFocused.current && document.body.contains(lastFocused.current)) {
@@ -61,6 +139,7 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
61139
} else {
62140
lastFocused.current.focus();
63141
}
142+
lastFocused.current = null;
64143
}
65144
};
66145
}, [ref]);
@@ -73,7 +152,16 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
73152
// - allows focus even outside a containing focus scope
74153
// - doesn’t dismiss overlays when clicking on it, even though it is outside
75154
// @ts-ignore
76-
'data-react-aria-top-layer': true
155+
'data-react-aria-top-layer': true,
156+
// listen to focus events separate from focuswithin because that will only fire once
157+
// and we need to follow all focus changes
158+
onFocus: (e) => {
159+
let target = e.target.closest('[role="alertdialog"]');
160+
focusedToast.current = toasts.current.findIndex(t => t === target);
161+
},
162+
onBlur: () => {
163+
focusedToast.current = -1;
164+
}
77165
})
78166
};
79167
}

packages/@react-aria/toast/stories/Example.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ function ToastRegion() {
4343
function Toast(props) {
4444
let state = useContext(ToastContext);
4545
let ref = useRef(null);
46-
let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref);
46+
let {toastProps, contentProps, titleProps, closeButtonProps} = useToast(props, state, ref);
4747
let buttonRef = useRef();
4848
let {buttonProps} = useButton(closeButtonProps, buttonRef);
4949

5050
return (
5151
<div {...toastProps} ref={ref} style={{margin: 20, display: 'flex', gap: 5}}>
52-
<div {...titleProps}>{props.toast.content}</div>
52+
<div {...contentProps}>
53+
<div {...titleProps}>{props.toast.content}</div>
54+
</div>
5355
<button {...buttonProps} ref={buttonRef}>x</button>
5456
</div>
5557
);

packages/@react-aria/toast/test/useToast.test.js

+57-4
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {renderHook} from '@react-spectrum/test-utils-internal';
14-
import {useRef} from 'react';
13+
import {act, fireEvent, pointerMap, render, renderHook, within} from '@react-spectrum/test-utils-internal';
14+
import {composeStories} from '@storybook/react';
15+
import React, {useRef} from 'react';
16+
import * as stories from '../stories/useToast.stories';
17+
import userEvent from '@testing-library/user-event';
1518
import {useToast} from '../';
1619

20+
let {Default} = composeStories(stories);
21+
1722
describe('useToast', () => {
1823
let close = jest.fn();
1924

@@ -27,9 +32,10 @@ describe('useToast', () => {
2732
};
2833

2934
it('handles defaults', function () {
30-
let {closeButtonProps, toastProps, titleProps} = renderToastHook({}, {close});
35+
let {closeButtonProps, toastProps, contentProps, titleProps} = renderToastHook({}, {close});
3136

32-
expect(toastProps.role).toBe('alert');
37+
expect(toastProps.role).toBe('alertdialog');
38+
expect(contentProps.role).toBe('alert');
3339
expect(closeButtonProps['aria-label']).toBe('Close');
3440
expect(typeof closeButtonProps.onPress).toBe('function');
3541
expect(titleProps.id).toEqual(toastProps['aria-labelledby']);
@@ -43,3 +49,50 @@ describe('useToast', () => {
4349
expect(close).toHaveBeenCalledWith(1);
4450
});
4551
});
52+
53+
describe('single toast at a time', () => {
54+
function fireAnimationEnd(alert) {
55+
let e = new Event('animationend', {bubbles: true, cancelable: false});
56+
e.animationName = 'fade-out';
57+
fireEvent(alert, e);
58+
}
59+
60+
let user;
61+
beforeAll(() => {
62+
user = userEvent.setup({delay: null, pointerMap});
63+
});
64+
65+
beforeEach(() => {
66+
jest.useFakeTimers();
67+
});
68+
69+
afterEach(() => {
70+
act(() => jest.runAllTimers());
71+
});
72+
73+
it('moves focus to the next toast when it appears', async () => {
74+
let tree = render(<Default />);
75+
// eslint-disable-next-line
76+
let [bLow, bMedium, bHigh] = tree.getAllByRole('button');
77+
78+
await user.click(bHigh);
79+
await user.click(bLow);
80+
81+
let toast = tree.getByRole('alertdialog');
82+
expect(toast.textContent).toContain('High');
83+
let closeButton = within(toast).getByRole('button');
84+
await user.click(closeButton);
85+
fireAnimationEnd(toast);
86+
87+
toast = tree.getByRole('alertdialog');
88+
expect(toast.textContent).toContain('Low');
89+
expect(toast).toHaveFocus();
90+
91+
closeButton = within(toast).getByRole('button');
92+
await user.click(closeButton);
93+
fireAnimationEnd(toast);
94+
95+
expect(tree.queryByRole('alertdialog')).toBeNull();
96+
expect(bLow).toHaveFocus();
97+
});
98+
});

packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ describe('Breadcrumbs', function () {
333333
expect(onAction).toHaveBeenCalledWith('Folder 1');
334334

335335
// menu item
336-
expect(item1[1]).not.toHaveAttribute('role');
336+
expect(item1[1]).toHaveAttribute('role', 'none');
337337
await user.click(item1[1]);
338338
expect(onAction).toHaveBeenCalledWith('Folder 1');
339339
});

packages/@react-spectrum/text/src/Text.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function Text(props: TextProps, ref: DOMRef) {
2626
let domRef = useDOMRef(ref);
2727

2828
return (
29-
<span {...filterDOMProps(otherProps)} {...styleProps} ref={domRef}>
29+
<span role="none" {...filterDOMProps(otherProps)} {...styleProps} ref={domRef}>
3030
{children}
3131
</span>
3232
);

0 commit comments

Comments
 (0)