From 083c858fd2f25baec4723e14a647333756f599f0 Mon Sep 17 00:00:00 2001 From: vaebe Date: Sun, 16 Aug 2026 13:44:57 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E5=B7=A5=E5=85=B7=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config-provider/src/config-provider.tsx | 20 +++- .../test/config-provider.test.ts | 22 +++- .../ui/shared/hooks/use-virtual-list.test.ts | 70 ++++++++++++ .../ccui/ui/shared/hooks/use-virtual-list.ts | 34 +++--- packages/ccui/ui/shared/utils/overlay.ts | 101 ++++++++++++++++++ packages/ccui/ui/util/src/func.ts | 27 ++++- packages/ccui/ui/util/test/util.test.ts | 21 ++++ 7 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 packages/ccui/ui/shared/hooks/use-virtual-list.test.ts create mode 100644 packages/ccui/ui/shared/utils/overlay.ts diff --git a/packages/ccui/ui/config-provider/src/config-provider.tsx b/packages/ccui/ui/config-provider/src/config-provider.tsx index 5370474f..27580f98 100644 --- a/packages/ccui/ui/config-provider/src/config-provider.tsx +++ b/packages/ccui/ui/config-provider/src/config-provider.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from 'vue' import type { ConfigContext, ConfigProviderProps, Locale } from './config-provider-types' -import { computed, defineComponent, inject, provide, watch } from 'vue' +import { computed, defineComponent, inject, provide, reactive, watch, watchEffect } from 'vue' import { useNamespace } from '../../shared/hooks/use-namespace' import { setDayjsLocale } from '../../shared/utils/dayjs-locale' import defaultLocale from '../../locale/zh-CN' @@ -64,16 +64,28 @@ export const ConfigProvider = defineComponent({ setup(props: ConfigProviderProps, { slots }) { const ns = useNamespace('config-provider') - const ctx = computed(() => ({ + // Keep one reactive context object so descendants that call useConfig() once + // still observe prop changes without needing to re-inject a new value. + const ctx = reactive({ prefixCls: props.prefixCls, componentSize: props.componentSize, locale: mergeLocale(props.locale), direction: props.direction, theme: props.theme, iconPrefixCls: props.iconPrefixCls, - })) + }) + provide(CONFIG_INJECT_KEY, ctx) - provide(CONFIG_INJECT_KEY, ctx.value) + watchEffect(() => { + Object.assign(ctx, { + prefixCls: props.prefixCls, + componentSize: props.componentSize, + locale: mergeLocale(props.locale), + direction: props.direction, + theme: props.theme, + iconPrefixCls: props.iconPrefixCls, + }) + }) // locale 变更时切全局 dayjs locale。ConfigProvider locale.locale 用 'zh-CN' / 'en-US' / // 'ja-JP' / 'ko-KR' 命名,dayjs 用小写的 'zh-cn' / 'en' / 'ja' / 'ko',做一次映射。 diff --git a/packages/ccui/ui/config-provider/test/config-provider.test.ts b/packages/ccui/ui/config-provider/test/config-provider.test.ts index 771ee57e..03632d43 100644 --- a/packages/ccui/ui/config-provider/test/config-provider.test.ts +++ b/packages/ccui/ui/config-provider/test/config-provider.test.ts @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import { defineComponent, h } from 'vue' +import { defineComponent, h, nextTick } from 'vue' import { jaJP, koKR } from '../../locale' import { ConfigProvider, useConfig } from '../index' @@ -31,6 +31,26 @@ describe('configProvider', () => { expect(wrapper.find('[data-testid="consumer"]').text()).toContain('"componentSize":"small"') }) + it('propagates reactive prop changes to an existing consumer', async () => { + const wrapper = mount(ConfigProvider, { + props: { componentSize: 'small' }, + slots: { default: () => h(ConsumerComp) }, + }) + expect(wrapper.find('[data-testid="consumer"]').text()).toContain('"componentSize":"small"') + await wrapper.setProps({ componentSize: 'large' }) + await nextTick() + expect(wrapper.find('[data-testid="consumer"]').text()).toContain('"componentSize":"large"') + }) + + it('preserves child attrs on the provider wrapper', () => { + const wrapper = mount(ConfigProvider, { + attrs: { id: 'settings-root', 'data-audit': 'config' }, + slots: { default: 'x' }, + }) + expect(wrapper.attributes('id')).toBe('settings-root') + expect(wrapper.attributes('data-audit')).toBe('config') + }) + it('applies theme tokens as CSS variables', () => { const wrapper = mount(ConfigProvider, { props: { theme: { token: { colorPrimary: '#ff0000' } } }, diff --git a/packages/ccui/ui/shared/hooks/use-virtual-list.test.ts b/packages/ccui/ui/shared/hooks/use-virtual-list.test.ts new file mode 100644 index 00000000..dc9700c7 --- /dev/null +++ b/packages/ccui/ui/shared/hooks/use-virtual-list.test.ts @@ -0,0 +1,70 @@ +import { computed, nextTick, ref } from 'vue' +import { describe, expect, it } from 'vite-plus/test' +import { useVirtualList } from './use-virtual-list' + +describe('useVirtualList', () => { + it('reacts to runtime item and viewport height changes', async () => { + const items = computed(() => Array.from({ length: 100 }, (_, index) => index)) + const itemHeight = ref(20) + const maxHeight = ref(100) + const virtual = useVirtualList(items, { itemHeight, maxHeight, buffer: 0 }) + + expect(virtual.totalHeight.value).toBe(2000) + expect(virtual.containerHeight.value).toBe(100) + expect(virtual.visible.value).toHaveLength(5) + + itemHeight.value = 40 + maxHeight.value = 80 + await nextTick() + + expect(virtual.totalHeight.value).toBe(4000) + expect(virtual.containerHeight.value).toBe(80) + expect(virtual.visible.value).toHaveLength(2) + }) + + it('normalizes invalid dimensions to finite safe values', () => { + const items = computed(() => ['a', 'b']) + const virtual = useVirtualList(items, { itemHeight: 0, maxHeight: -10, buffer: -2 }) + + expect(virtual.totalHeight.value).toBe(2) + expect(virtual.containerHeight.value).toBe(0) + expect(virtual.visible.value).toEqual([]) + }) + + it.each([ + { itemHeight: Number.NaN, maxHeight: 100, buffer: 0 }, + { itemHeight: Number.POSITIVE_INFINITY, maxHeight: 100, buffer: 0 }, + { itemHeight: Number.NEGATIVE_INFINITY, maxHeight: 100, buffer: 0 }, + ])('normalizes non-finite item height: $itemHeight', ({ itemHeight, maxHeight, buffer }) => { + const items = computed(() => ['a', 'b']) + const virtual = useVirtualList(items, { itemHeight, maxHeight, buffer }) + + expect(virtual.totalHeight.value).toBe(2) + expect(virtual.containerHeight.value).toBe(2) + expect(virtual.visible.value).toEqual([ + { index: 0, data: 'a', top: 0 }, + { index: 1, data: 'b', top: 1 }, + ]) + }) + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'normalizes non-finite max height: %s', + (maxHeight) => { + const items = computed(() => ['a', 'b']) + const virtual = useVirtualList(items, { itemHeight: 20, maxHeight, buffer: 0 }) + + expect(virtual.containerHeight.value).toBe(0) + expect(virtual.visible.value).toEqual([]) + }, + ) + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'normalizes non-finite buffer: %s', + (buffer) => { + const items = computed(() => ['a', 'b']) + const virtual = useVirtualList(items, { itemHeight: 20, maxHeight: 20, buffer }) + + expect(virtual.visible.value).toEqual([{ index: 0, data: 'a', top: 0 }]) + }, + ) +}) diff --git a/packages/ccui/ui/shared/hooks/use-virtual-list.ts b/packages/ccui/ui/shared/hooks/use-virtual-list.ts index 5f54d1a4..46021c79 100644 --- a/packages/ccui/ui/shared/hooks/use-virtual-list.ts +++ b/packages/ccui/ui/shared/hooks/use-virtual-list.ts @@ -1,5 +1,5 @@ -import type { ComputedRef, Ref } from 'vue' -import { computed, ref } from 'vue' +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue' +import { computed, ref, toValue } from 'vue' export interface VirtualListItem { index: number @@ -8,9 +8,13 @@ export interface VirtualListItem { } export interface UseVirtualListOptions { - itemHeight: number - maxHeight: number - buffer?: number + itemHeight: MaybeRefOrGetter + maxHeight: MaybeRefOrGetter + buffer?: MaybeRefOrGetter +} + +function finiteOr(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback } export function useVirtualList( @@ -25,20 +29,22 @@ export function useVirtualList( scrollToIndex: (index: number, container?: HTMLElement | null) => void } { const scrollTop = ref(0) - const buffer = options.buffer ?? 4 + const itemHeight = computed(() => Math.max(1, finiteOr(toValue(options.itemHeight), 1))) + const maxHeight = computed(() => Math.max(0, finiteOr(toValue(options.maxHeight), 0))) + const buffer = computed(() => Math.max(0, Math.floor(finiteOr(toValue(options.buffer ?? 4), 0)))) - const totalHeight = computed(() => items.value.length * options.itemHeight) - const containerHeight = computed(() => Math.min(options.maxHeight, totalHeight.value)) + const totalHeight = computed(() => items.value.length * itemHeight.value) + const containerHeight = computed(() => Math.min(maxHeight.value, totalHeight.value)) const visible = computed[]>(() => { const allItems = items.value if (allItems.length === 0) return [] - const start = Math.max(0, Math.floor(scrollTop.value / options.itemHeight) - buffer) - const visibleCount = Math.ceil(containerHeight.value / options.itemHeight) + buffer * 2 + const start = Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - buffer.value) + const visibleCount = Math.ceil(containerHeight.value / itemHeight.value) + buffer.value * 2 const end = Math.min(allItems.length, start + visibleCount) const out: VirtualListItem[] = [] for (let i = start; i < end; i += 1) { - out.push({ index: i, data: allItems[i], top: i * options.itemHeight }) + out.push({ index: i, data: allItems[i], top: i * itemHeight.value }) } return out }) @@ -49,11 +55,11 @@ export function useVirtualList( const scrollToIndex = (index: number, container?: HTMLElement | null) => { if (!container) return - const desiredTop = index * options.itemHeight + const desiredTop = index * itemHeight.value if (desiredTop < scrollTop.value) { container.scrollTop = desiredTop - } else if (desiredTop + options.itemHeight > scrollTop.value + containerHeight.value) { - container.scrollTop = desiredTop - containerHeight.value + options.itemHeight + } else if (desiredTop + itemHeight.value > scrollTop.value + containerHeight.value) { + container.scrollTop = desiredTop - containerHeight.value + itemHeight.value } } diff --git a/packages/ccui/ui/shared/utils/overlay.ts b/packages/ccui/ui/shared/utils/overlay.ts new file mode 100644 index 00000000..0ece0705 --- /dev/null +++ b/packages/ccui/ui/shared/utils/overlay.ts @@ -0,0 +1,101 @@ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +interface OverlayEntry { + container: HTMLElement + closeOnEsc: boolean + onEscape: () => void +} + +const overlayStack: OverlayEntry[] = [] +let originalBodyOverflow = '' +let scrollLockCount = 0 + +export function canUseDom(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +export function lockBodyScroll(): () => void { + if (!canUseDom()) return () => {} + + if (scrollLockCount === 0) { + originalBodyOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + } + scrollLockCount++ + document.body.dataset.ccuiOverlayCount = String(scrollLockCount) + + let released = false + return () => { + if (released || !canUseDom()) return + released = true + scrollLockCount = Math.max(0, scrollLockCount - 1) + if (scrollLockCount === 0) { + document.body.style.overflow = originalBodyOverflow + delete document.body.dataset.ccuiOverlayCount + } else { + document.body.dataset.ccuiOverlayCount = String(scrollLockCount) + } + } +} + +function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true', + ) +} + +function handleOverlayKeydown(event: KeyboardEvent): void { + const activeOverlay = overlayStack.at(-1) + if (!activeOverlay) return + + if (event.key === 'Escape' && activeOverlay.closeOnEsc) { + event.preventDefault() + activeOverlay.onEscape() + return + } + if (event.key !== 'Tab') return + + const focusable = getFocusableElements(activeOverlay.container) + if (focusable.length === 0) { + event.preventDefault() + activeOverlay.container.focus({ preventScroll: true }) + return + } + + const first = focusable[0] + const last = focusable[focusable.length - 1] + const current = document.activeElement + if (event.shiftKey && (current === first || !activeOverlay.container.contains(current))) { + event.preventDefault() + last.focus({ preventScroll: true }) + } else if (!event.shiftKey && (current === last || !activeOverlay.container.contains(current))) { + event.preventDefault() + first.focus({ preventScroll: true }) + } +} + +export function activateOverlay(entry: OverlayEntry): () => void { + if (!canUseDom()) return () => {} + + overlayStack.push(entry) + if (overlayStack.length === 1) document.addEventListener('keydown', handleOverlayKeydown) + + const firstFocusable = getFocusableElements(entry.container)[0] + ;(firstFocusable ?? entry.container).focus({ preventScroll: true }) + + let released = false + return () => { + if (released || !canUseDom()) return + released = true + const index = overlayStack.indexOf(entry) + if (index >= 0) overlayStack.splice(index, 1) + if (overlayStack.length === 0) document.removeEventListener('keydown', handleOverlayKeydown) + } +} diff --git a/packages/ccui/ui/util/src/func.ts b/packages/ccui/ui/util/src/func.ts index 4971ea63..b1027fa3 100644 --- a/packages/ccui/ui/util/src/func.ts +++ b/packages/ccui/ui/util/src/func.ts @@ -21,6 +21,17 @@ export function debounce any>(fn: T, wait = 200) { export function throttle any>(fn: T, wait = 200) { let last = 0 let timer: ReturnType | null = null + let trailingCall: (() => void) | null = null + + // 清除延迟调用,供组件卸载时释放尚未执行的回调。 + const cancel = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + trailingCall = null + } + function throttled(this: any, ...args: Parameters): void { const now = Date.now() const remaining = wait - (now - last) @@ -31,15 +42,20 @@ export function throttle any>(fn: T, wait = 200) { } last = now fn.apply(this, args) - } else if (!timer) { + } else { + // trailing 调用应反映节流窗口内最后一次输入,而非第一次输入。 + trailingCall = () => fn.apply(this, args) + if (timer) return timer = setTimeout(() => { last = Date.now() timer = null - fn.apply(this, args) + trailingCall?.() + trailingCall = null }, remaining) } } - return throttled as T + throttled.cancel = cancel + return throttled as T & { cancel: () => void } } export function noop(): void {} @@ -49,5 +65,8 @@ export function isFunction(v: unknown): v is (...args: any[]) => any { } export function isObject(v: unknown): v is Record { - return v !== null && typeof v === 'object' && !Array.isArray(v) + if (v === null || typeof v !== 'object') return false + // 仅接受普通对象和无原型对象,避免把 Date、Map 等实例当作配置对象。 + const prototype = Object.getPrototypeOf(v) + return prototype === Object.prototype || prototype === null } diff --git a/packages/ccui/ui/util/test/util.test.ts b/packages/ccui/ui/util/test/util.test.ts index 569db89d..4898ab8c 100644 --- a/packages/ccui/ui/util/test/util.test.ts +++ b/packages/ccui/ui/util/test/util.test.ts @@ -35,7 +35,10 @@ describe('util', () => { it('isObject only returns true for plain objects', () => { expect(isObject({})).toBe(true) + expect(isObject(Object.create(null))).toBe(true) expect(isObject([])).toBe(false) + expect(isObject(new Date())).toBe(false) + expect(isObject(Object.create({}))).toBe(false) expect(isObject(null)).toBe(false) expect(isObject(1)).toBe(false) }) @@ -132,6 +135,24 @@ describe('util', () => { vi.useRealTimers() }) + it('throttle uses the latest trailing arguments and can cancel pending work', () => { + vi.useFakeTimers() + const fn = vi.fn() + const t = throttle(fn, 50) + + t('first') + t('stale') + t('latest') + vi.advanceTimersByTime(60) + expect(fn.mock.calls).toEqual([['first'], ['latest']]) + + t('cancelled') + t.cancel() + vi.advanceTimersByTime(60) + expect(fn).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + it('noop is callable', () => { expect(noop()).toBeUndefined() }) From 2b9f5c63e7bba1e19bbcb792d4c8d5289b41e8ea Mon Sep 17 00:00:00 2001 From: vaebe Date: Sun, 16 Aug 2026 13:45:25 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E8=BE=93=E5=85=A5=E7=BB=84=E4=BB=B6=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auto-complete/src/auto-complete-types.ts | 4 + .../ui/auto-complete/src/auto-complete.scss | 8 + .../ui/auto-complete/src/auto-complete.tsx | 107 ++++++- .../auto-complete/test/auto-complete.test.ts | 159 ++++++++++ .../ccui/ui/check-box/src/check-box-group.tsx | 24 +- .../ccui/ui/check-box/src/check-box-types.ts | 11 +- packages/ccui/ui/check-box/src/check-box.scss | 40 ++- packages/ccui/ui/check-box/src/check-box.tsx | 88 +++++- .../ccui/ui/check-box/test/check-box.test.ts | 237 +++++++++++++- .../ui/color-picker/src/color-picker-types.ts | 2 +- .../ui/color-picker/src/color-picker.scss | 21 ++ .../ccui/ui/color-picker/src/color-picker.tsx | 133 ++++++-- .../ui/color-picker/test/color-picker.test.ts | 160 +++++++++- .../ccui/ui/date-picker/src/date-picker.scss | 4 + .../ccui/ui/date-picker/src/date-picker.tsx | 289 +++++++++++------ .../ui/date-picker/test/date-picker.test.ts | 180 +++++++++++ packages/ccui/ui/form/src/form-item.tsx | 83 +++-- packages/ccui/ui/form/src/form-types.ts | 1 + packages/ccui/ui/form/src/form.tsx | 27 +- packages/ccui/ui/form/src/utils.ts | 9 +- packages/ccui/ui/form/test/form.test.ts | 291 ++++++++++++++++- .../ccui/ui/input-number/src/input-number.tsx | 249 ++++++++++----- .../ui/input-number/test/input-number.test.ts | 191 +++++++++++ packages/ccui/ui/input-otp/index.ts | 8 +- .../ccui/ui/input-otp/src/input-otp-types.ts | 18 +- packages/ccui/ui/input-otp/src/input-otp.scss | 4 + packages/ccui/ui/input-otp/src/input-otp.tsx | 179 +++++++++-- .../ccui/ui/input-otp/test/input-otp.test.ts | 279 +++++++++++++++- .../ui/input-search/src/input-search-types.ts | 3 +- .../ccui/ui/input-search/src/input-search.tsx | 198 ++++++++++-- .../ui/input-search/test/input-search.test.ts | 190 ++++++++++- packages/ccui/ui/input/src/input.scss | 13 + packages/ccui/ui/input/src/input.tsx | 90 ++++-- packages/ccui/ui/input/test/input.test.ts | 56 ++++ .../ccui/ui/mentions/src/mentions-types.ts | 4 + packages/ccui/ui/mentions/src/mentions.scss | 4 + packages/ccui/ui/mentions/src/mentions.tsx | 226 +++++++++---- .../ccui/ui/mentions/test/mentions.test.ts | 242 ++++++++++++++ packages/ccui/ui/radio/src/radio-group.tsx | 85 ++++- packages/ccui/ui/radio/src/radio-types.ts | 23 +- packages/ccui/ui/radio/src/radio.scss | 8 + packages/ccui/ui/radio/src/radio.tsx | 115 +++++-- packages/ccui/ui/radio/test/radio.test.ts | 297 +++++++++++++++++- .../ui/range-picker/src/range-picker.scss | 11 + .../ccui/ui/range-picker/src/range-picker.tsx | 263 +++++++++++++--- .../ui/range-picker/test/range-picker.test.ts | 122 +++++++ packages/ccui/ui/select/src/select.tsx | 5 +- .../ui/slider/src/composables/use-slider.ts | 2 + packages/ccui/ui/slider/src/slider.tsx | 18 +- packages/ccui/ui/slider/test/slider.test.ts | 28 +- packages/ccui/ui/switch/src/switch-types.ts | 7 + packages/ccui/ui/switch/src/switch.tsx | 150 +++++++-- packages/ccui/ui/switch/test/switch.test.ts | 213 ++++++++++++- packages/ccui/ui/textarea/src/textarea.scss | 3 + packages/ccui/ui/textarea/src/textarea.tsx | 59 +++- .../ccui/ui/textarea/test/textarea.test.ts | 45 ++- .../ccui/ui/time-picker/src/time-picker.scss | 15 + .../ccui/ui/time-picker/src/time-picker.tsx | 197 +++++++++++- .../ui/time-picker/test/time-picker.test.ts | 181 ++++++++++- .../src/time-range-picker.scss | 1 + .../src/time-range-picker.tsx | 139 +++++++- .../test/time-range-picker.test.ts | 87 ++++- 62 files changed, 5305 insertions(+), 601 deletions(-) diff --git a/packages/ccui/ui/auto-complete/src/auto-complete-types.ts b/packages/ccui/ui/auto-complete/src/auto-complete-types.ts index 718d2db0..94a681f8 100644 --- a/packages/ccui/ui/auto-complete/src/auto-complete-types.ts +++ b/packages/ccui/ui/auto-complete/src/auto-complete-types.ts @@ -40,6 +40,10 @@ export const autoCompleteProps = { type: Boolean, default: false, }, + readonly: { + type: Boolean, + default: false, + }, allowClear: { type: Boolean, default: false, diff --git a/packages/ccui/ui/auto-complete/src/auto-complete.scss b/packages/ccui/ui/auto-complete/src/auto-complete.scss index 2d22ba62..32cd5d8b 100644 --- a/packages/ccui/ui/auto-complete/src/auto-complete.scss +++ b/packages/ccui/ui/auto-complete/src/auto-complete.scss @@ -42,6 +42,14 @@ } } + &.is-readonly { + cursor: default; + + input { + cursor: default; + } + } + &--size-small { height: 24px; } diff --git a/packages/ccui/ui/auto-complete/src/auto-complete.tsx b/packages/ccui/ui/auto-complete/src/auto-complete.tsx index a885cb72..08615d5f 100644 --- a/packages/ccui/ui/auto-complete/src/auto-complete.tsx +++ b/packages/ccui/ui/auto-complete/src/auto-complete.tsx @@ -10,6 +10,7 @@ import { inject, onMounted, onUnmounted, + nextTick, ref, shallowRef, Teleport, @@ -49,6 +50,7 @@ export default defineComponent({ const open = shallowRef(false) const innerValue = shallowRef(props.defaultValue ?? '') const activeIndex = shallowRef(-1) + const isComposing = shallowRef(false) const formItem = inject(formItemInjectionKey, null) const isControlled = computed(() => props.modelValue !== undefined) @@ -101,7 +103,7 @@ export default defineComponent({ } function openPopup() { - if (props.disabled || open.value) return + if (props.disabled || props.readonly || open.value) return open.value = true activeIndex.value = findFirstEnabledIndex() emit('open-change', true) @@ -117,6 +119,7 @@ export default defineComponent({ const backfillDisplay = shallowRef(null) let debounceTimer: ReturnType | null = null + let compositionValueToIgnore: string | null = null function setValue(next: string | number) { backfillDisplay.value = null @@ -126,9 +129,13 @@ export default defineComponent({ emit('update:modelValue', next) emit('change', next) + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } if (props.searchDebounce > 0) { - if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { + debounceTimer = null emit('search', String(next)) }, props.searchDebounce) } else { @@ -148,6 +155,32 @@ export default defineComponent({ function onInput(e: Event) { const target = e.target as HTMLInputElement + if (props.disabled || props.readonly || isComposing.value) return + if (compositionValueToIgnore === target.value) { + compositionValueToIgnore = null + return + } + setValue(target.value) + if (!open.value) openPopup() + activeIndex.value = -1 + } + + function onCompositionstart() { + if (props.disabled || props.readonly) return + isComposing.value = true + compositionValueToIgnore = null + } + + function onCompositionend(e: CompositionEvent) { + if (!isComposing.value) return + isComposing.value = false + if (props.disabled || props.readonly) return + const target = e.target as HTMLInputElement + // 部分浏览器会在 compositionend 后额外派发一次同值 input,避免重复 search/change。 + compositionValueToIgnore = target.value + queueMicrotask(() => { + compositionValueToIgnore = null + }) setValue(target.value) if (!open.value) openPopup() activeIndex.value = -1 @@ -155,16 +188,19 @@ export default defineComponent({ function onFocus(e: FocusEvent) { emit('focus', e) - if (!props.disabled) openPopup() + openPopup() } function onBlur(e: FocusEvent) { + const next = e.relatedTarget as Node | null + if (next && (rootRef.value?.contains(next) || popupRef.value?.contains(next))) return emit('blur', e) + closePopup() formItem?.validate('blur') } function onKeydown(e: KeyboardEvent) { - if (props.disabled) return + if (props.disabled || props.readonly || isComposing.value) return const list = filteredOptions.value const enabled = list.filter((o) => !o.disabled) function applyBackfill(idx: number) { @@ -217,12 +253,22 @@ export default defineComponent({ closePopup() } - function clear(e: MouseEvent) { + function clear(e: Event) { e.stopPropagation() e.preventDefault() setValue('') } + async function onClearKeydown(e: KeyboardEvent) { + if (e.key !== 'Enter' && e.key !== ' ') return + clear(e) + await nextTick() + const trigger = + inputRef.value ?? + rootRef.value?.querySelector('input, textarea, [contenteditable="true"], [tabindex]') + trigger?.focus() + } + onMounted(() => { document.addEventListener('mousedown', onClickOutside, true) }) @@ -239,16 +285,27 @@ export default defineComponent({ // 当 options 变化或 keyword 变化时,如果当前 active index 越界,重置 watch(filteredOptions, (list) => { - if (activeIndex.value >= list.length) activeIndex.value = -1 + const active = list[activeIndex.value] + if (!active || active.disabled) activeIndex.value = findFirstEnabledIndex() }) - const showClear = computed(() => props.allowClear && !props.disabled && inputDisplay.value !== '') + watch( + () => [props.disabled, props.readonly] as const, + ([disabled, readonly]) => { + if (disabled || readonly) closePopup() + }, + ) + + const showClear = computed( + () => props.allowClear && !props.disabled && !props.readonly && inputDisplay.value !== '', + ) function renderInput(): VNode { const wrapClass = [ ns.e('wrap'), ns.em('size', props.size), props.disabled ? ns.is('disabled') : '', + props.readonly ? ns.is('readonly') : '', open.value ? ns.is('open') : '', mergedStatus.value ? ns.em('wrap', `status-${mergedStatus.value}`) : '', ] @@ -262,6 +319,9 @@ export default defineComponent({ onKeydown, placeholder: props.placeholder, disabled: props.disabled, + readonly: props.readonly, + onCompositionstart, + onCompositionend, }) ) : ( = 0 ? optionId(activeIndex.value) : undefined} onInput={onInput} onFocus={onFocus} onBlur={onBlur} onKeydown={onKeydown} + onCompositionstart={onCompositionstart} + onCompositionend={onCompositionend} /> ) + const clearIconNode = slots.clearIcon + ? slots.clearIcon() + : (renderIconNode(props.clearIcon) ?? renderIconNode('mdi:close-circle')) + const clearNode = showClear.value ? ( + + {clearIconNode} + + ) : null return (
{inputNode} - {showClear.value && ( - - {slots.clearIcon - ? slots.clearIcon() - : (renderIconNode(props.clearIcon) ?? renderIconNode('mdi:close-circle'))} - - )} + {clearNode}
) } @@ -349,7 +424,9 @@ export default defineComponent({ {list.length === 0 ? (
{notFoundLocal.value}
) : ( -
    {list.map((opt, i) => renderOption(opt, i))}
+ )} diff --git a/packages/ccui/ui/auto-complete/test/auto-complete.test.ts b/packages/ccui/ui/auto-complete/test/auto-complete.test.ts index 333ddaf1..536554af 100644 --- a/packages/ccui/ui/auto-complete/test/auto-complete.test.ts +++ b/packages/ccui/ui/auto-complete/test/auto-complete.test.ts @@ -484,3 +484,162 @@ describe('XL-4 ARIA combobox / activedescendant', () => { expect(activeOpt.attributes('aria-selected')).toBe('true') }) }) + +describe('auto-complete composition and debounce lifecycle', () => { + it('does not search or change for intermediate IME input and commits once on compositionend', async () => { + const wrapper = mountAC() + const input = wrapper.find('input') + const element = input.element as HTMLInputElement + element.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })) + element.value = 'に' + element.dispatchEvent(new Event('input', { bubbles: true })) + element.value = '日本' + element.dispatchEvent(new Event('input', { bubbles: true })) + expect(wrapper.emitted('search')).toBeUndefined() + expect(wrapper.emitted('change')).toBeUndefined() + + // 浏览器会紧随 compositionend 派发同值 input;两者必须只提交一次。 + element.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })) + element.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + expect(wrapper.emitted('search')).toEqual([['日本']]) + expect(wrapper.emitted('change')).toEqual([['日本']]) + expect(wrapper.emitted('update:modelValue')).toEqual([['日本']]) + }) + + it('cancels a pending debounced query before an immediate query after debounce changes', async () => { + vi.useFakeTimers() + try { + const wrapper = mountAC({ searchDebounce: 100 }) + await wrapper.find('input').setValue('old') + await wrapper.setProps({ searchDebounce: 0 }) + await wrapper.find('input').setValue('new') + expect(wrapper.emitted('search')).toEqual([['new']]) + vi.runAllTimers() + expect(wrapper.emitted('search')).toEqual([['new']]) + } finally { + vi.useRealTimers() + } + }) + + it('cancels a pending debounced search when unmounted', async () => { + vi.useFakeTimers() + try { + const wrapper = mountAC({ searchDebounce: 100 }) + await wrapper.find('input').setValue('pending') + wrapper.unmount() + vi.runAllTimers() + expect(wrapper.emitted('search')).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) +}) + +describe('auto-complete focus, readonly, and dynamic option safety', () => { + it('closes on keyboard blur and validates FormItem blur once', async () => { + const validate = vi.fn(async () => true) + const wrapper = mount(AutoComplete, { + props: { options: SAMPLE }, + attachTo: document.body, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref(''), + isInsideForm: true, + validate, + }, + }, + }, + }) + wrappers.push(wrapper) + await focus(wrapper) + await wrapper.find('input').trigger('blur', { relatedTarget: document.body }) + await nextTick() + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + expect(wrapper.emitted('open-change')).toEqual([[true], [false]]) + expect(validate).toHaveBeenCalledWith('blur') + }) + + it('does not emit composite blur while focus moves to the clear control', async () => { + const wrapper = mountAC({ allowClear: true, defaultValue: 'Apple' }) + await focus(wrapper) + const clear = wrapper.find(ns.e('clear')) + await wrapper.find('input').trigger('blur', { relatedTarget: clear.element }) + expect(wrapper.emitted('blur')).toBeUndefined() + expect(wrapper.find(ns.e('panel')).exists()).toBe(true) + await clear.trigger('blur', { relatedTarget: document.body }) + expect(wrapper.emitted('blur')).toHaveLength(1) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) + + it('closes when disabled or readonly changes while open', async () => { + const wrapper = mountAC() + await focus(wrapper) + await wrapper.setProps({ disabled: true }) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + + await wrapper.setProps({ disabled: false }) + await focus(wrapper) + await wrapper.setProps({ readonly: true }) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + expect(wrapper.find('input').attributes('readonly')).toBeDefined() + expect(wrapper.find('input').attributes('aria-readonly')).toBe('true') + await focus(wrapper) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) + + it('moves an invalid active descendant to the first enabled async replacement option', async () => { + const wrapper = mountAC({ defaultActiveFirstOption: true, options: ['Apple', 'Banana'] }) + await focus(wrapper) + const oldActiveId = wrapper.find('input').attributes('aria-activedescendant') + expect(wrapper.find(`#${oldActiveId}`).text()).toBe('Apple') + + await wrapper.setProps({ + options: [{ value: 'Apple', disabled: true }, { value: 'Banana' }], + }) + await nextTick() + const activeId = wrapper.find('input').attributes('aria-activedescendant') + expect(wrapper.find(`#${activeId}`).text()).toBe('Banana') + expect(wrapper.find(`#${activeId}`).attributes('aria-disabled')).toBe('false') + }) + + it.each([ + ['Enter', 'Enter'], + ['Space', ' '], + ])('supports %s clearing, then restores input focus', async (_label, key) => { + const wrapper = mountAC({ allowClear: true, defaultValue: 'Apple' }) + const clear = wrapper.find(ns.e('clear')) + expect(clear.attributes('tabindex')).toBe('0') + ;(clear.element as HTMLElement).focus() + expect(document.activeElement).toBe(clear.element) + await clear.trigger('keydown', { key }) + await nextTick() + expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['']) + expect(document.activeElement).toBe(wrapper.find('input').element) + }) + + it('blocks custom-trigger input events while readonly', async () => { + const wrapper = mount(AutoComplete, { + props: { options: SAMPLE, readonly: true }, + slots: { + trigger: (slotProps: any) => + h('textarea', { + class: 'readonly-trigger', + value: slotProps.value, + readonly: slotProps.readonly, + onInput: slotProps.onInput, + }), + }, + attachTo: document.body, + }) + wrappers.push(wrapper) + await wrapper.find('.readonly-trigger').setValue('blocked') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('gives an unlabeled combobox a fallback accessible name', () => { + const wrapper = mountAC() + expect(wrapper.find('input').attributes('aria-label')).toBe('请输入') + }) +}) diff --git a/packages/ccui/ui/check-box/src/check-box-group.tsx b/packages/ccui/ui/check-box/src/check-box-group.tsx index 297128f5..cd26fe98 100644 --- a/packages/ccui/ui/check-box/src/check-box-group.tsx +++ b/packages/ccui/ui/check-box/src/check-box-group.tsx @@ -1,5 +1,7 @@ +import type { FormItemInjectedContext } from '../../form/src/form-types' import type { CheckBoxGroupProps, LabelType } from './check-box-types' -import { computed, defineComponent, provide, toRef } from 'vue' +import { computed, defineComponent, inject, provide, toRef } from 'vue' +import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' import { checkBoxGroupInjectionKey, checkBoxGroupProps } from './check-box-types' import './check-box-group.scss' @@ -10,6 +12,7 @@ export default defineComponent({ emits: ['change', 'update:modelValue'], setup(props: CheckBoxGroupProps, { emit, slots }) { const ns = useNamespace('check-box-group') + const formItem = inject(formItemInjectionKey, null) const valueList = toRef(props, 'modelValue') @@ -23,6 +26,7 @@ export default defineComponent({ const res = [...valueList.value, val] emit('change', res) emit('update:modelValue', res) + void formItem?.validate('change') return } @@ -30,6 +34,7 @@ export default defineComponent({ const res = valueList.value.filter((item) => item !== val) emit('change', res) emit('update:modelValue', res) + void formItem?.validate('change') } const isItemChecked = (val: LabelType) => { // 验证数组中是否存在该项 返回boolean @@ -39,7 +44,8 @@ export default defineComponent({ provide(checkBoxGroupInjectionKey, { disabled: toRef(props, 'disabled'), color: toRef(props, 'color'), - beforeChange: props.beforeChange, + name: toRef(props, 'name'), + beforeChange: toRef(props, 'beforeChange'), toggleGroupVal, isItemChecked, }) @@ -48,9 +54,21 @@ export default defineComponent({ return `${ns.b()} ${ns.is(props.direction)}` }) + const handleFocusout = (event: FocusEvent) => { + const currentTarget = event.currentTarget as HTMLElement + if (!currentTarget.contains(event.relatedTarget as Node | null)) { + void formItem?.validate('blur') + } + } + return () => { return ( -
+
{slots.default && slots.default()}
) diff --git a/packages/ccui/ui/check-box/src/check-box-types.ts b/packages/ccui/ui/check-box/src/check-box-types.ts index 01a7fc81..c388f126 100644 --- a/packages/ccui/ui/check-box/src/check-box-types.ts +++ b/packages/ccui/ui/check-box/src/check-box-types.ts @@ -12,7 +12,7 @@ export const checkBoxProps = { default: null, }, label: { - type: String as PropType, + type: [String, Number, Boolean] as PropType, default: '', }, name: { @@ -27,6 +27,10 @@ export const checkBoxProps = { type: Boolean, default: false, }, + indeterminate: { + type: Boolean, + default: false, + }, beforeChange: { type: Function as PropType, default: undefined, @@ -40,7 +44,7 @@ export const checkBoxGroupProps = { ...checkBoxProps, modelValue: { type: Array, - default: [], + default: () => [], required: true, }, direction: { @@ -55,7 +59,8 @@ export type CheckBoxGroupProps = ExtractPropTypes interface CheckBoxGroupInjection { disabled: Ref color: Ref - beforeChange: undefined | BeforeChangeType + name: Ref + beforeChange: Ref toggleGroupVal: (v: LabelType) => void isItemChecked: (v: LabelType) => boolean } diff --git a/packages/ccui/ui/check-box/src/check-box.scss b/packages/ccui/ui/check-box/src/check-box.scss index 4c9e2c0b..07eb1ba6 100644 --- a/packages/ccui/ui/check-box/src/check-box.scss +++ b/packages/ccui/ui/check-box/src/check-box.scss @@ -80,6 +80,11 @@ } } + &__input:focus-visible + &__icon { + outline: 2px solid $ccui-color-primary; + outline-offset: 2px; + } + &:not(.disabled):hover { .#{$cls-prefix}-check-box__icon { color: $ccui-color-primary; @@ -104,6 +109,26 @@ } } + &.indeterminate { + .#{$cls-prefix}-check-box__icon { + color: $ccui-color-primary; + + &::before { + background-color: currentColor; + border-color: currentColor; + } + + &::after { + width: 8px; + height: 2px; + border: 0; + background-color: var(--ccui-check-box-indeterminate-mark-color, #fff); + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + } + } + &.disabled { color: $ccui-color-text-disabled; cursor: not-allowed; @@ -119,7 +144,7 @@ &.active { .#{$cls-prefix}-check-box__icon { &::before { - background-color: $ccui-color-fill; + background-color: var(--ccui-check-box-indeterminate-background, $ccui-color-fill); border-color: $ccui-color-border-disabled; } @@ -128,5 +153,18 @@ } } } + + &.indeterminate { + .#{$cls-prefix}-check-box__icon { + &::before { + background-color: $ccui-color-fill; + border-color: $ccui-color-border-disabled; + } + + &::after { + background-color: var(--ccui-check-box-indeterminate-mark-color, $ccui-color-text-secondary); + } + } + } } } diff --git a/packages/ccui/ui/check-box/src/check-box.tsx b/packages/ccui/ui/check-box/src/check-box.tsx index cc3bbc3f..8abbd115 100644 --- a/packages/ccui/ui/check-box/src/check-box.tsx +++ b/packages/ccui/ui/check-box/src/check-box.tsx @@ -1,5 +1,7 @@ +import type { FormItemInjectedContext } from '../../form/src/form-types' import type { CheckBoxProps, LabelType } from './check-box-types' -import { computed, defineComponent, inject } from 'vue' +import { computed, defineComponent, inject, onBeforeUnmount, ref, watch } from 'vue' +import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' import { checkBoxGroupInjectionKey, checkBoxProps } from './check-box-types' import IconActive from './components/icon-active' @@ -14,6 +16,10 @@ export default defineComponent({ const ns = useNamespace('check-box') const checkBoxGroupInject = inject(checkBoxGroupInjectionKey, null) + const formItem = inject(formItemInjectionKey, null) + const isPending = ref(false) + let requestId = 0 + let isUnmounted = false const isDisabled = computed(() => { return checkBoxGroupInject?.disabled.value || props.disabled @@ -26,12 +32,19 @@ export default defineComponent({ // 计算组件样式 const labelClass = computed(() => { - return `${ns.b()} ${isChecked.value ? 'active' : ''} ${isDisabled.value ? 'disabled' : ''}` + return `${ns.b()} ${isChecked.value ? 'active' : ''} ${props.indeterminate ? 'indeterminate' : ''} ${isDisabled.value ? 'disabled' : ''}` }) const iconColor = computed(() => { const color = checkBoxGroupInject?.color.value || props.color - return color ? `color: ${color}; fill: ${color}` : '' + const styles = color ? [`color: ${color}`, `fill: ${color}`] : [] + if (isDisabled.value && props.indeterminate) { + styles.push( + '--ccui-check-box-indeterminate-background: var(--ccui-color-fill)', + '--ccui-check-box-indeterminate-mark-color: var(--ccui-color-text-secondary)', + ) + } + return styles.join('; ') }) // todo 带测试逻辑 @@ -41,46 +54,95 @@ export default defineComponent({ return Promise.resolve(false) } - const beforeChange = checkBoxGroupInject?.beforeChange || props.beforeChange + const beforeChange = checkBoxGroupInject?.beforeChange.value || props.beforeChange // 判断beforeChange事件是否存在 if (beforeChange) { - const res = beforeChange(hasChecked, value) + let res: ReturnType + try { + res = beforeChange(hasChecked, value) + } catch { + return Promise.resolve(false) + } // 存在boolean 返回对应的值,否则直接返回 if (typeof res === 'boolean') { return Promise.resolve(res) } - return res + return Promise.resolve(res).catch(() => false) } return Promise.resolve(true) } - const handleChange = async () => { - const curStatus = !isChecked.value + watch( + [isChecked, isDisabled, () => props.label], + () => { + // Every observed transition invalidates an in-flight decision, including + // ABA sequences whose final value happens to equal the request snapshot. + requestId += 1 + isPending.value = false + }, + { flush: 'sync' }, + ) + + const handleChange = (event: Event) => { + const input = event.target as HTMLInputElement + // Native checkbox state changes before `change`. Keep the rendered state + // stable while an async guard is pending (or when it rejects). + input.checked = isChecked.value + input.indeterminate = props.indeterminate + if (isPending.value) return - void judgeCanChange(curStatus, props.label).then((res) => { - if (res) { + const initialChecked = isChecked.value + const curStatus = !initialChecked + const currentLabel = props.label + const currentRequest = ++requestId + isPending.value = true + + void judgeCanChange(curStatus, currentLabel).then((res) => { + if (currentRequest !== requestId || isUnmounted) return + isPending.value = false + // External controlled updates and dynamic labels supersede the request + // that was started against the previous render. + if (res && !isDisabled.value && isChecked.value === initialChecked && props.label === currentLabel) { // 更新选中的数组 - checkBoxGroupInject?.toggleGroupVal(props.label) + checkBoxGroupInject?.toggleGroupVal(currentLabel) emit('change', curStatus) emit('update:modelValue', curStatus) + if (!checkBoxGroupInject) { + void formItem?.validate('change') + } } }) } + const handleBlur = () => { + if (!checkBoxGroupInject) { + void formItem?.validate('blur') + } + } + + onBeforeUnmount(() => { + isUnmounted = true + requestId += 1 + }) + return () => { return (
@@ -359,27 +429,30 @@ export default defineComponent({ mergedStatus.value ? ns.em('status', mergedStatus.value) : '', ] return ( - {props.allowClear && !props.disabled && ( - + )} - + ) } @@ -474,7 +547,7 @@ export default defineComponent({ maxlength={8} spellcheck={false} onInput={onHexInput} - onBlur={onHexCommit} + onBlur={onHexBlur} onKeydown={onHexKeydown} aria-label="hex" /> @@ -612,6 +685,8 @@ export default defineComponent({ style={[floatingStyles.value, props.styles?.popup] as any} role="dialog" aria-label="选择颜色" + onKeydown={onPopupKeydown} + onFocusout={teleported.value ? onComponentFocusout : undefined} > {body} @@ -622,6 +697,7 @@ export default defineComponent({ return () => (
{renderTrigger()} {renderPopup()} diff --git a/packages/ccui/ui/color-picker/test/color-picker.test.ts b/packages/ccui/ui/color-picker/test/color-picker.test.ts index dac07232..d23d71f3 100644 --- a/packages/ccui/ui/color-picker/test/color-picker.test.ts +++ b/packages/ccui/ui/color-picker/test/color-picker.test.ts @@ -6,6 +6,7 @@ import { ColorPicker } from '../index' import { useNamespace } from '../../shared/hooks/use-namespace' import { hexToRgb, hsvToRgb, hsvToString, rgbToHex, rgbToHsv, rgbToString } from '../../shared/utils/color' import { Form, FormItem } from '../../form' +import { formItemInjectionKey } from '../../form/src/form-types' const ns = useNamespace('color-picker', true) const wrappers: VueWrapper[] = [] @@ -137,6 +138,45 @@ describe('color-picker popup', () => { expect(wrapper.find(ns.e('panel')).exists()).toBe(false) }) + it('passes the actual trigger button to getPopupContainer and uses it as the popup anchor', async () => { + const getPopupContainer = vi.fn((_triggerNode: HTMLElement | null) => document.body) + const wrapper = mountCP({ getPopupContainer }) + const trigger = wrapper.find(ns.e('trigger')).element + await openPanel(wrapper) + expect(getPopupContainer).toHaveBeenCalledWith(trigger) + expect(getPopupContainer.mock.calls.some(([node]) => node !== trigger && node !== null)).toBe(false) + }) + + it('focuses the picker when opened and restores trigger focus after Escape', async () => { + const wrapper = mountCP() + const trigger = wrapper.find(ns.e('trigger')).element as HTMLButtonElement + trigger.focus() + await openPanel(wrapper) + expect(document.activeElement).toBe(wrapper.find(ns.e('sv')).element) + + await wrapper.find(ns.e('panel')).trigger('keydown', { key: 'Escape' }) + await nextTick() + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + expect(document.activeElement).toBe(trigger) + expect(wrapper.emitted('open-change')?.slice(-1)[0]).toEqual([false]) + }) + + it('closes an open popup and stops pointer tracking when dynamically disabled', async () => { + const onChange = vi.fn() + const wrapper = mountCP({ modelValue: '#ff0000', onChange }) + await openPanel(wrapper) + const hue = wrapper.find(ns.e('hue')).element as HTMLElement + stubRect(hue, 100, 10) + hue.dispatchEvent(new MouseEvent('pointerdown', { clientX: 20, clientY: 5, bubbles: true })) + const changesBeforeDisable = onChange.mock.calls.length + + await wrapper.setProps({ disabled: true }) + document.dispatchEvent(new MouseEvent('pointermove', { clientX: 80, clientY: 5, bubbles: true })) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + expect(onChange).toHaveBeenCalledTimes(changesBeforeDisable) + expect(wrapper.emitted('open-change')?.slice(-1)[0]).toEqual([false]) + }) + it('renders alpha slider by default and hides it when disabledAlpha=true', async () => { const a = mountCP() await openPanel(a) @@ -147,9 +187,59 @@ describe('color-picker popup', () => { expect(b.find(ns.e('alpha')).exists()).toBe(false) expect(b.find(ns.e('alpha-input-wrap')).exists()).toBe(false) }) + + it('resynchronizes pending alpha when disabledAlpha changes dynamically', async () => { + const wrapper = mountCP({ modelValue: '#1677ff80' }) + await openPanel(wrapper) + expect(wrapper.find(ns.e('alpha')).attributes('aria-valuenow')).toBe('50') + await wrapper.setProps({ disabledAlpha: true }) + expect(wrapper.find(ns.e('alpha')).exists()).toBe(false) + await wrapper.setProps({ disabledAlpha: false }) + expect(wrapper.find(ns.e('alpha')).attributes('aria-valuenow')).toBe('50') + }) + + it('stops an active alpha drag immediately when disabledAlpha becomes true', async () => { + const onChange = vi.fn() + const wrapper = mountCP({ modelValue: '#1677ff80', onChange }) + await openPanel(wrapper) + const alpha = wrapper.find(ns.e('alpha')).element as HTMLElement + stubRect(alpha, 100, 10) + alpha.dispatchEvent(new MouseEvent('pointerdown', { clientX: 20, clientY: 5, bubbles: true })) + const changesBeforeDisable = onChange.mock.calls.length + + await wrapper.setProps({ disabledAlpha: true }) + document.dispatchEvent(new MouseEvent('pointermove', { clientX: 80, clientY: 5, bubbles: true })) + expect(wrapper.find(ns.e('alpha')).exists()).toBe(false) + expect(onChange).toHaveBeenCalledTimes(changesBeforeDisable) + }) }) describe('color-picker SV / hue / alpha drag', () => { + it('removes document pointer listeners when unmounted during a drag', async () => { + const onChange = vi.fn() + const wrapper = mountCP({ modelValue: '#ff0000', onChange }) + await wrapper.find(ns.e('trigger')).trigger('click') + await nextTick() + const sv = document.body.querySelector(ns.e('sv')) as HTMLElement + vi.spyOn(sv, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 100, + height: 100, + right: 100, + bottom: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }) + sv.dispatchEvent(new MouseEvent('pointerdown', { clientX: 20, clientY: 20, bubbles: true })) + const changesBeforeUnmount = onChange.mock.calls.length + + wrapper.unmount() + document.dispatchEvent(new MouseEvent('pointermove', { clientX: 80, clientY: 80, bubbles: true })) + expect(onChange).toHaveBeenCalledTimes(changesBeforeUnmount) + }) + it('clicking SV area updates s/v based on relative position', async () => { const wrapper = mountCP({ defaultValue: '#ff0000' }) await openPanel(wrapper) @@ -236,11 +326,13 @@ describe('color-picker hex input', () => { await openPanel(wrapper) const input = wrapper.find(`${ns.e('hex-input')}`) await input.setValue('00ff00') + ;(input.element as HTMLInputElement).focus() await input.trigger('keydown', { key: 'Enter' }) await nextTick() // input.blur 在 jsdom 内会触发我们 onBlur → commit const last = wrapper.emitted('update:modelValue')?.slice(-1)[0][0] as string | undefined expect(last?.toLowerCase()).toBe('#00ff00') + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) }) }) @@ -296,6 +388,14 @@ describe('color-picker controlled / uncontrolled', () => { const fg = wrapper.find(ns.e('swatch-fg')) // 父级未提交,swatch 仍是 #1677ff expect((fg.element as HTMLElement).style.backgroundColor).toBe('rgb(22, 119, 255)') + expect(wrapper.find(ns.e('hue')).attributes('aria-valuenow')).toBe(String(rgbToHsv(hexToRgb('#1677ff')!).h)) + }) + + it('normalizes an invalid controlled color consistently instead of exposing invalid text', async () => { + const wrapper = mountCP({ modelValue: 'not-a-color', showText: true }) + expect(wrapper.find(ns.e('value-text')).text()).toBe('#1677FF') + await openPanel(wrapper) + expect((wrapper.find(ns.e('hex-input')).element as HTMLInputElement).value).toBe('1677FF') }) }) @@ -332,6 +432,36 @@ describe('color-picker form integration', () => { await nextTick() expect(value.value?.toLowerCase()).toBe('#ff0000') }) + + it('does not validate blur while focus moves into the popup and validates once when leaving the component', async () => { + const validate = vi.fn() + const wrapper = mount(ColorPicker, { + props: { popupAppendToBody: true }, + attachTo: document.body, + global: { + provide: { + [formItemInjectionKey as symbol]: { validateStatus: ref(''), validate }, + }, + }, + }) + wrappers.push(wrapper) + const outside = document.createElement('button') + document.body.append(outside) + + const trigger = wrapper.find(ns.e('trigger')).element as HTMLButtonElement + trigger.focus() + await wrapper.find(ns.e('trigger')).trigger('click') + await nextTick() + const teleportedSv = document.body.querySelector(ns.e('sv')) as HTMLElement + expect(teleportedSv).toBeTruthy() + expect(document.activeElement).toBe(teleportedSv) + expect(validate).not.toHaveBeenCalledWith('blur') + + outside.focus() + await nextTick() + expect(validate.mock.calls.filter(([reason]) => reason === 'blur')).toHaveLength(1) + outside.remove() + }) }) describe('color-picker RGB inputs', () => { @@ -351,7 +481,6 @@ describe('color-picker RGB inputs', () => { const rInput = wrapper.findAll(ns.e('rgb-input'))[0] // R: 255→128 await rInput.setValue('128') - await rInput.trigger('input') await nextTick() const emitted = wrapper.emitted('update:modelValue') expect(emitted).toBeDefined() @@ -410,12 +539,34 @@ describe('color-picker trigger slot', () => { // 默认触发器不渲染 expect(wrapper.find(ns.e('swatch')).exists()).toBe(false) }) + + it('gives a custom trigger button semantics and supports keyboard open and Escape focus restore', async () => { + const wrapper = mount(ColorPicker, { + props: { modelValue: '#ff0000' }, + slots: { trigger: () => h('span', '自定义颜色') }, + attachTo: document.body, + }) + wrappers.push(wrapper) + const trigger = wrapper.find(ns.e('trigger-custom')) + expect(trigger.attributes('role')).toBe('button') + expect(trigger.attributes('tabindex')).toBe('0') + expect(trigger.attributes('aria-haspopup')).toBe('dialog') + await trigger.trigger('keydown', { key: 'Enter' }) + await nextTick() + expect(wrapper.find(ns.e('panel')).exists()).toBe(true) + await wrapper.find(ns.e('panel')).trigger('keydown', { key: 'Escape' }) + await nextTick() + expect(document.activeElement).toBe(trigger.element) + }) }) describe('color-picker allowClear', () => { it('shows clear button when allowClear=true', () => { const wrapper = mountCP({ modelValue: '#ff0000', allowClear: true }) expect(wrapper.find(ns.e('clear')).exists()).toBe(true) + expect(wrapper.find(ns.e('trigger')).attributes('aria-label')).toContain('#FF0000') + expect(wrapper.findAll('button')).toHaveLength(2) + expect(wrapper.find(ns.e('clear')).attributes('aria-label')).toBe('清空颜色') }) it('does not show clear button by default', () => { @@ -485,6 +636,13 @@ describe('color-picker M-A2 classNames / styles 钩子', () => { }) describe('color-picker M-B6 presets 分组 / 对象项', () => { + it('支持扁平 `{ color, label }` 对象数组', async () => { + const wrapper = mountCP({ presets: [{ color: '#1677ff', label: '品牌蓝' }] }) + await openPanel(wrapper) + const preset = wrapper.find(ns.e('preset')) + expect(preset.attributes('aria-label')).toBe('品牌蓝') + }) + it('支持 `{ label, colors }` 分组形态:渲染每组的 label 和色块', async () => { const wrapper = mountCP({ presets: [ diff --git a/packages/ccui/ui/date-picker/src/date-picker.scss b/packages/ccui/ui/date-picker/src/date-picker.scss index 0dec781d..bea22820 100644 --- a/packages/ccui/ui/date-picker/src/date-picker.scss +++ b/packages/ccui/ui/date-picker/src/date-picker.scss @@ -351,6 +351,10 @@ grid-template-columns: repeat(2, 1fr); } + &__grid-row { + display: contents; + } + &__week-cell { padding: 4px 0; } diff --git a/packages/ccui/ui/date-picker/src/date-picker.tsx b/packages/ccui/ui/date-picker/src/date-picker.tsx index d40b6c77..6b3851e1 100644 --- a/packages/ccui/ui/date-picker/src/date-picker.tsx +++ b/packages/ccui/ui/date-picker/src/date-picker.tsx @@ -4,7 +4,6 @@ import type { DatePickerPlacement, DatePickerProps, DatePickerType, - DisabledTimeReturn, PresetItem, TimeShowConfig, } from './date-picker-types' @@ -111,6 +110,8 @@ export default defineComponent({ const rootRef = ref(null) const popupRef = ref(null) const inputRef = ref(null) + // inputReadOnly=false 时保留用户正在录入的文本;null 表示跟随受控 modelValue 展示。 + const inputDraft = shallowRef(null) const open = shallowRef(false) const formItem = inject(formItemInjectionKey, null) @@ -141,6 +142,14 @@ export default defineComponent({ if (isOutOfRange(d, unit)) return true return !!props.disabledDate?.(d) } + function normalizePickerValue(d: Dayjs): Dayjs { + return props.picker === 'week' ? startOfWeek(d, props.weekStart) : d + } + function isPickerValueDisabled(d: Dayjs): boolean { + const unit = + props.picker === 'month' || props.picker === 'year' || props.picker === 'quarter' ? props.picker : 'day' + return isDateDisabled(normalizePickerValue(d), unit) + } const effectiveTimeFormat = computed(() => showTimeActive.value ? timeCfg.value.format || DEFAULT_TIME_FORMAT : '', ) @@ -178,6 +187,17 @@ export default defineComponent({ }, ) + watch([() => props.modelValue, effectiveFormat, () => props.picker], () => { + inputDraft.value = null + }) + + watch( + () => props.inputReadOnly, + (readOnly) => { + if (readOnly) inputDraft.value = null + }, + ) + const placement = computed(() => PLACEMENT_TO_FLOATING[props.placement]) const popupContainer = computed(() => { if (typeof document === 'undefined') return null @@ -236,11 +256,12 @@ export default defineComponent({ pendingDirty.value = false } - function closePopup() { + function closePopup(restoreFocus = false) { if (!open.value) return open.value = false focusedCellDate.value = null emit('open-change', false) + if (restoreFocus) nextTick(() => inputRef.value?.focus()) } function togglePopup() { @@ -249,6 +270,7 @@ export default defineComponent({ } function emitChange(next: Dayjs | null) { + inputDraft.value = null const value = emitValue(next, props.valueFormat, effectiveFormat.value) emit('update:modelValue', value) emit('change', value, next ? next.format(effectiveFormat.value) : '') @@ -261,12 +283,13 @@ export default defineComponent({ if (isDateDisabled(cell)) return if (props.picker === 'week') { const wkStart = startOfWeek(cell, props.weekStart) + if (isDateDisabled(wkStart)) return if (selectedDayjs.value && isSameWeek(selectedDayjs.value, cell, props.weekStart)) { closePopup() return } emitChange(wkStart) - closePopup() + closePopup(true) return } // showTime 启用:暂存选中日期 + 保留当前时分秒,不立即关闭 @@ -277,11 +300,11 @@ export default defineComponent({ return } if (selectedDayjs.value && isSameDay(selectedDayjs.value, cell)) { - closePopup() + closePopup(true) return } emitChange(cell) - closePopup() + closePopup(true) } function pickTime(unit: 'hour' | 'minute' | 'second', value: number) { @@ -297,29 +320,24 @@ export default defineComponent({ if (showTimeActive.value) { // 与 cell 选择 / 时间列保持一致:此刻命中 disabledDate / minDate / maxDate / disabledTime 时不提交 if (isDateDisabled(now)) return - if ( - mergedDisabledHours().includes(now.hour()) || - mergedDisabledMinutes(now.hour()).includes(now.minute()) || - mergedDisabledSeconds(now.hour(), now.minute()).includes(now.second()) - ) - return + if (isTimeDisabled(now)) return emitChange(now) } else { const target = now.startOf('day') if (isDateDisabled(target)) return emitChange(target) } - closePopup() + closePopup(true) } function clickOk() { - if (!pendingValue.value) { + if (!pendingValue.value || isPendingDisabled.value) { // 没选过日期,但 pendingValue 在 openPopup 时被初始化过 — 只有不在 showTime 模式时才会是 null closePopup() return } emitChange(pendingValue.value) - closePopup() + closePopup(true) } function clickPreset(p: PresetItem) { @@ -327,26 +345,24 @@ export default defineComponent({ // 预设值用非严格解析:业务常用 '2026-05-09' / Date / Dayjs 等,不强求匹配 effectiveFormat const d = toDayjs(raw as never) if (!d) return + const normalized = normalizePickerValue(d) + if (isPickerValueDisabled(normalized)) return if (showTimeActive.value) { - pendingValue.value = d + pendingValue.value = normalized pendingDirty.value = true - viewMonth.value = d + viewMonth.value = normalized return } // 非 showTime:立即提交(按 picker 模式语义对齐) - if (props.picker === 'week') { - emitChange(startOfWeek(d, props.weekStart)) - } else { - emitChange(d) - } - closePopup() + emitChange(normalized) + closePopup(true) } function pickMonth(cell: Dayjs) { if (isDateDisabled(cell, 'month')) return if (props.picker === 'month') { emitChange(cell) - closePopup() + closePopup(true) return } // date / week 模式:选中月后下钻回 date 视图 @@ -358,7 +374,7 @@ export default defineComponent({ if (isDateDisabled(cell, 'year')) return if (props.picker === 'year') { emitChange(cell) - closePopup() + closePopup(true) return } viewMonth.value = cell @@ -373,7 +389,7 @@ export default defineComponent({ function pickQuarter(cell: Dayjs) { if (isDateDisabled(cell, 'quarter')) return emitChange(cell) - closePopup() + closePopup(true) } // ===== 上一/下一与逐级展开 ===== @@ -414,6 +430,22 @@ export default defineComponent({ emitChange(null) } + function commitInput() { + if (props.inputReadOnly || inputDraft.value === null) return + const raw = inputDraft.value.trim() + if (!raw) { + if (selectedDayjs.value) emitChange(null) + else inputDraft.value = null + return + } + const parsed = toDayjs(raw, effectiveFormat.value) + if (!parsed || isPickerValueDisabled(parsed)) { + inputDraft.value = null + return + } + emitChange(normalizePickerValue(parsed)) + } + function onClickOutside(e: MouseEvent) { if (!open.value) return const target = e.target as Node | null @@ -434,6 +466,13 @@ export default defineComponent({ document.removeEventListener('mousedown', onClickOutside, true) }) + watch( + () => props.disabled, + (disabled) => { + if (disabled) closePopup() + }, + ) + const showClear = computed(() => props.clearable && !props.disabled && !!selectedDayjs.value) // ===== 标签 / 月名 / 季度名 ===== @@ -558,6 +597,22 @@ export default defineComponent({ return Array.from(new Set([...fromTime, ...fromDynamic])) } + function isTimeDisabled(value: Dayjs): boolean { + if (mergedDisabledHours().includes(value.hour())) return true + if (hasMinutes.value && mergedDisabledMinutes(value.hour()).includes(value.minute())) return true + return hasSeconds.value && mergedDisabledSeconds(value.hour(), value.minute()).includes(value.second()) + } + + const isPendingDisabled = computed(() => { + const pending = pendingValue.value + if (!pending || isDateDisabled(pending)) return true + return isTimeDisabled(pending) + }) + + function dateCellId(value: Dayjs): string { + return `${popupId}-cell-${value.format('YYYY-MM-DD')}` + } + function renderCellInner(current: Dayjs, type: 'date' | 'month' | 'year' | 'quarter', fallback: string | number) { if (slots.cell) { return slots.cell({ current, type, today: dayjs() }) @@ -581,7 +636,7 @@ export default defineComponent({ // 面板已打开 if (k === 'Escape') { e.preventDefault() - closePopup() + closePopup(true) return } if (k === 'Tab') { @@ -622,14 +677,17 @@ export default defineComponent({
{w}
))}
-
+
{rows.map((row) => { const rowWeekInfo = weekPicker ? getWeekInfo(row[0].date, props.weekStart) : null const rowSelected = weekPicker && !!sel && row.some((c) => isSameWeek(sel, c.date, props.weekStart)) return ( - +
{rowWeekInfo && ( -
+
{rowWeekInfo.weekNumber}
)} @@ -648,6 +706,7 @@ export default defineComponent({ ] return (
) })} - +
) })}
@@ -735,7 +794,8 @@ export default defineComponent({ function renderFooter() { // ok 禁用:showTime 启用且既无已有 modelValue 又未动过任何格 - const okDisabled = showTimeActive.value && !selectedDayjs.value && !pendingDirty.value + const okDisabled = + showTimeActive.value && ((!selectedDayjs.value && !pendingDirty.value) || isPendingDisabled.value) const hasExtra = !!slots['extra-footer'] const showActions = showTimeActive.value if (!hasExtra && !showActions) return null @@ -765,91 +825,106 @@ export default defineComponent({ function renderMonthPanel() { const cells = generateYearMonthGrid(viewMonth.value) + const rows = Array.from({ length: 4 }, (_, index) => cells.slice(index * 3, index * 3 + 3)) return ( -
- {cells.map((c) => { - const disabled = isDateDisabled(c.date, 'month') - const selected = !!selectedDayjs.value && isSameMonth(selectedDayjs.value, c.date) - const cellCls = [ - ns.e('cell'), - ns.em('cell', 'month'), - c.isToday && ns.em('cell', 'today'), - selected && ns.em('cell', 'selected'), - disabled && ns.em('cell', 'disabled'), - ] - return ( -
!disabled && pickMonth(c.date)} - > - {renderCellInner(c.date, 'month', monthNames.value[c.month])} -
- ) - })} +
+ {rows.map((row) => ( +
+ {row.map((c) => { + const disabled = isDateDisabled(c.date, 'month') + const selected = !!selectedDayjs.value && isSameMonth(selectedDayjs.value, c.date) + const cellCls = [ + ns.e('cell'), + ns.em('cell', 'month'), + c.isToday && ns.em('cell', 'today'), + selected && ns.em('cell', 'selected'), + disabled && ns.em('cell', 'disabled'), + ] + return ( +
!disabled && pickMonth(c.date)} + > + {renderCellInner(c.date, 'month', monthNames.value[c.month])} +
+ ) + })} +
+ ))}
) } function renderYearPanel() { const cells = generateDecadeYearGrid(viewMonth.value) + const rows = Array.from({ length: 4 }, (_, index) => cells.slice(index * 3, index * 3 + 3)) return ( -
- {cells.map((c) => { - const disabled = isDateDisabled(c.date, 'year') - const selected = !!selectedDayjs.value && selectedDayjs.value.year() === c.year - const cellCls = [ - ns.e('cell'), - ns.em('cell', 'year'), - !c.isInDecade && ns.em('cell', 'outside'), - c.isToday && ns.em('cell', 'today'), - selected && ns.em('cell', 'selected'), - disabled && ns.em('cell', 'disabled'), - ] - return ( -
!disabled && pickYear(c.date)} - > - {renderCellInner(c.date, 'year', c.year)} -
- ) - })} +
+ {rows.map((row) => ( +
+ {row.map((c) => { + const disabled = isDateDisabled(c.date, 'year') + const selected = !!selectedDayjs.value && selectedDayjs.value.year() === c.year + const cellCls = [ + ns.e('cell'), + ns.em('cell', 'year'), + !c.isInDecade && ns.em('cell', 'outside'), + c.isToday && ns.em('cell', 'today'), + selected && ns.em('cell', 'selected'), + disabled && ns.em('cell', 'disabled'), + ] + return ( +
!disabled && pickYear(c.date)} + > + {renderCellInner(c.date, 'year', c.year)} +
+ ) + })} +
+ ))}
) } function renderQuarterPanel() { const cells = generateQuarterGrid(viewMonth.value) + const rows = Array.from({ length: 2 }, (_, index) => cells.slice(index * 2, index * 2 + 2)) return ( -
- {cells.map((c) => { - const disabled = isDateDisabled(c.date, 'quarter') - const selected = !!selectedDayjs.value && isSameQuarter(selectedDayjs.value, c.date) - const cellCls = [ - ns.e('cell'), - ns.em('cell', 'quarter'), - c.isCurrentQuarter && ns.em('cell', 'today'), - selected && ns.em('cell', 'selected'), - disabled && ns.em('cell', 'disabled'), - ] - return ( -
!disabled && pickQuarter(c.date)} - > - {renderCellInner(c.date, 'quarter', quarterNames.value[c.quarter - 1])} -
- ) - })} +
+ {rows.map((row) => ( +
+ {row.map((c) => { + const disabled = isDateDisabled(c.date, 'quarter') + const selected = !!selectedDayjs.value && isSameQuarter(selectedDayjs.value, c.date) + const cellCls = [ + ns.e('cell'), + ns.em('cell', 'quarter'), + c.isCurrentQuarter && ns.em('cell', 'today'), + selected && ns.em('cell', 'selected'), + disabled && ns.em('cell', 'disabled'), + ] + return ( +
!disabled && pickQuarter(c.date)} + > + {renderCellInner(c.date, 'quarter', quarterNames.value[c.quarter - 1])} +
+ ) + })} +
+ ))}
) } @@ -933,12 +1008,22 @@ export default defineComponent({ readonly={props.inputReadOnly} disabled={props.disabled} placeholder={placeholderText.value} - value={inputDisplay.value} + value={inputDraft.value ?? inputDisplay.value} aria-haspopup="dialog" aria-expanded={open.value} aria-controls={popupId} + aria-activedescendant={ + open.value && panelMode.value === 'date' && focusedCellDate.value + ? dateCellId(focusedCellDate.value) + : undefined + } onFocus={() => emit('focus')} + onInput={(e: Event) => { + if (!props.inputReadOnly) inputDraft.value = (e.target as HTMLInputElement).value + }} + onChange={commitInput} onBlur={() => { + commitInput() emit('blur') formItem?.validate('blur') }} diff --git a/packages/ccui/ui/date-picker/test/date-picker.test.ts b/packages/ccui/ui/date-picker/test/date-picker.test.ts index 72e8c423..4f489c75 100644 --- a/packages/ccui/ui/date-picker/test/date-picker.test.ts +++ b/packages/ccui/ui/date-picker/test/date-picker.test.ts @@ -110,6 +110,14 @@ describe('date-picker popup open/close', () => { await nextTick() expect(document.activeElement).toBe(wrapper.find('input').element) }) + + it('closes an open panel when disabled becomes true', async () => { + const wrapper = mountDP() + await openPanel(wrapper) + await wrapper.setProps({ disabled: true }) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + expect(wrapper.emitted('open-change')?.at(-1)).toEqual([false]) + }) }) describe('date-picker selection', () => { @@ -160,6 +168,17 @@ describe('date-picker selection', () => { expect(wrapper.find(ns.e('panel')).exists()).toBe(false) }) + it('restores focus to the input after an internal selection closes the panel', async () => { + const wrapper = mountDP({ picker: 'month', modelValue: '2026-05' }) + await openPanel(wrapper) + const nextYear = wrapper.find(ns.em('arrow', 'next-year')) + ;(nextYear.element as HTMLButtonElement).focus() + expect(document.activeElement).toBe(nextYear.element) + await wrapper.findAll(ns.em('cell', 'month'))[6].trigger('click') + await nextTick() + expect(document.activeElement).toBe(wrapper.find('input').element) + }) + it('does not re-emit change when clicking the same selected day', async () => { const wrapper = mountDP({ modelValue: '2026-05-09' }) await openPanel(wrapper) @@ -251,6 +270,71 @@ describe('date-picker clearable', () => { }) }) +describe('date-picker editable input', () => { + it('strictly parses and emits a valid typed value on change', async () => { + const wrapper = mountDP({ inputReadOnly: false, format: 'YYYY/MM/DD' }) + const input = wrapper.find('input') + await input.setValue('2026/06/18') + await input.trigger('change') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2026/06/18']) + expect(wrapper.emitted('change')?.[0]).toEqual(['2026/06/18', '2026/06/18']) + }) + + it('rejects invalid or disabled typed values and restores the controlled display', async () => { + const wrapper = mountDP({ inputReadOnly: false, modelValue: '2026-05-09', minDate: '2026-05-10' }) + const input = wrapper.find('input') + await input.setValue('2026-05-01') + await input.trigger('change') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect((input.element as HTMLInputElement).value).toBe('2026-05-09') + }) + + it('lets external controlled state and dynamic parsing props replace an in-progress draft', async () => { + const model = ref(new Date('2026-05-09T00:00:00')) + const format = ref('YYYY-MM-DD') + const picker = ref<'date' | 'month'>('date') + const readOnly = ref(false) + const Host = defineComponent({ + setup() { + return () => + h(DatePicker, { + modelValue: model.value, + format: format.value, + picker: picker.value, + inputReadOnly: readOnly.value, + }) + }, + }) + const wrapper = mount(Host, { attachTo: document.body }) + wrappers.push(wrapper as unknown as VueWrapper) + const typeDraft = (value: string) => { + const input = wrapper.find('input').element as HTMLInputElement + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + } + + typeDraft('stale-model') + model.value = new Date('2026-06-18T00:00:00') + await nextTick() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('2026-06-18') + + typeDraft('stale-format') + format.value = 'YYYY/MM/DD' + await nextTick() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('2026/06/18') + + typeDraft('stale-picker') + picker.value = 'month' + await nextTick() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('2026/06/18') + + typeDraft('stale-readonly') + readOnly.value = true + await nextTick() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('2026/06/18') + }) +}) + describe('date-picker month / year navigation', () => { it('navigates to previous month without emitting change', async () => { const wrapper = mountDP({ modelValue: '2026-05-09' }) @@ -856,6 +940,41 @@ describe('date-picker showTime', () => { expect(wrapper.emitted('update:modelValue')![0][0]).toBe('2026-03') }) + it('disables confirmation when the pending time is forbidden', async () => { + const wrapper = mountDP({ showTime: { disabledHours: () => [0] } }) + await openPanel(wrapper) + const dateCell = wrapper + .findAll(ns.e('cell')) + .find((c) => !c.classes(ns.em('cell', 'outside').slice(1)) && c.text() === '20') + await dateCell!.trigger('click') + expect(wrapper.find(ns.em('footer-btn', 'ok')).attributes('disabled')).toBeDefined() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('ignores disabled rules for minute/second columns hidden by the time format', async () => { + const minuteHidden = mountDP({ showTime: { format: 'HH', disabledMinutes: () => [0] } }) + await openPanel(minuteHidden) + const firstDate = minuteHidden + .findAll(ns.e('cell')) + .find((c) => !c.classes(ns.em('cell', 'outside').slice(1)) && c.text() === '20')! + await firstDate.trigger('click') + expect(minuteHidden.find(ns.em('footer-btn', 'ok')).attributes('disabled')).toBeUndefined() + + const secondHidden = mountDP({ + showTime: { format: 'HH:mm' }, + disabledTime: () => ({ disabledSeconds: () => [0] }), + }) + await openPanel(secondHidden) + const secondDate = secondHidden + .findAll(ns.e('cell')) + .find((c) => !c.classes(ns.em('cell', 'outside').slice(1)) && c.text() === '21')! + await secondDate.trigger('click') + const ok = secondHidden.find(ns.em('footer-btn', 'ok')) + expect(ok.attributes('disabled')).toBeUndefined() + await ok.trigger('click') + expect(secondHidden.emitted('update:modelValue')?.[0]).toEqual(['2026-05-21 00:00']) + }) + it('format 显式覆盖 showTime 的兜底(YYYY/MM/DD HH:mm)', async () => { const wrapper = mountDP({ showTime: { format: 'HH:mm' }, format: 'YYYY/MM/DD HH:mm' }) await openPanel(wrapper) @@ -964,6 +1083,43 @@ describe('date-picker presets', () => { expect(wrapper.find(ns.em('panel', 'with-presets')).exists()).toBe(true) }) + it('does not let presets bypass minDate / disabledDate constraints', async () => { + const wrapper = mountDP({ + minDate: '2026-05-10', + disabledDate: (d: dayjs.Dayjs) => d.date() === 12, + presets: [ + { label: '过早', value: '2026-05-09' }, + { label: '禁用', value: '2026-05-12' }, + ], + }) + await openPanel(wrapper) + await wrapper.findAll(ns.e('preset-item'))[0].trigger('click') + await wrapper.findAll(ns.e('preset-item'))[1].trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find(ns.e('panel')).exists()).toBe(true) + }) + + it('checks week constraints against the normalized week start for presets and typed input', async () => { + const preset = mountDP({ + picker: 'week', + weekStart: 0, + minDate: '2026-05-11', + presets: [{ label: '本周', value: '2026-05-12' }], + }) + await openPanel(preset) + await preset.find(ns.e('preset-item')).trigger('click') + expect(preset.emitted('update:modelValue')).toBeUndefined() + + const editable = mountDP({ picker: 'week', weekStart: 0, minDate: '2026-05-11', inputReadOnly: false }) + const input = editable.find('input').element as HTMLInputElement + input.value = '2026-05-12' + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + await nextTick() + expect(editable.emitted('update:modelValue')).toBeUndefined() + expect(input.value).toBe('') + }) + describe('variant', () => { it('默认 variant 为 outlined', () => { const wrapper = mountDP() @@ -1236,4 +1392,28 @@ describe('XL-4 ARIA dialog', () => { expect(panel.attributes('role')).toBe('dialog') expect(panel.attributes('aria-label')).toBeTruthy() }) + + it('uses real row context and exposes keyboard navigation through aria-activedescendant', async () => { + const wrapper = mountDP({ modelValue: '2026-05-09' }) + await openPanel(wrapper) + const grid = wrapper.find(ns.e('grid')) + expect(grid.attributes('role')).toBe('grid') + expect(Array.from(grid.element.children).every((node) => node.getAttribute('role') === 'row')).toBe(true) + expect( + Array.from(grid.element.querySelectorAll('[role="gridcell"]')).every( + (cell) => cell.parentElement?.getAttribute('role') === 'row', + ), + ).toBe(true) + + const input = wrapper.find('input').element as HTMLInputElement + input.focus() + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + await nextTick() + const activeId = input.getAttribute('aria-activedescendant') + const activeCell = activeId ? document.getElementById(activeId) : null + expect(document.activeElement).toBe(input) + expect(activeCell?.getAttribute('role')).toBe('gridcell') + expect(activeCell?.textContent).toContain('10') + expect(document.querySelectorAll(`#${activeId}`).length).toBe(1) + }) }) diff --git a/packages/ccui/ui/form/src/form-item.tsx b/packages/ccui/ui/form/src/form-item.tsx index 1b2bbbd4..bd825f99 100644 --- a/packages/ccui/ui/form/src/form-item.tsx +++ b/packages/ccui/ui/form/src/form-item.tsx @@ -40,6 +40,8 @@ import { } from './utils' import './form.scss' +let formItemId = 0 + export default defineComponent({ name: 'CFormItem', props: formItemProps, @@ -52,6 +54,7 @@ export default defineComponent({ const validateMessage = ref('') const warningState = ref(false) const itemRef = ref(null) + const itemId = ++formItemId const fieldName = computed(() => { const raw = props.name if (!formList) { @@ -104,6 +107,7 @@ export default defineComponent({ return validateState.value }) const currentMessage = computed(() => validateMessage.value || props.help) + const messageId = computed(() => (currentMessage.value ? `ccui-form-item-${itemId}-message` : undefined)) const shouldShowOptional = computed(() => !isRequired.value && form?.requiredMark.value === 'optional') const shouldShowRequiredMark = computed(() => isRequired.value && form?.requiredMark.value !== false) const mergedColon = computed(() => props.colon ?? form?.colon.value ?? true) @@ -141,26 +145,51 @@ export default defineComponent({ [ns.m('top')]: form?.labelPosition.value === 'top' || form?.layout.value === 'vertical', })) + let validationGeneration = 0 + let validateTimer: ReturnType | null = null + let pendingValidateResolvers: Array<(valid: boolean) => void> = [] + + const settleResolvers = (resolvers: Array<(valid: boolean) => void>, valid: boolean) => { + resolvers.forEach((resolve) => resolve(valid)) + } + + const cancelPendingDebouncedValidations = () => { + if (validateTimer) { + clearTimeout(validateTimer) + validateTimer = null + } + const resolvers = pendingValidateResolvers + pendingValidateResolvers = [] + settleResolvers(resolvers, false) + } + const clearValidate = () => { + validationGeneration += 1 + cancelPendingDebouncedValidations() validateState.value = '' validateMessage.value = '' warningState.value = false } - let validateTimer: ReturnType | null = null - - const doValidate = async (trigger?: FormValidateTrigger) => { + const doValidate = async (trigger: FormValidateTrigger | undefined, generation: number) => { if (!fieldKey.value || !form) { return true } const rules = getTriggeredRules(mergedRules.value, trigger) if (rules.length === 0) { - clearValidate() + if (generation === validationGeneration) { + validateState.value = '' + validateMessage.value = '' + warningState.value = false + } return true } - validateState.value = 'validating' + if (generation === validationGeneration) { + warningState.value = false + validateState.value = 'validating' + } const value = getValueByPath(form.model.value, fieldName.value) // warningOnly 规则:失败时降级为 warning,不阻塞 submit @@ -180,13 +209,19 @@ export default defineComponent({ // 继续校验其它规则,不立即返回 continue } - validateState.value = 'error' - validateMessage.value = error.message - form.emitValidate(fieldKey.value, false, error.message) + if (generation === validationGeneration) { + validateState.value = 'error' + validateMessage.value = error.message + form.emitValidate(fieldKey.value, false, error.message) + } return false } } + if (generation !== validationGeneration) { + return true + } + if (warningMessage) { // 仅 warning:状态降级(保留 success 类型签名,CSS 走 --warning class) validateState.value = 'success' as any @@ -205,15 +240,23 @@ export default defineComponent({ } const validate = async (trigger?: FormValidateTrigger) => { + const generation = ++validationGeneration if (props.validateDebounce > 0) { if (validateTimer) clearTimeout(validateTimer) return new Promise((resolve) => { + pendingValidateResolvers.push(resolve) validateTimer = setTimeout(() => { - doValidate(trigger).then(resolve) + validateTimer = null + const resolvers = pendingValidateResolvers + pendingValidateResolvers = [] + doValidate(trigger, generation).then( + (valid) => settleResolvers(resolvers, valid), + () => settleResolvers(resolvers, false), + ) }, props.validateDebounce) }) } - return doValidate(trigger) + return doValidate(trigger, generation) } const resetField = () => { @@ -257,7 +300,11 @@ export default defineComponent({ } } - const onFocusoutCapture = () => { + const onFocusoutCapture = (event: FocusEvent) => { + const nextTarget = event.relatedTarget + if (nextTarget instanceof Node && itemRef.value?.contains(nextTarget)) { + return + } void validate('blur') } @@ -273,11 +320,8 @@ export default defineComponent({ }) onUnmounted(() => { - // 卸载时清理仍处于防抖窗口的定时器,避免触发已卸载组件的 doValidate - if (validateTimer) { - clearTimeout(validateTimer) - validateTimer = null - } + validationGeneration += 1 + cancelPendingDebouncedValidations() form?.removeField(fieldContext) const itemPreserve = props.preserve const formPreserve = form?.preserve.value ?? true @@ -295,12 +339,13 @@ export default defineComponent({ provide(formItemInjectionKey, { validateStatus: currentStatus, + messageId, isInsideForm: !!form, validate, }) watch( - () => form?.model.value, + () => props.dependencies.map((dependency) => getValueByPath(form?.model.value ?? {}, dependency)), () => { if (props.dependencies.length > 0) { void validate() @@ -362,7 +407,9 @@ export default defineComponent({ slots.default?.(), renderFeedbackIcon(), ]), - currentMessage.value ? h('div', { class: ns.e('message'), role: 'alert' }, currentMessage.value) : null, + currentMessage.value + ? h('div', { id: messageId.value, class: ns.e('message'), role: 'alert' }, currentMessage.value) + : null, props.extra ? h('div', { class: ns.e('extra') }, props.extra) : null, ]) diff --git a/packages/ccui/ui/form/src/form-types.ts b/packages/ccui/ui/form/src/form-types.ts index 5c8fcef9..ece6c7f4 100644 --- a/packages/ccui/ui/form/src/form-types.ts +++ b/packages/ccui/ui/form/src/form-types.ts @@ -125,6 +125,7 @@ export const formInjectionKey: InjectionKey = Symbol('ccuiForm') export interface FormItemInjectedContext { validateStatus: Ref + messageId?: ComputedRef isInsideForm: boolean validate: (trigger?: FormValidateTrigger) => Promise } diff --git a/packages/ccui/ui/form/src/form.tsx b/packages/ccui/ui/form/src/form.tsx index 28db6f44..6daefbb7 100644 --- a/packages/ccui/ui/form/src/form.tsx +++ b/packages/ccui/ui/form/src/form.tsx @@ -127,18 +127,6 @@ export default defineComponent({ }, ) - watch( - () => props.model, - () => { - fields.value.forEach((field) => { - if (field.dependencies.length > 0) { - void field.validate() - } - }) - }, - { deep: true }, - ) - const exposed: FormInstance = { validate, validateField, @@ -156,6 +144,21 @@ export default defineComponent({ } }) + watch( + () => props.name, + (name, previousName) => { + if (!provider) { + return + } + if (previousName) { + provider.unregisterForm(previousName) + } + if (name) { + provider.registerForm(name, exposed) + } + }, + ) + onUnmounted(() => { if (props.name && provider) { provider.unregisterForm(props.name) diff --git a/packages/ccui/ui/form/src/utils.ts b/packages/ccui/ui/form/src/utils.ts index a5f74bf7..18100341 100644 --- a/packages/ccui/ui/form/src/utils.ts +++ b/packages/ccui/ui/form/src/utils.ts @@ -271,8 +271,13 @@ export async function validateRule( } } - if (rule.pattern && !rule.pattern.test(String(value))) { - return { field, message: getMessage(rule, field, label, validateMessages, 'pattern') } + if (rule.pattern) { + rule.pattern.lastIndex = 0 + const matches = rule.pattern.test(String(value)) + rule.pattern.lastIndex = 0 + if (!matches) { + return { field, message: getMessage(rule, field, label, validateMessages, 'pattern') } + } } if (rule.validator) { diff --git a/packages/ccui/ui/form/test/form.test.ts b/packages/ccui/ui/form/test/form.test.ts index f9dfae94..ba18f7e5 100644 --- a/packages/ccui/ui/form/test/form.test.ts +++ b/packages/ccui/ui/form/test/form.test.ts @@ -1,9 +1,10 @@ import type { VueWrapper } from '@vue/test-utils' import { mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { describe, expect, it, vi } from 'vite-plus/test' import { Icon as IconifyIcon } from '@iconify/vue' -import { defineComponent, h, nextTick, reactive } from 'vue' +import { defineComponent, h, nextTick, reactive, ref } from 'vue' import { Form, FormItem, FormList, FormProvider } from '../index' +import { Input } from '../../input' import { useNamespace } from '../../shared/hooks/use-namespace' const formNs = useNamespace('form', true) @@ -375,6 +376,24 @@ describe('form', () => { expect(wrapper.find(itemNs.e('message')).text()).toBe('External error') }) + it('associates an Input with its label and validation message', () => { + const wrapper = mount(Form, { + props: { model: { email: '' } }, + slots: { + default: () => + h(FormItem, { label: 'Email', name: 'email', htmlFor: 'email-input', help: 'Use a work email' }, () => + h(Input, { id: 'email-input' }), + ), + }, + }) + const input = wrapper.find('input') + const message = wrapper.find(itemNs.e('message')) + + expect(wrapper.find(itemNs.e('label')).attributes('for')).toBe(input.attributes('id')) + expect(message.attributes('id')).toBeTruthy() + expect(input.attributes('aria-describedby')).toBe(message.attributes('id')) + }) + it('clears validation state for all fields', async () => { const wrapper = mount(Form, { props: { @@ -1146,6 +1165,28 @@ describe('form validateDebounce', () => { expect(wrapper.find(itemNs.b()).classes()).toContain('ccui-form-item--error') vi.useRealTimers() }) + + it('settles every validation promise when repeated calls reset the debounce timer', async () => { + vi.useFakeTimers() + const model = reactive({ name: '' }) + const wrapper = mount(Form, { + props: { + model, + rules: { name: [{ required: true, message: 'required' }] }, + }, + slots: { + default: () => h(FormItem, { name: 'name', validateDebounce: 200 }, () => h('input')), + }, + }) + const form = getFormVm(wrapper) + + const firstValidation = form.validate() + const secondValidation = form.validate() + vi.advanceTimersByTime(200) + + await expect(Promise.all([firstValidation, secondValidation])).resolves.toEqual([false, false]) + vi.useRealTimers() + }) }) describe('form normalize', () => { @@ -1382,3 +1423,249 @@ describe('L-1.6 rules 函数式', () => { expect(wrapper.find('.ccui-form-item__message').text()).toBe('两次密码不一致') }) }) + +describe('form deep-review regressions', () => { + it('validates a dependent field once for one model mutation', async () => { + const validator = vi.fn(() => true) + const model = reactive({ source: 'one', dependent: 'value', unrelated: 'first' }) + mount(Form, { + props: { model }, + slots: { + default: () => + h(FormItem, { name: 'dependent', dependencies: ['source'], rules: { validator } }, () => h('input')), + }, + }) + + await nextTick() + model.source = 'two' + await nextTick() + await Promise.resolve() + + expect(validator).toHaveBeenCalledTimes(1) + + model.unrelated = 'second' + await nextTick() + model.dependent = 'next value' + await nextTick() + expect(validator).toHaveBeenCalledTimes(1) + }) + + it('does not let an older async validation overwrite the latest result', async () => { + const resolvers: Array<(result: true | string) => void> = [] + const model = reactive({ name: 'old' }) + const wrapper = mount(Form, { + props: { + model, + rules: { + name: { + validator: () => new Promise((resolve) => resolvers.push(resolve)), + }, + }, + }, + slots: { default: () => h(FormItem, { name: 'name' }, () => h('input')) }, + }) + const form = getFormVm(wrapper) + + const older = form.validateField('name') + model.name = 'new' + const latest = form.validateField('name') + resolvers[1](true) + await latest + resolvers[0]('stale error') + await older + await nextTick() + + expect(wrapper.find(itemNs.e('message')).exists()).toBe(false) + expect(wrapper.findComponent(FormItem).classes()).toContain(itemNs.m('success').slice(1)) + }) + + it('settles an obsolete multi-rule validation with its own blocking result', async () => { + const resolvers: Array<(result: true) => void> = [] + const model = reactive({ name: 'invalid' }) + const wrapper = mount(Form, { + props: { + model, + rules: { + name: [ + { validator: () => new Promise((resolve) => resolvers.push(resolve)) }, + { pattern: /^valid$/, message: 'invalid value' }, + ], + }, + }, + slots: { default: () => h(FormItem, { name: 'name' }, () => h('input')) }, + }) + const form = getFormVm(wrapper) + + const older = form.validateField('name') + model.name = 'valid' + const latest = form.validateField('name') + resolvers[1](true) + await expect(latest).resolves.toBe(true) + resolvers[0](true) + await expect(older).resolves.toBe(false) + + expect(wrapper.find(itemNs.e('message')).exists()).toBe(false) + expect(wrapper.findComponent(FormItem).classes()).toContain(itemNs.m('success').slice(1)) + }) + + it.each(['clearValidate', 'resetFields'] as const)( + '%s cancels and settles validation still waiting in the debounce window', + async (method) => { + vi.useFakeTimers() + const model = reactive({ name: '' }) + const wrapper = mount(Form, { + props: { model, rules: { name: { required: true, message: 'required' } } }, + slots: { + default: () => h(FormItem, { name: 'name', validateDebounce: 200 }, () => h('input')), + }, + }) + const form = getFormVm(wrapper) + const item = wrapper.findComponent(FormItem).vm.$.exposed as { + validate: () => Promise + } + + const pending = item.validate() + form[method]('name') + await expect(pending).resolves.toBe(false) + vi.advanceTimersByTime(200) + await nextTick() + + expect(wrapper.findComponent(FormItem).classes()).not.toContain(itemNs.m('error').slice(1)) + expect(wrapper.find(itemNs.e('message')).exists()).toBe(false) + vi.useRealTimers() + }, + ) + + it('does not emit or write state after an active async validator is unmounted', async () => { + const show = ref(true) + let resolveValidator!: (result: string) => void + const wrapper = mount( + defineComponent({ + setup: () => () => + h(Form, { model: { name: 'value' } }, () => + show.value + ? h( + FormItem, + { + name: 'name', + rules: { + validator: () => new Promise((resolve) => (resolveValidator = resolve)), + }, + }, + () => h('input'), + ) + : null, + ), + }), + ) + const formWrapper = wrapper.findComponent(Form) + const pending = getFormVm(formWrapper).validateField('name') + + show.value = false + await nextTick() + resolveValidator('late error') + await expect(pending).resolves.toBe(false) + await nextTick() + + expect(formWrapper.emitted('validate')).toBeUndefined() + expect(wrapper.findComponent(FormItem).exists()).toBe(false) + }) + + it('invalidates an active async validator as soon as a newer debounced request is queued', async () => { + vi.useFakeTimers() + const resolvers: Array<(result: true | string) => void> = [] + const wrapper = mount(Form, { + props: { model: { name: 'value' } }, + slots: { + default: () => + h( + FormItem, + { + name: 'name', + validateDebounce: 200, + rules: { validator: () => new Promise((resolve) => resolvers.push(resolve)) }, + }, + () => h('input'), + ), + }, + }) + const formWrapper = wrapper.findComponent(Form) + const item = wrapper.findComponent(FormItem).vm.$.exposed as { + validate: () => Promise + } + + const older = item.validate() + vi.advanceTimersByTime(200) + await Promise.resolve() + expect(resolvers).toHaveLength(1) + + const latest = item.validate() + resolvers[0]('stale error') + await expect(older).resolves.toBe(false) + await nextTick() + + expect(wrapper.find(itemNs.e('message')).exists()).toBe(false) + expect(formWrapper.emitted('validate')).toBeUndefined() + + vi.advanceTimersByTime(200) + await Promise.resolve() + resolvers[1](true) + await expect(latest).resolves.toBe(true) + vi.useRealTimers() + }) + + it('keeps blur validation idle while focus moves inside one FormItem', async () => { + const validator = vi.fn(() => true) + const wrapper = mount(Form, { + props: { model: { value: 'ok' }, rules: { value: { trigger: 'blur', validator } } }, + slots: { + default: () => h(FormItem, { name: 'value' }, () => [h('input'), h('button', 'Next')]), + }, + }) + const input = wrapper.find('input') + const button = wrapper.find('button') + + input.element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: button.element })) + await nextTick() + expect(validator).not.toHaveBeenCalled() + + button.element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body })) + await nextTick() + await Promise.resolve() + expect(validator).toHaveBeenCalledTimes(1) + }) + + it('reuses global regular expressions without leaking lastIndex', async () => { + const wrapper = mount(Form, { + props: { model: { code: 'ok' }, rules: { code: { pattern: /^ok$/g } } }, + slots: { default: () => h(FormItem, { name: 'code' }, () => h('input')) }, + }) + const form = getFormVm(wrapper) + + await expect(form.validateField('code')).resolves.toBe(true) + await expect(form.validateField('code')).resolves.toBe(true) + }) + + it('updates FormProvider registration when the form name changes', async () => { + const changeHandler = vi.fn() + const formName = reactive({ value: 'old-name' }) + const model = reactive({ name: '' }) + const wrapper = mount(FormProvider, { + props: { onFormChange: changeHandler }, + slots: { + default: () => h(Form, { name: formName.value, model }, () => h(FormItem, { name: 'name' }, () => h('input'))), + }, + }) + + formName.value = 'new-name' + await nextTick() + model.name = 'updated' + await wrapper.find('input').trigger('change') + await nextTick() + + const [name, info] = changeHandler.mock.calls.at(-1)! + expect(name).toBe('new-name') + expect(info.forms['new-name']).toBeDefined() + expect(info.forms['old-name']).toBeUndefined() + }) +}) diff --git a/packages/ccui/ui/input-number/src/input-number.tsx b/packages/ccui/ui/input-number/src/input-number.tsx index 03cd4bc9..517978cf 100644 --- a/packages/ccui/ui/input-number/src/input-number.tsx +++ b/packages/ccui/ui/input-number/src/input-number.tsx @@ -1,25 +1,68 @@ import type { FormItemInjectedContext } from '../../form/src/form-types' import type { InputNumberInstance, InputNumberProps, InputNumberValue } from './input-number-types' -import { computed, defineComponent, inject, nextTick, ref, watch } from 'vue' +import { computed, defineComponent, inject, mergeProps, nextTick, ref, watch } from 'vue' import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' import { inputNumberProps } from './input-number-types' import './input-number.scss' +function getDecimalPlaces(value: number): number { + const [coefficient, exponentText] = value.toString().toLowerCase().split('e') + const fractionLength = coefficient.split('.')[1]?.length ?? 0 + const exponent = Number(exponentText ?? 0) + return Math.min(15, Math.max(0, fractionLength - exponent)) +} + +function addStep(value: number, step: number, direction: 1 | -1): number { + const factor = 10 ** Math.max(getDecimalPlaces(value), getDecimalPlaces(step)) + const result = (Math.round(value * factor) + direction * Math.round(step * factor)) / factor + return Number.isFinite(result) ? result : value + direction * step +} + export default defineComponent({ name: 'CInputNumber', + inheritAttrs: false, props: inputNumberProps, emits: ['update:modelValue', 'change', 'blur', 'focus', 'input'], - setup(props: InputNumberProps, { emit, expose }) { + setup(props: InputNumberProps, { attrs, emit, expose }) { const ns = useNamespace('input-number') const inputRef = ref() const formItem = inject(formItemInjectionKey, null) const validationStatus = computed(() => formItem?.validateStatus.value ?? '') const mergedStatus = computed(() => props.status || validationStatus.value) - // 内部值状态 - const innerValue = ref(props.modelValue) + const normalizedPrecision = computed(() => { + if (props.precision === undefined || !Number.isFinite(props.precision)) return undefined + return Math.min(100, Math.max(0, Math.trunc(props.precision))) + }) + const normalizedMin = computed(() => (Number.isFinite(props.min) ? props.min : -Infinity)) + const normalizedMax = computed(() => { + const max = Number.isFinite(props.max) ? props.max : Infinity + return Math.max(normalizedMin.value, max) + }) + const normalizedStep = computed(() => (Number.isFinite(props.step) && props.step > 0 ? props.step : 1)) + + const formatValue = (value: number | string | undefined | null): InputNumberValue => { + if (value === '' || value === undefined || value === null) { + return props.allowEmpty ? undefined : 0 + } + + let numValue = typeof value === 'string' ? Number.parseFloat(value) : value + if (!Number.isFinite(numValue)) return props.allowEmpty ? undefined : 0 + + if (normalizedPrecision.value !== undefined) { + numValue = Number.parseFloat(numValue.toFixed(normalizedPrecision.value)) + } + + return Math.max(normalizedMin.value, Math.min(normalizedMax.value, numValue)) + } + + // 内部值状态;committedValue 用于让原生 change 保留本次编辑前的旧值。 + const innerValue = ref(formatValue(props.modelValue)) + const committedValue = ref(innerValue.value) const focused = ref(false) + const composing = ref(false) + let composedInputValue: string | undefined // 计算显示值 const displayValue = computed(() => { @@ -27,8 +70,8 @@ export default defineComponent({ return props.allowEmpty ? '' : '0' } - if (props.precision !== undefined) { - return Number(innerValue.value).toFixed(props.precision) + if (normalizedPrecision.value !== undefined) { + return Number(innerValue.value).toFixed(normalizedPrecision.value) } return String(innerValue.value) @@ -37,41 +80,28 @@ export default defineComponent({ // 计算是否禁用增加按钮 const maxDisabled = computed(() => { if (innerValue.value === undefined || innerValue.value === null) return false - return innerValue.value >= props.max + return innerValue.value >= normalizedMax.value }) // 计算是否禁用减少按钮 const minDisabled = computed(() => { if (innerValue.value === undefined || innerValue.value === null) return false - return innerValue.value <= props.min + return innerValue.value <= normalizedMin.value }) - // 数值处理函数 - 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 updateValue = (newValue: InputNumberValue, triggerChange = true, forceEmit = false) => { + newValue = formatValue(newValue) const oldValue = innerValue.value + if (oldValue === newValue) { + if (forceEmit) { + emit('update:modelValue', newValue) + emit('input', newValue) + formItem?.validate('change') + } + if (triggerChange) committedValue.value = newValue + return + } innerValue.value = newValue emit('update:modelValue', newValue) @@ -79,6 +109,7 @@ export default defineComponent({ if (triggerChange && oldValue !== newValue) { emit('change', newValue, oldValue) + committedValue.value = newValue } formItem?.validate('change') @@ -89,9 +120,22 @@ export default defineComponent({ const target = event.target as HTMLInputElement const value = target.value + if (composing.value) return + if (composedInputValue !== undefined) { + const shouldSkip = value === composedInputValue + composedInputValue = undefined + if (shouldSkip) return + } + // 正则限制 if (props.reg) { - const regex = typeof props.reg === 'string' ? new RegExp(props.reg) : props.reg + let regex: RegExp + try { + regex = typeof props.reg === 'string' ? new RegExp(props.reg) : props.reg + } catch { + regex = /(?:)/ + } + regex.lastIndex = 0 if (!regex.test(value)) { target.value = displayValue.value return @@ -101,7 +145,7 @@ export default defineComponent({ // 空值处理 if (value === '') { if (props.allowEmpty) { - updateValue(undefined, false) + updateValue(undefined, false, true) } else { target.value = displayValue.value } @@ -109,14 +153,32 @@ export default defineComponent({ } const numValue = formatValue(value) - updateValue(numValue, false) + updateValue(numValue, false, true) + } + + const handleCompositionStart = () => { + composing.value = true + composedInputValue = undefined + } + + const handleCompositionEnd = (event: CompositionEvent) => { + if (!composing.value) return + composing.value = false + handleInput(event) + // 浏览器通常在 compositionend 后再派发一次相同最终值的 input;吞掉该尾随事件。 + composedInputValue = (event.target as HTMLInputElement).value } // 输入变化处理 const handleInputChange = (event: Event) => { + composedInputValue = undefined const target = event.target as HTMLInputElement const numValue = formatValue(target.value) - updateValue(numValue) + if (numValue !== innerValue.value) updateValue(numValue, false) + if (numValue !== committedValue.value) { + emit('change', numValue, committedValue.value) + committedValue.value = numValue + } // 更新显示值 void nextTick(() => { @@ -133,6 +195,7 @@ export default defineComponent({ } const handleBlur = (event: FocusEvent) => { + handleInputChange(event) focused.value = false emit('blur', event) @@ -146,19 +209,19 @@ export default defineComponent({ // 增加值 const increase = () => { - if (props.disabled || maxDisabled.value) return + if (props.disabled || props.readonly || maxDisabled.value) return const currentValue = innerValue.value ?? 0 - const newValue = formatValue(currentValue + props.step) + const newValue = formatValue(addStep(currentValue, normalizedStep.value, 1)) updateValue(newValue) } // 减少值 const decrease = () => { - if (props.disabled || minDisabled.value) return + if (props.disabled || props.readonly || minDisabled.value) return const currentValue = innerValue.value ?? 0 - const newValue = formatValue(currentValue - props.step) + const newValue = formatValue(addStep(currentValue, normalizedStep.value, -1)) updateValue(newValue) } @@ -175,6 +238,9 @@ export default defineComponent({ event.preventDefault() decrease() break + case 'Enter': + handleInputChange(event) + break } } @@ -203,15 +269,70 @@ export default defineComponent({ watch( () => props.modelValue, (newValue) => { - if (newValue !== innerValue.value) { - innerValue.value = newValue + const normalizedValue = formatValue(newValue) + if (normalizedValue !== innerValue.value) { + innerValue.value = normalizedValue + committedValue.value = normalizedValue } }, { immediate: true }, ) + watch( + () => [props.min, props.max, props.precision, props.allowEmpty] as const, + () => { + const normalizedValue = formatValue(innerValue.value) + if (normalizedValue !== innerValue.value) { + updateValue(normalizedValue) + } + }, + ) + + watch( + () => props.disabled, + (disabled) => { + if (disabled && focused.value) inputRef.value?.blur() + }, + ) + return () => { const controlsAtRight = props.controlsPosition === 'right' + const { class: rootClass, style: rootStyle, 'aria-describedby': describedBy, ...nativeAttrs } = attrs + const descriptionIds = [describedBy, formItem?.messageId?.value] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .flatMap((value) => value.split(/\s+/)) + const ariaDescribedBy = [...new Set(descriptionIds)].join(' ') || undefined + const min = Number.isFinite(normalizedMin.value) ? normalizedMin.value : undefined + const max = Number.isFinite(normalizedMax.value) ? normalizedMax.value : undefined + const controlDisabled = props.disabled || props.readonly + const inputAttrs = mergeProps(nativeAttrs, { + ref: inputRef, + type: 'number', + step: normalizedStep.value, + class: ns.e('inner'), + value: displayValue.value, + placeholder: props.placeholder, + disabled: props.disabled, + readonly: props.readonly, + min, + max, + role: 'spinbutton', + 'aria-valuenow': innerValue.value, + 'aria-valuetext': innerValue.value === undefined ? undefined : displayValue.value, + 'aria-valuemin': min, + 'aria-valuemax': max, + 'aria-describedby': ariaDescribedBy, + 'aria-disabled': props.disabled ? true : undefined, + 'aria-readonly': props.readonly ? true : undefined, + 'aria-invalid': mergedStatus.value === 'error' ? true : undefined, + onInput: handleInput, + onCompositionstart: handleCompositionStart, + onCompositionend: handleCompositionEnd, + onChange: handleInputChange, + onFocus: handleFocus, + onBlur: handleBlur, + onKeydown: handleKeydown, + }) return (
{/* 左侧控制按钮 */} {props.controls && !controlsAtRight && ( { if (e.key === 'Enter' || e.key === ' ') { @@ -256,30 +378,7 @@ export default defineComponent({ {/* 输入框 */}
- +
{/* 左侧增加按钮 */} @@ -287,9 +386,9 @@ export default defineComponent({ { if (e.key === 'Enter' || e.key === ' ') { @@ -310,9 +409,9 @@ export default defineComponent({ { if (e.key === 'Enter' || e.key === ' ') { @@ -328,9 +427,9 @@ export default defineComponent({ { if (e.key === 'Enter' || e.key === ' ') { diff --git a/packages/ccui/ui/input-number/test/input-number.test.ts b/packages/ccui/ui/input-number/test/input-number.test.ts index a5a693ac..d8e1c613 100644 --- a/packages/ccui/ui/input-number/test/input-number.test.ts +++ b/packages/ccui/ui/input-number/test/input-number.test.ts @@ -367,4 +367,195 @@ describe('inputNumber', () => { expect(inp.attributes('aria-invalid')).toBe('true') }) }) + + describe('deep review regressions', () => { + it('emits one committed change with the value from before typing', async () => { + const wrapper = createWrapper({ modelValue: 2 }) + const input = wrapper.find('.ccui-input-number__inner') + + ;(input.element as HTMLInputElement).value = '12' + await input.trigger('input') + await input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toEqual([[12]]) + expect(wrapper.emitted('input')).toEqual([[12]]) + expect(wrapper.emitted('change')).toEqual([[12, 2]]) + }) + + it('commits typed input with Enter without emitting a duplicate native change', async () => { + const wrapper = createWrapper({ modelValue: 2 }) + const input = wrapper.find('.ccui-input-number__inner') + + ;(input.element as HTMLInputElement).value = '3' + await input.trigger('input') + await input.trigger('keydown', { key: 'Enter' }) + await input.trigger('change') + + expect(wrapper.emitted('change')).toEqual([[3, 2]]) + }) + + it('forwards native form and accessibility attributes to the input', () => { + const wrapper = mount(InputNumber, { + attrs: { + id: 'quantity', + name: 'quantity', + 'aria-label': 'Quantity', + autocomplete: 'off', + class: 'outer-class', + }, + }) + const input = wrapper.find('.ccui-input-number__inner') + + expect(input.attributes('id')).toBe('quantity') + expect(input.attributes('name')).toBe('quantity') + expect(input.attributes('aria-label')).toBe('Quantity') + expect(input.attributes('autocomplete')).toBe('off') + expect(wrapper.classes()).toContain('outer-class') + expect(wrapper.attributes('name')).toBeUndefined() + }) + + it('merges external and FormItem aria-describedby ids', () => { + const wrapper = mount(InputNumber, { + attrs: { 'aria-describedby': 'hint shared' }, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref(''), + messageId: ref('error shared'), + isInsideForm: true, + validate: vi.fn(async () => true), + }, + }, + }, + }) + + expect(wrapper.find('input').attributes('aria-describedby')).toBe('hint shared error') + }) + + it('does not mutate readonly values through controls or exposed methods', async () => { + const wrapper = createWrapper({ modelValue: 5, readonly: true }) + const vm = wrapper.vm as any + + await wrapper.find('.ccui-input-number__increase').trigger('click') + vm.increase() + vm.decrease() + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('.ccui-input-number__increase').attributes('aria-disabled')).toBe('true') + expect(wrapper.find('.ccui-input-number__increase').attributes('tabindex')).toBe('-1') + }) + + it('clears focused state when disabled dynamically', async () => { + const wrapper = mount(InputNumber, { attachTo: document.body }) + const input = wrapper.find('input') + ;(input.element as HTMLInputElement).focus() + await nextTick() + expect(wrapper.classes()).toContain('ccui-input-number--focused') + + await wrapper.setProps({ disabled: true }) + expect(wrapper.classes()).not.toContain('ccui-input-number--focused') + expect(wrapper.emitted('blur')).toHaveLength(1) + wrapper.unmount() + }) + + it('keeps floating point steps stable without requiring precision', async () => { + const wrapper = createWrapper({ modelValue: 0.2, step: 0.1 }) + + await wrapper.find('.ccui-input-number__increase').trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([[0.3]]) + expect(wrapper.emitted('change')).toEqual([[0.3, 0.2]]) + }) + + it('normalizes invalid step and precision values without crashing', async () => { + const wrapper = createWrapper({ modelValue: 1.25, step: 0, precision: -3 }) + const input = wrapper.find('input') + + expect((input.element as HTMLInputElement).value).toBe('1') + expect(input.attributes('step')).toBe('1') + await wrapper.find('.ccui-input-number__increase').trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[2]]) + }) + + it('does not expose non-finite defaults as invalid native or ARIA values', () => { + const wrapper = createWrapper({ modelValue: Number.NaN }) + const input = wrapper.find('input') + + expect((input.element as HTMLInputElement).value).toBe('0') + expect(input.attributes('min')).toBeUndefined() + expect(input.attributes('max')).toBeUndefined() + expect(input.attributes('aria-valuemin')).toBeUndefined() + expect(input.attributes('aria-valuemax')).toBeUndefined() + expect(input.attributes('aria-valuenow')).toBe('0') + }) + + it('renormalizes and emits when dynamic bounds invalidate the current value', async () => { + const wrapper = createWrapper({ modelValue: 5, min: 0, max: 10 }) + + await wrapper.setProps({ min: 7 }) + + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('7') + expect(wrapper.emitted('update:modelValue')).toEqual([[7]]) + expect(wrapper.emitted('change')).toEqual([[7, 5]]) + }) + + it('reuses global regular expressions without leaking lastIndex', async () => { + const wrapper = createWrapper({ modelValue: 1, reg: /^\d+$/g }) + const input = wrapper.find('input') + + ;(input.element as HTMLInputElement).value = '2' + await input.trigger('input') + ;(input.element as HTMLInputElement).value = '3' + await input.trigger('input') + + expect(wrapper.emitted('update:modelValue')).toEqual([[2], [3]]) + }) + + it('commits a complete IME sequence exactly once', async () => { + const onValidate = vi.fn(async () => true) + const wrapper = mount(InputNumber, { + props: { modelValue: 1 }, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref(''), + isInsideForm: true, + validate: onValidate, + }, + }, + }, + }) + const input = wrapper.find('input') + + await input.trigger('compositionstart') + ;(input.element as HTMLInputElement).value = '2' + await input.trigger('input') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + + await input.trigger('compositionend') + // 真实浏览器会在 compositionend 后派发一次相同最终值的 input。 + await input.trigger('input') + await input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toEqual([[2]]) + expect(wrapper.emitted('input')).toEqual([[2]]) + expect(wrapper.emitted('change')).toEqual([[2, 1]]) + expect(onValidate).toHaveBeenCalledTimes(1) + expect(onValidate).toHaveBeenCalledWith('change') + }) + + it('does not crash when a dynamic regexp string is invalid', async () => { + const wrapper = createWrapper({ modelValue: 1, reg: '[' }) + const input = wrapper.find('input') + + ;(input.element as HTMLInputElement).value = '2' + await expect(input.trigger('input')).resolves.toBeUndefined() + expect(wrapper.emitted('update:modelValue')).toEqual([[2]]) + }) + + it('uses the formatted display as aria-valuetext', () => { + const wrapper = createWrapper({ modelValue: 1.2, precision: 2 }) + expect(wrapper.find('input').attributes('aria-valuetext')).toBe('1.20') + }) + }) }) diff --git a/packages/ccui/ui/input-otp/index.ts b/packages/ccui/ui/input-otp/index.ts index 1ea8c8c3..6d664f3c 100644 --- a/packages/ccui/ui/input-otp/index.ts +++ b/packages/ccui/ui/input-otp/index.ts @@ -7,7 +7,13 @@ InputOtp.install = function (app: App): void { export { InputOtp } -export type { InputOtpFormatter, InputOtpProps, InputOtpSize, InputOtpStatus } from './src/input-otp-types' +export type { + InputOtpFormatter, + InputOtpProps, + InputOtpSize, + InputOtpStatus, + InputOtpType, +} from './src/input-otp-types' export default { title: 'InputOtp 一次性密码', diff --git a/packages/ccui/ui/input-otp/src/input-otp-types.ts b/packages/ccui/ui/input-otp/src/input-otp-types.ts index ccea12b5..b2cbe4e1 100644 --- a/packages/ccui/ui/input-otp/src/input-otp-types.ts +++ b/packages/ccui/ui/input-otp/src/input-otp-types.ts @@ -2,6 +2,7 @@ import type { ExtractPropTypes, PropType } from 'vue' export type InputOtpSize = 'large' | 'default' | 'small' export type InputOtpStatus = '' | 'error' | 'warning' +export type InputOtpType = 'number' | 'text' export type InputOtpFormatter = (value: string) => string @@ -24,12 +25,20 @@ export const inputOtpProps = { default: undefined, }, /** - * OTP 单元格数量。默认 6。 + * OTP 单元格数量。取整并限制在 1–64,默认 6。 */ length: { type: Number, default: 6, }, + /** + * 移动端软键盘提示。`number` 使用数字键盘,`text` 使用文本键盘。 + * 字符过滤仍由 formatter 控制。 + */ + type: { + type: String as PropType, + default: 'number', + }, /** * 自动获得首个 cell 焦点。 */ @@ -44,6 +53,13 @@ export const inputOtpProps = { type: Boolean, default: false, }, + /** + * 只读。仍可聚焦和选择内容,但不会接受输入、粘贴或删除操作。 + */ + readOnly: { + type: Boolean, + default: false, + }, /** * 字符遮罩:`true` 用 `•`,`string` 用任意单字符(仅取首字符)。 * 注意:mask 只影响显示,emit 仍是真实字符。 diff --git a/packages/ccui/ui/input-otp/src/input-otp.scss b/packages/ccui/ui/input-otp/src/input-otp.scss index 1556f398..3628d4e0 100644 --- a/packages/ccui/ui/input-otp/src/input-otp.scss +++ b/packages/ccui/ui/input-otp/src/input-otp.scss @@ -52,6 +52,10 @@ font-size: $ccui-font-size-sm; } + &--readonly &__cell { + cursor: default; + } + &--status-error &__cell { border-color: $ccui-color-error; diff --git a/packages/ccui/ui/input-otp/src/input-otp.tsx b/packages/ccui/ui/input-otp/src/input-otp.tsx index a5d69b45..062c71f6 100644 --- a/packages/ccui/ui/input-otp/src/input-otp.tsx +++ b/packages/ccui/ui/input-otp/src/input-otp.tsx @@ -1,5 +1,19 @@ import type { InputOtpProps } from './input-otp-types' -import { computed, defineComponent, h, nextTick, onMounted, ref, watch } from 'vue' +import type { FormItemInjectedContext } from '../../form/src/form-types' +import { + computed, + defineComponent, + getCurrentInstance, + h, + inject, + nextTick, + onBeforeUnmount, + onBeforeUpdate, + onMounted, + ref, + watch, +} from 'vue' +import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' import { inputOtpProps } from './input-otp-types' import './input-otp.scss' @@ -16,18 +30,48 @@ function normalizeMask(mask: boolean | string): string | null { return null } +const MAX_OTP_LENGTH = 64 + export default defineComponent({ name: 'CInputOtp', + inheritAttrs: false, props: inputOtpProps, - emits: ['update:modelValue', 'change', 'focus', 'blur'], - setup(props: InputOtpProps, { emit }) { + emits: ['update:modelValue', 'change', 'complete', 'focus', 'blur'], + setup(props: InputOtpProps, { attrs, emit }) { const ns = useNamespace('input-otp') + const groupRef = ref(null) const cellRefs = ref<(HTMLInputElement | null)[]>([]) + const formItem = inject(formItemInjectionKey, null) + const validationStatus = computed(() => formItem?.validateStatus.value ?? '') + const mergedStatus = computed(() => props.status || validationStatus.value) + const groupLabel = computed(() => + typeof attrs['aria-label'] === 'string' && attrs['aria-label'] ? attrs['aria-label'] : 'OTP input', + ) + const effectiveLength = computed(() => { + if (!Number.isFinite(props.length)) return 1 + return Math.min(MAX_OTP_LENGTH, Math.max(1, Math.floor(props.length))) + }) + const describedBy = computed(() => { + const attrIds = typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'].split(/\s+/) : [] + return ( + [...new Set([...attrIds, formItem?.messageId?.value].filter((id): id is string => !!id))].join(' ') || undefined + ) + }) + const instance = getCurrentInstance() + const hasModelValue = () => { + const vnodeProps = instance?.vnode.props + return !!vnodeProps && ('modelValue' in vnodeProps || 'model-value' in vnodeProps) + } + let modelValueWasProvided = hasModelValue() + let isUnmounted = false + let groupFocused = false + let lastCompletedValue = '' + const composingCells = new Set() const stringToCells = (str: string): string[] => { - const cells: string[] = Array.from({ length: props.length }, () => '') + const cells: string[] = Array.from({ length: effectiveLength.value }, () => '') const chars = Array.from(str ?? '') - for (let i = 0; i < props.length; i++) { + for (let i = 0; i < effectiveLength.value; i++) { cells[i] = chars[i] ?? '' } return cells @@ -35,7 +79,7 @@ export default defineComponent({ const cellsToString = (cells: string[]): string => cells.join('') - const initial = props.modelValue !== '' ? props.modelValue : (props.defaultValue ?? '') + const initial = modelValueWasProvided ? props.modelValue : (props.defaultValue ?? '') const cells = ref(stringToCells(initial)) const setCellRef = (idx: number) => (el: unknown) => { @@ -60,21 +104,38 @@ export default defineComponent({ const value = cellsToString(cells.value) emit('update:modelValue', value) emit('change', value, { index: changedIndex }) + void formItem?.validate('change') + if (cells.value.length === effectiveLength.value && cells.value.every(Boolean)) { + if (value !== lastCompletedValue) { + lastCompletedValue = value + emit('complete', value) + } + } else { + lastCompletedValue = '' + } } const handleInput = (idx: number, e: Event) => { const target = e.target as HTMLInputElement + if (props.disabled || props.readOnly) { + target.value = cells.value[idx] && maskChar.value ? maskChar.value : (cells.value[idx] ?? '') + return + } + if (composingCells.has(idx) || (e as InputEvent).isComposing) return const raw = target.value + if (maskChar.value && cells.value[idx] && raw === maskChar.value) return + const previous = cellsToString(cells.value) // 用户可能一次输入多个字符(IME / 粘贴 / 安卓键盘)。逐格填入并往后跳。 const chars = Array.from(raw) if (chars.length === 0) { + if (!cells.value[idx]) return cells.value[idx] = '' commit(idx) return } let writeIdx = idx for (const ch of chars) { - if (writeIdx >= props.length) break + if (writeIdx >= effectiveLength.value) break const formatted = formatChar(ch) if (!formatted) continue cells.value[writeIdx] = formatted @@ -82,16 +143,19 @@ export default defineComponent({ } // 回写 input 元素,避免显示多字符 target.value = cells.value[idx] ?? '' + if (cellsToString(cells.value) === previous) return commit(idx) // 焦点:跳到下一个未填的 / 最后一个 - const nextIdx = Math.min(writeIdx, props.length - 1) + const nextIdx = Math.min(writeIdx, effectiveLength.value - 1) if (nextIdx !== idx) { void nextTick(() => focusCell(nextIdx)) } } const handleKeydown = (idx: number, e: KeyboardEvent) => { + if (props.disabled) return if (e.key === 'Backspace') { + if (props.readOnly) return if (cells.value[idx]) { e.preventDefault() cells.value[idx] = '' @@ -105,37 +169,61 @@ export default defineComponent({ } return } + if (e.key === 'Delete') { + if (props.readOnly) return + if (cells.value[idx]) { + e.preventDefault() + cells.value[idx] = '' + commit(idx) + } + return + } if (e.key === 'ArrowLeft' && idx > 0) { e.preventDefault() focusCell(idx - 1) return } - if (e.key === 'ArrowRight' && idx < props.length - 1) { + if (e.key === 'ArrowRight' && idx < effectiveLength.value - 1) { e.preventDefault() focusCell(idx + 1) } } const handlePaste = (idx: number, e: ClipboardEvent) => { + if (props.disabled || props.readOnly) return const text = e.clipboardData?.getData('text') ?? '' if (!text) return e.preventDefault() + const previous = cellsToString(cells.value) const chars = Array.from(text) let writeIdx = idx for (const ch of chars) { - if (writeIdx >= props.length) break + if (writeIdx >= effectiveLength.value) break const formatted = formatChar(ch) if (!formatted) continue cells.value[writeIdx] = formatted writeIdx++ } + if (cellsToString(cells.value) === previous) return commit(idx) - const nextIdx = Math.min(writeIdx, props.length - 1) + const nextIdx = Math.min(writeIdx, effectiveLength.value - 1) void nextTick(() => focusCell(nextIdx)) } - const handleFocus = (e: FocusEvent) => emit('focus', e) - const handleBlur = (e: FocusEvent) => emit('blur', e) + const handleFocus = (e: FocusEvent) => { + if (groupFocused) return + groupFocused = true + emit('focus', e) + } + const handleBlur = (e: FocusEvent) => { + void nextTick(() => { + if (isUnmounted || groupRef.value?.contains(document.activeElement)) return + if (!groupFocused) return + groupFocused = false + emit('blur', e) + void formItem?.validate('blur') + }) + } watch( () => props.modelValue, @@ -143,39 +231,76 @@ export default defineComponent({ const expected = cellsToString(cells.value) if (newVal !== expected) { cells.value = stringToCells(newVal) + lastCompletedValue = cells.value.every(Boolean) ? cellsToString(cells.value) : '' } }, ) watch( - () => props.length, - () => { - cells.value = stringToCells(cellsToString(cells.value)) + () => effectiveLength.value, + (newLength, oldLength) => { + const previous = cellsToString(cells.value) + cells.value = stringToCells(previous) + lastCompletedValue = cells.value.every(Boolean) ? cellsToString(cells.value) : '' + cellRefs.value.length = newLength + if (newLength < oldLength) { + const normalized = cellsToString(cells.value) + if (normalized !== previous) emit('update:modelValue', normalized) + } }, ) + onBeforeUpdate(() => { + const modelValueIsProvided = hasModelValue() + if (modelValueIsProvided && !modelValueWasProvided) { + cells.value = stringToCells(props.modelValue) + } + modelValueWasProvided = modelValueIsProvided + }) + onMounted(() => { if (props.autoFocus) focusCell(0) }) + watch( + () => props.disabled, + (disabled) => { + if (!disabled || typeof document === 'undefined') return + const activeElement = document.activeElement + if (activeElement instanceof HTMLInputElement && groupRef.value?.contains(activeElement)) { + activeElement.blur() + } + }, + ) + + onBeforeUnmount(() => { + isUnmounted = true + composingCells.clear() + }) + const maskChar = computed(() => normalizeMask(props.mask)) const wrapperCls = computed(() => ({ [ns.b()]: true, [ns.m(props.size)]: !!props.size, [ns.m('disabled')]: props.disabled, - [ns.m(`status-${props.status}`)]: !!props.status, + [ns.m('readonly')]: props.readOnly, + [ns.m(`status-${mergedStatus.value}`)]: !!mergedStatus.value, })) return () => h( 'div', { - class: wrapperCls.value, + ...attrs, + ref: groupRef, + class: [attrs.class, wrapperCls.value], role: 'group', - 'aria-label': 'OTP input', + 'aria-label': groupLabel.value, 'aria-disabled': props.disabled ? true : undefined, - 'aria-invalid': props.status === 'error' ? true : undefined, + 'aria-readonly': props.readOnly ? true : undefined, + 'aria-invalid': mergedStatus.value === 'error' ? true : undefined, + 'aria-describedby': describedBy.value, }, cells.value.map((cellValue, idx) => { const displayValue = cellValue && maskChar.value ? maskChar.value : cellValue @@ -184,15 +309,23 @@ export default defineComponent({ ref: setCellRef(idx), class: ns.e('cell'), type: 'text', - inputmode: 'numeric', + inputmode: props.type === 'number' ? 'numeric' : 'text', maxlength: 1, autocomplete: idx === 0 ? 'one-time-code' : 'off', value: displayValue, disabled: props.disabled, - 'aria-label': `OTP cell ${idx + 1}`, + readonly: props.readOnly, + 'aria-label': `${groupLabel.value}, cell ${idx + 1} of ${effectiveLength.value}`, 'aria-disabled': props.disabled ? true : undefined, - 'aria-invalid': props.status === 'error' ? true : undefined, + 'aria-readonly': props.readOnly ? true : undefined, + 'aria-invalid': mergedStatus.value === 'error' ? true : undefined, + 'aria-describedby': describedBy.value, onInput: (e: Event) => handleInput(idx, e), + onCompositionstart: () => composingCells.add(idx), + onCompositionend: (e: CompositionEvent) => { + composingCells.delete(idx) + handleInput(idx, e) + }, onKeydown: (e: KeyboardEvent) => handleKeydown(idx, e), onPaste: (e: ClipboardEvent) => handlePaste(idx, e), onFocus: handleFocus, diff --git a/packages/ccui/ui/input-otp/test/input-otp.test.ts b/packages/ccui/ui/input-otp/test/input-otp.test.ts index 6ab13312..57f73a4d 100644 --- a/packages/ccui/ui/input-otp/test/input-otp.test.ts +++ b/packages/ccui/ui/input-otp/test/input-otp.test.ts @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vite-plus/test' -import { nextTick } from 'vue' +import { describe, expect, it, vi } from 'vite-plus/test' +import { defineComponent, h, nextTick, ref } from 'vue' +import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' import { InputOtp } from '../index' @@ -19,6 +20,27 @@ describe('input-otp', () => { expect(wrapper.findAll(ns.e('cell')).length).toBe(4) }) + it.each([ + [0, 1], + [-10, 1], + [2.9, 2], + [Number.NaN, 1], + [Number.POSITIVE_INFINITY, 1], + [Number.MAX_VALUE, 64], + ])('length=%s 安全归一为 %s 个 cell', (length, expected) => { + const wrapper = mount(InputOtp, { props: { length } }) + expect(wrapper.findAll(ns.e('cell'))).toHaveLength(expected) + }) + + it('动态超大 length 被限制为 64,随后缩短仍同步归一值', async () => { + const wrapper = mount(InputOtp, { props: { modelValue: '1234', length: 4 } }) + await wrapper.setProps({ length: Number.MAX_VALUE }) + expect(wrapper.findAll(ns.e('cell'))).toHaveLength(64) + await wrapper.setProps({ length: 2.9 }) + expect(wrapper.findAll(ns.e('cell'))).toHaveLength(2) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['12']) + }) + it('disabled 时所有 cell disabled', () => { const wrapper = mount(InputOtp, { props: { disabled: true } }) const cells = wrapper.findAll(ns.e('cell')) @@ -38,6 +60,13 @@ describe('input-otp', () => { expect(first.attributes('inputmode')).toBe('numeric') expect(first.attributes('maxlength')).toBe('1') }) + + it('动态 type 切换移动端 inputmode,但不负责字符过滤', async () => { + const wrapper = mount(InputOtp, { props: { type: 'number' } }) + expect(wrapper.findAll(ns.e('cell'))[0].attributes('inputmode')).toBe('numeric') + await wrapper.setProps({ type: 'text' }) + expect(wrapper.findAll(ns.e('cell'))[0].attributes('inputmode')).toBe('text') + }) }) describe('v-model + defaultValue', () => { @@ -69,6 +98,41 @@ describe('input-otp', () => { const cells = wrapper.findAll(ns.e('cell')) expect((cells[0].element as HTMLInputElement).value).toBe('2') }) + + it('显式空 modelValue 不会错误采用 defaultValue', () => { + const wrapper = mount(InputOtp, { + props: { modelValue: '', defaultValue: '1234', length: 4 }, + }) + expect(wrapper.findAll(ns.e('cell')).map((cell) => (cell.element as HTMLInputElement).value)).toEqual([ + '', + '', + '', + '', + ]) + }) + + it('从非受控动态切换为显式空 modelValue 时清空 defaultValue', async () => { + const wrapper = mount(InputOtp, { props: { defaultValue: '1234', length: 4 } }) + await wrapper.setProps({ modelValue: '' }) + expect(wrapper.findAll(ns.e('cell')).map((cell) => (cell.element as HTMLInputElement).value)).toEqual([ + '', + '', + '', + '', + ]) + }) + + it('截断超长外部值,并在 length 缩短时同步归一值', async () => { + const wrapper = mount(InputOtp, { props: { modelValue: '123456', length: 6 } }) + await wrapper.setProps({ length: 4 }) + expect(wrapper.findAll(ns.e('cell')).map((cell) => (cell.element as HTMLInputElement).value)).toEqual([ + '1', + '2', + '3', + '4', + ]) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234']) + }) }) describe('输入 + 焦点流转', () => { @@ -95,6 +159,32 @@ describe('input-otp', () => { wrapper.unmount() }) + it('在真实 v-model 父组件中保留输入值并推进焦点', async () => { + const Host = defineComponent({ + setup() { + const value = ref('') + return () => + h(InputOtp, { + modelValue: value.value, + length: 4, + 'onUpdate:modelValue': (next: string) => { + value.value = next + }, + }) + }, + }) + const wrapper = mount(Host, { attachTo: document.body }) + const cells = wrapper.findAll(ns.e('cell')) + ;(cells[0].element as HTMLInputElement).focus() + ;(cells[0].element as HTMLInputElement).value = '1' + await cells[0].trigger('input') + await nextTick() + + expect((cells[0].element as HTMLInputElement).value).toBe('1') + expect(document.activeElement).toBe(cells[1].element) + wrapper.unmount() + }) + it('一次输入多字符(IME / 安卓)逐格填入', async () => { const wrapper = mount(InputOtp, { props: { length: 4 } }) const cells = wrapper.findAll(ns.e('cell')) @@ -104,6 +194,36 @@ describe('input-otp', () => { const emitted = wrapper.emitted('update:modelValue') expect(emitted?.[emitted.length - 1]).toEqual(['123']) }) + + it('IME 组合期间不提交中间态,并吞掉 compositionend 后同任务尾随 input', async () => { + const validate = vi.fn(() => Promise.resolve(true)) + const wrapper = mount(InputOtp, { + props: { length: 1 }, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref(''), + isInsideForm: true, + validate, + }, + }, + }, + }) + const first = wrapper.findAll(ns.e('cell'))[0] + first.element.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })) + ;(first.element as HTMLInputElement).value = 'h' + first.element.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'h', isComposing: true })) + expect(wrapper.emitted('change')).toBeUndefined() + ;(first.element as HTMLInputElement).value = '汉' + first.element.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '汉' })) + first.element.dispatchEvent(new InputEvent('input', { bubbles: true, data: '汉' })) + await nextTick() + expect(wrapper.emitted('update:modelValue')).toEqual([['汉']]) + expect(wrapper.emitted('change')).toEqual([['汉', { index: 0 }]]) + expect(wrapper.emitted('complete')).toEqual([['汉']]) + expect(validate).toHaveBeenCalledTimes(1) + expect(validate).toHaveBeenCalledWith('change') + }) }) describe('Backspace', () => { @@ -130,6 +250,19 @@ describe('input-otp', () => { expect(emitted?.[emitted.length - 1]).toEqual(['']) wrapper.unmount() }) + + it('Delete 清除当前格且不移动焦点', async () => { + const wrapper = mount(InputOtp, { + props: { modelValue: '12', length: 4 }, + attachTo: document.body, + }) + const second = wrapper.findAll(ns.e('cell'))[1] + ;(second.element as HTMLInputElement).focus() + await second.trigger('keydown', { key: 'Delete' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1']) + expect(document.activeElement).toBe(second.element) + wrapper.unmount() + }) }) describe('ArrowLeft / ArrowRight', () => { @@ -170,6 +303,19 @@ describe('input-otp', () => { const emitted = wrapper.emitted('update:modelValue') expect(emitted?.[emitted.length - 1]).toEqual(['ABXY']) }) + + it('填满时 complete 对同一完成值只触发一次,变为未完成后可再次触发', async () => { + const wrapper = mount(InputOtp, { props: { length: 4 } }) + const first = wrapper.findAll(ns.e('cell'))[0] + const dt = { getData: (_t: string) => '1234' } + await first.trigger('paste', { clipboardData: dt }) + await first.trigger('paste', { clipboardData: dt }) + expect(wrapper.emitted('complete')).toEqual([['1234']]) + await wrapper.findAll(ns.e('cell'))[3].trigger('keydown', { key: 'Backspace' }) + ;(wrapper.findAll(ns.e('cell'))[3].element as HTMLInputElement).value = '4' + await wrapper.findAll(ns.e('cell'))[3].trigger('input') + expect(wrapper.emitted('complete')).toEqual([['1234'], ['1234']]) + }) }) describe('formatter', () => { @@ -181,6 +327,16 @@ describe('input-otp', () => { const emitted = wrapper.emitted('update:modelValue') expect(emitted?.[0]).toEqual(['A']) }) + + it('formatter 拒绝字符时不发出无变化事件', async () => { + const wrapper = mount(InputOtp, { props: { length: 4, formatter: () => '' } }) + const first = wrapper.findAll(ns.e('cell'))[0] + ;(first.element as HTMLInputElement).value = 'x' + await first.trigger('input') + await first.trigger('paste', { clipboardData: { getData: () => 'abcd' } }) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.emitted('change')).toBeUndefined() + }) }) describe('mask', () => { @@ -197,6 +353,15 @@ describe('input-otp', () => { const first = wrapper.findAll(ns.e('cell'))[0] expect((first.element as HTMLInputElement).value).toBe('#') }) + + it('mask 显示值产生的尾随 input 不会覆盖真实字符', async () => { + const wrapper = mount(InputOtp, { props: { length: 4, modelValue: '1', mask: true } }) + const first = wrapper.findAll(ns.e('cell'))[0] + ;(first.element as HTMLInputElement).value = '•' + await first.trigger('input') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect((first.element as HTMLInputElement).value).toBe('•') + }) }) describe('status', () => { @@ -220,6 +385,90 @@ describe('input-otp', () => { expect(wrapper.emitted('focus')).toBeTruthy() expect(wrapper.emitted('blur')).toBeTruthy() }) + + it('内部 cell 间移动只产生一次 group focus,离开 group 才产生 blur', async () => { + const outside = document.createElement('button') + document.body.append(outside) + const wrapper = mount(InputOtp, { props: { length: 4 }, attachTo: document.body }) + const cells = wrapper.findAll(ns.e('cell')) + ;(cells[0].element as HTMLInputElement).focus() + ;(cells[1].element as HTMLInputElement).focus() + await nextTick() + expect(wrapper.emitted('focus')).toHaveLength(1) + expect(wrapper.emitted('blur')).toBeUndefined() + outside.focus() + await nextTick() + expect(wrapper.emitted('blur')).toHaveLength(1) + wrapper.unmount() + outside.remove() + }) + + it('readonly 保持可聚焦,但输入、粘贴和删除均不修改值', async () => { + const wrapper = mount(InputOtp, { props: { modelValue: '12', length: 4, readOnly: true } }) + const first = wrapper.findAll(ns.e('cell'))[0] + expect(first.attributes('readonly')).toBeDefined() + ;(first.element as HTMLInputElement).value = '9' + await first.trigger('input') + await first.trigger('keydown', { key: 'Backspace' }) + await first.trigger('paste', { clipboardData: { getData: () => '9876' } }) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect((first.element as HTMLInputElement).value).toBe('1') + expect(wrapper.attributes('aria-readonly')).toBe('true') + }) + + it('聚焦期间动态 disabled 会退出组件并只触发一次聚合 blur', async () => { + const wrapper = mount(InputOtp, { props: { length: 4 }, attachTo: document.body }) + ;(wrapper.findAll(ns.e('cell'))[0].element as HTMLInputElement).focus() + await wrapper.setProps({ disabled: true }) + await nextTick() + expect(wrapper.emitted('blur')).toHaveLength(1) + expect(wrapper.findAll(ns.e('cell')).every((cell) => cell.attributes('disabled') !== undefined)).toBe(true) + wrapper.unmount() + }) + + it('卸载会取消排队中的 group blur 提交', async () => { + const wrapper = mount(InputOtp, { props: { length: 4 }, attachTo: document.body }) + const first = wrapper.findAll(ns.e('cell'))[0] + ;(first.element as HTMLInputElement).focus() + await first.trigger('blur') + wrapper.unmount() + await nextTick() + expect(wrapper.emitted('blur')).toBeUndefined() + }) + + it('通知 FormItem change/blur,内部焦点移动不误触 blur 校验', async () => { + const validate = vi.fn(() => Promise.resolve(true)) + const outside = document.createElement('button') + document.body.append(outside) + const wrapper = mount(InputOtp, { + props: { length: 4 }, + attachTo: document.body, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref('error'), + messageId: ref('otp-error'), + isInsideForm: true, + validate, + }, + }, + }, + }) + const cells = wrapper.findAll(ns.e('cell')) + ;(cells[0].element as HTMLInputElement).value = '1' + await cells[0].trigger('input') + ;(cells[1].element as HTMLInputElement).focus() + await nextTick() + expect(validate).toHaveBeenCalledTimes(1) + outside.focus() + await nextTick() + expect(validate.mock.calls).toEqual([['change'], ['blur']]) + expect(wrapper.attributes('aria-invalid')).toBe('true') + expect(wrapper.attributes('aria-describedby')).toBe('otp-error') + expect(cells.every((cell) => cell.attributes('aria-describedby') === 'otp-error')).toBe(true) + wrapper.unmount() + outside.remove() + }) }) describe('XL-4 ARIA', () => { @@ -234,5 +483,31 @@ describe('input-otp', () => { expect(wrapper.attributes('aria-disabled')).toBe('true') expect(wrapper.attributes('aria-invalid')).toBe('true') }) + + it('允许覆盖 group 名称,并为 cell 暴露位置与总数', () => { + const wrapper = mount(InputOtp, { props: { length: 4 }, attrs: { 'aria-label': '短信验证码' } }) + expect(wrapper.attributes('aria-label')).toBe('短信验证码') + expect(wrapper.findAll(ns.e('cell'))[2].attributes('aria-label')).toBe('短信验证码, cell 3 of 4') + }) + + it('group 与每个 cell 都关联去重后的调用方及 FormItem 描述', () => { + const wrapper = mount(InputOtp, { + attrs: { 'aria-describedby': 'hint shared' }, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref('error'), + messageId: ref('shared'), + isInsideForm: true, + validate: vi.fn(), + }, + }, + }, + }) + expect(wrapper.attributes('aria-describedby')).toBe('hint shared') + expect(wrapper.findAll(ns.e('cell')).every((cell) => cell.attributes('aria-describedby') === 'hint shared')).toBe( + true, + ) + }) }) }) diff --git a/packages/ccui/ui/input-search/src/input-search-types.ts b/packages/ccui/ui/input-search/src/input-search-types.ts index 8af9f374..386179e2 100644 --- a/packages/ccui/ui/input-search/src/input-search-types.ts +++ b/packages/ccui/ui/input-search/src/input-search-types.ts @@ -4,7 +4,8 @@ import { inputProps } from '../../input/src/input-types' export type InputSearchEnterButton = boolean | string | VNode export const inputSearchProps = { - // 复用 Input 的所有 props(type/size/placeholder/disabled/readonly/clearable/showCount/...) + // 保留历史上由 Input 展开的完整 props,避免小版本升级收窄 InputSearchProps,或让这些 + // 已识别属性意外跌落为原生 DOM attrs。未实现的兼容项已在文档标为 deprecated。 ...inputProps, /** * 搜索按钮: diff --git a/packages/ccui/ui/input-search/src/input-search.tsx b/packages/ccui/ui/input-search/src/input-search.tsx index 79543e55..9a4d9ea9 100644 --- a/packages/ccui/ui/input-search/src/input-search.tsx +++ b/packages/ccui/ui/input-search/src/input-search.tsx @@ -1,7 +1,21 @@ import type { VNode } from 'vue' +import type { FormItemInjectedContext } from '../../form/src/form-types' import type { InputSearchProps } from './input-search-types' import { Icon as IconifyIcon } from '@iconify/vue' -import { computed, defineComponent, Fragment, h, ref, watch } from 'vue' +import { + computed, + defineComponent, + Fragment, + getCurrentInstance, + h, + inject, + nextTick, + onBeforeUpdate, + onUpdated, + ref, + watch, +} from 'vue' +import { formItemInjectionKey } from '../../form/src/form-types' import { renderIconNode } from '../../shared/hooks/use-icon' import { useNamespace } from '../../shared/hooks/use-namespace' import { inputSearchProps } from './input-search-types' @@ -13,13 +27,29 @@ function isVNode(value: unknown): value is VNode { export default defineComponent({ name: 'CInputSearch', + inheritAttrs: false, props: inputSearchProps, emits: ['update:modelValue', 'input', 'change', 'focus', 'blur', 'clear', 'press-enter', 'search'], - setup(props: InputSearchProps, { emit, slots }) { + setup(props: InputSearchProps, { attrs, emit, slots }) { const ns = useNamespace('input-search') + const instance = getCurrentInstance() + const inputRef = ref(null) + const rootRef = ref(null) + const formItem = inject(formItemInjectionKey, null) + const validationStatus = computed(() => formItem?.validateStatus.value ?? '') + const mergedStatus = computed(() => props.status || validationStatus.value) - const initial = props.modelValue !== '' ? props.modelValue : (props.defaultValue ?? '') + const hasModelValue = () => { + const vnodeProps = instance?.vnode.props + return !!vnodeProps && ('modelValue' in vnodeProps || 'model-value' in vnodeProps) + } + let modelValueWasProvided = hasModelValue() + const initial = modelValueWasProvided ? props.modelValue : (props.defaultValue ?? '') const innerValue = ref(initial) + const isComposing = ref(false) + let compositionValueToIgnore: string | null = null + let focusWithin = false + let focusedElement: EventTarget | null = null watch( () => props.modelValue, @@ -28,6 +58,14 @@ export default defineComponent({ }, ) + onBeforeUpdate(() => { + const modelValueIsProvided = hasModelValue() + if (modelValueIsProvided && !modelValueWasProvided && props.modelValue !== innerValue.value) { + innerValue.value = props.modelValue + } + modelValueWasProvided = modelValueIsProvided + }) + const hasEnterButton = computed(() => { const v = props.enterButton if (v === false || v === '' || v === undefined || v === null) return !!slots['enter-button'] @@ -43,10 +81,34 @@ export default defineComponent({ innerValue.value = value emit('update:modelValue', value) emit('input', value) + void formItem?.validate('change') } const handleInput = (e: Event) => { const target = e.target as HTMLInputElement + if (props.disabled || props.readonly || isComposing.value) return + if (compositionValueToIgnore === target.value) { + compositionValueToIgnore = null + return + } + updateValue(target.value) + } + + const handleCompositionStart = () => { + if (props.disabled || props.readonly) return + isComposing.value = true + compositionValueToIgnore = null + } + + const handleCompositionEnd = (e: CompositionEvent) => { + if (!isComposing.value) return + isComposing.value = false + if (props.disabled || props.readonly) return + const target = e.target as HTMLInputElement + compositionValueToIgnore = target.value + queueMicrotask(() => { + compositionValueToIgnore = null + }) updateValue(target.value) } @@ -55,23 +117,53 @@ export default defineComponent({ emit('change', target.value) } - const handleFocus = (e: FocusEvent) => emit('focus', e) - const handleBlur = (e: FocusEvent) => emit('blur', e) + const handleFocus = (e: FocusEvent) => { + focusedElement = e.currentTarget + if (focusWithin) return + focusWithin = true + emit('focus', e) + } + const settleBlur = (e: FocusEvent) => { + if (!focusWithin) return + focusWithin = false + focusedElement = null + emit('blur', e) + void formItem?.validate('blur') + } + const handleBlur = (e: FocusEvent) => { + const next = e.relatedTarget as Node | null + if (next && rootRef.value?.contains(next)) return + void nextTick(() => { + if (rootRef.value?.contains(document.activeElement)) return + settleBlur(e) + }) + } + + // 浏览器不会可靠地为被 Vue 直接卸载的已聚焦节点派发 blur。动态 loading、modelValue + // 或 disabled 可能移除 inline-search / clear,因此在 patch 后主动结算聚合焦点状态。 + onUpdated(() => { + if (!focusWithin) return + const focusedNode = focusedElement instanceof Node ? focusedElement : null + if (focusedNode?.isConnected && rootRef.value?.contains(focusedNode)) return + settleBlur(new FocusEvent('blur')) + }) const handleKeydown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !isComposing.value && !e.isComposing && e.keyCode !== 229) { emit('press-enter', e) triggerSearch(e) } } const handleClear = () => { + if (props.disabled || props.readonly || !innerValue.value) return updateValue('') emit('clear') - emit('search', '', undefined) + triggerSearch() + inputRef.value?.focus() } - const renderLoadingIcon = () => h('i', { class: ns.e('loading-icon'), 'aria-label': 'loading' }) + const renderLoadingIcon = () => h('i', { class: ns.e('loading-icon'), 'aria-hidden': 'true' }) const renderSearchIcon = () => h(IconifyIcon, { icon: 'mdi:magnify', class: ns.e('search-icon') }) const renderClear = () => { @@ -84,14 +176,17 @@ export default defineComponent({ class: ns.e('clear'), role: 'button', tabindex: 0, + onMousedown: (e: MouseEvent) => e.preventDefault(), onClick: handleClear, + onFocus: handleFocus, + onBlur: handleBlur, onKeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleClear() } }, - 'aria-label': 'clear', + 'aria-label': '清除输入', }, [renderIconNode('mdi:close-circle')], ) @@ -112,6 +207,7 @@ export default defineComponent({ const renderEnterButton = () => { if (!hasEnterButton.value) return null + const isIconOnly = props.enterButton === true && !slots['enter-button'] return h( 'button', { @@ -119,12 +215,17 @@ export default defineComponent({ class: [ ns.e('button'), { - [ns.em('button', 'icon-only')]: props.enterButton === true && !slots['enter-button'], + [ns.em('button', 'icon-only')]: isIconOnly, [ns.em('button', 'disabled')]: props.disabled || props.loading, }, ], disabled: props.disabled || props.loading, + 'aria-label': isIconOnly ? '搜索' : undefined, + 'aria-busy': props.loading ? 'true' : undefined, + onMousedown: (e: MouseEvent) => e.preventDefault(), onClick: (e: Event) => triggerSearch(e), + onFocus: handleFocus, + onBlur: handleBlur, }, [renderEnterButtonContent()], ) @@ -141,9 +242,13 @@ export default defineComponent({ { class: ns.e('inline-icon'), role: 'button', - tabindex: 0, - 'aria-label': 'search', + tabindex: props.disabled ? -1 : 0, + 'aria-label': '搜索', + 'aria-disabled': props.disabled ? 'true' : undefined, + onMousedown: (e: MouseEvent) => e.preventDefault(), onClick: (e: Event) => triggerSearch(e), + onFocus: handleFocus, + onBlur: handleBlur, onKeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() @@ -161,31 +266,56 @@ export default defineComponent({ [ns.m(props.size)]: !!props.size, [ns.m('disabled')]: props.disabled, [ns.m('readonly')]: props.readonly, - [ns.m(`status-${props.status}`)]: !!props.status, + [ns.m(`status-${mergedStatus.value}`)]: !!mergedStatus.value, [ns.m('with-button')]: hasEnterButton.value, })) + const getInputAttrs = () => { + const { class: _class, style: _style, 'aria-describedby': describedBy, ...nativeAttrs } = attrs + const descriptionIds = [describedBy, formItem?.messageId?.value] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .flatMap((value) => value.split(/\s+/)) + return { + ...nativeAttrs, + ref: inputRef, + class: [ns.e('inner'), props.classNames?.input], + style: props.styles?.input, + type: props.type === 'password' ? 'password' : 'text', + placeholder: props.placeholder, + disabled: props.disabled, + readonly: props.readonly, + maxlength: props.maxLength, + value: innerValue.value, + 'aria-invalid': mergedStatus.value === 'error' ? 'true' : undefined, + 'aria-describedby': [...new Set(descriptionIds)].join(' ') || undefined, + 'aria-disabled': props.disabled ? 'true' : undefined, + 'aria-readonly': props.readonly ? 'true' : undefined, + onInput: handleInput, + onChange: handleChange, + onFocus: handleFocus, + onBlur: handleBlur, + onKeydown: handleKeydown, + onCompositionstart: handleCompositionStart, + onCompositionend: handleCompositionEnd, + } + } + return () => - h('div', { class: wrapperCls.value }, [ - h('div', { class: ns.e('input-wrap') }, [ - slots.prefix ? h('span', { class: ns.e('prefix') }, slots.prefix()) : null, - h('input', { - class: ns.e('inner'), - type: props.type === 'password' ? 'password' : 'text', - placeholder: props.placeholder, - disabled: props.disabled, - readonly: props.readonly, - maxlength: props.maxLength, - value: innerValue.value, - onInput: handleInput, - onChange: handleChange, - onFocus: handleFocus, - onBlur: handleBlur, - onKeydown: handleKeydown, - }), - renderSuffix(), - ]), - renderEnterButton(), - ]) + h( + 'div', + { + ref: rootRef, + class: [wrapperCls.value, props.classNames?.root, attrs.class], + style: [props.styles?.root, attrs.style], + }, + [ + h('div', { class: [ns.e('input-wrap'), props.classNames?.wrapper], style: props.styles?.wrapper }, [ + slots.prefix ? h('span', { class: ns.e('prefix') }, slots.prefix()) : null, + h('input', getInputAttrs()), + renderSuffix(), + ]), + renderEnterButton(), + ], + ) }, }) diff --git a/packages/ccui/ui/input-search/test/input-search.test.ts b/packages/ccui/ui/input-search/test/input-search.test.ts index 1ddcce43..8835788c 100644 --- a/packages/ccui/ui/input-search/test/input-search.test.ts +++ b/packages/ccui/ui/input-search/test/input-search.test.ts @@ -1,9 +1,48 @@ import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vite-plus/test' +import { ref } from 'vue' +import { describe, expect, it, vi } from 'vite-plus/test' +import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' +import type { InputSearchProps } from '../index' import { InputSearch } from '../index' const ns = useNamespace('input-search', true) +const legacyPropsTypeCompatibility = { + showPassword: true, + prepend: 'before', + append: 'after', + showCount: true, + variant: 'filled', +} satisfies Partial +const focusRemovalCases: Array<{ + name: string + props: Partial + selector: string + removeProps: Partial + restoreProps: Partial +}> = [ + { + name: 'loading 卸载 inline search', + props: {}, + selector: ns.e('inline-icon'), + removeProps: { loading: true }, + restoreProps: { loading: false }, + }, + { + name: 'modelValue 清空卸载 clear', + props: { clearable: true, modelValue: 'query' }, + selector: ns.e('clear'), + removeProps: { modelValue: '' }, + restoreProps: { modelValue: 'query' }, + }, + { + name: 'disabled 卸载 clear', + props: { clearable: true, modelValue: 'query' }, + selector: ns.e('clear'), + removeProps: { disabled: true }, + restoreProps: { disabled: false }, + }, +] describe('input-search', () => { describe('基本渲染', () => { @@ -34,6 +73,16 @@ describe('input-search', () => { const wrapper = mount(InputSearch, { props: { size: 'large' } }) expect(wrapper.find(ns.m('large')).exists()).toBe(true) }) + + it('保留历史 Input props 的类型和运行时识别兼容', () => { + const wrapper = mount(InputSearch, { props: legacyPropsTypeCompatibility }) + expect(wrapper.props()).toMatchObject(legacyPropsTypeCompatibility) + expect(wrapper.find('input').attributes('showpassword')).toBeUndefined() + expect(wrapper.find('input').attributes('prepend')).toBeUndefined() + expect(wrapper.find('input').attributes('append')).toBeUndefined() + expect(wrapper.find('input').attributes('showcount')).toBeUndefined() + expect(wrapper.find('input').attributes('variant')).toBeUndefined() + }) }) describe('v-model + defaultValue', () => { @@ -47,6 +96,17 @@ describe('input-search', () => { expect((wrapper.find('input').element as HTMLInputElement).value).toBe('preset') }) + it('显式空 modelValue 优先于 defaultValue', () => { + const wrapper = mount(InputSearch, { props: { modelValue: '', defaultValue: 'preset' } }) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') + }) + + it('从缺省 modelValue 切换为显式空值时同步受控值', async () => { + const wrapper = mount(InputSearch, { props: { defaultValue: 'preset' } }) + await wrapper.setProps({ modelValue: '' }) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') + }) + it('输入触发 update:modelValue + input', async () => { const wrapper = mount(InputSearch) const input = wrapper.find('input') @@ -75,6 +135,7 @@ describe('input-search', () => { const btn = wrapper.find(ns.e('button')) expect(btn.exists()).toBe(true) expect(wrapper.find(ns.em('button', 'icon-only')).exists()).toBe(true) + expect(btn.attributes('aria-label')).toBe('搜索') }) it('enterButton="搜索" 渲染文字按钮', () => { @@ -137,6 +198,14 @@ describe('input-search', () => { expect(wrapper.emitted('search')).toBeUndefined() }) + it('loading 时清除仍更新值但不触发 search', async () => { + const wrapper = mount(InputSearch, { props: { clearable: true, modelValue: 'kw', loading: true } }) + await wrapper.find(ns.e('clear')).trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([['']]) + expect(wrapper.emitted('clear')).toEqual([[]]) + expect(wrapper.emitted('search')).toBeUndefined() + }) + it('清除按钮也会触发 search("")', async () => { const wrapper = mount(InputSearch, { props: { clearable: true, modelValue: 'kw' } }) await wrapper.find(ns.e('clear')).trigger('click') @@ -153,6 +222,7 @@ describe('input-search', () => { const btn = wrapper.find(ns.e('button')) expect(btn.find(ns.e('loading-icon')).exists()).toBe(true) expect(btn.attributes('disabled')).toBeDefined() + expect(btn.attributes('aria-busy')).toBe('true') }) it('loading=true 且无按钮时 suffix 渲染 loading 图标', () => { @@ -194,6 +264,124 @@ describe('input-search', () => { await wrapper.find(ns.e('clear')).trigger('click') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']) }) + + it('清除后把焦点恢复到输入框', async () => { + const wrapper = mount(InputSearch, { attachTo: document.body, props: { clearable: true, modelValue: 'x' } }) + const input = wrapper.find('input') + ;(input.element as HTMLInputElement).focus() + await wrapper.find(ns.e('clear')).trigger('click') + expect(document.activeElement).toBe(input.element) + wrapper.unmount() + }) + }) + + describe('IME、原生属性与 FormItem', () => { + it('IME 组合期间不提交中间值,compositionend 最终值只提交一次', async () => { + const wrapper = mount(InputSearch) + const input = wrapper.find('input') + const element = input.element as HTMLInputElement + + element.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })) + element.value = 'h' + await input.trigger('input') + await input.trigger('keydown', { key: 'Enter', isComposing: true }) + expect(wrapper.emitted('input')).toBeUndefined() + expect(wrapper.emitted('search')).toBeUndefined() + + element.value = '汉' + element.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '汉' })) + await input.trigger('input') + expect(wrapper.emitted('input')).toEqual([['汉']]) + expect(wrapper.emitted('update:modelValue')).toEqual([['汉']]) + }) + + it('把原生属性透传给 input,并将 class/style 保留在根节点', () => { + const wrapper = mount(InputSearch, { + attrs: { + class: 'consumer-root', + style: 'width: 240px', + name: 'query', + autocomplete: 'off', + 'aria-label': '站内搜索', + }, + }) + const input = wrapper.find('input') + expect(input.attributes('name')).toBe('query') + expect(input.attributes('autocomplete')).toBe('off') + expect(input.attributes('aria-label')).toBe('站内搜索') + expect(input.classes()).not.toContain('consumer-root') + expect(wrapper.classes()).toContain('consumer-root') + }) + + it('继承 FormItem 状态、描述,并在 change/blur 时触发校验', async () => { + const validate = vi.fn(async () => true) + const wrapper = mount(InputSearch, { + attachTo: document.body, + attrs: { 'aria-describedby': 'hint' }, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref('error'), + messageId: ref('field-error'), + isInsideForm: true, + validate, + }, + }, + }, + }) + const input = wrapper.find('input') + expect(wrapper.find(ns.m('status-error')).exists()).toBe(true) + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.attributes('aria-describedby')).toBe('hint field-error') + + await input.setValue('query') + expect(validate).toHaveBeenCalledWith('change') + ;(input.element as HTMLInputElement).focus() + ;(input.element as HTMLInputElement).blur() + await wrapper.vm.$nextTick() + expect(validate).toHaveBeenCalledWith('blur') + wrapper.unmount() + }) + + it('disabled 的内联搜索控件退出 Tab 顺序并暴露禁用状态', () => { + const wrapper = mount(InputSearch, { props: { disabled: true } }) + const icon = wrapper.find(ns.e('inline-icon')) + expect(icon.attributes('tabindex')).toBe('-1') + expect(icon.attributes('aria-disabled')).toBe('true') + }) + + it.each(focusRemovalCases)( + '$name 时恰好结算一次 blur,恢复后可再次 focus', + async ({ props, selector, removeProps, restoreProps }) => { + const validate = vi.fn(async (_trigger?: string) => true) + const wrapper = mount(InputSearch, { + attachTo: document.body, + props, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus: ref(''), + isInsideForm: true, + validate, + }, + }, + }, + }) + + ;(wrapper.find(selector).element as HTMLElement).focus() + expect(wrapper.emitted('focus')).toHaveLength(1) + await wrapper.setProps(removeProps) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('blur')).toHaveLength(1) + expect(validate.mock.calls.filter(([trigger]) => trigger === 'blur')).toHaveLength(1) + + await wrapper.setProps(restoreProps) + ;(wrapper.find('input').element as HTMLInputElement).focus() + expect(wrapper.emitted('focus')).toHaveLength(2) + expect(wrapper.emitted('blur')).toHaveLength(1) + wrapper.unmount() + }, + ) }) describe('prefix / suffix slot', () => { diff --git a/packages/ccui/ui/input/src/input.scss b/packages/ccui/ui/input/src/input.scss index db3f34ff..65897a8c 100644 --- a/packages/ccui/ui/input/src/input.scss +++ b/packages/ccui/ui/input/src/input.scss @@ -195,4 +195,17 @@ color: $ccui-text; } } + + &__clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: 0; + font: inherit; + line-height: 1; + } } diff --git a/packages/ccui/ui/input/src/input.tsx b/packages/ccui/ui/input/src/input.tsx index 2926611f..cbce449b 100644 --- a/packages/ccui/ui/input/src/input.tsx +++ b/packages/ccui/ui/input/src/input.tsx @@ -1,6 +1,6 @@ import type { FormItemInjectedContext } from '../../form/src/form-types' import type { InputProps, InputShowCountObject } from './input-types' -import { computed, defineComponent, inject, ref, watch } from 'vue' +import { computed, defineComponent, getCurrentInstance, inject, onBeforeUpdate, ref, watch } from 'vue' import { formItemInjectionKey } from '../../form/src/form-types' import { useNamespace } from '../../shared/hooks/use-namespace' import { inputProps } from './input-types' @@ -12,9 +12,10 @@ function isShowCountObject(value: unknown): value is InputShowCountObject { export default defineComponent({ name: 'CInput', + inheritAttrs: false, props: inputProps, emits: ['update:modelValue', 'input', 'change', 'focus', 'blur', 'clear', 'press-enter'], - setup(props: InputProps, { emit, slots }) { + setup(props: InputProps, { attrs, emit, slots }) { const ns = useNamespace('input') const inputRef = ref(null) const formItem = inject(formItemInjectionKey, null) @@ -22,7 +23,13 @@ export default defineComponent({ const mergedStatus = computed(() => props.status || validationStatus.value) // ── 受控 / 非受控值(defaultValue 仅在首次取) ───────── - const initial = props.modelValue !== '' ? props.modelValue : (props.defaultValue ?? '') + const instance = getCurrentInstance() + const hasModelValue = () => { + const vnodeProps = instance?.vnode.props + return !!vnodeProps && ('modelValue' in vnodeProps || 'model-value' in vnodeProps) + } + let modelValueWasProvided = hasModelValue() + const initial = modelValueWasProvided ? props.modelValue : (props.defaultValue ?? '') const inputValue = ref(initial) const isFocused = ref(false) const isPasswordVisible = ref(false) @@ -150,6 +157,16 @@ export default defineComponent({ }, ) + // raw prop 从“缺省”切换为显式空字符串时,resolved prop 仍是默认值 '', + // 普通 props watcher 不会触发;在组件更新前按 vnode prop 存在性补一次同步。 + onBeforeUpdate(() => { + const modelValueIsProvided = hasModelValue() + if (modelValueIsProvided && !modelValueWasProvided && props.modelValue !== inputValue.value) { + inputValue.value = props.modelValue + } + modelValueWasProvided = modelValueIsProvided + }) + // 当密码显隐能力关闭(type 离开 'password' 或 showPassword 关闭)时, // 复位 isPasswordVisible,避免残留显隐态在下次进入 password 模式时直接以明文展示 watch( @@ -182,7 +199,17 @@ export default defineComponent({ return (
- {hasClear && } + {hasClear && ( + + )} {hasPasswordToggle && ( ({ - ref: inputRef, - class: [inputClass.value, props.classNames?.input], - style: props.styles?.input, - placeholder: props.placeholder, - disabled: props.disabled, - readonly: props.readonly, - maxlength: props.maxLength, - value: inputValue.value, - 'aria-invalid': mergedStatus.value === 'error' ? true : undefined, - 'aria-disabled': props.disabled ? true : undefined, - 'aria-readonly': props.readonly ? true : undefined, - onInput: handleInput, - onChange: handleChange, - onFocus: handleFocus, - onBlur: handleBlur, - onKeydown: handleKeydown, - }) + const getInputAttrs = () => { + const { class: _class, style: _style, 'aria-describedby': describedBy, ...nativeAttrs } = attrs + const descriptionIds = [describedBy, formItem?.messageId?.value] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .flatMap((value) => value.split(/\s+/)) + const ariaDescribedBy = [...new Set(descriptionIds)].join(' ') || undefined + + return { + ...nativeAttrs, + ref: inputRef, + class: [inputClass.value, props.classNames?.input], + style: props.styles?.input, + placeholder: props.placeholder, + disabled: props.disabled, + readonly: props.readonly, + maxlength: props.maxLength, + value: inputValue.value, + 'aria-invalid': mergedStatus.value === 'error' ? true : undefined, + 'aria-describedby': ariaDescribedBy, + 'aria-disabled': props.disabled ? true : undefined, + 'aria-readonly': props.readonly ? true : undefined, + onInput: handleInput, + onChange: handleChange, + onFocus: handleFocus, + onBlur: handleBlur, + onKeydown: handleKeydown, + } + } return () => { const prependContent = renderAddonBefore() @@ -241,7 +278,10 @@ export default defineComponent({ if (prependContent || appendContent) { return ( -
+
{prependContent}
{mainContent} @@ -257,8 +297,8 @@ export default defineComponent({ } return (
{mainContent}
diff --git a/packages/ccui/ui/input/test/input.test.ts b/packages/ccui/ui/input/test/input.test.ts index a674230d..40d86fe9 100644 --- a/packages/ccui/ui/input/test/input.test.ts +++ b/packages/ccui/ui/input/test/input.test.ts @@ -51,6 +51,27 @@ describe('input', () => { expect(wrapper.find('input').attributes('placeholder')).toBe('请输入内容') }) + it('forwards native attributes to the input while keeping class and style on the root', () => { + const wrapper = mount(Input, { + attrs: { + id: 'account-input', + name: 'account', + autocomplete: 'username', + class: 'custom-root', + style: 'width: 240px', + }, + }) + const input = wrapper.find('input') + + expect(input.attributes('id')).toBe('account-input') + expect(input.attributes('name')).toBe('account') + expect(input.attributes('autocomplete')).toBe('username') + expect(input.classes()).not.toContain('custom-root') + expect(wrapper.classes()).toContain('custom-root') + expect(wrapper.attributes('style')).toContain('width: 240px') + wrapper.unmount() + }) + it('disabled', async () => { const wrapper = createShallowWrapper({ disabled: true }) const disabledClass = ns.m('disabled').substring(1) @@ -66,6 +87,25 @@ describe('input', () => { it('clearable', async () => { const wrapper = createShallowWrapper({ clearable: true, modelValue: 'test' }) expect(wrapper.find(ns.e('clear')).exists()).toBeTruthy() + expect(wrapper.find(ns.e('clear')).element.tagName).toBe('BUTTON') + expect(wrapper.find(ns.e('clear')).attributes('aria-label')).toBe('清除输入') + expect(wrapper.find(ns.e('clear')).text()).toBe('×') + expect(wrapper.find(`${ns.e('clear')} [aria-hidden="true"]`).exists()).toBe(true) + }) + + it('prevents clear-button mousedown from stealing input focus', () => { + const wrapper = mount(Input, { props: { clearable: true, modelValue: 'test' }, attachTo: document.body }) + const input = wrapper.find('input').element as HTMLInputElement + const clear = wrapper.find(ns.e('clear')).element + input.focus() + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + + clear.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(input) + expect(wrapper.emitted('blur')).toBeUndefined() + wrapper.unmount() }) it('prepend', async () => { @@ -342,6 +382,22 @@ describe('input', () => { expect((wrapper.find('input').element as HTMLInputElement).value).toBe('real') wrapper.unmount() }) + + it('显式空 modelValue 也优先于 defaultValue', () => { + const wrapper = mount(Input, { props: { modelValue: '', defaultValue: 'preset' } }) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') + wrapper.unmount() + }) + + it('运行时新增显式空 modelValue 会接管 defaultValue', async () => { + const wrapper = mount(Input, { props: { defaultValue: 'preset' } }) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('preset') + + await wrapper.setProps({ modelValue: '' }) + + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') + wrapper.unmount() + }) }) describe('M-A2 classNames / styles 钩子', () => { diff --git a/packages/ccui/ui/mentions/src/mentions-types.ts b/packages/ccui/ui/mentions/src/mentions-types.ts index 6105aaa5..f741af64 100644 --- a/packages/ccui/ui/mentions/src/mentions-types.ts +++ b/packages/ccui/ui/mentions/src/mentions-types.ts @@ -52,6 +52,10 @@ export const mentionsProps = { type: Boolean, default: false, }, + readonly: { + type: Boolean, + default: false, + }, rows: { type: Number, default: 3, diff --git a/packages/ccui/ui/mentions/src/mentions.scss b/packages/ccui/ui/mentions/src/mentions.scss index c3109c89..1d6ff76e 100644 --- a/packages/ccui/ui/mentions/src/mentions.scss +++ b/packages/ccui/ui/mentions/src/mentions.scss @@ -9,6 +9,10 @@ cursor: not-allowed; } + &.is-readonly { + cursor: default; + } + // M-A1:录入组件统一 variant 形态 —— 视觉边框在 __textarea @include form-control-variants-on('.#{$cls-prefix}-mentions__textarea'); diff --git a/packages/ccui/ui/mentions/src/mentions.tsx b/packages/ccui/ui/mentions/src/mentions.tsx index 31c5084a..b3bab1cb 100644 --- a/packages/ccui/ui/mentions/src/mentions.tsx +++ b/packages/ccui/ui/mentions/src/mentions.tsx @@ -22,9 +22,10 @@ import './mentions.scss' export default defineComponent({ name: 'CMentions', + inheritAttrs: false, props: mentionsProps, emits: ['update:modelValue', 'change', 'select', 'search', 'focus', 'blur'], - setup(props: MentionsProps, { emit, slots }) { + setup(props: MentionsProps, { attrs, emit, slots }) { const ns = useNamespace('mentions') const cfg = useConfig() const uid = getCurrentInstance()?.uid ?? 0 @@ -37,6 +38,7 @@ export default defineComponent({ const innerValue = shallowRef(props.defaultValue ?? '') const activeIndex = shallowRef(0) const activeMatch = shallowRef(null) + const isComposing = shallowRef(false) const formItem = inject(formItemInjectionKey, null) const validationStatus = computed(() => formItem?.validateStatus.value ?? '') const mergedStatus = computed(() => props.status || validationStatus.value) @@ -49,7 +51,7 @@ export default defineComponent({ const prefixList = computed(() => { const p = props.prefix - return Array.isArray(p) ? p : [p] + return [...new Set((Array.isArray(p) ? p : [p]).filter((item) => item.length > 0))] }) const normalized = computed(() => (props.options || []).map((item) => normalizeMention(item))) @@ -72,13 +74,29 @@ export default defineComponent({ // 过滤列表收缩时,把 activeIndex 钳到首个可用项,避免越界导致无高亮且 Enter/Tab 选不中 watch(filteredOptions, (list) => { if (!open.value) return - if (activeIndex.value >= list.length || list[activeIndex.value]?.disabled) { + if (activeIndex.value < 0 || activeIndex.value >= list.length || list[activeIndex.value]?.disabled) { const first = list.findIndex((o) => !o.disabled) - activeIndex.value = first === -1 ? 0 : first + activeIndex.value = first } }) let debounceTimer: ReturnType | null = null + let compositionValueToIgnore: string | null = null + let lastSearchSignature: string | null = null + + function cancelPendingSearch(): void { + if (!debounceTimer) return + clearTimeout(debounceTimer) + debounceTimer = null + } + + function closePopup(): void { + open.value = false + activeMatch.value = null + activeIndex.value = -1 + lastSearchSignature = null + cancelPendingSearch() + } function setValue(next: string) { if (!isControlled.value) { @@ -105,32 +123,49 @@ export default defineComponent({ function refreshMatch(): void { const ta = textareaRef.value - if (!ta) return + if (!ta || props.disabled || props.readonly || isComposing.value) { + closePopup() + return + } const cursor = ta.selectionStart - const match = findActiveMention(currentValue.value, cursor, prefixList.value) + const match = findActiveMention(ta.value, cursor, prefixList.value) activeMatch.value = match if (match) { if (!open.value) { open.value = true - // 初始高亮首个可用项;全 disabled / 空列表时 findIndex 返回 -1,用 Math.max(0, ...) 兜底保持原行为 - activeIndex.value = Math.max( - 0, - filteredOptions.value.findIndex((o) => !o.disabled), - ) + activeIndex.value = filteredOptions.value.findIndex((o) => !o.disabled) } + const searchSignature = `${match.prefix}\0${match.search}` + if (lastSearchSignature === searchSignature) return + lastSearchSignature = searchSignature + cancelPendingSearch() if (props.searchDebounce > 0) { - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => emit('search', match.search, match.prefix), props.searchDebounce) + debounceTimer = setTimeout(() => { + debounceTimer = null + emit('search', match.search, match.prefix) + }, props.searchDebounce) } else { emit('search', match.search, match.prefix) } } else if (open.value) { - open.value = false + closePopup() + } else { + cancelPendingSearch() } } function onInput(e: Event): void { - const next = (e.target as HTMLTextAreaElement).value + const target = e.target as HTMLTextAreaElement + if (props.disabled || props.readonly) { + target.value = currentValue.value + return + } + if (isComposing.value || (e as InputEvent).isComposing) return + if (compositionValueToIgnore === target.value) { + compositionValueToIgnore = null + return + } + const next = target.value setValue(next) nextTick(() => { refreshMatch() @@ -139,7 +174,7 @@ export default defineComponent({ } function onKeyup(): void { - // 方向键移动光标后也要刷新(不改 value,但改光标位置) + // 仅光标导航键会在不产生 input 的情况下改变 selection;Escape 等键不可在 keyup 时重开浮层。 refreshMatch() } @@ -151,14 +186,16 @@ export default defineComponent({ const ta = textareaRef.value const match = activeMatch.value if (!ta || !match || opt.disabled) return - const before = currentValue.value.slice(0, match.start) - const after = currentValue.value.slice(ta.selectionStart) + const sourceValue = ta.value + const before = sourceValue.slice(0, match.start) + const after = sourceValue.slice(ta.selectionStart) const inserted = `${match.prefix}${opt.value}${props.split}` const next = `${before}${inserted}${after}` setValue(next) emit('select', opt.raw, match.prefix) open.value = false activeMatch.value = null + activeIndex.value = -1 // 把光标定位到 inserted 末尾 const newCursor = before.length + inserted.length nextTick(() => { @@ -170,7 +207,7 @@ export default defineComponent({ } function onKeydown(e: KeyboardEvent): void { - if (props.disabled) return + if (props.disabled || props.readonly || isComposing.value || e.isComposing) return if (!open.value) return const list = filteredOptions.value const enabled = list.filter((o) => !o.disabled) @@ -194,16 +231,36 @@ export default defineComponent({ } } else if (e.key === 'Escape') { e.preventDefault() - open.value = false - activeMatch.value = null + closePopup() } } + function onCompositionstart(): void { + if (props.disabled || props.readonly) return + isComposing.value = true + compositionValueToIgnore = null + closePopup() + } + + function onCompositionend(e: CompositionEvent): void { + if (!isComposing.value) return + isComposing.value = false + if (props.disabled || props.readonly) return + const target = e.target as HTMLTextAreaElement + compositionValueToIgnore = target.value + setValue(target.value) + nextTick(() => { + refreshMatch() + adjustHeight() + }) + } + function onFocus(e: FocusEvent): void { emit('focus', e) } function onBlur(e: FocusEvent): void { emit('blur', e) + closePopup() formItem?.validate('blur') } @@ -212,7 +269,7 @@ export default defineComponent({ const target = e.target as Node | null if (!target) return if (rootRef.value?.contains(target)) return - open.value = false + closePopup() } onMounted(() => { @@ -223,11 +280,36 @@ export default defineComponent({ document.removeEventListener('mousedown', onClickOutside, true) // flush 搜索 debounce,避免卸载后仍触发一次 emit('search') if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null + cancelPendingSearch() } }) + watch( + () => [props.disabled, props.readonly] as const, + ([disabled, readonly]) => { + if (disabled || readonly) { + isComposing.value = false + compositionValueToIgnore = null + closePopup() + } + }, + ) + + watch( + () => props.prefix, + () => { + if (open.value) refreshMatch() + }, + { deep: true }, + ) + + watch(currentValue, () => { + nextTick(() => { + adjustHeight() + if (open.value) refreshMatch() + }) + }) + function renderOption(opt: NormalizedOption, index: number): VNode { const cls = [ ns.e('option'), @@ -284,44 +366,62 @@ export default defineComponent({ ) } - return () => ( -
-