Skip to content
Merged
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
39 changes: 39 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,45 @@ export const basicDemo = {
</Button>`
};

export const anchoredDemo = {
type: 'code',
tabs: [
{
name: 'Default',
code: `
<Button
onClick={event =>
toastManager.add({
id: "copied",
title: "Copied",
timeout: 1500,
positionerProps: { anchor: event.currentTarget }
})
}
>
Copy link
</Button>`
},
{
name: 'With arrow',
code: `
<Button
onClick={event =>
toastManager.add({
id: "copied",
title: "Copied",
timeout: 1500,
showArrow: true,
positionerProps: { anchor: event.currentTarget }
})
}
>
Copy link
</Button>`
}
]
};

export const typesDemo = {
type: 'code',
tabs: [
Expand Down
24 changes: 21 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,20 @@ 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. 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.

<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 +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 `<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 +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 |
Expand All @@ -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"`
Expand Down
59 changes: 57 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,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.
Expand All @@ -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;
}

/**
Expand All @@ -107,7 +161,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
16 changes: 15 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 { Arrow } 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,12 @@ function PopoverContent({
data-slot='popover-content'
>
{children}
{showArrow && (
<PopoverPrimitive.Arrow
data-slot='popover-arrow'
render={<Arrow />}
/>
)}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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();
});
});
Expand Down
Loading
Loading