From 19894ebfaa44deb300e1ffb3b29f552006e1665f Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 12:22:38 +0800 Subject: [PATCH 01/13] feat: popover --- packages/ccui/ui/popover/index.ts | 17 + packages/ccui/ui/popover/src/popover-types.ts | 88 +++++ packages/ccui/ui/popover/src/popover.scss | 81 +++++ packages/ccui/ui/popover/src/popover.tsx | 266 +++++++++++++++ packages/ccui/ui/popover/test/popover.test.ts | 323 ++++++++++++++++++ packages/docs/components/popover/index.md | 124 +++++++ 6 files changed, 899 insertions(+) create mode 100644 packages/ccui/ui/popover/index.ts create mode 100644 packages/ccui/ui/popover/src/popover-types.ts create mode 100644 packages/ccui/ui/popover/src/popover.scss create mode 100644 packages/ccui/ui/popover/src/popover.tsx create mode 100644 packages/ccui/ui/popover/test/popover.test.ts create mode 100644 packages/docs/components/popover/index.md 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..7c0ee471 --- /dev/null +++ b/packages/ccui/ui/popover/src/popover-types.ts @@ -0,0 +1,88 @@ +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' + +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: 8, + }, + rawContent: { + type: Boolean, + default: false, + }, + enterable: { + type: Boolean, + default: true, + }, + ariaLabel: { + type: String, + default: '', + }, + width: { + type: [Number, String] as PropType, + default: '', + }, +} 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..846fe947 --- /dev/null +++ b/packages/ccui/ui/popover/src/popover.scss @@ -0,0 +1,81 @@ +.ccui-popover { + position: relative; + display: inline-block; + + &__trigger { + display: inline-block; + } + + &__popper { + z-index: 2000; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; + min-width: 10px; + word-wrap: break-word; + + &--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); + } + } + + &__header { + padding: 8px 12px 0 12px; + font-weight: 500; + border-radius: inherit; + } + + &__content { + padding: 8px 12px 12px 12px; + text-align: left; + border-radius: inherit; + } + + &__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; +} + +.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..d3fca9d0 --- /dev/null +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -0,0 +1,266 @@ +import type { PopoverProps } from './popover-types' +import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' +import { computed, defineComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { popoverProps } from './popover-types' +import './popover.scss' + +export default defineComponent({ + name: 'CPopover', + props: popoverProps, + emits: ['before-show', 'show', 'before-hide', 'hide', 'update:visible'], + setup(props: PopoverProps, { emit, slots }) { + const ns = useNamespace('popover') + + const visible = ref(false) + const triggerRef = ref() + const popperRef = ref() + const arrowRef = ref() + const showTimer = ref() + const hideTimer = ref() + + const isControlled = computed(() => props.visible !== undefined) + const actualVisible = computed(() => (isControlled.value ? props.visible : visible.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(triggerRef, 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 + } + } + + 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.showAfter > 0) { + showTimer.value = window.setTimeout(showPopover, props.showAfter) + } + else { + showPopover() + } + } + + 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 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 handleFocus = () => { + if (props.trigger === 'focus') { + doShow() + } + } + const handleBlur = () => { + if (props.trigger === 'focus') { + doHide() + } + } + 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 && triggerRef.value && popperRef.value) { + cleanup = autoUpdate(triggerRef.value, popperRef.value, update) + } + }) + onUnmounted(() => { + clearTimers() + cleanup?.() + }) + watch(() => props.visible, (newVal) => { + if (newVal !== undefined && newVal) { + nextTick(() => { + update() + }) + } + }) + watch(actualVisible, (newVal) => { + if (newVal && triggerRef.value && popperRef.value) { + cleanup?.() + cleanup = autoUpdate(triggerRef.value, popperRef.value, update) + } + else { + cleanup?.() + } + }) + + 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 + } + + return () => { + const triggerEvents: Record = {} + 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 + } + + return ( +
+
+ {slots.default?.()} +
+ + {actualVisible.value && ( + + )} +
+ ) + } + }, +}) 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..2703befd --- /dev/null +++ b/packages/ccui/ui/popover/test/popover.test.ts @@ -0,0 +1,323 @@ +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') + }) + }) +}) diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md new file mode 100644 index 00000000..5b36138d --- /dev/null +++ b/packages/docs/components/popover/index.md @@ -0,0 +1,124 @@ +# Popover 弹出框 + +### 何时使用 +- 需要在不打断用户流程的情况下展示补充信息和操作内容 +- 支持标题、富文本内容、不同触发方式与位置控制 + +### 基本用法 +:::demo + +```vue + +``` + +::: + +### 悬停触发 +:::demo + +```vue + +``` + +::: + +### 自定义内容与标题插槽 +:::demo + +```vue + +``` + +::: + +### 位置与主题 +:::demo + +```vue + +``` + +::: + +### 受控显示 +:::demo + +```vue + + + +``` + +::: + +### API + +| 参数 | 类型 | 默认 | 说明 | +| ---- | ---- | ---- | ---- | +| `title` | `string` | `''` | 标题文本,支持 `#title` 插槽 | +| `content` | `string` | `''` | 内容文本,支持 `#content` 插槽 | +| `placement` | `'top'\|...` | `'bottom'` | 弹出层位置 | +| `effect` | `'dark'\|'light'` | `'light'` | 主题样式 | +| `visible` | `boolean` | `undefined` | 受控显隐状态,配合 `update:visible` | +| `disabled` | `boolean` | `false` | 是否禁用触发 | +| `showArrow` | `boolean` | `true` | 是否显示箭头 | +| `trigger` | `'hover'\|'click'\|'focus'\|'manual'` | `'click'` | 触发方式 | +| `showAfter` | `number` | `0` | 显示延迟(ms) | +| `hideAfter` | `number` | `200` | 隐藏延迟(ms),`click` 不延迟 | +| `popperClass` | `string` | `''` | 弹出层附加类名 | +| `offset` | `number` | `8` | 触发元素与弹出层的偏移 | +| `rawContent` | `boolean` | `false` | 是否将 `content` 按 HTML 渲染 | +| `enterable` | `boolean` | `true` | 悬停至弹层是否保持显示 | +| `ariaLabel` | `string` | `''` | 触发器 `aria-label` 文本 | +| `width` | `number\|string` | `''` | 弹层宽度,如 `200` 或 `200px` | + +| 事件 | 说明 | +| ---- | ---- | +| `before-show` | 显示前触发 | +| `show` | 显示后触发 | +| `before-hide` | 隐藏前触发 | +| `hide` | 隐藏后触发 | +| `update:visible` | 受控模式显隐变更 | + +| 插槽名 | 说明 | +| ---- | ---- | +| `default` | 触发器内容 | +| `title` | 标题区域 | +| `content` | 主体内容区域 | From 4ace55aef886d28045d0254ef24c245b011534c9 Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 12:24:33 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=E6=96=87=E6=A1=A3=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/docs/components/popover/index.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md index 5b36138d..009fc05b 100644 --- a/packages/docs/components/popover/index.md +++ b/packages/docs/components/popover/index.md @@ -10,7 +10,7 @@ ```vue ``` @@ -23,7 +23,7 @@ ```vue ``` @@ -42,10 +42,10 @@ - 自定义内容 + 自定义内容 ``` @@ -58,10 +58,10 @@ ```vue @@ -81,7 +81,7 @@ const toggle = () => v.value = !v.value ``` From f0bf4f10f49095ce653db5f4b3e5be978b62f27f Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 13:22:19 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=20=E6=A0=B7=E5=BC=8F=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/popover/src/popover-types.ts | 2 +- packages/ccui/ui/popover/src/popover.scss | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ccui/ui/popover/src/popover-types.ts b/packages/ccui/ui/popover/src/popover-types.ts index 7c0ee471..a95eccc5 100644 --- a/packages/ccui/ui/popover/src/popover-types.ts +++ b/packages/ccui/ui/popover/src/popover-types.ts @@ -65,7 +65,7 @@ export const popoverProps = { }, offset: { type: Number, - default: 8, + default: 4, }, rawContent: { type: Boolean, diff --git a/packages/ccui/ui/popover/src/popover.scss b/packages/ccui/ui/popover/src/popover.scss index 846fe947..4db508f5 100644 --- a/packages/ccui/ui/popover/src/popover.scss +++ b/packages/ccui/ui/popover/src/popover.scss @@ -12,6 +12,7 @@ font-size: 12px; line-height: 1.4; min-width: 10px; + width: max-content; word-wrap: break-word; &--dark { @@ -36,7 +37,7 @@ } &__content { - padding: 8px 12px 12px 12px; + padding: 8px; text-align: left; border-radius: inherit; } From 02f5a61775726aa6f70c993b43225bdfbf47205d Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 13:26:04 +0800 Subject: [PATCH 04/13] =?UTF-8?q?chore:=20test=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=8F=AA=E6=89=A7=E8=A1=8C=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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": { From d7418bf80141f8146100cbee45d39ddbd780fee9 Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 14:01:32 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=E4=BB=A3=E7=A0=81=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/popover/src/popover-types.ts | 8 +++++ packages/ccui/ui/popover/src/popover.tsx | 36 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/ccui/ui/popover/src/popover-types.ts b/packages/ccui/ui/popover/src/popover-types.ts index a95eccc5..5e56d86a 100644 --- a/packages/ccui/ui/popover/src/popover-types.ts +++ b/packages/ccui/ui/popover/src/popover-types.ts @@ -75,6 +75,14 @@ export const popoverProps = { type: Boolean, default: true, }, + hideOnClickOutside: { + type: Boolean, + default: true, + }, + closeOnEsc: { + type: Boolean, + default: true, + }, ariaLabel: { type: String, default: '', diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index d3fca9d0..9233a4dd 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -160,9 +160,39 @@ export default defineComponent({ cleanup = autoUpdate(triggerRef.value, popperRef.value, update) } }) + 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 target = e.target as Node | null + const inTrigger = !!(target && triggerRef.value && triggerRef.value.contains(target as Node)) + const inPopper = !!(target && popperRef.value && popperRef.value.contains(target as Node)) + 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) }) watch(() => props.visible, (newVal) => { if (newVal !== undefined && newVal) { @@ -175,9 +205,13 @@ export default defineComponent({ if (newVal && triggerRef.value && popperRef.value) { cleanup?.() cleanup = autoUpdate(triggerRef.value, 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) } }) @@ -225,7 +259,7 @@ export default defineComponent({ } return ( -
+
Date: Thu, 13 Nov 2025 14:08:07 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20bug=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/popover/src/popover.tsx | 15 +++--- packages/ccui/ui/popover/test/popover.test.ts | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index 9233a4dd..7ebba3f5 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -170,9 +170,10 @@ export default defineComponent({ return if (!props.hideOnClickOutside) return - const target = e.target as Node | null - const inTrigger = !!(target && triggerRef.value && triggerRef.value.contains(target as Node)) - const inPopper = !!(target && popperRef.value && popperRef.value.contains(target as Node)) + const rawTarget = e.target as any + const target: Node | null = rawTarget instanceof Node ? rawTarget : null + const inTrigger = !!(target && triggerRef.value && triggerRef.value.contains(target)) + const inPopper = !!(target && popperRef.value && popperRef.value.contains(target)) if (!inTrigger && !inPopper) { doHide() } @@ -202,9 +203,11 @@ export default defineComponent({ } }) watch(actualVisible, (newVal) => { - if (newVal && triggerRef.value && popperRef.value) { - cleanup?.() - cleanup = autoUpdate(triggerRef.value, popperRef.value, update) + if (newVal) { + if (triggerRef.value && popperRef.value) { + cleanup?.() + cleanup = autoUpdate(triggerRef.value, popperRef.value, update) + } window.addEventListener('mousedown', onDocumentMouseDown, true) window.addEventListener('keydown', onDocumentKeydown, true) } diff --git a/packages/ccui/ui/popover/test/popover.test.ts b/packages/ccui/ui/popover/test/popover.test.ts index 2703befd..589abae6 100644 --- a/packages/ccui/ui/popover/test/popover.test.ts +++ b/packages/ccui/ui/popover/test/popover.test.ts @@ -320,4 +320,52 @@ describe('popover', () => { 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) + }) + }) }) From 406ba50e69bc12c6cf4958bedbabef27fcc9116b Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 14:19:28 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E6=96=87=E6=A1=A3=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/docs/components/popover/index.md | 311 +++++++++++++++++----- 1 file changed, 240 insertions(+), 71 deletions(-) diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md index 009fc05b..94bf5093 100644 --- a/packages/docs/components/popover/index.md +++ b/packages/docs/components/popover/index.md @@ -1,124 +1,293 @@ # Popover 弹出框 -### 何时使用 +用于在不打断用户流程的情况下展示补充信息和操作内容,支持标题、富文本内容、不同触发方式与位置控制。 + +## 何时使用 + - 需要在不打断用户流程的情况下展示补充信息和操作内容 - 支持标题、富文本内容、不同触发方式与位置控制 +- 需要展示比 Tooltip 更复杂的内容和操作 + +## 基本用法 + +最简单的用法,点击触发显示弹出框。 -### 基本用法 :::demo ```vue + + + + ``` ::: -### 悬停触发 +## 悬停触发 + +鼠标悬停时显示弹出框。 + :::demo ```vue + + + + ``` ::: -### 自定义内容与标题插槽 +## 自定义内容与标题插槽 + +支持自定义标题和内容插槽,可以插入任意 Vue 组件。 + :::demo ```vue + + + + ``` ::: -### 位置与主题 +## 位置与主题 + +支持 12 个方向的位置和两种主题样式。 + :::demo ```vue + + + + ``` ::: -### 受控显示 +## 受控显示 + +通过 `v-model` 或 `visible` 属性手动控制弹出框的显示状态。 + :::demo ```vue - + + ``` ::: -### API - -| 参数 | 类型 | 默认 | 说明 | -| ---- | ---- | ---- | ---- | -| `title` | `string` | `''` | 标题文本,支持 `#title` 插槽 | -| `content` | `string` | `''` | 内容文本,支持 `#content` 插槽 | -| `placement` | `'top'\|...` | `'bottom'` | 弹出层位置 | -| `effect` | `'dark'\|'light'` | `'light'` | 主题样式 | -| `visible` | `boolean` | `undefined` | 受控显隐状态,配合 `update:visible` | -| `disabled` | `boolean` | `false` | 是否禁用触发 | -| `showArrow` | `boolean` | `true` | 是否显示箭头 | -| `trigger` | `'hover'\|'click'\|'focus'\|'manual'` | `'click'` | 触发方式 | -| `showAfter` | `number` | `0` | 显示延迟(ms) | -| `hideAfter` | `number` | `200` | 隐藏延迟(ms),`click` 不延迟 | -| `popperClass` | `string` | `''` | 弹出层附加类名 | -| `offset` | `number` | `8` | 触发元素与弹出层的偏移 | -| `rawContent` | `boolean` | `false` | 是否将 `content` 按 HTML 渲染 | -| `enterable` | `boolean` | `true` | 悬停至弹层是否保持显示 | -| `ariaLabel` | `string` | `''` | 触发器 `aria-label` 文本 | -| `width` | `number\|string` | `''` | 弹层宽度,如 `200` 或 `200px` | - -| 事件 | 说明 | -| ---- | ---- | -| `before-show` | 显示前触发 | -| `show` | 显示后触发 | -| `before-hide` | 隐藏前触发 | -| `hide` | 隐藏后触发 | -| `update:visible` | 受控模式显隐变更 | - -| 插槽名 | 说明 | -| ---- | ---- | -| `default` | 触发器内容 | -| `title` | 标题区域 | -| `content` | 主体内容区域 | +## 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 | click | +| show-after | 延迟出现,单位毫秒 | number | — | 0 | +| hide-after | 延迟关闭,单位毫秒 | number | — | 200 | +| popper-class | 为 Popover 的 popper 添加类名 | string | — | — | +| offset | 出现位置的偏移量 | number | — | 8 | +| raw-content | 是否将 content 作为 HTML 字符串处理 | boolean | — | false | +| enterable | 鼠标是否可进入到 popover 中 | boolean | — | true | +| aria-label | 屏幕阅读器标签 | string | — | — | +| width | 弹层宽度 | number\|string | — | — | + +### Popover Events + +| 事件名 | 说明 | 回调参数 | +| -------------- | -------------- | -------- | +| before-show | 显示前触发 | — | +| show | 显示时触发 | — | +| before-hide | 隐藏前触发 | — | +| hide | 隐藏时触发 | — | +| update:visible | 状态变更时触发 | visible | + +### Popover Slots + +| 插槽名 | 说明 | +| ------- | ------------------------- | +| default | Popover 触发 & 引用的元素 | +| title | 自定义标题 | +| content | 自定义内容 | From 1e1cb531a79ab2aca6fb8c34c6dce5d29011d042 Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 16:46:14 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/popover/src/popover-types.ts | 34 ++- packages/ccui/ui/popover/src/popover.scss | 53 +++- packages/ccui/ui/popover/src/popover.tsx | 255 +++++++++++----- packages/ccui/ui/popover/test/popover.test.ts | 143 +++++++++ packages/docs/components/popover/index.md | 287 +++++++++++++++++- 5 files changed, 685 insertions(+), 87 deletions(-) diff --git a/packages/ccui/ui/popover/src/popover-types.ts b/packages/ccui/ui/popover/src/popover-types.ts index 5e56d86a..03b32c36 100644 --- a/packages/ccui/ui/popover/src/popover-types.ts +++ b/packages/ccui/ui/popover/src/popover-types.ts @@ -16,7 +16,7 @@ export type PopoverPlacement export type PopoverEffect = 'dark' | 'light' -export type PopoverTrigger = 'hover' | 'click' | 'focus' | 'manual' +export type PopoverTrigger = 'hover' | 'click' | 'focus' | 'manual' | 'contextmenu' export const popoverProps = { title: { @@ -91,6 +91,38 @@ export const popoverProps = { 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', 'Space'], + }, } as const export type PopoverProps = ExtractPropTypes diff --git a/packages/ccui/ui/popover/src/popover.scss b/packages/ccui/ui/popover/src/popover.scss index 4db508f5..2afd1d81 100644 --- a/packages/ccui/ui/popover/src/popover.scss +++ b/packages/ccui/ui/popover/src/popover.scss @@ -9,11 +9,14 @@ &__popper { z-index: 2000; border-radius: 4px; - font-size: 12px; + 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; @@ -28,18 +31,51 @@ 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: 8px 12px 0 12px; + padding: 0 0 8px 0; font-weight: 500; + font-size: 16px; + color: #303133; border-radius: inherit; + + .ccui-popover__popper--dark & { + color: #fff; + } } &__content { - padding: 8px; + padding: 0; text-align: left; border-radius: inherit; + font-size: 14px; + line-height: 1.4; } &__arrow { @@ -63,6 +99,17 @@ 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; diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index 7ebba3f5..d289dd06 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -1,6 +1,6 @@ import type { PopoverProps } from './popover-types' import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' -import { computed, defineComponent, nextTick, onMounted, onUnmounted, ref, watch } from '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' @@ -8,8 +8,8 @@ import './popover.scss' export default defineComponent({ name: 'CPopover', props: popoverProps, - emits: ['before-show', 'show', 'before-hide', 'hide', 'update:visible'], - setup(props: PopoverProps, { emit, slots }) { + 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 visible = ref(false) @@ -18,10 +18,19 @@ export default defineComponent({ 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'), @@ -31,15 +40,19 @@ export default defineComponent({ ].filter(Boolean).join(' ') }) - const { floatingStyles, middlewareData, update } = useFloating(triggerRef, popperRef, { - placement: props.placement as any, - middleware: [ - offset(props.offset), - flip(), - shift({ padding: 8 }), - ...(props.showArrow ? [arrow({ element: arrowRef })] : []), - ], - }) + 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) @@ -69,6 +82,28 @@ export default defineComponent({ 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 = () => { @@ -84,6 +119,12 @@ export default defineComponent({ nextTick(() => { update() emit('show') + // 设置自动关闭定时器 + if (props.autoClose > 0) { + autoCloseTimer.value = window.setTimeout(() => { + doHide() + }, props.autoClose) + } }) } if (props.showAfter > 0) { @@ -94,24 +135,6 @@ export default defineComponent({ } } - 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 handleMouseEnter = () => { if (props.trigger === 'hover') { doShow() @@ -132,6 +155,17 @@ export default defineComponent({ } } } + const handleContextMenu = (e: MouseEvent) => { + if (props.trigger === 'contextmenu') { + e.preventDefault() + if (actualVisible.value) { + doHide() + } + else { + doShow() + } + } + } const handleFocus = () => { if (props.trigger === 'focus') { doShow() @@ -142,6 +176,17 @@ export default defineComponent({ doHide() } } + const handleKeydown = (e: KeyboardEvent) => { + if (props.trigger === 'focus' && props.triggerKeys.includes(e.key)) { + e.preventDefault() + if (actualVisible.value) { + doHide() + } + else { + doShow() + } + } + } const handlePopperMouseEnter = () => { if (props.trigger === 'hover' && props.enterable) { clearTimers() @@ -156,8 +201,28 @@ export default defineComponent({ let cleanup: (() => void) | undefined onMounted(() => { - if (actualVisible.value && triggerRef.value && popperRef.value) { - cleanup = autoUpdate(triggerRef.value, popperRef.value, update) + 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(() => { @@ -172,7 +237,8 @@ export default defineComponent({ return const rawTarget = e.target as any const target: Node | null = rawTarget instanceof Node ? rawTarget : null - const inTrigger = !!(target && triggerRef.value && triggerRef.value.contains(target)) + 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() @@ -194,6 +260,18 @@ export default defineComponent({ 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) { @@ -204,9 +282,10 @@ export default defineComponent({ }) watch(actualVisible, (newVal) => { if (newVal) { - if (triggerRef.value && popperRef.value) { + const triggerElement = actualTriggerRef.value + if (triggerElement && popperRef.value) { cleanup?.() - cleanup = autoUpdate(triggerRef.value, popperRef.value, update) + cleanup = autoUpdate(triggerElement, popperRef.value, update) } window.addEventListener('mousedown', onDocumentMouseDown, true) window.addEventListener('keydown', onDocumentKeydown, true) @@ -247,55 +326,83 @@ export default defineComponent({ return props.content } + // 暴露方法 + expose({ + hide: doHide, + }) + return () => { const triggerEvents: Record = {} - 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 + 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 + } } - return ( -
-
- {slots.default?.()} + const popperContent = ( + + ) - {actualVisible.value && ( + return ( +
+ {!props.virtualTriggering && ( )} + + {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 index 589abae6..692eb1aa 100644 --- a/packages/ccui/ui/popover/test/popover.test.ts +++ b/packages/ccui/ui/popover/test/popover.test.ts @@ -368,4 +368,147 @@ describe('popover', () => { 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', 'Space'] }, + 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) + + // 测试 Space 键 + await trigger.trigger('keydown', { key: 'Space' }) + 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 index 94bf5093..92aadd1e 100644 --- a/packages/docs/components/popover/index.md +++ b/packages/docs/components/popover/index.md @@ -251,6 +251,255 @@ export default defineComponent({ ::: +## 右键菜单触发 + +支持右键菜单触发方式。 + +:::demo + +```vue + + + + + +``` + +::: + +## 虚拟触发 + +支持虚拟元素触发,适用于触发元素和展示内容分离的场景。 + +:::demo + +```vue + + + + + +``` + +::: + +## 嵌套操作 + +可以在 Popover 中嵌套其他组件和操作。 + +:::demo + +```vue + + + + + +``` + +::: + +## 自动关闭 + +设置自动关闭时间,弹出框会在指定时间后自动隐藏。 + +:::demo + +```vue + + + + + +``` + +::: + ## API ### Popover Props @@ -264,25 +513,39 @@ export default defineComponent({ | visible / v-model:visible | 状态是否可见 | boolean | — | false | | disabled | Popover 是否可用 | boolean | — | false | | show-arrow | 是否显示 Popover 箭头 | boolean | — | true | -| trigger | 触发方式 | string | hover/focus/click/manual | click | +| trigger | 触发方式 | string | hover/focus/click/manual/contextmenu | click | | show-after | 延迟出现,单位毫秒 | number | — | 0 | | hide-after | 延迟关闭,单位毫秒 | number | — | 200 | | popper-class | 为 Popover 的 popper 添加类名 | string | — | — | -| offset | 出现位置的偏移量 | number | — | 8 | +| 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-show | 显示前触发 | — | +| show | 显示时触发 | — | +| before-hide | 隐藏前触发 | — | +| hide | 隐藏时触发 | — | +| update:visible | 状态变更时触发 | visible | +| before-enter | 显示动画播放前触发 | — | +| after-enter | 显示动画播放后触发 | — | +| before-leave | 隐藏动画播放前触发 | — | +| after-leave | 隐藏动画播放后触发 | — | ### Popover Slots @@ -291,3 +554,9 @@ export default defineComponent({ | default | Popover 触发 & 引用的元素 | | title | 自定义标题 | | content | 自定义内容 | + +### Popover Exposes + +| 方法名 | 说明 | 类型 | +| ------ | ---------- | ---------- | +| hide | 隐藏弹出框 | () => void | From 68b4ed74175787f0be00c9ff0e35083006ae09ad Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 17:03:38 +0800 Subject: [PATCH 09/13] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/docs/components/popover/index.md | 102 ++++++++-------------- 1 file changed, 36 insertions(+), 66 deletions(-) diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md index 92aadd1e..d7f32013 100644 --- a/packages/docs/components/popover/index.md +++ b/packages/docs/components/popover/index.md @@ -36,9 +36,6 @@ export default defineComponent({ ``` @@ -72,9 +69,6 @@ export default defineComponent({ ``` @@ -119,9 +113,6 @@ export default defineComponent({ ``` @@ -176,10 +167,6 @@ export default defineComponent({ ``` @@ -270,8 +254,8 @@ export default defineComponent({ ``` @@ -327,7 +308,7 @@ export default defineComponent({ ``` @@ -504,33 +474,33 @@ export default defineComponent({ ### 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 | — | — | +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ------------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------- | ------------------ | +| 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 From c8097239537110b52b4e07bb56a4fa4985a0a45d Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 17:25:19 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=E6=96=87=E6=A1=A3=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/docs/components/popover/index.md | 138 ++++++++++++++++------ 1 file changed, 104 insertions(+), 34 deletions(-) diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md index d7f32013..bf320f28 100644 --- a/packages/docs/components/popover/index.md +++ b/packages/docs/components/popover/index.md @@ -137,54 +137,124 @@ export default defineComponent({ ``` From 18794c2f057bbb14a529391bc5dbb5ea93068f97 Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 18:08:22 +0800 Subject: [PATCH 11/13] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20codeflicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/tooltip-component-development.md | 387 ------------------ .gitignore | 3 +- 2 files changed, 2 insertions(+), 388 deletions(-) delete mode 100644 .codeflicker/specs/tooltip-component-development.md 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 From 4de621d78ec29405b4e4fb9636170a2ca05757fc Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 18:09:38 +0800 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=E4=BB=A3=E7=A0=81=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/popover/src/popover-types.ts | 2 +- packages/ccui/ui/popover/src/popover.tsx | 34 ++++++++++++++----- packages/ccui/ui/popover/test/popover.test.ts | 6 ++-- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/ccui/ui/popover/src/popover-types.ts b/packages/ccui/ui/popover/src/popover-types.ts index 03b32c36..b0b0b4a1 100644 --- a/packages/ccui/ui/popover/src/popover-types.ts +++ b/packages/ccui/ui/popover/src/popover-types.ts @@ -121,7 +121,7 @@ export const popoverProps = { }, triggerKeys: { type: Array as PropType, - default: () => ['Enter', 'Space'], + default: () => ['Enter', ' '], }, } as const diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index d289dd06..dd079cee 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -176,15 +176,33 @@ export default defineComponent({ 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' && props.triggerKeys.includes(e.key)) { - e.preventDefault() - if (actualVisible.value) { - doHide() - } - else { - doShow() - } + 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 = () => { diff --git a/packages/ccui/ui/popover/test/popover.test.ts b/packages/ccui/ui/popover/test/popover.test.ts index 692eb1aa..5d49e3c6 100644 --- a/packages/ccui/ui/popover/test/popover.test.ts +++ b/packages/ccui/ui/popover/test/popover.test.ts @@ -420,7 +420,7 @@ describe('popover', () => { it('键盘触发功能', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'focus', triggerKeys: ['Enter', 'Space'] }, + props: { content: 'Test', trigger: 'focus', triggerKeys: ['Enter', ' '] }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -436,8 +436,8 @@ describe('popover', () => { await nextTick() expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) - // 测试 Space 键 - await trigger.trigger('keydown', { key: 'Space' }) + // 测试空格键 + await trigger.trigger('keydown', { key: ' ' }) await nextTick() expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) }) From 71fe2ce2855f6f1b71816d720567aa9e14bfd312 Mon Sep 17 00:00:00 2001 From: vae <18137693952@163.com> Date: Thu, 13 Nov 2025 18:21:08 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/popover/src/popover.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index dd079cee..d4a51818 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -5,12 +5,15 @@ 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() @@ -374,7 +377,7 @@ export default defineComponent({ ref={popperRef} class={popperClass.value} role="dialog" - id={ns.e('popper')} + id={popperId} style={{ ...floatingStyles.value, zIndex: 2000, @@ -398,7 +401,7 @@ export default defineComponent({