wui
组件

可调整面板 Resizable

通过指针或键盘在两个工作区之间调整可用空间。

第三方依赖 · lucide-react

基础用法

Loading…
pnpm dlx @wui-design/cli@latest add @wui/resizable
components/ui/resizable.tsx
"use client"

import * as React from "react"
import { GripVerticalIcon } from "lucide-react"

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

type ResizableOrientation = "horizontal" | "vertical"

interface ResizableContextValue {
  disabled: boolean
  dragging: boolean
  setDragging: (dragging: boolean) => void
  maxSize: number
  minSize: number
  orientation: ResizableOrientation
  setSize: (size: number) => void
  size: number
  step: number
}

const ResizableContext = React.createContext<ResizableContextValue | null>(null)

function useResizableContext() {
  const context = React.useContext(ResizableContext)
  if (!context) {
    throw new Error("Resizable 子组件必须位于 ResizablePanelGroup 内")
  }
  return context
}

export interface ResizablePanelGroupProps extends Omit<
  React.ComponentProps<"div">,
  "onChange"
> {
  /** 首个面板的受控尺寸百分比。 */
  size?: number
  /** 首个面板的初始尺寸百分比。@default 50 */
  defaultSize?: number
  /** 首个面板允许的最小尺寸百分比。@default 20 */
  minSize?: number
  /** 首个面板允许的最大尺寸百分比。@default 80 */
  maxSize?: number
  /** 面板排列与拖动方向。@default "horizontal" */
  orientation?: ResizableOrientation
  /** 键盘方向键每次调整的百分比。@default 2 */
  step?: number
  /** 禁止指针与键盘调整。@default false */
  disabled?: boolean
  /** 首个面板尺寸变化时触发。 */
  onSizeChange?: (size: number) => void
}

/** 协调两个面板及其拖动手柄的尺寸。 */
function ResizablePanelGroup({
  className,
  children,
  size: controlledSize,
  defaultSize = 50,
  minSize = 20,
  maxSize = 80,
  orientation = "horizontal",
  step = 2,
  disabled = false,
  onSizeChange,
  style,
  ...props
}: ResizablePanelGroupProps) {
  const lowerBound = Math.min(minSize, maxSize)
  const upperBound = Math.max(minSize, maxSize)
  const clamp = React.useCallback(
    (next: number) => Math.min(upperBound, Math.max(lowerBound, next)),
    [lowerBound, upperBound]
  )
  const [internalSize, setInternalSize] = React.useState(() =>
    clamp(defaultSize)
  )
  const currentSize = clamp(controlledSize ?? internalSize)
  const [dragging, setDragging] = React.useState(false)

  const setSize = React.useCallback(
    (next: number) => {
      const clamped = clamp(next)
      if (controlledSize === undefined) setInternalSize(clamped)
      onSizeChange?.(clamped)
    },
    [clamp, controlledSize, onSizeChange]
  )

  const context = React.useMemo(
    () => ({
      disabled,
      dragging,
      setDragging,
      maxSize: upperBound,
      minSize: lowerBound,
      orientation,
      setSize,
      size: currentSize,
      step: Math.max(0.1, step),
    }),
    [currentSize, disabled, dragging, lowerBound, orientation, setSize, step, upperBound]
  )

  return (
    <ResizableContext.Provider value={context}>
      <div
        data-slot="resizable-panel-group"
        data-orientation={orientation}
        data-dragging={dragging || undefined}
        className={cn(
          "flex size-full min-h-0 min-w-0 overflow-hidden",
          orientation === "vertical" && "flex-col",
          className
        )}
        style={
          {
            "--resizable-primary-size": `${currentSize}%`,
            ...style,
          } as React.CSSProperties
        }
        {...props}
      >
        {children}
      </div>
    </ResizableContext.Provider>
  )
}

/**
 * ResizablePanelGroup 内的内容面板;首个面板由手柄调整尺寸。
 * 键盘调整时尺寸平滑过渡,拖动期间跟手无延迟。
 */
function ResizablePanel({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="resizable-panel"
      className={cn(
        "min-h-0 min-w-0 flex-1 overflow-auto first:grow-0 first:basis-[var(--resizable-primary-size)] first:transition-[flex-basis] first:duration-200 first:ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none [[data-dragging]>&]:transition-none",
        className
      )}
      {...props}
    />
  )
}

export interface ResizableHandleProps extends React.ComponentProps<"div"> {
  /** 在手柄中央显示抓取指示器。@default false */
  withHandle?: boolean
}

/** 支持指针拖动与方向键调整的面板分隔手柄。 */
function ResizableHandle({
  className,
  withHandle = false,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onLostPointerCapture,
  onKeyDown,
  ...props
}: ResizableHandleProps) {
  const context = useResizableContext()
  const group = React.useRef<HTMLElement | null>(null)

  function updateFromPointer(event: React.PointerEvent<HTMLDivElement>) {
    if (
      !event.currentTarget.hasPointerCapture(event.pointerId) ||
      !group.current
    )
      return
    const rect = group.current.getBoundingClientRect()
    const next =
      context.orientation === "horizontal"
        ? ((event.clientX - rect.left) / rect.width) * 100
        : ((event.clientY - rect.top) / rect.height) * 100
    context.setSize(next)
  }

  return (
    <div
      data-slot="resizable-handle"
      data-orientation={context.orientation}
      data-disabled={context.disabled || undefined}
      data-dragging={context.dragging || undefined}
      role="separator"
      aria-orientation={context.orientation}
      aria-valuemin={context.minSize}
      aria-valuemax={context.maxSize}
      aria-valuenow={Math.round(context.size)}
      aria-disabled={context.disabled}
      tabIndex={context.disabled ? -1 : 0}
      className={cn(
        "group/handle bg-border relative z-10 flex shrink-0 touch-none select-none items-center justify-center outline-none transition-colors duration-200",
        // 1px visual line with a wider invisible hit area so it is easy to grab.
        "after:absolute after:content-[''] data-[orientation=horizontal]:w-px data-[orientation=horizontal]:after:inset-y-0 data-[orientation=horizontal]:after:-inset-x-1.5 data-[orientation=vertical]:h-px data-[orientation=vertical]:after:inset-x-0 data-[orientation=vertical]:after:-inset-y-1.5",
        "data-[orientation=horizontal]:cursor-col-resize data-[orientation=vertical]:cursor-row-resize hover:bg-primary/50 focus-visible:bg-primary data-[dragging]:bg-primary data-[disabled]:cursor-not-allowed data-[disabled]:bg-border data-[disabled]:opacity-50 [&[data-orientation=vertical]>div]:rotate-90",
        className
      )}
      onPointerDown={(event) => {
        onPointerDown?.(event)
        if (event.defaultPrevented || context.disabled) return
        group.current = event.currentTarget.parentElement
        event.currentTarget.setPointerCapture(event.pointerId)
        context.setDragging(true)
        event.preventDefault()
      }}
      onPointerUp={(event) => {
        onPointerUp?.(event)
        if (event.currentTarget.hasPointerCapture(event.pointerId))
          event.currentTarget.releasePointerCapture(event.pointerId)
      }}
      onLostPointerCapture={(event) => {
        onLostPointerCapture?.(event)
        context.setDragging(false)
      }}
      onPointerMove={(event) => {
        onPointerMove?.(event)
        if (!event.defaultPrevented && !context.disabled)
          updateFromPointer(event)
      }}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        if (event.defaultPrevented || context.disabled) return
        const decrementKey =
          context.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp"
        const incrementKey =
          context.orientation === "horizontal" ? "ArrowRight" : "ArrowDown"
        if (event.key === decrementKey)
          context.setSize(context.size - context.step)
        else if (event.key === incrementKey)
          context.setSize(context.size + context.step)
        else if (event.key === "Home") context.setSize(context.minSize)
        else if (event.key === "End") context.setSize(context.maxSize)
        else return
        event.preventDefault()
      }}
      {...props}
    >
      {withHandle ? (
        <div
          data-slot="resizable-handle-grip"
          className="bg-background text-muted-foreground relative z-10 flex h-7 w-4 items-center justify-center rounded-sm border transition-[color,border-color,scale] duration-200 group-hover/handle:text-foreground group-focus-visible/handle:border-primary group-focus-visible/handle:text-primary group-data-[dragging]/handle:scale-110 group-data-[dragging]/handle:border-primary group-data-[dragging]/handle:text-primary group-data-[disabled]/handle:text-muted-foreground motion-reduce:transition-none"
        >
          <GripVerticalIcon className="size-3" aria-hidden="true" />
        </div>
      ) : null}
    </div>
  )
}

export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

使用场景

Resizable 适合编辑器、文件管理器、预览器等需要由用户分配空间的双面板工作区。它不适合普通内容页,也不应替代响应式断点:窄屏下通常应切换为抽屉或上下布局。

组件聚焦于两个直接面板。请按 ResizablePanel → ResizableHandle → ResizablePanel 的顺序组合,并为面板组提供明确高度;面板内容默认可独立滚动。

属性

属性类型默认值说明
withHandlebooleanfalse在手柄中央显示抓取指示器。@default false
  • size、defaultSize 与 onSizeChange 管理首个面板的百分比尺寸。
  • minSize 与 maxSize 限制首个面板可调整范围。
  • orientation 支持 horizontal 与 vertical。
  • step 控制方向键单次调整幅度。
  • ResizableHandle 的 withHandle 用于显示可抓取指示器。

交互反馈

  • 分隔线视觉宽度为 1px,但两侧各扩展 6px 的隐形热区,便于精确抓取。
  • 悬停时分隔线转为半透明主色,拖动或键盘聚焦时转为主色,抓取指示器同步描边高亮并轻微放大;拖动期间面板组带有 data-dragging 属性,可用于自定义样式。
  • 通过方向键、Home / End 或外部受控 size 调整时,首个面板尺寸以 200ms 缓出曲线平滑过渡;指针拖动时关闭过渡,保证跟手。系统开启“减少动态效果”时所有过渡自动关闭。

扩展用法

受控的纵向面板

Loading…

受控模式适合显示当前比例、保存用户偏好或与其他布局状态同步;示例中的快捷按钮直接修改 size,面板会平滑过渡到目标比例。拖动过程中会连续触发 onSizeChange,业务层如需持久化,建议在外部进行节流或仅在交互结束后保存。

无障碍

手柄使用 separator 角色并暴露当前、最小与最大值。聚焦手柄后,方向键按 step 调整,Home 与 End 分别移动到最小和最大尺寸。禁用时手柄会从 Tab 顺序中移除。