diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 4d5a06714..0dd782652 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,35 @@ export const descriptionDemo = { ` }; +export const leadingIconDemo = { + type: 'code', + code: ` + + + + + ` +}; + export const actionDemo = { 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..ebd188808 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 } from "./demo.ts"; - + ## Anatomy @@ -55,7 +55,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 +63,12 @@ Use the `type` prop to change the visual styling of the toast. +### With Leading Icon + +Pass any React node via `leadingIcon` to render an icon before the title. The icon inherits color from the toast `type`. + + + ### With Action Button Use `actionProps` to render an action button inside the toast. diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index 14ed93021..7ea80cd51 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -69,6 +69,12 @@ export interface ToastManagerAddOptions { */ actionProps?: React.ComponentPropsWithoutRef<'button'>; + /** + * Icon rendered before the toast title. Inherits color from the toast type + * (e.g. green for `type: "success"`). + */ + leadingIcon?: React.ReactNode; + /** * Custom data to attach to the toast. */ diff --git a/packages/raystack/components/toast/__tests__/toast.test.tsx b/packages/raystack/components/toast/__tests__/toast.test.tsx index fe49d1220..83db4efe3 100644 --- a/packages/raystack/components/toast/__tests__/toast.test.tsx +++ b/packages/raystack/components/toast/__tests__/toast.test.tsx @@ -208,6 +208,53 @@ describe('Toast', () => { }); }); + describe('Toast leadingIcon', () => { + beforeEach(() => { + renderWithProvider(); + }); + + it('renders leadingIcon when provided', async () => { + act(() => { + toastManager.add({ + title: 'With icon', + leadingIcon: + }); + }); + + expect(await screen.findByText('With icon')).toBeInTheDocument(); + expect(screen.getByTestId('leading-icon')).toBeInTheDocument(); + }); + + it('does not render leading-icon slot when leadingIcon is omitted', async () => { + act(() => { + toastManager.add({ title: 'No icon' }); + }); + + expect(await screen.findByText('No icon')).toBeInTheDocument(); + expect(screen.queryByTestId('leading-icon')).not.toBeInTheDocument(); + }); + + it('forwards leadingIcon through update()', async () => { + let id: string; + act(() => { + id = toastManager.add({ title: 'Initial' }); + }); + expect(await screen.findByText('Initial')).toBeInTheDocument(); + + act(() => { + toastManager.update(id!, { + title: 'Updated', + leadingIcon: + }); + }); + + await waitFor(() => { + expect(screen.getByText('Updated')).toBeInTheDocument(); + expect(screen.getByTestId('updated-icon')).toBeInTheDocument(); + }); + }); + }); + describe('Multiple toasts', () => { beforeEach(() => { renderWithProvider(); diff --git a/packages/raystack/components/toast/toast-manager.ts b/packages/raystack/components/toast/toast-manager.ts index 42678f1a7..586f8873d 100644 --- a/packages/raystack/components/toast/toast-manager.ts +++ b/packages/raystack/components/toast/toast-manager.ts @@ -1,5 +1,98 @@ 'use client'; import { Toast as ToastPrimitive } from '@base-ui/react'; +import type { ReactNode } from 'react'; -export const toastManager = ToastPrimitive.createToastManager(); +export interface ToastData { + leadingIcon?: ReactNode; +} + +type BaseManager = ReturnType< + typeof ToastPrimitive.createToastManager +>; +type BaseAddOptions = Parameters[0]; +type BaseUpdateOptions = Parameters[1]; + +export type ToastAddOptions = Omit & { + /** + * Icon rendered before the toast title. Inherits color from the toast type + * (e.g. green for `type: "success"`). + */ + leadingIcon?: ReactNode; +}; + +export type ToastUpdateOptions = Omit & { + leadingIcon?: ReactNode; +}; + +export interface ToastPromiseOptions { + loading: string | ToastUpdateOptions; + success: + | string + | ToastUpdateOptions + | ((result: Value) => string | ToastUpdateOptions); + error: + | string + | ToastUpdateOptions + | ((error: unknown) => string | ToastUpdateOptions); +} + +export interface ToastManager { + add: (options: ToastAddOptions) => string; + close: (id?: string) => void; + update: (id: string, options: ToastUpdateOptions) => void; + promise: ( + promise: Promise, + options: ToastPromiseOptions + ) => Promise; +} + +/** + * Internal map from public ToastManager wrappers to the underlying Base UI + * manager. Used by `ToastProvider` to forward the correct instance to + * `ToastPrimitive.Provider`. + */ +export const _baseManagerRef = new WeakMap(); + +function lift(options: O) { + const { leadingIcon, ...rest } = options; + if (leadingIcon === undefined) return rest; + return { ...rest, data: { leadingIcon } }; +} + +function liftPromiseOption( + option: string | O | ((arg: A) => string | O) +) { + if (typeof option === 'string') return option; + if (typeof option === 'function') { + const fn = option as (arg: A) => string | O; + return (arg: A) => { + const result = fn(arg); + return typeof result === 'string' ? result : lift(result); + }; + } + return lift(option); +} + +export function createToastManager(): ToastManager { + const base = ToastPrimitive.createToastManager(); + const wrapper: ToastManager = { + add: options => base.add(lift(options) as BaseAddOptions), + close: id => base.close(id), + update: (id, options) => + base.update(id, lift(options) as BaseUpdateOptions), + promise: (promise, { loading, success, error }) => + base.promise(promise, { + // biome-ignore lint/suspicious/noExplicitAny: Base UI accepts these shapes via union + loading: liftPromiseOption(loading) as any, + // biome-ignore lint/suspicious/noExplicitAny: Base UI accepts these shapes via union + success: liftPromiseOption(success) as any, + // biome-ignore lint/suspicious/noExplicitAny: Base UI accepts these shapes via union + error: liftPromiseOption(error) as any + }) + }; + _baseManagerRef.set(wrapper, base); + return wrapper; +} + +export const toastManager = createToastManager(); diff --git a/packages/raystack/components/toast/toast-provider.tsx b/packages/raystack/components/toast/toast-provider.tsx index 82d4772e5..babfac5a0 100644 --- a/packages/raystack/components/toast/toast-provider.tsx +++ b/packages/raystack/components/toast/toast-provider.tsx @@ -3,7 +3,11 @@ import { Toast as ToastPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import styles from './toast.module.css'; -import { toastManager } from './toast-manager'; +import { + _baseManagerRef, + toastManager as defaultToastManager, + type ToastManager +} from './toast-manager'; import { ToastRoot } from './toast-root'; export type ToastPosition = @@ -14,12 +18,19 @@ export type ToastPosition = | 'bottom-center' | 'bottom-right'; -export interface ToastProviderProps extends ToastPrimitive.Provider.Props { +export interface ToastProviderProps + extends Omit { /** * Position of the toast viewport on screen. * @default "bottom-right" */ position?: ToastPosition; + /** + * Toast manager instance. Defaults to the singleton exported as + * `toastManager`. Provide a custom one created via + * `Toast.createToastManager()` to scope toasts to this provider. + */ + toastManager?: ToastManager; } function ToastList({ position }: { position: ToastPosition }) { @@ -31,11 +42,18 @@ function ToastList({ position }: { position: ToastPosition }) { export function ToastProvider({ position = 'bottom-right', + toastManager = defaultToastManager, children, ...props }: ToastProviderProps) { + const baseManager = _baseManagerRef.get(toastManager); + if (!baseManager) { + throw new Error( + 'ToastProvider: invalid toastManager. Use `Toast.createToastManager()` from @raystack/apsara to create one.' + ); + } return ( - + {children} = { + default: , + success: , + error: , + warning: , + info: , + loading: +}; + type SwipeDirection = 'up' | 'down' | 'left' | 'right'; function getSwipeDirection(position: ToastPosition): SwipeDirection[] { @@ -39,6 +57,12 @@ export function ToastRoot({ }: ToastRootProps) { const swipeDirection = getSwipeDirection(position); const hasDescription = !!toast.description; + const explicitLeadingIcon = (toast.data as ToastData | undefined) + ?.leadingIcon; + const leadingIcon = + explicitLeadingIcon ?? + (toast.type ? TOAST_ICONS[toast.type] : null) ?? + TOAST_ICONS.default; return ( - - {toast.title && ( - +
+ {leadingIcon && ( + + )} + {toast.title && ( + + {toast.title} + + )} +
+ + {toast.actionProps && ( + } + /> + )} + } > - {toast.title} -
- )} - {hasDescription && ( + + +
+ + {hasDescription && ( +
{toast.description} - )} - - - {toast.actionProps && ( - } - /> - )} - } - > - - - +
+ )}
); diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index e27f9b8a8..3c1ecadab 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -205,36 +205,11 @@ 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); + flex-direction: column; + gap: var(--rs-space-3); transition: opacity 250ms; } @@ -246,15 +221,72 @@ opacity: 1; } -/* ===== Text container (title + description) ===== */ -.textContainer { - flex: 1; +/* ===== Header row (icon + title + actions) ===== */ +.header { + display: flex; + align-items: center; + gap: var(--rs-space-5); + min-height: var(--rs-space-7); + width: 100%; +} + +.headerLeft { + display: flex; + flex: 1 0 0; + align-items: center; + gap: var(--rs-space-3); min-width: 0; } +/* ===== Description row (indented to align under title text) ===== */ +.descriptionRow { + display: flex; + padding-left: var(--rs-space-7); + width: 100%; +} + +/* ===== Leading icon ===== */ +.leadingIcon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--rs-space-5); + height: var(--rs-space-5); + color: var(--rs-color-foreground-base-secondary); +} + +.leadingIcon > svg { + width: 100%; + height: 100%; +} + +/* Type-based leading icon colors. The toast itself stays visually neutral + across all types — only the leading icon changes color per type. */ +.root[data-type="success"] .leadingIcon { + color: var(--rs-color-foreground-success-primary); +} + +.root[data-type="error"] .leadingIcon { + color: var(--rs-color-foreground-danger-primary); +} + +.root[data-type="warning"] .leadingIcon { + color: var(--rs-color-foreground-attention-primary); +} + +.root[data-type="info"] .leadingIcon { + color: var(--rs-color-foreground-accent-primary); +} + +/* ===== Actions (action button + close) ===== */ +.actions { + flex-shrink: 0; +} + /* ===== Title ===== */ .title { - color: inherit; + color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-regular); font-weight: var(--rs-font-weight-medium); line-height: var(--rs-line-height-regular); @@ -264,7 +296,7 @@ /* ===== Description ===== */ .description { - color: inherit; + color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-small); font-weight: var(--rs-font-weight-regular); line-height: var(--rs-line-height-small); @@ -272,14 +304,6 @@ 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; -} - @media (prefers-reduced-motion: no-preference) { .root { transition: diff --git a/packages/raystack/components/toast/toast.tsx b/packages/raystack/components/toast/toast.tsx index ff22ccafa..c6f55aee7 100644 --- a/packages/raystack/components/toast/toast.tsx +++ b/packages/raystack/components/toast/toast.tsx @@ -1,10 +1,11 @@ import { Toast as ToastPrimitive } from '@base-ui/react'; +import { createToastManager } from './toast-manager'; import { ToastProvider } from './toast-provider'; import { ToastRoot } from './toast-root'; export const Toast = Object.assign(ToastRoot, { Provider: ToastProvider, - createToastManager: ToastPrimitive.createToastManager, + createToastManager, useToastManager: ToastPrimitive.useToastManager });