From 95d41608fcae7540c8c62f9172250155866bba5c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 10:38:34 -0400 Subject: [PATCH 1/3] fix(headless): stop menu Escape from bubbling to the parent floating element Escape now closes one level: the menu, leaving its parent popover or menu open. An outside press still dismisses the whole stack. --- .../headless/src/primitives/menu/README.md | 2 +- .../src/primitives/menu/menu-root.tsx | 4 +- .../src/primitives/menu/menu.test.tsx | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/headless/src/primitives/menu/README.md b/packages/headless/src/primitives/menu/README.md index ee2c9dda87e..4ac10b6dac2 100644 --- a/packages/headless/src/primitives/menu/README.md +++ b/packages/headless/src/primitives/menu/README.md @@ -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 diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx index bf70205abb2..2afbed329be 100644 --- a/packages/headless/src/primitives/menu/menu-root.tsx +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -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, diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx index 0baf2917088..ec5349e579a 100644 --- a/packages/headless/src/primitives/menu/menu.test.tsx +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -682,6 +682,80 @@ describe('Menu', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + + it('Escape closes only the submenu', async () => { + const user = userEvent.setup(); + render( + + Actions + + + + Share + + + Email + + + + + + , + ); + + 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', ''); + }); + }); + + describe('inside a popover', () => { + function renderMenuInPopover() { + return render( + + Open popover + + + + Actions + + + Cut + + + + + + , + ); + } + + 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', () => { From 9653a8fcb72b1e127c80ea5db31cd030c4a18148 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 11:25:49 -0400 Subject: [PATCH 2/3] feat(headless): add alignOffset to Popover.Root Nudges the popup along the alignment axis, the counterpart to sideOffset. --- .../headless/src/primitives/popover/README.md | 17 +++++---- .../src/primitives/popover/popover-root.tsx | 12 +++++- .../src/stories/popover.component.mdx | 37 +++++++++++++++---- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md index 58ceb1d53aa..dd2a537d7d0 100644 --- a/packages/headless/src/primitives/popover/README.md +++ b/packages/headless/src/primitives/popover/README.md @@ -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` diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx index d9831ea4e16..212277c3e4c 100644 --- a/packages/headless/src/primitives/popover/popover-root.tsx +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -31,6 +31,7 @@ export interface PopoverProps { onOpenChange?: (open: boolean) => void; placement?: Placement; sideOffset?: number; + alignOffset?: number; modal?: boolean; /** * Where focus lands when the popup opens. @@ -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); @@ -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', diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index 9ce80bcbbb2..fde2fb75410 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -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 + + Open + + + Pulled 8px left of the trigger's start edge. + + +; +``` + +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 `