diff --git a/docs/reference/generated/toast-close.json b/docs/reference/generated/toast-close.json new file mode 100644 index 0000000000..7381d9df92 --- /dev/null +++ b/docs/reference/generated/toast-close.json @@ -0,0 +1,16 @@ +{ + "name": "ToastClose", + "description": "Closes the toast when clicked.\nRenders a ` + + + + + ); +} + +function Toasts() { + const { toasts } = Toast.useToast(); + return toasts.map((toast) => ( + + {toast.title && {toast.title}} + {toast.description && ( + {toast.description} + )} + {toast.type === 'undo' && ( + + )} + + x + + + )); +} + +function ToastPromiseExample() { + const toast = Toast.useToast(); + + const handlePromiseClick = () => { + toast + .promise(fetchUserData(), { + loading: 'Fetching user data...', + success: 'User data loaded!', + error: 'Failed to load user data', + }) + .then((data) => { + console.log('User data:', data); + }) + .catch((err) => { + console.error('Error handled:', err); + }); + }; + + return ( + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/custom/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/toast/demos/custom/css-modules/index.module.css new file mode 100644 index 0000000000..16eb3f0f27 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/custom/css-modules/index.module.css @@ -0,0 +1,280 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Viewport { + position: fixed; + width: 100%; + max-width: 320px; + margin: 0 auto; + left: 0; + right: 0; + + &[data-position='top'] { + top: 1rem; + } + + &[data-position='top-left'] { + top: 1rem; + left: 1rem; + right: auto; + align-items: flex-start; + } + + &[data-position='top-right'] { + top: 1rem; + right: 1rem; + left: auto; + align-items: flex-end; + } + + &[data-position='bottom'] { + bottom: 2rem; + top: auto; + } + + &[data-position='bottom-left'] { + bottom: 2rem; + left: 2rem; + right: auto; + top: auto; + } + + &[data-position='bottom-right'] { + bottom: 2rem; + right: 2rem; + left: auto; + top: auto; + } +} + +.Toast { + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + box-sizing: border-box; + background: var(--color-gray-50); + color: var(--color-gray-900); + border: 1px solid var(--color-gray-200); + padding: 1rem; + width: 300px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + border-radius: 0.5rem; + transition: + transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.5s; + user-select: none; + z-index: calc(2147483647 - var(--toast-index)); + + --gap: 10px; + + &::after { + content: ''; + position: absolute; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); + } + + &[data-position^='top'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * 20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) + calc(var(--toast-index) * var(--gap))) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(-150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position^='bottom'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * -20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + bottom: 0; + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) * -1 + calc(var(--toast-index) * var(--gap) * -1)) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position$='left'] { + right: auto; + margin-left: 0; + } + + &[data-position$='right'] { + left: auto; + margin-right: 0; + } + + &[data-ending-style] { + transition-duration: 0.2s; + transition-timing-function: ease-in; + opacity: 0; + } +} + +.Title { + font-weight: 500; + font-size: 0.975rem; + line-height: 1.25rem; +} + +.Description { + font-size: 0.925rem; + line-height: 1.25rem; + color: var(--color-gray-700); +} + +.Actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.UndoButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 2rem; + padding: 0 0.75rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + border-radius: 0.25rem; + background-color: var(--color-gray-900); + color: var(--color-gray-50); + border: none; + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: none; + background: transparent; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-gray-500); + border-radius: 0.25rem; + + &:hover { + background-color: var(--color-gray-100); + color: var(--color-gray-700); + } +} + +.Icon { + width: 1rem; + height: 1rem; +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/custom/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/toast/demos/custom/css-modules/index.tsx new file mode 100644 index 0000000000..9db3b954c4 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/custom/css-modules/index.tsx @@ -0,0 +1,97 @@ +'use client'; +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import styles from './index.module.css'; + +interface CustomToastData { + userId: string; +} + +function isCustomToast( + toast: Toast.Root.ToastType, +): toast is Toast.Root.ToastType { + return toast.data?.userId !== undefined; +} + +export default function CustomToastExample() { + return ( + + + + + + + ); +} + +function CustomToast() { + const toast = Toast.useToast(); + + function action() { + const data: CustomToastData = { + userId: '123', + }; + + toast.add({ + title: 'Toast with custom data', + data, + }); + } + + return ( + + ); +} + +function ToastList() { + const { toasts } = Toast.useToast(); + + return toasts.map((toast) => ( + + {toast.title && ( + {toast.title} + )} + {toast.description && ( + + {toast.description} + + )} + {isCustomToast(toast) && toast.data && ( + + `data.userId` is {toast.data.userId} + + )} + + + + + )); +} + +function XIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/custom/index.ts b/docs/src/app/(public)/(content)/react/components/toast/demos/custom/index.ts new file mode 100644 index 0000000000..94264ab85d --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/custom/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default as CssModules } from './css-modules'; diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/hero/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/css-modules/index.module.css new file mode 100644 index 0000000000..dfb5b55a2b --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/css-modules/index.module.css @@ -0,0 +1,255 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Viewport { + position: fixed; + width: 100%; + max-width: 320px; + margin: 0 auto; + left: 0; + right: 0; + + &[data-position='top'] { + top: 1rem; + } + + &[data-position='top-left'] { + top: 1rem; + left: 1rem; + right: auto; + align-items: flex-start; + } + + &[data-position='top-right'] { + top: 1rem; + right: 1rem; + left: auto; + align-items: flex-end; + } + + &[data-position='bottom'] { + bottom: 2rem; + top: auto; + } + + &[data-position='bottom-left'] { + bottom: 2rem; + left: 2rem; + right: auto; + top: auto; + } + + &[data-position='bottom-right'] { + bottom: 2rem; + right: 2rem; + left: auto; + top: auto; + } +} + +.Toast { + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + box-sizing: border-box; + background: var(--color-gray-50); + color: var(--color-gray-900); + border: 1px solid var(--color-gray-200); + padding: 1rem; + width: 300px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + background-clip: padding-box; + border-radius: 0.5rem; + transition: + transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.5s; + user-select: none; + z-index: calc(1000 - var(--toast-index)); + + --gap: 10px; + + &::after { + content: ''; + position: absolute; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); + } + + &[data-position^='top'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * 20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) + calc(var(--toast-index) * var(--gap))) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(-150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position^='bottom'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * -20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + bottom: 0; + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) * -1 + calc(var(--toast-index) * var(--gap) * -1)) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position$='left'] { + right: auto; + margin-left: 0; + } + + &[data-position$='right'] { + left: auto; + margin-right: 0; + } + + &[data-ending-style] { + transition-duration: 0.2s; + transition-timing-function: ease-in; + opacity: 0; + } +} + +.Title { + font-weight: 500; + font-size: 0.975rem; + line-height: 1.25rem; +} + +.Description { + font-size: 0.925rem; + line-height: 1.25rem; + color: var(--color-gray-700); +} + +.Close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: none; + background: transparent; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-gray-500); + border-radius: 0.25rem; + + &:hover { + background-color: var(--color-gray-100); + color: var(--color-gray-700); + } +} + +.Icon { + width: 1rem; + height: 1rem; +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/hero/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/css-modules/index.tsx new file mode 100644 index 0000000000..a19f880d66 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/css-modules/index.tsx @@ -0,0 +1,79 @@ +'use client'; +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import styles from './index.module.css'; + +export default function ExampleToast() { + return ( + + + + + + + ); +} + +function ToastButton() { + const toast = Toast.useToast(); + const [count, setCount] = React.useState(0); + + function createToast() { + setCount((prev) => prev + 1); + toast.add({ + title: `Toast ${count + 1} created`, + description: 'This is a toast notification.', + }); + } + + return ( + + ); +} + +function ToastList() { + const { toasts } = Toast.useToast(); + + return toasts.map((toast) => ( + + {toast.title && ( + {toast.title} + )} + {toast.description && ( + + {toast.description} + + )} + + + + + )); +} + +function XIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/hero/index.ts b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/index.ts new file mode 100644 index 0000000000..80097d6015 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/index.ts @@ -0,0 +1,3 @@ +'use client'; +export { default as CssModules } from './css-modules'; +export { default as Tailwind } from './tailwind'; diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/hero/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/tailwind/index.tsx new file mode 100644 index 0000000000..f7b22163d0 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/hero/tailwind/index.tsx @@ -0,0 +1,148 @@ +'use client'; +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; + +export default function ExampleToast() { + return ( + + + + + + + ); +} + +function ToastButton() { + const toast = Toast.useToast(); + const [count, setCount] = React.useState(0); + + function createToast() { + setCount((prev) => prev + 1); + toast.add({ + title: `Toast ${count + 1} created`, + description: 'This is a toast notification.', + }); + } + + return ( + + ); +} + +function ToastList() { + const { toasts } = Toast.useToast(); + + return toasts.map((toast) => ( + + {toast.title && ( + + {toast.title} + + )} + {toast.description && ( + + {toast.description} + + )} + + + + + )); +} + +function XIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/promise/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/toast/demos/promise/css-modules/index.module.css new file mode 100644 index 0000000000..4ec3ce4d64 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/promise/css-modules/index.module.css @@ -0,0 +1,263 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Viewport { + position: fixed; + width: 100%; + max-width: 320px; + margin: 0 auto; + left: 0; + right: 0; + + &[data-position='top'] { + top: 1rem; + } + + &[data-position='top-left'] { + top: 1rem; + left: 1rem; + right: auto; + align-items: flex-start; + } + + &[data-position='top-right'] { + top: 1rem; + right: 1rem; + left: auto; + align-items: flex-end; + } + + &[data-position='bottom'] { + bottom: 2rem; + top: auto; + } + + &[data-position='bottom-left'] { + bottom: 2rem; + left: 2rem; + right: auto; + top: auto; + } + + &[data-position='bottom-right'] { + bottom: 2rem; + right: 2rem; + left: auto; + top: auto; + } +} + +.Toast { + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + box-sizing: border-box; + background: var(--color-gray-50); + color: var(--color-gray-900); + border: 1px solid var(--color-gray-200); + padding: 1rem; + width: 300px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + background-clip: padding-box; + border-radius: 0.5rem; + transition: + transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.5s; + user-select: none; + z-index: calc(2147483647 - var(--toast-index)); + + --gap: 10px; + + &::after { + content: ''; + position: absolute; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); + } + + &[data-type='success'] { + background-color: lightgreen; + } + + &[data-type='error'] { + background-color: lightpink; + } + + &[data-position^='top'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * 20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) + calc(var(--toast-index) * var(--gap))) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(-150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position^='bottom'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * -20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + bottom: 0; + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) * -1 + calc(var(--toast-index) * var(--gap) * -1)) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position$='left'] { + right: auto; + margin-left: 0; + } + + &[data-position$='right'] { + left: auto; + margin-right: 0; + } + + &[data-ending-style] { + transition-duration: 0.2s; + transition-timing-function: ease-in; + opacity: 0; + } +} + +.Title { + font-weight: 500; + font-size: 0.975rem; + line-height: 1.25rem; +} + +.Description { + font-size: 0.925rem; + line-height: 1.25rem; + color: var(--color-gray-700); +} + +.Close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: none; + background: transparent; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-gray-500); + border-radius: 0.25rem; + + &:hover { + background-color: var(--color-gray-100); + color: var(--color-gray-700); + } +} + +.Icon { + width: 1rem; + height: 1rem; +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/promise/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/toast/demos/promise/css-modules/index.tsx new file mode 100644 index 0000000000..bb6114dcf9 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/promise/css-modules/index.tsx @@ -0,0 +1,93 @@ +'use client'; +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import styles from './index.module.css'; + +export default function PromiseToastExample() { + return ( + + + + + + + ); +} + +function PromiseDemo() { + const toast = Toast.useToast(); + + function runPromise() { + toast.promise( + // Simulate an API request with a promise that resolves after 2 seconds + new Promise((resolve, reject) => { + const shouldSucceed = Math.random() > 0.3; // 70% success rate + setTimeout(() => { + if (shouldSucceed) { + resolve('operation completed'); + } else { + reject(new Error('operation failed')); + } + }, 2000); + }), + { + loading: 'Loading data...', + success: (data: string) => `Success: ${data}`, + error: (err: Error) => `Error: ${err.message}`, + }, + ); + } + + return ( + + ); +} + +function ToastList() { + const { toasts } = Toast.useToast(); + + return toasts.map((toast) => ( + + {toast.title && ( + {toast.title} + )} + {toast.description && ( + + {toast.description} + + )} + + + + + )); +} + +function XIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/promise/index.ts b/docs/src/app/(public)/(content)/react/components/toast/demos/promise/index.ts new file mode 100644 index 0000000000..94264ab85d --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/promise/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default as CssModules } from './css-modules'; diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/undo/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/toast/demos/undo/css-modules/index.module.css new file mode 100644 index 0000000000..16eb3f0f27 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/undo/css-modules/index.module.css @@ -0,0 +1,280 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Viewport { + position: fixed; + width: 100%; + max-width: 320px; + margin: 0 auto; + left: 0; + right: 0; + + &[data-position='top'] { + top: 1rem; + } + + &[data-position='top-left'] { + top: 1rem; + left: 1rem; + right: auto; + align-items: flex-start; + } + + &[data-position='top-right'] { + top: 1rem; + right: 1rem; + left: auto; + align-items: flex-end; + } + + &[data-position='bottom'] { + bottom: 2rem; + top: auto; + } + + &[data-position='bottom-left'] { + bottom: 2rem; + left: 2rem; + right: auto; + top: auto; + } + + &[data-position='bottom-right'] { + bottom: 2rem; + right: 2rem; + left: auto; + top: auto; + } +} + +.Toast { + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + box-sizing: border-box; + background: var(--color-gray-50); + color: var(--color-gray-900); + border: 1px solid var(--color-gray-200); + padding: 1rem; + width: 300px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + border-radius: 0.5rem; + transition: + transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.5s; + user-select: none; + z-index: calc(2147483647 - var(--toast-index)); + + --gap: 10px; + + &::after { + content: ''; + position: absolute; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); + } + + &[data-position^='top'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * 20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) + calc(var(--toast-index) * var(--gap))) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(-150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position^='bottom'] { + transform: translate( + var(--toast-swipe-move-x), + calc(var(--toast-swipe-move-y) + calc(var(--toast-index) * -20%)) + ) + scale(calc(1 - (var(--toast-index) * 0.1))); + bottom: 0; + + &::after { + top: 100%; + } + + &[data-expanded] { + transform: translate( + var(--toast-swipe-move-x), + calc( + calc(var(--toast-offset) * -1 + calc(var(--toast-index) * var(--gap) * -1)) + + var(--toast-swipe-move-y) + ) + ); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(150%); + } + + &[data-ending-style] { + &[data-swipe-direction='up'] { + transform: translateY(calc(var(--toast-swipe-move-y) - 100%)); + } + + &[data-swipe-direction='left'] { + transform: translateX(calc(var(--toast-swipe-move-x) - 100%)); + } + + &[data-swipe-direction='right'] { + transform: translateX(calc(var(--toast-swipe-move-x) + 100%)); + } + + &[data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-move-y) + 100%)); + } + } + } + + &[data-position$='left'] { + right: auto; + margin-left: 0; + } + + &[data-position$='right'] { + left: auto; + margin-right: 0; + } + + &[data-ending-style] { + transition-duration: 0.2s; + transition-timing-function: ease-in; + opacity: 0; + } +} + +.Title { + font-weight: 500; + font-size: 0.975rem; + line-height: 1.25rem; +} + +.Description { + font-size: 0.925rem; + line-height: 1.25rem; + color: var(--color-gray-700); +} + +.Actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.UndoButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 2rem; + padding: 0 0.75rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + border-radius: 0.25rem; + background-color: var(--color-gray-900); + color: var(--color-gray-50); + border: none; + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: none; + background: transparent; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-gray-500); + border-radius: 0.25rem; + + &:hover { + background-color: var(--color-gray-100); + color: var(--color-gray-700); + } +} + +.Icon { + width: 1rem; + height: 1rem; +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/undo/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/toast/demos/undo/css-modules/index.tsx new file mode 100644 index 0000000000..c6396cb1e6 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/undo/css-modules/index.tsx @@ -0,0 +1,97 @@ +'use client'; +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import styles from './index.module.css'; + +export default function UndoToastExample() { + return ( + + + + + + + ); +} + +function SaveForm() { + const toast = Toast.useToast(); + + function action() { + const id = toast.add({ + title: 'Action performed', + description: 'You can undo this action.', + type: 'success', + actionProps: { + children: 'Undo', + onClick() { + toast.remove(id); + toast.add({ + title: 'Action undone', + }); + }, + }, + }); + } + + return ( + + ); +} + +function ToastList() { + const { toasts } = Toast.useToast(); + + return toasts.map((toast) => ( + + {toast.title && ( + {toast.title} + )} + {toast.description && ( + + {toast.description} + + )} + {toast.actionProps && ( +
+
+ )} + + + +
+ )); +} + +function XIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/toast/demos/undo/index.ts b/docs/src/app/(public)/(content)/react/components/toast/demos/undo/index.ts new file mode 100644 index 0000000000..94264ab85d --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/demos/undo/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default as CssModules } from './css-modules'; diff --git a/docs/src/app/(public)/(content)/react/components/toast/page.mdx b/docs/src/app/(public)/(content)/react/components/toast/page.mdx new file mode 100644 index 0000000000..3b4a9ca396 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/toast/page.mdx @@ -0,0 +1,250 @@ +# Toast + +Generates toast notifications. + + + + + +## Usage + +- `` can be wrapped around your entire app, ensuring all toasts are rendered in the same viewport. +- `` is positioned with your own CSS. The example above specifies `data-position` attributes for the CSS to adjust the position of the viewport container—the available positions in the example are `top`, `top-left`, `top-right`, `bottom`, `bottom-left`, and `bottom-right`, but you're free to change the CSS as needed. +- F6 lets users jump into the toast viewport landmark region to navigate toasts with + their keyboard. + +## API reference + +Import the component and assemble its parts: + +```jsx title="Anatomy" +import { Toast } from '@base-ui-components/react/toast'; + + + + + + + + + +; +``` + + + +## useToast + +Manages toasts, called inside of a `Toast.Provider`. + +```tsx +const toast = Toast.useToast(); +``` + +### Return value + + void', + description: 'Add a toast to the toast list.', + }, + remove: { + type: '(toastId: string) => void', + description: 'Remove a toast from the toast list.', + }, + update: { + type: '(toastId: string, options: useToast.UpdateOptions) => void', + description: 'Update a toast in the toast list.', + }, + promise: { + type: '(promise: Promise, options: useToast.PromiseOptions) => void', + description: + 'Create a toast that resolves with a value, with three possible states for the toast: `loading`, `success`, and `error`.', + }, + }} +/> + +### Method options + + void', + description: 'A callback invoked when the toast is removed.', + }, + onRemoveComplete: { + type: '() => void', + description: + 'A callback invoked when the toast is removed after any animations are complete.', + }, + actionProps: { + type: "React.ComponentPropsWithRef<'button'>", + description: 'The props of the action button.', + }, + data: { + type: 'Record', + description: 'The data of the toast.', + }, + }} +/> + +### `add` method + +Creates a toast by adding it to the toast list. + +```jsx +const toastId = toast.add({ + title: 'Hello, world!', +}); +``` + +```jsx title="Usage" {2,7} +function App() { + const toast = Toast.useToast(); + return ( + + ); +} +``` + +### `update` method + +The `add` method returns a toast ID, which can be used to update the toast later. + +```jsx +toast.update(toastId, { + title: 'New title', +}); +``` + +### `remove` method + +The `add` method returns a toast ID, which can be used to remove the toast from the toast list later. + +```jsx +toast.remove(toastId); +``` + +### `promise` method + +Creates an asynchronous toast that resolves with a value, with three possible states for the toast: `loading`, `success`, and `error`. + +```tsx +const toastId = toast.promise( + new Promise((resolve) => { + setTimeout(() => resolve('world!'), 1000); + }), + { + // Each are a shortcut for the title of the toast. + loading: 'Loading...', + success: (data) => `Hello ${data}`, + error: (err) => `Error: ${err}`, + }, +); +``` + +Each state also accepts the [method options](/react/components/toast#method-options) object to granularly control the toast for each state: + +```tsx +const toastId = toast.promise( + new Promise((resolve) => { + setTimeout(() => resolve('world!'), 1000); + }), + { + loading: { + title: 'Loading...', + description: 'The promise is loading.', + }, + success: { + title: 'Success', + description: 'The promise resolved successfully.', + }, + error: { + title: 'Error', + description: 'The promise rejected.', + actionProps: { + children: 'Contact support', + onClick() { + // Redirect to support page + }, + }, + }, + }, +); +``` + +## Global manager + +A global toast manager can be created by passing the `toastManager` prop to the `Toast.Provider`. This enables you to queue a toast from anywhere in the app (such as in functions outside the React tree) while still using the same toast renderer. + +The created `toastManager` object has the same properties and methods as the `Toast.useToast()` hook. + +```tsx +const toastManager = Toast.createToastManager(); +``` + +```jsx + +``` + +## Examples + +### Undo action + +When adding a toast, the `actionProps` option can be used to define props for an action button inside of it—this enables the ability to undo an action associated with the toast. + + + +### Promise + +An asynchronous toast can be created with three possible states for the toast's title: `loading`, `success`, and `error`. The `type` string matches these states to change the styling. Each of the states also accepts the [method options](/react/components/toast#method-options) object for more granular control. + + + +### Custom + +A toast with custom data can be created by passing any typed object interface to the `data` option. This enables you to pass any data (including functions) you need to the toast and access it in the toast's rendering logic. + + diff --git a/docs/src/components/ReferenceTable/PropsReferenceTable.tsx b/docs/src/components/ReferenceTable/PropsReferenceTable.tsx index 9d30687fea..9155567289 100644 --- a/docs/src/components/ReferenceTable/PropsReferenceTable.tsx +++ b/docs/src/components/ReferenceTable/PropsReferenceTable.tsx @@ -9,18 +9,31 @@ import { TableCode } from '../TableCode'; interface PropsReferenceTableProps extends React.ComponentProps { data: Record; + type?: 'props' | 'return'; } -export async function PropsReferenceTable({ data, ...props }: PropsReferenceTableProps) { +export async function PropsReferenceTable({ + data, + type = 'props', + ...props +}: PropsReferenceTableProps) { return ( Prop - + Type - Default + {type === 'props' && ( + Default + )} @@ -51,9 +64,11 @@ export async function PropsReferenceTable({ data, ...props }: PropsReferenceTabl - - - + {type === 'props' && ( + + + + )} @@ -62,10 +77,12 @@ export async function PropsReferenceTable({ data, ...props }: PropsReferenceTabl
Type
-
-
Default
- -
+ {type === 'props' && ( +
+
Default
+ +
+ )}
diff --git a/docs/src/nav.ts b/docs/src/nav.ts index 32cdcf1ab0..36ad976e7b 100644 --- a/docs/src/nav.ts +++ b/docs/src/nav.ts @@ -132,6 +132,10 @@ export const nav = [ label: 'Tabs', href: '/react/components/tabs', }, + { + label: 'Toast', + href: '/react/components/toast', + }, { label: 'Toggle', href: '/react/components/toggle', diff --git a/packages/react/package.json b/packages/react/package.json index f747bcd61b..43e8ecaa3a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -53,6 +53,7 @@ "./slider": "./src/slider/index.ts", "./switch": "./src/switch/index.ts", "./tabs": "./src/tabs/index.ts", + "./toast": "./src/toast/index.ts", "./toggle": "./src/toggle/index.ts", "./toggle-group": "./src/toggle-group/index.ts", "./tooltip": "./src/tooltip/index.ts", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8bbe3a2fa9..97605278bf 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -24,6 +24,7 @@ export * from './separator'; export * from './slider'; export * from './switch'; export * from './tabs'; +export * from './toast'; export * from './toggle'; export * from './toggle-group'; export * from './tooltip'; diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index c3024c5bc9..38215b0189 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -10,6 +10,7 @@ import { tabsStyleHookMapping } from '../root/styleHooks'; import { useTabsListContext } from '../list/TabsListContext'; import { ActiveTabPosition, ActiveTabSize, useTabsIndicator } from './useTabsIndicator'; import { script as prehydrationScript } from './prehydrationScript.min'; +import { generateId } from '../../utils/generateId'; const noop = () => null; @@ -28,7 +29,7 @@ const TabsIndicator = React.forwardRef( const { tabsListRef } = useTabsListContext(); - const [instanceId] = React.useState(() => Math.random().toString(36).slice(2)); + const [instanceId] = React.useState(() => generateId('tab')); const [isMounted, setIsMounted] = React.useState(false); const { value: activeTabValue } = useTabsRootContext(); diff --git a/packages/react/src/toast/close/ToastClose.test.tsx b/packages/react/src/toast/close/ToastClose.test.tsx new file mode 100644 index 0000000000..84f75c17bf --- /dev/null +++ b/packages/react/src/toast/close/ToastClose.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import { screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { createRenderer, describeConformance } from '#test-utils'; +import { List, Button } from '../utils/test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + const toast = { + id: 'test', + title: 'title', + }; + + describeConformance(, () => ({ + refInstanceof: window.HTMLButtonElement, + render(node) { + return render( + + + {node} + + , + ); + }, + })); + + it('closes the toast when clicked', async () => { + const { user } = await render( + + + + + + ); + } + + await render( + + + + + + , + ); + + expect(screen.queryByTestId('title')).to.equal(null); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.queryByTestId('title')).not.to.equal(null); + + clock.tick(5000); + + expect(screen.queryByTestId('title')).to.equal(null); + }); + + it('returns a toast id', async () => { + const toastManager = Toast.createToastManager(); + + const toastId = toastManager.add({ + title: 'title', + }); + + expect(toastId).to.be.a('string'); + }); + }); + + describe('promise', () => { + it('adds a toast with the loading state that is updated with the success state', async () => { + const toastManager = Toast.createToastManager(); + + function add() { + toastManager.promise( + new Promise((resolve) => { + setTimeout(() => { + resolve('success'); + }, 1000); + }), + { + loading: 'loading', + success: 'success', + error: 'error', + }, + ); + } + + function AddButton() { + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + await flushMicrotasks(); + + expect(screen.queryByTestId('title')).to.have.text('loading'); + + clock.tick(1000); + await flushMicrotasks(); + + expect(screen.queryByTestId('title')).to.have.text('success'); + }); + + it('adds a toast with the loading state that is updated with the error state', async () => { + const toastManager = Toast.createToastManager(); + + function promise() { + toastManager + .promise( + new Promise((res, rej) => { + rej(new Error('error')); + }), + { + loading: 'loading', + success: 'success', + error: 'error', + }, + ) + .catch(() => { + // Swallow the error + }); + } + + function AddButton() { + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + await flushMicrotasks(); + + expect(screen.getByTestId('title')).to.have.text('error'); + }); + }); + + describe('update', () => { + it('updates a toast', async () => { + const toastManager = Toast.createToastManager(); + + let toastId: string; + + function add() { + toastId = toastManager.add({ + title: 'title', + }); + } + + function update() { + toastManager.update(toastId, { + title: 'updated', + }); + } + + function Buttons() { + return ( + + + + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + const updateButton = screen.getByRole('button', { name: 'update' }); + fireEvent.click(updateButton); + + expect(screen.getByTestId('title')).to.have.text('updated'); + }); + }); + + describe('remove', () => { + it('removes a toast', async () => { + const toastManager = Toast.createToastManager(); + + let toastId: string; + + function add() { + toastId = toastManager.add({ + title: 'title', + }); + } + + function remove() { + toastManager.remove(toastId); + } + + function Buttons() { + return ( + + + + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + const removeButton = screen.getByRole('button', { name: 'remove' }); + fireEvent.click(removeButton); + + expect(screen.queryByTestId('title')).to.equal(null); + }); + }); +}); diff --git a/packages/react/src/toast/createToastManager.ts b/packages/react/src/toast/createToastManager.ts new file mode 100644 index 0000000000..4a4079de35 --- /dev/null +++ b/packages/react/src/toast/createToastManager.ts @@ -0,0 +1,98 @@ +import { generateId } from '../utils/generateId'; +import { useToast } from './useToast'; + +export interface ToastManagerEvent { + action: 'add' | 'remove' | 'update' | 'promise'; + options: any; +} + +/** + * Creates a new toast manager. + */ +export function createToastManager(): createToastManager.ToastManager { + const listeners: ((data: ToastManagerEvent) => void)[] = []; + + function emit(data: ToastManagerEvent) { + listeners.forEach((listener) => listener(data)); + } + + return { + // This should be private aside from ToastProvider needing to access it. + // https://x.com/drosenwasser/status/1816947740032872664 + ' subscribe': function subscribe(listener: (data: ToastManagerEvent) => void) { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + }; + }, + + add(options: useToast.AddOptions): string { + const id = options.id || generateId('toast'); + const toastToAdd = { + ...options, + id, + animation: 'starting' as const, + }; + + emit({ + action: 'add', + options: toastToAdd, + }); + + return id; + }, + + remove(id: string): void { + emit({ + action: 'remove', + options: { id }, + }); + }, + + update(id: string, updates: useToast.UpdateOptions): void { + emit({ + action: 'update', + options: { + ...updates, + id, + }, + }); + }, + + promise( + promiseValue: Promise, + options: useToast.PromiseOptions, + ): Promise { + let handledPromise = promiseValue; + + emit({ + action: 'promise', + options: { + ...options, + promise: promiseValue, + setPromise(promise: Promise) { + handledPromise = promise; + }, + }, + }); + + return handledPromise; + }, + }; +} + +export namespace createToastManager { + export interface ToastManager { + ' subscribe': (listener: (data: ToastManagerEvent) => void) => () => void; + add: (options: useToast.AddOptions) => string; + remove: (id: string) => void; + update: (id: string, updates: useToast.UpdateOptions) => void; + promise: ( + promiseValue: Promise, + options: useToast.PromiseOptions, + ) => Promise; + } +} diff --git a/packages/react/src/toast/description/ToastDescription.test.tsx b/packages/react/src/toast/description/ToastDescription.test.tsx new file mode 100644 index 0000000000..603e53a816 --- /dev/null +++ b/packages/react/src/toast/description/ToastDescription.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import { createRenderer, describeConformance } from '#test-utils'; +import { screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { List, Button } from '../utils/test-utils'; + +const toast = { + id: 'test', + title: 'Toast title', +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLParagraphElement, + render(node) { + return render( + + + {node} + + , + ); + }, + })); + + it('adds aria-describedby to the root element', async () => { + const { user } = await render( + + + + + + ); + } + + function AccessibilityTestList() { + return Toast.useToast().toasts.map((toastItem) => ( + + {toastItem.title} + {toastItem.description} + + + )); + } + + await render( + + + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'add' })); + + const status = screen.getByRole('status'); + expect(status).not.to.have.text('titledescription'); + + await waitFor(() => { + expect(status).to.have.text('titledescription'); + }); + }); +}); diff --git a/packages/react/src/toast/root/ToastRoot.tsx b/packages/react/src/toast/root/ToastRoot.tsx new file mode 100644 index 0000000000..24d4735e6b --- /dev/null +++ b/packages/react/src/toast/root/ToastRoot.tsx @@ -0,0 +1,625 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { activeElement, contains, getTarget } from '@floating-ui/react/utils'; +import { useToastContext } from '../provider/ToastProviderContext'; +import type { BaseUIComponentProps } from '../../utils/types'; +import type { Toast } from '../useToast'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { ToastRootContext } from './ToastRootContext'; +import { useForkRef } from '../../utils/useForkRef'; +import { mergeProps } from '../../merge-props'; +import { ToastRootCssVars } from './ToastRootCssVars'; +import { transitionStatusMapping } from '../../utils/styleHookMapping'; +import type { TransitionStatus } from '../../utils/useTransitionStatus'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete'; +import { ownerDocument } from '../../utils/owner'; +import { visuallyHidden } from '../../utils/visuallyHidden'; +import { ToastRootDataAttributes } from './ToastRootDataAttributes'; + +const SWIPE_THRESHOLD = 15; +const OPPOSITE_DIRECTION_DAMPING_FACTOR = 0.5; +const MIN_DRAG_THRESHOLD = 0; + +/** + * Groups all parts of an individual toast. + * Renders a `
` element. + * + * Documentation: [Base UI Toast](https://base-ui.com/react/components/toast) + */ +const ToastRoot = React.forwardRef(function ToastRoot( + props: ToastRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { toast, render, className, children, swipeDirection = 'up', ...other } = props; + + const swipeDirections = Array.isArray(swipeDirection) ? swipeDirection : [swipeDirection]; + + const { toasts, hovering, focused, finalizeRemove, setToasts, remove } = useToastContext(); + + const [renderChildren, setRenderChildren] = React.useState(false); + + const rootRef = React.useRef(null); + const mergedRef = useForkRef(rootRef, forwardedRef); + + useOpenChangeComplete({ + open: toast.animation !== 'ending', + ref: rootRef, + onComplete() { + if (toast.animation === 'ending') { + finalizeRemove(toast.id); + } + }, + }); + + const [isDragging, setIsDragging] = React.useState(false); + const [isRealDrag, setIsRealDrag] = React.useState(false); + const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 }); + const [dragDismissed, setDragDismissed] = React.useState(false); + const [swipeState, setSwipeState] = React.useState<'start' | 'move' | 'end' | 'cancel' | null>( + null, + ); + const [initialTransform, setInitialTransform] = React.useState({ x: 0, y: 0, scale: 1 }); + const [titleId, setTitleId] = React.useState(); + const [descriptionId, setDescriptionId] = React.useState(); + const [lockedDirection, setLockedDirection] = React.useState<'horizontal' | 'vertical' | null>( + null, + ); + + const dragStartPosRef = React.useRef({ x: 0, y: 0 }); + const initialTransformRef = React.useRef({ x: 0, y: 0, scale: 1 }); + const dragHistoryRef = React.useRef>([]); + + const domIndex = React.useMemo(() => toasts.indexOf(toast), [toast, toasts]); + const index = React.useMemo( + () => toasts.filter((t) => t.animation !== 'ending').indexOf(toast), + [toast, toasts], + ); + // It's not possible to stack a smaller height toast onto a larger height toast, but + // the reverse is possible. For simplicity, we'll enforce the expanded state if the + // toasts aren't all the same height. + const hasDifferingHeights = React.useMemo(() => { + return toasts.some((t) => t.height !== 0 && toast.height !== 0 && t.height !== toast.height); + }, [toast, toasts]); + + const state: ToastRoot.State = React.useMemo( + () => ({ + transitionStatus: toast.animation, + expanded: hovering || focused || hasDifferingHeights, + }), + [toast.animation, hovering, focused, hasDifferingHeights], + ); + + useEnhancedEffect(() => { + if (!rootRef.current) { + return undefined; + } + + function setHeights() { + const height = rootRef.current?.offsetHeight; + setToasts((prev) => + prev.map((t) => + t.id === toast.id + ? { + ...t, + ref: rootRef, + height, + animation: undefined, + } + : t, + ), + ); + } + + setHeights(); + + if (typeof ResizeObserver === 'function') { + const resizeObserver = new ResizeObserver(setHeights); + resizeObserver.observe(rootRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + + return undefined; + }, [toast.id, setToasts]); + + // Calculate offset based on heights of previous toasts + const offset = React.useMemo(() => { + const i = toasts.findIndex((t) => t.id === toast.id); + return toasts.slice(0, i).reduce((acc, t) => acc + (t.height ?? 0), 0); + }, [toasts, toast.id]); + + function getElementTransform(element: HTMLElement) { + const computedStyle = window.getComputedStyle(element); + const transform = computedStyle.transform; + + let translateX = 0; + let translateY = 0; + let scale = 1; + + // Parse transform matrix if it exists + if (transform && transform !== 'none') { + const matrix = transform.match(/matrix(?:3d)?\(([^)]+)\)/); + if (matrix) { + const values = matrix[1].split(', ').map(parseFloat); + + // Handle both 2D (6 values) and 3D (16 values) matrices + if (values.length === 6) { + // 2D matrix: matrix(a, b, c, d, tx, ty) + translateX = values[4]; + translateY = values[5]; + // Calculate scale from the matrix (approximate) + scale = Math.sqrt(values[0] * values[0] + values[1] * values[1]); + } else if (values.length === 16) { + // 3D matrix: matrix3d(...) + translateX = values[12]; + translateY = values[13]; + scale = values[0]; // Simplified scale calculation + } + } + } + + return { x: translateX, y: translateY, scale }; + } + + function applyDirectionalDamping(deltaX: number, deltaY: number) { + let newDeltaX = deltaX; + let newDeltaY = deltaY; + + // If horizontal direction is not allowed, apply damping to X movements + if (!swipeDirections.includes('left') && !swipeDirections.includes('right')) { + newDeltaX = + deltaX > 0 + ? deltaX ** OPPOSITE_DIRECTION_DAMPING_FACTOR + : -(Math.abs(deltaX) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); + } else { + // Apply damping based on allowed directions + if (!swipeDirections.includes('right') && deltaX > 0) { + newDeltaX = deltaX ** OPPOSITE_DIRECTION_DAMPING_FACTOR; + } + if (!swipeDirections.includes('left') && deltaX < 0) { + newDeltaX = -(Math.abs(deltaX) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); + } + } + + // If vertical direction is not allowed, apply damping to Y movements + if (!swipeDirections.includes('up') && !swipeDirections.includes('down')) { + newDeltaY = + deltaY > 0 + ? deltaY ** OPPOSITE_DIRECTION_DAMPING_FACTOR + : -(Math.abs(deltaY) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); + } else { + // Apply damping based on allowed directions + if (!swipeDirections.includes('down') && deltaY > 0) { + newDeltaY = deltaY ** OPPOSITE_DIRECTION_DAMPING_FACTOR; + } + if (!swipeDirections.includes('up') && deltaY < 0) { + newDeltaY = -(Math.abs(deltaY) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); + } + } + + return { x: newDeltaX, y: newDeltaY }; + } + + function handlePointerDown(event: React.PointerEvent) { + // Only handle left clicks or touch + if (event.button !== 0) { + return; + } + + // Check if the event target is (or is inside) an interactive element (button, a, input, etc.) + // If so, don't initiate dragging to allow normal click behavior + const target = event.target as HTMLElement; + const isInteractiveElement = + target.tagName === 'BUTTON' || + target.tagName === 'A' || + target.tagName === 'INPUT' || + target.closest('button,a,input,[role="button"]') !== null; + + if (isInteractiveElement) { + return; + } + + dragStartPosRef.current = { x: event.clientX, y: event.clientY }; + + if (rootRef.current) { + const transform = getElementTransform(rootRef.current); + initialTransformRef.current = transform; + setInitialTransform(transform); + setDragOffset({ + x: transform.x, + y: transform.y, + }); + } + + setIsDragging(true); + setIsRealDrag(false); + setLockedDirection(null); + setSwipeState('start'); + + if (rootRef.current) { + rootRef.current.setPointerCapture(event.pointerId); + } + } + + function handlePointerMove(event: React.PointerEvent) { + if (!isDragging) { + return; + } + + // Record position for velocity calculation + dragHistoryRef.current.push({ + x: event.clientX, + y: event.clientY, + time: Date.now(), + }); + + if (dragHistoryRef.current.length > 5) { + dragHistoryRef.current.shift(); + } + + const deltaX = event.clientX - dragStartPosRef.current.x; + const deltaY = event.clientY - dragStartPosRef.current.y; + + if (!isRealDrag) { + const movementDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + if (movementDistance >= MIN_DRAG_THRESHOLD) { + setIsRealDrag(true); + setSwipeState('move'); + + if (lockedDirection === null) { + // Only lock a direction if multiple directions are allowed + const hasHorizontalDirections = + swipeDirections.includes('left') || swipeDirections.includes('right'); + const hasVerticalDirections = + swipeDirections.includes('up') || swipeDirections.includes('down'); + + if (hasHorizontalDirections && hasVerticalDirections) { + // Determine the primary direction based on which has greater movement + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + setLockedDirection(absX > absY ? 'horizontal' : 'vertical'); + } + } + } + } + + // Apply damping to opposite direction movements + const dampedDelta = applyDirectionalDamping(deltaX, deltaY); + + let newOffsetX = initialTransformRef.current.x; + let newOffsetY = initialTransformRef.current.y; + + // If we have a locked direction, only allow movement in that direction + if (lockedDirection === 'horizontal') { + // Only allow x movement + if (swipeDirections.includes('left') || swipeDirections.includes('right')) { + newOffsetX += dampedDelta.x; + } + } else if (lockedDirection === 'vertical') { + // Only allow y movement + if (swipeDirections.includes('up') || swipeDirections.includes('down')) { + newOffsetY += dampedDelta.y; + } + } else { + // No locked direction yet, allow movement based on allowed directions + if (swipeDirections.includes('left') || swipeDirections.includes('right')) { + newOffsetX += dampedDelta.x; + } + + if (swipeDirections.includes('up') || swipeDirections.includes('down')) { + newOffsetY += dampedDelta.y; + } + } + + setDragOffset({ x: newOffsetX, y: newOffsetY }); + } + + function handlePointerUp(event: React.PointerEvent) { + if (!isDragging) { + return; + } + + setIsDragging(false); + setIsRealDrag(false); + setLockedDirection(null); + + if (rootRef.current) { + rootRef.current.releasePointerCapture(event.pointerId); + } + + // Check if swipe exceeds threshold in any of the allowed directions. + // Use the dragOffset (which respects the locked direction and damping) rather than raw event coordinates. + let shouldClose = false; + + const deltaX = dragOffset.x - initialTransform.x; + const deltaY = dragOffset.y - initialTransform.y; + + for (const direction of swipeDirections) { + switch (direction) { + case 'right': + if (deltaX > SWIPE_THRESHOLD) { + shouldClose = true; + } + break; + case 'left': + if (deltaX < -SWIPE_THRESHOLD) { + shouldClose = true; + } + break; + case 'down': + if (deltaY > SWIPE_THRESHOLD) { + shouldClose = true; + } + break; + case 'up': + if (deltaY < -SWIPE_THRESHOLD) { + shouldClose = true; + } + break; + default: + break; + } + + if (shouldClose) { + break; + } + } + + if (shouldClose) { + setDragDismissed(true); + setSwipeState('end'); + remove(toast.id); + } else { + setDragOffset({ + x: initialTransform.x, + y: initialTransform.y, + }); + setSwipeState('cancel'); + dragHistoryRef.current = []; + } + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + if ( + !rootRef.current || + !contains(rootRef.current, activeElement(ownerDocument(rootRef.current))) + ) { + return; + } + + remove(toast.id); + } + } + + // Prevent page scrolling when dragging the toast + React.useEffect(() => { + const element = rootRef.current; + + if (!element) { + return undefined; + } + + function preventDefaultTouchStart(event: TouchEvent) { + if (contains(element, getTarget(event) as HTMLElement | null)) { + event.preventDefault(); + } + } + + element.addEventListener('touchmove', preventDefaultTouchStart, { passive: false }); + + return () => { + element.removeEventListener('touchmove', preventDefaultTouchStart); + }; + }, []); + + // Add the effect from ToastContent for screen reader announcements + React.useEffect(() => { + const timeout = setTimeout( + () => { + setRenderChildren(true); + }, + // macOS Safari needs some time to pass after the status node has been + // created before changing its text content to reliably announce the toast + 50, + ); + return () => clearTimeout(timeout); + }, []); + + function getDragStyles() { + if ( + !isDragging && + dragOffset.x === initialTransform.x && + dragOffset.y === initialTransform.y && + !dragDismissed + ) { + return { + [ToastRootCssVars.swipeMoveX]: '0px', + [ToastRootCssVars.swipeMoveY]: '0px', + }; + } + + const deltaX = dragOffset.x - initialTransform.x; + const deltaY = dragOffset.y - initialTransform.y; + + // Determine primary swipe direction based on movement + let currentSwipeDirection: 'up' | 'down' | 'left' | 'right' | undefined; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + currentSwipeDirection = deltaX > 0 ? 'right' : 'left'; + } else { + currentSwipeDirection = deltaY > 0 ? 'down' : 'up'; + } + + // Only use a direction that's allowed + if (currentSwipeDirection && !swipeDirections.includes(currentSwipeDirection)) { + currentSwipeDirection = undefined; + } + + return { + transition: isDragging ? 'none' : undefined, + userSelect: isDragging ? 'none' : undefined, + touchAction: 'none', + [ToastRootCssVars.swipeMoveX]: `${deltaX}px`, + [ToastRootCssVars.swipeMoveY]: `${deltaY}px`, + } as const; + } + + function getSwipeDirection(): 'up' | 'down' | 'left' | 'right' | undefined { + if (!isRealDrag && !dragDismissed) { + return undefined; + } + + const deltaX = dragOffset.x - initialTransform.x; + const deltaY = dragOffset.y - initialTransform.y; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + return deltaX > 0 ? 'right' : 'left'; + } + + return deltaY > 0 ? 'down' : 'up'; + } + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + ref: mergedRef, + className, + state, + customStyleHookMapping: transitionStatusMapping, + extraProps: mergeProps( + { + role: toast.priority === 'high' ? 'alertdialog' : 'dialog', + tabIndex: 0, + 'aria-modal': false, + 'aria-labelledby': titleId, + 'aria-describedby': descriptionId, + 'data-base-ui-toast': toast.id, + [ToastRootDataAttributes.swipe]: swipeState, + [ToastRootDataAttributes.swipeDirection]: getSwipeDirection(), + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onKeyDown: handleKeyDown, + style: { + ...getDragStyles(), + [ToastRootCssVars.index]: toast.animation === 'ending' ? domIndex : index, + [ToastRootCssVars.offset]: `${offset}px`, + }, + // Screen readers won't announce the text upon DOM insertion + // of the component. We need to wait until the next tick to render the children + // so that screen readers can announce the contents. + children: ( + + {children} + {!focused && ( +
+ {renderChildren && ( + +
{toast.title}
+
{toast.description}
+
+ )} +
+ )} +
+ ), + }, + other, + ), + }); + + const contextValue = React.useMemo( + () => ({ + toast, + rootRef, + titleId, + setTitleId, + descriptionId, + setDescriptionId, + }), + [toast, rootRef, titleId, setTitleId, descriptionId, setDescriptionId], + ); + + return ( + {renderElement()} + ); +}); + +export namespace ToastRoot { + export type ToastType = Toast; + + export interface State { + transitionStatus: TransitionStatus; + expanded: boolean; + } + + export interface Props extends BaseUIComponentProps<'div', State> { + /** + * The toast to render. + */ + toast: Toast; + /** + * Direction(s) in which the toast can be swiped to dismiss. + * @default 'up' + */ + swipeDirection?: 'up' | 'down' | 'left' | 'right' | ('up' | 'down' | 'left' | 'right')[]; + } +} + +ToastRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * CSS class applied to the element, or a function that + * returns a class based on the component’s state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * Allows you to replace the component’s HTML element + * with a different tag, or compose it with another component. + * + * Accepts a `ReactElement` or a function that returns the element to render. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * Direction(s) in which the toast can be swiped to dismiss. + * @default 'up' + */ + swipeDirection: PropTypes.oneOfType([ + PropTypes.oneOf(['down', 'left', 'right', 'up']), + PropTypes.arrayOf(PropTypes.oneOf(['down', 'left', 'right', 'up']).isRequired), + ]), + /** + * The toast to render. + */ + toast: PropTypes.shape({ + actionProps: PropTypes.object, + animation: PropTypes.oneOf(['ending', 'starting']), + data: PropTypes.any, + description: PropTypes.string, + height: PropTypes.number, + id: PropTypes.string.isRequired, + onRemove: PropTypes.func, + onRemoveComplete: PropTypes.func, + priority: PropTypes.oneOf(['high', 'low']), + timeout: PropTypes.number, + title: PropTypes.string, + type: PropTypes.string, + }).isRequired, +} as any; + +export { ToastRoot }; diff --git a/packages/react/src/toast/root/ToastRootContext.ts b/packages/react/src/toast/root/ToastRootContext.ts new file mode 100644 index 0000000000..060a88ce00 --- /dev/null +++ b/packages/react/src/toast/root/ToastRootContext.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import type { Toast } from '../useToast'; + +export interface ToastRootContext { + toast: Toast; + rootRef: React.RefObject; + titleId: string | undefined; + setTitleId: React.Dispatch>; + descriptionId: string | undefined; + setDescriptionId: React.Dispatch>; +} + +export const ToastRootContext = React.createContext(undefined); + +export function useToastRootContext(): ToastRootContext { + const context = React.useContext(ToastRootContext); + if (!context) { + throw new Error('useToastRoot must be used within a ToastRoot'); + } + return context as ToastRootContext; +} diff --git a/packages/react/src/toast/root/ToastRootCssVars.ts b/packages/react/src/toast/root/ToastRootCssVars.ts new file mode 100644 index 0000000000..d2a59ab49d --- /dev/null +++ b/packages/react/src/toast/root/ToastRootCssVars.ts @@ -0,0 +1,27 @@ +export enum ToastRootCssVars { + /** + * Indicates the index of the toast in the list. + * @type {number} + */ + index = '--toast-index', + /** + * Indicates the offset of the toast in the list. + * @type {number} + */ + offset = '--toast-offset', + /** + * Indicates the direction the toast is swiped. + * @type {'up' | 'down' | 'left' | 'right'} + */ + swipeDirection = '--toast-swipe-direction', + /** + * Indicates the horizontal swipe movement of the toast. + * @type {number} + */ + swipeMoveX = '--toast-swipe-move-x', + /** + * Indicates the vertical swipe movement of the toast. + * @type {number} + */ + swipeMoveY = '--toast-swipe-move-y', +} diff --git a/packages/react/src/toast/root/ToastRootDataAttributes.ts b/packages/react/src/toast/root/ToastRootDataAttributes.ts new file mode 100644 index 0000000000..0c56f7f55e --- /dev/null +++ b/packages/react/src/toast/root/ToastRootDataAttributes.ts @@ -0,0 +1,12 @@ +export enum ToastRootDataAttributes { + /** + * Indicates the current swipe state. + * @type {'start' | 'move' | 'end' | 'cancel'} + */ + swipe = 'data-swipe', + /** + * Indicates the direction of the swipe. + * @type {'up' | 'down' | 'left' | 'right'} + */ + swipeDirection = 'data-swipe-direction', +} diff --git a/packages/react/src/toast/title/ToastTitle.test.tsx b/packages/react/src/toast/title/ToastTitle.test.tsx new file mode 100644 index 0000000000..a59910988b --- /dev/null +++ b/packages/react/src/toast/title/ToastTitle.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import { createRenderer, describeConformance } from '#test-utils'; +import { screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { List, Button } from '../utils/test-utils'; + +const toast = { + id: 'test', + title: 'Toast title', +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLHeadingElement, + render(node) { + return render( + + + {node} + + , + ); + }, + })); + + it('adds aria-labelledby to the root element', async () => { + const { user } = await render( + + + + + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.queryByTestId('root')).not.to.equal(null); + + clock.tick(5000); + + expect(screen.queryByTestId('root')).to.equal(null); + }); + + describe('option: timeout', () => { + it('dismisses the toast after the specified timeout', async () => { + function AddButton() { + const { add } = useToast(); + return ; + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.queryByTestId('root')).not.to.equal(null); + + clock.tick(1000); + + expect(screen.queryByTestId('root')).to.equal(null); + }); + }); + + describe('option: title', () => { + it('renders the title', async () => { + function AddButton() { + const { add } = useToast(); + return ( + + ); + } + + function CustomList() { + const { toasts } = useToast(); + return toasts.map((t) => ( + + {t.title} + + )); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.queryByTestId('title')).to.have.text('title'); + }); + }); + + describe('option: description', () => { + it('renders the description', async () => { + function AddButton() { + const { add } = useToast(); + return ( + + ); + } + + function CustomList() { + const { toasts } = useToast(); + return toasts.map((t) => ( + + {t.description} + + )); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.queryByTestId('description')).to.have.text('description'); + }); + }); + + describe('option: type', () => { + it('renders the type', async () => { + function AddButton() { + const { add } = useToast(); + return ; + } + + function CustomList() { + const { toasts } = useToast(); + return toasts.map((t) => ( + + {t.title} + {t.type} + + )); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.queryByTestId('title')).to.have.text('test'); + expect(screen.queryByText('success')).not.to.equal(null); + }); + }); + }); + + describe('promise', () => { + const { clock, render } = createRenderer(); + + clock.withFakeTimers(); + + function CustomList() { + const { toasts } = useToast(); + return toasts.map((t) => ( + + {t.title} + {t.description} + {t.type} + + )); + } + + it('displays success state as title after promise resolves', async () => { + function AddButton() { + const { promise } = useToast(); + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.getByTestId('title')).to.have.text('loading'); + + clock.tick(1000); + await flushMicrotasks(); + + expect(screen.getByTestId('title')).to.have.text('success'); + }); + + it('displays error state as title after promise rejects', async () => { + function AddButton() { + const { promise } = useToast(); + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.getByTestId('title')).to.have.text('loading'); + + clock.tick(1000); + await flushMicrotasks(); + + expect(screen.getByTestId('title')).to.have.text('error'); + }); + + it('passes data when success is a function', async () => { + function AddButton() { + const { promise } = useToast(); + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.getByTestId('title')).to.have.text('loading'); + + clock.tick(1000); + await flushMicrotasks(); + + expect(screen.getByTestId('title')).to.have.text('test success'); + }); + + it('passes data when error is a function', async () => { + function AddButton() { + const { promise } = useToast(); + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.getByTestId('title')).to.have.text('loading'); + + clock.tick(1000); + await flushMicrotasks(); + + expect(screen.getByTestId('title')).to.have.text('test error'); + }); + + it('supports custom options', async () => { + function AddButton() { + const { promise } = useToast(); + return ( + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.getByTestId('title')).to.have.text('loading title'); + expect(screen.getByTestId('description')).to.have.text('loading description'); + + await flushMicrotasks(); + }); + }); + + describe('update', () => { + const { clock, render } = createRenderer(); + + clock.withFakeTimers(); + + function CustomList() { + const { toasts } = useToast(); + return toasts.map((t) => ( + + {t.title} + + )); + } + + it('updates the toast', async () => { + function AddButton() { + const { add, update } = useToast(); + const idRef = React.useRef(null); + return ( + + + + + ); + } + + await render( + + + + + + , + ); + + const button = screen.getByRole('button', { name: 'add' }); + fireEvent.click(button); + + expect(screen.getByTestId('title')).to.have.text('test'); + + const updateButton = screen.getByRole('button', { name: 'update' }); + fireEvent.click(updateButton); + + expect(screen.getByTestId('title')).to.have.text('updated'); + }); + }); + + describe('remove', () => { + const { clock, render } = createRenderer(); + + clock.withFakeTimers(); + + function CustomList() { + const { toasts } = useToast(); + return toasts.map((t) => ( + + {t.title} + + )); + } + + it('removes the toast', async () => { + function AddButton() { + const { add, remove } = useToast(); + const idRef = React.useRef(null); + return ( + + + + + ); + } + + await render( + + + + + + , + ); + + const addButton = screen.getByRole('button', { name: 'add' }); + fireEvent.click(addButton); + + expect(screen.getByTestId('root')).not.to.equal(null); + + const removeButton = screen.getByRole('button', { name: 'remove' }); + fireEvent.click(removeButton); + + expect(screen.queryByTestId('root')).to.equal(null); + }); + }); +}); diff --git a/packages/react/src/toast/useToast.ts b/packages/react/src/toast/useToast.ts new file mode 100644 index 0000000000..bf0667f595 --- /dev/null +++ b/packages/react/src/toast/useToast.ts @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { ToastContext } from './provider/ToastProviderContext'; + +/** + * Returns the array of toasts and methods to create toasts. + */ +export function useToast(): useToast.ReturnValue { + const context = React.useContext(ToastContext); + + if (!context) { + throw new Error('Base UI: useToast must be used within .'); + } + + const { toasts, add, remove, update, promise } = context; + + return React.useMemo( + () => ({ + toasts, + add, + remove, + update, + promise, + }), + [toasts, add, remove, update, promise], + ); +} + +export namespace useToast { + export interface ReturnValue { + toasts: ToastContext['toasts']; + add: (options: AddOptions) => string; + remove: (toastId: string) => void; + update: (toastId: string, options: UpdateOptions) => void; + promise: ( + promise: Promise, + options: PromiseOptions, + ) => Promise; + } + + export interface AddOptions + extends Omit, 'id' | 'animation' | 'height' | 'ref'> { + id?: string; + } + + export interface UpdateOptions extends Partial> {} + + export interface PromiseOptions { + loading: string | UpdateOptions; + success: string | UpdateOptions | ((result: Value) => string | UpdateOptions); + error: string | UpdateOptions | ((error: any) => string | UpdateOptions); + } +} + +export interface Toast { + /** + * The unique identifier for the toast. + */ + id: string; + /** + * The ref for the toast. + */ + ref?: React.RefObject; + /** + * The title of the toast. + */ + title?: string; + /** + * The type of the toast. Used to conditionally style the toast, + * including conditionally rendering elements based on the type. + */ + type?: string; + /** + * The description of the toast. + */ + description?: string; + /** + * The amount of time (in ms) before the toast is auto dismissed. + * A value of `0` will prevent the toast from being dismissed automatically. + */ + timeout?: number; + /** + * The priority of the toast. + * - `low` - The toast will be announced politely. + * - `high` - The toast will be announced urgently. + * @default 'low' + */ + priority?: 'low' | 'high'; + /** + * The animation state of the toast. + */ + animation?: 'starting' | 'ending' | undefined; + /** + * The height of the toast. + */ + height?: number; + /** + * Callback function to be called when the toast is removed. + */ + onRemove?: () => void; + /** + * Callback function to be called when the toast is removed after any animations are complete. + */ + onRemoveComplete?: () => void; + /** + * The props for the action button. + */ + actionProps?: React.ComponentPropsWithoutRef<'button'>; + /** + * Custom data for the toast. + */ + data?: Data; +} + +export interface ToastContextValue { + toasts: Toast[]; + setToasts: React.Dispatch[]>>; + hovering: boolean; + setHovering: React.Dispatch>; + focused: boolean; + setFocused: React.Dispatch>; + add: (options: useToast.AddOptions) => string; + update: (id: string, options: useToast.UpdateOptions) => void; + promise: ( + value: Promise, + options: useToast.PromiseOptions, + ) => Promise; + remove: (id: string) => void; + pauseTimers: () => void; + resumeTimers: () => void; + finalizeRemove: (id: string) => void; + prevFocusElement: HTMLElement | null; + setPrevFocusElement: React.Dispatch>; + viewportRef: React.RefObject; + windowFocusedRef: React.RefObject; + scheduleTimer: (id: string, delay: number, callback: () => void) => void; +} diff --git a/packages/react/src/toast/utils/focusVisible.ts b/packages/react/src/toast/utils/focusVisible.ts new file mode 100644 index 0000000000..b97f637fdd --- /dev/null +++ b/packages/react/src/toast/utils/focusVisible.ts @@ -0,0 +1,9 @@ +export function isFocusVisible(element: Element | null) { + let result = false; + try { + result = element?.matches(':focus-visible') ?? true; + } catch (error) { + result = true; + } + return result; +} diff --git a/packages/react/src/toast/utils/resolvePromiseOptions.ts b/packages/react/src/toast/utils/resolvePromiseOptions.ts new file mode 100644 index 0000000000..ec8810d7b5 --- /dev/null +++ b/packages/react/src/toast/utils/resolvePromiseOptions.ts @@ -0,0 +1,22 @@ +import { useToast } from '../useToast'; + +export function resolvePromiseOptions( + options: + | string + | useToast.UpdateOptions + | ((result: T) => string | useToast.UpdateOptions), + result?: T, +): useToast.UpdateOptions { + if (typeof options === 'string') { + return { + title: options, + }; + } + + if (typeof options === 'function') { + const resolvedOptions = options(result as T); + return typeof resolvedOptions === 'string' ? { title: resolvedOptions } : resolvedOptions; + } + + return options; +} diff --git a/packages/react/src/toast/utils/test-utils.tsx b/packages/react/src/toast/utils/test-utils.tsx new file mode 100644 index 0000000000..86eab0b1ae --- /dev/null +++ b/packages/react/src/toast/utils/test-utils.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; + +export function Button() { + const { add } = Toast.useToast(); + return ( + + ); +} + +export function List() { + return Toast.useToast().toasts.map((toastItem) => ( + + {toastItem.title} + {toastItem.description} + + + )); +} diff --git a/packages/react/src/toast/viewport/FocusGuard.tsx b/packages/react/src/toast/viewport/FocusGuard.tsx new file mode 100644 index 0000000000..e9979e9799 --- /dev/null +++ b/packages/react/src/toast/viewport/FocusGuard.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { isSafari } from '@floating-ui/react/utils'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { visuallyHidden } from '../../utils/visuallyHidden'; + +/** + * @ignore - internal component. + */ +const FocusGuard = React.forwardRef(function FocusGuard( + props: React.ComponentPropsWithoutRef<'span'>, + ref: React.ForwardedRef, +) { + const [role, setRole] = React.useState<'button' | undefined>(); + + useEnhancedEffect(() => { + if (isSafari()) { + // Unlike other screen readers such as NVDA and JAWS, the virtual cursor + // on VoiceOver does trigger the onFocus event, so we can use the focus + // trap element. On Safari, only buttons trigger the onFocus event. + setRole('button'); + } + }, []); + + const restProps = { + ref, + tabIndex: 0, + // Role is only for VoiceOver + role, + 'aria-hidden': role ? undefined : true, + style: visuallyHidden, + }; + + return ; +}); + +FocusGuard.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, +} as any; + +export { FocusGuard }; diff --git a/packages/react/src/toast/viewport/ToastViewport.test.tsx b/packages/react/src/toast/viewport/ToastViewport.test.tsx new file mode 100644 index 0000000000..104ec54481 --- /dev/null +++ b/packages/react/src/toast/viewport/ToastViewport.test.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import { createRenderer, describeConformance } from '#test-utils'; +import { act, fireEvent, screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { List, Button } from '../utils/test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); + + it('gets focused when F6 is pressed', async () => { + const { user } = await render( + + + + +