Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/www/src/content/docs/components/toast/demo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
'use client';

export const getCode = (
_updatedProps: Record<string, any>,
allProps: Record<string, any>
) => {
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 `
<Toast.Provider>
<Button onClick={() => toastManager.add(${optsStr})}>
Show toast
</Button>
</Toast.Provider>`;
};

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: `
Expand Down Expand Up @@ -86,6 +135,35 @@ export const descriptionDemo = {
</Flex>`
};

export const leadingIconDemo = {
type: 'code',
code: `
<Flex gap="medium" wrap="wrap">
<Button onClick={() => toastManager.add({
title: "Saved successfully",
type: "success",
leadingIcon: <CheckCircledIcon />
})}>
Success with icon
</Button>
<Button onClick={() => toastManager.add({
title: "Upload failed",
description: "We couldn't upload your file. Please try again.",
type: "error",
leadingIcon: <CrossCircledIcon />
})}>
Error with icon
</Button>
<Button onClick={() => toastManager.add({
title: "FYI: System update available",
type: "info",
leadingIcon: <InfoCircledIcon />
})}>
Info with icon
</Button>
</Flex>`
};

export const actionDemo = {
type: 'code',
code: `
Expand Down
12 changes: 9 additions & 3 deletions apps/www/src/content/docs/components/toast/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

<Demo data={preview} />
<Demo data={playground} />

## Anatomy

Expand Down Expand Up @@ -55,14 +55,20 @@ 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.

<Demo data={typesDemo} />

### With Title and Description

<Demo data={descriptionDemo} />

### With Leading Icon

Pass any React node via `leadingIcon` to render an icon before the title. The icon inherits color from the toast `type`.

<Demo data={leadingIconDemo} />

### With Action Button

Use `actionProps` to render an action button inside the toast.
Expand Down
6 changes: 6 additions & 0 deletions apps/www/src/content/docs/components/toast/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
47 changes: 47 additions & 0 deletions packages/raystack/components/toast/__tests__/toast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,53 @@ describe('Toast', () => {
});
});

describe('Toast leadingIcon', () => {
beforeEach(() => {
renderWithProvider();
});

it('renders leadingIcon when provided', async () => {
act(() => {
toastManager.add({
title: 'With icon',
leadingIcon: <svg data-testid='leading-icon' />
});
});

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: <svg data-testid='updated-icon' />
});
});

await waitFor(() => {
expect(screen.getByText('Updated')).toBeInTheDocument();
expect(screen.getByTestId('updated-icon')).toBeInTheDocument();
});
});
});

describe('Multiple toasts', () => {
beforeEach(() => {
renderWithProvider();
Expand Down
95 changes: 94 additions & 1 deletion packages/raystack/components/toast/toast-manager.ts
Original file line number Diff line number Diff line change
@@ -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<ToastData>
>;
type BaseAddOptions = Parameters<BaseManager['add']>[0];
type BaseUpdateOptions = Parameters<BaseManager['update']>[1];

export type ToastAddOptions = Omit<BaseAddOptions, 'data'> & {
/**
* Icon rendered before the toast title. Inherits color from the toast type
* (e.g. green for `type: "success"`).
*/
leadingIcon?: ReactNode;
};

export type ToastUpdateOptions = Omit<BaseUpdateOptions, 'data'> & {
leadingIcon?: ReactNode;
};

export interface ToastPromiseOptions<Value> {
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: <Value>(
promise: Promise<Value>,
options: ToastPromiseOptions<Value>
) => Promise<Value>;
}

/**
* 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<ToastManager, BaseManager>();

function lift<O extends { leadingIcon?: ReactNode }>(options: O) {
const { leadingIcon, ...rest } = options;
if (leadingIcon === undefined) return rest;
return { ...rest, data: { leadingIcon } };
}

function liftPromiseOption<O extends { leadingIcon?: ReactNode }, A>(
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<ToastData>();
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();
24 changes: 21 additions & 3 deletions packages/raystack/components/toast/toast-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -14,12 +18,19 @@ export type ToastPosition =
| 'bottom-center'
| 'bottom-right';

export interface ToastProviderProps extends ToastPrimitive.Provider.Props {
export interface ToastProviderProps
extends Omit<ToastPrimitive.Provider.Props, 'toastManager'> {
/**
* 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 }) {
Expand All @@ -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 (
<ToastPrimitive.Provider toastManager={toastManager} {...props}>
<ToastPrimitive.Provider toastManager={baseManager} {...props}>
{children}
<ToastPrimitive.Portal>
<ToastPrimitive.Viewport
Expand Down
Loading
Loading