diff --git a/.github/workflows/buildComponents.yml b/.github/workflows/buildComponents.yml index df800feb..bf504868 100644 --- a/.github/workflows/buildComponents.yml +++ b/.github/workflows/buildComponents.yml @@ -1,4 +1,4 @@ -name: Test +name: BuildComponents on: pull_request: @@ -30,5 +30,5 @@ jobs: - name: Install dependencies run: pnpm install - - name: Run tests - run: pnpm --filter cli build:components \ No newline at end of file + - name: Run build:components + run: pnpm --filter cli build:components diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d54309c7..36fff284 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,4 +31,4 @@ jobs: run: pnpm install - name: Run tests - run: pnpm --filter ccui test \ No newline at end of file + run: pnpm --filter vue3-ccui test diff --git a/.ls-lint.yml b/.ls-lint.yml index c99002fc..c18d47b4 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -21,3 +21,4 @@ ignore: - packages/ccui/ui/theme - packages/cli/node_modules - packages/build + - node_modules diff --git a/packages/ccui/ui/input-number/index.ts b/packages/ccui/ui/input-number/index.ts new file mode 100644 index 00000000..b8fb6fef --- /dev/null +++ b/packages/ccui/ui/input-number/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import InputNumber from './src/input-number' + +InputNumber.install = function (app: App) { + app.component(InputNumber.name!, InputNumber) +} + +export { InputNumber } + +export default { + title: 'InputNumber 数字输入框', + category: '数据录入', + status: '100%', + install(app: App) { + app.component(InputNumber.name!, InputNumber) + }, +} diff --git a/packages/ccui/ui/input-number/src/input-number-types.ts b/packages/ccui/ui/input-number/src/input-number-types.ts new file mode 100644 index 00000000..0a3e966d --- /dev/null +++ b/packages/ccui/ui/input-number/src/input-number-types.ts @@ -0,0 +1,127 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +/** + * 数字输入框的值类型 + * - number: 有效的数字值 + * - undefined: 空值(当 allowEmpty 为 true 时) + */ +export type InputNumberValue = number | undefined + +/** + * 输入框尺寸类型 + */ +export type ISize = 'lg' | 'md' | 'sm' + +/** + * 控制按钮位置类型 + */ +export type ControlsPosition = 'right' | 'both' + +/** + * 格式化选项接口 + */ +export interface FormatOptions { + /** 数字精度,小数点后保留位数 */ + precision?: number + /** 是否允许空值 */ + allowEmpty: boolean + /** 最小值 */ + min: number + /** 最大值 */ + max: number +} + +export const inputNumberProps = { + modelValue: { + type: Number as PropType, + default: undefined, + }, + step: { + type: Number, + default: 1, + }, + placeholder: { + type: String, + default: '', + }, + max: { + type: Number, + default: Infinity, + }, + min: { + type: Number, + default: -Infinity, + }, + disabled: { + type: Boolean, + default: false, + }, + readonly: { + type: Boolean, + default: false, + }, + precision: { + type: Number as PropType, + default: undefined, + }, + size: { + type: String as PropType, + default: 'md', + }, + reg: { + type: [RegExp, String] as PropType, + default: undefined, + }, + allowEmpty: { + type: Boolean, + default: false, + }, + showGlowStyle: { + type: Boolean, + default: true, + }, + controls: { + type: Boolean, + default: true, + }, + controlsPosition: { + type: String as PropType, + default: 'both', + }, +} as const + +export type InputNumberProps = ExtractPropTypes + +/** + * 数字输入框事件类型定义 + */ +export interface InputNumberEmits { + /** 值更新事件 */ + 'update:modelValue': [value: InputNumberValue] + /** 值变化事件 */ + 'change': [currentVal: InputNumberValue, oldVal: InputNumberValue] + /** 失去焦点事件 */ + 'blur': [event: FocusEvent] + /** 获得焦点事件 */ + 'focus': [event: FocusEvent] + /** 输入事件 */ + 'input': [currentValue: InputNumberValue] +} + +/** + * 数字输入框实例类型 + */ +export interface InputNumberInstance { + /** 获取当前值 */ + getValue: () => InputNumberValue + /** 设置值 */ + setValue: (value: InputNumberValue) => void + /** 聚焦 */ + focus: () => void + /** 失焦 */ + blur: () => void + /** 增加值 */ + increase: () => void + /** 减少值 */ + decrease: () => void +} diff --git a/packages/ccui/ui/input-number/src/input-number.scss b/packages/ccui/ui/input-number/src/input-number.scss new file mode 100644 index 00000000..33f97875 --- /dev/null +++ b/packages/ccui/ui/input-number/src/input-number.scss @@ -0,0 +1,254 @@ +@use '../../style-var/index.scss' as *; + +// 通用混入 +@mixin disabled-state { + cursor: not-allowed; + color: $ccui-disabled-text; + background-color: $ccui-disabled-bg; + border-color: $ccui-disabled-line; + + &:hover { + color: $ccui-disabled-text; + background-color: $ccui-disabled-bg; + border-color: $ccui-disabled-line; + } +} + +@mixin focus-state { + border-color: $ccui-primary; + box-shadow: 0 0 0 2px rgba(118, 147, 245, 0.2); +} + +@mixin hide-controls { + .#{$cls-prefix}-input-number__increase, + .#{$cls-prefix}-input-number__decrease { + display: none; + } +} + +@mixin control-button-base { + position: relative; + display: flex; + align-items: center; + justify-content: center; + color: $ccui-text; + text-align: center; + cursor: pointer; + background-color: $ccui-base-bg; + transition: all 0.3s; + user-select: none; + + &:hover { + color: $ccui-primary; + background-color: $ccui-list-item-hover-bg; + } + + &:active { + background-color: $ccui-list-item-active-bg; + } + + &.is-disabled { + @include disabled-state; + } + + svg { + fill: currentColor; + } +} + +@mixin size-variant($width, $height, $font-size, $inner-height, $control-width, $svg-size, $padding: 0 11px) { + width: $width; + height: $height; + font-size: $font-size; + + .#{$cls-prefix}-input-number__inner { + height: $inner-height; + padding: $padding; + font-size: $font-size; + } + + .#{$cls-prefix}-input-number__increase, + .#{$cls-prefix}-input-number__decrease { + width: $control-width; + + svg { + width: $svg-size; + height: $svg-size; + } + } + + &.#{$cls-prefix}-input-number--controls-right { + .#{$cls-prefix}-input-number__inner { + padding-left: 0; + padding-right: $control-width; + } + + .#{$cls-prefix}-input-number__controls { + width: $control-width; + height: $inner-height; + } + } +} + +.#{$cls-prefix}-input-number { + position: relative; + display: inline-flex; + align-items: stretch; + width: 150px; + height: 32px; + font-size: $ccui-font-size; + line-height: 1.5; + color: $ccui-text; + background-color: $ccui-base-bg; + border: 1px solid $ccui-form-control-line; + border-radius: $ccui-border-radius; + transition: all 0.3s; + + &:hover { + border-color: $ccui-primary; + } + + &--focused { + @include focus-state; + } + + &--glow { + @include focus-state; + } + + &--disabled { + @include disabled-state; + + .#{$cls-prefix}-input-number__inner { + cursor: not-allowed; + background-color: transparent; + color: $ccui-disabled-text; + } + + .#{$cls-prefix}-input-number__increase, + .#{$cls-prefix}-input-number__decrease { + @include disabled-state; + } + } + + &--readonly { + .#{$cls-prefix}-input-number__inner { + cursor: default; + } + + @include hide-controls; + } + + &__input { + position: relative; + flex: 1; + display: inline-block; + width: 100%; + } + + &__inner { + width: 100%; + height: 30px; + padding: 0 11px; + font-size: inherit; + line-height: 1.5; + color: $ccui-text; + background-color: transparent; + border: none; + outline: none; + transition: all 0.3s; + appearance: none; + text-align: center; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + margin: 0; + appearance: none; + } + + &[type='number'] { + appearance: textfield; + } + + &::placeholder { + color: $ccui-text-weak; + } + + &:focus { + outline: none; + } + } + + &__increase, + &__decrease { + @include control-button-base; + width: 32px; + height: 100%; + font-size: 12px; + + svg { + width: 12px; + height: 12px; + } + } + + &__increase { + border-left: 1px solid $ccui-form-control-line; + } + + &__decrease { + border-right: 1px solid $ccui-form-control-line; + } + + // 无控制按钮样式 + &--without-controls { + .#{$cls-prefix}-input-number__inner { + padding: 0 11px; + } + + @include hide-controls; + } + + // 右侧控制按钮样式 + &--controls-right { + .#{$cls-prefix}-input-number__inner { + padding: 0 20px 0 0; + } + + .#{$cls-prefix}-input-number__controls { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + width: 22px; + height: 30px; + border-radius: 0 $ccui-border-radius $ccui-border-radius 0; + } + + .#{$cls-prefix}-input-number__increase, + .#{$cls-prefix}-input-number__decrease { + width: 22px; + border: unset; + border-left: 1px solid $ccui-form-control-line; + + svg { + width: 10px; + height: 10px; + } + } + + .#{$cls-prefix}-input-number__increase { + border-bottom: 1px solid $ccui-form-control-line; + } + } + + // 尺寸变体 + &--lg { + @include size-variant(200px, 40px, $ccui-font-size-lg, 38px, 40px, 14px); + } + + &--sm { + @include size-variant(100px, 24px, $ccui-font-size-sm, 22px, 24px, 8px, 0 7px); + } +} diff --git a/packages/ccui/ui/input-number/src/input-number.tsx b/packages/ccui/ui/input-number/src/input-number.tsx new file mode 100644 index 00000000..92264615 --- /dev/null +++ b/packages/ccui/ui/input-number/src/input-number.tsx @@ -0,0 +1,318 @@ +import type { + InputNumberInstance, + InputNumberProps, + InputNumberValue, +} from './input-number-types' +import { computed, defineComponent, nextTick, ref, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { inputNumberProps } from './input-number-types' +import './input-number.scss' + +export default defineComponent({ + name: 'CInputNumber', + props: inputNumberProps, + emits: ['update:modelValue', 'change', 'blur', 'focus', 'input'], + setup(props: InputNumberProps, { emit, expose }) { + const ns = useNamespace('input-number') + const inputRef = ref() + + // 内部值状态 + const innerValue = ref(props.modelValue) + const focused = ref(false) + + // 计算显示值 + const displayValue = computed(() => { + if (innerValue.value === undefined || innerValue.value === null) { + return props.allowEmpty ? '' : '0' + } + + if (props.precision !== undefined) { + return Number(innerValue.value).toFixed(props.precision) + } + + return String(innerValue.value) + }) + + // 计算是否禁用增加按钮 + const maxDisabled = computed(() => { + if (innerValue.value === undefined) + return false + return innerValue.value >= props.max + }) + + // 计算是否禁用减少按钮 + const minDisabled = computed(() => { + if (innerValue.value === undefined) + return false + return innerValue.value <= props.min + }) + + // 数值处理函数 + const formatValue = (value: number | string | undefined): InputNumberValue => { + if (value === '' || value === undefined || value === null) { + return props.allowEmpty ? undefined : 0 + } + + let numValue = typeof value === 'string' ? Number.parseFloat(value) : value + + if (Number.isNaN(numValue)) { + return props.allowEmpty ? undefined : 0 + } + + // 应用精度 + if (props.precision !== undefined) { + numValue = Number.parseFloat(numValue.toFixed(props.precision)) + } + + // 应用范围限制 + numValue = Math.max(props.min, Math.min(props.max, numValue)) + + return numValue + } + + // 更新值 + const updateValue = (newValue: InputNumberValue, triggerChange = true) => { + const oldValue = innerValue.value + innerValue.value = newValue + + emit('update:modelValue', newValue) + emit('input', newValue) + + if (triggerChange && oldValue !== newValue) { + emit('change', newValue, oldValue) + } + } + + // 输入处理 + const handleInput = (event: Event) => { + const target = event.target as HTMLInputElement + const value = target.value + + // 正则限制 + if (props.reg) { + const regex = typeof props.reg === 'string' ? new RegExp(props.reg) : props.reg + if (!regex.test(value)) { + target.value = displayValue.value + return + } + } + + // 空值处理 + if (value === '') { + if (props.allowEmpty) { + updateValue(undefined, false) + } + else { + target.value = displayValue.value + } + return + } + + const numValue = formatValue(value) + updateValue(numValue, false) + } + + // 输入变化处理 + const handleInputChange = (event: Event) => { + const target = event.target as HTMLInputElement + const numValue = formatValue(target.value) + updateValue(numValue) + + // 更新显示值 + nextTick(() => { + if (inputRef.value) { + inputRef.value.value = displayValue.value + } + }) + } + + // 焦点处理 + const handleFocus = (event: FocusEvent) => { + focused.value = true + emit('focus', event) + } + + const handleBlur = (event: FocusEvent) => { + focused.value = false + emit('blur', event) + + // 失焦时格式化值 + if (inputRef.value) { + inputRef.value.value = displayValue.value + } + } + + // 增加值 + const increase = () => { + if (props.disabled || maxDisabled.value) + return + + const currentValue = innerValue.value ?? 0 + const newValue = formatValue(currentValue + props.step) + updateValue(newValue) + } + + // 减少值 + const decrease = () => { + if (props.disabled || minDisabled.value) + return + + const currentValue = innerValue.value ?? 0 + const newValue = formatValue(currentValue - props.step) + updateValue(newValue) + } + + // 键盘事件处理 + const handleKeydown = (event: KeyboardEvent) => { + if (props.disabled || props.readonly) + return + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + increase() + break + case 'ArrowDown': + event.preventDefault() + decrease() + break + } + } + + // 暴露的方法 + const focus = () => { + inputRef.value?.focus() + } + + const blur = () => { + inputRef.value?.blur() + } + + // 实例方法 + const instance: InputNumberInstance = { + getValue: () => innerValue.value, + setValue: (value: InputNumberValue) => updateValue(value), + focus, + blur, + increase, + decrease, + } + + expose(instance) + + // 监听 modelValue 变化 + watch( + () => props.modelValue, + (newValue) => { + if (newValue !== innerValue.value) { + innerValue.value = newValue + } + }, + { immediate: true }, + ) + + return () => { + const controlsAtRight = props.controlsPosition === 'right' + + return ( +
+ {/* 左侧控制按钮 */} + {props.controls && !controlsAtRight && ( + + + + + + )} + + {/* 输入框 */} +
+ +
+ + {/* 左侧增加按钮 */} + {props.controls && !controlsAtRight && ( + + + + + + )} + + {/* 右侧控制按钮 */} + {props.controls && controlsAtRight && ( +
+ + + + + + + + + + +
+ )} +
+ ) + } + }, +}) diff --git a/packages/ccui/ui/input-number/test/input-number.test.ts b/packages/ccui/ui/input-number/test/input-number.test.ts new file mode 100644 index 00000000..76a59a79 --- /dev/null +++ b/packages/ccui/ui/input-number/test/input-number.test.ts @@ -0,0 +1,291 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' +import { InputNumber } from '../index' + +describe('inputNumber', () => { + it('should render correctly', () => { + const wrapper = mount(InputNumber) + expect(wrapper.find('.ccui-input-number').exists()).toBe(true) + expect(wrapper.find('.ccui-input-number__inner').exists()).toBe(true) + }) + + it('should support v-model', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 5, + }, + }) + + const input = wrapper.find('.ccui-input-number__inner') + expect((input.element as HTMLInputElement).value).toBe('5') + }) + + it('should handle input change', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 0, + }, + }) + + const input = wrapper.find('.ccui-input-number__inner') + await input.setValue('10') + await input.trigger('input') + await input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + expect(wrapper.emitted('input')).toBeTruthy() + }) + + it('should handle increase and decrease', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 5, + step: 2, + }, + }) + + const increaseBtn = wrapper.find('.ccui-input-number__increase') + const decreaseBtn = wrapper.find('.ccui-input-number__decrease') + + await increaseBtn.trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([7]) + + await decreaseBtn.trigger('click') + expect(wrapper.emitted('update:modelValue')?.[1]).toEqual([5]) + }) + + it('should respect min and max limits', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 5, + min: 0, + max: 10, + }, + }) + + const input = wrapper.find('.ccui-input-number__inner') + + // Test max limit + await input.setValue('15') + await input.trigger('input') + await input.trigger('change') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([10]) + + // Reset wrapper to test min limit separately + const wrapper2 = mount(InputNumber, { + props: { + modelValue: 5, + min: 0, + max: 10, + }, + }) + + const input2 = wrapper2.find('.ccui-input-number__inner') + + // Test min limit + await input2.setValue('-5') + await input2.trigger('input') + await input2.trigger('change') + expect(wrapper2.emitted('update:modelValue')?.[0]).toEqual([0]) + }) + + it('should handle precision', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 1.234, + precision: 2, + }, + }) + + const input = wrapper.find('.ccui-input-number__inner') + expect((input.element as HTMLInputElement).value).toBe('1.23') + }) + + it('should disable buttons when reaching limits', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 10, + min: 0, + max: 10, + }, + }) + + const increaseBtn = wrapper.find('.ccui-input-number__increase') + const decreaseBtn = wrapper.find('.ccui-input-number__decrease') + + expect(increaseBtn.classes()).toContain('is-disabled') + + await wrapper.setProps({ modelValue: 0 }) + await nextTick() + expect(decreaseBtn.classes()).toContain('is-disabled') + }) + + it('should handle disabled state', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 5, + disabled: true, + }, + }) + + expect(wrapper.classes()).toContain('ccui-input-number--disabled') + + const input = wrapper.find('.ccui-input-number__inner') + expect((input.element as HTMLInputElement).disabled).toBe(true) + + const increaseBtn = wrapper.find('.ccui-input-number__increase') + await increaseBtn.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeFalsy() + }) + + it('should handle readonly state', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 5, + readonly: true, + }, + }) + + expect(wrapper.classes()).toContain('ccui-input-number--readonly') + + const input = wrapper.find('.ccui-input-number__inner') + expect((input.element as HTMLInputElement).readOnly).toBe(true) + }) + + it('should support different sizes', async () => { + const wrapper = mount(InputNumber, { + props: { + size: 'lg', + }, + }) + + expect(wrapper.classes()).toContain('ccui-input-number--lg') + + await wrapper.setProps({ size: 'sm' }) + expect(wrapper.classes()).toContain('ccui-input-number--sm') + }) + + it('should handle controls position', async () => { + const wrapper = mount(InputNumber, { + props: { + controlsPosition: 'right', + }, + }) + + expect(wrapper.classes()).toContain('ccui-input-number--controls-right') + expect(wrapper.find('.ccui-input-number__controls').exists()).toBe(true) + }) + + it('should hide controls when controls is false', async () => { + const wrapper = mount(InputNumber, { + props: { + controls: false, + }, + }) + + expect(wrapper.classes()).toContain('ccui-input-number--without-controls') + expect(wrapper.find('.ccui-input-number__increase').exists()).toBe(false) + expect(wrapper.find('.ccui-input-number__decrease').exists()).toBe(false) + }) + + it('should handle keyboard events', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 5, + step: 1, + }, + }) + + const input = wrapper.find('.ccui-input-number__inner') + + await input.trigger('keydown', { key: 'ArrowUp' }) + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([6]) + + await input.trigger('keydown', { key: 'ArrowDown' }) + expect(wrapper.emitted('update:modelValue')?.[1]).toEqual([5]) + }) + + it('should handle focus and blur events', async () => { + const wrapper = mount(InputNumber) + const input = wrapper.find('.ccui-input-number__inner') + + await input.trigger('focus') + expect(wrapper.emitted('focus')).toBeTruthy() + expect(wrapper.classes()).toContain('ccui-input-number--focused') + + await input.trigger('blur') + expect(wrapper.emitted('blur')).toBeTruthy() + }) + + it('should handle allowEmpty option', async () => { + const wrapper = mount(InputNumber, { + props: { + allowEmpty: true, + }, + }) + + const input = wrapper.find('.ccui-input-number__inner') + await input.setValue('') + await input.trigger('change') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([undefined]) + }) + + it('should expose methods', () => { + const wrapper = mount(InputNumber) + const vm = wrapper.vm as any + + expect(typeof vm.getValue).toBe('function') + expect(typeof vm.setValue).toBe('function') + expect(typeof vm.focus).toBe('function') + expect(typeof vm.blur).toBe('function') + expect(typeof vm.increase).toBe('function') + expect(typeof vm.decrease).toBe('function') + }) + + it('应该正确处理精度设置', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 1.234567, + precision: 2, + }, + }) + + const input = wrapper.find('input') + expect(input.element.value).toBe('1.23') + }) + + it('应该支持自定义步长', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 0, + step: 0.1, + precision: 1, + }, + }) + + const increaseBtn = wrapper.find('.ccui-input-number__increase') + await increaseBtn.trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([0.1]) + }) + + it('应该在达到边界值时禁用按钮', async () => { + const wrapper = mount(InputNumber, { + props: { + modelValue: 10, + min: 0, + max: 10, + }, + }) + + const increaseBtn = wrapper.find('.ccui-input-number__increase') + const decreaseBtn = wrapper.find('.ccui-input-number__decrease') + + expect(increaseBtn.classes()).toContain('is-disabled') + + await wrapper.setProps({ modelValue: 0 }) + expect(decreaseBtn.classes()).toContain('is-disabled') + }) +}) diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index d4a51818..9e972c4e 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -24,7 +24,10 @@ export default defineComponent({ const autoCloseTimer = ref() const isControlled = computed(() => props.visible !== undefined) - const actualVisible = computed(() => (isControlled.value ? props.visible : visible.value)) + const actualVisible = computed(() => { + const val = isControlled.value ? props.visible : visible.value + return Boolean(val) + }) // 虚拟触发支持 const actualTriggerRef = computed(() => { @@ -219,33 +222,6 @@ export default defineComponent({ } } - 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(' ') }) @@ -276,6 +252,22 @@ export default defineComponent({ doHide() } } + + let cleanup: (() => void) | undefined + + onMounted(() => { + // 初始化时如果 visible 为 true,确保显示并监听文档级事件 + if (props.visible) { + nextTick(() => { + const triggerElement = actualTriggerRef.value + if (triggerElement && popperRef.value) { + cleanup = autoUpdate(triggerElement, popperRef.value, update) + } + window.addEventListener('mousedown', onDocumentMouseDown, true) + window.addEventListener('keydown', onDocumentKeydown, true) + }) + } + }) onUnmounted(() => { clearTimers() cleanup?.() @@ -295,10 +287,16 @@ export default defineComponent({ } }) watch(() => props.visible, (newVal) => { - if (newVal !== undefined && newVal) { - nextTick(() => { - update() - }) + if (isControlled.value) { + const boolVal = Boolean(newVal) + if (boolVal !== visible.value) { + visible.value = boolVal + if (boolVal) { + nextTick(() => { + update() + }) + } + } } }) watch(actualVisible, (newVal) => { diff --git a/packages/ccui/ui/popover/test/popover.test.ts b/packages/ccui/ui/popover/test/popover.test.ts index 5d49e3c6..ae98a486 100644 --- a/packages/ccui/ui/popover/test/popover.test.ts +++ b/packages/ccui/ui/popover/test/popover.test.ts @@ -44,6 +44,7 @@ describe('popover', () => { title: 'Title', content: 'Popover content', visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -59,6 +60,7 @@ describe('popover', () => { wrapper = mount(Popover, { props: { visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -77,6 +79,7 @@ describe('popover', () => { content: 'Bold', rawContent: true, visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -92,6 +95,7 @@ describe('popover', () => { content: 'W', width: '200px', visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -110,6 +114,7 @@ describe('popover', () => { content: 'Test', effect: 'light', visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -125,6 +130,7 @@ describe('popover', () => { content: 'Test', effect: 'dark', visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -142,6 +148,7 @@ describe('popover', () => { content: 'Test', placement: placement as any, visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '', @@ -159,6 +166,7 @@ describe('popover', () => { content: 'Test', showArrow: true, visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '' }, }) @@ -172,6 +180,7 @@ describe('popover', () => { content: 'Test', showArrow: false, visible: true, + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '' }, }) @@ -186,6 +195,7 @@ describe('popover', () => { props: { content: 'Test', trigger: 'click', + teleported: false, // 测试中禁用 teleport,以便在组件内部查找元素 }, slots: { default: '' }, }) @@ -200,7 +210,7 @@ describe('popover', () => { it('悬停时显示与隐藏', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'hover', hideAfter: 0 }, + props: { content: 'Test', trigger: 'hover', hideAfter: 0, teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -214,7 +224,7 @@ describe('popover', () => { it('获得焦点时显示,失焦时隐藏', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'focus', hideAfter: 0 }, + props: { content: 'Test', trigger: 'focus', hideAfter: 0, teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -230,7 +240,7 @@ describe('popover', () => { describe('禁用与延迟', () => { it('禁用时不显示', async () => { wrapper = mount(Popover, { - props: { content: 'Test', disabled: true, trigger: 'hover' }, + props: { content: 'Test', disabled: true, trigger: 'hover', teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -248,7 +258,7 @@ describe('popover', () => { }) it('延迟显示', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'hover', showAfter: 100 }, + props: { content: 'Test', trigger: 'hover', showAfter: 100, teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -261,7 +271,7 @@ describe('popover', () => { }) it('延迟隐藏', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'hover', hideAfter: 100 }, + props: { content: 'Test', trigger: 'hover', hideAfter: 100, teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -309,14 +319,16 @@ describe('popover', () => { it('aRIA 属性', async () => { wrapper = mount(Popover, { - props: { content: 'Test', ariaLabel: 'Test popover', visible: true }, + props: { content: 'Test', ariaLabel: 'Test popover', visible: true, teleported: false }, 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') + // aria-describedby 应该匹配实际的 popper ID,格式为 ccui-popover__popper-{数字} + const popperId = popper.attributes('id') + expect(trigger.attributes('aria-describedby')).toBe(popperId) expect(popper.attributes('role')).toBe('dialog') }) }) @@ -324,7 +336,7 @@ describe('popover', () => { describe('外部交互', () => { it('点击页面空白处应关闭(默认)', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'click' }, + props: { content: 'Test', trigger: 'click', teleported: false }, slots: { default: '' }, attachTo: document.body, }) @@ -339,7 +351,7 @@ describe('popover', () => { it('hideOnClickOutside=false 时点击外部不关闭', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'click', hideOnClickOutside: false }, + props: { content: 'Test', trigger: 'click', hideOnClickOutside: false, teleported: false }, slots: { default: '' }, attachTo: document.body, }) @@ -354,7 +366,7 @@ describe('popover', () => { it('按下 Escape 应关闭(默认)', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'click' }, + props: { content: 'Test', trigger: 'click', teleported: false }, slots: { default: '' }, attachTo: document.body, }) @@ -372,7 +384,7 @@ describe('popover', () => { describe('新增功能测试', () => { it('右键菜单触发', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'contextmenu' }, + props: { content: 'Test', trigger: 'contextmenu', teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -392,6 +404,7 @@ describe('popover', () => { virtualRef: virtualElement, trigger: 'manual', visible: true, + teleported: false, }, }) await nextTick() @@ -404,7 +417,7 @@ describe('popover', () => { it('自动关闭功能', async () => { vi.useFakeTimers() wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'click', autoClose: 1000 }, + props: { content: 'Test', trigger: 'click', autoClose: 1000, teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') @@ -420,26 +433,39 @@ describe('popover', () => { it('键盘触发功能', async () => { wrapper = mount(Popover, { - props: { content: 'Test', trigger: 'focus', triggerKeys: ['Enter', ' '] }, + props: { content: 'Test', trigger: 'focus', triggerKeys: ['Enter', ' '], hideAfter: 0, teleported: false }, slots: { default: '' }, }) const trigger = wrapper.find('.ccui-popover__trigger') - // 测试 Enter 键 + // focus 事件会自动显示 popover await trigger.trigger('focus') - await trigger.trigger('keydown', { key: 'Enter' }) await nextTick() expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) - // 再次按 Enter 键关闭 + // 测试 Enter 键 - 此时应该隐藏 popover + await trigger.trigger('keydown', { key: 'Enter' }) + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) + + // 再次按 Enter 键显示 popover await trigger.trigger('keydown', { key: 'Enter' }) await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + + // 测试空格键 - 应该隐藏 popover + await trigger.trigger('keydown', { key: ' ' }) + await nextTick() expect(wrapper.find('.ccui-popover__popper').exists()).toBe(false) - // 测试空格键 + // 再次按空格键显示 popover await trigger.trigger('keydown', { key: ' ' }) await nextTick() expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + + // 清理:失焦关闭 + await trigger.trigger('blur') + await nextTick() }) it('teleport 功能', async () => { @@ -465,6 +491,7 @@ describe('popover', () => { props: { 'content': 'Test', 'trigger': 'click', + 'teleported': false, 'onBefore-enter': beforeEnter, 'onAfter-enter': afterEnter, 'onBefore-leave': beforeLeave, @@ -499,12 +526,28 @@ describe('popover', () => { it('exposes methods', async () => { wrapper = mount(Popover, { - props: { content: 'Test', visible: true }, + props: { content: 'Test', visible: true, teleported: false }, slots: { default: '' }, }) await nextTick() expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + // 调用暴露的 hide 方法 + // 注意:在受控模式下(有 visible 属性),hide 方法不会直接修改 visible + // 所以需要测试非受控模式 + wrapper.unmount() + + wrapper = mount(Popover, { + props: { content: 'Test', teleported: false }, + slots: { default: '' }, + }) + + // 手动触发显示 + const trigger = wrapper.find('.ccui-popover__trigger') + await trigger.trigger('click') + await nextTick() + expect(wrapper.find('.ccui-popover__popper').exists()).toBe(true) + // 调用暴露的 hide 方法 wrapper.vm.hide() await nextTick() diff --git a/packages/ccui/ui/slider/index.ts b/packages/ccui/ui/slider/index.ts new file mode 100644 index 00000000..0715206d --- /dev/null +++ b/packages/ccui/ui/slider/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Slider from './src/slider' + +Slider.install = function (app: App): void { + app.component(Slider.name!, Slider) +} + +export { Slider } + +export default { + title: 'Slider 滑块', + category: '数据录入', + status: '100%', + install(app: App): void { + app.component(Slider.name!, Slider) + }, +} diff --git a/packages/ccui/ui/slider/src/composables/use-slider-tooltip.ts b/packages/ccui/ui/slider/src/composables/use-slider-tooltip.ts new file mode 100644 index 00000000..01f1c401 --- /dev/null +++ b/packages/ccui/ui/slider/src/composables/use-slider-tooltip.ts @@ -0,0 +1,126 @@ +import type { Ref } from 'vue' +import type { SliderProps } from '../slider-types' +import { computed, ref } from 'vue' + +export function useSliderTooltip( + props: SliderProps, + isDragging: Ref, + currentValue: Ref, +) { + const isHovering = ref(false) + const hoverIndex = ref(null) + + // 是否显示 Tooltip + const shouldShowTooltip = computed(() => props.showTooltip) + + // 格式化提示文本 + const formatTooltipText = (value: number) => { + if (props.tipsRenderer === null) { + return null + } + if (props.formatTooltip) { + return props.formatTooltip(value) + } + if (props.tipsRenderer) { + return props.tipsRenderer(value) + } + return value.toString() + } + + // 获取默认文本(用于没有 tipsRenderer 的情况) + const getDefaultText = (value: number) => { + if (props.formatTooltip) { + return props.formatTooltip(value) + } + return value.toString() + } + + // 格式化值文本用于无障碍访问 + const getAriaValueText = (value: number) => { + if (props.formatValueText) { + return props.formatValueText(value) + } + return value.toString() + } + + // 处理按钮悬停 + const handleButtonMouseEnter = (index: number) => { + if (!props.disabled) { + isHovering.value = true + hoverIndex.value = index + } + } + + const handleButtonMouseLeave = () => { + isHovering.value = false + hoverIndex.value = null + } + + // 获取当前值 + const getCurrentValue = (index: number) => { + if (props.range && Array.isArray(currentValue.value)) { + return currentValue.value[index] + } + return Array.isArray(currentValue.value) ? currentValue.value[0] : currentValue.value + } + + // 判断是否显示 tooltip + const shouldShowTooltipForButton = (index: number) => { + return shouldShowTooltip.value && (hoverIndex.value === index || isDragging.value) + } + + // 判断是否显示默认 tooltip(没有 tipsRenderer 的情况) + const shouldShowDefaultTooltipForButton = (index: number) => { + return props.showTooltip + && props.tipsRenderer === null + && props.showDefaultTooltip + && (hoverIndex.value === index || isDragging.value || props.persistent) + } + + // 获取 tooltip 内容 + const getTooltipContent = (index: number) => { + const value = getCurrentValue(index) + + if (shouldShowTooltipForButton(index) && formatTooltipText(value)) { + return formatTooltipText(value) + } + + if (shouldShowDefaultTooltipForButton(index)) { + return getDefaultText(value) + } + + return '' + } + + // 获取 tooltip 可见性 + const getTooltipVisible = (index: number) => { + return shouldShowTooltipForButton(index) || shouldShowDefaultTooltipForButton(index) + } + + // 获取 tooltip 位置 + const getTooltipPlacement = () => { + // 如果用户设置了 placement,则使用用户设置的值 + if (props.placement && props.placement !== 'top') { + return props.placement + } + // 默认根据垂直/水平模式设置位置 + return props.vertical ? 'right' : 'top' + } + + return { + isHovering, + hoverIndex, + shouldShowTooltip, + formatTooltipText, + getDefaultText, + getAriaValueText, + handleButtonMouseEnter, + handleButtonMouseLeave, + getCurrentValue, + shouldShowTooltipForButton, + shouldShowDefaultTooltipForButton, + getTooltipContent, + getTooltipVisible, + getTooltipPlacement, + } +} diff --git a/packages/ccui/ui/slider/src/composables/use-slider.ts b/packages/ccui/ui/slider/src/composables/use-slider.ts new file mode 100644 index 00000000..10ec41f7 --- /dev/null +++ b/packages/ccui/ui/slider/src/composables/use-slider.ts @@ -0,0 +1,403 @@ +import type { Ref } from 'vue' +import type { SliderProps } from '../slider-types' +import { computed, ref } from 'vue' + +export function useSliderValue( + props: SliderProps, + emit: (event: 'update:modelValue' | 'input' | 'change', value: number | number[]) => void, +) { + const currentValue = computed({ + get() { + return props.modelValue + }, + set(val: number | number[]) { + emit('update:modelValue', val) + emit('input', val) + }, + }) + + return { + currentValue, + } +} + +export function useSliderCalculation(props: SliderProps) { + const range = props.max - props.min + + // 计算百分比位置 + const getPercent = (value: number) => { + if (range <= 0) + return 0 + + const percent = ((value - props.min) / range) * 100 + return Math.max(0, Math.min(100, percent)) + } + + // 根据百分比计算值(按 step 和 min 对齐) + const getValueFromPercent = (percent: number) => { + if (range <= 0) + return props.min + + const raw = props.min + (percent / 100) * range + + if (props.step <= 0) + return Math.max(props.min, Math.min(props.max, raw)) + + const stepped + = props.min + Math.round((raw - props.min) / props.step) * props.step + + return Math.max(props.min, Math.min(props.max, stepped)) + } + + return { + getPercent, + getValueFromPercent, + } +} + +export function useSliderStyle( + props: SliderProps, + currentValue: Ref, + getPercent: (value: number) => number, +) { + // 计算滑块轨道样式 + const trackStyle = computed(() => { + if (props.range && Array.isArray(currentValue.value)) { + const [start, end] = currentValue.value + if (props.vertical) { + return { + bottom: `${getPercent(start)}%`, + height: `${getPercent(end) - getPercent(start)}%`, + } + } + + return { + left: `${getPercent(start)}%`, + width: `${getPercent(end) - getPercent(start)}%`, + } + } + else { + const value = Array.isArray(currentValue.value) ? currentValue.value[0] : currentValue.value + if (props.vertical) { + return { + height: `${getPercent(value)}%`, + } + } + + return { + width: `${getPercent(value)}%`, + } + } + }) + + // 计算第一个滑块位置 + const firstButtonStyle = computed(() => { + const value = Array.isArray(currentValue.value) ? currentValue.value[0] : currentValue.value + const percent = getPercent(value) + + if (props.vertical) { + return { bottom: `${percent}%` } + } + + return { left: `${percent}%` } + }) + + // 计算第二个滑块位置 + const secondButtonStyle = computed(() => { + if (props.range && Array.isArray(currentValue.value)) { + if (props.vertical) { + return { + bottom: `${getPercent(currentValue.value[1])}%`, + } + } + + return { + left: `${getPercent(currentValue.value[1])}%`, + } + } + return {} + }) + + return { + trackStyle, + firstButtonStyle, + secondButtonStyle, + } +} + +export function useSliderInteraction( + props: SliderProps, + currentValue: Ref, + emit: (event: 'update:modelValue' | 'input' | 'change', value: number | number[]) => void, + sliderRef: Ref, + getValueFromPercent: (percent: number) => number, +) { + const isDragging = ref(false) + const dragIndex = ref(null) + + // 获取鼠标/触摸位置 + const getPosition = (event: MouseEvent | TouchEvent) => { + const rect = sliderRef.value?.getBoundingClientRect() + if (!rect) + return 0 + + if (props.vertical) { + const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY + return ((rect.bottom - clientY) / rect.height) * 100 + } + + const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX + return ((clientX - rect.left) / rect.width) * 100 + } + + // 处理滑块点击 + const handleSliderClick = (event: MouseEvent) => { + if (props.disabled || isDragging.value) + return + + const percent = getPosition(event) + const newValue = getValueFromPercent(percent) + + if (props.range && Array.isArray(currentValue.value)) { + const [start, end] = currentValue.value + const mid = (start + end) / 2 + + if (newValue <= mid) { + currentValue.value = [Math.max(props.min, Math.min(newValue, end)), end] + } + else { + currentValue.value = [start, Math.min(props.max, Math.max(newValue, start))] + } + } + else { + currentValue.value = Math.max(props.min, Math.min(props.max, newValue)) + } + + emit('change', currentValue.value) + } + + // 拖拽移动 + const handleDragMove = (event: MouseEvent | TouchEvent) => { + if (!isDragging.value) + return + + event.preventDefault() + const percent = getPosition(event as MouseEvent) + const newValue = getValueFromPercent(percent) + + if (props.range && Array.isArray(currentValue.value)) { + const [start, end] = currentValue.value + + if (dragIndex.value === 0) { + currentValue.value = [Math.max(props.min, Math.min(newValue, end)), end] + } + else { + currentValue.value = [start, Math.min(props.max, Math.max(newValue, start))] + } + } + else { + currentValue.value = Math.max(props.min, Math.min(props.max, newValue)) + } + } + + // 结束拖拽 + const handleDragEnd = () => { + if (!isDragging.value) + return + + isDragging.value = false + dragIndex.value = null + + document.removeEventListener('mousemove', handleDragMove) + document.removeEventListener('mouseup', handleDragEnd) + document.removeEventListener('touchmove', handleDragMove) + document.removeEventListener('touchend', handleDragEnd) + + emit('change', currentValue.value) + } + + // 开始拖拽 + const handleDragStart = (event: MouseEvent | TouchEvent, index?: number) => { + if (props.disabled) + return + + event.preventDefault() + isDragging.value = true + dragIndex.value = index ?? 0 + + document.addEventListener('mousemove', handleDragMove) + document.addEventListener('mouseup', handleDragEnd) + document.addEventListener('touchmove', handleDragMove) + document.addEventListener('touchend', handleDragEnd) + } + + // Cleanup function + const cleanup = () => { + if (isDragging.value) { + handleDragEnd() + } + } + + return { + isDragging, + dragIndex, + handleSliderClick, + handleDragStart, + handleDragEnd, + cleanup, + } +} + +export function useSliderKeyboard( + props: SliderProps, + currentValue: Ref, + emit: (event: 'update:modelValue' | 'input' | 'change', value: number | number[]) => void, +) { + // 键盘事件处理 + const handleKeydown = (event: KeyboardEvent, index?: number) => { + if (props.disabled) + return + + let delta = 0 + switch (event.key) { + case 'ArrowLeft': + case 'ArrowDown': + delta = -props.step + break + case 'ArrowRight': + case 'ArrowUp': + delta = props.step + break + case 'Home': + delta = props.min - (Array.isArray(currentValue.value) + ? currentValue.value[index ?? 0] + : currentValue.value) + break + case 'End': + delta = props.max - (Array.isArray(currentValue.value) + ? currentValue.value[index ?? 0] + : currentValue.value) + break + default: + return + } + + event.preventDefault() + + if (props.range && Array.isArray(currentValue.value)) { + const [start, end] = currentValue.value + + if (index === 0) { + const newStart = Math.max(props.min, Math.min(start + delta, end)) + currentValue.value = [newStart, end] + } + else { + const newEnd = Math.min(props.max, Math.max(end + delta, start)) + currentValue.value = [start, newEnd] + } + } + else { + const current = Array.isArray(currentValue.value) ? currentValue.value[0] : currentValue.value + currentValue.value = Math.max(props.min, Math.min(props.max, current + delta)) + } + + emit('change', currentValue.value) + } + + return { + handleKeydown, + } +} + +export function useSliderMarks( + props: SliderProps, + getPercent: (value: number) => number, +) { + // 计算标记 + const marks = computed(() => { + if (!props.marks) + return {} + + const result: Record = {} + Object.keys(props.marks).forEach((key) => { + const value = Number(key) + if (value >= props.min && value <= props.max) { + result[value] = props.marks![key as any] + } + }) + return result + }) + + // 获取标记样式 + const getMarkStyle = (value: number) => { + const percent = getPercent(value) + const mark = marks.value[value] + + const baseStyle = props.vertical + ? { bottom: `${percent}%` } + : { left: `${percent}%` } + + if (typeof mark === 'object' && mark.style) { + return { ...baseStyle, ...mark.style } + } + return baseStyle + } + + // 获取标记标签 + const getMarkLabel = (value: number) => { + const mark = marks.value[value] + if (typeof mark === 'string') { + return mark + } + else if (typeof mark === 'object' && mark.label !== undefined) { + return mark.label + } + return value + } + + return { + marks, + getMarkStyle, + getMarkLabel, + } +} + +export function useSliderInput( + props: SliderProps, + currentValue: Ref, + emit: (event: 'update:modelValue' | 'input' | 'change', value: number | number[]) => void, +) { + // 处理输入框变化 + const handleInputChange = (value: number | undefined, index?: number) => { + if (value === undefined || value === null) { + return + } + + const clampedValue = Math.max(props.min, Math.min(props.max, value)) + + if (props.range && Array.isArray(currentValue.value)) { + const [start, end] = currentValue.value + if (index === 0) { + currentValue.value = [Math.min(clampedValue, end), end] + } + else { + currentValue.value = [start, Math.max(clampedValue, start)] + } + } + else { + currentValue.value = clampedValue + } + + emit('change', currentValue.value) + + // 触发表单验证 + if (props.validateEvent) { + // 这里可以集成表单验证逻辑 + // 例如:formItem?.validate?.('change') + } + } + + return { + handleInputChange, + } +} diff --git a/packages/ccui/ui/slider/src/slider-types.ts b/packages/ccui/ui/slider/src/slider-types.ts new file mode 100644 index 00000000..364ad655 --- /dev/null +++ b/packages/ccui/ui/slider/src/slider-types.ts @@ -0,0 +1,112 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type SliderSize = 'large' | 'default' | 'small' +export type SliderPlacement = 'top' | 'right' | 'bottom' | 'left' +export type SliderMarks = Record, label?: any }> + +export const sliderProps = { + modelValue: { + type: [Number, Array] as PropType, + default: 0, + }, + min: { + type: Number, + default: 0, + }, + max: { + type: Number, + default: 100, + }, + step: { + type: Number, + default: 1, + }, + disabled: { + type: Boolean, + default: false, + }, + showInput: { + type: Boolean, + default: false, + }, + showInputControls: { + type: Boolean, + default: true, + }, + size: { + type: String as PropType, + default: 'default', + }, + inputSize: { + type: String as PropType, + default: 'default', + }, + label: { + type: String, + }, + precision: { + type: Number, + }, + showStops: { + type: Boolean, + default: false, + }, + showTooltip: { + type: Boolean, + default: true, + }, + formatTooltip: { + type: Function as PropType<(value: number) => string>, + }, + range: { + type: Boolean, + default: false, + }, + vertical: { + type: Boolean, + default: false, + }, + height: { + type: String, + default: '200px', + }, + ariaLabel: { + type: String, + }, + rangeStartLabel: { + type: String, + }, + rangeEndLabel: { + type: String, + }, + formatValueText: { + type: Function as PropType<(value: number) => string>, + }, + tooltipClass: { + type: String, + }, + placement: { + type: String as PropType, + default: 'top', + }, + marks: { + type: Object as PropType, + }, + validateEvent: { + type: Boolean, + default: true, + }, + persistent: { + type: Boolean, + default: true, + }, + tipsRenderer: { + type: [Function, null] as PropType<((value: number) => string) | null>, + }, + showDefaultTooltip: { + type: Boolean, + default: false, + }, +} as const + +export type SliderProps = ExtractPropTypes diff --git a/packages/ccui/ui/slider/src/slider.scss b/packages/ccui/ui/slider/src/slider.scss new file mode 100644 index 00000000..6e6f4f71 --- /dev/null +++ b/packages/ccui/ui/slider/src/slider.scss @@ -0,0 +1,320 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-slider { + position: relative; + display: inline-block; + width: 100%; + font-size: $ccui-font-size; + color: $ccui-text; + user-select: none; + + // 禁用状态 + &--disabled { + cursor: not-allowed; + + .#{$cls-prefix}-slider__wrapper { + cursor: not-allowed; + } + + .#{$cls-prefix}-slider__track { + background-color: $ccui-disabled-line; + } + + .#{$cls-prefix}-slider__bar { + background-color: $ccui-disabled-text; + } + + .#{$cls-prefix}-slider__button { + cursor: not-allowed; + border-color: $ccui-disabled-line; + background-color: $ccui-disabled-bg; + + &:hover { + transform: none; + border-color: $ccui-disabled-line; + } + } + } + + // 垂直模式 + &--vertical { + display: inline-block; + width: auto; + height: 200px; + + .#{$cls-prefix}-slider__wrapper { + width: 6px; + height: 100%; + margin: 0 auto; + } + + .#{$cls-prefix}-slider__track { + width: 100%; + height: 100%; + top: 0; + left: 0; + } + + .#{$cls-prefix}-slider__bar { + width: 100%; + height: auto; + bottom: 0; + top: auto; + } + + .#{$cls-prefix}-slider__button-wrapper { + top: auto; + left: 50%; + transform: translateX(-50%) translateY(50%); + } + + .#{$cls-prefix}-slider__stops { + width: 100%; + height: 100%; + top: 0; + left: 0; + + .#{$cls-prefix}-slider__stop { + width: 100%; + height: 2px; + top: auto; + left: 0; + } + } + + .#{$cls-prefix}-slider__marks { + top: 0; + left: 16px; + width: 20px; + height: 100%; + + .#{$cls-prefix}-slider__mark { + top: auto; + left: 0; + transform: translateY(50%); + + &-line { + width: 8px; + height: 1px; + background-color: $ccui-dividing-line; + + &::before { + width: 8px; + height: 8px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + &-label { + top: 50%; + left: 12px; + transform: translateY(-50%); + } + } + } + } + + // 尺寸变体 + &--large { + .#{$cls-prefix}-slider__button { + width: 18px; + height: 18px; + } + } + + &--small { + .#{$cls-prefix}-slider__button { + width: 14px; + height: 14px; + } + } + + // 带输入框的容器 + &--with-input { + display: flex; + align-items: center; + width: 100%; + flex-direction: row-reverse; + gap: 16px; + + .#{$cls-prefix}-slider__wrapper { + flex: 1; + min-width: 0; + } + + .#{$cls-prefix}-slider__input { + flex-shrink: 0; + width: 120px; + } + } + + &__input-number { + width: 100%; + } + + // 滑块包装器 + &__wrapper { + position: relative; + height: 32px; + cursor: pointer; + display: flex; + align-items: center; + } + + // 轨道 + &__track { + position: relative; + width: 100%; + height: 6px; + background-color: $ccui-dividing-line; + border-radius: 3px; + overflow: hidden; + } + + // 进度条 + &__bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: $ccui-primary; + border-radius: 3px; + transition: all $ccui-animation-duration-base $ccui-animation-ease-out; + } + + // 刻度点 + &__stops { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 6px; + margin-top: -3px; + pointer-events: none; + + .#{$cls-prefix}-slider__stop { + position: absolute; + top: 0; + width: 2px; + height: 100%; + background-color: $ccui-base-bg; + transform: translateX(-50%); + } + } + + // 标记 + &__marks { + position: absolute; + top: 12px; + left: 0; + right: 0; + height: 100%; + pointer-events: none; + + .#{$cls-prefix}-slider__mark { + position: absolute; + top: 0; + transform: translateX(-50%); + + &-line { + width: 1px; + height: 8px; + background-color: $ccui-dividing-line; + margin: 0 auto; + position: relative; + + // 标记点 + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $ccui-base-bg; + border: 2px solid $ccui-dividing-line; + } + } + + &-label { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + font-size: $ccui-font-size-sm; + color: $ccui-aide-text; + white-space: nowrap; + line-height: 1.2; + margin-top: 2px; + } + } + } + + // 滑块按钮包装器 + &__button-wrapper { + position: absolute; + top: 50%; + z-index: 2; + transform: translate(-50%, -50%); + + &--first { + z-index: 3; + } + + &--second { + z-index: 2; + } + + // 确保 Tooltip 组件不影响按钮定位 + .#{$cls-prefix}-tooltip { + display: block; + width: 100%; + height: 100%; + } + + .#{$cls-prefix}-tooltip__trigger { + display: block; + width: 100%; + height: 100%; + } + } + + // 滑块按钮 + &__button { + position: relative; + width: 16px; + height: 16px; + border: 2px solid $ccui-primary; + border-radius: 50%; + background-color: $ccui-base-bg; + cursor: grab; + transition: all $ccui-animation-duration-base $ccui-animation-ease-out; + box-shadow: $ccui-shadow-length-base $ccui-shadow; + + &:hover { + border-color: $ccui-primary-hover; + transform: scale(1.1); + } + + &:active { + cursor: grabbing; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba($ccui-primary, 0.2); + } + } + + // 响应式设计 + @media (max-width: 768px) { + .#{$cls-prefix}-slider__button { + width: 18px; + height: 18px; + } + } +} diff --git a/packages/ccui/ui/slider/src/slider.tsx b/packages/ccui/ui/slider/src/slider.tsx new file mode 100644 index 00000000..493ec080 --- /dev/null +++ b/packages/ccui/ui/slider/src/slider.tsx @@ -0,0 +1,259 @@ +import type { SliderProps } from './slider-types' +import { defineComponent, onBeforeUnmount, onUnmounted, ref } from 'vue' +import { InputNumber } from '../../input-number' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Tooltip } from '../../tooltip' +import { + useSliderCalculation, + useSliderInput, + useSliderInteraction, + useSliderKeyboard, + useSliderMarks, + useSliderStyle, + useSliderValue, +} from './composables/use-slider' +import { useSliderTooltip } from './composables/use-slider-tooltip' +import { sliderProps } from './slider-types' +import './slider.scss' + +export default defineComponent({ + name: 'CSlider', + props: sliderProps, + emits: ['update:modelValue', 'change', 'input'], + setup(props: SliderProps, { emit }) { + const ns = useNamespace('slider') + const sliderRef = ref() + + // 使用组合式函数 + const { currentValue } = useSliderValue(props, emit) + const { getPercent, getValueFromPercent } = useSliderCalculation(props) + const { trackStyle, firstButtonStyle, secondButtonStyle } = useSliderStyle( + props, + currentValue, + getPercent, + ) + const { isDragging, handleSliderClick, handleDragStart, handleDragEnd, cleanup: cleanupInteraction } = useSliderInteraction( + props, + currentValue, + emit, + sliderRef, + getValueFromPercent, + ) + const { handleKeydown } = useSliderKeyboard(props, currentValue, emit) + const { marks, getMarkStyle, getMarkLabel } = useSliderMarks(props, getPercent) + const { handleInputChange } = useSliderInput(props, currentValue, emit) + const { + formatTooltipText, + getDefaultText, + getAriaValueText, + handleButtonMouseEnter, + handleButtonMouseLeave, + shouldShowTooltipForButton, + shouldShowDefaultTooltipForButton, + getCurrentValue, + getTooltipContent, + getTooltipVisible, + getTooltipPlacement, + } = useSliderTooltip(props, isDragging, currentValue) + + // 清理事件监听器 + onBeforeUnmount(() => { + cleanupInteraction() + }) + + onUnmounted(() => { + // Force cleanup of any lingering drag listeners + handleDragEnd() + }) + + // 渲染滑块按钮的函数 + const renderButton = (index: number, value: number, style: any, ariaLabel: string) => { + const buttonProps = { + 'class': [ + ns.e('button'), + { [ns.em('button', 'disabled')]: props.disabled }, + ], + 'tabindex': props.disabled ? -1 : 0, + 'onMousedown': (e: MouseEvent) => handleDragStart(e, index), + 'onTouchstart': (e: TouchEvent) => handleDragStart(e, index), + 'onKeydown': (e: KeyboardEvent) => handleKeydown(e, index), + 'onMouseenter': () => handleButtonMouseEnter(index), + 'onMouseleave': handleButtonMouseLeave, + 'role': 'slider', + 'aria-label': ariaLabel, + 'aria-valuemin': props.min, + 'aria-valuemax': props.max, + 'aria-valuenow': value, + 'aria-valuetext': getAriaValueText(value), + 'aria-orientation': props.vertical ? 'vertical' : 'horizontal', + } + + return props.showTooltip + ? ( + +
+ + ) + : ( +
+ ) + } + + return { + ns, + sliderRef, + isDragging, + currentValue, + trackStyle, + firstButtonStyle, + secondButtonStyle, + handleSliderClick, + handleDragStart, + handleKeydown, + marks, + getMarkStyle, + getMarkLabel, + formatTooltipText, + getDefaultText, + getPercent, + getValueFromPercent, + handleInputChange, + getAriaValueText, + handleButtonMouseEnter, + handleButtonMouseLeave, + shouldShowTooltipForButton, + shouldShowDefaultTooltipForButton, + getCurrentValue, + getTooltipContent, + getTooltipVisible, + getTooltipPlacement, + renderButton, + } + }, + render() { + const isRange = this.range && Array.isArray(this.currentValue) + const firstValue = this.getCurrentValue(0) + const secondValue = isRange ? this.getCurrentValue(1) : 0 + + return ( +
+ {/* 输入框 */} + {this.showInput && !this.range && ( +
+ +
+ )} + + {/* 滑块容器 */} +
+ {/* 轨道 */} +
+
+
+ + {/* 刻度点 */} + {this.showStops && ( +
+ {Array.from({ length: Math.floor((this.max - this.min) / this.step) + 1 }) + .map((_, index) => { + const stopValue = this.min + index * this.step + const percent = this.getPercent(stopValue) + return ( +
+
+ ) + })} +
+ )} + + {/* 标记 */} + {this.marks && Object.keys(this.marks).length > 0 && ( +
+ {Object.keys(this.marks).map((key) => { + const value = Number(key) + return ( +
+
+ + {this.getMarkLabel(value)} + +
+ ) + })} +
+ )} + + {/* 第一个滑块按钮 */} +
+ {this.renderButton(0, firstValue, this.firstButtonStyle, this.rangeStartLabel || 'start value')} +
+ + {/* 第二个滑块按钮(范围模式) */} + {isRange && ( +
+ {this.renderButton(1, secondValue, this.secondButtonStyle, this.rangeEndLabel || 'end value')} +
+ )} +
+
+ ) + }, +}) diff --git a/packages/ccui/ui/slider/test/slider.test.ts b/packages/ccui/ui/slider/test/slider.test.ts new file mode 100644 index 00000000..59e3a78e --- /dev/null +++ b/packages/ccui/ui/slider/test/slider.test.ts @@ -0,0 +1,713 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Slider } from '../index' + +const ns = useNamespace('slider', true) +const baseClass = ns.b() +const wrapperClass = ns.e('wrapper') +const trackClass = ns.e('track') +const buttonClass = ns.e('button') + +describe('slider', () => { + let wrapper: any + + beforeEach(() => { + wrapper = null + }) + + it('dom', () => { + wrapper = mount(Slider) + + expect(wrapper.find(baseClass).exists()).toBe(true) + expect(wrapper.find(wrapperClass).exists()).toBe(true) + expect(wrapper.find(trackClass).exists()).toBe(true) + expect(wrapper.find(buttonClass).exists()).toBe(true) + + wrapper.unmount() + }) + + it('props - modelValue', async () => { + const modelValue = 50 + wrapper = mount(Slider, { + props: { + modelValue, + }, + }) + + expect(wrapper.vm.currentValue).toBe(modelValue) + wrapper.unmount() + }) + + it('props - range mode', () => { + wrapper = mount(Slider, { + props: { + range: true, + modelValue: [20, 80], + }, + }) + + expect(wrapper.vm.currentValue).toEqual([20, 80]) + expect(wrapper.findAll(ns.e('button-wrapper')).length).toBe(2) + wrapper.unmount() + }) + + it('props - disabled', () => { + wrapper = mount(Slider, { + props: { + disabled: true, + }, + }) + + expect(wrapper.find(ns.m('disabled')).exists()).toBe(true) + expect(wrapper.find(ns.em('button', 'disabled')).exists()).toBe(true) + wrapper.unmount() + }) + + it('props - vertical', () => { + wrapper = mount(Slider, { + props: { + vertical: true, + }, + }) + + expect(wrapper.find(ns.m('vertical')).exists()).toBe(true) + wrapper.unmount() + }) + + it('props - showTooltip', async () => { + wrapper = mount(Slider, { + props: { + showTooltip: true, + modelValue: 50, + }, + }) + + // 需要触发悬停才能显示 tooltip + const button = wrapper.find(ns.e('button')) + await button.trigger('mouseenter') + + // 使用 CTooltip 组件,检查 tooltip 是否存在 + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(true) + expect(tooltipComponent.props('visible')).toBe(true) + expect(tooltipComponent.props('content')).toBe('50') + wrapper.unmount() + }) + + it('props - showTooltip false', () => { + wrapper = mount(Slider, { + props: { + showTooltip: false, + }, + }) + + // tooltip 组件不应该存在 + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(false) + wrapper.unmount() + }) + + it('props - showInput', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + }, + }) + + expect(wrapper.find(ns.e('input')).exists()).toBe(true) + expect(wrapper.find(ns.e('input-number')).exists()).toBe(true) + wrapper.unmount() + }) + + it('props - showInputControls', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + showInputControls: true, + modelValue: 50, + }, + }) + + // InputNumber 组件内部有控制按钮 + expect(wrapper.find(ns.e('input-number')).exists()).toBe(true) + wrapper.unmount() + }) + + it('props - showInputControls false', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + showInputControls: false, + modelValue: 50, + }, + }) + + expect(wrapper.find(ns.e('input-controls')).exists()).toBe(false) + wrapper.unmount() + }) + + it('props - showInput with range should not show input', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + range: true, + modelValue: [20, 80], + }, + }) + + expect(wrapper.find(ns.e('input')).exists()).toBe(false) + wrapper.unmount() + }) + + it('props - tipsRenderer null should hide tooltip', () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + tipsRenderer: null, + }, + }) + + // tooltip 组件应该存在但不显示内容 + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(true) + expect(tooltipComponent.props('content')).toBe('') + wrapper.unmount() + }) + + it('props - tipsRenderer custom function', async () => { + const tipsRenderer = (value: number) => `${value} apples` + + wrapper = mount(Slider, { + props: { + modelValue: 5, + tipsRenderer, + showTooltip: true, + }, + }) + + // 需要触发悬停才能显示 tooltip + const button = wrapper.find(ns.e('button')) + await button.trigger('mouseenter') + + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(true) + expect(tooltipComponent.props('visible')).toBe(true) + expect(tooltipComponent.props('content')).toBe('5 apples') + wrapper.unmount() + }) + + it('props - placement', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + placement: 'bottom', + showTooltip: true, + }, + }) + + // 需要触发悬停才能显示 tooltip + const button = wrapper.find(ns.e('button')) + await button.trigger('mouseenter') + + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(true) + expect(tooltipComponent.props('visible')).toBe(true) + // placement 应该使用用户设置的值 + expect(tooltipComponent.props('placement')).toBe('bottom') + wrapper.unmount() + }) + + it('props - marks', () => { + const marks = { + 0: '0°C', + 25: '25°C', + 50: '50°C', + 75: '75°C', + 100: '100°C', + } + + wrapper = mount(Slider, { + props: { + marks, + }, + }) + + expect(wrapper.findAll(ns.e('mark')).length).toBe(Object.keys(marks).length) + expect(wrapper.find(ns.e('mark-label')).text()).toBe('0°C') + wrapper.unmount() + }) + + it('props - showStops', () => { + wrapper = mount(Slider, { + props: { + showStops: true, + step: 10, + }, + }) + + expect(wrapper.find(ns.e('stops')).exists()).toBe(true) + expect(wrapper.findAll(ns.e('stop')).length).toBe(11) // 0-100 with step 10 + wrapper.unmount() + }) + + it('props - size', () => { + wrapper = mount(Slider, { + props: { + size: 'large', + }, + }) + + expect(wrapper.find(ns.m('large')).exists()).toBe(true) + wrapper.unmount() + }) + + it('event - click', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 0, + }, + }) + + // 模拟点击滑块 + const sliderWrapper = wrapper.find(wrapperClass) + + // 模拟 getBoundingClientRect + const mockRect = { + left: 0, + width: 100, + top: 0, + height: 32, + right: 100, + bottom: 32, + } + + vi.spyOn(sliderWrapper.element, 'getBoundingClientRect').mockReturnValue(mockRect) + + await sliderWrapper.trigger('click', { clientX: 50 }) + + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + expect(wrapper.emitted('input')).toBeTruthy() + wrapper.unmount() + }) + + it('event - keyboard', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + }, + }) + + const button = wrapper.find(buttonClass) + await button.trigger('keydown', { key: 'ArrowRight' }) + + expect(wrapper.emitted('change')).toBeTruthy() + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + wrapper.unmount() + }) + + it('event - keyboard Home and End', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + min: 0, + max: 100, + }, + }) + + const button = wrapper.find(buttonClass) + + // Test Home key + await button.trigger('keydown', { key: 'Home' }) + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + + // Test End key + await button.trigger('keydown', { key: 'End' }) + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + wrapper.unmount() + }) + + it('event - drag', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + }, + }) + + const button = wrapper.find(buttonClass) + + // Mock getBoundingClientRect + const mockRect = { + left: 0, + width: 100, + top: 0, + height: 32, + right: 100, + bottom: 32, + } + + vi.spyOn(wrapper.vm.sliderRef, 'getBoundingClientRect').mockReturnValue(mockRect) + + await button.trigger('mousedown', { clientX: 50 }) + expect(wrapper.vm.isDragging).toBe(true) + wrapper.unmount() + }) + + it('disabled state', async () => { + wrapper = mount(Slider, { + props: { + disabled: true, + modelValue: 50, + }, + }) + + const sliderWrapper = wrapper.find(wrapperClass) + await sliderWrapper.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeFalsy() + wrapper.unmount() + }) + + it('formatTooltip function', () => { + const formatTooltip = (value: number) => `${value}%` + + wrapper = mount(Slider, { + props: { + modelValue: 50, + formatTooltip, + }, + }) + + expect(wrapper.vm.formatTooltipText(50)).toBe('50%') + wrapper.unmount() + }) + + it('tipsRenderer function', () => { + const tipsRenderer = (value: number) => `${value} apples` + + wrapper = mount(Slider, { + props: { + modelValue: 5, + tipsRenderer, + }, + }) + + expect(wrapper.vm.formatTooltipText(5)).toBe('5 apples') + wrapper.unmount() + }) + + it('step calculation', () => { + wrapper = mount(Slider, { + props: { + step: 5, + min: 0, + max: 100, + }, + }) + + // Test that value is rounded to step + const value = wrapper.vm.getValueFromPercent(23) // Should be rounded to nearest step + expect(value % 5).toBe(0) + wrapper.unmount() + }) + + it('range mode functionality', async () => { + wrapper = mount(Slider, { + props: { + range: true, + modelValue: [20, 80], + }, + }) + + expect(wrapper.vm.currentValue).toEqual([20, 80]) + + // Should have two buttons + const buttons = wrapper.findAll(buttonClass) + expect(buttons.length).toBe(2) + wrapper.unmount() + }) + + it('percentage calculation', () => { + wrapper = mount(Slider, { + props: { + min: 0, + max: 100, + modelValue: 50, + }, + }) + + expect(wrapper.vm.getPercent(50)).toBe(50) + wrapper.unmount() + }) + + it('input change event', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + min: 0, + max: 100, + }, + }) + + // 使用 InputNumber 组件的事件 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + await inputNumber.vm.$emit('update:modelValue', 75) + + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + expect(wrapper.emitted('change')).toBeTruthy() + wrapper.unmount() + }) + + it('input change with invalid value', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + min: 0, + max: 100, + }, + }) + + // InputNumber 组件内部处理无效值 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.exists()).toBe(true) + wrapper.unmount() + }) + + it('event - input change with value exceeding max', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + min: 0, + max: 100, + }, + }) + + // 使用 InputNumber 组件的事件,测试边界值处理 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + await inputNumber.vm.$emit('update:modelValue', 150) + + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + // Should clamp to max value + const emittedValues = wrapper.emitted('update:modelValue') as any[] + expect(emittedValues[emittedValues.length - 1][0]).toBe(100) + wrapper.unmount() + }) + + it('input change with value below min', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + min: 0, + max: 100, + }, + }) + + // 使用 InputNumber 组件的事件,测试边界值处理 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + await inputNumber.vm.$emit('update:modelValue', -10) + + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + // Should clamp to min value + const emittedValues = wrapper.emitted('update:modelValue') as any[] + expect(emittedValues[emittedValues.length - 1][0]).toBe(0) + wrapper.unmount() + }) + + it('event - input increase button', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + showInputControls: true, + modelValue: 50, + step: 5, + max: 100, + }, + }) + + // InputNumber 组件内部处理增减按钮 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.exists()).toBe(true) + wrapper.unmount() + }) + + it('event - input decrease button', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + showInputControls: true, + modelValue: 50, + step: 5, + min: 0, + }, + }) + + // InputNumber 组件内部处理增减按钮 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.exists()).toBe(true) + wrapper.unmount() + }) + + it('event - input buttons disabled at boundaries', async () => { + wrapper = mount(Slider, { + props: { + showInput: true, + showInputControls: true, + modelValue: 0, + min: 0, + max: 100, + }, + }) + + // InputNumber 组件内部处理边界禁用逻辑 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.exists()).toBe(true) + wrapper.unmount() + }) + + it('props - aria labels', () => { + wrapper = mount(Slider, { + props: { + ariaLabel: 'Custom slider label', + rangeStartLabel: 'Start', + rangeEndLabel: 'End', + range: true, + modelValue: [20, 80], + }, + }) + + const sliderWrapper = wrapper.find(ns.e('wrapper')) + expect(sliderWrapper.attributes('aria-label')).toBe('Custom slider label') + wrapper.unmount() + }) + + it('props - formatValueText', () => { + const formatValueText = (value: number) => `${value} degrees` + + wrapper = mount(Slider, { + props: { + modelValue: 50, + formatValueText, + }, + }) + + expect(wrapper.vm.getAriaValueText(50)).toBe('50 degrees') + wrapper.unmount() + }) + + it('props - label', () => { + wrapper = mount(Slider, { + props: { + label: 'Volume Control', + modelValue: 50, + }, + }) + + expect(wrapper.attributes('aria-label')).toBe('Volume Control') + wrapper.unmount() + }) + + it('props - inputSize', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + inputSize: 'large', + modelValue: 50, + }, + }) + + // InputNumber 组件接收 size 属性 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.props('size')).toBe('large') + wrapper.unmount() + }) + + it('props - persistent tooltip', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + persistent: true, + showTooltip: true, + }, + }) + + // persistent 模式下 tooltip 应该一直显示,但仍需要悬停或拖拽状态 + const button = wrapper.find('.ccui-slider__button') + await button.trigger('mouseenter') + + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(true) + expect(tooltipComponent.props('visible')).toBe(true) + expect(tooltipComponent.props('content')).toBe('50') + wrapper.unmount() + }) + + it('props - persistent false with showTooltip false', () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + persistent: true, + showTooltip: false, + }, + }) + + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(false) + wrapper.unmount() + }) + + it('props - showDefaultTooltip true', async () => { + wrapper = mount(Slider, { + props: { + modelValue: 50, + tipsRenderer: null, + showTooltip: true, + showDefaultTooltip: true, + }, + }) + + // 需要触发悬停才能显示 tooltip + const button = wrapper.find(ns.e('button')) + await button.trigger('mouseenter') + + const tooltipComponent = wrapper.findComponent({ name: 'CTooltip' }) + expect(tooltipComponent.exists()).toBe(true) + expect(tooltipComponent.props('visible')).toBe(true) + expect(tooltipComponent.props('content')).toBe('50') + wrapper.unmount() + }) + + it('props - validateEvent', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + validateEvent: true, + }, + }) + + // InputNumber 组件处理输入 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.exists()).toBe(true) + wrapper.unmount() + }) + + it('props - validateEvent false', () => { + wrapper = mount(Slider, { + props: { + showInput: true, + modelValue: 50, + validateEvent: false, + }, + }) + + // InputNumber 组件处理输入 + const inputNumber = wrapper.findComponent({ name: 'CInputNumber' }) + expect(inputNumber.exists()).toBe(true) + wrapper.unmount() + }) +}) diff --git a/packages/ccui/ui/timeline/src/timeline-item.tsx b/packages/ccui/ui/timeline/src/timeline-item.tsx index de4028f6..f5e97f17 100644 --- a/packages/ccui/ui/timeline/src/timeline-item.tsx +++ b/packages/ccui/ui/timeline/src/timeline-item.tsx @@ -1,5 +1,5 @@ import type { TimelineItemProps } from './timeline-types' -import { computed, defineComponent, h } from 'vue' +import { computed, defineComponent, h, markRaw } from 'vue' import { useNamespace } from '../../shared/hooks/use-namespace' import { timelineItemProps } from './timeline-types' @@ -35,8 +35,8 @@ export default defineComponent({ return } else { - // 如果是组件,使用 h 函数渲染 - return h(props.icon, { class: ns.e('icon') }) + // 如果是组件,使用 markRaw 避免不必要的响应式转换,然后使用 h 函数渲染 + return h(markRaw(props.icon), { class: ns.e('icon') }) } } return null diff --git a/packages/ccui/ui/timeline/test/timeline.test.ts b/packages/ccui/ui/timeline/test/timeline.test.ts index 78db997e..63b8aa34 100644 --- a/packages/ccui/ui/timeline/test/timeline.test.ts +++ b/packages/ccui/ui/timeline/test/timeline.test.ts @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import { h } from 'vue' +import { h, markRaw } from 'vue' import { Timeline, TimelineItem } from '../index' describe('timeline', () => { @@ -159,12 +159,12 @@ describe('timelineItem', () => { }) it('should render with component icon', () => { - const IconComponent = { + const IconComponent = markRaw({ name: 'TestIcon', render() { return h('span', { class: 'test-icon' }, 'Icon') }, - } + }) const wrapper = mount(TimelineItem, { props: { diff --git a/packages/docs/components/input-number/index.md b/packages/docs/components/input-number/index.md new file mode 100644 index 00000000..c49acda7 --- /dev/null +++ b/packages/docs/components/input-number/index.md @@ -0,0 +1,306 @@ +# InputNumber 数字输入框 + +数字输入框组件,用于输入数字类型的数据。 + +## 何时使用 + +当需要获取标准数值时。 + +## 基本用法 + +基础的数字输入框用法。 + +:::demo + +```vue + + + + + +``` + +::: + +## 禁用状态 + +通过 `disabled` 属性指定是否禁用 input 组件。 + +:::demo + +```vue + + + +``` + +::: + +## 数值范围 + +使用 `min` 和 `max` 属性限制数值范围。 + +:::demo + +```vue + + + +``` + +::: + +## 步数 + +使用 `step` 属性设置步长。 + +:::demo + +```vue + + + +``` + +::: + +## 精度 + +使用 `precision` 属性设置数值精度。 + +:::demo + +```vue + + + +``` + +::: + +## 尺寸 + +使用 `size` 属性设置不同尺寸。 + +:::demo + +```vue + + + +``` + +::: + +## 控制按钮位置 + +使用 `controls-position` 属性设置控制按钮位置。 + +:::demo + +```vue + + + +``` + +::: + +## 允许空值 + +使用 `allow-empty` 属性允许输入框为空。 + +:::demo + +```vue + + + +``` + +::: + +## InputNumber参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----------------- | ---------------------- | ----------- | ---------------------- | +| v-model | `number \| undefined` | `undefined` | 绑定值 | +| step | `number` | `1` | 计数器步长 | +| placeholder | `string` | `''` | 输入框占位文本 | +| max | `number` | `Infinity` | 设置计数器允许的最大值 | +| min | `number` | `-Infinity` | 设置计数器允许的最小值 | +| disabled | `boolean` | `false` | 是否禁用计数器 | +| readonly | `boolean` | `false` | 是否只读 | +| precision | `number` | `undefined` | 数值精度 | +| size | `'lg' \| 'md' \| 'sm'` | `'md'` | 计数器尺寸 | +| controls | `boolean` | `true` | 是否显示控制按钮 | +| controls-position | `'both' \| 'right'` | `'both'` | 控制按钮位置 | +| allow-empty | `boolean` | `false` | 是否允许空值 | +| show-glow-style | `boolean` | `true` | 是否显示悬浮发光效果 | +| reg | `RegExp \| string` | `undefined` | 输入限制的正则表达式 | + +## InputNumber事件 + +| 事件名 | 回调参数 | 说明 | +| ------ | ---------------------------------------------------------------- | ----------------------- | +| change | `(currentVal: number \| undefined, oldVal: number \| undefined)` | 绑定值被改变时触发 | +| blur | `(event: Event)` | 在 Input 失去焦点时触发 | +| focus | `(event: Event)` | 在 Input 获得焦点时触发 | +| input | `(currentValue: number \| undefined)` | 在 Input 值改变时触发 | + +## InputNumber方法 + +| 方法名 | 说明 | 参数 | +| ------ | ------------------- | ---- | +| focus | 使 input 获取焦点 | - | +| blur | 使 input 失去焦点 | - | +| select | 选中 input 中的文字 | - | diff --git a/packages/docs/components/slider/index.md b/packages/docs/components/slider/index.md new file mode 100644 index 00000000..f4f56e8e --- /dev/null +++ b/packages/docs/components/slider/index.md @@ -0,0 +1,541 @@ +# Slider 滑块 + +滑块组件用于在数值区间内进行选择。 + +## 何时使用 + +- 当用户需要在数值区间内进行选择时 +- 当需要调整设置值时,如音量、亮度等 +- 当需要选择范围值时 + +## 基本用法 + +基本的滑块用法。 + +:::demo + +```vue + + + + + +``` + +::: + +## 范围选择 + +支持选择数值范围。 + +:::demo + +```vue + + + + + +``` + +::: + +## 垂直模式 + +垂直方向的滑块。 + +:::demo + +```vue + + + + + +``` + +::: + +## 步长 + +设置步长,取值必须大于 0,并且可被 (max - min) 整除。 + +:::demo + +```vue + + + + + +``` + +::: + +## 显示间断点 + +使用 `show-stops` 属性可以显示间断点。 + +:::demo + +```vue + + + + + +``` + +::: + +## 带标记 + +使用 `marks` 属性可以显示标记。 + +:::demo + +```vue + + + + + +``` + +::: + +## 带输入框 + +通过设置 `show-input` 属性可以显示输入框。支持单值模式和范围模式。 + +### 单值模式 + +:::demo + +```vue + + + + + +``` + +::: + +### 范围模式 + +:::demo + +```vue + + + + + +``` + +::: + +## 定制 Tooltip 显示内容 + +通过 `tips-renderer` 属性可以定制 Tooltip 显示内容,设置 `show-tooltip="false"` 可以隐藏 Tooltip。 + +:::demo + +```vue + + + + + +``` + +::: + +## Tooltip 位置 + +通过 `placement` 属性可以设置 Tooltip 的显示位置。 + +:::demo + +```vue + + + + + +``` + +::: + +## 尺寸变体 + +Slider 提供三种尺寸:large、default、small。 + +:::demo + +```vue + + + + + +``` + +::: + +## 禁用状态 + +通过设置 `disabled` 属性来禁用滑块。 + +:::demo + +```vue + + + + + +``` + +::: + +## 无障碍访问 + +Slider 组件支持完整的无障碍访问功能。 + +:::demo + +```vue + + + + + +``` + +::: + +## API + +### Slider Props + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| --------------------- | ------------------------------------------------------------------------------------ | ---------------------- | --------------------------- | ------- | +| model-value / v-model | 绑定值 | number / number[] | — | 0 | +| min | 最小值 | number | — | 0 | +| max | 最大值 | number | — | 100 | +| disabled | 是否禁用 | boolean | — | false | +| step | 步长 | number | — | 1 | +| show-input | 是否显示输入框,支持单值模式和范围模式 | boolean | — | false | +| show-input-controls | 在显示输入框的情况下,是否显示输入框的控制按钮 | boolean | — | true | +| input-size | 输入框的尺寸 | string | large / default / small | default | +| show-stops | 是否显示间断点 | boolean | — | false | +| show-tooltip | 是否显示 tooltip | boolean | — | true | +| format-tooltip | 格式化 tooltip message | function(value) | — | — | +| tips-renderer | 自定义 tooltip 内容 | function(value) / null | — | — | +| placement | Tooltip 显示位置 | string | top / right / bottom / left | top | +| range | 是否为范围选择 | boolean | — | false | +| vertical | 是否竖向模式 | boolean | — | false | +| height | Slider 高度,竖向模式时必填 | string | — | 200px | +| size | Slider 尺寸 | string | large / default / small | default | +| label | 屏幕阅读器标签 | string | — | — | +| aria-label | 无障碍标签 | string | — | — | +| range-start-label | 范围选择起始标签 | string | — | — | +| range-end-label | 范围选择结束标签 | string | — | — | +| format-value-text | 格式化值文本用于无障碍访问 | function(value) | — | — | +| tooltip-class | tooltip 的自定义类名 | string | — | — | +| marks | 标记,key 的类型必须为 number 且取值在闭区间 [min, max] 内,每个标记可以单独设置样式 | object | — | — | +| validate-event | 是否触发表单验证 | boolean | — | true | +| persistent | 是否持久化显示 tooltip | boolean | — | true | +| show-default-tooltip | 是否显示默认 tooltip(当 tipsRenderer 为 null 时) | boolean | — | false | +| precision | 数值精度 | number | — | — | + +### Slider Events + +| 事件名 | 说明 | 回调参数 | +| ------ | -------------------------------------------------- | ---------- | +| change | 值改变时触发(使用鼠标拖拽时,只在松开鼠标后触发) | 改变后的值 | +| input | 数据改变时触发(使用鼠标拖拽时,活动过程实时触发) | 改变后的值 | diff --git a/packages/docs/components/timeline/index.md b/packages/docs/components/timeline/index.md index 06ca9169..5531c1ed 100644 --- a/packages/docs/components/timeline/index.md +++ b/packages/docs/components/timeline/index.md @@ -223,36 +223,36 @@ export default defineComponent({ ### Timeline Slots -| 插槽名 | 说明 | 子标签 | -| ---- | ---- | ---- | +| 插槽名 | 说明 | 子标签 | +| ------- | ----------------------------- | ------------- | | default | timeline 组件的自定义默认内容 | Timeline-Item | ### Timeline-Item Props -| 参数 | 说明 | 类型 | 可选值 | 默认值 | -| ---- | -------- | ---- | ---- | ---- | -| timestamp | 时间戳 | string | — | '' | -| hide-timestamp | 是否隐藏时间戳 | boolean | — | false | -| center | 是否垂直居中 | boolean | — | false | -| placement | 时间戳位置 | string | top/bottom | bottom | -| type | 节点类型 | string | [TimelineItemType](#timelineitemtype) | — | -| color | 节点颜色 | string | — | — | -| size | 节点尺寸 | string | normal/large | normal | -| icon | 自定义图标 | string/Component | — | — | -| hollow | 是否空心点 | boolean | — | false | +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| -------------- | -------------- | ---------------- | ------------------------------------- | ------ | +| timestamp | 时间戳 | string | — | '' | +| hide-timestamp | 是否隐藏时间戳 | boolean | — | false | +| center | 是否垂直居中 | boolean | — | false | +| placement | 时间戳位置 | string | top/bottom | bottom | +| type | 节点类型 | string | [TimelineItemType](#timelineitemtype) | — | +| color | 节点颜色 | string | — | — | +| size | 节点尺寸 | string | normal/large | normal | +| icon | 自定义图标 | string/Component | — | — | +| hollow | 是否空心点 | boolean | — | false | ### Timeline-Item Events | 事件名 | 说明 | 回调参数 | -| ---- | ---- | ---- | -| — | — | — | +| ------ | ---- | -------- | +| — | — | — | ### Timeline-Item Slots -| 插槽名 | 说明 | -| ---- | ---- | +| 插槽名 | 说明 | +| ------- | ---------- | | default | 自定义内容 | -| dot | 自定义节点 | +| dot | 自定义节点 | ## Timeline类型定义