diff --git a/packages/dev/s2-docs/pages/react-aria/ExampleToast.tsx b/packages/dev/s2-docs/pages/react-aria/ExampleToast.tsx new file mode 100644 index 00000000000..326f3604d6f --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/ExampleToast.tsx @@ -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
; +} diff --git a/packages/dev/s2-docs/pages/react-aria/Toast.mdx b/packages/dev/s2-docs/pages/react-aria/Toast.mdx new file mode 100644 index 00000000000..0b39ee77e56 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/Toast.mdx @@ -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 + +{docs.exports.UNSTABLE_Toast.description} + + + ```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 ( +
+ + +
+ ); + } + ``` + + ```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 ( +
+ + +
+ ); + } + ``` + +
+ +## Content + +### Title and description + +Use the `"title"` and `"description"` slots within `` 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 ( + + ); +} +``` + +### Close button + +Include a ` + ); +} +``` + + + Accessibility + 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. + + +### 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 ( + + ); +} +``` + +## Accessibility + +Toast regions are [landmark regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) that can be navigated using F6 to move forward and Shift + F6 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 + + + +```tsx links={{ToastRegion: '#toastregion', Toast: '#toast', ToastContent: '#toastcontent', ToastQueue: '#toastqueue', Button: 'Button.html'}} + + {({toast}) => ( + + + + + + + + )} + + ); +} + +export function MyToast(props: ToastProps) { + return ; +} diff --git a/starters/docs/stories/Toast.stories.tsx b/starters/docs/stories/Toast.stories.tsx new file mode 100644 index 00000000000..4e00c0784e4 --- /dev/null +++ b/starters/docs/stories/Toast.stories.tsx @@ -0,0 +1,102 @@ +import {MyToastRegion, queue} from '../src/Toast'; +import {Button} from '../src/Button'; +import type {Meta, StoryObj} from '@storybook/react'; + +interface ToastStoryArgs { + title: string; + description?: string; + timeout?: number; + buttonLabel: string; +} + +const meta: Meta = { + title: 'Toast', + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + title: { + control: 'text', + description: 'The title of the toast.' + }, + description: { + control: 'text', + description: 'Optional description text.' + }, + timeout: { + control: 'number', + description: 'Auto-dismiss timeout in milliseconds.' + }, + buttonLabel: { + control: 'text', + description: 'Label for the trigger button.' + } + }, + args: { + title: 'Files uploaded', + description: '3 files uploaded successfully.', + buttonLabel: 'Show toast' + } +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: (args) => ( + <> + + + + ), + parameters: { + docs: { + source: { + transform: () => { + return ` +const queue = new ToastQueue(); + +function MyToast(props: ToastProps) { + return ; +} + +function MyToastRegion() { + return ( + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description && ( + {toast.content.description} + )} + + + + )} + + ); +} + +<> + + +`; + } + } + } + } +}; diff --git a/starters/tailwind/src/Toast.css b/starters/tailwind/src/Toast.css new file mode 100644 index 00000000000..4142bf59556 --- /dev/null +++ b/starters/tailwind/src/Toast.css @@ -0,0 +1,23 @@ +::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; + } +} diff --git a/starters/tailwind/src/Toast.tsx b/starters/tailwind/src/Toast.tsx new file mode 100644 index 00000000000..2255de33d5f --- /dev/null +++ b/starters/tailwind/src/Toast.tsx @@ -0,0 +1,75 @@ +'use client'; +import React from 'react'; +import { + UNSTABLE_ToastRegion as ToastRegion, + UNSTABLE_Toast as Toast, + UNSTABLE_ToastQueue as ToastQueue, + UNSTABLE_ToastContent as ToastContent, + ToastProps, + Button, + Text +} from 'react-aria-components'; +import {XIcon} from 'lucide-react'; +import {composeTailwindRenderProps} from './utils'; +import {flushSync} from 'react-dom'; +import './Toast.css'; + +// 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({ + // 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. + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description && ( + {toast.content.description} + )} + + + + )} + + ); +} + +export function MyToast(props: ToastProps) { + return ( + + ); +} diff --git a/starters/tailwind/stories/Toast.stories.tsx b/starters/tailwind/stories/Toast.stories.tsx new file mode 100644 index 00000000000..cf7cfa270b3 --- /dev/null +++ b/starters/tailwind/stories/Toast.stories.tsx @@ -0,0 +1,116 @@ +import {MyToastRegion, queue} from '../src/Toast'; +import {Button} from '../src/Button'; +import {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; + +interface ToastStoryArgs { + title: string; + description?: string; + timeout?: number; + buttonLabel: string; +} + +const meta: Meta = { + title: 'Toast', + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + title: { + control: 'text', + description: 'The title of the toast.' + }, + description: { + control: 'text', + description: 'Optional description text.' + }, + timeout: { + control: 'number', + description: 'Auto-dismiss timeout in milliseconds.' + }, + buttonLabel: { + control: 'text', + description: 'Label for the trigger button.' + } + }, + args: { + title: 'Files uploaded', + description: '3 files uploaded successfully.', + buttonLabel: 'Show toast' + } +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: (args) => ( + <> + + + + ), + parameters: { + docs: { + source: { + transform: () => { + return ` +const queue = new ToastQueue(); + +function MyToastRegion() { + return ( + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description && ( + {toast.content.description} + )} + + + + )} + + ); +} + +function MyToast(props: ToastProps) { + return ( + + ); +} + +<> + + +`; + } + } + } + } +};