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

feat(Toast): use View Transition API for Toast animations #7631

Merged
merged 22 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d701afa
remove animation code from toast hooks
reidbarber Jan 16, 2025
0cd2fce
add View Transitions
reidbarber Jan 17, 2025
c74e05a
lint
reidbarber Jan 17, 2025
08e4fe5
fix invalid viewTransitionName
reidbarber Jan 21, 2025
c0661d5
Merge remote-tracking branch 'origin/main' into remove-toast-hooks-an…
reidbarber Jan 21, 2025
af35479
fix Multiple story
reidbarber Jan 23, 2025
4f5b74a
fix transition when programmatically closing toast
reidbarber Jan 27, 2025
60ab596
Merge remote-tracking branch 'origin/main' into remove-toast-hooks-an…
reidbarber Jan 27, 2025
73d2b6b
fix slide in/out
reidbarber Jan 28, 2025
71d02f5
Merge remote-tracking branch 'origin/main' into remove-toast-hooks-an…
reidbarber Jan 28, 2025
dcd1262
Merge remote-tracking branch 'origin/main' into remove-toast-hooks-an…
reidbarber Jan 30, 2025
21458e5
fix ts-ignore
reidbarber Jan 30, 2025
a4023ec
update yarn.lock
reidbarber Jan 30, 2025
99db3bc
memoize placement
reidbarber Jan 30, 2025
b7140b6
add placement to fullscreen story
reidbarber Jan 30, 2025
ed3a0c8
fade out toasts that are centered, and not the last one
reidbarber Jan 30, 2025
71c7938
lint
reidbarber Jan 30, 2025
fa7ce9c
add wrapUpdate option to ToastQueue
reidbarber Feb 3, 2025
e53b49d
update where runWithWrapUpdate gets called
reidbarber Feb 5, 2025
e2499b8
Merge remote-tracking branch 'origin/main' into remove-toast-hooks-an…
reidbarber Feb 5, 2025
1c2d8e3
fix function param
reidbarber Feb 7, 2025
e7a18b2
Merge remote-tracking branch 'origin/main' into remove-toast-hooks-an…
reidbarber Feb 7, 2025
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
66 changes: 0 additions & 66 deletions packages/@react-aria/toast/docs/useToast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ There is no built in way to toast notifications in HTML. <TypeLink links={docs.l
* **Accessible** – Toasts follow the [ARIA alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/). 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.
* **Animations** – Toasts support optional entry and exit animations.

## Anatomy

Expand Down Expand Up @@ -346,71 +345,6 @@ Now you can queue a toast from anywhere:
<Button onPress={() => toastQueue.add('Toast is done!')}>Show toast</Button>
```

### Animations

`useToastState` and `ToastQueue` support a `hasExitAnimation` option. When enabled, toasts transition to an "exiting" state when closed rather than immediately being removed. This allows you to trigger an exit animation. When complete, call the `state.remove` function.

Each <TypeLink links={statelyDocs.links} type={statelyDocs.exports.QueuedToast} /> includes an `animation` property that indicates the current animation state. There are three possible states:

* `entering` – The toast is entering immediately after being triggered.
* `queued` – The toast is entering from the queue (out of view).
* `exiting` – The toast is exiting from view.

```tsx
function ToastRegion() {
let state = useToastState({
maxVisibleToasts: 5,
/*- begin highlight -*/
hasExitAnimation: true
/*- end highlight -*/
});

// ...
}

function Toast({state, ...props}) {
let ref = React.useRef(null);
let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref);

return (
<div
{...toastProps}
ref={ref}
className="toast"
/*- begin highlight -*/
// Use a data attribute to trigger animations in CSS.
data-animation={props.toast.animation}
onAnimationEnd={() => {
// Remove the toast when the exiting animation completes.
if (props.toast.animation === 'exiting') {
state.remove(props.toast.key);
}
}}
/*- end highlight -*/
>
<div {...titleProps}>{props.toast.content}</div>
<Button {...closeButtonProps}>x</Button>
</div>
);
}
```

In CSS, the data attribute defined above can be used to trigger keyframe animations:

```css
.toast[data-animation=entering] {
animation-name: slide-in;
}

.toast[data-animation=queued] {
animation-name: fade-in;
}

.toast[data-animation=exiting] {
animation-name: slide-out;
}
```

### TypeScript

A `ToastQueue` and `useToastState` use a generic type to represent toast content. The examples so far have used strings, but you can type this however you want to enable passing custom objects or options. This example uses a custom object to support toasts with both a title and description.
Expand Down
19 changes: 3 additions & 16 deletions packages/@react-aria/toast/src/useToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@re
// @ts-ignore
import intlMessages from '../intl/*.json';
import {QueuedToast, ToastState} from '@react-stately/toast';
import React, {useEffect} from 'react';
import {useEffect} from 'react';
import {useId, useSlotId} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

Expand Down Expand Up @@ -46,8 +46,7 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
let {
key,
timer,
timeout,
animation
timeout
} = props.toast;

useEffect(() => {
Expand All @@ -61,13 +60,6 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
};
}, [timer, timeout]);

let [isEntered, setIsEntered] = React.useState(false);
useEffect(() => {
if (animation === 'entering' || animation === 'queued') {
setIsEntered(true);
}
}, [animation]);

let titleId = useId();
let descriptionId = useSlotId();
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/toast');
Expand All @@ -80,16 +72,11 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
'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,
tabIndex: 0
},
contentProps: {
role: 'alert',
'aria-atomic': 'true',
style: {
visibility: isEntered || animation === null ? 'visible' : 'hidden'
}
'aria-atomic': 'true'
},
titleProps: {
id: titleId
Expand Down
12 changes: 12 additions & 0 deletions packages/@react-aria/toast/src/useToastRegion.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils';
import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions';
Expand Down
10 changes: 1 addition & 9 deletions packages/@react-aria/toast/test/useToast.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {act, fireEvent, pointerMap, render, renderHook, within} from '@react-spectrum/test-utils-internal';
import {act, 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';
Expand Down Expand Up @@ -51,12 +51,6 @@ describe('useToast', () => {
});

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});
Expand All @@ -82,15 +76,13 @@ describe('single toast at a time', () => {
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();
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/toast/chromatic/Toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {useToastState} from '@react-stately/toast';

function FakeToast(props) {
let state = useToastState<any>();
return <Toast toast={{content: props, key: 'toast', animation: 'entering'}} state={state} />;
return <Toast toast={{content: props, key: 'toast'}} state={state} />;
}

export default {
Expand Down
7 changes: 0 additions & 7 deletions packages/@react-spectrum/toast/src/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export const Toast = React.forwardRef(function Toast(props: SpectrumToastProps,
let {
toast: {
key,
animation,
content: {
children,
variant,
Expand Down Expand Up @@ -107,12 +106,6 @@ export const Toast = React.forwardRef(function Toast(props: SpectrumToastProps,
style={{
...styleProps.style,
zIndex: props.toast.priority
}}
data-animation={animation}
onAnimationEnd={() => {
if (animation === 'exiting') {
state.remove(key);
}
}}>
<div
{...contentProps}
Expand Down
65 changes: 48 additions & 17 deletions packages/@react-spectrum/toast/src/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {AriaToastRegionProps} from '@react-aria/toast';
import {classNames} from '@react-spectrum/utils';
import {DOMProps} from '@react-types/shared';
import {filterDOMProps} from '@react-aria/utils';
import React, {ReactElement, useEffect, useRef} from 'react';
import {flushSync} from 'react-dom';
import React, {ReactElement, useEffect, useMemo, useRef} from 'react';
import {SpectrumToastValue, Toast} from './Toast';
import toastContainerStyles from './toastContainer.css';
import {Toaster} from './Toaster';
Expand All @@ -38,13 +39,29 @@ export interface SpectrumToastOptions extends Omit<ToastOptions, 'priority'>, DO

type CloseFunction = () => void;

function wrapInViewTransition<R>(fn: () => R): R {
if ('startViewTransition' in document) {
let result: R;
// @ts-expect-error
document.startViewTransition(() => {
flushSync(() => {
result = fn();
});
});
// @ts-ignore
return result;
reidbarber marked this conversation as resolved.
Show resolved Hide resolved
} else {
return fn();
}
}

// There is a single global toast queue instance for the whole app, initialized lazily.
let globalToastQueue: ToastQueue<SpectrumToastValue> | null = null;
function getGlobalToastQueue() {
if (!globalToastQueue) {
globalToastQueue = new ToastQueue({
maxVisibleToasts: Infinity,
hasExitAnimation: true
wrapUpdate: wrapInViewTransition
});
}

Expand Down Expand Up @@ -94,12 +111,6 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
triggerSubscriptions();

return () => {
// When this toast provider unmounts, reset all animations so that
// when the new toast provider renders, it is seamless.
for (let toast of getGlobalToastQueue().visibleToasts) {
toast.animation = null;
}

// Remove this toast provider, and call subscriptions.
// This will cause all other instances to re-render,
// and the first one to become the new active toast provider.
Expand All @@ -112,19 +123,39 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
let activeToastContainer = useActiveToastContainer();
let state = useToastQueue(getGlobalToastQueue());

let {placement, isCentered} = useMemo(() => {
let placements = (props.placement ?? 'bottom').split(' ');
let placement = placements[placements.length - 1];
let isCentered = placements.length === 1;
return {placement, isCentered};
}, [props.placement]);

if (ref === activeToastContainer && state.visibleToasts.length > 0) {
return (
<Toaster state={state} {...props}>
<ol className={classNames(toastContainerStyles, 'spectrum-ToastContainer-list')}>
{state.visibleToasts.slice().reverse().map((toast) => (
<li
key={toast.key}
className={classNames(toastContainerStyles, 'spectrum-ToastContainer-listitem')}>
<Toast
toast={toast}
state={state} />
</li>
))}
{state.visibleToasts.slice().reverse().map((toast, index) => {
let shouldFade = isCentered && index !== 0;
return (
<li
key={toast.key}
className={classNames(toastContainerStyles, 'spectrum-ToastContainer-listitem')}
style={{
// @ts-expect-error
viewTransitionName: `_${toast.key.slice(2)}`,
snowystinger marked this conversation as resolved.
Show resolved Hide resolved
viewTransitionClass: classNames(
toastContainerStyles,
'toast',
placement,
{'fadeOnly': shouldFade}
)
}}>
<Toast
toast={toast}
state={state} />
</li>
);
})}
</ol>
</Toaster>
);
Expand Down
Loading