From bb14f2b196cc6e912a2600a89c1bd9d02068c031 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 26 Feb 2026 23:31:27 +0530 Subject: [PATCH 1/4] wip: migrate toast --- .../components/playground/toast-examples.tsx | 138 +++++---- .../src/content/docs/components/toast/demo.ts | 204 +++++++++++- .../content/docs/components/toast/index.mdx | 102 ++++-- .../content/docs/components/toast/props.ts | 79 ++--- .../components/toast/__tests__/toast.test.tsx | 292 +++++++++++------- packages/raystack/components/toast/index.tsx | 4 +- .../components/toast/toast-manager.ts | 5 + .../raystack/components/toast/toast-misc.tsx | 75 +++++ .../components/toast/toast-provider.tsx | 51 +++ .../raystack/components/toast/toast-root.tsx | 56 ++++ .../components/toast/toast.module.css | 282 ++++++++++++++++- packages/raystack/components/toast/toast.tsx | 70 ++--- packages/raystack/index.tsx | 2 +- packages/raystack/package.json | 3 +- 14 files changed, 1050 insertions(+), 313 deletions(-) create mode 100644 packages/raystack/components/toast/toast-manager.ts create mode 100644 packages/raystack/components/toast/toast-misc.tsx create mode 100644 packages/raystack/components/toast/toast-provider.tsx create mode 100644 packages/raystack/components/toast/toast-root.tsx 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..b836007e1 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -3,13 +3,201 @@ export const preview = { type: 'code', code: ` - function ToastTest(){ - return
- - + + + ) + }` +}; + +export const basicDemo = { + type: 'code', + code: ` + ` +}; + +export const typesDemo = { + type: 'code', + tabs: [ + { + name: 'Success', + code: ` + ` + }, + { + name: 'Error', + code: ` + ` + }, + { + name: 'Warning', + code: ` + ` + }, + { + name: 'Info', + code: ` + ` + }, + { + name: 'Loading', + 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: 'Bottom Right', + code: ` + ` + }, + { + name: 'Top Center', + code: ` + ` + }, + { + name: 'Top Right', + code: ` + ` + }, + { + name: 'Bottom Left', + code: ` + ` + } + ] +}; + +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-misc.tsx b/packages/raystack/components/toast/toast-misc.tsx new file mode 100644 index 000000000..696e55648 --- /dev/null +++ b/packages/raystack/components/toast/toast-misc.tsx @@ -0,0 +1,75 @@ +'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 styles from './toast.module.css'; + +export const ToastTitle = forwardRef< + ElementRef, + ToastPrimitive.Title.Props +>(({ className, ...props }, ref) => ( + +)); + +ToastTitle.displayName = 'Toast.Title'; + +export const ToastDescription = forwardRef< + ElementRef, + ToastPrimitive.Description.Props +>(({ className, ...props }, ref) => ( + +)); + +ToastDescription.displayName = 'Toast.Description'; + +export const ToastAction = forwardRef< + ElementRef, + ToastPrimitive.Action.Props +>(({ className, ...props }, ref) => ( + +)); + +ToastAction.displayName = 'Toast.Action'; + +export const ToastClose = forwardRef< + ElementRef, + ToastPrimitive.Close.Props +>(({ className, children, ...props }, ref) => ( + + {children ?? +)); + +ToastClose.displayName = 'Toast.Close'; + +export const ToastViewport = forwardRef< + ElementRef, + ToastPrimitive.Viewport.Props +>(({ className, ...props }, ref) => ( + +)); + +ToastViewport.displayName = 'Toast.Viewport'; diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx new file mode 100644 index 000000000..4b4d8738b --- /dev/null +++ b/packages/raystack/components/toast/toast-provider.tsx @@ -0,0 +1,51 @@ +'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() { + 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..e2ca9b3de --- /dev/null +++ b/packages/raystack/components/toast/toast-root.tsx @@ -0,0 +1,56 @@ +'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 styles from './toast.module.css'; + +export interface ToastRootProps + extends Omit { + toast: ToastPrimitive.Root.Props['toast']; +} + +export const ToastRoot = forwardRef< + ElementRef, + ToastRootProps +>(({ toast, className, ...props }, ref) => { + return ( + + +
+ {toast.title && ( + + {toast.title} + + )} + {toast.description && ( + + {toast.description} + + )} +
+
+ {toast.actionProps && ( + + {toast.actionProps.children} + + )} + + +
+
+
+ ); +}); + +ToastRoot.displayName = 'Toast'; diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index 06bcc2e58..ade0fc376 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -1,3 +1,281 @@ -.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)); + --offset-y: calc( + var(--toast-offset-y) * -1 + (var(--toast-index) * var(--gap) * -1) + + var(--toast-swipe-movement-y) + ); + + position: absolute; + bottom: 0; + right: 0; + left: auto; + 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) var(--rs-space-4); + background-clip: padding-box; + + user-select: none; + cursor: default; + z-index: calc(1000 - var(--toast-index)); + transform-origin: bottom center; + + transition: + transform 500ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 500ms, + height 150ms; + + 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)); +} + +/* 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::after { + content: ''; + position: absolute; + top: 100%; + width: 100%; + left: 0; + height: calc(var(--gap) + 1px); +} + +/* ===== Enter / exit animations ===== */ +.root[data-starting-style], +.root[data-ending-style] { + transform: translateY(150%); +} + +.root[data-limited] { + opacity: 0; +} + +.root[data-ending-style] { + opacity: 0; +} + +.root[data-ending-style][data-swipe-direction='up'] { + 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)); +} + +.root[data-ending-style][data-swipe-direction='down'] { + transform: translateY(calc(var(--toast-swipe-movement-y) + 150%)); +} + +/* ===== 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); +} + +.root[data-type='loading'] { + background: var(--rs-color-background-base-primary); + border-color: var(--rs-color-border-base-primary); + color: var(--rs-color-foreground-base-secondary); +} + +/* ===== Content (stacking visibility) ===== */ +.content { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--rs-space-3); + overflow: hidden; + transition: opacity 250ms; +} + +.content[data-behind] { + opacity: 0; +} + +.content[data-expanded] { + opacity: 1; +} + +.textContainer { + display: flex; + flex-direction: column; + gap: var(--rs-space-1); + flex: 1; + min-width: 0; +} + +/* ===== Title ===== */ +.title { + color: inherit; + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + margin: 0; +} + +/* ===== Description ===== */ +.description { + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + 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 { + color: inherit; + opacity: 0.85; +} + +/* ===== Actions container ===== */ +.actions { + display: flex; + align-items: center; + gap: var(--rs-space-2); + flex-shrink: 0; +} + +/* ===== Action button ===== */ +.action { + all: unset; + display: inline-flex; + align-items: center; + padding: var(--rs-space-1) var(--rs-space-3); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + border-radius: var(--rs-radius-1); + cursor: pointer; + color: inherit; + border: 0.5px solid currentColor; + opacity: 0.8; +} + +.action:hover { + opacity: 1; +} + +/* ===== Close button ===== */ +.close { + all: unset; + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--rs-space-1); + border-radius: var(--rs-radius-1); + cursor: pointer; + color: inherit; + opacity: 0.5; +} + +.close:hover { + opacity: 1; +} + +/* ===== 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..ea4151327 100644 --- a/packages/raystack/components/toast/toast.tsx +++ b/packages/raystack/components/toast/toast.tsx @@ -1,47 +1,23 @@ -'use client'; - -import { ReactNode } from 'react'; -import { toast as sonnerToast, Toaster, type ToasterProps } from 'sonner'; - -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 }; +import { Toast as ToastPrimitive } from '@base-ui/react'; +import { + ToastAction, + ToastClose, + ToastDescription, + ToastTitle, + ToastViewport +} from './toast-misc'; +import { ToastProvider } from './toast-provider'; +import { ToastRoot } from './toast-root'; + +export const Toast = Object.assign(ToastRoot, { + Provider: ToastProvider, + Title: ToastTitle, + Description: ToastDescription, + Action: ToastAction, + Close: ToastClose, + Viewport: ToastViewport, + createToastManager: ToastPrimitive.createToastManager, + useToastManager: ToastPrimitive.useToastManager +}); + +export { toastManager } from './toast-manager'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 20696b59e..899987edb 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -65,5 +65,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 35527459d..77df93468 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", From 5625bda2792a0b53abd6a0b3e800f58a32245e95 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 26 Feb 2026 23:32:43 +0530 Subject: [PATCH 2/4] wip: toast styles --- .../components/toast/toast.module.css | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index ade0fc376..6ff471465 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -48,8 +48,10 @@ --shrink: calc(1 - var(--scale)); --height: var(--toast-frontmost-height, var(--toast-height)); --offset-y: calc( - var(--toast-offset-y) * -1 + (var(--toast-index) * var(--gap) * -1) + - var(--toast-swipe-movement-y) + var(--toast-offset-y) * + -1 + + (var(--toast-index) * var(--gap) * -1) + + var(--toast-swipe-movement-y) ); position: absolute; @@ -81,8 +83,9 @@ transform: translateX(var(--toast-swipe-movement-x)) translateY( calc( - var(--toast-swipe-movement-y) - (var(--toast-index) * var(--peek)) - - (var(--shrink) * var(--height)) + var(--toast-swipe-movement-y) - + (var(--toast-index) * var(--peek)) - + (var(--shrink) * var(--height)) ) ) scale(var(--scale)); @@ -97,7 +100,7 @@ /* Gap area for hover detection between toasts */ .root::after { - content: ''; + content: ""; position: absolute; top: 100%; width: 100%; @@ -119,50 +122,50 @@ opacity: 0; } -.root[data-ending-style][data-swipe-direction='up'] { +.root[data-ending-style][data-swipe-direction="up"] { transform: translateY(calc(var(--toast-swipe-movement-y) - 150%)); } -.root[data-ending-style][data-swipe-direction='left'] { +.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'] { +.root[data-ending-style][data-swipe-direction="right"] { transform: translateX(calc(var(--toast-swipe-movement-x) + 150%)) translateY(var(--offset-y)); } -.root[data-ending-style][data-swipe-direction='down'] { +.root[data-ending-style][data-swipe-direction="down"] { transform: translateY(calc(var(--toast-swipe-movement-y) + 150%)); } /* ===== Type-based styling ===== */ -.root[data-type='success'] { +.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'] { +.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'] { +.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'] { +.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); } -.root[data-type='loading'] { +.root[data-type="loading"] { background: var(--rs-color-background-base-primary); border-color: var(--rs-color-border-base-primary); color: var(--rs-color-foreground-base-secondary); @@ -215,10 +218,10 @@ } /* 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 { +.root[data-type="success"] .description, +.root[data-type="error"] .description, +.root[data-type="warning"] .description, +.root[data-type="info"] .description { color: inherit; opacity: 0.85; } From 285b47779d40043d4f6af2272c8de8582505ed85 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 9 Mar 2026 09:43:17 +0530 Subject: [PATCH 3/4] chore: update pnpm lockfile --- pnpm-lock.yaml | 16 ---------------- 1 file changed, 16 deletions(-) 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: {} From e075c324a34378a473d3d05b8584d4f510a79fbe Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 12 Mar 2026 11:17:31 +0530 Subject: [PATCH 4/4] feat: toast improvements --- .../src/content/docs/components/toast/demo.ts | 110 ++++++++-- .../raystack/components/toast/toast-misc.tsx | 75 ------- .../components/toast/toast-provider.tsx | 8 +- .../raystack/components/toast/toast-root.tsx | 55 +++-- .../components/toast/toast.module.css | 188 ++++++++++-------- packages/raystack/components/toast/toast.tsx | 12 -- 6 files changed, 237 insertions(+), 211 deletions(-) delete mode 100644 packages/raystack/components/toast/toast-misc.tsx diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index b836007e1..4d5a06714 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -7,7 +7,7 @@ export const preview = { return ( - @@ -27,6 +27,13 @@ export const basicDemo = { export const typesDemo = { type: 'code', tabs: [ + { + name: 'Default', + code: ` + ` + }, { name: 'Success', code: ` @@ -53,13 +60,6 @@ export const typesDemo = { code: ` ` - }, - { - name: 'Loading', - code: ` - ` } ] @@ -139,32 +139,100 @@ export const positionDemo = { type: 'code', tabs: [ { - name: 'Bottom Right', + 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 ( + + + + + + ) + }` } ] }; diff --git a/packages/raystack/components/toast/toast-misc.tsx b/packages/raystack/components/toast/toast-misc.tsx deleted file mode 100644 index 696e55648..000000000 --- a/packages/raystack/components/toast/toast-misc.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'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 styles from './toast.module.css'; - -export const ToastTitle = forwardRef< - ElementRef, - ToastPrimitive.Title.Props ->(({ className, ...props }, ref) => ( - -)); - -ToastTitle.displayName = 'Toast.Title'; - -export const ToastDescription = forwardRef< - ElementRef, - ToastPrimitive.Description.Props ->(({ className, ...props }, ref) => ( - -)); - -ToastDescription.displayName = 'Toast.Description'; - -export const ToastAction = forwardRef< - ElementRef, - ToastPrimitive.Action.Props ->(({ className, ...props }, ref) => ( - -)); - -ToastAction.displayName = 'Toast.Action'; - -export const ToastClose = forwardRef< - ElementRef, - ToastPrimitive.Close.Props ->(({ className, children, ...props }, ref) => ( - - {children ?? -)); - -ToastClose.displayName = 'Toast.Close'; - -export const ToastViewport = forwardRef< - ElementRef, - ToastPrimitive.Viewport.Props ->(({ className, ...props }, ref) => ( - -)); - -ToastViewport.displayName = 'Toast.Viewport'; diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx index 4b4d8738b..f5bc78c7b 100644 --- a/packages/raystack/components/toast/toast-provider.tsx +++ b/packages/raystack/components/toast/toast-provider.tsx @@ -24,9 +24,11 @@ export interface ToastProviderProps position?: ToastPosition; } -function ToastList() { +function ToastList({ position }: { position: ToastPosition }) { const { toasts } = ToastPrimitive.useToastManager(); - return toasts.map(toast => ); + return toasts.map(toast => ( + + )); } export const ToastProvider = forwardRef< @@ -41,7 +43,7 @@ export const ToastProvider = forwardRef< ref={ref} className={cx(styles.viewport, styles[`viewport-${position}`])} > - + diff --git a/packages/raystack/components/toast/toast-root.tsx b/packages/raystack/components/toast/toast-root.tsx index e2ca9b3de..d38536458 100644 --- a/packages/raystack/components/toast/toast-root.tsx +++ b/packages/raystack/components/toast/toast-root.tsx @@ -4,50 +4,81 @@ 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, ...props }, ref) => { +>(({ toast, className, position = 'bottom-right', ...props }, ref) => { + const swipeDirection = getSwipeDirection(position); + const hasDescription = !!toast.description; + return ( -
+ {toast.title && ( - + {toast.title} )} - {toast.description && ( + {hasDescription && ( {toast.description} )} -
-
+ + {toast.actionProps && ( - - {toast.actionProps.children} - + } + /> )} } > - -
+
); diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index 6ff471465..81d9e4f7e 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -47,17 +47,8 @@ --scale: calc(max(0, 1 - (var(--toast-index) * 0.1))); --shrink: calc(1 - var(--scale)); --height: var(--toast-frontmost-height, var(--toast-height)); - --offset-y: calc( - var(--toast-offset-y) * - -1 + - (var(--toast-index) * var(--gap) * -1) + - var(--toast-swipe-movement-y) - ); position: absolute; - bottom: 0; - right: 0; - left: auto; box-sizing: border-box; width: 100%; height: var(--height); @@ -67,18 +58,32 @@ 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) var(--rs-space-4); + padding: var(--rs-space-3); background-clip: padding-box; + overflow: clip; user-select: none; cursor: default; z-index: calc(1000 - var(--toast-index)); - transform-origin: bottom center; 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( @@ -91,15 +96,63 @@ scale(var(--scale)); } -/* Expanded state (on hover) */ +/* ===== 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::after { +/* ===== 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%; @@ -108,24 +161,43 @@ height: calc(var(--gap) + 1px); } -/* ===== Enter / exit animations ===== */ -.root[data-starting-style], -.root[data-ending-style] { - transform: translateY(150%); +/* ===== 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)); @@ -136,10 +208,6 @@ translateY(var(--offset-y)); } -.root[data-ending-style][data-swipe-direction="down"] { - transform: translateY(calc(var(--toast-swipe-movement-y) + 150%)); -} - /* ===== Type-based styling ===== */ .root[data-type="success"] { background: var(--rs-color-background-success-primary); @@ -165,19 +233,11 @@ color: var(--rs-color-foreground-accent-primary); } -.root[data-type="loading"] { - background: var(--rs-color-background-base-primary); - border-color: var(--rs-color-border-base-primary); - color: var(--rs-color-foreground-base-secondary); -} - /* ===== Content (stacking visibility) ===== */ .content { display: flex; - align-items: center; - justify-content: space-between; - gap: var(--rs-space-3); - overflow: hidden; + align-items: start; + gap: var(--rs-space-5); transition: opacity 250ms; } @@ -189,10 +249,8 @@ opacity: 1; } +/* ===== Text container (title + description) ===== */ .textContainer { - display: flex; - flex-direction: column; - gap: var(--rs-space-1); flex: 1; min-width: 0; } @@ -200,20 +258,20 @@ /* ===== Title ===== */ .title { color: inherit; - font-size: var(--rs-font-size-small); + font-size: var(--rs-font-size-regular); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-small); - letter-spacing: var(--rs-letter-spacing-small); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); margin: 0; } /* ===== Description ===== */ .description { - color: var(--rs-color-foreground-base-secondary); - font-size: var(--rs-font-size-mini); + color: inherit; + font-size: var(--rs-font-size-small); font-weight: var(--rs-font-weight-regular); - line-height: var(--rs-line-height-mini); - letter-spacing: var(--rs-letter-spacing-mini); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); margin: 0; } @@ -222,55 +280,9 @@ .root[data-type="error"] .description, .root[data-type="warning"] .description, .root[data-type="info"] .description { - color: inherit; opacity: 0.85; } -/* ===== Actions container ===== */ -.actions { - display: flex; - align-items: center; - gap: var(--rs-space-2); - flex-shrink: 0; -} - -/* ===== Action button ===== */ -.action { - all: unset; - display: inline-flex; - align-items: center; - padding: var(--rs-space-1) var(--rs-space-3); - font-size: var(--rs-font-size-mini); - font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-mini); - border-radius: var(--rs-radius-1); - cursor: pointer; - color: inherit; - border: 0.5px solid currentColor; - opacity: 0.8; -} - -.action:hover { - opacity: 1; -} - -/* ===== Close button ===== */ -.close { - all: unset; - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--rs-space-1); - border-radius: var(--rs-radius-1); - cursor: pointer; - color: inherit; - opacity: 0.5; -} - -.close:hover { - opacity: 1; -} - /* ===== Reduced motion ===== */ @media (prefers-reduced-motion: reduce) { .root { diff --git a/packages/raystack/components/toast/toast.tsx b/packages/raystack/components/toast/toast.tsx index ea4151327..ff22ccafa 100644 --- a/packages/raystack/components/toast/toast.tsx +++ b/packages/raystack/components/toast/toast.tsx @@ -1,21 +1,9 @@ import { Toast as ToastPrimitive } from '@base-ui/react'; -import { - ToastAction, - ToastClose, - ToastDescription, - ToastTitle, - ToastViewport -} from './toast-misc'; import { ToastProvider } from './toast-provider'; import { ToastRoot } from './toast-root'; export const Toast = Object.assign(ToastRoot, { Provider: ToastProvider, - Title: ToastTitle, - Description: ToastDescription, - Action: ToastAction, - Close: ToastClose, - Viewport: ToastViewport, createToastManager: ToastPrimitive.createToastManager, useToastManager: ToastPrimitive.useToastManager });