Files
workspace/code/fms/.codebuddy/plans/modal-drawer-components_ff3d0a3c.md
T
2026-08-16 22:01:32 +08:00

12 KiB
Raw Blame History

name, overview, design, todos
name overview design todos
modal-drawer-components 在 fms-vue 组件库中新增 Modal 模态框与 Drawer 抽屉两个弹层组件:参考 antdv-next 与 shadcn 的 dialog 设计,按本项目规范做轻量化实现——Teleport 渲染、遮罩/点击遮罩关闭/Esc 关闭、body 滚动锁定、焦点陷阱与焦点还原、mask fade + 面板 zoom/slide 过渡、受控/非受控双模式,并在 App.vue 反馈分类新增演示。
architecture styleKeywords fontSystem colorSystem
framework
vue
简洁
中性色
主色强调
清晰层次
fontFamily heading subheading body
PingFang SC
size weight
16px 600
size weight
14px 500
size weight
14px 400
primary background text functional
#0f172b
#ffffff
#ffffff
#f5f6f8
#f1f5f9
#000000
#1f2329
#86909c
#ffffff
#e5e6eb
#c9cdd4
#f2f3f5
rgba(0,0,0,0.45)
id content status
create-shared-utils 使用 [skill:ui-ux-pro-max] 核对对话框/抽屉可访问性规范后,创建 utils/focus.js 焦点陷阱与 utils/scroll.js 滚动锁 completed
id content status dependencies
build-modal 创建 modal/modal.vue 与 modal/index.scss:受控 open、mask fade + panel zoom、标题/关闭/确定取消/footer 插槽、ARIA 与焦点滚动接入 completed
create-shared-utils
id content status dependencies
build-drawer 创建 drawer/drawer.vue 与 drawer/index.scss:四方向滑入、header/extra/body/footer 三段式、ARIA 与焦点滚动接入 completed
create-shared-utils
id content status dependencies
wire-tokens-demo tokens.css 新增 mask/z-index token;App.vue 反馈分类新增 Modal/Drawer 导航与演示 section;静态检查后汇报 completed
build-modal
build-drawer

产品概述

在 fms-vue 组件库中新增 Modal 对话框与 Drawer 抽屉两个浮层组件:参考 antdv-next 的 Modal/Drawer 与 shadcn dialog 的设计,两者共享遮罩、焦点陷阱、滚动锁、关闭动画等基础能力,按本项目开发规范做轻量化实现,并在演示站 App.vue 反馈分类中新增展示。

核心功能

Modal 对话框

  • 受控开关 v-model:open,遮罩点击、右上角关闭按钮、Esc 键均可关闭
  • 标题区(title 属性或 #title 插槽)、内容区(默认插槽)、底部操作区(默认"取消/确定"按钮,支持 #footer 自定义、footer=false 隐藏)
  • 确定按钮加载态(confirmLoading)、宽度可配置(默认 520)、居中显示(centered)
  • destroyOnClose 销毁内容、afterOpenChange 动画完成回调

Drawer 抽屉

  • 受控开关 v-model:open,支持四方向滑出(placement: right/left/top/bottom,默认 right)
  • 尺寸可配置(默认 378,左右取宽、上下取高)
  • 三段式结构:header(标题 + 关闭按钮 + #extra 附加区)、body(内容滚动区)、footer(纯插槽,无默认按钮,与 Modal 不同)
  • 遮罩点击、关闭按钮、Esc 键关闭

共同能力

  • 打开时锁定 body 滚动(滚动条宽度补偿,避免布局跳动),支持多个弹层嵌套计数
  • 焦点管理:打开聚焦面板、Tab 焦点陷阱循环、关闭后焦点还原到触发元素
  • 关闭动画(mask 淡出 + panel 缩放/滑出)后再真正卸载;prefers-reduced-motion 下关闭动画
  • ARIA:role=dialog、aria-modal、aria-labelledby 关联标题、关闭按钮 aria-label
  • 非受控模式:open 未传时组件内部维护(参照 Popover 先例)

视觉效果

  • Modal:居中卡片,默认宽 520px,圆角 6px(--fms-control-radius),阴影 --fms-popover-shadow,深色半透明遮罩(0.45 透明度),打开 mask 淡入 + 面板轻微缩放
  • Drawer:从边缘滑入(±100% 位移),面板白底,header 下边框、body 独立滚动、footer 上边框,阴影加深
  • 全部复用 --fms-* token,深色模式自动适配

技术栈

  • Vue 3 <script setup> + Sass + Vite,与现有组件库一致
  • 图标按需导入 @lucide/vue(X 关闭图标,notification 已有先例)
  • Teleport to body + Transition 动画(Popover / message-container 已有先例)
  • 复用现有 Button(确定/取消/关闭)与关闭按钮样式范式(notification)

实现方案

公共逻辑抽取(utils/)

两个组件共用"焦点陷阱 + 滚动锁",按规范抽到 components/ui/utils:

utils/focus.js — 对话框焦点管理纯 DOM 辅助:

  • saveActiveElement():保存当前聚焦元素(关闭后还原)
  • focusFirstIn(container):打开后聚焦面板内首个可聚焦元素(无则聚焦面板本身,带 tabindex="-1")
  • createFocusTrap(container):返回 { trapKeydown, destroy };trapKeydown 处理 Tab 在可聚焦元素间循环(首尾 wrap),destroy 移除监听
  • 可聚焦元素选择器:a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])

utils/scroll.js — body 滚动锁,计数支持嵌套:

  • lockScroll() / unlockScroll():模块级计数器,第一次 lock 时保存 body.style.overflow 与 paddingRight,设置 overflow: hidden 并用 window.innerWidth - document.documentElement.clientWidth 计算滚动条宽度补偿 paddingRight;全部 unlock 后恢复原值

Modal 组件(modal/modal.vue + index.scss)

  • Props:open(undefined 非受控)、title、width(520, Number|String)、centered(false)、mask(true)、maskClosable(true)、keyboard(true)、closable(true)、footer(undefined,undefined=默认按钮, false=隐藏)、okText('确定')、cancelText('取消')、confirmLoading(false)、destroyOnClose(false)、zIndex(undefined)
  • Emits:update:open、ok、cancel、afterOpenChange
  • 插槽:默认内容、#title、#footer、#closeIcon
  • 结构:<Teleport to="body"><Transition name="modal"><div v-if="resolvedOpen" class="modal-root" :style="zIndex"> > mask(点击关闭)+ wrap(flex 居中/顶部)+ panel(container,聚焦陷阱作用域)
  • 行为:handleOk 仅 emit ok 不自动关闭(与 antd 一致);handleCancel emit cancel + 关闭;confirmLoading 时 ok 按钮 loading 且不可重复触发
  • 动画:单 Transition + 子元素各自 transition 属性——mask 用 opacity,panel 用 opacity + transform scale(0.98→1)
  • 焦点:watch open 打开时 focusFirstIn + createFocusTrap + lockScroll;关闭时移除 trap + 还原焦点 + unlockScroll

Drawer 组件(drawer/drawer.vue + index.scss)

  • Props:open、title、placement(right, validator 四方向)、size(378, Number|String)、mask(true)、maskClosable(true)、keyboard(true)、closable(true)、extra(undefined)、footer(undefined)、destroyOnClose(false)、zIndex
  • Emits / 插槽:同 Modal(无 ok/cancel);#extra 插槽
  • 结构:root > mask + content-wrapper(按 placement 定位,left/right 撑满高度、top/bottom 撑满宽度)> section(flex column)> header(标题+关闭+#extra)+ body(flex:1 滚动)+ footer(可选)
  • 动画:mask fade + panel 按方向 translate(left: translateX(-100%)→none,bottom: translateY(100%)→none 等)
  • 焦点/滚动:与 Modal 复用 utils 逻辑

受控 / 非受控

  • open 默认 undefined;resolvedOpen = computed(() => props.open ?? internalOpen)(Popover 先例)
  • 关闭路径统一 requestClose():非受控写内部 ref,受控 emit update:open(false),均触发关闭动画

tokens.css 新增

  • --fms-mask-bg: rgba(0, 0, 0, 0.45)(:root 与 .dark 均可复用)
  • --fms-z-modal: 2100、--fms-z-drawer: 2100(需高于 message/notification 的 2000)
  • --fms-modal-padding-x/y 或直接复用现有 --fms-control-padding-x 派生;面板内边距与 Drawer 尺寸尽量用 calc 派生,不新增冗余 token

性能与可靠性

  • 全局监听(keydown)仅在打开期间注册、关闭/卸载清理(Popover 先例)
  • 焦点陷阱与滚动锁都支持清理路径,卸载时兜底释放
  • 无高频事件、无深度 watch;Transition 仅 opacity/transform 低成本属性
  • prefers-reduced-motion 下关闭全部过渡

目录结构

fms-vue/src/components/ui/utils/
├── focus.js      # [NEW] 焦点陷阱:保存/还原焦点、聚焦首元素、Tab 循环 trap
└── scroll.js     # [NEW] body 滚动锁:计数式 lock/unlock,滚动条宽度补偿
fms-vue/src/components/ui/modal/
├── modal.vue     # [NEW] 对话框:受控 open、mask/panel 动画、标题/内容/footer、焦点与滚动管理、ARIA
└── index.scss    # [NEW] 遮罩、居中 wrap、panel 卡片、header/body/footer、关闭按钮、动画
fms-vue/src/components/ui/drawer/
├── drawer.vue    # [NEW] 抽屉:四方向滑出、header/extra/body/footer 三段式、焦点与滚动管理、ARIA
└── index.scss    # [NEW] 遮罩、placement 定位、section 布局、动画
fms-vue/src/theme/tokens.css  # [MODIFY] 新增 --fms-mask-bg、--fms-z-modal、--fms-z-drawer
fms-vue/src/App.vue           # [MODIFY] 反馈分类新增 Modal/Drawer 导航项 + 演示 section

不新增其他文件,无 barrel index;公共逻辑仅抽 utils 两个文件,两组件内部保持各自业务语义。

已知风险与规避

  • 焦点陷阱边界:多弹层同时打开时各自 trap 可能冲突,本项目演示为单层场景,trap 按"最后打开者优先"自然覆盖;如后续需要支持多层,可在 utils 中改为栈式管理(本次不做,避免过度设计)
  • body 滚动锁与弹层内滚动:锁定的是 body,面板内部 overflow auto 不受影响;未配置 body padding 补偿时可能出现 17px 位移,已在 utils 中处理
  • 关闭动画期间用户再次打开:resolvedOpen 立即回 true 会中断 leave,Vue Transition 会自动处理,无额外代码

设计风格

延续项目 tokens 驱动的简洁中性风格,参考 antd Modal/Drawer 经典形态与 shadcn dialog 的层次感。

  • 布局:Modal 使用 fixed 全屏 root(mask 铺满 + wrap 承载面板),默认顶部 100px 显示、centered 时垂直水平居中;面板 flex column 三段式(header/body/footer)。Drawer 面板贴边滑出,left/right 撑满视口高度、top/bottom 撑满宽度,header 16px/24px + 下边框,body flex:1 滚动,footer 上边框
  • 视觉:Modal 面板宽 520px、圆角 6px、背景 --fms-card、阴影 --fms-popover-shadow;遮罩半透明深色(rgba(0,0,0,0.45)),打开时 mask 淡入、面板 0.98 缩放淡入,关闭反向;Drawer 面板边缘阴影加深,按方向平移滑入/滑出
  • 交互反馈:关闭按钮 22px 方形、hover 浅灰背景(notification 先例)、:focus-visible 主色 outline;按钮区右对齐、间距 8px
  • 响应式:Modal 窄屏 max-width calc(100vw - 16px) 防溢出;Drawer 尺寸由调用方控制
  • 动画仅限 opacity/transform,prefers-reduced-motion 下全部关闭

Agent Extensions

Skill

  • ui-ux-pro-max
  • 用途:实现前查询对话框(dialog)与抽屉(drawer)的可访问性、焦点管理与键盘操作 UX 规则(focus trap、aria-modal、Esc 关闭、滚动锁),核对交互是否符合常见实践
  • 预期结果:获得对话框/抽屉的交互与可访问性指引;若查询无有效输出,则回退到开发规范内置的交互底线(focus-visible、ARIA、禁用语义)实现
  • antdv-next
  • 用途:核对 antdv-next Modal/Drawer 的 API 行为细节(open 受控、ok/cancel 语义、placement 尺寸映射、动画方向),作为实现参照的补充验证
  • 预期结果:确认 API 命名与行为与参考库一致,避免设计偏差