// ~/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";