From a0adc3806c381afed2af08dde0a62980e7e3065b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Apr 2026 00:20:19 +0530 Subject: [PATCH 1/5] feat(toast): add leadingIcon support and align styles with Figma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `leadingIcon` prop to toastManager.add/update and Toast.createToastManager by lifting it onto Base UI's typed `data` slot via a wrapper. - Render the leading icon before the title; color is driven by toast `type` (success/error/warning/info), the toast container itself stays neutral. - Drop typed background/border/text-color overrides so success/error/info/ warning toasts share the default surface — only the icon color changes. - Tighten content alignment: center title-only toasts, top-align when a description is present. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/content/docs/components/toast/demo.ts | 29 ++++++ .../content/docs/components/toast/index.mdx | 10 +- .../content/docs/components/toast/props.ts | 6 ++ .../components/toast/__tests__/toast.test.tsx | 47 +++++++++ .../components/toast/toast-manager.ts | 95 ++++++++++++++++++- .../components/toast/toast-provider.tsx | 24 ++++- .../raystack/components/toast/toast-root.tsx | 14 ++- .../components/toast/toast.module.css | 86 +++++++++-------- packages/raystack/components/toast/toast.tsx | 3 +- 9 files changed, 268 insertions(+), 46 deletions(-) diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 4d5a06714..35a513140 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -86,6 +86,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..15f590fb4 100644 --- a/apps/www/src/content/docs/components/toast/index.mdx +++ b/apps/www/src/content/docs/components/toast/index.mdx @@ -4,7 +4,7 @@ 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 { preview, basicDemo, typesDemo, descriptionDemo, leadingIconDemo, actionDemo, promiseDemo, positionDemo, updateDemo } from "./demo.ts"; @@ -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} - + + {leadingIcon && ( + + )} {toast.title && ( )} - + {toast.actionProps && ( 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); +} + /* ===== Text container (title + description) ===== */ .textContainer { flex: 1; min-width: 0; } +/* ===== 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 +284,7 @@ /* ===== Description ===== */ .description { - color: inherit; + color: var(--rs-color-foreground-base-secondary); font-size: var(--rs-font-size-small); font-weight: var(--rs-font-weight-regular); line-height: var(--rs-line-height-small); @@ -272,14 +292,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 }); From de3506afa528aa338fdd3566ff8571b9805a0c61 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Apr 2026 01:45:23 +0530 Subject: [PATCH 2/5] feat(toast): default leading icons per type Map success/error/warning/info/loading toast types to Radix icons (CheckCircled / CrossCircled / ExclamationTriangle / InfoCircled) and the apsara Spinner for loading. Untyped toasts fall back to InfoCircledIcon with the existing base-secondary color. Explicit `leadingIcon` still wins. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../raystack/components/toast/toast-root.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/toast/toast-root.tsx b/packages/raystack/components/toast/toast-root.tsx index 06bd72847..ab8b26760 100644 --- a/packages/raystack/components/toast/toast-root.tsx +++ b/packages/raystack/components/toast/toast-root.tsx @@ -1,15 +1,32 @@ 'use client'; import { Toast as ToastPrimitive } from '@base-ui/react'; -import { Cross1Icon } from '@radix-ui/react-icons'; +import { + CheckCircledIcon, + Cross1Icon, + CrossCircledIcon, + ExclamationTriangleIcon, + InfoCircledIcon +} from '@radix-ui/react-icons'; import { cx } from 'class-variance-authority'; +import type { ReactNode } from 'react'; import { Button } from '../button'; import { Flex } from '../flex'; import { IconButton } from '../icon-button'; +import { Spinner } from '../spinner'; import styles from './toast.module.css'; import type { ToastData } from './toast-manager'; import type { ToastPosition } from './toast-provider'; +const TOAST_ICONS: Record = { + default: , + success: , + error: , + warning: , + info: , + loading: +}; + type SwipeDirection = 'up' | 'down' | 'left' | 'right'; function getSwipeDirection(position: ToastPosition): SwipeDirection[] { @@ -40,7 +57,12 @@ export function ToastRoot({ }: ToastRootProps) { const swipeDirection = getSwipeDirection(position); const hasDescription = !!toast.description; - const leadingIcon = (toast.data as ToastData | undefined)?.leadingIcon; + const explicitLeadingIcon = (toast.data as ToastData | undefined) + ?.leadingIcon; + const leadingIcon = + explicitLeadingIcon ?? + (toast.type ? TOAST_ICONS[toast.type] : null) ?? + TOAST_ICONS.default; return ( Date: Thu, 30 Apr 2026 01:53:02 +0530 Subject: [PATCH 3/5] fix(toast): match Figma layout and description color - Description text color: base-secondary -> base-primary (Figma node 3594:25050 specifies foreground-base-primary). - Restructure content into a header row (icon + title + actions) followed by a separate description row indented 24px (rs-space-7), matching the Figma column layout instead of stacking title/desc next to the icon. - Header row gets gap=5 between left and actions, gap=3 between icon and title; min-height = rs-space-7 to keep title-only toasts consistent. - Title now always uses .title styling (was swapping to .description styling for title-only toasts). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../raystack/components/toast/toast-root.tsx | 65 +++++++++---------- .../components/toast/toast.module.css | 40 ++++++++---- 2 files changed, 58 insertions(+), 47 deletions(-) diff --git a/packages/raystack/components/toast/toast-root.tsx b/packages/raystack/components/toast/toast-root.tsx index ab8b26760..4063f56ab 100644 --- a/packages/raystack/components/toast/toast-root.tsx +++ b/packages/raystack/components/toast/toast-root.tsx @@ -72,43 +72,42 @@ export function ToastRoot({ data-position={position} {...props} > - - {leadingIcon && ( - - )} - - {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 55cb6a152..3c1ecadab 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -208,17 +208,11 @@ /* ===== Content (stacking visibility) ===== */ .content { display: flex; - align-items: center; + flex-direction: column; gap: var(--rs-space-3); transition: opacity 250ms; } -/* Top-align icon and actions when description is present so the icon - sits next to the title rather than centering against both lines. */ -.content[data-has-description] { - align-items: flex-start; -} - .content[data-behind] { opacity: 0; } @@ -227,6 +221,30 @@ opacity: 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; @@ -261,12 +279,6 @@ color: var(--rs-color-foreground-accent-primary); } -/* ===== Text container (title + description) ===== */ -.textContainer { - flex: 1; - min-width: 0; -} - /* ===== Actions (action button + close) ===== */ .actions { flex-shrink: 0; @@ -284,7 +296,7 @@ /* ===== Description ===== */ .description { - color: var(--rs-color-foreground-base-secondary); + 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); From e0664619368625eb805fbfe455d7ae951c0fca57 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Apr 2026 01:57:09 +0530 Subject: [PATCH 4/5] fix(toast): shrink title to description style for title-only toasts When a toast has a title but no description, render the title with .description style (12px regular) rather than .title style (14px medium) so the toast doesn't look outsized for a single short message. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/raystack/components/toast/toast-root.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/raystack/components/toast/toast-root.tsx b/packages/raystack/components/toast/toast-root.tsx index 4063f56ab..e84314b42 100644 --- a/packages/raystack/components/toast/toast-root.tsx +++ b/packages/raystack/components/toast/toast-root.tsx @@ -81,7 +81,9 @@ export function ToastRoot({ )} {toast.title && ( - + {toast.title} )} From f1792bf4d1c71bbb8ab4282f22b5e7a5b14f6d44 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Apr 2026 02:12:15 +0530 Subject: [PATCH 5/5] docs(toast): add interactive playground Adds a playground demo with controls for title, description, type, and an actionButton boolean toggle. Empty title/description strings are omitted from the toastManager.add call so users can test partial configurations. Replaces the static preview Demo at the top of the toast docs page with the playground. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/content/docs/components/toast/demo.ts | 49 +++++++++++++++++++ .../content/docs/components/toast/index.mdx | 4 +- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 35a513140..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: ` diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx index 15f590fb4..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, leadingIconDemo, actionDemo, promiseDemo, positionDemo, updateDemo } from "./demo.ts"; +import { playground, basicDemo, typesDemo, descriptionDemo, leadingIconDemo, actionDemo, promiseDemo, positionDemo, updateDemo } from "./demo.ts"; - + ## Anatomy