diff --git a/apps/www/src/content/docs/components/popover/demo.ts b/apps/www/src/content/docs/components/popover/demo.ts
index f15dc1e14..62b0ce551 100644
--- a/apps/www/src/content/docs/components/popover/demo.ts
+++ b/apps/www/src/content/docs/components/popover/demo.ts
@@ -28,8 +28,9 @@ export const playground = {
options: ['top', 'right', 'bottom', 'left'],
defaultValue: 'bottom'
},
- sideOffset: { type: 'number', min: 0, defaultValue: 2 },
+ sideOffset: { type: 'number', min: 0, defaultValue: 4 },
collisionPadding: { type: 'number', min: 0, defaultValue: 0 },
+ showArrow: { type: 'checkbox', initialValue: false, defaultValue: false },
children: { type: 'text', initialValue: 'This is the popover content.' }
},
getCode
@@ -130,6 +131,17 @@ export const alignDemo = {
]
};
+export const arrowDemo = {
+ type: 'code',
+ code: `
+
+ }>Popover with arrow
+
+ The arrow points at the trigger.
+
+ `
+};
+
export const controlledDemo = {
type: 'code',
code: `
diff --git a/apps/www/src/content/docs/components/popover/index.mdx b/apps/www/src/content/docs/components/popover/index.mdx
index 8a0de78c7..4bf87ff3a 100644
--- a/apps/www/src/content/docs/components/popover/index.mdx
+++ b/apps/www/src/content/docs/components/popover/index.mdx
@@ -4,7 +4,7 @@ description: Displays rich content in a portal, triggered by a button.
source: packages/react/components/popover
---
-import { playground, alignDemo, positionDemo,
+import { playground, alignDemo, positionDemo, arrowDemo,
controlledDemo, viewportDemo,
} from "./demo.ts";
@@ -39,6 +39,12 @@ Customize how the popover aligns with its trigger.
+### Arrow
+
+Set `showArrow` to draw an arrow that points at the trigger. It is off by default. With the arrow on, `sideOffset` defaults to 10 to make room for it.
+
+
+
### Controlled
Pass `open` with `onOpenChange` to own the state. Do this when something other than the trigger has to close the popover, such as applying a filter.
@@ -87,6 +93,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
|------|---------|
| `popover-positioner` | Positioner that places the popup |
| `popover-content` | The popover popup |
+| `popover-arrow` | The arrow (when `showArrow`) |
| `popover-viewport` | The `Popover.Viewport` element |
## Accessibility
diff --git a/apps/www/src/content/docs/components/popover/props.ts b/apps/www/src/content/docs/components/popover/props.ts
index abbf0a233..cd4db98a6 100644
--- a/apps/www/src/content/docs/components/popover/props.ts
+++ b/apps/www/src/content/docs/components/popover/props.ts
@@ -19,9 +19,18 @@ export interface PopoverContentProps {
/** Alignment relative to trigger. */
align?: 'start' | 'center' | 'end';
- /** Distance in pixels from the trigger. */
+ /**
+ * Distance in pixels from the trigger.
+ * @default 4, or 10 with `showArrow`
+ */
sideOffset?: number;
+ /**
+ * Shows an arrow that points at the trigger.
+ * @default false
+ */
+ showArrow?: boolean;
+
/** Offset in pixels from alignment edge. */
alignOffset?: number;
diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts
index 2fb68704d..0a8c4d39e 100644
--- a/apps/www/src/content/docs/components/toast/demo.ts
+++ b/apps/www/src/content/docs/components/toast/demo.ts
@@ -73,6 +73,45 @@ export const basicDemo = {
`
};
+export const anchoredDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Default',
+ code: `
+ `
+ },
+ {
+ name: 'With arrow',
+ code: `
+ `
+ }
+ ]
+};
+
export const typesDemo = {
type: 'code',
tabs: [
diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx
index e5386a71c..e6204de5a 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/react/components/toast
---
-import { playground, basicDemo, typesDemo, descriptionDemo, leadingIconDemo, actionDemo, promiseDemo, positionDemo, updateDemo, upsertDemo, hookDemo } from "./demo.ts";
+import { playground, basicDemo, typesDemo, descriptionDemo, leadingIconDemo, actionDemo, promiseDemo, positionDemo, anchoredDemo, updateDemo, upsertDemo, hookDemo } from "./demo.ts";
@@ -70,6 +70,20 @@ Control where toasts appear on screen with the `position` prop on `Toast.Provide
+### Anchored toast
+
+Pass `positionerProps.anchor` to show the toast next to an element. Use it for short feedback near the control that caused it, such as "Copied".
+
+An anchored toast looks like a tooltip. It shows only the title, or the description when there is no title. It has no icon, action, or close button, so it closes when its `timeout` ends or when you call `close`. Pass `showArrow: true` to add an arrow that points at the anchor.
+
+`Toast.Provider` renders anchored toasts in their own layer. They do not join the stack, and `limit` applies to each group on its own. The same `toastManager` and `useToastManager()` add, update, and close both kinds. Pass `side`, `align`, or `sideOffset` in `positionerProps` to place the toast. It shows on the `top` side by default, 4px from the anchor, or 10px with the arrow.
+
+The manager picks the layer when it creates the toast: from the `add` options, or from `loading` for `promise`. A later `update` does not move a toast between the stack and an anchor, and the `positionerProps` it passes are merged with the current ones. A toast with `anchor: null` joins the stack. A custom `id` must be unique across both layers.
+
+The demo passes an `id`, so repeated clicks update one toast instead of adding more.
+
+
+
### Close and update
Create a toast, then update or close it programmatically using the returned ID.
@@ -142,7 +156,7 @@ The `promise(promise, options)` method drives a toast through the resolve/reject
### useToastManager()
-Hook for dispatching toasts from inside a component tree wrapped by ``. Returns the same methods as `createToastManager()`, including built-in `leadingIcon` support, plus a reactive `toasts` array reflecting the toasts on screen.
+Hook for dispatching toasts from inside a component tree wrapped by ``. Returns the same methods as `createToastManager()`, including built-in `leadingIcon` support, plus a reactive `toasts` array reflecting the stacked toasts on screen. The array does not include anchored toasts.
```tsx
import { useToastManager } from '@raystack/apsara'
@@ -166,7 +180,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| Slot | Element |
|------|---------|
-| `toast-viewport` | The region that holds all toasts |
+| `toast-viewport` | The region that holds stacked toasts |
| `toast` | A single toast root |
| `toast-content` | Content wrapper inside a toast |
| `toast-body` | Layout row holding the icon and text column |
@@ -178,10 +192,14 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `toast-action` | The action button (when `actionProps`) |
| `toast-close` | The close button |
| `toast-description` | The description (when both title and description are set) |
+| `toast-anchored-viewport` | The region that holds anchored toasts |
+| `toast-positioner` | Positioner that places an anchored toast next to its anchor |
+| `toast-arrow` | The arrow of an anchored toast (when `showArrow`) |
## Accessibility
- Uses `aria-live` regions for screen reader announcements
+- Stacked toasts are in a region named "Notifications", and anchored toasts are in a region named "Anchored notifications"
- `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"`
diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts
index ebf6970a0..0715ae755 100644
--- a/apps/www/src/content/docs/components/toast/props.ts
+++ b/apps/www/src/content/docs/components/toast/props.ts
@@ -78,11 +78,57 @@ export interface ToastManagerAddOptions {
leadingIcon?: React.ReactNode;
/**
- * Optional custom ID for the toast. Auto-generated if not provided. If a toast with this ID is open, it is updated in place.
+ * Shows the toast next to `anchor`, like a tooltip, instead of in the stack.
+ * Also takes `side`, `align`, `sideOffset`, and `alignOffset`.
+ *
+ * @remarks `{ anchor?: Element | null; side?: 'top' | 'right' | 'bottom' | 'left'; align?: 'start' | 'center' | 'end'; sideOffset?: number; alignOffset?: number }`
+ */
+ positionerProps?: ToastPositionerOptions;
+
+ /**
+ * Shows an arrow that points at the anchor. Only anchored toasts use it.
+ * @default false
+ */
+ showArrow?: boolean;
+
+ /**
+ * Optional custom ID for the toast. Auto-generated if not provided. If a toast with this ID is open, it is updated in place. Must be unique across stacked and anchored toasts.
*/
id?: string;
}
+/**
+ * Placement of an anchored toast, passed as `positionerProps`.
+ */
+export interface ToastPositionerOptions {
+ /** The element to show the toast next to. With `null`, the toast joins the stack. */
+ anchor?: Element | null;
+
+ /**
+ * Side of the anchor to show the toast on.
+ * @default "top"
+ */
+ side?: 'top' | 'right' | 'bottom' | 'left';
+
+ /**
+ * Alignment against the anchor.
+ * @default "center"
+ */
+ align?: 'start' | 'center' | 'end';
+
+ /**
+ * Distance in pixels from the anchor.
+ * @default 4, or 10 with `showArrow`
+ */
+ sideOffset?: number;
+
+ /**
+ * Offset in pixels along the alignment edge.
+ * @default 0
+ */
+ alignOffset?: number;
+}
+
/**
* Options for `update(id, options)`. Same shape as `ToastManagerAddOptions`,
* but every field is optional and the toast `id` moves to the first argument.
@@ -97,6 +143,14 @@ export interface ToastManagerUpdateOptions {
onRemove?: () => void;
actionProps?: React.ComponentPropsWithoutRef<'button'>;
leadingIcon?: React.ReactNode;
+ /**
+ * Placement of an anchored toast, merged with its current placement. It
+ * does not move a toast into or out of the stack.
+ *
+ * @remarks `{ anchor?: Element | null; side?: 'top' | 'right' | 'bottom' | 'left'; align?: 'start' | 'center' | 'end'; sideOffset?: number; alignOffset?: number }`
+ */
+ positionerProps?: ToastPositionerOptions;
+ showArrow?: boolean;
}
/**
@@ -107,7 +161,8 @@ export interface ToastManagerUpdateOptions {
*/
export interface ToastManagerPromiseOptions {
/**
- * Toast shown while the promise is pending. Auto-dismiss is disabled.
+ * Toast shown while the promise is pending. Auto-dismiss is disabled. Its
+ * `positionerProps.anchor` decides whether the toast is anchored.
*
* @remarks `string | ToastManagerUpdateOptions`
*/
diff --git a/packages/react/components/popover/__tests__/data-slots.test.tsx b/packages/react/components/popover/__tests__/data-slots.test.tsx
index 1801a6825..c6dca0cb2 100644
--- a/packages/react/components/popover/__tests__/data-slots.test.tsx
+++ b/packages/react/components/popover/__tests__/data-slots.test.tsx
@@ -1,6 +1,6 @@
import { render } from '@testing-library/react';
-import { describe, it } from 'vitest';
-import { expectSlots } from '~/test-utils/data-slots';
+import { describe, expect, it } from 'vitest';
+import { expectSlots, getSlot } from '~/test-utils/data-slots';
import { Popover } from '../popover';
describe('Popover data-slot contract', () => {
@@ -15,6 +15,26 @@ describe('Popover data-slot contract', () => {
expectSlots(document.body, ['popover-positioner', 'popover-content']);
});
+ it('exposes the arrow slot with showArrow', () => {
+ render(
+
+ Open
+ Content
+
+ );
+ expectSlots(document.body, ['popover-arrow']);
+ });
+
+ it('omits the arrow slot by default', () => {
+ render(
+
+ Open
+ Content
+
+ );
+ expect(getSlot(document.body, 'popover-arrow')).toBeNull();
+ });
+
it('exposes the viewport slot', () => {
render(
diff --git a/packages/react/components/popover/popover.module.css b/packages/react/components/popover/popover.module.css
index 9c207143d..8ad87af08 100644
--- a/packages/react/components/popover/popover.module.css
+++ b/packages/react/components/popover/popover.module.css
@@ -22,6 +22,12 @@
transition: opacity var(--rs-duration-normal) var(--rs-ease-out);
}
+/* The arrow sits outside the popup box, so the popup must not clip it. */
+.show-arrow {
+ position: relative;
+ overflow: visible;
+}
+
.popover[data-starting-style],
.popover[data-ending-style] {
opacity: 0;
diff --git a/packages/react/components/popover/popover.tsx b/packages/react/components/popover/popover.tsx
index 03cbde50d..6e9dbde89 100644
--- a/packages/react/components/popover/popover.tsx
+++ b/packages/react/components/popover/popover.tsx
@@ -2,6 +2,7 @@
import { Popover as PopoverPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
+import { Arrow } from '../../shared/arrow';
import { type Radius, radiusStyle } from '../../shared/radius';
import { useThemeInjection } from '../theme/portal';
import styles from './popover.module.css';
@@ -12,6 +13,11 @@ export interface PopoverContentProps
'render' | 'className' | 'style' | 'ref'
>,
PopoverPrimitive.Popup.Props {
+ /**
+ * Shows an arrow that points at the trigger.
+ * @default false
+ */
+ showArrow?: boolean;
/** Corner radius for this popup only. Overrides the theme's `radius`. */
radius?: Radius;
}
@@ -25,7 +31,8 @@ function PopoverContent({
render,
children,
radius,
- sideOffset = 4,
+ showArrow = false,
+ sideOffset = showArrow ? 10 : 4,
...positionerProps
}: PopoverContentProps) {
const theme = useThemeInjection();
@@ -43,6 +50,7 @@ function PopoverContent({
{...theme}
className={cx(
styles.popover,
+ showArrow && styles['show-arrow'],
theme?.className,
radiusStyle({ radius }),
className
@@ -54,6 +62,12 @@ function PopoverContent({
data-slot='popover-content'
>
{children}
+ {showArrow && (
+ }
+ />
+ )}
diff --git a/packages/react/components/preview-card/__tests__/preview-card.test.tsx b/packages/react/components/preview-card/__tests__/preview-card.test.tsx
index 835d0787e..bb27f8ce4 100644
--- a/packages/react/components/preview-card/__tests__/preview-card.test.tsx
+++ b/packages/react/components/preview-card/__tests__/preview-card.test.tsx
@@ -131,7 +131,7 @@ describe('PreviewCard', () => {
expect(screen.getByText(CARD_CONTENT)).toBeInTheDocument();
});
- const arrow = document.querySelector(`.${styles.arrow}`);
+ const arrow = document.querySelector('[data-slot="preview-card-arrow"]');
expect(arrow).not.toBeInTheDocument();
});
@@ -143,7 +143,9 @@ describe('PreviewCard', () => {
);
await waitFor(() => {
- const arrow = document.querySelector(`.${styles.arrow}`);
+ const arrow = document.querySelector(
+ '[data-slot="preview-card-arrow"]'
+ );
expect(arrow).toBeInTheDocument();
});
});
diff --git a/packages/react/components/preview-card/preview-card.module.css b/packages/react/components/preview-card/preview-card.module.css
index 25188f9dd..a39554bfa 100644
--- a/packages/react/components/preview-card/preview-card.module.css
+++ b/packages/react/components/preview-card/preview-card.module.css
@@ -37,37 +37,6 @@
opacity: 0;
}
-.arrow svg {
- color: var(--rs-color-panel);
-}
-
-.arrow {
- z-index: var(--rs-z-index-portal);
- filter: drop-shadow(0 1px 0 var(--rs-color-border-base-primary))
- drop-shadow(0 1px 1px var(--rs-color-border-base-primary));
-}
-
-.arrow[data-side="top"] {
- bottom: -7px;
-}
-
-.arrow[data-side="bottom"] {
- transform: translateY(-100%) rotate(180deg);
- top: 0;
-}
-
-.arrow[data-side="left"],
-.arrow[data-side="inline-start"] {
- right: 0;
- transform: translateY(-50%) translateX(100%) rotate(-90deg);
-}
-
-.arrow[data-side="right"],
-.arrow[data-side="inline-end"] {
- left: 0;
- transform: translateY(-50%) translateX(-100%) rotate(90deg);
-}
-
.viewport {
position: relative;
overflow: clip;
diff --git a/packages/react/components/preview-card/preview-card.tsx b/packages/react/components/preview-card/preview-card.tsx
index 08af27fdd..d65a82bba 100644
--- a/packages/react/components/preview-card/preview-card.tsx
+++ b/packages/react/components/preview-card/preview-card.tsx
@@ -2,6 +2,7 @@
import { PreviewCard as PreviewCardPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
+import { Arrow } from '../../shared/arrow';
import { type Radius, radiusStyle } from '../../shared/radius';
import { useThemeInjection } from '../theme/portal';
import styles from './preview-card.module.css';
@@ -57,22 +58,9 @@ function PreviewCardContent({
{children}
{showArrow && (
-
-
+ render={}
+ />
)}
diff --git a/packages/react/components/toast/__tests__/data-slots.test.tsx b/packages/react/components/toast/__tests__/data-slots.test.tsx
index 95f51a11a..8b23685a1 100644
--- a/packages/react/components/toast/__tests__/data-slots.test.tsx
+++ b/packages/react/components/toast/__tests__/data-slots.test.tsx
@@ -42,6 +42,36 @@ describe('Toast data-slot contract', () => {
]);
});
+ it('exposes slots for an anchored toast with showArrow', async () => {
+ const manager = renderProvider();
+ const anchor = screen.getByText('App content');
+ act(() => {
+ manager.add({
+ title: 'Copied',
+ showArrow: true,
+ positionerProps: { anchor }
+ });
+ });
+ await screen.findByText('Copied');
+ expectSlots(document.body, [
+ 'toast-anchored-viewport',
+ 'toast-positioner',
+ 'toast',
+ 'toast-title',
+ 'toast-arrow'
+ ]);
+ });
+
+ it('omits the positioner and arrow slots for a stacked toast', async () => {
+ const manager = renderProvider();
+ act(() => {
+ manager.add({ title: 'Stacked' });
+ });
+ await screen.findByText('Stacked');
+ expect(getSlot(document.body, 'toast-positioner')).toBeNull();
+ expect(getSlot(document.body, 'toast-arrow')).toBeNull();
+ });
+
it('omits the description slot when only a title is given', async () => {
const manager = renderProvider();
act(() => {
diff --git a/packages/react/components/toast/__tests__/toast.test.tsx b/packages/react/components/toast/__tests__/toast.test.tsx
index 59a97129f..a0a0bf5e5 100644
--- a/packages/react/components/toast/__tests__/toast.test.tsx
+++ b/packages/react/components/toast/__tests__/toast.test.tsx
@@ -1,7 +1,9 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { useEffect, useRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { Toast, toastManager } from '../toast';
+import tooltipStyles from '../../tooltip/tooltip.module.css';
+import { Toast, toastManager, useToastManager } from '../toast';
const renderWithProvider = (
props?: Partial>
@@ -19,6 +21,13 @@ describe('Toast', () => {
renderWithProvider();
expect(screen.getByText('App content')).toBeInTheDocument();
});
+
+ it('renders one Notifications region', () => {
+ renderWithProvider();
+ expect(
+ screen.getAllByRole('region', { name: 'Notifications' })
+ ).toHaveLength(1);
+ });
});
describe('toastManager.add()', () => {
@@ -351,6 +360,225 @@ describe('Toast', () => {
});
});
+ describe('Anchored toasts', () => {
+ const renderWithAnchor = () => {
+ const manager = Toast.createToastManager();
+ render(
+
+
+
+ );
+ return { manager, anchor: screen.getByText('Copy') };
+ };
+
+ it('renders an anchored toast like a tooltip, with only its title', async () => {
+ const { manager, anchor } = renderWithAnchor();
+ act(() => {
+ manager.add({
+ title: 'Copied',
+ description: 'The link is on your clipboard',
+ type: 'success',
+ positionerProps: { anchor, side: 'bottom' }
+ });
+ });
+ const toastEl = (await screen.findByText('Copied')).closest(
+ '[data-slot="toast"]'
+ );
+ const positioner = toastEl?.closest('[data-slot="toast-positioner"]');
+ expect(positioner).toHaveAttribute('data-side', 'bottom');
+ expect(
+ toastEl?.closest('[data-slot="toast-anchored-viewport"]')
+ ).toBeInTheDocument();
+ expect(toastEl).toHaveClass(tooltipStyles.content);
+ expect(toastEl?.textContent).toBe('Copied');
+ expect(
+ toastEl?.querySelector('[data-slot="toast-arrow"]')
+ ).not.toBeInTheDocument();
+ });
+
+ it('keeps the anchor when an update changes the placement', async () => {
+ const { manager, anchor } = renderWithAnchor();
+ let id: string;
+ act(() => {
+ id = manager.add({ title: 'Copied', positionerProps: { anchor } });
+ });
+ await screen.findByText('Copied');
+ let placement: unknown;
+ act(() => {
+ manager.update(id!, { positionerProps: { side: 'bottom' } });
+ manager.update(id!, prev => {
+ placement = prev.positionerProps;
+ return {};
+ });
+ });
+ expect(placement).toEqual({ anchor, side: 'bottom' });
+ });
+
+ it('keeps toasts without an anchor in the stacked viewport', async () => {
+ const { manager, anchor } = renderWithAnchor();
+ act(() => {
+ manager.add({ title: 'Anchored', positionerProps: { anchor } });
+ manager.add({ title: 'Stacked' });
+ });
+ const stacked = (await screen.findByText('Stacked')).closest(
+ '[data-slot="toast"]'
+ );
+ expect(stacked).toHaveAttribute('data-position', 'bottom-right');
+ // The anchored toast does not count toward the stack.
+ expect(
+ (stacked as HTMLElement).style.getPropertyValue('--toast-index')
+ ).toBe('0');
+ expect(
+ stacked?.closest('[data-slot="toast-positioner"]')
+ ).not.toBeInTheDocument();
+ expect(
+ stacked?.querySelector('[data-slot="toast-arrow"]')
+ ).not.toBeInTheDocument();
+ expect(stacked?.closest('[data-slot="toast-viewport"]')).not.toBeNull();
+ });
+
+ it('stacks a toast whose anchor is null', async () => {
+ const { manager } = renderWithAnchor();
+ act(() => {
+ manager.add({ title: 'Saved', positionerProps: { anchor: null } });
+ });
+ const toastEl = (await screen.findByText('Saved')).closest(
+ '[data-slot="toast"]'
+ );
+ expect(
+ toastEl?.closest('[data-slot="toast-viewport"]')
+ ).toBeInTheDocument();
+ expect(
+ toastEl?.closest('[data-slot="toast-positioner"]')
+ ).not.toBeInTheDocument();
+ });
+
+ it('updates an anchored toast', async () => {
+ const { manager, anchor } = renderWithAnchor();
+ let id: string;
+ act(() => {
+ id = manager.add({ title: 'Copying', positionerProps: { anchor } });
+ });
+ await screen.findByText('Copying');
+ act(() => {
+ manager.update(id!, { title: 'Copied' });
+ });
+ const toastEl = await screen.findByText('Copied');
+ expect(
+ toastEl.closest('[data-slot="toast-positioner"]')
+ ).toBeInTheDocument();
+ });
+
+ it('keeps a promise toast anchored when it resolves', async () => {
+ const { manager, anchor } = renderWithAnchor();
+ act(() => {
+ manager.promise(Promise.resolve(), {
+ loading: { title: 'Copying', positionerProps: { anchor } },
+ success: { title: 'Copied' },
+ error: { title: 'Failed' }
+ });
+ });
+ const toastEl = await screen.findByText('Copied');
+ expect(
+ toastEl.closest('[data-slot="toast-positioner"]')
+ ).toBeInTheDocument();
+ });
+
+ it('closes an anchored toast by id', async () => {
+ const { manager, anchor } = renderWithAnchor();
+ const onClose = vi.fn();
+ let id: string;
+ act(() => {
+ id = manager.add({
+ title: 'Copied',
+ positionerProps: { anchor },
+ onClose
+ });
+ });
+ await screen.findByText('Copied');
+ act(() => {
+ manager.close(id!);
+ });
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('routes anchored toasts from useToastManager', async () => {
+ function CopyButton() {
+ const { add } = useToastManager();
+ return (
+
+ );
+ }
+ render(
+
+
+
+ );
+ await userEvent.click(screen.getByText('Copy'));
+ const toastEl = await screen.findByText('Copied');
+ expect(
+ toastEl.closest('[data-slot="toast-positioner"]')
+ ).toBeInTheDocument();
+ });
+
+ it('routes anchored toasts from useToastManager in a mount effect', async () => {
+ function CopyOnMount() {
+ const ref = useRef(null);
+ const { add } = useToastManager();
+ useEffect(() => {
+ add({ title: 'Copied', positionerProps: { anchor: ref.current } });
+ }, [add]);
+ return (
+
+ );
+ }
+ render(
+
+
+
+ );
+ const toastEl = await screen.findByText('Copied');
+ expect(
+ toastEl.closest('[data-slot="toast-positioner"]')
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('useToastManager()', () => {
+ it('keeps add the same when the toast list changes', async () => {
+ let runs = 0;
+ function WelcomeOnMount() {
+ const { add } = useToastManager();
+ useEffect(() => {
+ runs += 1;
+ add({ title: 'Welcome' });
+ }, [add]);
+ return null;
+ }
+ render(
+
+
+
+ );
+ expect(await screen.findByText('Welcome')).toBeInTheDocument();
+ expect(runs).toBe(1);
+ });
+ });
+
describe('onClose callback', () => {
beforeEach(() => {
renderWithProvider();
diff --git a/packages/react/components/toast/toast-manager.ts b/packages/react/components/toast/toast-manager.ts
index bae2fe155..c3285f3f8 100644
--- a/packages/react/components/toast/toast-manager.ts
+++ b/packages/react/components/toast/toast-manager.ts
@@ -5,7 +5,7 @@ import {
type ToastObject,
Toast as ToastPrimitive
} from '@base-ui/react';
-import { type ReactNode, useMemo } from 'react';
+import { createContext, type ReactNode, useContext, useMemo } from 'react';
export interface ToastData {
/**
@@ -23,8 +23,16 @@ type BaseManager = ReturnType<
typeof ToastPrimitive.createToastManager
>;
+interface ToastOptions extends ToastData {
+ /**
+ * Shows an arrow that points at the anchor. Only anchored toasts use it.
+ * @default false
+ */
+ showArrow?: boolean;
+}
+
/** Public option shape: hides Base UI's internal storage slot and exposes `leadingIcon` directly. */
-type WithModifiedOptions = Omit & ToastData;
+type WithModifiedOptions = Omit & ToastOptions;
export type ToastAddOptions = WithModifiedOptions<
Parameters[0]
@@ -57,41 +65,45 @@ function lift(options: O) {
return { ...rest, data: { leadingIcon } };
}
-function liftUpdater(updater: ToastUpdater) {
- if (typeof updater === 'function') {
- return (prevToast: ToastObject) => lift(updater(prevToast));
- }
- return lift(updater);
-}
+/** Sets the placement options of a toast for the store it is in. */
+type Place = (options: ToastUpdateOptions) => ToastUpdateOptions;
-function liftDescriptor(option: string | ToastUpdateOptions) {
- return typeof option === 'string' ? option : lift(option);
+function liftDescriptor(option: string | ToastUpdateOptions, place: Place) {
+ return typeof option === 'string' ? option : lift(place(option));
}
function liftCallable(
option:
| string
| ToastUpdateOptions
- | ((arg: Arg) => string | ToastUpdateOptions)
+ | ((arg: Arg) => string | ToastUpdateOptions),
+ place: Place
) {
if (typeof option === 'function') {
- return (arg: Arg) => liftDescriptor(option(arg));
+ return (arg: Arg) => liftDescriptor(option(arg), place);
}
- return liftDescriptor(option);
+ return liftDescriptor(option, place);
}
-function liftPromiseOptions({
- loading,
- success,
- error
-}: ToastPromiseOptions) {
+function liftPromiseOptions(
+ { loading, success, error }: ToastPromiseOptions,
+ place: Place
+) {
return {
- loading: liftDescriptor(loading),
- success: liftCallable(success),
- error: liftCallable(error)
+ loading: liftDescriptor(loading, place),
+ success: liftCallable(success, place),
+ error: liftCallable(error, place)
};
}
+/** A toast with `positionerProps.anchor` shows next to that element. */
+function isAnchored(options: ToastUpdateOptions) {
+ return options.positionerProps?.anchor != null;
+}
+
+// A symbol key keeps the anchored manager out of the public API.
+export const anchoredManager = Symbol('anchoredManager');
+
/**
* Public toast manager. Mirrors the Base UI manager but exposes `leadingIcon`
* as a first-class option on `add`/`update`/`promise`. All other Base UI
@@ -105,21 +117,74 @@ export interface ToastManager
promise: Promise,
options: ToastPromiseOptions
) => Promise;
+ /** Base UI renders anchored toasts in a separate provider, with this manager. */
+ [anchoredManager]: BaseManager;
+}
+
+// Base UI turns off swipe when `positionerProps.anchor` is set, even to `null`.
+function unanchored(options: O) {
+ return { ...options, positionerProps: undefined };
+}
+
+// Base UI replaces `positionerProps` on update, which would drop `anchor`.
+function keepPlacement(prev: ToastUpdateOptions['positionerProps']): Place {
+ return options =>
+ options.positionerProps
+ ? { ...options, positionerProps: { ...prev, ...options.positionerProps } }
+ : options;
+}
+
+type Methods = Pick;
+
+// The toast's store is picked when it is created, and only `add` and `promise`
+// know it. `close` and `update` go to both stores, which ignore unknown ids.
+function route(
+ stacked: Methods,
+ anchored: Methods
+): Pick {
+ return {
+ add: options =>
+ isAnchored(options)
+ ? anchored.add(lift(options))
+ : stacked.add(lift(unanchored(options))),
+ close: id => {
+ stacked.close(id);
+ anchored.close(id);
+ },
+ update: (id, updater) => {
+ const resolve = (prev: ToastObject) =>
+ typeof updater === 'function' ? updater(prev) : updater;
+ stacked.update(id, prev => lift(unanchored(resolve(prev))));
+ anchored.update(id, prev =>
+ lift(keepPlacement(prev.positionerProps)(resolve(prev)))
+ );
+ },
+ promise: (promise, options) => {
+ const { loading } = options;
+ return typeof loading !== 'string' && isAnchored(loading)
+ ? anchored.promise(
+ promise,
+ liftPromiseOptions(options, keepPlacement(loading.positionerProps))
+ )
+ : stacked.promise(promise, liftPromiseOptions(options, unanchored));
+ }
+ };
}
export function createToastManager(): ToastManager {
- const base = ToastPrimitive.createToastManager();
+ const stacked = ToastPrimitive.createToastManager();
+ const anchored = ToastPrimitive.createToastManager();
return {
- ...base,
- add: options => base.add(lift(options)),
- update: (id, options) => base.update(id, liftUpdater(options)),
- promise: (promise, options) =>
- base.promise(promise, liftPromiseOptions(options))
+ ...stacked,
+ ...route(stacked, anchored),
+ [anchoredManager]: anchored
};
}
export const toastManager = createToastManager();
+export const ToastManagerContext = createContext(toastManager);
+
type BaseHookReturn = ReturnType<
typeof ToastPrimitive.useToastManager
>;
@@ -127,7 +192,7 @@ type BaseHookReturn = ReturnType<
/**
* Reactive view of the active toast list plus the same `leadingIcon`-aware
* `add`/`update`/`promise` API as the standalone manager. Must be used inside
- * ``.
+ * ``. The `toasts` list holds stacked toasts only.
*/
export interface UseToastManagerReturn
extends Omit {
@@ -137,15 +202,15 @@ export interface UseToastManagerReturn
}
export function useToastManager(): UseToastManagerReturn {
- const base = ToastPrimitive.useToastManager();
- return useMemo(
- () => ({
- ...base,
- add: options => base.add(lift(options)),
- update: (id, options) => base.update(id, liftUpdater(options)),
- promise: (promise, options) =>
- base.promise(promise, liftPromiseOptions(options))
- }),
- [base]
+ const { toasts, add, close, update, promise } =
+ ToastPrimitive.useToastManager();
+ const manager = useContext(ToastManagerContext);
+ // Stacked toasts go straight into the store, so a toast added in a mount
+ // effect shows even though the provider has not subscribed yet. The store
+ // methods never change, so the routed methods stay the same as toasts change.
+ const methods = useMemo(
+ () => route({ add, close, update, promise }, manager[anchoredManager]),
+ [add, close, update, promise, manager]
);
+ return useMemo(() => ({ toasts, ...methods }), [toasts, methods]);
}
diff --git a/packages/react/components/toast/toast-provider.tsx b/packages/react/components/toast/toast-provider.tsx
index 013864a7b..aaadf5b70 100644
--- a/packages/react/components/toast/toast-provider.tsx
+++ b/packages/react/components/toast/toast-provider.tsx
@@ -5,10 +5,12 @@ import { cx } from 'class-variance-authority';
import { useThemeInjection } from '../theme/portal';
import styles from './toast.module.css';
import {
+ anchoredManager,
toastManager as defaultToastManager,
- type ToastManager
+ type ToastManager,
+ ToastManagerContext
} from './toast-manager';
-import { ToastRoot } from './toast-root';
+import { AnchoredToastRoot, ToastRoot } from './toast-root';
export type ToastPosition =
| 'top-left'
@@ -33,11 +35,45 @@ export interface ToastProviderProps
toastManager?: ToastManager;
}
-function ToastList({ position }: { position: ToastPosition }) {
+function StackedToasts({ position }: { position: ToastPosition }) {
+ const theme = useThemeInjection();
+ const { toasts } = ToastPrimitive.useToastManager();
+ return (
+
+
+ {toasts.map(toast => (
+
+ ))}
+
+
+ );
+}
+
+function AnchoredToasts() {
+ const theme = useThemeInjection();
const { toasts } = ToastPrimitive.useToastManager();
- return toasts.map(toast => (
-
- ));
+ return (
+
+
+ {toasts.map(toast => (
+
+ ))}
+
+
+ );
}
export function ToastProvider({
@@ -46,31 +82,27 @@ export function ToastProvider({
children,
...props
}: ToastProviderProps) {
- const theme = useThemeInjection();
return (
-
- {children}
-
-
-
-
-
-
+
+ {/* Comes first, so it subscribes before effects in `children` run. */}
+
+
+
+
+ {children}
+
+
+
);
}
diff --git a/packages/react/components/toast/toast-root.tsx b/packages/react/components/toast/toast-root.tsx
index 422414429..9a3f3cffa 100644
--- a/packages/react/components/toast/toast-root.tsx
+++ b/packages/react/components/toast/toast-root.tsx
@@ -4,12 +4,14 @@ import { Toast as ToastPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
import type { ReactNode } from 'react';
import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon, XIcon } from '~/icons';
+import { Arrow } from '../../shared/arrow';
import { Button } from '../button';
import { Flex } from '../flex';
import { IconButton } from '../icon-button';
import { Spinner } from '../spinner';
+import tooltipStyles from '../tooltip/tooltip.module.css';
import styles from './toast.module.css';
-import type { ToastData } from './toast-manager';
+import type { ToastAddOptions, ToastData } from './toast-manager';
import type { ToastPosition } from './toast-provider';
const TOAST_ICONS: Record = {
@@ -153,3 +155,40 @@ export function ToastRoot({
}
ToastRoot.displayName = 'Toast';
+
+interface AnchoredToastRootProps {
+ toast: ToastPrimitive.Root.Props['toast'] &
+ Pick;
+}
+
+export function AnchoredToastRoot({ toast }: AnchoredToastRootProps) {
+ const title = toast.title ?? toast.description;
+ return (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {toast.showArrow && (
+ } />
+ )}
+
+
+ );
+}
diff --git a/packages/react/components/toast/toast.module.css b/packages/react/components/toast/toast.module.css
index 3c8ec68e7..d00d2863f 100644
--- a/packages/react/components/toast/toast.module.css
+++ b/packages/react/components/toast/toast.module.css
@@ -298,6 +298,28 @@
margin: 0;
}
+/* ===== Anchored toasts (the surface comes from the tooltip) ===== */
+.anchoredViewport {
+ outline: none;
+}
+
+.positioner {
+ z-index: calc(var(--rs-z-index-toast) - var(--toast-index));
+}
+
+.positioner[data-anchor-hidden] {
+ visibility: hidden;
+}
+
+.anchored[data-limited] {
+ opacity: 0;
+}
+
+.anchoredTitle {
+ margin: 0;
+ font: inherit;
+}
+
@media (prefers-reduced-motion: no-preference) {
.root {
transition:
diff --git a/packages/react/components/tooltip/tooltip-content.tsx b/packages/react/components/tooltip/tooltip-content.tsx
index eb9a9d510..358dc41c1 100644
--- a/packages/react/components/tooltip/tooltip-content.tsx
+++ b/packages/react/components/tooltip/tooltip-content.tsx
@@ -2,6 +2,7 @@
import { Tooltip as TooltipPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
+import { Arrow } from '../../shared/arrow';
import { type Radius, radiusStyle } from '../../shared/radius';
import { Text } from '../text';
import { useThemeInjection } from '../theme/portal';
@@ -69,22 +70,9 @@ export function TooltipContent({
)}
{showArrow && (
-
-
+ render={}
+ />
)}
diff --git a/packages/react/components/tooltip/tooltip.module.css b/packages/react/components/tooltip/tooltip.module.css
index 677d3153c..7c029afae 100644
--- a/packages/react/components/tooltip/tooltip.module.css
+++ b/packages/react/components/tooltip/tooltip.module.css
@@ -23,37 +23,6 @@
transition: opacity var(--rs-duration-fast) var(--rs-ease-out);
}
-.arrow svg {
- color: var(--rs-color-panel);
-}
-.arrow {
- z-index: var(--rs-z-index-portal);
- filter: drop-shadow(0 1px 0 var(--rs-color-border-base-primary))
- drop-shadow(0 1px 1px var(--rs-color-border-base-primary));
-}
-
-/* Arrow positioning based on side */
-.arrow[data-side="top"] {
- bottom: -7px;
-}
-
-.arrow[data-side="bottom"] {
- transform: translateY(-100%) rotate(180deg);
- top: 0;
-}
-
-.arrow[data-side="left"],
-.arrow[data-side="inline-start"] {
- right: 0;
- transform: translateY(-50%) translateX(100%) rotate(-90deg);
-}
-
-.arrow[data-side="right"],
-.arrow[data-side="inline-end"] {
- left: 0;
- transform: translateY(-50%) translateX(-100%) rotate(90deg);
-}
-
.content[data-starting-style],
.content[data-ending-style] {
opacity: 0;
diff --git a/packages/react/shared/arrow/arrow.module.css b/packages/react/shared/arrow/arrow.module.css
new file mode 100644
index 000000000..dad69633b
--- /dev/null
+++ b/packages/react/shared/arrow/arrow.module.css
@@ -0,0 +1,30 @@
+.arrow svg {
+ color: var(--rs-color-panel);
+}
+.arrow {
+ z-index: var(--rs-z-index-portal);
+ filter: drop-shadow(0 1px 0 var(--rs-color-border-base-primary))
+ drop-shadow(0 1px 1px var(--rs-color-border-base-primary));
+}
+
+/* Arrow positioning based on side */
+.arrow[data-side="top"] {
+ bottom: -7px;
+}
+
+.arrow[data-side="bottom"] {
+ transform: translateY(-100%) rotate(180deg);
+ top: 0;
+}
+
+.arrow[data-side="left"],
+.arrow[data-side="inline-start"] {
+ right: 0;
+ transform: translateY(-50%) translateX(100%) rotate(-90deg);
+}
+
+.arrow[data-side="right"],
+.arrow[data-side="inline-end"] {
+ left: 0;
+ transform: translateY(-50%) translateX(-100%) rotate(90deg);
+}
diff --git a/packages/react/shared/arrow/arrow.tsx b/packages/react/shared/arrow/arrow.tsx
new file mode 100644
index 000000000..05fde3d58
--- /dev/null
+++ b/packages/react/shared/arrow/arrow.tsx
@@ -0,0 +1,23 @@
+import { cx } from 'class-variance-authority';
+import type { ComponentProps } from 'react';
+import styles from './arrow.module.css';
+
+/** The arrow of a popup. Pass it to the `render` prop of a Base UI `Arrow` part. */
+export function Arrow({ className, ...props }: ComponentProps<'div'>) {
+ return (
+
+ );
+}
diff --git a/packages/react/shared/arrow/index.ts b/packages/react/shared/arrow/index.ts
new file mode 100644
index 000000000..2c9555f11
--- /dev/null
+++ b/packages/react/shared/arrow/index.ts
@@ -0,0 +1 @@
+export * from './arrow';