diff --git a/apps/www/src/app/examples/combobox/page.tsx b/apps/www/src/app/examples/combobox/page.tsx index da4352c17..c3cee46d3 100644 --- a/apps/www/src/app/examples/combobox/page.tsx +++ b/apps/www/src/app/examples/combobox/page.tsx @@ -1,20 +1,161 @@ 'use client'; -import { Combobox, Flex, Slider } from '@raystack/apsara'; -const Page = () => { +import { + Button, + Combobox, + Dialog, + Flex, + Toast, + toastManager, + useToastManager +} from '@raystack/apsara'; + +const FRUITS = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']; + +function ToastButton({ + label, + source, + type +}: { + label: string; + source: string; + type?: 'success' | 'info' | 'warning'; +}) { + // Demonstrates the hook flavor — works because every button is a + // descendant of in the tree below. + const { add } = useToastManager(); return ( - + add({ + title: `Toast from ${source}`, + description: 'Toasts portal to the top-level provider.', + type + }) + } > - - + {label} + + ); +} + +const Page = () => { + return ( + + +

Combobox + nested dialogs + toast

+

+ Toasts triggered from any depth of nested dialog still render at the + root viewport. Each level has its own combobox and toast button. +

+ + + + + {FRUITS.map(f => ( + + {f} + + ))} + + + + + {/* Singleton flavor — usable from anywhere, including non-React code. */} + + + + }> + Open dialog 1 + + + + Dialog 1 + + Triggers a toast and opens a nested dialog. + + + + + + + + {FRUITS.map(f => ( + + {f} + + ))} + + + + + + + }> + Open dialog 2 + + + + Dialog 2 (nested) + + A toast fired from here still appears at the root + viewport — even though this dialog is portaled. + + + + + + + + + }> + Close + + + + + + + + + }> + Close + + + + + +
+
); }; diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 4d5a06714..f946cdfcf 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -1,5 +1,54 @@ 'use client'; +export const getCode = ( + _updatedProps: Record, + allProps: Record +) => { + const { title, description, type, actionButton } = allProps; + const opts: string[] = []; + if (title && title !== '') opts.push(`title: "${title}"`); + if (description && description !== '') + opts.push(`description: "${description}"`); + if (type && type !== 'default') opts.push(`type: "${type}"`); + if (actionButton) + opts.push(`actionProps: { children: "Action", onClick: () => {} }`); + + const optsStr = opts.length ? `{ ${opts.join(', ')} }` : '{}'; + + return ` + + + `; +}; + +export const playground = { + type: 'playground', + controls: { + title: { + type: 'text', + initialValue: 'Order placed', + defaultValue: '' + }, + description: { + type: 'text', + initialValue: 'Monday, 7 Oct 2024 at 10:20 AM', + defaultValue: '' + }, + type: { + type: 'select', + options: ['default', 'success', 'error', 'warning', 'info', 'loading'], + defaultValue: 'default' + }, + actionButton: { + type: 'checkbox', + defaultValue: false + } + }, + getCode +}; + export const preview = { type: 'code', code: ` @@ -86,6 +135,52 @@ export const descriptionDemo = { ` }; +export const leadingIconDemo = { + type: 'code', + tabs: [ + { + name: 'Custom icons', + code: ` + + + + + ` + }, + { + name: 'No icon', + code: ` + ` + } + ] +}; + export const actionDemo = { type: 'code', code: ` @@ -237,6 +332,34 @@ export const positionDemo = { ] }; +export const hookDemo = { + type: 'code', + code: ` + function HookDemo() { + // Hook usage lives in an inner component so it runs inside the Provider. + function Inner() { + const { add, toasts } = useToastManager(); + return ( + + + Active toasts: {toasts.length} + + ) + } + return ( + + + + ) + }` +}; + export const updateDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx index 17962298f..9eae7bc25 100644 --- a/apps/www/src/content/docs/components/toast/index.mdx +++ b/apps/www/src/content/docs/components/toast/index.mdx @@ -4,9 +4,9 @@ description: Displays temporary notification messages using Base UI Toast primit source: packages/raystack/components/toast --- -import { preview, basicDemo, typesDemo, descriptionDemo, actionDemo, promiseDemo, positionDemo, updateDemo } from "./demo.ts"; +import { playground, basicDemo, typesDemo, descriptionDemo, leadingIconDemo, actionDemo, promiseDemo, positionDemo, updateDemo, hookDemo } from "./demo.ts"; - + ## Anatomy @@ -38,14 +38,55 @@ Creates a new toast and returns its unique ID. The `toastManager` can be importe -### toastManager methods +### createToastManager() -| 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 | +Factory that returns a fresh toast manager. The exported singleton `toastManager` is one such instance created at module load. Pass a custom manager to `` to scope toasts to that provider. + +```tsx +import { Toast, createToastManager } from '@raystack/apsara' + +const manager = createToastManager() + + + + +``` + +The returned object exposes the four manager methods: + + + +#### Update options + +The `update(id, options)` method accepts a partial of the toast — every field optional, used to patch an existing toast in place. + + + +#### Promise options + +The `promise(promise, options)` method drives a toast through the resolve/reject lifecycle of a Promise. + + + +### useToastManager() + +Hook for dispatching toasts from inside a component tree wrapped by ``. Returns the same methods as `createToastManager()` — including first-class `leadingIcon` support — plus a reactive `toasts` array reflecting the currently-displayed toasts. + +```tsx +import { useToastManager } from '@raystack/apsara' +``` + +The hook is exported as a top-level named export (not as `Toast.useToastManager`) so React's rules-of-hooks linting recognizes it correctly. + + + +#### Toast object + +Each entry in `toasts` is a snapshot of a currently-mounted toast. + + + +Prefer the singleton `toastManager` when triggering toasts from non-React code (interceptors, utility functions, event handlers outside the provider). Use `useToastManager()` inside components when you need access to the live `toasts` array, or want the dispatch call colocated with the rest of the component's logic. ## Examples @@ -55,7 +96,7 @@ Creates a new toast and returns its unique ID. The `toastManager` can be importe ### Type Variants -Use the `type` prop to change the visual styling of the toast. +Use the `type` prop to drive the leading icon color (e.g. green for success, red for error). The toast container itself stays visually neutral regardless of type. @@ -63,6 +104,12 @@ Use the `type` prop to change the visual styling of the toast. +### With Leading Icon + +Each toast `type` ships with a sensible default icon. Pass `leadingIcon` to override it with any React node, or pass `leadingIcon: null` to render no icon at all. + + + ### With Action Button Use `actionProps` to render an action button inside the toast. @@ -87,6 +134,12 @@ Create a toast, then update or close it programmatically using the returned ID. +### Dispatching from a Component (Hook) + +Use `useToastManager()` inside a component to dispatch toasts and/or read the reactive list of active toasts. The component must be a descendant of ``. + + + ## Accessibility - Uses `aria-live` regions for screen reader announcements diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index 14ed93021..bbf4a275a 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -70,12 +70,138 @@ export interface ToastManagerAddOptions { actionProps?: React.ComponentPropsWithoutRef<'button'>; /** - * Custom data to attach to the toast. + * Icon rendered before the toast title. Inherits color from the toast type + * (e.g. green for `type: "success"`). Omit to use the default icon for the + * toast type, pass any React node to override it, or pass `null` to render + * no icon at all. */ - data?: Record; + leadingIcon?: React.ReactNode; /** * Optional custom ID for the toast. Auto-generated if not provided. */ id?: string; } + +/** + * Options for `update(id, options)`. Same shape as `ToastManagerAddOptions`, + * but every field is optional and the toast `id` moves to the first argument. + */ +export interface ToastManagerUpdateOptions { + title?: React.ReactNode; + description?: React.ReactNode; + type?: 'success' | 'error' | 'info' | 'warning' | 'loading'; + timeout?: number; + priority?: 'low' | 'high'; + onClose?: () => void; + onRemove?: () => void; + actionProps?: React.ComponentPropsWithoutRef<'button'>; + leadingIcon?: React.ReactNode; +} + +/** + * Options passed to `promise(promise, options)`. Each lifecycle stage accepts + * a string (used as the toast description), an options object, or — for + * `success`/`error` — a callable that receives the resolved value or + * rejection reason and returns either of the above. + */ +export interface ToastManagerPromiseOptions { + /** + * Toast shown while the promise is pending. Auto-dismiss is disabled. + * + * @remarks `string | ToastManagerUpdateOptions` + */ + loading: string | ToastManagerUpdateOptions; + + /** + * Toast shown when the promise resolves. If a function, it receives the + * resolved value and must return a string or update-options object. + * + * @remarks `string | ToastManagerUpdateOptions | ((result: Value) => string | ToastManagerUpdateOptions)` + */ + success: + | string + | ToastManagerUpdateOptions + | ((result: Value) => string | ToastManagerUpdateOptions); + + /** + * Toast shown when the promise rejects. If a function, it receives the + * rejection reason and must return a string or update-options object. + * + * @remarks `string | ToastManagerUpdateOptions | ((error: unknown) => string | ToastManagerUpdateOptions)` + */ + error: + | string + | ToastManagerUpdateOptions + | ((error: unknown) => string | ToastManagerUpdateOptions); +} + +/** + * Live snapshot of an active toast as exposed via `useToastManager().toasts`. + * Read-only — to mutate, use the manager methods (`add`/`update`/`close`). + */ +export interface ToastObject { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + type?: 'success' | 'error' | 'info' | 'warning' | 'loading'; + timeout?: number; + priority?: 'low' | 'high'; +} + +/** + * The toast manager returned by `createToastManager()` and exported as the + * `toastManager` singleton. + */ +export interface CreateToastManagerReturn { + /** + * Create a new toast and return its ID. The ID can later be passed to + * `update` or `close`. + * + * @remarks `(options: ToastManagerAddOptions) => string` + */ + add: (options: ToastManagerAddOptions) => string; + + /** + * Close a specific toast by ID, or close all visible toasts if no ID is + * given. + * + * @remarks `(id?: string) => void` + */ + close: (id?: string) => void; + + /** + * Update an existing toast in place. Typically used to swap a loading + * toast to a success or error state without dismissing it. + * + * @remarks `(id: string, options: ToastManagerUpdateOptions) => void` + */ + update: (id: string, options: ToastManagerUpdateOptions) => void; + + /** + * Tie a toast's lifecycle to a promise. Renders the `loading` toast + * immediately, then transitions to `success` or `error` when the promise + * settles. Returns the original promise so callers can still `await` it. + * + * @remarks `(promise: Promise, options: ToastManagerPromiseOptions) => Promise` + */ + promise: ( + promise: Promise, + options: ToastManagerPromiseOptions + ) => Promise; +} + +/** + * Return value of the `useToastManager()` hook. Same methods as + * `createToastManager()` plus a reactive `toasts` array driving component + * re-renders. + */ +export interface UseToastManagerReturn extends CreateToastManagerReturn { + /** + * Reactive array of currently-displayed toasts. The hook re-renders the + * caller whenever this list changes. + * + * @remarks `ToastObject[]` + */ + toasts: ToastObject[]; +} diff --git a/docs/V1-migration.md b/docs/V1-migration.md index 40996ba86..37500b4a3 100644 --- a/docs/V1-migration.md +++ b/docs/V1-migration.md @@ -1720,7 +1720,9 @@ Unchanged props: `disabled`, `placeholder`, `width`, `value`, `onChange`, `rows` ### Toast -**Exports renamed: `ToastContainer`/`toast` -> `Toast`/`toastManager`** +The library moved from Sonner to Base UI Toast primitives. The flat `toast()` function with chained shortcuts (`toast.success`, `toast.error`, `toast.dismiss`, …) is replaced by a single `toastManager.add(options)` entrypoint, and the standalone `` becomes a context-bearing `` that wraps your tree. + +**Exports renamed: `ToastContainer` / `toast` -> `Toast.Provider` / `toastManager`** ```tsx // ===== BEFORE (Sonner-based) ===== @@ -1731,7 +1733,7 @@ function App() { return ( <> - + ); } @@ -1740,6 +1742,7 @@ function App() { toast('Simple message'); toast.success('Operation complete'); toast.error('Something went wrong'); +toast.message('File deleted', { description: 'You can undo this action' }); toast('File deleted', { duration: 5000, action: { label: 'Undo', onClick: handleUndo }, @@ -1751,55 +1754,116 @@ toast.dismiss(); // dismiss all // ===== AFTER (Base UI-based) ===== import { Toast, toastManager } from '@raystack/apsara'; -// Provider — must wrap app as a context provider +// Provider — wraps the app and supplies context (required for useToastManager) function App() { return ( - + ); } -// Creating toasts +// Creating toasts — single entrypoint, `type` drives styling and default icon toastManager.add({ title: 'Simple message' }); toastManager.add({ title: 'Operation complete', type: 'success' }); toastManager.add({ title: 'Something went wrong', type: 'error' }); +toastManager.add({ title: 'File deleted', description: 'You can undo this action' }); toastManager.add({ title: 'File deleted', + timeout: 5000, actionProps: { children: 'Undo', onClick: handleUndo }, }); -const id = toastManager.add({ title: 'Uploading...' }); +const id = toastManager.add({ title: 'Uploading...', type: 'loading' }); toastManager.close(id); -// no built-in dismiss-all +toastManager.close(); // close all ``` -**Callbacks changed:** +**Provider prop mapping (`` -> ``):** + +| Before | After | Notes | +| --- | --- | --- | +| `duration` | `timeout` | Default auto-dismiss in ms; overridable per toast. | +| `visibleToasts` | `limit` | Max visible toasts (default 3). | +| `position` | `position` | Same accepted values. | +| `theme`, `richColors`, `closeButton`, `expand`, `gap`, `offset` | — | Removed. Theme follows the app's theme provider, the close button is always shown, expand-on-hover is always on. | + +**Toast option mapping (`toast(msg, options)` -> `toastManager.add(options)`):** + +| Before | After | Notes | +| --- | --- | --- | +| First-arg message | `title` | Now part of the options object. | +| `description` | `description` | If `title` is omitted, `description` is promoted into the headline slot so the layout stays single-line. | +| `duration` | `timeout` | `0` disables auto-dismiss (was `Infinity` in sonner). | +| `action: { label, onClick }` | `actionProps: { children, onClick, ...buttonProps }` | Accepts any `