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",
},
});

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useState } from "react";
import { Button } from "../Button/Button";
import { Text } from "../typography/Text";
import { Drawer } from "./Drawer";
import { DrawerBody } from "./DrawerBody";
import { DrawerClose } from "./DrawerClose";
import { DrawerDescription } from "./DrawerDescription";
import { DrawerFooter } from "./DrawerFooter";
import { DrawerHeader } from "./DrawerHeader";
import { DrawerPopup } from "./DrawerPopup";
import { DrawerSkeleton } from "./DrawerSkeleton";
import { DrawerTitle } from "./DrawerTitle";
import { DrawerTrigger } from "./DrawerTrigger";
export default {
title: "v2/Drawer",
component: Drawer,
};
export function Default() {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={<Button variant="solid" color="neutral" highContrast>Open drawer</Button>} />
<DrawerPopup side="right">
<DrawerHeader>
<DrawerTitle>Menu</DrawerTitle>
<DrawerClose render={<Button variant="soft" color="neutral" highContrast>Close</Button>} />
</DrawerHeader>
<DrawerBody>
<Text size={2} color="neutral">
Drawer body content navigation links, filters, or any other slot
the surrounding page composes.
</Text>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="solid" color="neutral" highContrast className="w-full">Done</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
);
}
export function BottomSheet() {
return (
<Drawer swipeDirection="down">
<DrawerTrigger render={<Button variant="soft" color="neutral" highContrast>Open bottom sheet</Button>} />
<DrawerPopup side="bottom">
<DrawerHeader>
<DrawerTitle>Options</DrawerTitle>
<DrawerClose render={<Button variant="soft" color="neutral" highContrast>Close</Button>} />
</DrawerHeader>
<DrawerBody>
<DrawerDescription>
Swipe down to dismiss.
</DrawerDescription>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="solid" color="neutral" highContrast className="w-full">Confirm</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
);
}
export function Controlled() {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col items-start gap-3">
<Button variant="soft" color="neutral" highContrast onClick={() => setOpen(true)}>
Open controlled drawer
</Button>
<Text size={1} color="faint">{open ? "open" : "closed"}</Text>
<Drawer open={open} onOpenChange={setOpen} swipeDirection="right">
<DrawerPopup side="right">
<DrawerHeader>
<DrawerTitle>Account</DrawerTitle>
</DrawerHeader>
<DrawerBody>
<Text size={2} color="neutral">
Controlled the same way as Base UI `open` / `onOpenChange`.
</Text>
</DrawerBody>
<DrawerFooter>
<Button variant="solid" color="neutral" highContrast className="w-full" onClick={() => setOpen(false)}>
Close
</Button>
</DrawerFooter>
</DrawerPopup>
</Drawer>
</div>
);
}
export function Skeleton() {
return (
<div className="flex h-80 justify-end border border-sand-6 bg-sand-2">
<DrawerSkeleton />
</div>
);
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer as BaseDrawer } from "@base-ui/react/drawer";
import type { ComponentProps } from "react";
// Root of the edge drawer. Controlled the same way as Base UI's Drawer:
// `open` / `onOpenChange`, plus `swipeDirection` for dismiss gestures. Pair
// `swipeDirection` with `DrawerPopup`'s `side` (right↔right, left↔left,
// bottom↔down, top↔up). See contrib/claude/ui.md.
export type DrawerProps = ComponentProps<typeof BaseDrawer.Root>;
export function Drawer(props: DrawerProps) {
return <BaseDrawer.Root {...props} />;
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { ComponentProps } from "react";
import { drawer } from "./variants";
// Main content region between the header and footer.
export function DrawerBody(props: ComponentProps<"div">) {
const { className, ...rest } = props;
const { body } = drawer();
return <div className={body({ className })} {...rest} />;
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer as BaseDrawer } from "@base-ui/react/drawer";
import type { ComponentProps } from "react";
// Closes the drawer. Pass `render` to reuse an existing control (e.g. an
// IconButton) as the close action, per Base UI's polymorphism.
export type DrawerCloseProps = ComponentProps<typeof BaseDrawer.Close>;
export function DrawerClose(props: DrawerCloseProps) {
return <BaseDrawer.Close {...props} />;
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer as BaseDrawer } from "@base-ui/react/drawer";
import type { ComponentProps } from "react";
import { drawer } from "./variants";
// Accessible drawer description (wires `aria-describedby` via Base UI).
export type DrawerDescriptionProps
= Omit<ComponentProps<typeof BaseDrawer.Description>, "className"> & {
className?: string;
};
export function DrawerDescription(props: DrawerDescriptionProps) {
const { className, ...rest } = props;
const { description } = drawer();
return <BaseDrawer.Description className={description({ className })} {...rest} />;
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { ComponentProps } from "react";
import { drawer } from "./variants";
// Footer region pinned to the bottom of the drawer for primary actions.
export function DrawerFooter(props: ComponentProps<"div">) {
const { className, ...rest } = props;
const { footer } = drawer();
return <div className={footer({ className })} {...rest} />;
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { ComponentProps } from "react";
import { drawer } from "./variants";
// Header region grouping the title (and optional close control).
export function DrawerHeader(props: ComponentProps<"div">) {
const { className, ...rest } = props;
const { header } = drawer();
return <div className={header({ className })} {...rest} />;
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer as BaseDrawer } from "@base-ui/react/drawer";
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants/lite";
import { drawer } from "./variants";
export type DrawerPopupProps
= & Omit<ComponentProps<typeof BaseDrawer.Popup>, "className">
& VariantProps<typeof drawer>
& {
className?: string;
};
// Portal + dimmed backdrop + edge-aligned popup. Children compose the header /
// body / footer regions inside Base UI's Content (swipe-safe text selection).
export function DrawerPopup(props: DrawerPopupProps) {
const { className, children, side, ...popupProps } = props;
const slots = drawer({ side });
return (
<BaseDrawer.Portal>
<BaseDrawer.Backdrop className={slots.backdrop()} />
<BaseDrawer.Viewport className={slots.viewport()}>
<BaseDrawer.Popup className={slots.popup({ className })} {...popupProps}>
<BaseDrawer.Content className={slots.content()}>
{children}
</BaseDrawer.Content>
</BaseDrawer.Popup>
</BaseDrawer.Viewport>
</BaseDrawer.Portal>
);
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { TextSkeleton } from "../typography/TextSkeleton";
import { drawer, drawerSkeleton } from "./variants";
// Loading placeholder matching the drawer frame (header + body + footer),
// importing only variants and skeleton primitives — never Base UI.
export function DrawerSkeleton() {
const { header, body, footer } = drawer();
return (
<div className={drawerSkeleton()} aria-hidden>
<div className={header()}>
<TextSkeleton size={4} className="w-24" />
<TextSkeleton size={2} className="w-8" />
</div>
<div className={body()}>
<TextSkeleton size={2} className="w-full" />
<TextSkeleton size={2} className="w-3/4" />
<TextSkeleton size={2} className="w-5/6" />
</div>
<div className={footer()}>
<TextSkeleton size={2} className="w-full" />
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer as BaseDrawer } from "@base-ui/react/drawer";
import type { ComponentProps } from "react";
import { drawer } from "./variants";
// Accessible drawer title (wires `aria-labelledby` via Base UI).
export type DrawerTitleProps = Omit<ComponentProps<typeof BaseDrawer.Title>, "className"> & {
className?: string;
};
export function DrawerTitle(props: DrawerTitleProps) {
const { className, ...rest } = props;
const { title } = drawer();
return <BaseDrawer.Title className={title({ className })} {...rest} />;
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Drawer as BaseDrawer } from "@base-ui/react/drawer";
import type { ComponentProps } from "react";
// Opens the drawer. Pass `render` to use an existing control (e.g. an
// IconButton) as the trigger, per Base UI's polymorphism.
export type DrawerTriggerProps = ComponentProps<typeof BaseDrawer.Trigger>;
export function DrawerTrigger(props: DrawerTriggerProps) {
return <BaseDrawer.Trigger {...props} />;
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Edge drawer over Base UI. Positioning lives in CSS (`side`); pass the matching
// `swipeDirection` on `Drawer` (right↔right, left↔left, bottom↔down, top↔up).
export const drawer = 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",
popup: [
"relative flex flex-col bg-sand-1 shadow-6 outline-none",
"transition-transform duration-200",
],
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",
description: "text-2 text-sand-11",
body: "flex min-h-0 flex-1 flex-col gap-1",
footer: "mt-auto flex flex-col gap-2 border-t border-sand-a3 pt-4",
},
variants: {
side: {
right: {
viewport: "justify-end",
popup: [
"h-full w-[min(20rem,100%)]",
"transform-[translateX(var(--drawer-swipe-movement-x,0px))]",
"data-starting-style:translate-x-full data-ending-style:translate-x-full",
],
},
left: {
viewport: "justify-start",
popup: [
"h-full w-[min(20rem,100%)]",
"transform-[translateX(var(--drawer-swipe-movement-x,0px))]",
"data-starting-style:-translate-x-full data-ending-style:-translate-x-full",
],
},
bottom: {
viewport: "items-end justify-center",
popup: [
"w-full max-h-[min(90dvh,100%)] rounded-t-5",
"transform-[translateY(var(--drawer-swipe-movement-y,0px))]",
"data-starting-style:translate-y-full data-ending-style:translate-y-full",
],
},
top: {
viewport: "items-start justify-center",
popup: [
"w-full max-h-[min(90dvh,100%)] rounded-b-5",
"transform-[translateY(var(--drawer-swipe-movement-y,0px))]",
"data-starting-style:-translate-y-full data-ending-style:-translate-y-full",
],
},
},
},
defaultVariants: {
side: "right",
},
});
// Static frame matching the drawer popup, without Base UI positioning, so a
// placeholder can render before the interactive drawer loads.
export const drawerSkeleton = tv({
base: [
"flex h-full w-[min(20rem,100%)] flex-col gap-4 bg-sand-1 p-4 shadow-6",
],
});