radix-vue 中 DrawerContent 深度解析:Props、事件与焦点、滑动手势的底层实现
【免费下载链接】radix-vueAn open-source UI component library for building high-quality, accessible design systems and web apps for Vue. Previously Radix Vue项目地址: https://gitcode.com/GitHub_Trending/ra/radix-vue
本篇围绕 radix-vue 的DrawerContent组件展开,完整覆盖其 API 参考(Props 与 Events 全量参数表),并结合仓库中packages/core/src/Drawer/下的真实源码,深入讲解模态分级(modal / 'trap-focus' / false)、焦点陷阱与焦点恢复、DismissableLayer 外置事件、滑动关闭(swipe-to-dismiss)与 CSS 自定义属性等底层机制。读完你可以直接照抄可运行的动画 CSS,并理解每个参数在源码中的实际作用路径。
一、DrawerContent 在 Drawer 体系中的位置
Drawer是 radix-vue(原 Radix Vue,组件从reka-ui包导入)中提供的一组"从屏幕边缘滑出的面板"原语,支持滑动手势关闭、吸附点(snap points)与嵌套抽屉。官方组件文档见 docs/content/docs/components/drawer.md。
DrawerContent是整个体系中承担"面板本体"职责的部件,官方文档对其定位是:
Contains the content to be rendered in the open drawer. Owns the swipe gesture and exposes the drag offset through CSS custom properties. Also aliased as
DrawerPopupfor Base UI parity.
也就是说,它负责三件事:渲染打开后的抽屉内容、拥有滑动手势、通过 CSS 自定义属性把拖拽偏移量暴露给外部样式。典型结构如下(摘自 drawer.md 的 Anatomy 一节):
<script setup> import { DrawerClose, DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle, DrawerTrigger, } from 'reka-ui' </script> <template> <DrawerRoot> <DrawerTrigger /> <DrawerPortal> <DrawerOverlay /> <DrawerContent> <DrawerHandle /> <DrawerTitle /> <DrawerDescription /> <DrawerClose /> </DrawerContent> </DrawerPortal> </DrawerRoot> </template>源码结构:Presence 外壳 + Impl 内核
从源码结构看,DrawerContent采用 radix-vue 惯用的"外壳 + Impl"双层拆分:
- DrawerContent.vue:负责生命周期(基于
Presence的挂载/卸载、支持forceMount)与模态分支逻辑; - DrawerContentImpl.vue:真正的行为内核,组合
FocusScope(焦点管理)与DismissableLayer(外置交互与关闭),并接入useDrawerSnapPoints、useSwipeDismiss两个 composable。
外壳的模板部分展示了这种分层的关键结构(DrawerContent.vue):
<Presence :present="forceMount || rootContext.open.value"> <!-- 分支 1:完整模态(modal === true) --> <DrawerContentImpl v-if="isFullModal" v-bind="{ ...props, ...emitsAsProps, ...$attrs }" :trap-focus="shouldTrapFocus" :disable-outside-pointer-events="true" @close-auto-focus="(e) => { /* 关闭后焦点强制回到 trigger */ }" @pointer-down-outside="(e) => { /* 忽略右键点击外部 */ }" @focus-outside="(e) => e.preventDefault()" > <slot /> </DrawerContentImpl> <!-- 分支 2:非完整模态('trap-focus' 或 false) --> <DrawerContentImpl v-else v-bind="{ ...props, ...emitsAsProps, ...$attrs }" :trap-focus="shouldTrapFocus" :disable-outside-pointer-events="false" @close-auto-focus="onCloseAutoFocusNonModal" @interact-outside="onInteractOutsideNonModal" > <slot /> </DrawerContentImpl> </Presence>Presence :present="forceMount || rootContext.open.value"这一行同时解释了forceMount参数的含义:即使open为false,只要forceMount为true,内容依然保持挂载(常用于需要控制退出动画、或提前渲染内容的场景)。
模态分级(Modality tiers)
DrawerContent.vue源码中的注释明确定义了三级模态(DrawerContent.vue#L26-L34):
// Modality tiers: // true → full modal (focus trap + hide others + outside pointer events blocked) // 'trap-focus' → traps focus but does NOT block outside pointer events // false → non-modal const isFullModal = computed(() => rootContext.modal.value === true) const isTrapFocusOnly = computed(() => rootContext.modal.value === 'trap-focus') const shouldTrapFocus = computed(() => (isFullModal.value || isTrapFocusOnly.value) && rootContext.open.value) const shouldHideOthers = computed(() => isFullModal.value ? currentElement.value : undefined) useHideOthers(shouldHideOthers)这三级来自DrawerRoot的modal属性(默认true,定义见 DrawerRoot.vue#L29-L35):
modal取值 | 焦点陷阱 | 阻止外部指针事件 | 隐藏其他内容(aria-hidden) |
|---|---|---|---|
true(默认) | 是 | 是(disable-outside-pointer-events="true") | 是(useHideOthers生效) |
'trap-focus' | 是 | 否 | 否 |
false | 否 | 否 | 否 |
对应的模板上,完整模态分支硬编码:disable-outside-pointer-events="true"并监听focus-outside事件直接preventDefault();非模态分支则传false,并改用interact-outside处理器记录"用户是否操作过外部"(见下文finalFocus的说明)。
二、Props 完整参考
以下表格完整继承自 docs/content/meta/DrawerContent.md,并补充了各参数在源码中的实际作用位置:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | The element or component this component should render as. Can be overwritten byasChild. | AsTag \| Component | No | "div" |
asChild | Change the default rendered element for the one passed as a child, merging their props and behavior. Read our Composition guide for more details. | boolean | No | - |
disableOutsidePointerEvents | When true, hover/focus/click interactions will be disabled on elements outside the DismissableLayer. Users will need to click twice on outside elements to interact with them: once to close the DismissableLayer, and again to trigger the element. | boolean | No | - |
finalFocus | Final focus target when the drawer closes.true/ default: focus the trigger;false: do not restore focus;element ref: focus that specific element | boolean \| HTMLElement \| null | No | - |
forceMount | 强制保持挂载(即使关闭状态) | boolean | No | - |
initialFocus | Initial focus target when the drawer opens.true/ default: focus the first focusable element inside;false: do not focus anything;element ref: focus that specific element | boolean \| HTMLElement \| null | No | - |
as与asChild
这两个参数来自DismissableLayer的通用组合(DrawerContentImplProps extends DismissableLayerProps,见 DrawerContentImpl.vue#L12-L28),在 Impl 的模板中透传给DismissableLayer(DrawerContentImpl.vue#L367-L372):
<DismissableLayer :id="rootContext.contentId" :ref="forwardRef" :as="as" :as-child="asChild" :disable-outside-pointer-events="disableOutsidePointerEvents" role="dialog" ...默认渲染div(as的默认值"div"),使用asChild可把抽屉面板合并到任意子元素上(例如直接渲染为一个自定义容器,同时继承抽屉的全部行为)。
forceMount
forceMount是DrawerContent外壳层独有的属性(DrawerContentProps extends Omit<DrawerContentImplProps, 'trapFocus'>中显式声明,见 DrawerContent.vue#L4-L7),不参与焦点/手势逻辑,只影响Presence的present判断。官方文档在Overlay、Content部件旁都附有 Presence 提示,说明这些部件的渲染受Presence生命周期控制。
initialFocus/finalFocus
这两个参数在 DrawerContentImpl.vue#L14-L27 中有完整的 JSDoc 定义:
/** * Initial focus target when the drawer opens. * - `true` / default: focus the first focusable element inside * - `false`: do not focus anything * - element ref: focus that specific element */ initialFocus?: boolean | HTMLElement | null /** * Final focus target when the drawer closes. * - `true` / default: focus the trigger * - `false`: do not restore focus * - element ref: focus that specific element */ finalFocus?: boolean | HTMLElement | null它们的实际执行者是FocusScope(trapped绑定到trapFocus,见下文"事件"一节)。但finalFocus的"默认回到 trigger"行为在DrawerContent外壳中被做了差异化增强:
- 完整模态:
close-auto-focus事件处理中,若未被 prevent,直接rootContext.triggerElement.value?.focus()(DrawerContent.vue#L70-L75); - 非模态:
onCloseAutoFocusNonModal只有在"用户没有与外部交互过"时才把焦点还给 trigger(DrawerContent.vue#L38-L46):
function onCloseAutoFocusNonModal(e: Event) { if (!e.defaultPrevented) { if (!hasInteractedOutside.value) rootContext.triggerElement.value?.focus() e.preventDefault() } hasInteractedOutside.value = false hasPointerDownOutside.value = false }这个细节意味着:非模态抽屉中,如果用户在关闭前点击过页面其他区域,焦点就不会被"拽回"trigger,避免打断用户正在进行的交互。而hasInteractedOutside由onInteractOutsideNonModal置位(DrawerContent.vue#L48-L59),它还会在"点击落在 trigger 自身上"时preventDefault(),从而让再次点击 trigger 正常切换状态而不是先关闭再打开。
disableOutsidePointerEvents
该属性透传给DismissableLayer。注意在完整模态分支下它被硬编码为true(模板中:disable-outside-pointer-events="true"),只有非模态分支才实际消费传入值("false")。这与 Props 表中的描述一致:开启后外部元素的 hover/focus/click 交互被禁用,用户需要点两次才能操作外部元素(第一次关闭抽屉,第二次才真正触发元素)。
三、Events 完整参考
以下表格完整继承自 docs/content/meta/DrawerContent.md:
| Name | Description | Type |
|---|---|---|
closeAutoFocus | Event handler called when auto-focusing on close. Can be prevented. | [event: Event] |
escapeKeyDown | Event handler called when the escape key is down. Can be prevented. | [event: KeyboardEvent] |
focusOutside | Event handler called when the focus moves outside of the DismissableLayer. Can be prevented. | [event: FocusOutsideEvent] |
interactOutside | Event handler called when an interaction happens outside the DismissableLayer. Specifically, when apointerdownevent happens outside or focus moves outside of it. Can be prevented. | [event: PointerDownOutsideEvent \| FocusOutsideEvent] |
openAutoFocus | Event handler called when auto-focusing on open. Can be prevented. | [event: Event] |
pointerDownOutside | Event handler called when apointerdownevent happens outside of the DismissableLayer. Can be prevented. | [event: PointerDownOutsideEvent] |
这 6 个事件的类型声明在 DrawerContentImpl.vue#L7-L10:DrawerContentImplEmits = DismissableLayerEmits & { openAutoFocus, closeAutoFocus }——即 4 个"外置"事件继承自DismissableLayer,2 个"自动聚焦"事件由FocusScope补充。
事件的底层触发链
FocusScope与DismissableLayer各自向上 emit,Impl 层再把它们转发出去(DrawerContentImpl.vue#L359-L385):
<FocusScope as-child loop :trapped="props.trapFocus" @mount-auto-focus="emits('openAutoFocus', $event)" @unmount-auto-focus="emits('closeAutoFocus', $event)"> <DismissableLayer ... @dismiss="onDismiss" @escape-key-down="onEscapeKeyDown" @focus-outside="onFocusOutside" @interact-outside="onInteractOutside" @pointer-down-outside="onPointerDownOutside"> <slot /> </DismissableLayer> </FocusScope>可以推断出触发语义:
openAutoFocus:FocusScope挂载并自动聚焦时触发(对应"打开时聚焦",受initialFocus控制);closeAutoFocus:FocusScope卸载自动聚焦时触发(对应"关闭时还原焦点",受finalFocus控制);- 全部 6 个事件都"可以被 prevent"(表格中 Can be prevented):prevent
escapeKeyDown/pointerDownOutside/focusOutside会阻止 DismissableLayer 触发关闭;preventopenAutoFocus/closeAutoFocus则接管焦点去向。
事件驱动关闭并附带 reason
escapeKeyDown、pointerDownOutside等事件不仅是通知,还参与"关闭原因"的判定。Impl 中维护了一个pendingDismissReason(DrawerContentImpl.vue#L215-L248):
// DismissableLayer fires `escape-key-down` / `pointer-down-outside` / `focus-outside` // before it fires `dismiss`, so we capture the reason in those handlers. let pendingDismissReason: 'escape-key' | 'outside-press' | undefined function onEscapeKeyDown(event: KeyboardEvent) { pendingDismissReason = 'escape-key' emits('escapeKeyDown', event) } function onPointerDownOutside(event: any) { if (isSwiping.value) { event.preventDefault(); return } pendingDismissReason = 'outside-press' emits('pointerDownOutside', event) } function onDismiss() { if (isSwiping.value) return rootContext.onOpenChange(false, pendingDismissReason ?? 'outside-press') pendingDismissReason = undefined }这个 reason 会一路透传到DrawerRoot的update:open事件 details 中(handleOpenChange在 DrawerRoot.vue#L213-L219 中把reason包装进DrawerOpenChangeDetails后 emit)。DrawerRoot定义的完整 reason 枚举为'swipe' | 'escape-key' | 'outside-press' | 'click' | 'cancel' | 'trigger-press' | 'close-press'(DrawerRoot.vue#L12-L19)。因此在抽屉关闭时你可以区分"按 Esc 关的"还是"点外面关的"还是"手势划走的":
<script setup> function onOpenChange(open, details) { if (!open && details?.reason === 'swipe') { // user flicked the drawer away } } </script> <template> <DrawerRoot @update:open="onOpenChange"> <DrawerTrigger>Open</DrawerTrigger> <DrawerPortal> <DrawerOverlay /> <DrawerContent>...</DrawerContent> </DrawerPortal> </DrawerRoot> </template>四、手势、CSS 自定义属性与动画
DrawerContent自己拥有滑动手势(官方文档原话:"Owns the swipe gesture and exposes the drag offset through CSS custom properties")。组件本身无样式,进出场过渡与跟手拖拽完全由你的 CSS 驱动。DrawerContent会把实时状态写到以下 CSS 自定义属性上(变量清单定义在 packages/core/src/Drawer/utils.ts#L6-L15):
| CSS 变量 | 含义 |
|---|---|
--drawer-swipe-movement-y | 垂直拖拽偏移(top/bottom 抽屉) |
--drawer-swipe-movement-x | 水平拖拽偏移(left/right 抽屉) |
--drawer-snap-point-offset | 当前吸附点的偏移量(配置了 snap points 时) |
--drawer-swipe-progress | 静止为0,随划走程度趋近1 |
--drawer-swipe-strength | 0.1–1 的标量,随释放速度缩放过渡时长 |
--drawer-height | 面板实测高度(ResizeObserver 写入) |
--nested-drawers | 嵌套抽屉计数(嵌套缩放效果用) |
这些变量在DrawerContentImpl挂载时会通过CSS.registerProperty注册为有类型的属性(长度型<length>初始0px,数值型<number>),见 utils.ts#L177-L205,注册后浏览器可以对其做插值过渡。
跟手拖拽 + 进出场动画的标准写法
摘自 drawer.md "Animating the drawer" 一节,可直接复制使用:
.DrawerContent { /* 拖拽时跟手 */ transform: translateY(var(--drawer-swipe-movement-y, 0px)); transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); } /* 进出场关键帧使用独立的 translate 属性, 与上面的 transform(承载拖拽偏移)合成而非互相覆盖 */ .DrawerContent[data-state='open'] { animation: slideIn 450ms cubic-bezier(0.32, 0.72, 0, 1); } .DrawerContent[data-state='closed'] { animation: slideOut 450ms cubic-bezier(0.32, 0.72, 0, 1); } /* 拖拽进行中切断 transition,让抽屉实时跟随指针 */ .DrawerContent[data-swiping] { transition-duration: 0ms; } @keyframes slideIn { from { translate: 0 100%; } } @keyframes slideOut { to { translate: 0 100%; } }仓库自带的 Tailwind 演示 docs/components/demo/Drawer/tailwind/index.vue 与上述写法一致,并且给出了两点工程细节值得注意:
- 样式块不能加
scoped——因为DrawerPortal会把 Content 传送到body,scoped 选择器到不了它; - 拖拽中除了
transition-duration: 0ms还加了user-select: none,避免拖动时误选文本。
Bleed:让反向拖拽"拉伸"而非"脱离"
反向拖(把抽屉往锚定边缘的外侧拉)不会关闭抽屉,而是阻尼回弹。官方文档指出,不加"出血区"时抽屉会整块离开边缘、露出下面的遮罩;解法是给面板加一段伸出视口外、再用等量负 margin 拉回屏外的区域:
.DrawerContent { --bleed: 48px; padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--bleed)); margin-bottom: calc(-1 * var(--bleed)); } /* 出血区已经在视口外,抽屉只需移动 height - bleed 即可完全离场 */ @keyframes slideIn { from { translate: 0 calc(100% - var(--bleed)); } } @keyframes slideOut { to { translate: 0 calc(100% - var(--bleed)); } }演示中的实际类名写法(demo/Drawer/tailwind/index.vue#L28-L29):
<DrawerContent class="DrawerContent fixed inset-x-0 bottom-0 z-[100] mx-auto flex max-w-[500px] flex-col rounded-t-[16px] bg-white outline-none [--bleed:48px] mb-[calc(-1*var(--bleed))] pb-[calc(env(safe-area-inset-bottom,0px)+var(--bleed))]" >侧边抽屉同理换轴:右锚定则右出血、左锚定则左出血(padding-inline-end/padding-inline-start配margin-inline-*负值),完整 CSS 见 drawer.md#L135-L153。
释放速度如何影响收尾动画
useSwipeDismiss的onRelease回调中,DrawerContentImpl会调用computeSwipeReleaseScalar计算一个 0.1–1 的标量写入--drawer-swipe-strength(DrawerContentImpl.vue#L142-L163)。该函数从 BaseUI 移植而来,其核心逻辑(utils.ts#L80-L136)是:用"剩余要走的距离 ÷ 速度"得到理论耗时(钳制在 80–360ms),再线性映射成标量——划得越快、剩余距离越短,标量越小,收尾过渡越快。这解释了为什么用力一甩抽屉是"嗖"地一下消失,而慢慢拖到边缘松开则缓慢滑出。
吸附点(Snap points)下的滑动
配置snapPoints后,手势不仅用于关闭,还用于在多个停靠位之间切换:DrawerContentImpl会放开"关闭方向 + 反方向"两个滑动方向(DrawerContentImpl.vue#L110-L119),释放时把实时位移换算成标量后调用snapToNearest决定停靠位。源码中有一段很值得细读的时序注释(DrawerContentImpl.vue#L173-L196):snapToNearest之后必须先同步写入新的 snap 偏移变量、再清零拖拽位移变量,否则 transform 会在单帧内从拖拽位置跳回旧偏移,再 CSS 过渡到新位置,用户看到的是"回弹后再动"而非从手指位置连续滑向新停靠位。
五、Data 属性
DrawerContent渲染的元素上会携带以下 data 属性(定义于 DrawerContentImpl.vue#L294-L305,与 drawer.md 的 Data Attributes 表格一致):
| Attribute | Values |
|---|---|
[data-state] | open/closed |
[data-swipe-direction] | up/down/left/right |
[data-swiping] | 拖拽进行中时存在 |
[data-nested-drawer-open] | 存在嵌套抽屉打开时存在 |
这四个属性正是上面动画 CSS 的全部选择器来源([data-state='open']触发进场关键帧、[data-swiping]切掉 transition),也是你写自定义样式时唯一需要依赖的稳定钩子。
六、无障碍(Accessibility)
DrawerContent遵循 Dialog 模态对话框的 WAI-ARIA 设计模式。从源码模板(DrawerContentImpl.vue#L367-L376)可以看到无障碍相关的固定输出:
<DismissableLayer :id="rootContext.contentId" role="dialog" :aria-describedby="rootContext.descriptionId" :aria-labelledby="rootContext.titleId" ...role="dialog"由组件硬编码;aria-labelledby/aria-describedby分别指向DrawerRoot通过useId生成的titleId/descriptionId(DrawerRoot.vue#L149-L151),由DrawerTitle/DrawerDescription消费;- 开发环境下若页面上找不到
DrawerTitle,组件会在挂载时console.warn提示"DrawerContent requires a DrawerTitle for accessibility"(DrawerContentImpl.vue#L347-L356)。
键盘交互(摘自 drawer.md 的 Keyboard Interactions):
| 按键 | 行为 |
|---|---|
Space | 打开/关闭抽屉 |
Enter | 打开/关闭抽屉 |
Tab | 焦点移动到下一个可聚焦元素 |
Shift + Tab | 焦点移动到上一个可聚焦元素 |
Esc | 关闭抽屉并把焦点移回DrawerTrigger |
其中Esc关闭、Tab循环(<FocusScope as-child loop :trapped="...">的loop属性)与finalFocus回 focus trigger 的行为,对应上文第二、三节的实现链条。
七、一个可直接运行的完整示例
结合演示目录中的真实代码(docs/components/demo/Drawer/tailwind/index.vue),一个底部滑出的表单抽屉如下:
<script setup lang="ts"> import { DrawerClose, DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle, DrawerTrigger, } from 'reka-ui' </script> <template> <DrawerRoot> <DrawerTrigger>Open Drawer</DrawerTrigger> <DrawerPortal> <DrawerOverlay class="fixed inset-0 z-30 bg-black/40" /> <DrawerContent class="fixed inset-x-0 bottom-0 z-[100] mx-auto flex max-w-[500px] flex-col rounded-t-[16px] bg-white outline-none [--bleed:48px] mb-[calc(-1*var(--bleed))] pb-[calc(env(safe-area-inset-bottom,0px)+var(--bleed))]" > <DrawerHandle class="mx-auto mt-3 h-1.5 w-12 rounded-full bg-mauve6" /> <div class="p-6"> <DrawerTitle>Edit profile</DrawerTitle> <DrawerDescription> Make changes to your profile here. Swipe down or click close when you're done. </DrawerDescription> <DrawerClose as-child> <button type="button">Save changes</button> </DrawerClose> </div> </DrawerContent> </DrawerPortal> </DrawerRoot> </template> <style> /* 与前面"Animating the drawer"一节的 CSS 相同: .DrawerContent 上绑定 --drawer-swipe-movement-y 的 transform, [data-state='open'/'closed'] 上的 translate 关键帧进出场, [data-swiping] 时 transition-duration: 0ms */ </style>需要控制打开状态时,DrawerRoot支持v-model:open、modal、swipeDirection、snapPoints+v-model:snapPoint等属性,且默认插槽暴露open/closeslot props,可以在抽屉内部任意位置调用close()以编程方式关闭(close-press会作为 reason 传入update:open,见 DrawerRoot.vue#L209-L211 与<template>中的:close="handleClose")。
八、测试验证与小结
DrawerContent的相关行为有测试覆盖:packages/core/src/Drawer/Drawer.test.ts 覆盖交互逻辑,packages/core/src/Drawer/Drawer.snap.test.ts 覆盖渲染结构快照;手势与吸附点的计算逻辑另有 useSwipeDismiss.test.ts、useDrawerSnapPoints.test.ts 与 utils.test.ts 三个单元测试文件。
小结一下DrawerContent的职责边界:
- 生命周期:由外层
Presence管理,forceMount可覆盖关闭态的卸载; - 模态策略:跟随
DrawerRoot的modal三级取值,完整模态时锁外部交互并隐藏其他内容,'trap-focus'时仅锁焦点; - 焦点:
initialFocus/finalFocus经FocusScope落地,默认"开时聚焦首个可聚焦元素、关时回 trigger",非模态下用户操作过外部则不回拉焦点; - 事件:6 个可 prevent 的事件构成与
DismissableLayer/FocusScope的交互契约,关闭原因(escape-key/outside-press/swipe等)会透传给update:open; - 手势:全部偏移以 CSS 自定义属性暴露,配合
data-state/data-swiping选择器即可完成跟手拖拽、速度感收尾与吸附点停靠的完整动画。
掌握以上各节后,你可以把DrawerContent当作一个"只暴露状态、不写死样式"的面板内核:API 层按 drawer.md 的表格使用,行为层则以上文给出的源码路径为据追查每一条实现细节。
【免费下载链接】radix-vueAn open-source UI component library for building high-quality, accessible design systems and web apps for Vue. Previously Radix Vue项目地址: https://gitcode.com/GitHub_Trending/ra/radix-vue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考