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

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 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
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
@@ -1,4 +1,4 @@
{
"close": "Close",
"notifications": "Notifications"
"notifications": "{count, plural, one {# notification} other {# notifications}}."
}
64 changes: 35 additions & 29 deletions packages/@react-aria/toast/src/useToast.ts
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 {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,
/** 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,47 @@ 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
useEffect(() => {
return () => {
if (focusOnUnmount.current) {
focusOnUnmount.current.focus();
}
};
}, [ref]);
// When toast unmounts, move focus to the next or previous toast.
// There's potentially a small problem here if two toasts next to each other unmount at the same time.
// It may be better to track this at the toast container level.
// We may also be assuming that other implemenations will have the same focus behavior.
// let container = useRef(null);
// useLayoutEffect(() => {
// let toast = ref.current;
// container.current = toast.closest('[role="region"]');
// let focusManager = createFocusManager(container);
// return () => {
// if (toast && toast.contains(document.activeElement)) {
// const from = document.activeElement?.closest('[role="alertdialog"]') || document.activeElement;
// const accept = (node:Element) => node.getAttribute('role') === 'alertdialog';
// let nextItemFocused = focusManager.focusNext({from, accept});
// if (!nextItemFocused || Object.keys(nextItemFocused).length === 0) {
// focusManager.focusPrevious({from, accept});
// }
// }
// };
// // runs only for unmount
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, []);

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

return {
toastProps: {
role: 'alert',
role: 'alertdialog',
'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'
},
titleProps: {
id: titleId
Expand Down
57 changes: 51 additions & 6 deletions packages/@react-aria/toast/src/useToastRegion.ts
@@ -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,51 @@ 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) {
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)) {
return;
}
// Get a list of all removed toasts by index.
let removedToasts = prevVisibleToasts.current
.map((t, i) => ({...t, i}))
.filter(((t) => !state.visibleToasts.some(t2 => t.key === t2.key)));

// If the focused toast was removed, focus the next or previous toast.
if (removedToasts.some(t => t.i === focusedToast.current)) {
let nextToast = focusedToast.current;
if (nextToast >= toasts.current.length) {
nextToast = toasts.current.length - 1;
}
if (nextToast !== -1) {
focusWithoutScrolling(toasts.current[nextToast]);
majornista marked this conversation as resolved.
Show resolved Hide resolved
}
}

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

let lastFocused = useRef(null);
let {focusWithinProps} = useFocusWithin({
onFocusWithin: (e) => {
Expand All @@ -50,9 +87,8 @@ 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
// 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.
useEffect(() => {
return () => {
if (lastFocused.current && document.body.contains(lastFocused.current)) {
Expand All @@ -73,7 +109,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;
}
})
};
}
5 changes: 3 additions & 2 deletions packages/@react-aria/toast/test/useToast.test.js
Expand Up @@ -27,9 +27,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 Down
46 changes: 25 additions & 21 deletions packages/@react-spectrum/toast/src/Toast.tsx
Expand Up @@ -18,12 +18,13 @@ import {DOMRef} from '@react-types/shared';
import InfoMedium from '@spectrum-icons/ui/InfoMedium';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {mergeProps} from '@react-aria/utils';
import {QueuedToast, ToastState} from '@react-stately/toast';
import React, {useContext} from 'react';
import React from 'react';
import styles from '@adobe/spectrum-css-temp/components/toast/vars.css';
import SuccessMedium from '@spectrum-icons/ui/SuccessMedium';
import toastContainerStyles from './toastContainer.css';
import {ToasterContext} from './Toaster';
import {useFocusRing} from '@react-aria/focus';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
import {useToast} from '@react-aria/toast';

Expand Down Expand Up @@ -67,14 +68,15 @@ function Toast(props: SpectrumToastProps, ref: DOMRef<HTMLDivElement>) {
let {
closeButtonProps,
titleProps,
toastProps
toastProps,
contentProps
} = useToast(props, state, domRef);
let {styleProps} = useStyleProps(otherProps);

let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/toast');
let iconLabel = variant && variant !== 'neutral' ? stringFormatter.format(variant) : null;
let Icon = ICONS[variant];
let isFocusVisible = useContext(ToasterContext);
let {isFocusVisible, focusProps} = useFocusRing();

const handleAction = () => {
if (onAction) {
Expand All @@ -89,7 +91,7 @@ function Toast(props: SpectrumToastProps, ref: DOMRef<HTMLDivElement>) {
return (
<div
{...styleProps}
{...toastProps}
{...mergeProps(toastProps, focusProps)}
ref={domRef}
className={classNames(styles,
'spectrum-Toast',
Expand All @@ -98,7 +100,7 @@ function Toast(props: SpectrumToastProps, ref: DOMRef<HTMLDivElement>) {
classNames(
toastContainerStyles,
'spectrum-Toast',
{'focus-ring': props.toast.key === state.visibleToasts[0]?.key && isFocusVisible}
{'focus-ring': isFocusVisible}
)
)}
style={{
Expand All @@ -111,22 +113,24 @@ function Toast(props: SpectrumToastProps, ref: DOMRef<HTMLDivElement>) {
state.remove(key);
}
}}>
{Icon &&
<Icon
aria-label={iconLabel}
UNSAFE_className={classNames(styles, 'spectrum-Toast-typeIcon')} />
}
<div className={classNames(styles, 'spectrum-Toast-body')}>
<div className={classNames(styles, 'spectrum-Toast-content')} {...titleProps}>{children}</div>
{actionLabel &&
<Button
onPress={handleAction}
UNSAFE_className={classNames(styles, 'spectrum-Button')}
variant="secondary"
staticColor="white">
{actionLabel}
</Button>
<div {...contentProps} className={classNames(toastContainerStyles, 'spectrum-Toast-contentWrapper')}>
{Icon &&
<Icon
aria-label={iconLabel}
UNSAFE_className={classNames(styles, 'spectrum-Toast-typeIcon')} />
}
<div className={classNames(styles, 'spectrum-Toast-body')} role="presentation">
<div className={classNames(styles, 'spectrum-Toast-content')} role="presentation" {...titleProps}>{children}</div>
{actionLabel &&
<Button
onPress={handleAction}
UNSAFE_className={classNames(styles, 'spectrum-Button')}
variant="secondary"
staticColor="white">
{actionLabel}
</Button>
}
</div>
</div>
<div className={classNames(styles, 'spectrum-Toast-buttons')}>
<ClearButton {...closeButtonProps} variant="overBackground">
Expand Down
20 changes: 14 additions & 6 deletions packages/@react-spectrum/toast/src/ToastContainer.tsx
Expand Up @@ -11,8 +11,10 @@
*/

import {AriaToastRegionProps} from '@react-aria/toast';
import {classNames} from '@react-spectrum/utils';
import React, {ReactElement, useEffect, useRef} from 'react';
import {SpectrumToastValue, Toast} from './Toast';
import toastContainerStyles from './toastContainer.css';
import {Toaster} from './Toaster';
import {ToastOptions, ToastQueue, useToastQueue} from '@react-stately/toast';
import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js';
Expand Down Expand Up @@ -103,15 +105,21 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
// Only render if this is the active toast provider instance, and there are visible toasts.
let activeToastContainer = useActiveToastContainer();
let state = useToastQueue(getGlobalToastQueue());

if (ref === activeToastContainer && state.visibleToasts.length > 0) {
return (
<Toaster state={state} {...props}>
{state.visibleToasts.map((toast) => (
<Toast
key={toast.key}
toast={toast}
state={state} />
))}
<ol reversed className={classNames(toastContainerStyles, 'spectrum-ToastContainer-list')}>
{state.visibleToasts.map((toast) => (
<li
key={toast.key}
className={classNames(toastContainerStyles, 'spectrum-ToastContainer-listitem')}>
<Toast
toast={toast}
state={state} />
</li>
))}
</ol>
</Toaster>
);
}
Expand Down
31 changes: 17 additions & 14 deletions packages/@react-spectrum/toast/src/Toaster.tsx
Expand Up @@ -12,13 +12,13 @@

import {AriaToastRegionProps, useToastRegion} from '@react-aria/toast';
import {classNames} from '@react-spectrum/utils';
import {FocusScope, useFocusRing} from '@react-aria/focus';
import {mergeProps} from '@react-aria/utils';
import {Provider} from '@react-spectrum/provider';
import React, {createContext, ReactElement, ReactNode, useRef} from 'react';
import ReactDOM from 'react-dom';
import toastContainerStyles from './toastContainer.css';
import {ToastState} from '@react-stately/toast';
import {useFocusRing} from '@react-aria/focus';

interface ToastContainerProps extends AriaToastRegionProps {
children: ReactNode,
Expand All @@ -39,19 +39,22 @@ export function Toaster(props: ToastContainerProps): ReactElement {

let contents = (
<Provider UNSAFE_style={{background: 'transparent'}}>
<ToasterContext.Provider value={isFocusVisible}>
<div
{...mergeProps(regionProps, focusProps)}
ref={ref}
data-position="bottom"
data-placement="center"
className={classNames(
toastContainerStyles,
'react-spectrum-ToastContainer'
)}>
{children}
</div>
</ToasterContext.Provider>
<FocusScope>
<ToasterContext.Provider value={isFocusVisible}>
<div
{...mergeProps(regionProps, focusProps)}
ref={ref}
data-position="bottom"
data-placement="center"
className={classNames(
toastContainerStyles,
'react-spectrum-ToastContainer',
{'focus-ring': isFocusVisible}
)}>
{children}
</div>
</ToasterContext.Provider>
</FocusScope>
</Provider>
);

Expand Down