Skip to content
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
11 changes: 11 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/ExampleToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

export interface MyToastContent {
title: string;
description?: string;
timeout?: number
}

// only added so we can have a component/type for the props we want to send to the actual toast example
export function MyToast(props: MyToastContent) {
return <div />;
}
206 changes: 206 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/Toast.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import {Layout} from '../../src/Layout';
export default Layout;

import docs from 'docs:react-aria-components';
import toastDocs from 'docs:./ExampleToast';
import '../../tailwind/tailwind.css';
import Anatomy from '@react-aria/toast/docs/toast-anatomy.svg';
import {InlineAlert, Heading, Content} from '@react-spectrum/s2';

export const tags = ['notifications'];
export const version = 'alpha';

# Toast

<PageDescription>{docs.exports.UNSTABLE_Toast.description}</PageDescription>

<ExampleSwitcher>
```tsx render docs={toastDocs.exports.MyToast} links={toastDocs.links} props={['title', 'description', 'timeout']} initialProps={{title: 'Files uploaded', description: '3 files uploaded successfully.', timeout: 0}} type="vanilla" files={["starters/docs/src/Toast.tsx", "starters/docs/src/Toast.css"]}
"use client";
import {MyToastRegion, queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example(props) {
return (
<div>
<MyToastRegion />
<Button onPress={() => queue.add(
{
title: props.title || 'Files uploaded',
description: props.description || '3 files uploaded successfully.'
},
props.timeout ? {timeout: props.timeout} : undefined
)}>
Upload files
</Button>
</div>
);
}
```

```tsx render docs={toastDocs.exports.MyToast} links={toastDocs.links} props={['title', 'description', 'timeout']} initialProps={{title: 'Files uploaded', description: '3 files uploaded successfully.', timeout: 0}} type="tailwind" files={["starters/tailwind/src/Toast.tsx"]}
"use client";
import {MyToastRegion, queue} from 'tailwind-starter/Toast';
import {Button} from 'tailwind-starter/Button';

function Example(props) {
return (
<div>
<MyToastRegion />
<Button onPress={() => queue.add(
{
title: props.title || 'Files uploaded',
description: props.description || '3 files uploaded successfully.'
},
props.timeout ? {timeout: props.timeout} : undefined
)}>
Upload files
</Button>
</div>
);
}
```

</ExampleSwitcher>

## Content

### Title and description

Use the `"title"` and `"description"` slots within `<ToastContent>` to provide structured content for the toast. The title is required, and description is optional.

```tsx render hideImports
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add({
title: 'Update available',
description: 'A new version is ready to install.'
})}
///- end highlight -///
>
Check for updates
</Button>
);
}
```

### Close button

Include a `<Button slot="close">` to allow users to dismiss the toast manually. This is important for accessibility.

<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
<Content>We recommend that that the close button should be rendered as a sibling of `<ToastContent>` rather than inside it, so that screen readers announce the toast content without the close button first.</Content>
</InlineAlert>

## Dismissal

Use the `timeout` option to automatically dismiss toasts after a period of time. For accessibility, toasts should have a minimum timeout of **5 seconds**. Timers automatically pause when the user focuses or hovers over a toast.

```tsx render hideImports
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add(
{title: 'File has been saved!'},
{timeout: 5000}
)}
///- end highlight -///
>
Save file
</Button>
);
}
```

<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
<Content>Only auto-dismiss toasts when the information is not critical, or may be found elsewhere. Some users may require additional time to read toasts, and screen zoom users may miss them entirely.</Content>
</InlineAlert>

### Programmatic dismissal

Toasts can be programmatically dismissed using the key returned from `queue.add()`. This is useful when a toast becomes irrelevant before the user manually closes it.

```tsx render hideImports
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';
import {useState} from 'react';

function Example() {
let [toastKey, setToastKey] = useState(null);

return (
<Button
///- begin highlight -///
onPress={() => {
if (!toastKey) {
setToastKey(queue.add(
{title: 'Processing...'},
{onClose: () => setToastKey(null)}
));
} else {
queue.close(toastKey);
}
}}
///- end highlight -///
>
{toastKey ? 'Cancel' : 'Process'}
</Button>
);
}
```

## Accessibility

Toast regions are [landmark regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) that can be navigated using <Keyboard>F6</Keyboard> to move forward and <Keyboard>Shift</Keyboard> + <Keyboard>F6</Keyboard> to move backward. This provides an easy way for keyboard users to jump to toasts from anywhere in the app.

When a toast is closed, focus moves to the next toast if any. When the last toast is closed, focus is restored to where it was before.

## API

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

```tsx links={{ToastRegion: '#toastregion', Toast: '#toast', ToastContent: '#toastcontent', ToastQueue: '#toastqueue', Button: 'Button.html'}}
<ToastRegion>
{({toast}) => (
<Toast toast={toast}>
<ToastContent>
<Text slot="title" />
<Text slot="description" />
</ToastContent>
<Button slot="close" />
</Toast>
)}
</ToastRegion>
```

### ToastRegion

<PropTable component={docs.exports.UNSTABLE_ToastRegion} links={docs.links} showDescription />

### Toast

<PropTable component={docs.exports.UNSTABLE_Toast} links={docs.links} showDescription />

### ToastContent

`<ToastContent>` renders the main content of a toast, including the title and description slots. It accepts all HTML attributes.

### ToastQueue

A `ToastQueue` manages the state for a `<ToastRegion>`. The state is stored outside React so you can trigger toasts from anywhere in your application.

<ClassAPI links={docs.links} class={docs.exports.UNSTABLE_ToastQueue} />
104 changes: 104 additions & 0 deletions starters/docs/src/Toast.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
@import "./theme.css";

.react-aria-ToastRegion {
position: fixed;
bottom: var(--spacing-4);
right: var(--spacing-4);
display: flex;
flex-direction: column-reverse;
gap: var(--spacing-2);
border-radius: var(--radius-lg);
outline: none;

&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: 2px;
}
}

.react-aria-Toast {
display: flex;
align-items: center;
gap: var(--spacing-4);
background: var(--tint-1000);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-lg);
outline: none;
forced-color-adjust: none;
view-transition-class: toast;

&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline-offset: 2px;
}

.react-aria-ToastContent {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
font: var(--font-size) system-ui;

[slot=title] {
font-weight: 600;
color: var(--highlight-foreground);
}

[slot=description] {
font-size: var(--font-size-sm);
color: var(--highlight-foreground);
}
}

.react-aria-Button[slot=close] {
flex: 0 0 auto;
background: none;
border: none;
appearance: none;
border-radius: var(--radius-sm);
height: var(--spacing-8);
width: var(--spacing-8);
color: var(--highlight-foreground);
padding: 0;
outline: none;
-webkit-tap-highlight-color: transparent;

&[data-hovered] {
background: var(--tint-900);
box-shadow: none;
}

&[data-focus-visible] {
outline: 2px solid var(--highlight-foreground);
outline-offset: 2px;
}

&[data-pressed] {
background: var(--highlight-pressed);
}
}
}

::view-transition-new(.toast):only-child {
animation: slide-in 400ms;
}

::view-transition-old(.toast):only-child {
animation: slide-out 400ms;
animation-fill-mode: forwards;
}

@keyframes slide-out {
to {
translate: 100% 0;
opacity: 0;
visibility: hidden;
}
}

@keyframes slide-in {
from {
translate: 100% 0;
opacity: 0;
}
}
59 changes: 59 additions & 0 deletions starters/docs/src/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';
import {
UNSTABLE_ToastRegion as ToastRegion,
UNSTABLE_Toast as Toast,
UNSTABLE_ToastQueue as ToastQueue,
UNSTABLE_ToastContent as ToastContent,
ToastProps,
Text
} from 'react-aria-components';
import {Button} from './Button';
import {X} from 'lucide-react';
import './Toast.css';
import {flushSync} from 'react-dom';

// Define the type for your toast content. This interface defines the properties of your toast content, affecting what you
// pass to the queue calls as arguments.
interface MyToastContent {
title: string;
description?: string;
}

// This is a global toast queue, to be imported and called where ever you want to queue a toast via queue.add().
export const queue = new ToastQueue<MyToastContent>({
// Wrap state updates in a CSS view transition.
wrapUpdate(fn) {
if ('startViewTransition' in document) {
document.startViewTransition(() => {
flushSync(fn);
});
} else {
fn();
}
}
});

export function MyToastRegion() {
return (
// The ToastRegion should be rendered at the root of your app.
<ToastRegion queue={queue}>
{({toast}) => (
<MyToast toast={toast} style={{viewTransitionName: toast.key}}>
<ToastContent>
<Text slot="title">{toast.content.title}</Text>
{toast.content.description && (
<Text slot="description">{toast.content.description}</Text>
)}
</ToastContent>
<Button slot="close" aria-label="Close" variant="quiet">
<X size={16} />
</Button>
</MyToast>
)}
</ToastRegion>
);
}

export function MyToast(props: ToastProps<MyToastContent>) {
return <Toast {...props} />;
}
Loading
Loading