diff --git a/apps/www/src/components/playground/toast-examples.tsx b/apps/www/src/components/playground/toast-examples.tsx index a60833d0b..faf8d923e 100644 --- a/apps/www/src/components/playground/toast-examples.tsx +++ b/apps/www/src/components/playground/toast-examples.tsx @@ -1,73 +1,85 @@ 'use client'; -import { Button, Flex, ToastContainer, toast } from '@raystack/apsara'; +import { Button, Flex, Toast, toastManager } from '@raystack/apsara'; import PlaygroundLayout from './playground-layout'; export function ToastExamples() { return ( - - - - - - - - - - - - - ) - }) - } - > - Action Toast - + + + + + + + + + + + + + ); } diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 64c9b91d8..4d5a06714 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -3,13 +3,269 @@ export const preview = { type: 'code', code: ` - function ToastTest(){ - return
- - + + + ) + }` +}; + +export const basicDemo = { + type: 'code', + code: ` + ` +}; + +export const typesDemo = { + type: 'code', + tabs: [ + { + name: 'Default', + code: ` + ` + }, + { + name: 'Success', + code: ` + ` + }, + { + name: 'Error', + code: ` + ` + }, + { + name: 'Warning', + code: ` + ` + }, + { + name: 'Info', + code: ` + ` + } + ] +}; + +export const descriptionDemo = { + type: 'code', + code: ` + + + -
-}` + ` +}; + +export const actionDemo = { + type: 'code', + code: ` + ` +}; + +export const promiseDemo = { + type: 'code', + tabs: [ + { + name: 'Basic', + code: ` + ` + }, + { + name: 'With options', + code: ` + ` + } + ] +}; + +export const positionDemo = { + type: 'code', + tabs: [ + { + name: 'Top Left', + code: ` + function ToastPreview() { + const manager = Toast.createToastManager(); + return ( + + + + + + ) + }` + }, + { + name: 'Top Center', + code: ` + function ToastPreview() { + const manager = Toast.createToastManager(); + return ( + + + + + + ) + }` + }, + { + name: 'Top Right', + code: ` + function ToastPreview() { + const manager = Toast.createToastManager(); + return ( + + + + + + ) + }` + }, + { + name: 'Bottom Left', + code: ` + function ToastPreview() { + const manager = Toast.createToastManager(); + return ( + + + + + + ) + }` + }, + { + name: 'Bottom Center', + code: ` + function ToastPreview() { + const manager = Toast.createToastManager(); + return ( + + + + + + ) + }` + }, + { + name: 'Bottom Right', + code: ` + function ToastPreview() { + const manager = Toast.createToastManager(); + return ( + + + + + + ) + }` + } + ] +}; + +export const updateDemo = { + type: 'code', + code: ` + function UpdateToast() { + const idRef = React.useRef(null); + return ( + + + + + + ) + }` }; diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx index ae85c17ba..d53884bdf 100644 --- a/apps/www/src/content/docs/components/toast/index.mdx +++ b/apps/www/src/content/docs/components/toast/index.mdx @@ -1,55 +1,97 @@ --- title: Toast -description: Toast wrapper and function. Toast internally uses Sonner +description: Displays temporary notification messages using Base UI Toast primitives. source: packages/raystack/components/toast --- -import { preview, basicDemo, iconsDemo, disabledDemo } from "./demo.ts"; +import { preview, basicDemo, typesDemo, descriptionDemo, actionDemo, promiseDemo, positionDemo, updateDemo } from "./demo.ts"; ## Anatomy -Import and assemble the component: +Import and set up the toast system: ```tsx -import { ToastContainer, toast } from '@raystack/apsara' +import { Toast, toastManager } from '@raystack/apsara' - +// Wrap your app with Toast.Provider (once, at root) + + + + +// Trigger toasts from anywhere +toastManager.add({ title: 'Hello!', type: 'success' }) ``` ## API Reference -Renders a temporary notification message. +### Toast.Provider + +Renders the toast viewport and manages the toast lifecycle. Place once at the root of your app. + + + +### toastManager.add(options) + +Creates a new toast and returns its unique ID. The `toastManager` can be imported and used anywhere — including outside React components (e.g., API interceptors, utility functions). + + + +### toastManager methods - +| Method | Signature | Description | +|--------|-----------|-------------| +| `add` | `(options) => string` | Create a toast, returns its ID | +| `close` | `(id: string) => void` | Close a specific toast by ID | +| `update` | `(id: string, options) => void` | Update an existing toast's properties | +| `promise` | `(promise, { loading, success, error }) => Promise` | Create a toast that tracks a promise lifecycle | ## Examples -### Basic Usage +### Basic Toast -```tsx -import { ToastContainer, toast, Button } from '@raystack/apsara' - - - - ), - }) - }> - Trigger toast - -``` + + +### Type Variants + +Use the `type` prop to change the visual styling of the toast. + + + +### With Title and Description + + + +### With Action Button + +Use `actionProps` to render an action button inside the toast. + + + +### Promise Toast + +Track an async operation with automatic loading, success, and error states. + + + +### Positioning + +Control where toasts appear on screen with the `position` prop on `Toast.Provider`. + + + +### Close and Update + +Create a toast, then update or close it programmatically using the returned ID. + + ## Accessibility -- Uses `role="alert"` for important notifications -- Automatically announced to screen readers via `aria-live` region -- Dismissible toasts support keyboard interaction +- Uses `aria-live` regions for screen reader announcements +- `priority: 'high'` uses `role="alert"` (assertive) for urgent notifications +- `priority: 'low'` (default) uses `role="status"` (polite) for non-urgent notifications +- Close button has `aria-label="Close toast"` +- Supports keyboard navigation and Escape to dismiss +- Swipe-to-dismiss gesture support diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index 2c25d7e8f..14ed93021 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -1,15 +1,4 @@ -export interface ToastProps { - /** - * Text shown below the title. - */ - description?: string; - - /** - * Auto-close time in milliseconds. - * @default 4000 - */ - duration?: number; - +export interface ToastProviderProps { /** * Toast position on screen. * @default "bottom-right" @@ -23,74 +12,70 @@ export interface ToastProps { | 'bottom-center'; /** - * Allow user to dismiss toast. - * @default true + * Maximum number of visible toasts. + * @default 3 */ - dismissible?: boolean; + limit?: number; /** - * Leading icon element. + * Default auto-dismiss time in milliseconds. + * @default 5000 */ - icon?: React.ReactNode; + timeout?: number; + + children?: React.ReactNode; +} +export interface ToastManagerAddOptions { /** - * Inverts the color scheme. - * @default false + * The title of the toast. */ - invert?: boolean; + title?: React.ReactNode; /** - * Show close button. - * @default false + * The description of the toast. */ - closeButton?: boolean; + description?: React.ReactNode; /** - * Remove default styling. - * @default false + * The type of the toast. Controls visual styling. */ - unstyled?: boolean; + type?: 'success' | 'error' | 'info' | 'warning' | 'loading'; /** - * Primary button configuration. + * Auto-dismiss time in milliseconds. 0 prevents auto-dismiss. + * @default 5000 */ - action?: { - label: string; - onClick: () => void; - }; + timeout?: number; /** - * Secondary button configuration. + * Announcement priority for screen readers. + * @default "low" */ - cancel?: { - label: string; - onClick: () => void; - }; + priority?: 'low' | 'high'; /** - * Styles for the primary button. - * @default {} + * Callback when the toast is closed. */ - actionButtonStyle?: React.CSSProperties; + onClose?: () => void; /** - * Styles for the secondary button. - * @default {} + * Callback when the toast is removed after closing animation completes. */ - cancelButtonStyle?: React.CSSProperties; + onRemove?: () => void; /** - * Called on manual dismiss. + * Props for the action button rendered in the toast. */ - onDismiss?: () => void; + actionProps?: React.ComponentPropsWithoutRef<'button'>; /** - * Called when the toast auto-closes. + * Custom data to attach to the toast. */ - onAutoClose?: () => void; + data?: Record; /** - * Custom toast ID. + * Optional custom ID for the toast. Auto-generated if not provided. */ id?: string; } diff --git a/packages/raystack/components/toast/__tests__/toast.test.tsx b/packages/raystack/components/toast/__tests__/toast.test.tsx index 3585fcb18..fe49d1220 100644 --- a/packages/raystack/components/toast/__tests__/toast.test.tsx +++ b/packages/raystack/components/toast/__tests__/toast.test.tsx @@ -1,28 +1,49 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { ToastContainer, toast } from '../toast'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Toast, toastManager } from '../toast'; + +const renderWithProvider = ( + props?: Partial> +) => { + return render( + +
App content
+
+ ); +}; describe('Toast', () => { - beforeEach(() => { - render(); + describe('Toast.Provider', () => { + it('renders provider with children', () => { + renderWithProvider(); + expect(screen.getByText('App content')).toBeInTheDocument(); + }); }); - describe('ToastContainer', () => { - it('renders ToastContainer component', () => { - expect(screen.getByLabelText('Notifications alt+T')).toBeInTheDocument(); + describe('toastManager.add()', () => { + beforeEach(() => { + renderWithProvider(); }); - }); - describe('Toast Function', () => { - it('shows basic toast message', async () => { - toast('Hello World'); + it('shows basic toast with title', async () => { + act(() => { + toastManager.add({ title: 'Hello World' }); + }); expect(await screen.findByText('Hello World')).toBeInTheDocument(); }); - it('shows JSX content in toast', async () => { - const jsxContent =
JSX Content
; - toast(jsxContent); - expect(await screen.findByTestId('jsx-content')).toBeInTheDocument(); + it('shows toast with title and description', async () => { + act(() => { + toastManager.add({ + title: 'Toast Title', + description: 'Toast description text' + }); + }); + expect(await screen.findByText('Toast Title')).toBeInTheDocument(); + expect( + await screen.findByText('Toast description text') + ).toBeInTheDocument(); }); const toastTypes = [ @@ -32,32 +53,79 @@ describe('Toast', () => { 'info', 'loading' ] as const; + toastTypes.forEach(type => { - it(`supports ${type} toast`, async () => { - toast[type]('Success message'); - expect(await screen.findByText('Success message')).toBeInTheDocument(); + it(`supports ${type} type`, async () => { + act(() => { + toastManager.add({ title: `${type} message`, type }); + }); + const toastEl = await screen.findByText(`${type} message`); + expect(toastEl).toBeInTheDocument(); + expect(toastEl.closest(`[data-type="${type}"]`)).toBeInTheDocument(); }); }); + }); + + describe('toastManager.close()', () => { + beforeEach(() => { + renderWithProvider(); + }); + + it('closes a specific toast by id', async () => { + let id: string; + act(() => { + id = toastManager.add({ title: 'Dismissible toast' }); + }); + expect(await screen.findByText('Dismissible toast')).toBeInTheDocument(); - it('supports custom toast with options', async () => { - const customOptions = { - duration: 5000, - description: 'Custom description' - }; + act(() => { + toastManager.close(id!); + }); - toast('Custom toast', customOptions); - expect(await screen.findByText('Custom toast')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('Dismissible toast')).not.toBeInTheDocument(); + }); + }); + }); + + describe('toastManager.update()', () => { + beforeEach(() => { + renderWithProvider(); }); - it('supports promise-based toast', async () => { - const promise = new Promise(resolve => { - setTimeout(() => resolve('Promise resolved'), 100); + it('updates an existing toast', async () => { + let id: string; + act(() => { + id = toastManager.add({ title: 'Original title' }); }); + expect(await screen.findByText('Original title')).toBeInTheDocument(); - toast.promise(promise, { - loading: 'Loading...', - success: 'Success!', - error: 'Error!' + act(() => { + toastManager.update(id!, { title: 'Updated title' }); + }); + + await waitFor(() => { + expect(screen.getByText('Updated title')).toBeInTheDocument(); + }); + }); + }); + + describe('toastManager.promise()', () => { + beforeEach(() => { + renderWithProvider(); + }); + + it('shows loading then success on resolution', async () => { + const promise = new Promise(resolve => + setTimeout(() => resolve('ok'), 50) + ); + + act(() => { + toastManager.promise(promise, { + loading: 'Loading...', + success: 'Success!', + error: 'Error!' + }); }); expect(await screen.findByText('Loading...')).toBeInTheDocument(); @@ -70,116 +138,116 @@ describe('Toast', () => { ); }); - it('supports dismiss functionality', async () => { - const toastId = toast('Dismissible toast'); - expect(await screen.findByText('Dismissible toast')).toBeInTheDocument(); - - toast.dismiss(toastId); + it('shows loading then error on rejection', async () => { + const promise = new Promise((_, reject) => + setTimeout(() => reject(new Error('fail')), 50) + ); - await waitFor(() => { - expect(screen.queryByText('Dismissible toast')).not.toBeInTheDocument(); + let result: Promise; + act(() => { + result = toastManager.promise(promise, { + loading: 'Loading...', + success: 'Success!', + error: 'Error!' + }); }); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + + // Catch the rejection to prevent unhandled promise rejection + await result!.catch(() => undefined); + + await waitFor( + () => { + expect(screen.getByText('Error!')).toBeInTheDocument(); + }, + { timeout: 200 } + ); }); + }); - it('supports dismissAll functionality', async () => { - toast('First toast'); - toast('Second toast'); + describe('Toast close button', () => { + beforeEach(() => { + renderWithProvider(); + }); - expect(await screen.findByText('First toast')).toBeInTheDocument(); - expect(await screen.findByText('Second toast')).toBeInTheDocument(); + it('renders close button and dismisses toast on click', async () => { + const user = userEvent.setup(); - // Dismiss all toasts by calling dismiss without ID - toast.dismiss(); + act(() => { + toastManager.add({ title: 'Closable toast' }); + }); + + const closeBtn = await screen.findByLabelText('Close toast'); + expect(closeBtn).toBeInTheDocument(); + + await user.click(closeBtn); await waitFor(() => { - expect(screen.queryByText('First toast')).not.toBeInTheDocument(); - expect(screen.queryByText('Second toast')).not.toBeInTheDocument(); + expect(screen.queryByText('Closable toast')).not.toBeInTheDocument(); }); }); + }); - it('handles multiple toasts simultaneously', async () => { - toast('First toast'); - toast('Second toast'); - toast('Third toast'); - - expect(await screen.findByText('First toast')).toBeInTheDocument(); - expect(await screen.findByText('Second toast')).toBeInTheDocument(); - expect(await screen.findByText('Third toast')).toBeInTheDocument(); + describe('Toast action button', () => { + beforeEach(() => { + renderWithProvider(); }); - it('supports custom action buttons', async () => { - toast('Toast with action', { - action: { - label: 'Undo', - onClick: () => console.log('Undo clicked') - } + it('renders action button from actionProps', async () => { + const onClick = vi.fn(); + + act(() => { + toastManager.add({ + title: 'With Action', + actionProps: { children: 'Undo', onClick } + }); }); - expect(await screen.findByText('Toast with action')).toBeInTheDocument(); expect(await screen.findByText('Undo')).toBeInTheDocument(); }); + }); - it('supports custom duration', async () => { - const shortDuration = 100; - toast('Short duration toast', { duration: shortDuration }); - - expect( - await screen.findByText('Short duration toast') - ).toBeInTheDocument(); - - await waitFor( - () => { - expect( - screen.queryByText('Short duration toast') - ).not.toBeInTheDocument(); - }, - { timeout: 500 } - ); + describe('Multiple toasts', () => { + beforeEach(() => { + renderWithProvider(); }); - it('supports custom className', async () => { - const customClass = 'custom-toast-class'; - toast('Custom class toast', { className: customClass }); + it('shows multiple toasts simultaneously', async () => { + act(() => { + toastManager.add({ title: 'First toast' }); + toastManager.add({ title: 'Second toast' }); + toastManager.add({ title: 'Third toast' }); + }); - expect(await screen.findByText('Custom class toast')).toBeInTheDocument(); + expect(await screen.findByText('First toast')).toBeInTheDocument(); + expect(await screen.findByText('Second toast')).toBeInTheDocument(); + expect(await screen.findByText('Third toast')).toBeInTheDocument(); }); + }); - it('supports custom style', async () => { - const customStyle = { backgroundColor: 'red' }; - toast('Custom style toast', { style: customStyle }); - - expect(await screen.findByText('Custom style toast')).toBeInTheDocument(); + describe('onClose callback', () => { + beforeEach(() => { + renderWithProvider(); }); - it('supports onDismiss callback', async () => { - const onDismiss = vi.fn(); - toast('Callback toast', { onDismiss }); + it('fires onClose when toast is closed', async () => { + const onClose = vi.fn(); + let id: string; - expect(await screen.findByText('Callback toast')).toBeInTheDocument(); + act(() => { + id = toastManager.add({ title: 'Callback toast', onClose }); + }); - // Dismiss the toast - toast.dismiss(); + expect(await screen.findByText('Callback toast')).toBeInTheDocument(); - await waitFor(() => { - expect(onDismiss).toHaveBeenCalled(); + act(() => { + toastManager.close(id!); }); - }); - it('supports onAutoClose callback', async () => { - const onAutoClose = vi.fn(); - toast('Auto close toast', { - duration: 100, - onAutoClose + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); }); - - expect(await screen.findByText('Auto close toast')).toBeInTheDocument(); - - await waitFor( - () => { - expect(onAutoClose).toHaveBeenCalled(); - }, - { timeout: 200 } - ); }); }); }); diff --git a/packages/raystack/components/toast/index.tsx b/packages/raystack/components/toast/index.tsx index c79ef750e..e1dea40a3 100644 --- a/packages/raystack/components/toast/index.tsx +++ b/packages/raystack/components/toast/index.tsx @@ -1 +1,3 @@ -export { toast,ToastContainer } from "./toast"; +export { Toast, toastManager } from './toast'; +export type { ToastPosition, ToastProviderProps } from './toast-provider'; +export type { ToastRootProps } from './toast-root'; diff --git a/packages/raystack/components/toast/toast-manager.ts b/packages/raystack/components/toast/toast-manager.ts new file mode 100644 index 000000000..42678f1a7 --- /dev/null +++ b/packages/raystack/components/toast/toast-manager.ts @@ -0,0 +1,5 @@ +'use client'; + +import { Toast as ToastPrimitive } from '@base-ui/react'; + +export const toastManager = ToastPrimitive.createToastManager(); diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx new file mode 100644 index 000000000..f5bc78c7b --- /dev/null +++ b/packages/raystack/components/toast/toast-provider.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Toast as ToastPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import styles from './toast.module.css'; +import { toastManager } from './toast-manager'; +import { ToastRoot } from './toast-root'; + +export type ToastPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +export interface ToastProviderProps + extends Omit { + /** + * Position of the toast viewport on screen. + * @default "bottom-right" + */ + position?: ToastPosition; +} + +function ToastList({ position }: { position: ToastPosition }) { + const { toasts } = ToastPrimitive.useToastManager(); + return toasts.map(toast => ( + + )); +} + +export const ToastProvider = forwardRef< + ElementRef, + ToastProviderProps +>(({ position = 'bottom-right', children, ...props }, ref) => { + return ( + + {children} + + + + + + + ); +}); + +ToastProvider.displayName = 'Toast.Provider'; diff --git a/packages/raystack/components/toast/toast-root.tsx b/packages/raystack/components/toast/toast-root.tsx new file mode 100644 index 000000000..d38536458 --- /dev/null +++ b/packages/raystack/components/toast/toast-root.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { Toast as ToastPrimitive } from '@base-ui/react'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import { Button } from '../button'; +import { Flex } from '../flex'; +import { IconButton } from '../icon-button'; +import styles from './toast.module.css'; +import type { ToastPosition } from './toast-provider'; + +type SwipeDirection = 'up' | 'down' | 'left' | 'right'; + +function getSwipeDirection(position: ToastPosition): SwipeDirection[] { + const verticalDirection: SwipeDirection = position.startsWith('top') + ? 'up' + : 'down'; + + if (position.includes('center')) { + return [verticalDirection]; + } + + if (position.includes('left')) { + return ['left', verticalDirection]; + } + + return ['right', verticalDirection]; +} + +export interface ToastRootProps + extends Omit { + toast: ToastPrimitive.Root.Props['toast']; + position?: ToastPosition; +} + +export const ToastRoot = forwardRef< + ElementRef, + ToastRootProps +>(({ toast, className, position = 'bottom-right', ...props }, ref) => { + const swipeDirection = getSwipeDirection(position); + const hasDescription = !!toast.description; + + return ( + + + + {toast.title && ( + + {toast.title} + + )} + {hasDescription && ( + + {toast.description} + + )} + + + {toast.actionProps && ( + } + /> + )} + } + > + + + + + + ); +}); + +ToastRoot.displayName = 'Toast'; diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index 06bcc2e58..81d9e4f7e 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -1,3 +1,296 @@ -.toast-wrapper { - margin-right: var(--rs-space-3); +/* ===== Viewport ===== */ +.viewport { + --gap: 0.75rem; + position: fixed; + z-index: var(--rs-z-index-portal); + width: 360px; + max-width: calc(100vw - var(--rs-space-10)); + outline: none; +} + +/* Position variants */ +.viewport-top-left { + top: var(--rs-space-5); + left: var(--rs-space-5); +} + +.viewport-top-center { + top: var(--rs-space-5); + left: 50%; + transform: translateX(-50%); +} + +.viewport-top-right { + top: var(--rs-space-5); + right: var(--rs-space-5); +} + +.viewport-bottom-left { + bottom: var(--rs-space-5); + left: var(--rs-space-5); +} + +.viewport-bottom-center { + bottom: var(--rs-space-5); + left: 50%; + transform: translateX(-50%); +} + +.viewport-bottom-right { + bottom: var(--rs-space-5); + right: var(--rs-space-5); +} + +/* ===== Toast Root (stacking) ===== */ +.root { + --peek: 0.75rem; + --scale: calc(max(0, 1 - (var(--toast-index) * 0.1))); + --shrink: calc(1 - var(--scale)); + --height: var(--toast-frontmost-height, var(--toast-height)); + + position: absolute; + box-sizing: border-box; + width: 100%; + height: var(--height); + + background: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-lifted); + padding: var(--rs-space-3); + background-clip: padding-box; + overflow: clip; + + user-select: none; + cursor: default; + z-index: calc(1000 - var(--toast-index)); + + transition: + transform 500ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 500ms, + height 150ms; +} + +/* ===== Vertical position: bottom ===== */ +.root[data-position*="bottom"] { + --offset-y: calc( + var(--toast-offset-y) * + -1 + + (var(--toast-index) * var(--gap) * -1) + + var(--toast-swipe-movement-y) + ); + + bottom: 0; + top: auto; + transform-origin: bottom center; + + transform: translateX(var(--toast-swipe-movement-x)) + translateY( + calc( + var(--toast-swipe-movement-y) - + (var(--toast-index) * var(--peek)) - + (var(--shrink) * var(--height)) + ) + ) + scale(var(--scale)); +} + +/* ===== Vertical position: top ===== */ +.root[data-position*="top"] { + --offset-y: calc( + var(--toast-offset-y) + + (var(--toast-index) * var(--gap)) + + var(--toast-swipe-movement-y) + ); + + top: 0; + bottom: auto; + transform-origin: top center; + + transform: translateX(var(--toast-swipe-movement-x)) + translateY( + calc( + var(--toast-swipe-movement-y) + + (var(--toast-index) * var(--peek)) + + (var(--shrink) * var(--height)) + ) + ) + scale(var(--scale)); +} + +/* ===== Horizontal alignment ===== */ +.root[data-position*="right"] { + right: 0; + left: auto; +} + +.root[data-position*="left"] { + left: 0; + right: auto; +} + +.root[data-position*="center"] { + left: 0; + right: 0; +} + +/* ===== Expanded state (on hover) ===== */ +.root[data-expanded] { + transform: translateX(var(--toast-swipe-movement-x)) + translateY(var(--offset-y)); + height: var(--toast-height); +} + +/* ===== Gap area for hover detection between toasts ===== */ +.root[data-position*="bottom"]::after { + content: ""; + position: absolute; + bottom: 100%; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); +} + +.root[data-position*="top"]::after { + content: ""; + position: absolute; + top: 100%; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); +} + +/* ===== Enter animations ===== */ +.root[data-position*="bottom"][data-starting-style] { + transform: translateY(calc(100% + var(--rs-space-5))); +} + +.root[data-position*="top"][data-starting-style] { + transform: translateY(calc(-100% - var(--rs-space-5))); +} + +/* ===== Limited state ===== */ +.root[data-limited] { + opacity: 0; +} + +/* ===== Exit animations ===== */ +.root[data-ending-style] { + opacity: 0; +} + +/* Default exit (no swipe) */ +.root[data-position*="bottom"][data-ending-style]:not([data-swipe-direction]) { + transform: translateY(calc(100% + var(--rs-space-5))); +} + +.root[data-position*="top"][data-ending-style]:not([data-swipe-direction]) { + transform: translateY(calc(-100% - var(--rs-space-5))); +} + +/* Swipe exit directions */ +.root[data-ending-style][data-swipe-direction="up"] { + transform: translateY(calc(var(--toast-swipe-movement-y) - 150%)); +} + +.root[data-ending-style][data-swipe-direction="down"] { + transform: translateY(calc(var(--toast-swipe-movement-y) + 150%)); +} + +.root[data-ending-style][data-swipe-direction="left"] { + transform: translateX(calc(var(--toast-swipe-movement-x) - 150%)) + translateY(var(--offset-y)); +} + +.root[data-ending-style][data-swipe-direction="right"] { + transform: translateX(calc(var(--toast-swipe-movement-x) + 150%)) + translateY(var(--offset-y)); +} + +/* ===== Type-based styling ===== */ +.root[data-type="success"] { + background: var(--rs-color-background-success-primary); + border-color: var(--rs-color-border-success-primary); + color: var(--rs-color-foreground-success-primary); +} + +.root[data-type="error"] { + background: var(--rs-color-background-danger-primary); + border-color: var(--rs-color-border-danger-primary); + color: var(--rs-color-foreground-danger-primary); +} + +.root[data-type="warning"] { + background: var(--rs-color-background-attention-primary); + border-color: var(--rs-color-border-attention-primary); + color: var(--rs-color-foreground-attention-primary); +} + +.root[data-type="info"] { + background: var(--rs-color-background-accent-primary); + border-color: var(--rs-color-border-accent-primary); + color: var(--rs-color-foreground-accent-primary); +} + +/* ===== Content (stacking visibility) ===== */ +.content { + display: flex; + align-items: start; + gap: var(--rs-space-5); + transition: opacity 250ms; +} + +.content[data-behind] { + opacity: 0; +} + +.content[data-expanded] { + opacity: 1; +} + +/* ===== Text container (title + description) ===== */ +.textContainer { + flex: 1; + min-width: 0; +} + +/* ===== Title ===== */ +.title { + color: inherit; + font-size: var(--rs-font-size-regular); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); + margin: 0; +} + +/* ===== Description ===== */ +.description { + color: inherit; + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + margin: 0; +} + +/* Override description color for typed toasts */ +.root[data-type="success"] .description, +.root[data-type="error"] .description, +.root[data-type="warning"] .description, +.root[data-type="info"] .description { + opacity: 0.85; +} + +/* ===== Reduced motion ===== */ +@media (prefers-reduced-motion: reduce) { + .root { + transition: none; + } + + .root[data-starting-style], + .root[data-ending-style] { + transform: none; + } } diff --git a/packages/raystack/components/toast/toast.tsx b/packages/raystack/components/toast/toast.tsx index ff6c21851..ff22ccafa 100644 --- a/packages/raystack/components/toast/toast.tsx +++ b/packages/raystack/components/toast/toast.tsx @@ -1,47 +1,11 @@ -'use client'; +import { Toast as ToastPrimitive } from '@base-ui/react'; +import { ToastProvider } from './toast-provider'; +import { ToastRoot } from './toast-root'; -import { ReactNode } from 'react'; -import { toast as sonnerToast, Toaster, type ToasterProps } from 'sonner'; +export const Toast = Object.assign(ToastRoot, { + Provider: ToastProvider, + createToastManager: ToastPrimitive.createToastManager, + useToastManager: ToastPrimitive.useToastManager +}); -import { useTheme } from '../theme-provider'; -import { UseThemeProps } from '../theme-provider/types'; -import styles from './toast.module.css'; - -interface ToastContainerProps extends ToasterProps {} - -const ToastContainer = (props: ToastContainerProps) => { - const { resolvedTheme } = useTheme(); - - return ( - - ); -}; - -const toast: typeof sonnerToast = Object.assign( - (message: string | ReactNode, options?: ToasterProps) => { - sonnerToast( -
{message}
, - options - ); - }, - sonnerToast -); - -(toast as typeof toast & { displayName: string }).displayName = 'toast'; - -ToastContainer.displayName = 'ToastContainer'; - -export { toast, ToastContainer }; +export { toastManager } from './toast-manager'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 03d3e7308..1be7dd578 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -66,5 +66,5 @@ export { ThemeSwitcher, useTheme } from './components/theme-provider'; -export { ToastContainer, toast } from './components/toast'; +export { Toast, toastManager } from './components/toast'; export { Tooltip } from './components/tooltip'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 9548bd01a..16a86cff5 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -126,8 +126,7 @@ "dayjs": "^1.11.11", "prism-react-renderer": "^2.4.1", "radix-ui": "^1.4.2", - "react-day-picker": "^9.6.7", - "sonner": "^2.0.6" + "react-day-picker": "^9.6.7" }, "peerDependencies": { "@types/react": "^18 || ^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 261a5c9b4..445c87798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,9 +214,6 @@ importers: react-day-picker: specifier: ^9.6.7 version: 9.6.7(react@19.2.1) - sonner: - specifier: ^2.0.6 - version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) devDependencies: '@figma/code-connect': specifier: ^1.3.5 @@ -8322,12 +8319,6 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 - sonner@2.0.7: - resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -11755,8 +11746,6 @@ snapshots: '@parcel/types': 2.12.0(@parcel/core@2.12.0) '@parcel/utils': 2.12.0 nullthrows: 1.1.1 - transitivePeerDependencies: - - '@swc/helpers' '@parcel/workers@2.9.2(@parcel/core@2.12.0)': dependencies: @@ -19433,11 +19422,6 @@ snapshots: react: 18.3.1 react-dom: 19.2.1(react@18.3.1) - sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - source-map-js@1.2.0: {} source-map-js@1.2.1: {}