From 7261fdcb629e18bed2a7ecbecdb23444ad336278 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:09:59 -0500 Subject: [PATCH 1/5] feat: [toast, popover] add anchored toasts and popover arrow --- .../content/docs/components/popover/demo.ts | 12 ++ .../content/docs/components/popover/index.mdx | 9 +- .../content/docs/components/popover/props.ts | 11 +- .../src/content/docs/components/toast/demo.ts | 17 ++ .../content/docs/components/toast/index.mdx | 15 +- .../content/docs/components/toast/props.ts | 40 ++++ .../popover/__tests__/data-slots.test.tsx | 24 ++- .../components/popover/popover.module.css | 6 + packages/react/components/popover/popover.tsx | 18 +- .../toast/__tests__/data-slots.test.tsx | 24 +++ .../components/toast/__tests__/toast.test.tsx | 107 +++++++++- .../react/components/toast/toast-manager.ts | 69 +++++-- .../react/components/toast/toast-provider.tsx | 75 ++++--- .../react/components/toast/toast-root.tsx | 194 +++++++++++------- .../react/components/toast/toast.module.css | 56 ++++- .../components/tooltip/tooltip-content.tsx | 16 +- .../components/tooltip/tooltip.module.css | 31 --- packages/react/shared/arrow/arrow.module.css | 30 +++ packages/react/shared/arrow/arrow.tsx | 22 ++ packages/react/shared/arrow/index.ts | 1 + 20 files changed, 603 insertions(+), 174 deletions(-) create mode 100644 packages/react/shared/arrow/arrow.module.css create mode 100644 packages/react/shared/arrow/arrow.tsx create mode 100644 packages/react/shared/arrow/index.ts diff --git a/apps/www/src/content/docs/components/popover/demo.ts b/apps/www/src/content/docs/components/popover/demo.ts index 8d482639e..f7d1c90c4 100644 --- a/apps/www/src/content/docs/components/popover/demo.ts +++ b/apps/www/src/content/docs/components/popover/demo.ts @@ -30,6 +30,7 @@ export const playground = { }, sideOffset: { type: 'number', min: 0, defaultValue: 2 }, 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 13c36e132..ddffabace 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, } 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. @@ -75,6 +81,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`) | ## Accessibility diff --git a/apps/www/src/content/docs/components/popover/props.ts b/apps/www/src/content/docs/components/popover/props.ts index 3ffe417fa..7624dea9a 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 fce5f3cae..52a6fbec8 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -73,6 +73,23 @@ export const basicDemo = { ` }; +export const anchoredDemo = { + type: 'code', + 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 37a9254f3..048e87dd5 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, hookDemo } from "./demo.ts"; +import { playground, basicDemo, typesDemo, descriptionDemo, leadingIconDemo, actionDemo, promiseDemo, positionDemo, anchoredDemo, updateDemo, hookDemo } from "./demo.ts"; @@ -70,6 +70,14 @@ 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, with an arrow that points at it. Use it for short feedback near the control that caused it, such as "Copied". + +`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, 10px from the anchor. + + + ### Close and update Create a toast, then update or close it programmatically using the returned ID. @@ -136,7 +144,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' @@ -172,6 +180,9 @@ 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 | ## Accessibility diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index 9ed4e6bc1..5ad3e393e 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -77,12 +77,52 @@ export interface ToastManagerAddOptions { */ leadingIcon?: React.ReactNode; + /** + * Shows the toast next to `anchor` with an arrow, 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; + /** * Optional custom ID for the toast. Auto-generated if not provided. */ id?: string; } +/** + * Placement of an anchored toast, passed as `positionerProps`. + */ +export interface ToastPositionerOptions { + /** The element to show the toast next to. */ + 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 10 + */ + 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. diff --git a/packages/react/components/popover/__tests__/data-slots.test.tsx b/packages/react/components/popover/__tests__/data-slots.test.tsx index 8e0efb133..4b2724a27 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', () => { @@ -14,4 +14,24 @@ describe('Popover data-slot contract', () => { // Popover content portals to the body. 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(); + }); }); diff --git a/packages/react/components/popover/popover.module.css b/packages/react/components/popover/popover.module.css index 856f41898..90f5e705f 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 92e85d3e4..25d57887d 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 { ArrowSvg, arrowClassName } 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,14 @@ function PopoverContent({ data-slot='popover-content' > {children} + {showArrow && ( + + + + )} diff --git a/packages/react/components/toast/__tests__/data-slots.test.tsx b/packages/react/components/toast/__tests__/data-slots.test.tsx index 95f51a11a..f3ced79aa 100644 --- a/packages/react/components/toast/__tests__/data-slots.test.tsx +++ b/packages/react/components/toast/__tests__/data-slots.test.tsx @@ -42,6 +42,30 @@ describe('Toast data-slot contract', () => { ]); }); + it('exposes positioner and arrow slots for an anchored toast', async () => { + const manager = renderProvider(); + const anchor = screen.getByText('App content'); + act(() => { + manager.add({ title: 'Copied', positionerProps: { anchor } }); + }); + await screen.findByText('Copied'); + expectSlots(document.body, [ + 'toast-anchored-viewport', + 'toast-positioner', + '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 2e860aab5..7de33f937 100644 --- a/packages/react/components/toast/__tests__/toast.test.tsx +++ b/packages/react/components/toast/__tests__/toast.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Toast, toastManager } from '../toast'; +import { Toast, toastManager, useToastManager } from '../toast'; const renderWithProvider = ( props?: Partial> @@ -332,6 +332,111 @@ describe('Toast', () => { }); }); + describe('Anchored toasts', () => { + const renderWithAnchor = () => { + const manager = Toast.createToastManager(); + render( + + + + ); + return { manager, anchor: screen.getByText('Copy') }; + }; + + it('renders a toast with an anchor in a positioner with an arrow', async () => { + const { manager, anchor } = renderWithAnchor(); + act(() => { + manager.add({ + title: 'Copied', + 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?.querySelector('[data-slot="toast-arrow"]') + ).toBeInTheDocument(); + expect( + toastEl?.closest('[data-slot="toast-anchored-viewport"]') + ).toBeInTheDocument(); + }); + + 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('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(); + }); + }); + 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..abc336e3f 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 { /** @@ -92,6 +92,11 @@ function liftPromiseOptions({ }; } +/** A toast with `positionerProps.anchor` shows next to that element. */ +function isAnchored(options: string | ToastUpdateOptions) { + return typeof options !== 'string' && options.positionerProps?.anchor != null; +} + /** * Public toast manager. Mirrors the Base UI manager but exposes `leadingIcon` * as a first-class option on `add`/`update`/`promise`. All other Base UI @@ -107,19 +112,42 @@ export interface ToastManager ) => Promise; } +// Base UI renders anchored toasts in their own provider, so each manager +// keeps a second Base UI manager for them. `Toast.Provider` reads it here. +const anchoredManagers = new WeakMap(); + +export function getAnchoredManager(manager: ToastManager) { + return anchoredManagers.get(manager); +} + export function createToastManager(): ToastManager { const base = ToastPrimitive.createToastManager(); - return { + const anchored = ToastPrimitive.createToastManager(); + const manager: ToastManager = { ...base, - add: options => base.add(lift(options)), - update: (id, options) => base.update(id, liftUpdater(options)), + add: options => (isAnchored(options) ? anchored : base).add(lift(options)), + close: id => { + base.close(id); + anchored.close(id); + }, + update: (id, options) => { + base.update(id, liftUpdater(options)); + anchored.update(id, liftUpdater(options)); + }, promise: (promise, options) => - base.promise(promise, liftPromiseOptions(options)) + (isAnchored(options.loading) ? anchored : base).promise( + promise, + liftPromiseOptions(options) + ) }; + anchoredManagers.set(manager, anchored); + return manager; } export const toastManager = createToastManager(); +export const ToastManagerContext = createContext(toastManager); + type BaseHookReturn = ReturnType< typeof ToastPrimitive.useToastManager >; @@ -127,7 +155,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 { @@ -138,14 +166,27 @@ export interface UseToastManagerReturn export function useToastManager(): UseToastManagerReturn { const base = ToastPrimitive.useToastManager(); - return useMemo( - () => ({ + const manager = useContext(ToastManagerContext); + return useMemo(() => { + const anchored = getAnchoredManager(manager); + return { ...base, - add: options => base.add(lift(options)), - update: (id, options) => base.update(id, liftUpdater(options)), + add: options => + anchored && isAnchored(options) + ? anchored.add(lift(options)) + : base.add(lift(options)), + close: id => { + base.close(id); + anchored?.close(id); + }, + update: (id, options) => { + base.update(id, liftUpdater(options)); + anchored?.update(id, liftUpdater(options)); + }, promise: (promise, options) => - base.promise(promise, liftPromiseOptions(options)) - }), - [base] - ); + anchored && isAnchored(options.loading) + ? anchored.promise(promise, liftPromiseOptions(options)) + : base.promise(promise, liftPromiseOptions(options)) + }; + }, [base, manager]); } diff --git a/packages/react/components/toast/toast-provider.tsx b/packages/react/components/toast/toast-provider.tsx index 013864a7b..67d5dfff2 100644 --- a/packages/react/components/toast/toast-provider.tsx +++ b/packages/react/components/toast/toast-provider.tsx @@ -6,9 +6,11 @@ import { useThemeInjection } from '../theme/portal'; import styles from './toast.module.css'; import { toastManager as defaultToastManager, - type ToastManager + getAnchoredManager, + type ToastManager, + ToastManagerContext } from './toast-manager'; -import { ToastRoot } from './toast-root'; +import { AnchoredToastRoot, ToastRoot } from './toast-root'; export type ToastPosition = | 'top-left' @@ -40,6 +42,13 @@ function ToastList({ position }: { position: ToastPosition }) { )); } +function AnchoredToastList() { + const { toasts } = ToastPrimitive.useToastManager(); + return toasts.map(toast => ( + + )); +} + export function ToastProvider({ position = 'bottom-right', toastManager = defaultToastManager, @@ -47,30 +56,46 @@ export function ToastProvider({ ...props }: ToastProviderProps) { const theme = useThemeInjection(); + const anchoredManager = getAnchoredManager(toastManager); return ( - - {children} - - - - - - + + + {children} + + + + + + + {anchoredManager && ( + + + + + + + + )} + ); } diff --git a/packages/react/components/toast/toast-root.tsx b/packages/react/components/toast/toast-root.tsx index 422414429..734e66d50 100644 --- a/packages/react/components/toast/toast-root.tsx +++ b/packages/react/components/toast/toast-root.tsx @@ -4,6 +4,7 @@ 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 { ArrowSvg, arrowClassName } from '../../shared/arrow'; import { Button } from '../button'; import { Flex } from '../flex'; import { IconButton } from '../icon-button'; @@ -23,6 +24,8 @@ const TOAST_ICONS: Record = { type SwipeDirection = 'up' | 'down' | 'left' | 'right'; +type ToastObject = ToastPrimitive.Root.Props['toast']; + function getSwipeDirection(position: ToastPosition): SwipeDirection[] { const verticalDirection: SwipeDirection = position.startsWith('top') ? 'up' @@ -39,17 +42,13 @@ function getSwipeDirection(position: ToastPosition): SwipeDirection[] { return ['right', verticalDirection]; } -export interface ToastRootProps extends ToastPrimitive.Root.Props { - position?: ToastPosition; -} - -export function ToastRoot({ +function ToastContent({ toast, - className, - position = 'bottom-right', - ...props -}: ToastRootProps) { - const swipeDirection = getSwipeDirection(position); + className +}: { + toast: ToastObject; + className?: string; +}) { // Promote description into the title slot when title is missing so the icon // and headline sit on the same row. The second row only renders when both // are present. @@ -65,91 +64,128 @@ export function ToastRoot({ : ((toast.type ? TOAST_ICONS[toast.type] : null) ?? TOAST_ICONS.default); return ( - - + + {leadingIcon && ( + + )} - {leadingIcon && ( - - )} + {title && ( + + {title} + + )} - {title && ( - - {title} - + {toast.actionProps && ( + + } + /> )} - } + data-slot='toast-close' > - {toast.actionProps && ( - - } - /> - )} - } - data-slot='toast-close' - > - - - + + - {hasBoth && ( - - {toast.description} - - )} + {hasBoth && ( + + {toast.description} + + )} - + + + ); +} + +export interface ToastRootProps extends ToastPrimitive.Root.Props { + position?: ToastPosition; +} + +export function ToastRoot({ + toast, + className, + position = 'bottom-right', + ...props +}: ToastRootProps) { + return ( + + ); } ToastRoot.displayName = 'Toast'; + +export function AnchoredToastRoot({ toast }: { toast: ToastObject }) { + return ( + + + + + + + + + ); +} diff --git a/packages/react/components/toast/toast.module.css b/packages/react/components/toast/toast.module.css index 3c8ec68e7..90e607299 100644 --- a/packages/react/components/toast/toast.module.css +++ b/packages/react/components/toast/toast.module.css @@ -256,19 +256,19 @@ /* 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 { +:is(.root, .anchored)[data-type="success"] .leadingIcon { color: var(--rs-color-foreground-success-primary); } -.root[data-type="error"] .leadingIcon { +:is(.root, .anchored)[data-type="error"] .leadingIcon { color: var(--rs-color-foreground-danger-primary); } -.root[data-type="warning"] .leadingIcon { +:is(.root, .anchored)[data-type="warning"] .leadingIcon { color: var(--rs-color-foreground-attention-primary); } -.root[data-type="info"] .leadingIcon { +:is(.root, .anchored)[data-type="info"] .leadingIcon { color: var(--rs-color-foreground-accent-primary); } @@ -298,6 +298,54 @@ margin: 0; } +/* ===== Anchored toasts ===== */ +.anchoredViewport { + outline: none; +} + +.positioner { + z-index: var(--rs-z-index-toast); +} + +.anchored { + position: relative; + box-sizing: border-box; + width: max-content; + max-width: min(360px, var(--available-width, 360px)); + + background: var(--rs-color-panel); + backdrop-filter: var(--rs-panel-backdrop-filter); + color: var(--rs-color-foreground-base-primary); + border: 0.5px solid var(--rs-color-border-base-primary); + --rs-radius-step: 4px; + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-lifted); + padding: var(--rs-space-3); + background-clip: padding-box; + + cursor: default; + transform-origin: var(--transform-origin); + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); +} + +.anchored[data-starting-style], +.anchored[data-ending-style] { + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .anchored { + transition: + opacity var(--rs-duration-normal) var(--rs-ease-out), + transform var(--rs-duration-normal) var(--rs-ease-out); + } + + .anchored[data-starting-style], + .anchored[data-ending-style] { + transform: scale(0.97); + } +} + @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 c458696ad..0b6f72469 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 { ArrowSvg, arrowClassName } from '../../shared/arrow'; import { type Radius, radiusStyle } from '../../shared/radius'; import { Text } from '../text'; import { useThemeInjection } from '../theme/portal'; @@ -69,21 +70,10 @@ export function TooltipContent({ )} {showArrow && ( - - - + )} diff --git a/packages/react/components/tooltip/tooltip.module.css b/packages/react/components/tooltip/tooltip.module.css index 250402f95..7f00e2cd8 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..b7cb806d9 --- /dev/null +++ b/packages/react/shared/arrow/arrow.tsx @@ -0,0 +1,22 @@ +import styles from './arrow.module.css'; + +/** Class for a Base UI `Arrow` part. It moves the arrow to the anchor side. */ +export const arrowClassName = styles.arrow; + +/** The arrow shape. Render it as the child of a Base UI `Arrow` part. */ +export function ArrowSvg() { + 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'; From 898d17e6f39c21558e5e79836eb03e286a317fa8 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 00:38:19 +0530 Subject: [PATCH 2/5] fix: [toast] route anchored toasts without a context - Build the manager and hook methods with one wrap function. The hook finds the anchored manager through the stacked store, so ToastManagerContext is removed. - Render the anchored provider first, so hook adds in mount effects reach it. - Drop positionerProps from stacked toasts, so anchor: null keeps swipe. - Label the anchored viewport "Anchored notifications", so there is one "Notifications" region. - Hide anchored toasts over the limit or with a hidden anchor, and show newer ones on top. - Document the routing rules and fix the Popover sideOffset playground default. --- .../content/docs/components/popover/demo.ts | 2 +- .../src/content/docs/components/toast/demo.ts | 1 + .../content/docs/components/toast/index.mdx | 7 +- .../content/docs/components/toast/props.ts | 15 ++- .../components/toast/__tests__/toast.test.tsx | 79 ++++++++++++++ .../react/components/toast/toast-manager.ts | 101 ++++++++++-------- .../react/components/toast/toast-provider.tsx | 95 +++++++++------- .../react/components/toast/toast.module.css | 9 +- 8 files changed, 217 insertions(+), 92 deletions(-) diff --git a/apps/www/src/content/docs/components/popover/demo.ts b/apps/www/src/content/docs/components/popover/demo.ts index f7d1c90c4..e38a414ba 100644 --- a/apps/www/src/content/docs/components/popover/demo.ts +++ b/apps/www/src/content/docs/components/popover/demo.ts @@ -28,7 +28,7 @@ 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.' } diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 52a6fbec8..cd5aee608 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -79,6 +79,7 @@ export const anchoredDemo = { + ); + } + render( + + + + ); + const toastEl = await screen.findByText('Copied'); + expect( + toastEl.closest('[data-slot="toast-positioner"]') + ).toBeInTheDocument(); + }); }); describe('onClose callback', () => { diff --git a/packages/react/components/toast/toast-manager.ts b/packages/react/components/toast/toast-manager.ts index abc336e3f..e09596e33 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 { createContext, type ReactNode, useContext, useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; export interface ToastData { /** @@ -112,42 +112,72 @@ export interface ToastManager ) => Promise; } +// Base UI turns off swipe when `positionerProps.anchor` is set, even to `null`. +function unanchored(options: O) { + return { ...options, positionerProps: undefined }; +} + +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 wrap( + stacked: Methods, + anchored?: Methods +): Pick { + return { + add: options => + anchored && isAnchored(options) + ? anchored.add(lift(options)) + : stacked.add(lift(unanchored(options))), + close: id => { + stacked.close(id); + anchored?.close(id); + }, + update: (id, options) => { + stacked.update(id, liftUpdater(options)); + anchored?.update(id, liftUpdater(options)); + }, + promise: (promise, { loading, ...options }) => + anchored && isAnchored(loading) + ? anchored.promise(promise, liftPromiseOptions({ ...options, loading })) + : stacked.promise( + promise, + liftPromiseOptions({ + ...options, + loading: + typeof loading === 'string' ? loading : unanchored(loading) + }) + ) + }; +} + // Base UI renders anchored toasts in their own provider, so each manager // keeps a second Base UI manager for them. `Toast.Provider` reads it here. const anchoredManagers = new WeakMap(); +// `useToastManager` can only reach the stacked store, through Base UI's hook. +// `Toast.Provider` links that store's `add` to the anchored manager. +const anchoredByStore = new WeakMap(); + export function getAnchoredManager(manager: ToastManager) { return anchoredManagers.get(manager); } +export function linkAnchoredManager(add: object, anchored?: BaseManager) { + if (anchored) anchoredByStore.set(add, anchored); +} + export function createToastManager(): ToastManager { - const base = ToastPrimitive.createToastManager(); + const stacked = ToastPrimitive.createToastManager(); const anchored = ToastPrimitive.createToastManager(); - const manager: ToastManager = { - ...base, - add: options => (isAnchored(options) ? anchored : base).add(lift(options)), - close: id => { - base.close(id); - anchored.close(id); - }, - update: (id, options) => { - base.update(id, liftUpdater(options)); - anchored.update(id, liftUpdater(options)); - }, - promise: (promise, options) => - (isAnchored(options.loading) ? anchored : base).promise( - promise, - liftPromiseOptions(options) - ) - }; + const manager = { ...stacked, ...wrap(stacked, anchored) }; anchoredManagers.set(manager, anchored); return manager; } export const toastManager = createToastManager(); -export const ToastManagerContext = createContext(toastManager); - type BaseHookReturn = ReturnType< typeof ToastPrimitive.useToastManager >; @@ -165,28 +195,9 @@ export interface UseToastManagerReturn } export function useToastManager(): UseToastManagerReturn { - const base = ToastPrimitive.useToastManager(); - const manager = useContext(ToastManagerContext); - return useMemo(() => { - const anchored = getAnchoredManager(manager); - return { - ...base, - add: options => - anchored && isAnchored(options) - ? anchored.add(lift(options)) - : base.add(lift(options)), - close: id => { - base.close(id); - anchored?.close(id); - }, - update: (id, options) => { - base.update(id, liftUpdater(options)); - anchored?.update(id, liftUpdater(options)); - }, - promise: (promise, options) => - anchored && isAnchored(options.loading) - ? anchored.promise(promise, liftPromiseOptions(options)) - : base.promise(promise, liftPromiseOptions(options)) - }; - }, [base, manager]); + const stacked = ToastPrimitive.useToastManager(); + return useMemo( + () => ({ ...stacked, ...wrap(stacked, anchoredByStore.get(stacked.add)) }), + [stacked] + ); } diff --git a/packages/react/components/toast/toast-provider.tsx b/packages/react/components/toast/toast-provider.tsx index 67d5dfff2..e449357e7 100644 --- a/packages/react/components/toast/toast-provider.tsx +++ b/packages/react/components/toast/toast-provider.tsx @@ -7,8 +7,8 @@ import styles from './toast.module.css'; import { toastManager as defaultToastManager, getAnchoredManager, - type ToastManager, - ToastManagerContext + linkAnchoredManager, + type ToastManager } from './toast-manager'; import { AnchoredToastRoot, ToastRoot } from './toast-root'; @@ -35,18 +35,53 @@ export interface ToastProviderProps toastManager?: ToastManager; } -function ToastList({ position }: { position: ToastPosition }) { - const { toasts } = ToastPrimitive.useToastManager(); - return toasts.map(toast => ( - - )); +function StackedToasts({ + position, + anchoredManager +}: { + position: ToastPosition; + anchoredManager: ReturnType; +}) { + const theme = useThemeInjection(); + const { toasts, add } = ToastPrimitive.useToastManager(); + // Runs before `children` render, so their `useToastManager` finds the link. + linkAnchoredManager(add, anchoredManager); + return ( + + + {toasts.map(toast => ( + + ))} + + + ); } -function AnchoredToastList() { +function AnchoredToasts() { + const theme = useThemeInjection(); const { toasts } = ToastPrimitive.useToastManager(); - return toasts.map(toast => ( - - )); + return ( + + + {toasts.map(toast => ( + + ))} + + + ); } export function ToastProvider({ @@ -55,10 +90,15 @@ export function ToastProvider({ children, ...props }: ToastProviderProps) { - const theme = useThemeInjection(); const anchoredManager = getAnchoredManager(toastManager); return ( - + <> + {/* Comes first so it subscribes before effects in `children` run. */} + {anchoredManager && ( + + + + )} + {children} - - - - - - {anchoredManager && ( - - - - - - - - )} - + ); } diff --git a/packages/react/components/toast/toast.module.css b/packages/react/components/toast/toast.module.css index 90e607299..eb1c91b15 100644 --- a/packages/react/components/toast/toast.module.css +++ b/packages/react/components/toast/toast.module.css @@ -304,7 +304,11 @@ } .positioner { - z-index: var(--rs-z-index-toast); + z-index: calc(var(--rs-z-index-toast) - var(--toast-index)); +} + +.positioner[data-anchor-hidden] { + visibility: hidden; } .anchored { @@ -329,7 +333,8 @@ } .anchored[data-starting-style], -.anchored[data-ending-style] { +.anchored[data-ending-style], +.anchored[data-limited] { opacity: 0; } From 1a3fdd3059315db08f48c960b77ac718947ec750 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 01:14:59 +0530 Subject: [PATCH 3/5] refactor: [toast] find the anchored manager through a context - Keep the anchored Base UI manager on the toast manager under a symbol key, and put the manager in ToastManagerContext. - useToastManager reads the context instead of a WeakMap link that Toast.Provider wrote during render. - StackedToasts no longer has to render before children. --- .../react/components/toast/toast-manager.ts | 54 +++++++++---------- .../react/components/toast/toast-provider.tsx | 38 ++++++------- 2 files changed, 40 insertions(+), 52 deletions(-) diff --git a/packages/react/components/toast/toast-manager.ts b/packages/react/components/toast/toast-manager.ts index e09596e33..976d5a1f7 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 { /** @@ -97,6 +97,9 @@ function isAnchored(options: string | ToastUpdateOptions) { return typeof options !== 'string' && 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 @@ -110,6 +113,8 @@ 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`. @@ -121,25 +126,25 @@ 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 wrap( +function route( stacked: Methods, - anchored?: Methods + anchored: Methods ): Pick { return { add: options => - anchored && isAnchored(options) + isAnchored(options) ? anchored.add(lift(options)) : stacked.add(lift(unanchored(options))), close: id => { stacked.close(id); - anchored?.close(id); + anchored.close(id); }, update: (id, options) => { stacked.update(id, liftUpdater(options)); - anchored?.update(id, liftUpdater(options)); + anchored.update(id, liftUpdater(options)); }, promise: (promise, { loading, ...options }) => - anchored && isAnchored(loading) + isAnchored(loading) ? anchored.promise(promise, liftPromiseOptions({ ...options, loading })) : stacked.promise( promise, @@ -152,32 +157,20 @@ function wrap( }; } -// Base UI renders anchored toasts in their own provider, so each manager -// keeps a second Base UI manager for them. `Toast.Provider` reads it here. -const anchoredManagers = new WeakMap(); - -// `useToastManager` can only reach the stacked store, through Base UI's hook. -// `Toast.Provider` links that store's `add` to the anchored manager. -const anchoredByStore = new WeakMap(); - -export function getAnchoredManager(manager: ToastManager) { - return anchoredManagers.get(manager); -} - -export function linkAnchoredManager(add: object, anchored?: BaseManager) { - if (anchored) anchoredByStore.set(add, anchored); -} - export function createToastManager(): ToastManager { const stacked = ToastPrimitive.createToastManager(); const anchored = ToastPrimitive.createToastManager(); - const manager = { ...stacked, ...wrap(stacked, anchored) }; - anchoredManagers.set(manager, anchored); - return manager; + return { + ...stacked, + ...route(stacked, anchored), + [anchoredManager]: anchored + }; } export const toastManager = createToastManager(); +export const ToastManagerContext = createContext(toastManager); + type BaseHookReturn = ReturnType< typeof ToastPrimitive.useToastManager >; @@ -195,9 +188,12 @@ export interface UseToastManagerReturn } export function useToastManager(): UseToastManagerReturn { - const stacked = ToastPrimitive.useToastManager(); + const store = 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. return useMemo( - () => ({ ...stacked, ...wrap(stacked, anchoredByStore.get(stacked.add)) }), - [stacked] + () => ({ ...store, ...route(store, manager[anchoredManager]) }), + [store, manager] ); } diff --git a/packages/react/components/toast/toast-provider.tsx b/packages/react/components/toast/toast-provider.tsx index e449357e7..aaadf5b70 100644 --- a/packages/react/components/toast/toast-provider.tsx +++ b/packages/react/components/toast/toast-provider.tsx @@ -5,10 +5,10 @@ import { cx } from 'class-variance-authority'; import { useThemeInjection } from '../theme/portal'; import styles from './toast.module.css'; import { + anchoredManager, toastManager as defaultToastManager, - getAnchoredManager, - linkAnchoredManager, - type ToastManager + type ToastManager, + ToastManagerContext } from './toast-manager'; import { AnchoredToastRoot, ToastRoot } from './toast-root'; @@ -35,17 +35,9 @@ export interface ToastProviderProps toastManager?: ToastManager; } -function StackedToasts({ - position, - anchoredManager -}: { - position: ToastPosition; - anchoredManager: ReturnType; -}) { +function StackedToasts({ position }: { position: ToastPosition }) { const theme = useThemeInjection(); - const { toasts, add } = ToastPrimitive.useToastManager(); - // Runs before `children` render, so their `useToastManager` finds the link. - linkAnchoredManager(add, anchoredManager); + const { toasts } = ToastPrimitive.useToastManager(); return ( - {/* Comes first so it subscribes before effects in `children` run. */} - {anchoredManager && ( - - - - )} + + {/* Comes first, so it subscribes before effects in `children` run. */} + + + - {children} + - + ); } From 0875da4a94fba0f6bf14bb461e0f336ab96ecd64 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 23:44:15 +0530 Subject: [PATCH 4/5] feat: [toast] show anchored toasts like a tooltip - Render an anchored toast with the tooltip surface and only its title. Add a showArrow option for the arrow, with sideOffset 4, or 10 with the arrow. - Merge positionerProps in update and in promise results, so a partial placement change keeps the anchor. Drop positionerProps from stacked toast updates. - Keep the methods from useToastManager the same when the toast list changes, so an effect that depends on add runs once. --- .../src/content/docs/components/toast/demo.ts | 25 ++- .../content/docs/components/toast/index.mdx | 10 +- .../content/docs/components/toast/props.ts | 15 +- .../toast/__tests__/data-slots.test.tsx | 10 +- .../components/toast/__tests__/toast.test.tsx | 52 ++++- .../react/components/toast/toast-manager.ts | 95 ++++---- .../react/components/toast/toast-root.tsx | 208 +++++++++--------- .../react/components/toast/toast.module.css | 47 +--- 8 files changed, 268 insertions(+), 194 deletions(-) diff --git a/apps/www/src/content/docs/components/toast/demo.ts b/apps/www/src/content/docs/components/toast/demo.ts index 3c10239b5..0a8c4d39e 100644 --- a/apps/www/src/content/docs/components/toast/demo.ts +++ b/apps/www/src/content/docs/components/toast/demo.ts @@ -75,20 +75,41 @@ export const basicDemo = { export const anchoredDemo = { type: 'code', - code: ` + tabs: [ + { + name: 'Default', + code: ` + ` + }, + { + name: 'With arrow', + code: ` ` + } + ] }; export const typesDemo = { diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx index 61dd77a30..e6204de5a 100644 --- a/apps/www/src/content/docs/components/toast/index.mdx +++ b/apps/www/src/content/docs/components/toast/index.mdx @@ -72,11 +72,13 @@ 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, with an arrow that points at it. Use it for short feedback near the control that caused it, such as "Copied". +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". -`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, 10px from the anchor. +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. -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. A toast with `anchor: null` joins the stack. A custom `id` must be unique across both layers. +`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. @@ -192,7 +194,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `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 | +| `toast-arrow` | The arrow of an anchored toast (when `showArrow`) | ## Accessibility diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index 9f36c071a..0715ae755 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -78,13 +78,19 @@ export interface ToastManagerAddOptions { leadingIcon?: React.ReactNode; /** - * Shows the toast next to `anchor` with an arrow, instead of in the stack. + * 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. */ @@ -112,7 +118,7 @@ export interface ToastPositionerOptions { /** * Distance in pixels from the anchor. - * @default 10 + * @default 4, or 10 with `showArrow` */ sideOffset?: number; @@ -138,12 +144,13 @@ export interface ToastManagerUpdateOptions { actionProps?: React.ComponentPropsWithoutRef<'button'>; leadingIcon?: React.ReactNode; /** - * Placement of an anchored toast. It does not move a toast into or out of - * the stack. + * 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; } /** diff --git a/packages/react/components/toast/__tests__/data-slots.test.tsx b/packages/react/components/toast/__tests__/data-slots.test.tsx index f3ced79aa..8b23685a1 100644 --- a/packages/react/components/toast/__tests__/data-slots.test.tsx +++ b/packages/react/components/toast/__tests__/data-slots.test.tsx @@ -42,16 +42,22 @@ describe('Toast data-slot contract', () => { ]); }); - it('exposes positioner and arrow slots for an anchored toast', async () => { + it('exposes slots for an anchored toast with showArrow', async () => { const manager = renderProvider(); const anchor = screen.getByText('App content'); act(() => { - manager.add({ title: 'Copied', positionerProps: { anchor } }); + 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' ]); }); diff --git a/packages/react/components/toast/__tests__/toast.test.tsx b/packages/react/components/toast/__tests__/toast.test.tsx index 8055e397a..a0a0bf5e5 100644 --- a/packages/react/components/toast/__tests__/toast.test.tsx +++ b/packages/react/components/toast/__tests__/toast.test.tsx @@ -2,6 +2,7 @@ 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 tooltipStyles from '../../tooltip/tooltip.module.css'; import { Toast, toastManager, useToastManager } from '../toast'; const renderWithProvider = ( @@ -370,11 +371,13 @@ describe('Toast', () => { return { manager, anchor: screen.getByText('Copy') }; }; - it('renders a toast with an anchor in a positioner with an arrow', async () => { + 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' } }); }); @@ -383,12 +386,32 @@ describe('Toast', () => { ); const positioner = toastEl?.closest('[data-slot="toast-positioner"]'); expect(positioner).toHaveAttribute('data-side', 'bottom'); - expect( - toastEl?.querySelector('[data-slot="toast-arrow"]') - ).toBeInTheDocument(); 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 () => { @@ -535,6 +558,27 @@ describe('Toast', () => { }); }); + 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 976d5a1f7..c3285f3f8 100644 --- a/packages/react/components/toast/toast-manager.ts +++ b/packages/react/components/toast/toast-manager.ts @@ -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,44 +65,40 @@ 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: string | ToastUpdateOptions) { - return typeof options !== 'string' && options.positionerProps?.anchor != null; +function isAnchored(options: ToastUpdateOptions) { + return options.positionerProps?.anchor != null; } // A symbol key keeps the anchored manager out of the public API. @@ -122,6 +126,14 @@ 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` @@ -139,21 +151,23 @@ function route( stacked.close(id); anchored.close(id); }, - update: (id, options) => { - stacked.update(id, liftUpdater(options)); - anchored.update(id, liftUpdater(options)); + 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, { loading, ...options }) => - isAnchored(loading) - ? anchored.promise(promise, liftPromiseOptions({ ...options, loading })) - : stacked.promise( + promise: (promise, options) => { + const { loading } = options; + return typeof loading !== 'string' && isAnchored(loading) + ? anchored.promise( promise, - liftPromiseOptions({ - ...options, - loading: - typeof loading === 'string' ? loading : unanchored(loading) - }) + liftPromiseOptions(options, keepPlacement(loading.positionerProps)) ) + : stacked.promise(promise, liftPromiseOptions(options, unanchored)); + } }; } @@ -188,12 +202,15 @@ export interface UseToastManagerReturn } export function useToastManager(): UseToastManagerReturn { - const store = ToastPrimitive.useToastManager(); + 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. - return useMemo( - () => ({ ...store, ...route(store, manager[anchoredManager]) }), - [store, manager] + // 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-root.tsx b/packages/react/components/toast/toast-root.tsx index 734e66d50..5abdfa21c 100644 --- a/packages/react/components/toast/toast-root.tsx +++ b/packages/react/components/toast/toast-root.tsx @@ -9,8 +9,9 @@ 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 = { @@ -24,8 +25,6 @@ const TOAST_ICONS: Record = { type SwipeDirection = 'up' | 'down' | 'left' | 'right'; -type ToastObject = ToastPrimitive.Root.Props['toast']; - function getSwipeDirection(position: ToastPosition): SwipeDirection[] { const verticalDirection: SwipeDirection = position.startsWith('top') ? 'up' @@ -42,13 +41,17 @@ function getSwipeDirection(position: ToastPosition): SwipeDirection[] { return ['right', verticalDirection]; } -function ToastContent({ +export interface ToastRootProps extends ToastPrimitive.Root.Props { + position?: ToastPosition; +} + +export function ToastRoot({ toast, - className -}: { - toast: ToastObject; - className?: string; -}) { + className, + position = 'bottom-right', + ...props +}: ToastRootProps) { + const swipeDirection = getSwipeDirection(position); // Promote description into the title slot when title is missing so the icon // and headline sit on the same row. The second row only renders when both // are present. @@ -64,127 +67,132 @@ function ToastContent({ : ((toast.type ? TOAST_ICONS[toast.type] : null) ?? TOAST_ICONS.default); return ( - - + - {leadingIcon && ( - - )} + {leadingIcon && ( + + )} - {title && ( - - {title} - - )} - {toast.actionProps && ( - - } - /> + {title && ( + + {title} + )} - } - data-slot='toast-close' + - - + {toast.actionProps && ( + + } + /> + )} + } + data-slot='toast-close' + > + + + + {hasBoth && ( + + {toast.description} + + )} - {hasBoth && ( - - {toast.description} - - )} - - - ); -} - -export interface ToastRootProps extends ToastPrimitive.Root.Props { - position?: ToastPosition; -} - -export function ToastRoot({ - toast, - className, - position = 'bottom-right', - ...props -}: ToastRootProps) { - return ( - - + ); } ToastRoot.displayName = 'Toast'; -export function AnchoredToastRoot({ toast }: { toast: ToastObject }) { +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 eb1c91b15..d00d2863f 100644 --- a/packages/react/components/toast/toast.module.css +++ b/packages/react/components/toast/toast.module.css @@ -256,19 +256,19 @@ /* Type-based leading icon colors. The toast itself stays visually neutral across all types. Only the leading icon changes color per type. */ -:is(.root, .anchored)[data-type="success"] .leadingIcon { +.root[data-type="success"] .leadingIcon { color: var(--rs-color-foreground-success-primary); } -:is(.root, .anchored)[data-type="error"] .leadingIcon { +.root[data-type="error"] .leadingIcon { color: var(--rs-color-foreground-danger-primary); } -:is(.root, .anchored)[data-type="warning"] .leadingIcon { +.root[data-type="warning"] .leadingIcon { color: var(--rs-color-foreground-attention-primary); } -:is(.root, .anchored)[data-type="info"] .leadingIcon { +.root[data-type="info"] .leadingIcon { color: var(--rs-color-foreground-accent-primary); } @@ -298,7 +298,7 @@ margin: 0; } -/* ===== Anchored toasts ===== */ +/* ===== Anchored toasts (the surface comes from the tooltip) ===== */ .anchoredViewport { outline: none; } @@ -311,44 +311,13 @@ visibility: hidden; } -.anchored { - position: relative; - box-sizing: border-box; - width: max-content; - max-width: min(360px, var(--available-width, 360px)); - - background: var(--rs-color-panel); - backdrop-filter: var(--rs-panel-backdrop-filter); - color: var(--rs-color-foreground-base-primary); - border: 0.5px solid var(--rs-color-border-base-primary); - --rs-radius-step: 4px; - border-radius: var(--rs-radius-2); - box-shadow: var(--rs-shadow-lifted); - padding: var(--rs-space-3); - background-clip: padding-box; - - cursor: default; - transform-origin: var(--transform-origin); - transition: opacity var(--rs-duration-normal) var(--rs-ease-out); -} - -.anchored[data-starting-style], -.anchored[data-ending-style], .anchored[data-limited] { opacity: 0; } -@media (prefers-reduced-motion: no-preference) { - .anchored { - transition: - opacity var(--rs-duration-normal) var(--rs-ease-out), - transform var(--rs-duration-normal) var(--rs-ease-out); - } - - .anchored[data-starting-style], - .anchored[data-ending-style] { - transform: scale(0.97); - } +.anchoredTitle { + margin: 0; + font: inherit; } @media (prefers-reduced-motion: no-preference) { From c7c45ae3a8e8d64838fcbb5f4046b2f4b39c4ebd Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Oct 2026 00:27:57 +0530 Subject: [PATCH 5/5] refactor: [arrow] share one Arrow component across popups - shared/arrow exports an Arrow component instead of a class name and an SVG. Each Base UI Arrow part renders it through the render prop. - Tooltip, Popover, anchored Toast, and PreviewCard use it. - PreviewCard drops its copy of the arrow CSS and SVG. --- packages/react/components/popover/popover.tsx | 8 ++--- .../__tests__/preview-card.test.tsx | 6 ++-- .../preview-card/preview-card.module.css | 31 ---------------- .../components/preview-card/preview-card.tsx | 18 ++-------- .../react/components/toast/toast-root.tsx | 9 ++--- .../components/tooltip/tooltip-content.tsx | 8 ++--- packages/react/shared/arrow/arrow.tsx | 35 ++++++++++--------- 7 files changed, 33 insertions(+), 82 deletions(-) diff --git a/packages/react/components/popover/popover.tsx b/packages/react/components/popover/popover.tsx index c8313ea18..6e9dbde89 100644 --- a/packages/react/components/popover/popover.tsx +++ b/packages/react/components/popover/popover.tsx @@ -2,7 +2,7 @@ import { Popover as PopoverPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ArrowSvg, arrowClassName } from '../../shared/arrow'; +import { Arrow } from '../../shared/arrow'; import { type Radius, radiusStyle } from '../../shared/radius'; import { useThemeInjection } from '../theme/portal'; import styles from './popover.module.css'; @@ -64,11 +64,9 @@ function PopoverContent({ {children} {showArrow && ( - - + render={} + /> )} 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/toast-root.tsx b/packages/react/components/toast/toast-root.tsx index 5abdfa21c..9a3f3cffa 100644 --- a/packages/react/components/toast/toast-root.tsx +++ b/packages/react/components/toast/toast-root.tsx @@ -4,7 +4,7 @@ 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 { ArrowSvg, arrowClassName } from '../../shared/arrow'; +import { Arrow } from '../../shared/arrow'; import { Button } from '../button'; import { Flex } from '../flex'; import { IconButton } from '../icon-button'; @@ -186,12 +186,7 @@ export function AnchoredToastRoot({ toast }: AnchoredToastRootProps) { )} {toast.showArrow && ( - - - + } /> )} diff --git a/packages/react/components/tooltip/tooltip-content.tsx b/packages/react/components/tooltip/tooltip-content.tsx index 29fb17073..358dc41c1 100644 --- a/packages/react/components/tooltip/tooltip-content.tsx +++ b/packages/react/components/tooltip/tooltip-content.tsx @@ -2,7 +2,7 @@ import { Tooltip as TooltipPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ArrowSvg, arrowClassName } from '../../shared/arrow'; +import { Arrow } from '../../shared/arrow'; import { type Radius, radiusStyle } from '../../shared/radius'; import { Text } from '../text'; import { useThemeInjection } from '../theme/portal'; @@ -70,11 +70,9 @@ export function TooltipContent({ )} {showArrow && ( - - + render={} + /> )} diff --git a/packages/react/shared/arrow/arrow.tsx b/packages/react/shared/arrow/arrow.tsx index b7cb806d9..05fde3d58 100644 --- a/packages/react/shared/arrow/arrow.tsx +++ b/packages/react/shared/arrow/arrow.tsx @@ -1,22 +1,23 @@ +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; import styles from './arrow.module.css'; -/** Class for a Base UI `Arrow` part. It moves the arrow to the anchor side. */ -export const arrowClassName = styles.arrow; - -/** The arrow shape. Render it as the child of a Base UI `Arrow` part. */ -export function ArrowSvg() { +/** 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 ( - - - +
+ + + +
); }