组件
可调整面板 Resizable
通过指针或键盘在两个工作区之间调整可用空间。
基础用法
Loading…
pnpm dlx @wui-design/cli@latest add @wui/resizable"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 的顺序组合,并为面板组提供明确高度;面板内容默认可独立滚动。
属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| withHandle | boolean | false | 在手柄中央显示抓取指示器。@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 顺序中移除。