Extract Base UI Drawer into the v2 kit

Promote the portal burger shell into a reusable
Drawer matching Dialog (thin Base UI + tv slots).
TopBarMobileNav now composes the kit parts.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-20 16:47:47 +02:00
parent ea8377a0cc
commit d047f1b323
14 changed files with 683 additions and 136 deletions

View File

@@ -18,7 +18,6 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer } from "@base-ui/react/drawer";
import {
BellIcon,
BellRingingIcon,
@@ -29,6 +28,14 @@ import {
} from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Link } from "@probo/ui/src/v2/Button/Link";
import { Drawer } from "@probo/ui/src/v2/Drawer/Drawer";
import { DrawerBody } from "@probo/ui/src/v2/Drawer/DrawerBody";
import { DrawerClose } from "@probo/ui/src/v2/Drawer/DrawerClose";
import { DrawerFooter } from "@probo/ui/src/v2/Drawer/DrawerFooter";
import { DrawerHeader } from "@probo/ui/src/v2/Drawer/DrawerHeader";
import { DrawerPopup } from "@probo/ui/src/v2/Drawer/DrawerPopup";
import { DrawerTitle } from "@probo/ui/src/v2/Drawer/DrawerTitle";
import { DrawerTrigger } from "@probo/ui/src/v2/Drawer/DrawerTrigger";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useState } from "react";
@@ -43,7 +50,7 @@ import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
import type { TopBarMobileNav_identity$key } from "./__generated__/TopBarMobileNav_identity.graphql";
import { TOP_BAR_NAV_ITEMS } from "./navItems";
import { topBar, topBarMobileNav } from "./variants";
import { topBar } from "./variants";
const topBarMobileNavFragment = graphql`
fragment TopBarMobileNav_identity on Identity {
@@ -72,7 +79,6 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
const identity = useFragment(topBarMobileNavFragment, identityKey);
const barSlots = topBar();
const slots = topBarMobileNav();
const displayName = identity
? (identity.fullName.trim() || identity.email)
@@ -81,9 +87,9 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
const close = () => setOpen(false);
return (
<Drawer.Root open={open} onOpenChange={setOpen} swipeDirection="right">
<Drawer open={open} onOpenChange={setOpen} swipeDirection="right">
<div className={barSlots.menuTrigger()}>
<Drawer.Trigger
<DrawerTrigger
render={(
<IconButton
variant="ghost"
@@ -96,117 +102,110 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
)}
/>
</div>
<Drawer.Portal>
<Drawer.Backdrop className={slots.backdrop()} />
<Drawer.Viewport className={slots.viewport()}>
<Drawer.Popup className={slots.popup()}>
<Drawer.Content className={slots.content()}>
<div className={slots.header()}>
<Drawer.Title className={slots.title()}>
{t("topBar.menuTitle")}
</Drawer.Title>
<Drawer.Close
render={(
<IconButton
variant="ghost"
color="neutral"
size={2}
aria-label={t("topBar.closeMenu")}
>
<XIcon />
</IconButton>
)}
/>
</div>
<DrawerPopup side="right">
<DrawerHeader>
<DrawerTitle>{t("topBar.menuTitle")}</DrawerTitle>
<DrawerClose
render={(
<IconButton
variant="ghost"
color="neutral"
size={2}
aria-label={t("topBar.closeMenu")}
>
<XIcon />
</IconButton>
)}
/>
</DrawerHeader>
<nav className={slots.nav()}>
{TOP_BAR_NAV_ITEMS.map(item => (
<Link
key={item.to}
to={item.to}
<DrawerBody>
<nav className="contents">
{TOP_BAR_NAV_ITEMS.map(item => (
<Link
key={item.to}
to={item.to}
variant="ghost"
color="neutral"
size={3}
active={isActive(pathname, item.to)}
className="w-full justify-start"
onClick={close}
>
{t(item.labelKey)}
</Link>
))}
</nav>
</DrawerBody>
<DrawerFooter>
{identity == null
? (
<Button
variant="solid"
color="neutral"
highContrast
size={3}
className="w-full"
iconStart={<LockSimpleIcon />}
onClick={() => {
close();
openSignIn({ continueTo: buildRequestAllContinueUrl() });
}}
>
{t("topBar.getAccess")}
</Button>
)
: (
<>
{displayName
? (
<div className="flex flex-col gap-0.5 px-1 pb-2">
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{displayName}
</Text>
<Text size={1} color="faint" className="truncate">
{identity.email}
</Text>
</div>
)
: null}
<Button
variant="ghost"
color="neutral"
color={isSubscribed ? "green" : "gold"}
size={3}
active={isActive(pathname, item.to)}
className="w-full justify-start"
onClick={close}
iconStart={isSubscribed ? <BellRingingIcon /> : <BellIcon />}
disabled={isUnsubscribing}
onClick={() => {
close();
if (isSubscribed) {
void unsubscribe();
return;
}
openSubscribe();
}}
>
{t(item.labelKey)}
</Link>
))}
</nav>
<div className={slots.actions()}>
{identity == null
? (
<Button
variant="solid"
color="neutral"
highContrast
size={3}
className="w-full"
iconStart={<LockSimpleIcon />}
onClick={() => {
close();
openSignIn({ continueTo: buildRequestAllContinueUrl() });
}}
>
{t("topBar.getAccess")}
</Button>
)
: (
<>
{displayName
? (
<div className="flex flex-col gap-0.5 px-1 pb-2">
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{displayName}
</Text>
<Text size={1} color="faint" className="truncate">
{identity.email}
</Text>
</div>
)
: null}
<Button
variant="ghost"
color={isSubscribed ? "green" : "gold"}
size={3}
className="w-full justify-start"
iconStart={isSubscribed ? <BellRingingIcon /> : <BellIcon />}
disabled={isUnsubscribing}
onClick={() => {
close();
if (isSubscribed) {
void unsubscribe();
return;
}
openSubscribe();
}}
>
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
</Button>
<Button
variant="ghost"
color="red"
size={3}
className="w-full justify-start"
iconStart={<SignOutIcon />}
disabled={isSigningOut}
onClick={() => {
close();
void signOut();
}}
>
{t("userMenu.signOut")}
</Button>
</>
)}
</div>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
</Button>
<Button
variant="ghost"
color="red"
size={3}
className="w-full justify-start"
iconStart={<SignOutIcon />}
disabled={isSigningOut}
onClick={() => {
close();
void signOut();
}}
>
{t("userMenu.signOut")}
</Button>
</>
)}
</DrawerFooter>
</DrawerPopup>
</Drawer>
);
}

View File

@@ -43,30 +43,7 @@ export const topBarUserMenuTrigger = tv({
base: [
"flex h-8 items-center gap-2 rounded-full py-1 pr-2.5 pl-1",
"cursor-pointer outline-none transition-colors select-none",
"hover:bg-sand-3 data-[popup-open]:bg-sand-3",
"hover:bg-sand-3 data-popup-open:bg-sand-3",
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
],
});
// Right-edge mobile navigation drawer (Base UI Drawer).
export const topBarMobileNav = tv({
slots: {
backdrop: [
"fixed inset-0 z-50 bg-sand-12/40",
"transition-opacity duration-200",
"data-starting-style:opacity-0 data-ending-style:opacity-0",
],
viewport: "fixed inset-0 z-50 flex justify-end",
popup: [
"relative flex h-full w-[min(20rem,100%)] flex-col bg-sand-1 shadow-6 outline-none",
"[transform:translateX(var(--drawer-swipe-movement-x,0px))]",
"transition-transform duration-200",
"data-starting-style:translate-x-full data-ending-style:translate-x-full",
],
content: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
header: "flex items-center justify-between gap-3",
title: "text-4 font-medium text-sand-12",
nav: "flex flex-col gap-1",
actions: "mt-auto flex flex-col gap-2 border-t border-sand-a3 pt-4",
},
});