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

chore: remove toast priority queue #7805

Merged
merged 8 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 4 additions & 31 deletions packages/@react-aria/toast/docs/useToast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,13 @@ keywords: [toast, notifications, alert, aria]
There is no built in way to display toast notifications in HTML. <TypeLink links={docs.links} type={docs.exports.useToastRegion} /> and <TypeLink links={docs.links} type={docs.exports.useToast} /> help achieve accessible toasts that can be styled as needed.

* **Accessible** – Toasts follow the [ARIA alertdialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/). They are rendered in a [landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/), which keyboard and screen reader users can easily jump to when an alert is announced.
* **Focus management** – When a toast unmounts, focus is moved to the next toast if any. Otherwise, focus is restored to where it was before navigating to the toast region.
* **Priority queue** – Toasts are displayed according to a priority queue, displaying a configurable number of toasts at a time. The queue can either be owned by a provider component, or global.
* **Focus management** – When a toast unmounts, focus is moved to the next toast if any. Otherwise, focus is restored to where it was before navigating to the toast region. Tabbing through the Toast region will move from newest to oldest.

## Anatomy

<Anatomy role="img" aria-label="Toast anatomy diagram, showing the toast's title and close button within the toast region." />

A toast region is an [ARIA landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) labeled "Notifications" by default. A toast region contains one or more visible toasts, in priority order. When the limit is reached, additional toasts are queued until the user dismisses one. Each toast is a non-modal ARIA [alertdialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/), containing the content of the notification and a close button.
A toast region is an [ARIA landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) labeled "Notifications" by default. A toast region contains one or more visible toasts, in chronological order. When the limit is reached, additional toasts are queued until the user dismisses one. Each toast is a non-modal ARIA [alertdialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/), containing the content of the notification and a close button.

Landmark regions including the toast container can be navigated using the keyboard by pressing the <Keyboard>F6</Keyboard> key to move forward, and the <Keyboard>Shift</Keyboard> + <Keyboard>F6</Keyboard> key to move backward. This provides an easy way for keyboard users to jump to the toasts from anywhere in the app. When the last toast is closed, keyboard focus is restored.

Expand Down Expand Up @@ -169,7 +168,7 @@ function Toast<T extends React.ReactNode>({state, ...props}: ToastProps<T>) {
bottom: 16px;
right: 16px;
display: flex;
flex-direction: column;
flex-direction: column-reverse;
gap: 8px;
}

Expand Down Expand Up @@ -231,32 +230,6 @@ function Button(props) {

The following examples show how to use the `ToastProvider` component created in the above example.

### Toast priorities

Toasts are displayed according to a priority queue. The priority of a toast can be set using the `priority` option, passed to the `state.add` function. Priorities are arbitrary numbers defined by your implementation.

```tsx example
<ToastProvider>
{state => (<>
{/*- begin highlight -*/}
<Button onPress={() => state.add('Toasting…', {priority: 1})}>
{/*- end highlight -*/}
Show low priority toast
</Button>
{/*- begin highlight -*/}
<Button onPress={() => state.add('Toast is done!', {priority: 2})}>
{/*- end highlight -*/}
Show medium priority toast
</Button>
{/*- begin highlight -*/}
<Button onPress={() => state.add('Toast is burned!', {priority: 3})}>
{/*- end highlight -*/}
Show high priority toast
</Button>
</>)}
</ToastProvider>
```

### Auto-dismiss

Toasts support a `timeout` option to automatically hide them after a certain amount of time. For accessibility, toasts should have a minimum timeout of 5 seconds to give users enough time to read them. If a toast includes action buttons or other interactive elements it should not auto dismiss. In addition, timers will automatically pause when the user focuses or hovers over a toast.
Expand All @@ -267,7 +240,7 @@ Be sure only to automatically dismiss toasts when the information is not importa
<ToastProvider>
{state => (
///- begin highlight -///
<Button onPress={() => state.add('Toast is done!', {timeout: 5000})}>
<Button onPress={() => state.add('Toast still toasting!', {timeout: 5000})}>
{/*- end highlight -*/}
Show toast
</Button>
Expand Down
2 changes: 0 additions & 2 deletions packages/@react-aria/toast/src/useToastRegion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState

// 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<FocusableElement[]>([]);
let prevVisibleToasts = useRef(state.visibleToasts);
let focusedToast = useRef<number | null>(null);
Expand Down
4 changes: 1 addition & 3 deletions packages/@react-aria/toast/stories/useToast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ let count = 0;
export const Default = args => (
<ToastContainer {...args}>
{state => (<>
<button onClick={() => state.add('High ' + ++count, {priority: 10, timeout: args.timeout})}>Add high priority toast</button>
<button onClick={() => state.add('Medium ' + ++count, {priority: 5, timeout: args.timeout})}>Add medium priority toast</button>
<button onClick={() => state.add('Low ' + ++count, {priority: 1, timeout: args.timeout})}>Add low priority toast</button>
<button onClick={() => state.add('Mmmmm toast ' + ++count, {timeout: args.timeout})}>Add toast</button>
</>)}
</ToastContainer>
);
13 changes: 6 additions & 7 deletions packages/@react-aria/toast/test/useToast.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,24 @@ describe('single toast at a time', () => {

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');
let button = tree.getByRole('button');

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

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

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

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

expect(tree.queryByRole('alertdialog')).toBeNull();
expect(bLow).toHaveFocus();
expect(button).toHaveFocus();
});
});
6 changes: 1 addition & 5 deletions packages/@react-spectrum/toast/src/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,7 @@ export const Toast = React.forwardRef(function Toast(props: SpectrumToastProps,
'spectrum-Toast',
{'focus-ring': isFocusVisible}
)
)}
style={{
...styleProps.style,
zIndex: props.toast.priority
}}>
)}>
<div
{...contentProps}
className={classNames(toastContainerStyles, 'spectrum-Toast-contentWrapper')}>
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/toast/src/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface SpectrumToastContainerProps extends AriaToastRegionProps {
placement?: ToastPlacement
}

export interface SpectrumToastOptions extends Omit<ToastOptions, 'priority'>, DOMProps {
export interface SpectrumToastOptions extends ToastOptions, DOMProps {
/** A label for the action button within the toast. */
actionLabel?: string,
/** Handler that is called when the action button is pressed. */
Expand Down Expand Up @@ -128,7 +128,7 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
return (
<Toaster state={state} {...props}>
<ol className={classNames(toastContainerStyles, 'spectrum-ToastContainer-list')}>
{state.visibleToasts.slice().reverse().map((toast, index) => {
{state.visibleToasts.map((toast, index) => {
let shouldFade = isCentered && index !== 0;
return (
<li
Expand All @@ -148,7 +148,7 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
state={state} />
</li>
);
})}
})}
</ol>
</Toaster>
);
Expand Down
19 changes: 3 additions & 16 deletions packages/@react-stately/toast/src/useToastState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export interface ToastOptions {
/** Handler that is called when the toast is closed, either by the user or after a timeout. */
onClose?: () => void,
/** A timeout to automatically close the toast after, in milliseconds. */
timeout?: number,
/** The priority of the toast relative to other toasts. Larger numbers indicate higher priority. */
priority?: number
timeout?: number
}

export interface QueuedToast<T> extends ToastOptions {
Expand Down Expand Up @@ -82,7 +80,7 @@ export function useToastQueue<T>(queue: ToastQueue<T>): ToastState<T> {
}

/**
* A ToastQueue is a priority queue of toasts.
* A ToastQueue manages the order of toasts.
*/
export class ToastQueue<T> {
private queue: QueuedToast<T>[] = [];
Expand Down Expand Up @@ -121,18 +119,7 @@ export class ToastQueue<T> {
timer: options.timeout ? new Timer(() => this.close(toastKey), options.timeout) : undefined
};

let low = 0;
let high = this.queue.length;
while (low < high) {
let mid = Math.floor((low + high) / 2);
if ((toast.priority || 0) > (this.queue[mid].priority || 0)) {
high = mid;
} else {
low = mid + 1;
}
}

this.queue.splice(low, 0, toast);
this.queue.unshift(toast);

this.updateVisibleToasts();
return toastKey;
Expand Down
56 changes: 20 additions & 36 deletions packages/@react-stately/toast/test/useToastState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ describe('useToastState', () => {

act(() => {result.current.add(secondToast.content, secondToast.props);});
expect(result.current.visibleToasts.length).toBe(2);
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);
expect(result.current.visibleToasts[1].content).toBe(secondToast.content);
expect(result.current.visibleToasts[0].content).toBe(secondToast.content);
expect(result.current.visibleToasts[1].content).toBe(newValue[0].content);
});

it('should be able to display three toasts and remove the middle toast via timeout then the visible toast', () => {
Expand All @@ -83,7 +83,7 @@ describe('useToastState', () => {
result.current.add('Second Toast', {timeout: 1000});
});
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Second Toast');

result.current.resumeAll();

Expand All @@ -92,21 +92,21 @@ describe('useToastState', () => {
result.current.add('Third Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(3);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
expect(result.current.visibleToasts[2].content).toBe('First Toast');

act(() => jest.advanceTimersByTime(500));
expect(result.current.visibleToasts).toHaveLength(3);

act(() => jest.advanceTimersByTime(1000));
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[1].content).toBe('Third Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[1].content).toBe('First Toast');

act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[0].content).toBe('First Toast');
});

it('should be able to display one toast, add multiple toasts, and remove the middle not visible one programmatically', () => {
Expand All @@ -125,24 +125,24 @@ describe('useToastState', () => {
secondToastKey = result.current.add('Second Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Second Toast');

// Add the third toast
act(() => {
result.current.add('Third Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');

// Remove a toast that isn't visible
act(() => {result.current.close(secondToastKey);});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');

// Remove the visible toast to confirm the middle toast was removed
act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[0].content).toBe('First Toast');
});

it('should be able to display one toast, add multiple toasts', () => {
Expand All @@ -160,14 +160,14 @@ describe('useToastState', () => {
result.current.add('Second Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Second Toast');

// Add the third toast
act(() => {
result.current.add('Third Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
});

it('should maintain the toast queue order on close', () => {
Expand All @@ -179,19 +179,19 @@ describe('useToastState', () => {

act(() => {result.current.add('Second Toast');});
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[0].content).toBe('Second Toast');
expect(result.current.visibleToasts[1].content).toBe('First Toast');

act(() => {result.current.add('Third Toast');});
expect(result.current.visibleToasts).toHaveLength(3);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
expect(result.current.visibleToasts[2].content).toBe('First Toast');

act(() => {result.current.close(result.current.visibleToasts[1].key);});
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[1].content).toBe('Third Toast');
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[1].content).toBe('First Toast');
});

it('should close a toast', () => {
Expand All @@ -211,22 +211,6 @@ describe('useToastState', () => {

act(() => {result.current.add('Second Toast');});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);

act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Second Toast');
});

it('should queue toasts with priority', () => {
let {result} = renderHook(() => useToastState());
expect(result.current.visibleToasts).toStrictEqual([]);

act(() => {result.current.add(newValue[0].content, newValue[0].props);});
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);

act(() => {result.current.add('Second Toast', {priority: 1});});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Second Toast');

act(() => {result.current.close(result.current.visibleToasts[0].key);});
Expand Down
8 changes: 4 additions & 4 deletions packages/react-aria-components/docs/Toast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function App() {
Then, you can trigger a toast from anywhere using the exported `queue`.

```tsx example
<Button
<Button
onPress={() => queue.add({
title: 'Toast complete!',
description: 'Great success.'
Expand Down Expand Up @@ -158,7 +158,7 @@ Then, you can trigger a toast from anywhere using the exported `queue`.
color: white;
padding: 0;
outline: none;

&[data-focus-visible] {
box-shadow: 0 0 0 2px slateblue, 0 0 0 4px white;
}
Expand All @@ -183,7 +183,7 @@ There is no built in way to display toast notifications in HTML. `<ToastRegion>`

<Anatomy role="img" aria-label="Toast anatomy diagram, showing the toast's title and close button within the toast region." />

A `<ToastRegion>` is an [ARIA landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) labeled "Notifications" by default. A `<ToastRegion>` accepts a function to render one or more visible toasts, in priority order. When the limit is reached, additional toasts are queued until the user dismisses one. Each `<Toast>` is a non-modal ARIA [alertdialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/), containing the content of the notification and a close button.
A `<ToastRegion>` is an [ARIA landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) labeled "Notifications" by default. A `<ToastRegion>` accepts a function to render one or more visible toasts, in chronological order. When the limit is reached, additional toasts are queued until the user dismisses one. Each `<Toast>` is a non-modal ARIA [alertdialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/), containing the content of the notification and a close button.

Landmark regions including the toast container can be navigated using the keyboard by pressing the <Keyboard>F6</Keyboard> key to move forward, and the <Keyboard>Shift</Keyboard> + <Keyboard>F6</Keyboard> key to move backward. This provides an easy way for keyboard users to jump to the toasts from anywhere in the app. When the last toast is closed, keyboard focus is restored.

Expand All @@ -210,7 +210,7 @@ Toasts support a `timeout` option to automatically hide them after a certain amo
Be sure only to automatically dismiss toasts when the information is not important, or may be found elsewhere. Some users may require additional time to read a toast message, and screen zoom users may miss toasts entirely.

```tsx example
<Button
<Button
/*- begin highlight -*/
onPress={() => queue.add({title: 'Toast is done!'}, {timeout: 5000})}
/*- end highlight -*/
Expand Down
2 changes: 1 addition & 1 deletion scripts/extractStarter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ fs.mkdirSync(`starters/docs/src`, {recursive: true});
fs.mkdirSync(`starters/docs/stories`, {recursive: true});

for (let file of glob.sync('packages/react-aria-components/docs/*.mdx')) {
if (!/^[A-Z]/.test(basename(file)) || /^Autocomplete|^Virtualizer/.test(basename(file))) {
if (!/^[A-Z]/.test(basename(file)) || /^Autocomplete|^Virtualizer|^Toast/.test(basename(file))) {
continue;
}
console.log('Processing ' + file);
Expand Down