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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/www/src/content/docs/components/popover/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,6 +131,17 @@ export const alignDemo = {
]
};

export const arrowDemo = {
type: 'code',
code: `
<Popover>
<Popover.Trigger render={<Button />}>Popover with arrow</Popover.Trigger>
<Popover.Content showArrow>
<Text size="small">The arrow points at the trigger.</Text>
</Popover.Content>
</Popover>`
};

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

Expand Down Expand Up @@ -39,6 +39,12 @@ Customize how the popover aligns with its trigger.

<Demo data={alignDemo} />

### 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.

<Demo data={arrowDemo} />

### 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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion apps/www/src/content/docs/components/popover/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions apps/www/src/content/docs/components/toast/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ export const basicDemo = {
</Button>`
};

export const anchoredDemo = {
type: 'code',
code: `
<Button
onClick={event =>
toastManager.add({
id: "copied",
title: "Copied",
type: "success",
timeout: 1500,
positionerProps: { anchor: event.currentTarget }
})
}
>
Copy link
</Button>`
};

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

<Demo data={playground} />

Expand Down Expand Up @@ -70,6 +70,18 @@ Control where toasts appear on screen with the `position` prop on `Toast.Provide

<Demo data={positionDemo} />

### 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.

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.

The demo passes an `id`, so repeated clicks update one toast instead of adding more.

<Demo data={anchoredDemo} />

### Close and update

Create a toast, then update or close it programmatically using the returned ID.
Expand Down Expand Up @@ -142,7 +154,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 `<Toast.Provider>`. 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 `<Toast.Provider>`. 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'
Expand All @@ -166,7 +178,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 |
Expand All @@ -178,10 +190,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 |

## 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"`
Expand Down
52 changes: 50 additions & 2 deletions apps/www/src/content/docs/components/toast/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,51 @@ 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` 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. 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 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.
Expand All @@ -97,6 +137,13 @@ export interface ToastManagerUpdateOptions {
onRemove?: () => void;
actionProps?: React.ComponentPropsWithoutRef<'button'>;
leadingIcon?: React.ReactNode;
/**
* Placement of an anchored toast. 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;
}

/**
Expand All @@ -107,7 +154,8 @@ export interface ToastManagerUpdateOptions {
*/
export interface ToastManagerPromiseOptions<Value = unknown> {
/**
* 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`
*/
Expand Down
24 changes: 22 additions & 2 deletions packages/react/components/popover/__tests__/data-slots.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -15,6 +15,26 @@ describe('Popover data-slot contract', () => {
expectSlots(document.body, ['popover-positioner', 'popover-content']);
});

it('exposes the arrow slot with showArrow', () => {
render(
<Popover open>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content showArrow>Content</Popover.Content>
</Popover>
);
expectSlots(document.body, ['popover-arrow']);
});

it('omits the arrow slot by default', () => {
render(
<Popover open>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>
);
expect(getSlot(document.body, 'popover-arrow')).toBeNull();
});

it('exposes the viewport slot', () => {
render(
<Popover open>
Expand Down
6 changes: 6 additions & 0 deletions packages/react/components/popover/popover.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion packages/react/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}
Expand All @@ -25,7 +31,8 @@ function PopoverContent({
render,
children,
radius,
sideOffset = 4,
showArrow = false,
sideOffset = showArrow ? 10 : 4,
...positionerProps
}: PopoverContentProps) {
const theme = useThemeInjection();
Expand All @@ -43,6 +50,7 @@ function PopoverContent({
{...theme}
className={cx(
styles.popover,
showArrow && styles['show-arrow'],
theme?.className,
radiusStyle({ radius }),
className
Expand All @@ -54,6 +62,14 @@ function PopoverContent({
data-slot='popover-content'
>
{children}
{showArrow && (
<PopoverPrimitive.Arrow
className={arrowClassName}
data-slot='popover-arrow'
>
<ArrowSvg />
</PopoverPrimitive.Arrow>
)}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
Expand Down
24 changes: 24 additions & 0 deletions packages/react/components/toast/__tests__/data-slots.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading
Loading