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';