Portal drawer selects onto the viewport

The swipe Popup keeps a CSS transform that re-roots
position:fixed, so Base UI's modal inert cutout missed
the locale trigger and blocked hover and clicks.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-29 18:04:23 +02:00
parent 72ca18e204
commit b0ad5796b6
4 changed files with 18 additions and 16 deletions

View File

@@ -40,7 +40,8 @@ interface LocaleSelectProps {
// Called after a locale change is requested (e.g. close the mobile drawer). // Called after a locale change is requested (e.g. close the mobile drawer).
onLocaleChange?: () => void; onLocaleChange?: () => void;
// Portal target for the menu. Required inside a Drawer/Dialog so the popup // Portal target for the menu. Required inside a Drawer/Dialog so the popup
// is not painted under the modal layer (Select defaults to body + z-3). // stacks in the modal layer (Select defaults to body + z-3). Prefer a
// non-transformed container (e.g. Drawer Viewport, not the swipe Popup).
portalContainer?: RefObject<HTMLElement | null>; portalContainer?: RefObject<HTMLElement | null>;
} }

View File

@@ -80,9 +80,10 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
const [signOut, isSigningOut] = useSignOut(); const [signOut, isSigningOut] = useSignOut();
const { displayMode, toggleDisplayMode } = useDisplayMode(); const { displayMode, toggleDisplayMode } = useDisplayMode();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Select menus portal to body at z-3 by default; mount them on the drawer // Select menus portal to body at z-3 by default (under the drawer at z-5).
// popup so they stack inside the modal layer (z-5) instead of under it. // Mount them on the drawer Viewport — not the swipe Popup, which keeps a CSS
const drawerPopupRef = useRef<HTMLDivElement>(null); // transform and would break Base UI's modal inert cutout over the trigger.
const drawerViewportRef = useRef<HTMLDivElement>(null);
const identity = useFragment(topBarMobileNavFragment, identityKey); const identity = useFragment(topBarMobileNavFragment, identityKey);
const localizedPath = useLocalizedPath(); const localizedPath = useLocalizedPath();
@@ -110,7 +111,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
)} )}
/> />
</div> </div>
<DrawerPopup side="right" ref={drawerPopupRef}> <DrawerPopup side="right" ref={drawerViewportRef}>
<DrawerHeader> <DrawerHeader>
<DrawerTitle>{t("topBar.menuTitle")}</DrawerTitle> <DrawerTitle>{t("topBar.menuTitle")}</DrawerTitle>
<DrawerClose <DrawerClose
@@ -154,7 +155,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
<LocaleSelect <LocaleSelect
persist={identity != null} persist={identity != null}
onLocaleChange={close} onLocaleChange={close}
portalContainer={drawerPopupRef} portalContainer={drawerViewportRef}
/> />
</div> </div>
{identity == null {identity == null

View File

@@ -33,8 +33,10 @@ export type DrawerPopupProps
// Portal + dimmed backdrop + edge-aligned popup. Children compose the header / // Portal + dimmed backdrop + edge-aligned popup. Children compose the header /
// body / footer regions inside Base UI's Content (swipe-safe text selection). // body / footer regions inside Base UI's Content (swipe-safe text selection).
// `ref` lands on the Popup panel (not the viewport) so nested menus can portal // `ref` lands on the Viewport (not the swipe Popup): nested Select/Menu portals
// into the drawer stacking context. // must mount on a non-transformed ancestor. The Popup keeps a CSS transform for
// swipe/enter animation, which would re-root `position: fixed` and misalign
// Base UI's modal inert cutout so the trigger looks unclickable.
export function DrawerPopup(props: DrawerPopupProps) { export function DrawerPopup(props: DrawerPopupProps) {
const { className, children, side, ref, ...popupProps } = props; const { className, children, side, ref, ...popupProps } = props;
const slots = drawer({ side }); const slots = drawer({ side });
@@ -42,12 +44,8 @@ export function DrawerPopup(props: DrawerPopupProps) {
return ( return (
<BaseDrawer.Portal> <BaseDrawer.Portal>
<BaseDrawer.Backdrop className={slots.backdrop()} /> <BaseDrawer.Backdrop className={slots.backdrop()} />
<BaseDrawer.Viewport className={slots.viewport()}> <BaseDrawer.Viewport ref={ref} className={slots.viewport()}>
<BaseDrawer.Popup <BaseDrawer.Popup className={slots.popup({ className })} {...popupProps}>
ref={ref}
className={slots.popup({ className })}
{...popupProps}
>
<BaseDrawer.Content className={slots.content()}> <BaseDrawer.Content className={slots.content()}>
{children} {children}
</BaseDrawer.Content> </BaseDrawer.Content>

View File

@@ -27,8 +27,10 @@ export type SelectPopupProps
= & Omit<ComponentProps<typeof BaseSelect.Popup>, "className"> = & Omit<ComponentProps<typeof BaseSelect.Popup>, "className">
& { & {
className?: string; className?: string;
// Mount inside a modal/drawer so the menu stacks above that layer // Mount inside a modal/drawer stacking context (body portal uses z-3,
// (body portal uses z-3, below drawers at z-5). // below drawers at z-5). Use a non-transformed ancestor — a CSS
// transform on the container re-roots `position: fixed` and breaks
// Base UI's modal inert cutout over the trigger.
container?: ComponentProps<typeof BaseSelect.Portal>["container"]; container?: ComponentProps<typeof BaseSelect.Portal>["container"];
// Positioner placement passthrough. // Positioner placement passthrough.
side?: ComponentProps<typeof BaseSelect.Positioner>["side"]; side?: ComponentProps<typeof BaseSelect.Positioner>["side"];