From 95d41608fcae7540c8c62f9172250155866bba5c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 10:38:34 -0400 Subject: [PATCH 01/80] 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 02/80] 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 ` + ) : ( +
+ {inner} +
+ )} + {active ? ( + + ) : null} + {trailing} + {hoverAction} + + ); +} + +function SuggestedBadge() { + const { suggestedBadge } = useRecipe(userButtonRecipe); + return Suggested; +} + +function InlineButton({ label, onClick }: { label: string; onClick: () => void }) { + const { inlineButton } = useRecipe(userButtonRecipe); + return ( + + ); +} + +function HoverAction({ onClick }: { onClick: () => void }) { + const { hoverAction } = useRecipe(userButtonRecipe); + return ( + + ); +} + +function AddRow({ label, onClick }: { label: string; onClick: () => void }) { + const theme = useMosaicTheme(); + const { add, addIcon } = useRecipe(userButtonRecipe); + return ( + + ); +} + +// ─── Sections ───────────────────────────────────────────────────────────────── + +interface HeaderAction { + icon: IconName; + label: string; + onClick: () => void; +} + +function Header() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { header, headerName, headerActions, action, secondary, upgrade } = useRecipe(userButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeSession.name; + const image = isOrg ? org.imageUrl : data.activeSession.imageUrl; + + const actions: HeaderAction[] = []; + if (isOrg) { + if (data.onManageOrganization) { + actions.push({ icon: 'cog', label: 'Settings', onClick: data.onManageOrganization }); + } + if (data.onManageMembers) { + actions.push({ icon: 'users', label: 'Members', onClick: data.onManageMembers }); + } + } else { + if (data.onManageAccount) { + actions.push({ icon: 'cog', label: 'Manage account', onClick: data.onManageAccount }); + } + const signOut = data.onSignOutSession; + if (signOut) { + actions.push({ icon: 'log-out', label: 'Sign out', onClick: () => signOut(data.activeSession.sessionId) }); + } + } + + const showUpgrade = isOrg && org.upgradeable === true && data.onUpgrade !== undefined; + const subtitle = isOrg ? membershipSubtitle(org) : data.activeSession.email; + + return ( +
+
+ + + {label} + + {subtitle} + {showUpgrade ? ( + <> + {subtitle ? ' · ' : null} + + + ) : null} + + +
+ {actions.length > 0 ? ( +
+ {actions.map(a => ( + + ))} +
+ ) : null} +
+ ); +} + +function WorkspaceList() { + const data = useUserButtonContext(); + const { group } = useRecipe(userButtonRecipe); + const selectOrg = data.onSelectOrganization; + const acceptSuggestion = data.onAcceptSuggestion; + const acceptInvitation = data.onAcceptInvitation; + const signOutSession = data.onSignOutSession; + + return ( +
+ signOutSession(data.activeSession.sessionId)} /> : undefined + } + /> + {data.memberships.map(m => ( + selectOrg(m.organizationId) : undefined} + active={m.organizationId === data.activeOrganizationId} + /> + ))} + {data.suggestions.map(s => ( + } + trailing={ + acceptSuggestion ? ( + acceptSuggestion(s.id)} + /> + ) : undefined + } + /> + ))} + {data.invitations.map(i => ( + acceptInvitation(i.id)} + /> + ) : undefined + } + /> + ))} + {data.onCreateOrganization ? ( + + ) : null} +
+ ); +} + +function SessionsSection() { + const data = useUserButtonContext(); + const { group, groupLabel } = useRecipe(userButtonRecipe); + const switchSession = data.onSwitchSession; + + if (data.additionalSessions.length === 0 && !data.onAddAccount) { + return null; + } + + return ( +
+ {data.additionalSessions.length > 0 ?
Additional accounts
: null} + {data.additionalSessions.map(a => ( + switchSession(a.sessionId) : undefined} + /> + ))} + {data.onAddAccount ? ( + + ) : null} +
+ ); +} + +function Footer() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { footer, signOutAll, branding } = useRecipe(userButtonRecipe); + return ( +
+ {data.onSignOutAll ? ( + + ) : null} +
Secured by Clerk
+
+ ); +} + +// ─── Public parts ─────────────────────────────────────────────────────────── + +export interface UserButtonRootProps extends UserButtonData, UserButtonCallbacks { + children: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: PopoverProps['placement']; + sideOffset?: number; +} + +/** + * Owns the account/organization data + callbacks and forwards the popover's open state straight to + * the headless `Popover.Root` — it does not keep a second controllable-state copy. Leaves consume + * the data through context. + */ +export function UserButtonRoot(props: UserButtonRootProps) { + const { children, open, defaultOpen, onOpenChange, placement, sideOffset, ...data } = props; + return ( + + {children} + + ); +} + +/** The sidebar trigger: active workspace avatar + name (+ plan badge for orgs) and a selector icon. */ +export function UserButtonTrigger() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { trigger, triggerName, triggerBadge } = useRecipe(userButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeSession.name; + const image = isOrg ? org.imageUrl : data.activeSession.imageUrl; + + return ( + ( + + )} + /> + ); +} + +/** The popover surface: header, workspace list, additional accounts, and footer. */ +export function UserButtonPopup() { + const data = useUserButtonContext(); + const { popup } = useRecipe(userButtonRecipe); + return ( + + + +
+ {data.hasOrganizations ? : null} + +