// ~/src/siteplane/components/drawer/drawer.tsx
"use client";
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { ChevronRightIcon, XIcon } from "lucide-react";
import type React from "react";
import { createContext, useContext } from "react";
import { cn, type WithStringClassName } from "../../system/utils/cn";
import { slotClass } from "../../system/utils/slot-class";
import { surfaceClassNames } from "../../system/styles/surface";
import { KitButton } from "../button/button-internal";
import {
menuGroupLabelClassName,
menuSeparatorClassName,
} from "./drawer-menu-shared";
import { type CheckboxProps } from "../checkbox";
import { KitCheckbox } from "../checkbox/checkbox-internal";
import { type RadioGroupProps, type RadioProps } from "../radio-group";
import { KitRadioGroup, KitRadio } from "../radio-group/radio-group-internal";
import { ScrollArea } from "../scroll-area";
import { type SwitchProps } from "../switch";
import { KitSwitch } from "../switch/switch-internal";
import {
type SiteplaneControlSize,
SiteplaneUIPortalScope,
useResolvedControlSize,
} from "../../system/provider/siteplane-ui-provider";
import "./drawer.css";
type DrawerPosition = "right" | "left" | "top" | "bottom";
const DrawerContext: React.Context<{ position: DrawerPosition }> =
createContext<{ position: DrawerPosition }>({
position: "bottom",
});
const directionMap: Record<
DrawerPosition,
DrawerPrimitive.Root.Props["swipeDirection"]
> = {
bottom: "down",
left: "left",
right: "right",
top: "up",
};
export const DrawerCreateHandle: typeof DrawerPrimitive.createHandle =
DrawerPrimitive.createHandle;
export function Drawer({
swipeDirection,
position = "bottom",
...props
}: DrawerPrimitive.Root.Props & {
position?: DrawerPosition;
}): React.ReactElement {
return (
);
}
// Base UI's own Portal type, with className narrowed to a string.
export const DrawerPortal: (
props: WithStringClassName> &
React.RefAttributes,
) => React.JSX.Element | null = DrawerPrimitive.Portal;
export function DrawerTrigger(
props: WithStringClassName,
): React.ReactElement {
return (
);
}
export function DrawerClose(
props: WithStringClassName,
): React.ReactElement {
return (
);
}
export function DrawerSwipeArea({
className,
position: positionProp,
...props
}: WithStringClassName & {
position?: DrawerPosition;
}): React.ReactElement {
const { position: contextPosition } = useContext(DrawerContext);
const position = positionProp ?? contextPosition;
return (
);
}
export function DrawerBackdrop({
className,
...props
}: WithStringClassName): React.ReactElement {
return (
);
}
export function DrawerViewport({
className,
position,
variant = "default",
...props
}: WithStringClassName & {
position?: DrawerPosition;
variant?: "default" | "straight" | "inset";
}): React.ReactElement {
return (
);
}
export function DrawerPopup({
className,
children,
elevation = "default",
showCloseButton = false,
position: positionProp,
variant = "default",
width,
showBar = false,
closeProps,
portalProps,
...props
}: WithStringClassName & {
elevation?: "default" | "none";
showCloseButton?: boolean;
position?: DrawerPosition;
variant?: "default" | "straight" | "inset";
/**
* Width of a bottom or top panel on a wide screen. `readable` caps it at
* `max-w-lg` (512 px) and centres it; `full` spans the whole edge. A `straight` panel stays attached along the whole edge, so it is
* `full` unless you ask for `readable`. Left and right panels keep their own
* width.
*/
width?: "readable" | "full";
showBar?: boolean;
closeProps?: WithStringClassName;
portalProps?: WithStringClassName;
}): React.ReactElement {
const { position: contextPosition } = useContext(DrawerContext);
const position = positionProp ?? contextPosition;
const resolvedElevation = variant === "straight" ? "none" : elevation;
const resolvedWidth = width ?? (variant === "straight" ? "full" : "readable");
return (
{children}
{/* The grab strip spans its whole edge; the close button comes
after it, so it paints and takes clicks above the strip. */}
{showBar && }
{showCloseButton && (
}
{...closeProps}
>
)}
);
}
// Base UI's bare content part: in it a pointer selects text instead of
// swiping the drawer. The header, footer and panel take it with
// allowSelection.
const SelectableContent = DrawerPrimitive.Content;
export function DrawerHeader({
className,
allowSelection = false,
render,
...props
}: useRender.ComponentProps<"div"> & {
allowSelection?: boolean;
}): React.ReactElement {
const defaultProps = {
className: cn(
slotClass("drawer-header", props, render),
"flex flex-col",
!allowSelection && "cursor-default",
className,
),
"data-slot": "drawer-header",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render: allowSelection ? : render,
});
}
export function DrawerFooter({
className,
allowSelection = true,
render,
...props
}: useRender.ComponentProps<"div"> & {
allowSelection?: boolean;
}): React.ReactElement {
const defaultProps = {
className: cn(
// Actions that do not fit wrap onto another row with the same gap instead
// of overflowing a narrow panel.
slotClass("drawer-footer", props, render),
"flex flex-wrap",
!allowSelection && "cursor-default",
className,
),
"data-slot": "drawer-footer",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render: allowSelection ? : render,
});
}
export function DrawerTitle({
className,
...props
}: WithStringClassName): React.ReactElement {
return (
);
}
export function DrawerDescription({
className,
...props
}: WithStringClassName): React.ReactElement {
return (
);
}
export function DrawerPanel({
className,
scrollFade = true,
scrollable = true,
allowSelection = true,
render,
...props
}: useRender.ComponentProps<"div"> & {
scrollFade?: boolean;
scrollable?: boolean;
allowSelection?: boolean;
}): React.ReactElement {
const defaultProps = {
className: cn(
slotClass("drawer-panel", props, render),
!allowSelection && "cursor-default",
className,
),
"data-slot": "drawer-panel",
};
const content = useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render: allowSelection ? : render,
});
if (scrollable) {
return (
{content}
);
}
return content;
}
export function DrawerBar({
className,
position: positionProp,
render,
...props
}: useRender.ComponentProps<"div"> & {
position?: DrawerPosition;
}): React.ReactElement {
const { position: contextPosition } = useContext(DrawerContext);
const position = positionProp ?? contextPosition;
const horizontal = position === "left" || position === "right";
const defaultProps = {
"aria-hidden": true as const,
className: cn(
slotClass("drawer-bar", props, render),
"absolute flex cursor-grab touch-none items-center justify-center p-3 active:cursor-grabbing before:rounded-full",
horizontal
? "inset-y-0 before:h-12 before:w-1"
: "inset-x-0 before:h-1 before:w-12",
position === "top" && "bottom-0",
position === "bottom" && "top-0",
position === "left" && "right-0",
position === "right" && "left-0",
className,
),
"data-slot": "drawer-bar",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function DrawerMenu({
className,
render,
size,
...props
}: useRender.ComponentProps<"nav"> & {
/**
* The row size; it follows the provider's control size. `xl` is the touch
* size for navigation and sheets that are tapped: 48px rows with 16px text
* and 20px icons. The other sizes keep the compact menu rows, which a touch
* screen turns into 48px rows as well.
*/
size?: SiteplaneControlSize;
}): React.ReactElement {
const resolvedSize = useResolvedControlSize(size);
const defaultProps = {
className: cn(
slotClass("drawer-menu", props, render),
"-m-2 flex flex-col",
className,
),
"data-size": resolvedSize,
"data-slot": "drawer-menu",
};
return useRender({
defaultTagName: "nav",
props: mergeProps<"nav">(defaultProps, props),
render,
});
}
export function DrawerMenuItem({
className,
variant = "default",
render,
disabled,
...props
}: useRender.ComponentProps<"button"> & {
variant?: "default" | "destructive";
}): React.ReactElement {
return (
);
}
export function DrawerMenuSeparator({
className,
render,
...props
}: useRender.ComponentProps<"div">): React.ReactElement {
const defaultProps = {
className: cn(
slotClass("drawer-menu-separator", props, render),
menuSeparatorClassName,
className,
),
"data-slot": "drawer-menu-separator",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function DrawerMenuGroup({
className,
render,
...props
}: useRender.ComponentProps<"div">): React.ReactElement {
const defaultProps = {
className: cn(
slotClass("drawer-menu-group", props, render),
"flex flex-col",
className,
),
"data-slot": "drawer-menu-group",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function DrawerMenuGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div">): React.ReactElement {
const defaultProps = {
className: cn(
slotClass("drawer-menu-group-label", props, render),
menuGroupLabelClassName,
className,
),
"data-slot": "drawer-menu-group-label",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function DrawerMenuTrigger({
className,
children,
...props
}: Omit<
WithStringClassName,
"render"
>): React.ReactElement {
return (
}
{...props}
>
{children}
);
}
export function DrawerMenuCheckboxItem({
variant = "default",
...props
}: Omit & {
variant?: "default" | "switch";
}): React.ReactElement {
if (variant === "switch") {
return (
);
}
return (
);
}
export function DrawerMenuRadioGroup({
className,
...props
}: Omit): React.ReactElement {
return (
);
}
export function DrawerMenuRadioItem({
indicatorPosition = "start",
...props
}: Omit & {
value: string;
/**
* Where the row shows its check: in the leading slot (`start`), or at the
* end of the row (`end`) for identity rows that lead with a logo or an
* avatar, such as the workspaces of a switcher.
*/
indicatorPosition?: "start" | "end" | undefined;
}): React.ReactElement {
return (
);
}
// DrawerContent is the shadcn name of DrawerPopup, as DialogContent is of
// DialogPopup. Base UI's bare content part is DrawerPrimitive.Content.
export { DrawerPrimitive, DrawerPopup as DrawerContent };
// ~/src/siteplane/components/drawer/drawer.css
@layer theme, base, components, siteplane, utilities;
@layer siteplane {
/* The panel slides from its edge and the backdrop fades with it, on the
* panel time and curve that Sheet shares (rulebook M1). A panel that is let
* go after a swipe settles within the release time, scaled by the swipe's
* strength; the backdrop follows the finger without a transition. A bottom
* panel at rest also eases its margin and padding as its snap point
* changes. Zero specificity, so the reduced-motion rules below and a
* consumer's transition utility both win. */
:where(.sp-drawer-popup) {
transition-property: transform, box-shadow, height, background-color;
transition-duration: var(--motion-duration-panel);
transition-timing-function: var(--motion-ease-panel);
}
:where(
.sp-drawer-popup[data-position="bottom"]:not(
[data-starting-style],
[data-ending-style]
)
) {
transition-property:
transform, box-shadow, height, background-color, margin, padding;
}
:where(.sp-drawer-backdrop) {
transition-property: opacity;
transition-duration: var(--motion-duration-panel);
transition-timing-function: var(--motion-ease-panel);
}
:where(:is(.sp-drawer-popup, .sp-drawer-backdrop)[data-ending-style]) {
transition-duration: calc(
var(--drawer-swipe-strength) * var(--motion-duration-panel-release)
);
}
:where(.sp-drawer-backdrop[data-swiping]) {
transition-duration: 0ms;
}
/* Reduced motion opens and closes the panel and its backdrop at once (M5):
* the document mode, the provider preset and the system setting. */
html[data-motion-reduce="true"] .sp-drawer-popup,
html[data-motion-reduce="true"] .sp-drawer-backdrop,
[data-sp-motion="reduced"] .sp-drawer-popup,
[data-sp-motion="reduced"] .sp-drawer-backdrop {
transition-duration: 1ms;
}
@media (prefers-reduced-motion: reduce) {
:root .sp-drawer-popup,
:root .sp-drawer-backdrop {
transition-duration: 1ms;
}
}
/* DrawerMenu touch size (size="xl").
*
* The shared menu-row recipe in design-rework.css is the compact one: 32px rows
* with 14px text and 16px icons, which only a coarse pointer turns into 48px
* rows. Navigation and sheets that are tapped ask for the touch size whatever
* the pointer: 48px rows, 16px text and a 20px icon slot, as the Sidebar rows at
* size xl.
*
* Every row kind takes the slot through the same two tokens, so the labels of
* icon, check, radio, switch and submenu rows still start at the same x
* (rulebook I8): the icon width is the width of the check and radio column and
* of the slot a switch row keeps, and the Button content gap is the gap of all
* of them. The row padding stays 8px, which the menu's negative margin
* offsets.
*
* The extra `[data-slot]` ranks the row rule above the menu-row profile of the
* Button, so the two files can load in any order. */
:root .sp-drawer-menu[data-size="xl"] {
--sp-rw-menu-row-icon: var(--sp-rw-control-icon-xl);
--sp-rw-button-content-gap: var(--sp-rw-space-2-5);
}
:root .sp-drawer-menu[data-size="xl"] [data-slot].sp-drawer-menu-item,
:root
.sp-drawer-menu[data-size="xl"]
[data-slot].sp-drawer-menu-checkbox-item,
:root .sp-drawer-menu[data-size="xl"] [data-slot].sp-drawer-menu-radio-item,
:root .sp-drawer-menu[data-size="xl"] [data-slot].sp-drawer-menu-trigger {
min-height: var(--sp-rw-control-xl);
border-radius: var(--sp-rw-radius-surface);
padding-block: var(--sp-rw-space-3);
font-size: var(--sp-rw-font-16);
line-height: var(--sp-rw-line-24);
}
:root .sp-drawer-menu[data-size="xl"] .sp-drawer-menu-group-label {
padding-block: var(--sp-rw-space-2);
font-size: var(--sp-rw-font-13);
line-height: var(--sp-rw-line-18);
}
:root .sp-drawer-menu[data-size="xl"] .sp-drawer-menu-separator {
margin-block: var(--sp-rw-space-2);
}
/* The current page of a navigation. The row keeps the persistent fill the
* Sidebar gives its active row (the pressed glow) and the full foreground, also
* under the pointer, so the hover never hides where you are (rulebook T5). */
:root
.sp-drawer-menu-item[data-chassis][data-variant="text"][aria-current="page"]:not(
:disabled,
[aria-disabled="true"]
) {
background: var(--sp-rw-glow-pressed);
color: var(--sp-rw-fg-primary);
}
:root
.sp-drawer-menu-item[aria-current="page"]:not(
:disabled,
[aria-disabled="true"]
)
svg {
color: var(--sp-rw-fg-primary);
}
}
// ~/src/siteplane/components/drawer/drawer-menu-shared.tsx
export {
menuGroupLabelClassName,
menuItemClassName,
menuPopupClassName,
menuPopupWidthClassNames,
menuSeparatorClassName,
menuShortcutClassName,
menuSubTriggerClassName,
type MenuPopupWidth,
} from "../menu/menu-shared";
// ~/src/siteplane/components/drawer/index.ts
export * from "./drawer";