diff --git a/lib/css.d.ts b/lib/css.d.ts new file mode 100644 index 00000000000..21aa02635ee --- /dev/null +++ b/lib/css.d.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +declare module 'react' { + interface CSSProperties { + viewTransitionName?: string, + viewTransitionClass?: string + } +} + +export {}; diff --git a/lib/viewTransitions.d.ts b/lib/viewTransitions.d.ts new file mode 100644 index 00000000000..db01d159b6a --- /dev/null +++ b/lib/viewTransitions.d.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +interface Document { + startViewTransition(fn: () => void): ViewTransition; +} + +interface ViewTransition { + ready: Promise; +} diff --git a/packages/@react-aria/toast/docs/useToast.mdx b/packages/@react-aria/toast/docs/useToast.mdx index b5ee5856a96..e886c55ed38 100644 --- a/packages/@react-aria/toast/docs/useToast.mdx +++ b/packages/@react-aria/toast/docs/useToast.mdx @@ -35,7 +35,7 @@ keywords: [toast, notifications, alert, aria] packageData={packageData} componentNames={['useToastRegion', 'useToast']} sourceData={[ - {type: 'W3C', url: 'https://www.w3.org/WAI/ARIA/apg/patterns/alert/'} + {type: 'W3C', url: 'https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/'} ]} /> ## API @@ -45,9 +45,9 @@ keywords: [toast, notifications, alert, aria] ## Features -There is no built in way to toast notifications in HTML. and help achieve accessible toasts that can be styled as needed. +There is no built in way to display toast notifications in HTML. and help achieve accessible toasts that can be styled as needed. -* **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. +* **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. @@ -55,7 +55,7 @@ There is no built in way to toast notifications in HTML. -A toast region is an ARIA landmark region 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 an ARIA alert element, 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 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. Landmark regions including the toast container can be navigated using the keyboard by pressing the F6 key to move forward, and the Shift + F6 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. diff --git a/packages/@react-spectrum/toast/src/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx index 52d07aa9601..fa5248d7f7d 100644 --- a/packages/@react-spectrum/toast/src/ToastContainer.tsx +++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx @@ -39,19 +39,13 @@ export interface SpectrumToastOptions extends Omit, DO type CloseFunction = () => void; -function wrapInViewTransition(fn: () => R): R { +function wrapInViewTransition(fn: () => void): void { if ('startViewTransition' in document) { - let result: R; - // @ts-expect-error document.startViewTransition(() => { - flushSync(() => { - result = fn(); - }); + flushSync(fn); }).ready.catch(() => {}); - // @ts-ignore - return result; } else { - return fn(); + fn(); } } @@ -141,8 +135,7 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement key={toast.key} className={classNames(toastContainerStyles, 'spectrum-ToastContainer-listitem')} style={{ - // @ts-expect-error - viewTransitionName: `_${toast.key.slice(2)}`, + viewTransitionName: toast.key, viewTransitionClass: classNames( toastContainerStyles, 'toast', diff --git a/packages/@react-stately/toast/src/useToastState.ts b/packages/@react-stately/toast/src/useToastState.ts index 56850277ffa..802347f5399 100644 --- a/packages/@react-stately/toast/src/useToastState.ts +++ b/packages/@react-stately/toast/src/useToastState.ts @@ -18,7 +18,7 @@ export interface ToastStateProps { /** The maximum number of toasts to display at a time. */ maxVisibleToasts?: number, /** Function to wrap updates in (i.e. document.startViewTransition()). */ - wrapUpdate?: (fn: () => R) => R + wrapUpdate?: (fn: () => void) => void } export interface ToastOptions { @@ -88,20 +88,20 @@ export class ToastQueue { private queue: QueuedToast[] = []; private subscriptions: Set<() => void> = new Set(); private maxVisibleToasts: number; - private wrapUpdate?: (fn: () => R) => R; + private wrapUpdate?: (fn: () => void) => void; /** The currently visible toasts. */ visibleToasts: QueuedToast[] = []; constructor(options?: ToastStateProps) { - this.maxVisibleToasts = options?.maxVisibleToasts ?? 1; + this.maxVisibleToasts = options?.maxVisibleToasts ?? Infinity; this.wrapUpdate = options?.wrapUpdate; } - private runWithWrapUpdate(fn: () => R): R { + private runWithWrapUpdate(fn: () => void): void { if (this.wrapUpdate) { - return this.wrapUpdate(fn); + this.wrapUpdate(fn); } else { - return fn(); + fn(); } } @@ -113,7 +113,7 @@ export class ToastQueue { /** Adds a new toast to the queue. */ add(content: T, options: ToastOptions = {}) { - let toastKey = Math.random().toString(36); + let toastKey = '_' + Math.random().toString(36).slice(2); let toast: QueuedToast = { ...options, content, diff --git a/packages/react-aria-components/docs/Toast.mdx b/packages/react-aria-components/docs/Toast.mdx new file mode 100644 index 00000000000..e6e61863d50 --- /dev/null +++ b/packages/react-aria-components/docs/Toast.mdx @@ -0,0 +1,346 @@ +{/* 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 {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:react-aria-components'; +import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI} from '@react-spectrum/docs'; +import styles from '@react-spectrum/docs/src/docs.css'; +import packageData from 'react-aria-components/package.json'; +import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; +import {ExampleCard} from '@react-spectrum/docs/src/ExampleCard'; +import {StarterKits} from '@react-spectrum/docs/src/StarterKits'; +import Anatomy from '@react-aria/toast/docs/toast-anatomy.svg'; +import {Keyboard} from '@react-spectrum/text'; +import {InlineAlert, Content, Heading} from '@adobe/react-spectrum'; + +--- +category: Status +keywords: [toast, notifications, alert, aria] +type: component +preRelease: alpha +--- + +# Toast + +{docs.exports.UNSTABLE_Toast.description} + + + + + Under construction + This component is in alpha. More documentation is coming soon! + + +## Example + +First, render a `ToastRegion` in the root of your app. + +```tsx example hidden export=true +import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components'; + +// Define the type for your toast content. +interface MyToastContent { + title: string, + description?: string +} + +// Create a global ToastQueue. +export const queue = new ToastQueue(); + +// Render a in the root of your app. +export function App() { + return ( + <> + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description} + + + + )} + + {/* Your app here */} + + ); +} +``` + +Then, you can trigger a toast from anywhere using the exported `queue`. + +```tsx example + +``` + +
+ Show CSS + +```css hidden +@import './Button.mdx' layer(button); +``` + +```css +@import "@react-aria/example-theme"; + +.react-aria-ToastRegion { + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; + border-radius: 8px; + outline: none; + + &[data-focus-visible] { + outline: 2px solid slateblue; + outline-offset: 2px; + } +} + +.react-aria-Toast { + display: flex; + align-items: center; + gap: 16px; + background: slateblue; + color: white; + padding: 12px 16px; + border-radius: 8px; + outline: none; + + &[data-focus-visible] { + outline: 2px solid slateblue; + outline-offset: 2px; + } + + .react-aria-ToastContent { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0px; + + [slot=title] { + font-weight: bold; + } + } + + .react-aria-Button[slot=close] { + flex: 0 0 auto; + background: none; + border: none; + appearance: none; + border-radius: 50%; + height: 32px; + width: 32px; + font-size: 16px; + border: 1px solid white; + color: white; + padding: 0; + outline: none; + + &[data-focus-visible] { + box-shadow: 0 0 0 2px slateblue, 0 0 0 4px white; + } + + &[data-pressed] { + background: rgba(255, 255, 255, 0.2); + } + } +} +``` + +
+ +## Features + +There is no built in way to display toast notifications in HTML. `` and `` 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. + +## Anatomy + + + +A `` is an [ARIA landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) labeled "Notifications" by default. A `` 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 `` 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 F6 key to move forward, and the Shift + F6 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. + +```tsx +import {ToastRegion, Toast, ToastContent, Text, Button} from 'react-aria-components'; + + + {({toast}) => ( + + + + + + +``` + +## Programmatic dismissal + +Toasts may be programmatically dismissed if they become irrelevant before the user manually closes them. `queue.add` returns a key for the toast which may be passed to `queue.close` to dismiss the toast. + +```tsx example +function Example() { + let [toastKey, setToastKey] = React.useState(null); + + return ( + + ); +} +``` + +## Animations + +Toast entry and exit animations can be done using third party animation libraries like [Motion](https://motion.dev/), or using native [CSS view transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API). + +This example shows how to use the `wrapUpdate` option of `ToastQueue` to wrap state updates in a CSS view transition. The `toast.key` can be used to assign a `viewTransitionName` to each `Toast`. + +```tsx example +import {flushSync} from 'react-dom'; + +const queue = new ToastQueue({ + /*- begin highlight -*/ + // Wrap state updates in a CSS view transition. + wrapUpdate(fn) { + if ('startViewTransition' in document) { + document.startViewTransition(() => { + flushSync(fn); + }); + } else { + fn(); + } + } + /*- end highlight -*/ +}); + + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description} + + + + )} + + +``` + +
+ Show CSS + +```css +.react-aria-Toast { + view-transition-class: toast; +} + +::view-transition-new(.toast):only-child { + animation: slide-in 400ms; +} + +::view-transition-old(.toast):only-child { + animation: slide-out 400ms; +} + +@keyframes slide-out { + to { + translate: 100% 0; + opacity: 0; + } +} + +@keyframes slide-in { + from { + translate: 100% 0; + opacity: 0; + } +} +``` + +
+ +## Props + +### ToastRegion + +`` renders a group of toasts. + + + +### Toast + +`` renders an individual toast. + + + +### ToastContent + +`` renders the main content of a toast, including the title and description. It accepts all HTML attributes. + +## ToastQueue API + +A `ToastQueue` manages the state for a ``. The state is stored outside React so that you can trigger toasts from anywhere in your application, not just inside components. + + diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index a6ba52df7d3..d7b991a460d 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -48,6 +48,7 @@ "@react-aria/interactions": "^3.23.0", "@react-aria/live-announcer": "^3.4.1", "@react-aria/menu": "^3.17.0", + "@react-aria/toast": "3.0.0-beta.19", "@react-aria/toolbar": "3.0.0-beta.12", "@react-aria/tree": "3.0.0-beta.3", "@react-aria/utils": "^3.27.0", @@ -59,6 +60,7 @@ "@react-stately/menu": "^3.9.1", "@react-stately/selection": "^3.19.0", "@react-stately/table": "^3.13.1", + "@react-stately/toast": "3.0.0-beta.7", "@react-stately/utils": "^3.10.5", "@react-stately/virtualizer": "^4.2.1", "@react-types/color": "^3.0.2", diff --git a/packages/react-aria-components/src/Toast.tsx b/packages/react-aria-components/src/Toast.tsx new file mode 100644 index 00000000000..6f41c63bd29 --- /dev/null +++ b/packages/react-aria-components/src/Toast.tsx @@ -0,0 +1,185 @@ +/* + * 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 {AriaToastProps, AriaToastRegionProps, useToast, useToastRegion} from '@react-aria/toast'; +import {ButtonContext} from './Button'; +import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; +import {createPortal} from 'react-dom'; +import {forwardRefType} from '@react-types/shared'; +import {mergeProps, useFocusRing} from 'react-aria'; +import {QueuedToast, ToastQueue, ToastState, useToastQueue} from '@react-stately/toast'; +import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactElement, useContext} from 'react'; +import {TextContext} from './Text'; +import {useObjectRef} from '@react-aria/utils'; + +const ToastStateContext = createContext | null>(null); + +export interface ToastRegionRenderProps { + /** A list of all currently visible toasts. */ + visibleToasts: QueuedToast[], + /** + * Whether the toast region is currently focused. + * @selector [data-focused] + */ + isFocused: boolean, + /** + * Whether the toast region is keyboard focused. + * @selector [data-focus-visible] + */ + isFocusVisible: boolean +} + +export interface ToastRegionProps extends AriaToastRegionProps, StyleRenderProps> { + /** The queue of toasts to display. */ + queue: ToastQueue, + /** A function to render each toast. */ + children: (renderProps: {toast: QueuedToast}) => ReactElement +} + +/** + * A ToastRegion displays one or more toast notifications. + */ +export const ToastRegion = /*#__PURE__*/ (forwardRef as forwardRefType)(function ToastRegion(props: ToastRegionProps, ref: ForwardedRef): JSX.Element | null { + let state = useToastQueue(props.queue); + let objectRef = useObjectRef(ref); + let {regionProps} = useToastRegion(props, state, objectRef); + + let {focusProps, isFocused, isFocusVisible} = useFocusRing(); + let renderProps = useRenderProps({ + ...props, + children: undefined, + defaultClassName: 'react-aria-ToastRegion', + values: { + visibleToasts: state.visibleToasts, + isFocused, + isFocusVisible + } + }); + + let region = ( + +
+ {typeof props.children === 'function' ? : props.children} +
+
+ ); + + return state.visibleToasts.length > 0 && typeof document !== 'undefined' + ? createPortal(region, document.body) + : null; +}); + +// TODO: possibly export this so additional children can be added to the region, outside the list. +const ToastList = /*#__PURE__*/ (forwardRef as forwardRefType)(function ToastList(props: ToastRegionProps, ref: ForwardedRef) { + let state = useContext(ToastStateContext)!; + return ( + // @ts-ignore +
    + {state.visibleToasts.map((toast) => ( +
  1. + {props.children({toast})} +
  2. + ))} +
+ ); +}); + +export interface ToastRenderProps { + /** + * The toast object to display. + */ + toast: QueuedToast, + /** + * Whether the toast is currently focused. + * @selector [data-focused] + */ + isFocused: boolean, + /** + * Whether the toast is keyboard focused. + * @selector [data-focus-visible] + */ + isFocusVisible: boolean +} + +export interface ToastProps extends AriaToastProps, RenderProps> {} + +/** + * A Toast displays a brief, temporary notification of actions, errors, or other events in an application. + */ +export const Toast = /*#__PURE__*/ (forwardRef as forwardRefType)(function Toast(props: ToastProps, ref: ForwardedRef) { + let state = useContext(ToastStateContext)!; + let objectRef = useObjectRef(ref); + let {toastProps, contentProps, titleProps, descriptionProps, closeButtonProps} = useToast( + props, + state, + objectRef + ); + + let {focusProps, isFocused, isFocusVisible} = useFocusRing(); + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-Toast', + values: { + toast: props.toast, + isFocused, + isFocusVisible + } + }); + + return ( +
+ + {renderProps.children} + +
+ ); +}); + +export const ToastContentContext = createContext, HTMLDivElement>>({}); + +/** + * ToastContent wraps the main content of a toast notification. + */ +export const ToastContent = /*#__PURE__*/ forwardRef(function ToastContent(props: HTMLAttributes, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, ToastContentContext); + return ( +
+ {props.children} +
+ ); +}); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 20278786380..e430beea030 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -70,6 +70,7 @@ export {TagGroup, TagGroupContext, TagList, TagListContext, Tag} from './TagGrou export {Text, TextContext} from './Text'; export {TextArea, TextAreaContext} from './TextArea'; export {TextField, TextFieldContext} from './TextField'; +export {Toast as UNSTABLE_Toast, ToastRegion as UNSTABLE_ToastRegion, ToastContent as UNSTABLE_ToastContent} from './Toast'; export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext, ToggleGroupStateContext} from './ToggleButtonGroup'; export {Toolbar, ToolbarContext} from './Toolbar'; @@ -81,6 +82,8 @@ export {Virtualizer} from './Virtualizer'; export {DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem, SSRProvider, RouterProvider, I18nProvider, useLocale, useFilter, Pressable, Focusable} from 'react-aria'; export {FormValidationContext} from 'react-stately'; export {parseColor, getColorChannels} from '@react-stately/color'; +export {ListLayout as UNSTABLE_ListLayout, GridLayout as UNSTABLE_GridLayout} from '@react-stately/layout'; +export {ToastQueue as UNSTABLE_ToastQueue} from '@react-stately/toast'; export {ListLayout, GridLayout, WaterfallLayout} from '@react-stately/layout'; export {Layout, LayoutInfo, Size, Rect, Point} from '@react-stately/virtualizer'; @@ -133,6 +136,8 @@ export type {TagGroupProps, TagListProps, TagListRenderProps, TagProps, TagRende export type {TextAreaProps} from './TextArea'; export type {TextFieldProps, TextFieldRenderProps} from './TextField'; export type {TextProps} from './Text'; +export type {ToastRegionProps, ToastRegionRenderProps, ToastProps, ToastRenderProps} from './Toast'; +export type {ToastOptions} from '@react-stately/toast'; export type {ToggleButtonProps, ToggleButtonRenderProps} from './ToggleButton'; export type {ToggleButtonGroupProps, ToggleButtonGroupRenderProps} from './ToggleButtonGroup'; export type {ToolbarProps, ToolbarRenderProps} from './Toolbar'; diff --git a/packages/react-aria-components/test/Toast.test.js b/packages/react-aria-components/test/Toast.test.js new file mode 100644 index 00000000000..621a7e4ea57 --- /dev/null +++ b/packages/react-aria-components/test/Toast.test.js @@ -0,0 +1,290 @@ +/* + * 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 {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {Button, Text, UNSTABLE_Toast as Toast, UNSTABLE_ToastContent as ToastContent, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastRegion as ToastRegion} from 'react-aria-components'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +function Example(options) { + const queue = new ToastQueue(); + return ( + <> + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description} + + + + )} + + + + ); +} + +describe('Toast', () => { + installPointerEvent(); + + let user; + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => jest.runAllTimers()); + }); + + it('should trigger a toast', async () => { + let {getByRole, queryByRole} = render(); + + let button = getByRole('button'); + + expect(queryByRole('alertdialog')).toBeNull(); + expect(queryByRole('alert')).toBeNull(); + await user.click(button); + + act(() => jest.advanceTimersByTime(100)); + + let region = getByRole('region'); + expect(region).toHaveAttribute('aria-label', '1 notification.'); + expect(region).toHaveAttribute('class', 'react-aria-ToastRegion'); + + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); + expect(toast).toHaveAttribute('class', 'react-aria-Toast'); + expect(toast).toHaveAttribute('aria-labelledby'); + expect(document.getElementById(toast.getAttribute('aria-labelledby'))).toHaveTextContent('Toast'); + expect(toast).toHaveAttribute('aria-describedby'); + expect(document.getElementById(toast.getAttribute('aria-describedby'))).toHaveTextContent('Description'); + expect(toast).toHaveAttribute('aria-modal', 'false'); + + let alert = within(toast).getByRole('alert'); + expect(alert).toBeVisible(); + expect(alert).toHaveAttribute('class', 'react-aria-ToastContent'); + + button = within(toast).getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'Close'); + await user.click(button); + + expect(queryByRole('alertdialog')).toBeNull(); + expect(queryByRole('alert')).toBeNull(); + }); + + it('removes a toast via timeout', async () => { + let {getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); + + act(() => jest.advanceTimersByTime(1000)); + act(() => jest.advanceTimersByTime(5000)); + expect(queryByRole('alertdialog')).toBeNull(); + }); + + it('pauses timers when hovering', async () => { + let {getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); + + act(() => jest.advanceTimersByTime(1000)); + await user.hover(toast); + + act(() => jest.advanceTimersByTime(7000)); + + await user.unhover(toast); + act(() => jest.advanceTimersByTime(4000)); + + expect(queryByRole('alertdialog')).toBeNull(); + }); + + it('pauses timers when focusing', async () => { + let {getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); + + act(() => jest.advanceTimersByTime(1000)); + act(() => within(toast).getByRole('button').focus()); + + act(() => jest.advanceTimersByTime(7000)); + + act(() => within(toast).getByRole('button').blur()); + act(() => jest.advanceTimersByTime(4000)); + + expect(queryByRole('alertdialog')).toBeNull(); + }); + + it('can focus toast region using F6', async () => { + let {getByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + + let toast = getByRole('alertdialog'); + expect(toast).toBeVisible(); + + expect(document.activeElement).toBe(button); + await user.keyboard('{F6}'); + + let region = getByRole('region'); + expect(document.activeElement).toBe(region); + }); + + it('should restore focus when a toast exits', async () => { + let {getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + + let toast = getByRole('alertdialog'); + let closeButton = within(toast).getByRole('button'); + + await user.click(closeButton); + expect(queryByRole('alertdialog')).toBeNull(); + expect(button).toHaveFocus(); + }); + + it('should move focus to remaining toast when a toast exits and there are more', async () => { + let {getAllByRole, getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + await user.click(button); + + let toast = getAllByRole('alertdialog')[0]; + let closeButton = within(toast).getByRole('button'); + await user.click(closeButton); + + toast = getByRole('alertdialog'); + expect(document.activeElement).toBe(toast); + + closeButton = within(toast).getByRole('button'); + await user.click(closeButton); + + expect(queryByRole('alertdialog')).toBeNull(); + expect(document.activeElement).toBe(button); + }); + + it('should move focus from the last toast to remaining toast when a the last toast is closed', async () => { + let {getAllByRole, getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + await user.click(button); + + let toast = getAllByRole('alertdialog')[1]; + let closeButton = within(toast).getByRole('button'); + await user.click(closeButton); + + toast = getByRole('alertdialog'); + expect(document.activeElement).toBe(toast); + expect(toast).toHaveAttribute('data-focused', 'true'); + + closeButton = within(toast).getByRole('button'); + await user.click(closeButton); + + expect(queryByRole('alertdialog')).toBeNull(); + expect(document.activeElement).toBe(button); + }); + + it('should support programmatically closing toasts', async () => { + const queue = new ToastQueue(); + function ToastToggle() { + let [key, setKey] = React.useState(null); + + return ( + <> + + {({toast}) => ( + + + {toast.content} + + + + )} + + + + ); + } + + let {getByRole, queryByRole} = render(); + let button = getByRole('button'); + + await user.click(button); + + act(() => jest.advanceTimersByTime(100)); + let toast = getByRole('alertdialog'); + let alert = within(toast).getByRole('alert'); + expect(toast).toBeVisible(); + expect(alert).toBeVisible(); + + await user.click(button); + expect(queryByRole('alertdialog')).toBeNull(); + expect(queryByRole('alert')).toBeNull(); + }); + + it('should support custom aria-label', async () => { + let queue = new ToastQueue(); + let {getByRole} = render( + <> + + {({toast}) => ( + + + {toast.content} + + + + )} + + + + ); + + let button = getByRole('button'); + await user.click(button); + + let region = getByRole('region'); + expect(region).toHaveAttribute('aria-label', 'Toasts'); + }); +}); diff --git a/scripts/extractExamples.mjs b/scripts/extractExamples.mjs index 4eba7559fc8..1757dc4d6cb 100644 --- a/scripts/extractExamples.mjs +++ b/scripts/extractExamples.mjs @@ -107,6 +107,8 @@ import ReactDOM from 'react-dom/client'; } fs.copyFileSync('lib/svg.d.ts', `${distDir}/svg.d.ts`); +fs.copyFileSync('lib/css.d.ts', `${distDir}/css.d.ts`); +fs.copyFileSync('lib/viewTransitions.d.ts', `${distDir}/viewTransitions.d.ts`); fs.writeFileSync(`${distDir}/tsconfig.json`, `{ "compilerOptions": { "target": "es2018", diff --git a/tsconfig.json b/tsconfig.json index 4df9d95a37e..b05da2d90c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,7 +45,9 @@ }, "include": [ "packages", - "lib/svg.d.ts" + "lib/svg.d.ts", + "lib/viewTransitions.d.ts", + "lib/css.d.ts" ], "exclude": [ "**/node_modules", diff --git a/yarn.lock b/yarn.lock index c3523240ee8..1fc5084fb7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29016,6 +29016,7 @@ __metadata: "@react-aria/interactions": "npm:^3.23.0" "@react-aria/live-announcer": "npm:^3.4.1" "@react-aria/menu": "npm:^3.17.0" + "@react-aria/toast": "npm:3.0.0-beta.19" "@react-aria/toolbar": "npm:3.0.0-beta.12" "@react-aria/tree": "npm:3.0.0-beta.3" "@react-aria/utils": "npm:^3.27.0" @@ -29027,6 +29028,7 @@ __metadata: "@react-stately/menu": "npm:^3.9.1" "@react-stately/selection": "npm:^3.19.0" "@react-stately/table": "npm:^3.13.1" + "@react-stately/toast": "npm:3.0.0-beta.7" "@react-stately/utils": "npm:^3.10.5" "@react-stately/virtualizer": "npm:^4.2.1" "@react-types/color": "npm:^3.0.2"