wui
组件

抽屉 Drawer

从视口边缘平滑进入、用于承载补充任务的焦点面板。

基础示例

Loading…
pnpm dlx wui@latest add @wui/drawer
components/ui/drawer.tsx
"use client"

import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DrawerPrimitive } from "radix-ui"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"

type DrawerContextValue = {
  open: boolean
}

const DrawerContext = React.createContext<DrawerContextValue | null>(null)
const MotionOverlay = motion.create(DrawerPrimitive.Overlay)
const MotionContent = motion.create(DrawerPrimitive.Content)

function useDrawerContext() {
  const context = React.useContext(DrawerContext)
  if (!context) throw new Error("Drawer parts must be used inside <Drawer>.")
  return context
}

export interface DrawerProps extends React.ComponentProps<
  typeof DrawerPrimitive.Root
> {
  /** Controlled visibility state. */
  open?: boolean
  /** Initial visibility in uncontrolled mode. @default false */
  defaultOpen?: boolean
  /** Called whenever the drawer requests a visibility change. */
  onOpenChange?: (open: boolean) => void
  /** Trap focus and disable interaction outside the panel. @default true */
  modal?: boolean
}

function Drawer({
  open: openProp,
  defaultOpen,
  onOpenChange,
  children,
  ...props
}: DrawerProps) {
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false)
  const open = openProp ?? internalOpen

  const handleOpenChange = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setInternalOpen(next)
      onOpenChange?.(next)
    },
    [onOpenChange, openProp]
  )

  return (
    <DrawerContext.Provider value={{ open }}>
      <DrawerPrimitive.Root
        data-slot="drawer"
        open={open}
        onOpenChange={handleOpenChange}
        {...props}
      >
        {children}
      </DrawerPrimitive.Root>
    </DrawerContext.Provider>
  )
}

function DrawerTrigger(
  props: React.ComponentProps<typeof DrawerPrimitive.Trigger>
) {
  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}

function DrawerClose(
  props: React.ComponentProps<typeof DrawerPrimitive.Close>
) {
  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}

function DrawerPortal(
  props: React.ComponentProps<typeof DrawerPrimitive.Portal>
) {
  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}

export interface DrawerContentProps extends React.ComponentProps<
  typeof DrawerPrimitive.Content
> {
  /** Edge from which the panel enters. @default "right" */
  side?: "top" | "right" | "bottom" | "left"
  /** Panel width or height preset. @default "default" */
  size?: "sm" | "default" | "lg" | "full"
  /** Hide the built-in close button. @default false */
  hideClose?: boolean
}

/** A focus-managed edge panel with spring-based enter and exit motion. */
function DrawerContent({
  className,
  children,
  side = "right",
  size = "default",
  hideClose = false,
  ...props
}: DrawerContentProps) {
  const { open } = useDrawerContext()
  const reduceMotion = useReducedMotion()
  const horizontal = side === "left" || side === "right"

  const hidden = reduceMotion
    ? { opacity: 0 }
    : {
        opacity: 0.72,
        x: side === "left" ? "-100%" : side === "right" ? "100%" : 0,
        y: side === "top" ? "-100%" : side === "bottom" ? "100%" : 0,
        scale: 0.985,
      }

  const placement = {
    top: "inset-x-0 top-0 border-b",
    right: "inset-y-0 right-0 border-l",
    bottom: "inset-x-0 bottom-0 border-t",
    left: "inset-y-0 left-0 border-r",
  }[side]

  const dimensions = horizontal
    ? {
        sm: "w-[min(20rem,calc(100vw-1rem))]",
        default: "w-[min(26rem,calc(100vw-1rem))]",
        lg: "w-[min(38rem,calc(100vw-1rem))]",
        full: "w-screen",
      }[size]
    : {
        sm: "h-[min(16rem,calc(100vh-1rem))]",
        default: "h-[min(24rem,calc(100vh-1rem))]",
        lg: "h-[min(36rem,calc(100vh-1rem))]",
        full: "h-screen",
      }[size]

  return (
    <AnimatePresence>
      {open ? (
        <DrawerPortal forceMount>
          <MotionOverlay
            forceMount
            data-slot="drawer-overlay"
            className="bg-overlay fixed inset-0 z-50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
          />
          <MotionContent
            forceMount
            data-slot="drawer-content"
            data-side={side}
            data-size={size}
            className={cn(
              "bg-background fixed z-50 flex flex-col shadow-xl outline-none",
              placement,
              dimensions,
              className
            )}
            initial={hidden}
            animate={{ opacity: 1, x: 0, y: 0, scale: 1 }}
            exit={hidden}
            transition={
              reduceMotion
                ? { duration: 0 }
                : { type: "spring", stiffness: 360, damping: 38, mass: 0.82 }
            }
            {...(props as unknown as React.ComponentProps<
              typeof MotionContent
            >)}
          >
            {children}
            {hideClose ? null : (
              <DrawerPrimitive.Close className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 absolute right-4 top-4 flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2">
                <XIcon className="size-4" />
                <span className="sr-only">Close</span>
              </DrawerPrimitive.Close>
            )}
          </MotionContent>
        </DrawerPortal>
      ) : null}
    </AnimatePresence>
  )
}

function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-header"
      className={cn("grid gap-1.5 border-b px-5 py-4 pr-14", className)}
      {...props}
    />
  )
}

function DrawerBody({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-body"
      className={cn("min-h-0 flex-1 overflow-y-auto px-5 py-4", className)}
      {...props}
    />
  )
}

function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-footer"
      className={cn(
        "mt-auto flex flex-col-reverse gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end",
        className
      )}
      {...props}
    />
  )
}

function DrawerTitle({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  return (
    <DrawerPrimitive.Title
      data-slot="drawer-title"
      className={cn("text-base font-semibold tracking-tight", className)}
      {...props}
    />
  )
}

function DrawerDescription({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  return (
    <DrawerPrimitive.Description
      data-slot="drawer-description"
      className={cn("text-muted-foreground text-sm leading-5", className)}
      {...props}
    />
  )
}

export {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerPortal,
  DrawerTitle,
  DrawerTrigger,
}

组件作用

Drawer 适合在不离开当前页面的情况下查看详情、编辑设置或完成一段短流程。它保留底层页面上下文,并提供焦点圈定、Esc 关闭和遮罩点击;需要用户集中确认的高风险操作应使用 Dialog。

组件属性

PropTypeDefaultDescription
openbooleanControlled visibility state.
defaultOpenbooleanfalseInitial visibility in uncontrolled mode.
onOpenChange((open: boolean) => void)Called whenever the drawer requests a visibility change.
modalbooleantrueTrap focus and disable interaction outside the panel.

DrawerContent 提供 sidetoprightbottomleft)、sizesmdefaultlgfull)与 hideClose

事件

根组件沿用 Radix Dialog 的 opendefaultOpenonOpenChange。进出场由同一组大面板弹簧驱动,关闭时按原路径返回;减少动态效果开启后直接呈现最终位置。

扩展使用

使用 DrawerHeaderDrawerBodyDrawerFooter 建立稳定的滚动区域。移动端的长表单建议使用 side="bottom"size="full",桌面详情面板通常从右侧进入。