diff --git a/.codeflicker/specs/tooltip-component-development.md b/.codeflicker/specs/tooltip-component-development.md deleted file mode 100644 index 9c110136..00000000 --- a/.codeflicker/specs/tooltip-component-development.md +++ /dev/null @@ -1,387 +0,0 @@ -# Tooltip 组件开发计划 - -## 项目概述 - -基于现有的 CCUI 项目代码规范,参考 Element Plus Tooltip 组件的实现,完善 Tooltip 组件的功能实现、样式设计、测试用例和文档。 - -## 技术架构分析 - -### 现有项目结构 - -- 使用 Vue 3 + TypeScript + JSX -- 组件采用 `defineComponent` + `setup` 函数式组件 -- 使用 `useNamespace` hook 生成 BEM 规范的 CSS 类名 -- 组件 props 通过 `ExtractPropTypes` 定义类型 -- 测试使用 Vitest + Vue Test Utils - -### Tooltip 组件核心功能需求 - -基于 Element Plus Tooltip 组件分析,需要实现以下核心功能: - -```mermaid -graph TD - A[Tooltip 组件] --> B[基础功能] - A --> C[高级功能] - A --> D[样式主题] - A --> E[交互控制] - - B --> B1[内容显示] - B --> B2[位置定位] - B --> B3[触发方式] - - C --> C1[延迟显示/隐藏] - C --> C2[禁用状态] - C --> C3[HTML内容支持] - C --> C4[自定义样式] - - D --> D1[Dark主题] - D --> D2[Light主题] - D --> D3[自定义主题] - - E --> E1[鼠标悬停] - E --> E2[点击触发] - E --> E3[焦点触发] - E --> E4[手动控制] -``` - -## 组件设计规范 - -### Props 设计 - -```typescript -interface TooltipProps { - // 基础属性 - content: string // 提示内容 - placement: TooltipPlacement // 位置:top/bottom/left/right + start/end - effect: 'dark' | 'light' // 主题效果 - - // 显示控制 - visible?: boolean // 手动控制显示状态 - disabled: boolean // 是否禁用 - showArrow: boolean // 是否显示箭头 - - // 交互控制 - trigger: 'hover' | 'click' | 'focus' | 'manual' // 触发方式 - showAfter: number // 延迟显示时间(ms) - hideAfter: number // 延迟隐藏时间(ms) - - // 样式定制 - popperClass?: string // 自定义弹出层类名 - offset: number // 偏移距离 - - // 高级功能 - rawContent: boolean // 是否支持HTML内容 - enterable: boolean // 鼠标是否可进入tooltip -} -``` - -### 事件设计 - -```typescript -interface TooltipEmits { - 'before-show': () => void // 显示前触发 - 'show': () => void // 显示后触发 - 'before-hide': () => void // 隐藏前触发 - 'hide': () => void // 隐藏后触发 - 'update:visible': (visible: boolean) => void // v-model支持 -} -``` - -## 实现方案 - -### 1. 组件核心逻辑实现 - -#### 位置计算系统 - -```mermaid -graph LR - A[触发元素] --> B[计算位置] - B --> C[应用偏移] - C --> D[边界检测] - D --> E[最终定位] - - B --> B1[top/bottom/left/right] - B --> B2[start/center/end对齐] -``` - -#### 状态管理 - -- 使用 `ref` 管理显示状态 -- 使用 `computed` 计算样式类名 -- 使用 `watch` 监听 props 变化 - -#### 事件处理 - -- 鼠标事件:mouseenter/mouseleave -- 点击事件:click -- 焦点事件:focus/blur -- 键盘事件:keydown/keyup - -### 2. 样式系统设计 - -#### BEM 类名规范 - -```scss -.ccui-tooltip { - // 触发器容器 - &__trigger { - display: inline-block; - } - - // 弹出层 - &__popper { - position: absolute; - z-index: 2000; - - // 主题变体 - &--dark { - background: #303133; - color: #fff; - } - - &--light { - background: #fff; - color: #303133; - border: 1px solid #e4e7ed; - } - } - - // 箭头 - &__arrow { - position: absolute; - - // 位置变体 - &--top { - /* 箭头在上方 */ - } - &--bottom { - /* 箭头在下方 */ - } - &--left { - /* 箭头在左侧 */ - } - &--right { - /* 箭头在右侧 */ - } - } - - // 内容区域 - &__content { - padding: 10px; - font-size: 12px; - line-height: 1.2; - } -} -``` - -#### 动画效果 - -```scss -.ccui-tooltip-fade-enter-active, -.ccui-tooltip-fade-leave-active { - transition: opacity 0.15s; -} - -.ccui-tooltip-fade-enter-from, -.ccui-tooltip-fade-leave-to { - opacity: 0; -} -``` - -### 3. 测试策略 - -#### 单元测试覆盖 - -```mermaid -graph TD - A[测试用例] --> B[基础功能测试] - A --> C[交互测试] - A --> D[边界测试] - A --> E[可访问性测试] - - B --> B1[组件渲染] - B --> B2[Props传递] - B --> B3[插槽内容] - - C --> C1[鼠标悬停] - C --> C2[点击触发] - C --> C3[键盘操作] - - D --> D1[边界位置] - D --> D2[极值参数] - D --> D3[异常情况] - - E --> E1[ARIA属性] - E --> E2[键盘导航] - E --> E3[屏幕阅读器] -``` - -#### 测试用例设计 - -1. **基础渲染测试** - - 组件正常挂载 - - 默认 props 生效 - - 插槽内容正确显示 - -2. **交互功能测试** - - 鼠标悬停显示/隐藏 - - 点击触发显示/隐藏 - - 延迟显示/隐藏时间 - - 禁用状态测试 - -3. **位置定位测试** - - 9种位置正确显示 - - 边界自动调整 - - 偏移量计算 - -4. **主题样式测试** - - dark/light 主题切换 - - 自定义样式应用 - - 箭头显示/隐藏 - -### 4. 文档完善 - -#### API 文档结构 - -```markdown -# Tooltip 文字提示 - -常用于展示鼠标 hover 时的提示信息。 - -## 何时使用 - -- 鼠标移入则显示提示,移出消失,气泡浮层不承载复杂文本和操作 -- 可用来代替系统默认的 title 提示,提供一个更好的用户体验 - -## 基本用法 - -:::demo 基础的、简洁的文字提示气泡框 - -## 不同位置 - -:::demo Tooltip 组件提供了12个不同的方向显示Tooltip - -## 主题 - -:::demo Tooltip 组件内置了两个主题:dark和light - -## 更多内容 - -:::demo 展示多行文本或者是设置文本内容的格式 - -## 高级扩展 - -:::demo 自定义过渡动画、延迟时间等高级功能 -``` - -#### 示例代码 - -每个功能点提供完整的 Vue 示例代码,包括: - -- Template 使用方式 -- Script 配置选项 -- Style 样式定制 - -## 开发里程碑 - -### 阶段一:核心功能实现 - -1. **Props 类型定义** - 完善 `tooltip-types.ts` -2. **基础组件结构** - 实现 `tooltip.tsx` 核心逻辑 -3. **位置计算系统** - 实现 12 种位置的定位算法 -4. **事件处理机制** - 实现各种触发方式 - -### 阶段二:样式和主题 - -1. **基础样式** - 完善 `tooltip.scss` -2. **主题系统** - 实现 dark/light 主题 -3. **动画效果** - 添加过渡动画 -4. **响应式适配** - 确保各尺寸设备兼容 - -### 阶段三:测试和文档 - -1. **单元测试** - 完善 `tooltip.test.ts` -2. **集成测试** - 测试与其他组件的交互 -3. **文档示例** - 完善 `index.md` 文档 -4. **可访问性** - 确保符合 WCAG 标准 - -### 阶段四:优化和发布 - -1. **性能优化** - 减少重复计算,优化渲染 -2. **边界处理** - 处理各种边界情况 -3. **代码审查** - 确保代码质量 -4. **版本发布** - 更新组件状态为 100% - -## 质量保证 - -### 代码规范 - -- 遵循项目现有的 TypeScript 规范 -- 使用 ESLint 和 Prettier 格式化 -- 组件命名遵循 BEM 规范 -- 注释覆盖率达到 80% 以上 - -### 测试覆盖率 - -- 单元测试覆盖率 > 90% -- 集成测试覆盖主要使用场景 -- 端到端测试验证用户交互流程 - -### 性能指标 - -- 组件初始化时间 < 10ms -- 位置计算时间 < 5ms -- 内存占用优化,避免内存泄漏 - -### 兼容性 - -- 支持 Vue 3.x -- 支持现代浏览器 (Chrome 90+, Firefox 88+, Safari 14+) -- 支持移动端触摸交互 - -## 风险评估 - -### 技术风险 - -- **位置计算复杂性**: 需要处理各种边界情况和滚动容器 -- **事件处理冲突**: 可能与其他组件的事件处理产生冲突 -- **性能问题**: 频繁的位置计算可能影响性能 - -### 解决方案 - -- 使用成熟的定位算法库或参考 Element Plus 实现 -- 建立完善的事件委托和清理机制 -- 实现防抖和节流优化性能 - -## 验收标准 - -### 功能完整性 - -- [ ] 支持 12 种位置定位 -- [ ] 支持 4 种触发方式 -- [ ] 支持 dark/light 主题 -- [ ] 支持延迟显示/隐藏 -- [ ] 支持禁用状态 -- [ ] 支持 HTML 内容 -- [ ] 支持自定义样式 - -### 代码质量 - -- [ ] TypeScript 类型完整 -- [ ] 单元测试覆盖率 > 90% -- [ ] 代码审查通过 -- [ ] 性能测试通过 - -### 用户体验 - -- [ ] 交互流畅自然 -- [ ] 视觉效果美观 -- [ ] 可访问性良好 -- [ ] 文档清晰完整 - -### 项目集成 - -- [ ] 与现有组件库风格一致 -- [ ] 构建和打包正常 -- [ ] 在示例项目中正常运行 -- [ ] 组件状态更新为 100% diff --git a/.gitignore b/.gitignore index bd24369d..59a11056 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ packages/ccui/build packages/ccui/coverage packages/ccui/ui/**/__snapshots__ .pnpm-debug.log -.claude \ No newline at end of file +.claude +.codeflicker \ No newline at end of file diff --git a/packages/ccui/package.json b/packages/ccui/package.json index 0af19ed0..113b88f1 100644 --- a/packages/ccui/package.json +++ b/packages/ccui/package.json @@ -25,7 +25,8 @@ "module": "vue-ccui.es.js", "style": "style.css", "scripts": { - "test": "vitest", + "test": "vitest run", + "test:watch": "vitest", "coverage": "vitest run --coverage" }, "dependencies": { diff --git a/packages/ccui/ui/popover/index.ts b/packages/ccui/ui/popover/index.ts new file mode 100644 index 00000000..1f544c57 --- /dev/null +++ b/packages/ccui/ui/popover/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Popover from './src/popover' + +Popover.install = function (app: App): void { + app.component(Popover.name, Popover) +} + +export { Popover } + +export default { + title: 'Popover 弹出框', + category: '反馈', + status: '100%', + install(app: App): void { + app.component(Popover.name, Popover) + }, +} diff --git a/packages/ccui/ui/popover/src/popover-types.ts b/packages/ccui/ui/popover/src/popover-types.ts new file mode 100644 index 00000000..b0b0b4a1 --- /dev/null +++ b/packages/ccui/ui/popover/src/popover-types.ts @@ -0,0 +1,128 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type PopoverPlacement + = | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' + | 'right' + | 'right-start' + | 'right-end' + +export type PopoverEffect = 'dark' | 'light' + +export type PopoverTrigger = 'hover' | 'click' | 'focus' | 'manual' | 'contextmenu' + +export const popoverProps = { + title: { + type: String, + default: '', + }, + content: { + type: String, + default: '', + }, + placement: { + type: String as PropType, + default: 'bottom' as PopoverPlacement, + }, + effect: { + type: String as PropType, + default: 'light' as PopoverEffect, + }, + visible: { + type: Boolean, + default: undefined, + }, + disabled: { + type: Boolean, + default: false, + }, + showArrow: { + type: Boolean, + default: true, + }, + trigger: { + type: String as PropType, + default: 'click' as PopoverTrigger, + }, + showAfter: { + type: Number, + default: 0, + }, + hideAfter: { + type: Number, + default: 200, + }, + popperClass: { + type: String, + default: '', + }, + offset: { + type: Number, + default: 4, + }, + rawContent: { + type: Boolean, + default: false, + }, + enterable: { + type: Boolean, + default: true, + }, + hideOnClickOutside: { + type: Boolean, + default: true, + }, + closeOnEsc: { + type: Boolean, + default: true, + }, + ariaLabel: { + type: String, + default: '', + }, + width: { + type: [Number, String] as PropType, + default: '', + }, + transition: { + type: String, + default: 'ccui-popover-fade', + }, + autoClose: { + type: Number, + default: 0, + }, + tabindex: { + type: [Number, String] as PropType, + default: 0, + }, + teleported: { + type: Boolean, + default: true, + }, + persistent: { + type: Boolean, + default: true, + }, + virtualTriggering: { + type: Boolean, + default: false, + }, + virtualRef: { + type: Object as PropType, + default: undefined, + }, + triggerKeys: { + type: Array as PropType, + default: () => ['Enter', ' '], + }, +} as const + +export type PopoverProps = ExtractPropTypes diff --git a/packages/ccui/ui/popover/src/popover.scss b/packages/ccui/ui/popover/src/popover.scss new file mode 100644 index 00000000..2afd1d81 --- /dev/null +++ b/packages/ccui/ui/popover/src/popover.scss @@ -0,0 +1,129 @@ +.ccui-popover { + position: relative; + display: inline-block; + + &__trigger { + display: inline-block; + } + + &__popper { + z-index: 2000; + border-radius: 4px; + font-size: 14px; + line-height: 1.4; + min-width: 10px; + width: max-content; + max-width: 400px; + word-wrap: break-word; + padding: 12px; + box-sizing: border-box; + + &--dark { + background: #303133; + color: #fff; + border: 1px solid #303133; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); + } + + &--light { + background: #fff; + color: #606266; + border: 1px solid #e4e7ed; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); + } + + // 位置相关的箭头样式 + &--top .ccui-popover__arrow { + bottom: -4px; + border-top-color: inherit; + border-bottom: none; + } + + &--bottom .ccui-popover__arrow { + top: -4px; + border-bottom-color: inherit; + border-top: none; + } + + &--left .ccui-popover__arrow { + right: -4px; + border-left-color: inherit; + border-right: none; + } + + &--right .ccui-popover__arrow { + left: -4px; + border-right-color: inherit; + border-left: none; + } + } + + &__header { + padding: 0 0 8px 0; + font-weight: 500; + font-size: 16px; + color: #303133; + border-radius: inherit; + + .ccui-popover__popper--dark & { + color: #fff; + } + } + + &__content { + padding: 0; + text-align: left; + border-radius: inherit; + font-size: 14px; + line-height: 1.4; + } + + &__arrow { + position: absolute; + width: 8px; + height: 8px; + background: inherit; + border: inherit; + transform: rotate(45deg); + z-index: -1; + } +} + +.ccui-popover-fade-enter-active, +.ccui-popover-fade-leave-active { + transition: opacity 0.15s ease; +} + +.ccui-popover-fade-enter-from, +.ccui-popover-fade-leave-to { + opacity: 0; +} + +// Element Plus 兼容的动画 +.el-fade-in-linear-enter-active, +.el-fade-in-linear-leave-active { + transition: opacity 0.2s linear; +} + +.el-fade-in-linear-enter-from, +.el-fade-in-linear-leave-to { + opacity: 0; +} + +.ccui-popover--disabled { + cursor: not-allowed; + + .ccui-popover__trigger { + pointer-events: none; + opacity: 0.6; + } +} + +@media (max-width: 768px) { + .ccui-popover { + &__popper { + font-size: 14px; + max-width: calc(100vw - 20px); + } + } +} diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx new file mode 100644 index 00000000..d4a51818 --- /dev/null +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -0,0 +1,431 @@ +import type { PopoverProps } from './popover-types' +import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' +import { computed, defineComponent, nextTick, onMounted, onUnmounted, ref, Teleport, Transition, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { popoverProps } from './popover-types' +import './popover.scss' + +let popoverIdCounter = 0 + +export default defineComponent({ + name: 'CPopover', + props: popoverProps, + emits: ['before-show', 'show', 'before-hide', 'hide', 'update:visible', 'before-enter', 'after-enter', 'before-leave', 'after-leave'], + setup(props: PopoverProps, { emit, slots, expose }) { + const ns = useNamespace('popover') + const popperId = `${ns.e('popper')}-${++popoverIdCounter}` + + const visible = ref(false) + const triggerRef = ref() + const popperRef = ref() + const arrowRef = ref() + const showTimer = ref() + const hideTimer = ref() + const autoCloseTimer = ref() + + const isControlled = computed(() => props.visible !== undefined) + const actualVisible = computed(() => (isControlled.value ? props.visible : visible.value)) + + // 虚拟触发支持 + const actualTriggerRef = computed(() => { + if (props.virtualTriggering && props.virtualRef) { + return props.virtualRef + } + return triggerRef.value + }) + + const popperClass = computed(() => { + return [ + ns.e('popper'), + ns.em('popper', props.effect), + ns.em('popper', props.placement.split('-')[0]), + props.popperClass, + ].filter(Boolean).join(' ') + }) + + const { floatingStyles, middlewareData, update } = useFloating( + computed(() => actualTriggerRef.value), + popperRef, + { + placement: props.placement as any, + middleware: [ + offset(props.offset), + flip(), + shift({ padding: 8 }), + ...(props.showArrow ? [arrow({ element: arrowRef })] : []), + ], + }, + ) + + const arrowStyles = computed(() => { + if (!props.showArrow || !middlewareData.value.arrow) + return {} + const { x, y } = middlewareData.value.arrow + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[props.placement.split('-')[0]] + return { + left: x != null ? `${x}px` : '', + top: y != null ? `${y}px` : '', + right: '', + bottom: '', + [staticSide!]: '-4px', + } + }) + + const clearTimers = () => { + if (showTimer.value) { + clearTimeout(showTimer.value) + showTimer.value = undefined + } + if (hideTimer.value) { + clearTimeout(hideTimer.value) + hideTimer.value = undefined + } + if (autoCloseTimer.value) { + clearTimeout(autoCloseTimer.value) + autoCloseTimer.value = undefined + } + } + + const doHide = () => { + clearTimers() + const hidePopover = () => { + emit('before-hide') + if (!isControlled.value) { + visible.value = false + } + emit('update:visible', false) + emit('hide') + } + if (props.hideAfter > 0 && props.trigger !== 'click') { + hideTimer.value = window.setTimeout(hidePopover, props.hideAfter) + } + else { + hidePopover() + } + } + + const doShow = () => { + if (props.disabled) + return + clearTimers() + const showPopover = () => { + emit('before-show') + if (!isControlled.value) { + visible.value = true + } + emit('update:visible', true) + nextTick(() => { + update() + emit('show') + // 设置自动关闭定时器 + if (props.autoClose > 0) { + autoCloseTimer.value = window.setTimeout(() => { + doHide() + }, props.autoClose) + } + }) + } + if (props.showAfter > 0) { + showTimer.value = window.setTimeout(showPopover, props.showAfter) + } + else { + showPopover() + } + } + + const handleMouseEnter = () => { + if (props.trigger === 'hover') { + doShow() + } + } + const handleMouseLeave = () => { + if (props.trigger === 'hover') { + doHide() + } + } + const handleClick = () => { + if (props.trigger === 'click') { + if (actualVisible.value) { + doHide() + } + else { + doShow() + } + } + } + const handleContextMenu = (e: MouseEvent) => { + if (props.trigger === 'contextmenu') { + e.preventDefault() + if (actualVisible.value) { + doHide() + } + else { + doShow() + } + } + } + const handleFocus = () => { + if (props.trigger === 'focus') { + doShow() + } + } + const handleBlur = () => { + if (props.trigger === 'focus') { + doHide() + } + } + const normalizeTriggerKey = (value: string) => { + if (!value) + return value + const lower = value.toLowerCase() + if (value === ' ' || lower === 'space' || lower === 'spacebar') + return 'Space' + return value + } + + const handleKeydown = (e: KeyboardEvent) => { + if (props.trigger !== 'focus') + return + + const normalizedEventKey = normalizeTriggerKey(e.key) + const matches = props.triggerKeys + .map(normalizeTriggerKey) + .includes(normalizedEventKey) + + if (!matches) + return + + e.preventDefault() + if (actualVisible.value) { + doHide() + } + else { + doShow() + } + } + const handlePopperMouseEnter = () => { + if (props.trigger === 'hover' && props.enterable) { + clearTimers() + } + } + const handlePopperMouseLeave = () => { + if (props.trigger === 'hover' && props.enterable) { + doHide() + } + } + + let cleanup: (() => void) | undefined + + onMounted(() => { + if (actualVisible.value && actualTriggerRef.value && popperRef.value) { + cleanup = autoUpdate(actualTriggerRef.value, popperRef.value, update) + } + + // 为虚拟触发元素添加事件监听 + if (props.virtualTriggering && props.virtualRef) { + const virtualEl = props.virtualRef + if (props.trigger === 'hover') { + virtualEl.addEventListener('mouseenter', handleMouseEnter) + virtualEl.addEventListener('mouseleave', handleMouseLeave) + } + else if (props.trigger === 'click') { + virtualEl.addEventListener('click', handleClick) + } + else if (props.trigger === 'focus') { + virtualEl.addEventListener('focus', handleFocus) + virtualEl.addEventListener('blur', handleBlur) + virtualEl.addEventListener('keydown', handleKeydown) + } + else if (props.trigger === 'contextmenu') { + virtualEl.addEventListener('contextmenu', handleContextMenu) + } + } + }) + const rootClass = computed(() => { + return [ns.b(), props.disabled ? ns.m('disabled') : ''].filter(Boolean).join(' ') + }) + const onDocumentMouseDown = (e: MouseEvent) => { + if (!actualVisible.value) + return + if (props.trigger === 'manual') + return + if (!props.hideOnClickOutside) + return + const rawTarget = e.target as any + const target: Node | null = rawTarget instanceof Node ? rawTarget : null + const triggerElement = props.virtualTriggering && props.virtualRef ? props.virtualRef : triggerRef.value + const inTrigger = !!(target && triggerElement && triggerElement.contains(target)) + const inPopper = !!(target && popperRef.value && popperRef.value.contains(target)) + if (!inTrigger && !inPopper) { + doHide() + } + } + const onDocumentKeydown = (e: KeyboardEvent) => { + if (!actualVisible.value) + return + if (props.trigger === 'manual') + return + if (!props.closeOnEsc) + return + if (e.key === 'Escape') { + doHide() + } + } + onUnmounted(() => { + clearTimers() + cleanup?.() + window.removeEventListener('mousedown', onDocumentMouseDown, true) + window.removeEventListener('keydown', onDocumentKeydown, true) + + // 清理虚拟触发元素的事件监听 + if (props.virtualTriggering && props.virtualRef) { + const virtualEl = props.virtualRef + virtualEl.removeEventListener('mouseenter', handleMouseEnter) + virtualEl.removeEventListener('mouseleave', handleMouseLeave) + virtualEl.removeEventListener('click', handleClick) + virtualEl.removeEventListener('focus', handleFocus) + virtualEl.removeEventListener('blur', handleBlur) + virtualEl.removeEventListener('keydown', handleKeydown) + virtualEl.removeEventListener('contextmenu', handleContextMenu) + } + }) + watch(() => props.visible, (newVal) => { + if (newVal !== undefined && newVal) { + nextTick(() => { + update() + }) + } + }) + watch(actualVisible, (newVal) => { + if (newVal) { + const triggerElement = actualTriggerRef.value + if (triggerElement && popperRef.value) { + cleanup?.() + cleanup = autoUpdate(triggerElement, popperRef.value, update) + } + window.addEventListener('mousedown', onDocumentMouseDown, true) + window.addEventListener('keydown', onDocumentKeydown, true) + } + else { + cleanup?.() + window.removeEventListener('mousedown', onDocumentMouseDown, true) + window.removeEventListener('keydown', onDocumentKeydown, true) + } + }) + + const renderArrow = () => { + if (!props.showArrow) + return null + const arrowClass = [ns.e('arrow'), ns.em('arrow', props.placement.split('-')[0])].join(' ') + return
+ } + + const renderHeader = () => { + const hasTitleSlot = !!slots.title + const hasTitleProp = !!props.title + if (!hasTitleSlot && !hasTitleProp) + return null + return ( +
+ {slots.title ? slots.title() : props.title} +
+ ) + } + + const renderContent = () => { + if (props.rawContent) { + return
+ } + if (slots.content) { + return slots.content() + } + return props.content + } + + // 暴露方法 + expose({ + hide: doHide, + }) + + return () => { + const triggerEvents: Record = {} + if (!props.virtualTriggering) { + if (props.trigger === 'hover') { + triggerEvents.onMouseenter = handleMouseEnter + triggerEvents.onMouseleave = handleMouseLeave + } + else if (props.trigger === 'click') { + triggerEvents.onClick = handleClick + } + else if (props.trigger === 'focus') { + triggerEvents.onFocus = handleFocus + triggerEvents.onBlur = handleBlur + triggerEvents.onKeydown = handleKeydown + } + else if (props.trigger === 'contextmenu') { + triggerEvents.onContextmenu = handleContextMenu + } + } + + const popperContent = ( + + ) + + return ( +
+ {!props.virtualTriggering && ( +
+ {slots.default?.()} +
+ )} + + {props.virtualTriggering && slots.default?.()} + + emit('before-enter')} + onAfterEnter={() => emit('after-enter')} + onBeforeLeave={() => emit('before-leave')} + onAfterLeave={() => emit('after-leave')} + > + {actualVisible.value && (props.teleported + ? {popperContent} + : popperContent + )} + +
+ ) + } + }, +}) diff --git a/packages/ccui/ui/popover/test/popover.test.ts b/packages/ccui/ui/popover/test/popover.test.ts new file mode 100644 index 00000000..5d49e3c6 --- /dev/null +++ b/packages/ccui/ui/popover/test/popover.test.ts @@ -0,0 +1,514 @@ +import { mount, shallowMount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { Popover } from '../index' + +describe('popover', () => { + let wrapper: any + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }) + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 768, + }) + }) + + afterEach(() => { + if (wrapper) { + wrapper.unmount() + } + vi.clearAllTimers() + }) + + describe('基础功能', () => { + it('正确渲染组件', () => { + wrapper = shallowMount(Popover, { + slots: { + default: '', + }, + }) + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.ccui-popover').exists()).toBe(true) + expect(wrapper.find('.ccui-popover__trigger').exists()).toBe(true) + }) + + it('显示内容与标题', async () => { + wrapper = mount(Popover, { + props: { + title: 'Title', + content: 'Popover content', + visible: true, + }, + slots: { + default: '', + }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + expect(wrapper.find('.ccui-popover__header').text()).toBe('Title') + expect(wrapper.find('.ccui-popover__content').text()).toBe('Popover content') + }) + + it('支持插槽内容', async () => { + wrapper = mount(Popover, { + props: { + visible: true, + }, + slots: { + default: '', + title: '
Custom Title
', + content: '
Custom content
', + }, + }) + await nextTick() + expect(wrapper.find('.custom-title').exists()).toBe(true) + expect(wrapper.find('.custom-content').exists()).toBe(true) + }) + + it('支持 HTML 内容', async () => { + wrapper = mount(Popover, { + props: { + content: 'Bold', + rawContent: true, + visible: true, + }, + slots: { + default: '', + }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__content strong').exists()).toBe(true) + }) + + it('支持设置宽度', async () => { + wrapper = mount(Popover, { + props: { + content: 'W', + width: '200px', + visible: true, + }, + slots: { + default: '', + }, + }) + await nextTick() + const popper = wrapper.find('.ccui-popover__popper') + expect(popper.element.style.width).toBe('200px') + }) + }) + + describe('主题与样式', () => { + it('应用 light 主题', async () => { + wrapper = mount(Popover, { + props: { + content: 'Test', + effect: 'light', + visible: true, + }, + slots: { + default: '', + }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper--light').exists()).toBe(true) + }) + + it('应用 dark 主题', async () => { + wrapper = mount(Popover, { + props: { + content: 'Test', + effect: 'dark', + visible: true, + }, + slots: { + default: '', + }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper--dark').exists()).toBe(true) + }) + + it('应用位置样式', async () => { + const placements = ['top', 'bottom', 'left', 'right'] + for (const placement of placements) { + wrapper = mount(Popover, { + props: { + content: 'Test', + placement: placement as any, + visible: true, + }, + slots: { + default: '', + }, + }) + await nextTick() + expect(wrapper.find(`.ccui-popover__popper--${placement}`).exists()).toBe(true) + wrapper.unmount() + } + }) + + it('显示箭头', async () => { + wrapper = mount(Popover, { + props: { + content: 'Test', + showArrow: true, + visible: true, + }, + slots: { default: '' }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__arrow').exists()).toBe(true) + }) + + it('隐藏箭头', async () => { + wrapper = mount(Popover, { + props: { + content: 'Test', + showArrow: false, + visible: true, + }, + slots: { default: '' }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__arrow').exists()).toBe(false) + }) + }) + + describe('交互功能', () => { + it('点击时切换显示状态', async () => { + wrapper = mount(Popover, { + props: { + content: 'Test', + trigger: 'click', + }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + + it('悬停时显示与隐藏', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'hover', hideAfter: 0 }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('mouseenter') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + await trigger.trigger('mouseleave') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + + it('获得焦点时显示,失焦时隐藏', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'focus', hideAfter: 0 }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('focus') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + await trigger.trigger('blur') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + }) + + describe('禁用与延迟', () => { + it('禁用时不显示', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', disabled: true, trigger: 'hover' }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('mouseenter') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + + describe('延迟', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + it('延迟显示', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'hover', showAfter: 100 }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('mouseenter') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + vi.advanceTimersByTime(100) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + }) + it('延迟隐藏', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'hover', hideAfter: 100 }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('mouseenter') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + await trigger.trigger('mouseleave') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + vi.advanceTimersByTime(100) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + }) + }) + + describe('事件与可访问性', () => { + it('触发事件', async () => { + const beforeShow = vi.fn() + const show = vi.fn() + const beforeHide = vi.fn() + const hide = vi.fn() + wrapper = mount(Popover, { + props: { + 'content': 'Test', + 'trigger': 'hover', + 'hideAfter': 0, + 'onBefore-show': beforeShow, + 'onShow': show, + 'onBefore-hide': beforeHide, + 'onHide': hide, + }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('mouseenter') + await nextTick() + expect(beforeShow).toHaveBeenCalled() + expect(show).toHaveBeenCalled() + await trigger.trigger('mouseleave') + await nextTick() + expect(beforeHide).toHaveBeenCalled() + expect(hide).toHaveBeenCalled() + }) + + it('aRIA 属性', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', ariaLabel: 'Test popover', visible: true }, + slots: { default: '' }, + }) + await nextTick() + const trigger = wrapper.find('.ccui-popover__trigger') + const popper = wrapper.find('.ccui-popover__popper') + expect(trigger.attributes('aria-label')).toBe('Test popover') + expect(trigger.attributes('aria-describedby')).toBe('ccui-popover__popper') + expect(popper.attributes('role')).toBe('dialog') + }) + }) + + describe('外部交互', () => { + it('点击页面空白处应关闭(默认)', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'click' }, + slots: { default: '' }, + attachTo: document.body, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + window.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + + it('hideOnClickOutside=false 时点击外部不关闭', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'click', hideOnClickOutside: false }, + slots: { default: '' }, + attachTo: document.body, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + window.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + }) + + it('按下 Escape 应关闭(默认)', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'click' }, + slots: { default: '' }, + attachTo: document.body, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + const escEvent = new KeyboardEvent('keydown', { key: 'Escape' }) + window.dispatchEvent(escEvent) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + }) + + describe('新增功能测试', () => { + it('右键菜单触发', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'contextmenu' }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('contextmenu') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + }) + + it('虚拟触发功能', async () => { + const virtualElement = document.createElement('div') + document.body.appendChild(virtualElement) + + wrapper = mount(Popover, { + props: { + content: 'Test', + virtualTriggering: true, + virtualRef: virtualElement, + trigger: 'manual', + visible: true, + }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + expect(wrapper.find('.ccui-popover__trigger').exists()).toBe(false) + + document.body.removeChild(virtualElement) + }) + + it('自动关闭功能', async () => { + vi.useFakeTimers() + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'click', autoClose: 1000 }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + + vi.advanceTimersByTime(1000) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + vi.useRealTimers() + }) + + it('键盘触发功能', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', trigger: 'focus', triggerKeys: ['Enter', ' '] }, + slots: { default: '' }, + }) + const trigger = wrapper.find('.ccui-popover__trigger') + + // 测试 Enter 键 + await trigger.trigger('focus') + await trigger.trigger('keydown', { key: 'Enter' }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + + // 再次按 Enter 键关闭 + await trigger.trigger('keydown', { key: 'Enter' }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + + // 测试空格键 + await trigger.trigger('keydown', { key: ' ' }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + }) + + it('teleport 功能', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', visible: true, teleported: true }, + slots: { default: '' }, + attachTo: document.body, + }) + await nextTick() + + // 检查弹出框是否被传送到 body 中 + const popperInBody = document.body.querySelector('.ccui-popover__popper') + expect(popperInBody).toBeTruthy() + }) + + it('动画事件触发', async () => { + const beforeEnter = vi.fn() + const afterEnter = vi.fn() + const beforeLeave = vi.fn() + const afterLeave = vi.fn() + + wrapper = mount(Popover, { + props: { + 'content': 'Test', + 'trigger': 'click', + 'onBefore-enter': beforeEnter, + 'onAfter-enter': afterEnter, + 'onBefore-leave': beforeLeave, + 'onAfter-leave': afterLeave, + }, + slots: { default: '' }, + }) + + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + + // 模拟动画事件 + const transition = wrapper.findComponent({ name: 'Transition' }) + if (transition.exists()) { + await transition.vm.$emit('before-enter') + await transition.vm.$emit('after-enter') + + expect(beforeEnter).toHaveBeenCalled() + expect(afterEnter).toHaveBeenCalled() + + await trigger.trigger('click') + await nextTick() + + await transition.vm.$emit('before-leave') + await transition.vm.$emit('after-leave') + + expect(beforeLeave).toHaveBeenCalled() + expect(afterLeave).toHaveBeenCalled() + } + }) + + it('exposes methods', async () => { + wrapper = mount(Popover, { + props: { content: 'Test', visible: true }, + slots: { default: '' }, + }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + + // 调用暴露的 hide 方法 + wrapper.vm.hide() + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + }) + }) +}) diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md new file mode 100644 index 00000000..bf320f28 --- /dev/null +++ b/packages/docs/components/popover/index.md @@ -0,0 +1,602 @@ +# Popover 弹出框 + +用于在不打断用户流程的情况下展示补充信息和操作内容,支持标题、富文本内容、不同触发方式与位置控制。 + +## 何时使用 + +- 需要在不打断用户流程的情况下展示补充信息和操作内容 +- 支持标题、富文本内容、不同触发方式与位置控制 +- 需要展示比 Tooltip 更复杂的内容和操作 + +## 基本用法 + +最简单的用法,点击触发显示弹出框。 + +:::demo + +```vue + + + + + +``` + +::: + +## 悬停触发 + +鼠标悬停时显示弹出框。 + +:::demo + +```vue + + + + + +``` + +::: + +## 自定义内容与标题插槽 + +支持自定义标题和内容插槽,可以插入任意 Vue 组件。 + +:::demo + +```vue + + + + + +``` + +::: + +## 位置与主题 + +支持 12 个方向的位置和两种主题样式。 + +:::demo + +```vue + + + + + +``` + +::: + +## 受控显示 + +通过 `v-model` 或 `visible` 属性手动控制弹出框的显示状态。 + +:::demo + +```vue + + + + + +``` + +::: + +## 右键菜单触发 + +支持右键菜单触发方式。 + +:::demo + +```vue + + + + + +``` + +::: + +## 虚拟触发 + +支持虚拟元素触发,适用于触发元素和展示内容分离的场景。 + +:::demo + +```vue + + + + + +``` + +::: + +## 嵌套操作 + +可以在 Popover 中嵌套其他组件和操作。 + +:::demo + +```vue + + + + + +``` + +::: + +## 自动关闭 + +设置自动关闭时间,弹出框会在指定时间后自动隐藏。 + +:::demo + +```vue + + + + + +``` + +::: + +## API + +### Popover Props + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ------------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------- | ------------------ | +| title | 标题文本,也可以通过 `slot#title` 传入 | string | — | — | +| content | 显示的内容,也可以通过 `slot#content` 传入 | string | — | — | +| placement | Popover 的出现位置 | string | top/top-start/top-end/bottom/bottom-start/bottom-end/left/left-start/left-end/right/right-start/right-end | bottom | +| effect | 默认提供的主题 | string | dark/light | light | +| visible / v-model:visible | 状态是否可见 | boolean | — | false | +| disabled | Popover 是否可用 | boolean | — | false | +| show-arrow | 是否显示 Popover 箭头 | boolean | — | true | +| trigger | 触发方式 | string | hover/focus/click/manual/contextmenu | click | +| show-after | 延迟出现,单位毫秒 | number | — | 0 | +| hide-after | 延迟关闭,单位毫秒 | number | — | 200 | +| popper-class | 为 Popover 的 popper 添加类名 | string | — | — | +| offset | 出现位置的偏移量 | number | — | 4 | +| raw-content | 是否将 content 作为 HTML 字符串处理 | boolean | — | false | +| enterable | 鼠标是否可进入到 popover 中 | boolean | — | true | +| hide-on-click-outside | 是否在点击外部时隐藏 | boolean | — | true | +| close-on-esc | 是否支持 ESC 键关闭 | boolean | — | true | +| aria-label | 屏幕阅读器标签 | string | — | — | +| width | 弹层宽度 | number\|string | — | — | +| transition | 定义渐变动画 | string | — | ccui-popover-fade | +| auto-close | 自动关闭时间,单位毫秒 | number | — | 0 | +| tabindex | Popover 组件的 tabindex | number\|string | — | 0 | +| teleported | 是否将 popover 插入至 body 元素 | boolean | — | true | +| persistent | 是否持久化 | boolean | — | true | +| virtual-triggering | 是否启用虚拟触发器 | boolean | — | false | +| virtual-ref | 虚拟触发器的参照元素 | HTMLElement | — | — | +| trigger-keys | 键盘触发按键 | string[] | — | ['Enter', 'Space'] | + +### Popover Events + +| 事件名 | 说明 | 回调参数 | +| -------------- | ------------------ | -------- | +| before-show | 显示前触发 | — | +| show | 显示时触发 | — | +| before-hide | 隐藏前触发 | — | +| hide | 隐藏时触发 | — | +| update:visible | 状态变更时触发 | visible | +| before-enter | 显示动画播放前触发 | — | +| after-enter | 显示动画播放后触发 | — | +| before-leave | 隐藏动画播放前触发 | — | +| after-leave | 隐藏动画播放后触发 | — | + +### Popover Slots + +| 插槽名 | 说明 | +| ------- | ------------------------- | +| default | Popover 触发 & 引用的元素 | +| title | 自定义标题 | +| content | 自定义内容 | + +### Popover Exposes + +| 方法名 | 说明 | 类型 | +| ------ | ---------- | ---------- | +| hide | 隐藏弹出框 | () => void |