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
2 changes: 2 additions & 0 deletions .changeset/headless-popover-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/headless/src/primitives/menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall
- Nested menus open on hover (75ms delay) with a `safePolygon` safe zone.
- Only one sibling submenu can be open at a time.
- Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event.
- `Escape` closes the innermost menu first, bubbling up through the tree.
- `Escape` closes one level: the innermost open menu, leaving its parent — a parent menu, or a `Popover` the menu is rendered inside — open. Pressing it again closes the next level up. An outside press is the opposite: it dismisses the whole stack at once.

## Important Notes

Expand Down
4 changes: 3 additions & 1 deletion packages/headless/src/primitives/menu/menu-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ function MenuInner(props: MenuProps) {
delete reference.role;
return { ...baseRole, reference };
}, [baseRole, isNested]);
const dismiss = useDismiss(floatingContext, { bubbles: true });
// Escape must not bubble: it closes this menu and leaves whatever it sits inside — a parent menu,
// or a popover — open. An outside press is the opposite, and dismisses the whole stack.
const dismiss = useDismiss(floatingContext, { bubbles: { escapeKey: false, outsidePress: true } });
const listNavigation = useListNavigation(floatingContext, {
listRef: elementsRef,
activeIndex,
Expand Down
74 changes: 74 additions & 0 deletions packages/headless/src/primitives/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,80 @@ describe('Menu', () => {

expect(onClick).toHaveBeenCalledTimes(1);
});

it('Escape closes only the submenu', async () => {
const user = userEvent.setup();
render(
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Root>
<Menu.Trigger>Share</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Email'>Email</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>,
);

await user.click(screen.getByText('Actions'));
await new Promise(r => requestAnimationFrame(r));
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowRight}');
await user.keyboard('{Escape}');

expect(screen.getByText('Share')).toHaveAttribute('data-closed', '');
expect(screen.getByText('Actions')).toHaveAttribute('data-open', '');
Comment on lines +708 to +713

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert each required intermediate open state.

These tests can pass if the menu never opens. The second Popover test can also pass if the first Escape closes the Popover.

  • packages/headless/src/primitives/menu/menu.test.tsx#L708-L713: Assert that Share is open after ArrowRight and before Escape.
  • packages/headless/src/primitives/menu/menu.test.tsx#L742-L746: Assert that Actions is open after the click and before Escape.
  • packages/headless/src/primitives/menu/menu.test.tsx#L753-L757: After the first Escape, assert that Actions is closed and Open popover remains open before the second Escape.

As per coding guidelines, unit tests must cover new functionality and edge cases.

📍 Affects 1 file
  • packages/headless/src/primitives/menu/menu.test.tsx#L708-L713 (this comment)
  • packages/headless/src/primitives/menu/menu.test.tsx#L742-L746
  • packages/headless/src/primitives/menu/menu.test.tsx#L753-L757
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/menu/menu.test.tsx` around lines 708 - 713,
Strengthen the menu keyboard and popover tests by asserting each intermediate
open/closed state: in packages/headless/src/primitives/menu/menu.test.tsx lines
708-713, verify Share is open after ArrowRight and before Escape; lines 742-746,
verify Actions is open after the click and before Escape; and lines 753-757,
verify Actions is closed while Open popover remains open after the first Escape
and before the second.

Source: Coding guidelines

});
});

describe('inside a popover', () => {
function renderMenuInPopover() {
return render(
<Popover.Root defaultOpen>
<Popover.Trigger>Open popover</Popover.Trigger>
<Popover.Positioner>
<Popover.Popup>
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Cut'>Cut</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>,
);
}

it('Escape closes only the menu', async () => {
const user = userEvent.setup();
renderMenuInPopover();

await user.click(screen.getByText('Actions'));
await user.keyboard('{Escape}');

expect(screen.getByText('Actions')).toHaveAttribute('data-closed', '');
expect(screen.getByText('Open popover')).toHaveAttribute('data-open', '');
});

it('Escape closes the popover once the menu is closed', async () => {
const user = userEvent.setup();
renderMenuInPopover();

await user.click(screen.getByText('Actions'));
await user.keyboard('{Escape}');
await user.keyboard('{Escape}');

expect(screen.getByText('Open popover')).toHaveAttribute('data-closed', '');
});
});

describe('positioner', () => {
Expand Down
18 changes: 10 additions & 8 deletions packages/headless/src/primitives/popover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ const [open, setOpen] = useState(false);

### `Popover.Root`

| Prop | Type | Default | Description |
| -------------- | ------------------------- | ---------- | ---------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `placement` | `Placement` | `"bottom"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
| `modal` | `boolean` | `false` | Traps focus within the popover |
| Prop | Type | Default | Description |
| -------------- | ------------------------- | ---------- | ----------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `placement` | `Placement` | `"bottom"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
| `alignOffset` | `number` | `0` | Nudge along the alignment axis (px) |
| `modal` | `boolean` | `false` | Traps focus within the popover |

### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close`

Expand Down Expand Up @@ -104,6 +105,7 @@ Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popu
- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent.
- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap.
- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically.
- **Popup contents freeze while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`@clerk/headless/utils`) and hold their last frame instead of re-rendering under the animation. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land. Freezing wraps the children in a `display: contents` element and detaches refs inside them until the popup reopens.

## ARIA

Expand Down
11 changes: 8 additions & 3 deletions packages/headless/src/primitives/popover/popover-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

import React from 'react';

import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils';
import { usePopoverContext } from './popover-context';

export type PopoverPopupProps = ComponentProps<'div'>;

export const PopoverPopup = React.forwardRef<HTMLDivElement, PopoverPopupProps>(function PopoverPopup(props, ref) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = usePopoverContext();
const { render, children, ...otherProps } = props;
const { open, popupRef, transitionProps } = usePopoverContext();

const defaultProps = {
...transitionProps,
// The popup outlives `open` by the length of its exit animation. Whatever closed it has
// usually changed the data behind it (switching account, picking an item), so the contents
// hold their last frame on the way out instead of swapping under the animation. The popup
// element itself stays live, so `data-closed` / `data-ending-style` still land.
children: <Freeze frozen={!open}>{children}</Freeze>,
};

return useRender({
Expand Down
12 changes: 10 additions & 2 deletions packages/headless/src/primitives/popover/popover-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface PopoverProps {
onOpenChange?: (open: boolean) => void;
placement?: Placement;
sideOffset?: number;
alignOffset?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add coverage for alignOffset.

This new public property has no supplied regression test. Add a test that verifies a non-zero alignOffset reaches the Floating UI offset middleware and changes an aligned placement. The changed Mosaic test only updates popup selectors.

As per coding guidelines, “Unit tests are required for all new functionality.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/popover/popover-root.tsx` at line 34, Add
regression coverage for the public alignOffset property in the popover tests:
render an aligned popover with a non-zero value, verify that the Floating UI
offset middleware receives it, and assert that the resulting aligned placement
changes accordingly. Keep the existing popup selector updates separate from this
new behavior test.

Source: Coding guidelines

modal?: boolean;
/**
* Where focus lands when the popup opens.
Expand All @@ -47,7 +48,14 @@ export interface PopoverProps {

function PopoverInner(props: PopoverProps) {
const nodeId = useFloatingNodeId();
const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, initialFocus = 'auto', children } = props;
const {
placement: placementProp = 'bottom',
sideOffset = 4,
alignOffset = 0,
modal = false,
initialFocus = 'auto',
children,
} = props;

const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);

Expand All @@ -71,7 +79,7 @@ function PopoverInner(props: PopoverProps) {
onOpenChange: setOpen,
placement: placementProp,
middleware: [
offset(sideOffset),
offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),
flip({
crossAxis: placementProp.includes('-'),
fallbackAxisSideDirection: 'end',
Expand Down
72 changes: 72 additions & 0 deletions packages/headless/src/utils/freeze.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { Freeze } from './freeze';

afterEach(() => {
cleanup();
});

describe('Freeze', () => {
it('renders children while not frozen', () => {
render(<Freeze frozen={false}>Acme</Freeze>);

expect(screen.getByText('Acme')).toBeInTheDocument();
});

it('holds the committed DOM when children change while frozen', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);

expect(screen.getByText('Acme')).toBeInTheDocument();
expect(screen.queryByText('Globex')).toBeNull();
});

it('keeps the held DOM visible', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);

expect(screen.getByText('Acme')).toBeVisible();
});

it('keeps the held DOM visible across further updates while frozen', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);
rerender(<Freeze frozen>Initech</Freeze>);

expect(screen.getByText('Acme')).toBeVisible();
});

it('commits the pending children once unfrozen', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);
rerender(<Freeze frozen={false}>Globex</Freeze>);

expect(screen.getByText('Globex')).toBeInTheDocument();
expect(screen.queryByText('Acme')).toBeNull();
});

it('holds state updates raised from inside the frozen subtree', () => {
function Counter({ count }: { count: number }) {
return <span>count: {count}</span>;
}

const { rerender } = render(
<Freeze frozen={false}>
<Counter count={0} />
</Freeze>,
);

rerender(
<Freeze frozen>
<Counter count={1} />
</Freeze>,
);

expect(screen.getByText('count: 0')).toBeInTheDocument();
Comment on lines +53 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test an actual state update from the frozen subtree.

Counter has no state or state setter. This test only replaces the parent-provided count prop. Add an interactive state update while frozen, then verify that it stays uncommitted until frozen becomes false.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must verify edge cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/utils/freeze.test.tsx` around lines 53 - 70, The test
around Counter must exercise a genuine internal state update rather than only
changing the parent-provided count prop. Add interactive state and trigger its
setter while the Freeze component is frozen, assert the update remains
uncommitted, then rerender with frozen set to false and verify the state update
appears.

Source: Coding guidelines

});
});
64 changes: 64 additions & 0 deletions packages/headless/src/utils/freeze.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client';

import * as React from 'react';

/**
* Never settles. Throwing it suspends the enclosing boundary indefinitely: React keeps
* rendering the subtree but holds the commit, so the DOM keeps painting its last frame.
*/
const never = new Promise<never>(() => {});

function Suspend(): null {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol, not an error. `React.use()` would say this more plainly but needs React 19.2; this package supports React 18.
throw never;
}

export interface FreezeProps {
/** While `true`, the DOM below holds whatever it last committed. */
frozen: boolean;
children?: React.ReactNode;
}

/**
* Holds its subtree's DOM at the last committed frame while `frozen`. Renders keep
* happening, they just don't reach the DOM; the pending one commits when `frozen` flips
* back to `false`.
*
* Use it to stop content from visibly changing under an exit animation — a popover that
* closes because the thing it was showing changed would otherwise swap its contents on the
* way out.
*/
export function Freeze({ frozen, children }: FreezeProps) {
const contentRef = React.useRef<HTMLDivElement | null>(null);

// Hold onto the node ourselves rather than reading a plain ref: hiding a boundary's children
// detaches their refs, so by the time the effect below runs a normal ref reads `null`.
const setContent = React.useCallback((node: HTMLDivElement | null) => {
if (node) {
contentRef.current = node;
}
}, []);

// React hides a suspended boundary's host children with `display: none !important`, which is
// the opposite of what this is for. Undo it on the commit that applies it: insertion effects
// run after the boundary's mutation and before paint, so the held frame never blinks out.
// `display: contents` is also what the wrapper renders with, so React puts it back on unfreeze
// and the wrapper stays out of the layout it is spliced into.
React.useInsertionEffect(() => {
if (frozen) {
contentRef.current?.style.setProperty('display', 'contents');
}
}, [frozen]);

return (
<React.Suspense fallback={null}>
{frozen ? <Suspend /> : null}
<div
ref={setContent}
style={{ display: 'contents' }}
>
{children}
</div>
</React.Suspense>
);
}
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { cssVars } from './css-vars';
export { Freeze, type FreezeProps } from './freeze';
export { isKeyboardEvent, isKeyboardOpen } from './interaction-modality';
export { resetLayoutStyles } from './reset-layout-styles';
export {
Expand Down
37 changes: 29 additions & 8 deletions packages/swingset/src/stories/popover.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,37 @@ centering on it.
Cross-axis flipping is only enabled for aligned placements, so `bottom-start` may become
`bottom-end` near a viewport edge while a plain `bottom` will not.

`alignOffset` nudges the popup along that alignment axis, the way `sideOffset` does along the side.
Use it to cancel padding inside the popup so its content, rather than its edge, lines up with the
trigger — a negative value pulls a `-start` placement further left.

```tsx
<Popover.Root
placement='bottom-start'
alignOffset={-8}
>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Popup aria-label='Account'>
<Card>
<Card.Content>Pulled 8px left of the trigger's start edge.</Card.Content>
</Card>
</Popover.Popup>
</Popover.Root>;
```

It is a preference like placement is: `shift` still claws the popup back when the nudge would push
it out of view.

## Parts

| Part | Slot | Description |
| --------------------- | --------------- | ----------------------------------------------------------------------- |
| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `modal`. |
| `Popover.Trigger` | — | Anchor element; renders a `<button>` and accepts a `render` prop. |
| `Popover.Popup` | `popover-popup` | The floating box; owns `size`, viewport clamps, and the enter/exit run. |
| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |
| Part | Slot | Description |
| --------------------- | --------------- | ----------------------------------------------------------------------------------- |
| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `alignOffset`, `modal`. |
| `Popover.Trigger` | — | Anchor element; renders a `<button>` and accepts a `render` prop. |
| `Popover.Popup` | `popover-popup` | The floating box; owns `size`, viewport clamps, and the enter/exit run. |
| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |

`Popover.Popup` renders the portal and the floating positioner internally. The positioner carries
the `popover-positioner` slot and is the `role="dialog"` element, which is why the popup's
Expand Down
Loading
Loading