Skip to content
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

Toast focus management take 2 #6223

Merged
merged 32 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a685b76
Toast focus management
snowystinger Apr 15, 2024
db1f278
Merge branch 'main' into toast-focus-take-2
majornista Apr 23, 2024
5d658cb
Merge branch 'main' into toast-focus-take-2
majornista Apr 26, 2024
5c8bf5c
lint fix: remove duplicated import
majornista Apr 29, 2024
744bfb4
Merge branch 'main' into toast-focus-take-2
majornista Apr 30, 2024
aae7f0f
Merge branch 'main' into toast-focus-take-2
snowystinger May 2, 2024
6d52a1b
Merge branch 'main' into toast-focus-take-2
snowystinger May 2, 2024
b78fd33
prevent a double announcement
snowystinger May 3, 2024
3f0ab0c
fix test
snowystinger May 3, 2024
8a2eb3f
Merge branch 'main' into toast-focus-take-2
snowystinger May 3, 2024
f718eaf
Merge branch 'main' into toast-focus-take-2
snowystinger May 3, 2024
3d38fdd
Correct the tab order
snowystinger May 3, 2024
344d3e0
Merge branch 'toast-focus-take-2' of github.com:adobe/react-spectrum …
snowystinger May 3, 2024
abca7f9
cleanup
snowystinger May 3, 2024
731f371
Merge branch 'main' into toast-focus-take-2
snowystinger May 8, 2024
2992e55
fix logic
snowystinger May 8, 2024
a23f010
fix logic when removing first or last Toast
majornista May 9, 2024
a62255b
Merge branch 'main' into toast-focus-take-2
snowystinger May 10, 2024
d619d72
add test
snowystinger May 10, 2024
98fdb0c
Fix and test single toast visible focus mgmt
snowystinger May 10, 2024
a025eb6
Merge branch 'main' into toast-focus-take-2
snowystinger May 16, 2024
e22e319
Fix Toast alert announcement using NVDA
majornista May 16, 2024
66ff206
Merge branch 'main' into toast-focus-take-2
snowystinger May 20, 2024
1752bf3
make tests more realistic, fix already rendered
snowystinger May 20, 2024
60692de
Always show toasts contents when switching toast containers
snowystinger May 20, 2024
48198c6
move hidden logic to hook
snowystinger May 21, 2024
0f25164
Merge branch 'main' into toast-focus-take-2
snowystinger May 21, 2024
0ccee46
Merge branch 'main' into toast-focus-take-2
snowystinger May 21, 2024
fd5ed10
Merge branch 'main' into toast-focus-take-2
snowystinger May 22, 2024
3a65e51
clarify difficult code
snowystinger May 24, 2024
5491f7d
Merge branch 'main' into toast-focus-take-2
snowystinger May 24, 2024
73a2157
Merge branch 'main' into toast-focus-take-2
snowystinger May 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@react-aria/toast/intl/en-US.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"close": "Close",
"notifications": "Notifications"
"notifications": "{count, plural, one {# notification} other {# notifications}}."
}
50 changes: 22 additions & 28 deletions packages/@react-aria/toast/src/useToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement} from '@react-types/s
// @ts-ignore
import intlMessages from '../intl/*.json';
import {QueuedToast, ToastState} from '@react-stately/toast';
import {RefObject, useEffect, useRef} from 'react';
import {useId, useLayoutEffect, useSlotId} from '@react-aria/utils';
import React, {RefObject, useEffect} from 'react';
import {useId, useSlotId} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

export interface AriaToastProps<T> extends AriaLabelingProps {
Expand All @@ -25,8 +25,10 @@ export interface AriaToastProps<T> extends AriaLabelingProps {
}

export interface ToastAria {
/** Props for the toast container element. */
/** Props for the toast container, non-modal dialog element. */
toastProps: DOMAttributes,
/** Props for the toast content alert message. */
contentProps: DOMAttributes,
Copy link
Member

Choose a reason for hiding this comment

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

Do we still need both contentProps and titleProps as separate elements or are they the same thing now?

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, it still provides a useful distinction for the aria-labelledby. You can put things like the icon on the outside of the title so it isn't read off all the time

/** Props for the toast title element. */
titleProps: DOMAttributes,
/** Props for the toast description element, if any. */
Expand All @@ -39,6 +41,7 @@ export interface ToastAria {
* Provides the behavior and accessibility implementation for a toast component.
* Toasts display brief, temporary notifications of actions, errors, or other events in an application.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Copy link
Member

Choose a reason for hiding this comment

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

Can we just remove the ref from the args even though it would be breaking? It is still in beta so might be ok?

Copy link
Member Author

Choose a reason for hiding this comment

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

it's more of a, we've needed to add it back in later in other cases. I'd prefer to know we'll have access to it

export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref: RefObject<FocusableElement>): ToastAria {
let {
key,
Expand All @@ -58,44 +61,35 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
};
}, [timer, timeout]);

// Restore focus to the toast container on unmount.
// If there are no more toasts, the container will be unmounted
// and will restore focus to wherever focus was before the user
// focused the toast region.
let focusOnUnmount = useRef(null);
useLayoutEffect(() => {
let container = ref.current.closest('[role=region]') as HTMLElement;
return () => {
if (container && container.contains(document.activeElement)) {
// Focus must be delayed for focus ring to appear, but we can't wait
// until useEffect cleanup to check if focus was inside the container.
focusOnUnmount.current = container;
}
};
}, [ref]);

// eslint-disable-next-line
let [isEntered, setIsEntered] = React.useState(false);
useEffect(() => {
return () => {
if (focusOnUnmount.current) {
focusOnUnmount.current.focus();
}
};
}, [ref]);
if (animation === 'entering') {
setIsEntered(true);
}
}, [animation]);

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

return {
toastProps: {
role: 'alert',
role: 'alertdialog',
'aria-modal': 'false',
Copy link
Member

Choose a reason for hiding this comment

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

non-action, just interested that this is specifically set to false. I'm just used to omitting the attribute but I'm guessing this explicit false made a difference in the screen reader behavior?

Copy link
Member Author

Choose a reason for hiding this comment

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

@majornista correct me if I'm wrong, but this is just to possibly future proof? since alertdialog would be considered a modal by default, and so we use aria-modal to communicate that it's not, in fact, a modal

Copy link
Collaborator

Choose a reason for hiding this comment

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

aria-modal="false" doesn't really make a difference for screen reader behavior; the default is for a dialog to be non-modal. However, the APG design pattern describes an alertdialog as modal, so I thought it best to be explicit:

An alert dialog is a modal dialog that interrupts the user's workflow to communicate an important message and acquire a response. Examples include action confirmation prompts and error message confirmations. The alertdialog role enables assistive technologies and browsers to distinguish alert dialogs from other dialogs so they have the option of giving alert dialogs special treatment, such as playing a system alert sound.

I use role="alertdialog", as a container for the toast message content, which has role="alert" to announce as a live region, and the button to dismiss the Toast, for a few reasons.

  1. A Toast is kind of a tiny non-modal dialog, so it didn't seem like much of a stretch.
  2. Using a dialog as a container helps disambiguate between the Dismiss buttons when more than one Toast is displayed.
  3. When we close a Toast, unlike the Dismiss button for an adjacent Toast, the dialog provides a uniquely labeled widget element to receive focus, and is easy to style using the React-Spectrum focus ring.
  4. Depending on the assistive technology being used, an alert dialog will open with an earcon, which I see as nice feature to distinguish a Toast from other content the screen reader may be announcing.

'aria-label': props['aria-label'],
'aria-labelledby': props['aria-labelledby'] || titleId,
'aria-describedby': props['aria-describedby'] || descriptionId,
'aria-details': props['aria-details'],
// Hide toasts that are animating out so VoiceOver doesn't announce them.
'aria-hidden': animation === 'exiting' ? 'true' : undefined
'aria-hidden': animation === 'exiting' ? 'true' : undefined,
tabIndex: 0
},
contentProps: {
role: 'alert',
'aria-atomic': 'true',
style: {
visibility: isEntered || animation === null ? 'visible' : 'hidden'
}
},
titleProps: {
id: titleId
Expand Down
102 changes: 95 additions & 7 deletions packages/@react-aria/toast/src/useToastRegion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {AriaLabelingProps, DOMAttributes} from '@react-types/shared';
import {focusWithoutScrolling, mergeProps} from '@react-aria/utils';
import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils';
import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions';
// @ts-ignore
import intlMessages from '../intl/*.json';
Expand Down Expand Up @@ -29,14 +29,80 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/toast');
let {landmarkProps} = useLandmark({
role: 'region',
'aria-label': props['aria-label'] || stringFormatter.format('notifications')
'aria-label': props['aria-label'] || stringFormatter.format('notifications', {count: state.visibleToasts.length})
}, ref);

let {hoverProps} = useHover({
onHoverStart: state.pauseAll,
onHoverEnd: state.resumeAll
});

// Manage focus within the toast region.
// If a focused containing toast is removed, move focus to the next toast, or the previous toast if there is no next toast.
// We might be making an assumption with how this works if someone implements the priority queue differently, or
// if they only show one toast at a time.
let toasts = useRef([]);
let prevVisibleToasts = useRef(state.visibleToasts);
let focusedToast = useRef(null);
useLayoutEffect(() => {
// If no toast has focus, then don't do anything.
if (focusedToast.current === -1 || state.visibleToasts.length === 0) {
toasts.current = [];
prevVisibleToasts.current = state.visibleToasts;
return;
}
toasts.current = [...ref.current.querySelectorAll('[role="alertdialog"]')];
// If the visible toasts haven't changed, we don't need to do anything.
if (prevVisibleToasts.current.length === state.visibleToasts.length
&& state.visibleToasts.every((t, i) => t.key === prevVisibleToasts.current[i].key)) {
prevVisibleToasts.current = state.visibleToasts;
return;
}
// Get a list of all toasts by index and add info if they are removed.
let allToasts = prevVisibleToasts.current
.map((t, i) => ({
...t,
i,
isRemoved: !state.visibleToasts.some(t2 => t.key === t2.key)
}));

let removedFocusedToastIndex = allToasts.findIndex(t => t.i === focusedToast.current);

// If the focused toast was removed, focus the next or previous toast.
if (removedFocusedToastIndex > -1) {
let i = 0;
let nextToast;
let prevToast;
while (i <= removedFocusedToastIndex) {
if (!allToasts[i].isRemoved) {
prevToast = Math.max(0, i - 1);
}
i++;
}
while (i < allToasts.length) {
if (!allToasts[i].isRemoved) {
nextToast = i - 1;
break;
}
i++;
}

// in the case where it's one toast at a time, both will be undefined, but we know the index must be 0
if (prevToast === undefined && nextToast === undefined) {
prevToast = 0;
}

// prioritize going to newer toasts
if (prevToast >= 0 && prevToast < toasts.current.length) {
focusWithoutScrolling(toasts.current[prevToast]);
} else if (nextToast >= 0 && nextToast < toasts.current.length) {
focusWithoutScrolling(toasts.current[nextToast]);
majornista marked this conversation as resolved.
Show resolved Hide resolved
}
}

prevVisibleToasts.current = state.visibleToasts;
}, [state.visibleToasts, ref]);
Copy link
Member

Choose a reason for hiding this comment

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

This logic is quite similar to what we have for other collection components when removing rows. Wonder if we could centralize this at some point.


let lastFocused = useRef(null);
let {focusWithinProps} = useFocusWithin({
onFocusWithin: (e) => {
Expand All @@ -49,10 +115,22 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
}
});

// When the region unmounts, restore focus to the last element that had focus
// before the user moved focus into the region.
// TODO: handle when the element has unmounted like FocusScope does?
// eslint-disable-next-line arrow-body-style
// When the number of visible toasts becomes 0 or the region unmounts,
// restore focus to the last element that had focus before the user moved focus
// into the region. FocusScope restore focus doesn't update whenever the focus
// moves in, it only happens once, so we correct it.
// Because we're in a hook, we can't control if the user unmounts or not.
useEffect(() => {
if (state.visibleToasts.length === 0 && lastFocused.current && document.body.contains(lastFocused.current)) {
if (getInteractionModality() === 'pointer') {
focusWithoutScrolling(lastFocused.current);
} else {
lastFocused.current.focus();
}
lastFocused.current = null;
}
}, [ref, state.visibleToasts.length]);

useEffect(() => {
return () => {
if (lastFocused.current && document.body.contains(lastFocused.current)) {
Expand All @@ -61,6 +139,7 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
} else {
lastFocused.current.focus();
}
lastFocused.current = null;
}
};
}, [ref]);
Expand All @@ -73,7 +152,16 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
// - allows focus even outside a containing focus scope
// - doesn’t dismiss overlays when clicking on it, even though it is outside
// @ts-ignore
'data-react-aria-top-layer': true
'data-react-aria-top-layer': true,
// listen to focus events separate from focuswithin because that will only fire once
// and we need to follow all focus changes
onFocus: (e) => {
let target = e.target.closest('[role="alertdialog"]');
focusedToast.current = toasts.current.findIndex(t => t === target);
},
onBlur: () => {
focusedToast.current = -1;
}
})
};
}
6 changes: 4 additions & 2 deletions packages/@react-aria/toast/stories/Example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ function ToastRegion() {
function Toast(props) {
let state = useContext(ToastContext);
let ref = useRef(null);
let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref);
let {toastProps, contentProps, titleProps, closeButtonProps} = useToast(props, state, ref);
let buttonRef = useRef();
let {buttonProps} = useButton(closeButtonProps, buttonRef);

return (
<div {...toastProps} ref={ref} style={{margin: 20, display: 'flex', gap: 5}}>
<div {...titleProps}>{props.toast.content}</div>
<div {...contentProps}>
<div {...titleProps}>{props.toast.content}</div>
</div>
<button {...buttonProps} ref={buttonRef}>x</button>
</div>
);
Expand Down
61 changes: 57 additions & 4 deletions packages/@react-aria/toast/test/useToast.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@
* governing permissions and limitations under the License.
*/

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

let {Default} = composeStories(stories);

describe('useToast', () => {
let close = jest.fn();

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

it('handles defaults', function () {
let {closeButtonProps, toastProps, titleProps} = renderToastHook({}, {close});
let {closeButtonProps, toastProps, contentProps, titleProps} = renderToastHook({}, {close});

expect(toastProps.role).toBe('alert');
expect(toastProps.role).toBe('alertdialog');
expect(contentProps.role).toBe('alert');
expect(closeButtonProps['aria-label']).toBe('Close');
expect(typeof closeButtonProps.onPress).toBe('function');
expect(titleProps.id).toEqual(toastProps['aria-labelledby']);
Expand All @@ -43,3 +49,50 @@ describe('useToast', () => {
expect(close).toHaveBeenCalledWith(1);
});
});

describe('single toast at a time', () => {
function fireAnimationEnd(alert) {
let e = new Event('animationend', {bubbles: true, cancelable: false});
e.animationName = 'fade-out';
fireEvent(alert, e);
}

let user;
beforeAll(() => {
user = userEvent.setup({delay: null, pointerMap});
});

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
act(() => jest.runAllTimers());
});

it('moves focus to the next toast when it appears', async () => {
let tree = render(<Default />);
// eslint-disable-next-line
let [bLow, bMedium, bHigh] = tree.getAllByRole('button');

await user.click(bHigh);
await user.click(bLow);

let toast = tree.getByRole('alertdialog');
expect(toast.textContent).toContain('High');
let closeButton = within(toast).getByRole('button');
await user.click(closeButton);
fireAnimationEnd(toast);

toast = tree.getByRole('alertdialog');
expect(toast.textContent).toContain('Low');
expect(toast).toHaveFocus();

closeButton = within(toast).getByRole('button');
await user.click(closeButton);
fireAnimationEnd(toast);

expect(tree.queryByRole('alertdialog')).toBeNull();
expect(bLow).toHaveFocus();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ describe('Breadcrumbs', function () {
expect(onAction).toHaveBeenCalledWith('Folder 1');

// menu item
expect(item1[1]).not.toHaveAttribute('role');
expect(item1[1]).toHaveAttribute('role', 'none');
await user.click(item1[1]);
expect(onAction).toHaveBeenCalledWith('Folder 1');
});
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/text/src/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function Text(props: TextProps, ref: DOMRef) {
let domRef = useDOMRef(ref);

return (
<span {...filterDOMProps(otherProps)} {...styleProps} ref={domRef}>
<span role="none" {...filterDOMProps(otherProps)} {...styleProps} ref={domRef}>
Copy link
Member

Choose a reason for hiding this comment

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

I see this was added to prevent double announcements, but where do we use Text in toasts?

Copy link
Member

Choose a reason for hiding this comment

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

Ah I see, the Button for the toast action. Will have to double check everywhere we use Text to see if there are any changes to the screen reader performance

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, i haven't noticed anything yet

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, this was something I noticed in testing with VoiceOver. When a Toast included an action button, the button text was announced twice. This has to do with how live regions work, announcing both element "additions" and "text." VoiceOver seem to interpret the Text component's span element child of the Button as an addition, and announces the text more than once.

{children}
</span>
);
Expand Down