ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

radix-vue 中 DrawerContent 深度解析:Props、事件与焦点、滑动手势的底层实现

radix-vue 中 DrawerContent 深度解析:Props、事件与焦点、滑动手势的底层实现 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 asDrawerPopupfor 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.vuePresence :presentforceMount || rootContext.open.value !-- 分支 1完整模态modal true -- DrawerContentImpl v-ifisFullModal v-bind{ ...props, ...emitsAsProps, ...$attrs } :trap-focusshouldTrapFocus :disable-outside-pointer-eventstrue 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-focusshouldTrapFocus :disable-outside-pointer-eventsfalse close-auto-focusonCloseAutoFocusNonModal interact-outsideonInteractOutsideNonModal slot / /DrawerContentImpl /PresencePresence :presentforceMount || rootContext.open.value这一行同时解释了forceMount参数的含义即使open为false只要forceMount为true内容依然保持挂载常用于需要控制退出动画、或提前渲染内容的场景。模态分级Modality tiersDrawerContent.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-L35modal取值焦点陷阱阻止外部指针事件隐藏其他内容aria-hiddentrue默认是是disable-outside-pointer-eventstrue是useHideOthers生效trap-focus是否否false否否否对应的模板上完整模态分支硬编码:disable-outside-pointer-eventstrue并监听focus-outside事件直接preventDefault()非模态分支则传false并改用interact-outside处理器记录用户是否操作过外部见下文finalFocus的说明。二、Props 完整参考以下表格完整继承自 docs/content/meta/DrawerContent.md并补充了各参数在源码中的实际作用位置NameDescriptionTypeRequiredDefaultasThe element or component this component should render as. Can be overwritten byasChild.AsTag \| ComponentNodivasChildChange the default rendered element for the one passed as a child, merging their props and behavior. Read our Composition guide for more details.booleanNo-disableOutsidePointerEventsWhen 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.booleanNo-finalFocusFinal focus target when the drawer closes.true/ default: focus the triggerfalse: do not restore focuselement ref: focus that specific elementboolean \| HTMLElement \| nullNo-forceMount强制保持挂载即使关闭状态booleanNo-initialFocusInitial focus target when the drawer opens.true/ default: focus the first focusable element insidefalse: do not focus anythingelement ref: focus that specific elementboolean \| HTMLElement \| nullNo-as与asChild这两个参数来自DismissableLayer的通用组合DrawerContentImplProps extends DismissableLayerProps见 DrawerContentImpl.vue#L12-L28在 Impl 的模板中透传给DismissableLayerDrawerContentImpl.vue#L367-L372DismissableLayer :idrootContext.contentId :refforwardRef :asas :as-childasChild :disable-outside-pointer-eventsdisableOutsidePointerEvents roledialog ...默认渲染divas的默认值div使用asChild可把抽屉面板合并到任意子元素上例如直接渲染为一个自定义容器同时继承抽屉的全部行为。forceMountforceMount是DrawerContent外壳层独有的属性DrawerContentProps extends OmitDrawerContentImplProps, 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它们的实际执行者是FocusScopetrapped绑定到trapFocus见下文事件一节。但finalFocus的默认回到 trigger行为在DrawerContent外壳中被做了差异化增强完整模态close-auto-focus事件处理中若未被 prevent直接rootContext.triggerElement.value?.focus()DrawerContent.vue#L70-L75非模态onCloseAutoFocusNonModal只有在用户没有与外部交互过时才把焦点还给 triggerDrawerContent.vue#L38-L46function 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-eventstrue只有非模态分支才实际消费传入值false。这与 Props 表中的描述一致开启后外部元素的 hover/focus/click 交互被禁用用户需要点两次才能操作外部元素第一次关闭抽屉第二次才真正触发元素。三、Events 完整参考以下表格完整继承自 docs/content/meta/DrawerContent.mdNameDescriptionTypecloseAutoFocusEvent handler called when auto-focusing on close. Can be prevented.[event: Event]escapeKeyDownEvent handler called when the escape key is down. Can be prevented.[event: KeyboardEvent]focusOutsideEvent handler called when the focus moves outside of the DismissableLayer. Can be prevented.[event: FocusOutsideEvent]interactOutsideEvent 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]openAutoFocusEvent handler called when auto-focusing on open. Can be prevented.[event: Event]pointerDownOutsideEvent handler called when apointerdownevent happens outside of the DismissableLayer. Can be prevented.[event: PointerDownOutsideEvent]这 6 个事件的类型声明在 DrawerContentImpl.vue#L7-L10DrawerContentImplEmits DismissableLayerEmits { openAutoFocus, closeAutoFocus }——即 4 个外置事件继承自DismissableLayer2 个自动聚焦事件由FocusScope补充。事件的底层触发链FocusScope与DismissableLayer各自向上 emitImpl 层再把它们转发出去DrawerContentImpl.vue#L359-L385FocusScope as-child loop :trappedprops.trapFocus mount-auto-focusemits(openAutoFocus, $event) unmount-auto-focusemits(closeAutoFocus, $event) DismissableLayer ... dismissonDismiss escape-key-downonEscapeKeyDown focus-outsideonFocusOutside interact-outsideonInteractOutside pointer-down-outsideonPointerDownOutside slot / /DismissableLayer /FocusScope可以推断出触发语义openAutoFocusFocusScope挂载并自动聚焦时触发对应打开时聚焦受initialFocus控制closeAutoFocusFocusScope卸载自动聚焦时触发对应关闭时还原焦点受finalFocus控制全部 6 个事件都可以被 prevent表格中 Can be preventedpreventescapeKeyDown/pointerDownOutside/focusOutside会阻止 DismissableLayer 触发关闭preventopenAutoFocus/closeAutoFocus则接管焦点去向。事件驱动关闭并附带 reasonescapeKeyDown、pointerDownOutside等事件不仅是通知还参与关闭原因的判定。Impl 中维护了一个pendingDismissReasonDrawerContentImpl.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-pressDrawerRoot.vue#L12-L19。因此在抽屉关闭时你可以区分按 Esc 关的还是点外面关的还是手势划走的script setup function onOpenChange(open, details) { if (!open details?.reason swipe) { // user flicked the drawer away } } /script template DrawerRoot update:openonOpenChange DrawerTriggerOpen/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-L15CSS 变量含义--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-strength0.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-stateopen] { animation: slideIn 450ms cubic-bezier(0.32, 0.72, 0, 1); } .DrawerContent[data-stateclosed] { 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 传送到bodyscoped 选择器到不了它拖拽中除了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-L29DrawerContent classDrawerContent 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-strengthDrawerContentImpl.vue#L142-L163。该函数从 BaseUI 移植而来其核心逻辑utils.ts#L80-L136是用剩余要走的距离 ÷ 速度得到理论耗时钳制在 80–360ms再线性映射成标量——划得越快、剩余距离越短标量越小收尾过渡越快。这解释了为什么用力一甩抽屉是嗖地一下消失而慢慢拖到边缘松开则缓慢滑出。吸附点Snap points下的滑动配置snapPoints后手势不仅用于关闭还用于在多个停靠位之间切换DrawerContentImpl会放开关闭方向 反方向两个滑动方向DrawerContentImpl.vue#L110-L119释放时把实时位移换算成标量后调用snapToNearest决定停靠位。源码中有一段很值得细读的时序注释DrawerContentImpl.vue#L173-L196snapToNearest之后必须先同步写入新的 snap 偏移变量、再清零拖拽位移变量否则 transform 会在单帧内从拖拽位置跳回旧偏移再 CSS 过渡到新位置用户看到的是回弹后再动而非从手指位置连续滑向新停靠位。五、Data 属性DrawerContent渲染的元素上会携带以下 data 属性定义于 DrawerContentImpl.vue#L294-L305与 drawer.md 的 Data Attributes 表格一致AttributeValues[data-state]open/closed[data-swipe-direction]up/down/left/right[data-swiping]拖拽进行中时存在[data-nested-drawer-open]存在嵌套抽屉打开时存在这四个属性正是上面动画 CSS 的全部选择器来源[data-stateopen]触发进场关键帧、[data-swiping]切掉 transition也是你写自定义样式时唯一需要依赖的稳定钩子。六、无障碍AccessibilityDrawerContent遵循 Dialog 模态对话框的 WAI-ARIA 设计模式。从源码模板DrawerContentImpl.vue#L367-L376可以看到无障碍相关的固定输出DismissableLayer :idrootContext.contentId roledialog :aria-describedbyrootContext.descriptionId :aria-labelledbyrootContext.titleId ...roledialog由组件硬编码aria-labelledby/aria-describedby分别指向DrawerRoot通过useId生成的titleId/descriptionIdDrawerRoot.vue#L149-L151由DrawerTitle/DrawerDescription消费开发环境下若页面上找不到DrawerTitle组件会在挂载时console.warn提示DrawerContent requires a DrawerTitle for accessibilityDrawerContentImpl.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 langts import { DrawerClose, DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle, DrawerTrigger, } from reka-ui /script template DrawerRoot DrawerTriggerOpen Drawer/DrawerTrigger DrawerPortal DrawerOverlay classfixed inset-0 z-30 bg-black/40 / DrawerContent classfixed 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 classmx-auto mt-3 h-1.5 w-12 rounded-full bg-mauve6 / div classp-6 DrawerTitleEdit profile/DrawerTitle DrawerDescription Make changes to your profile here. Swipe down or click close when youre done. /DrawerDescription DrawerClose as-child button typebuttonSave changes/button /DrawerClose /div /DrawerContent /DrawerPortal /DrawerRoot /template style /* 与前面Animating the drawer一节的 CSS 相同 .DrawerContent 上绑定 --drawer-swipe-movement-y 的 transform [data-stateopen/closed] 上的 translate 关键帧进出场 [data-swiping] 时 transition-duration: 0ms */ /style需要控制打开状态时DrawerRoot支持v-model:open、modal、swipeDirection、snapPointsv-model:snapPoint等属性且默认插槽暴露open/closeslot props可以在抽屉内部任意位置调用close()以编程方式关闭close-press会作为 reason 传入update:open见 DrawerRoot.vue#L209-L211 与template中的:closehandleClose。八、测试验证与小结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),仅供参考
返回列表