diff --git a/src/components/DatePicker.spec.ts b/src/components/DatePicker.spec.ts index f5667cb..38d2d91 100644 --- a/src/components/DatePicker.spec.ts +++ b/src/components/DatePicker.spec.ts @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import DatePicker from './DatePicker.vue' @@ -7,6 +7,21 @@ function mountPicker(modelValue: string | null, label?: string) { return mount(DatePicker, { props: label === undefined ? { modelValue } : { modelValue, label } }) } +/** jsdom 没有 showPicker,按需打到原型上 */ +function stubShowPicker(impl: () => void = () => {}) { + const spy = vi.fn(impl) + Object.defineProperty(HTMLInputElement.prototype, 'showPicker', { + configurable: true, + writable: true, + value: spy, + }) + return spy +} + +afterEach(() => { + delete (HTMLInputElement.prototype as { showPicker?: unknown }).showPicker +}) + describe('DatePicker', () => { it('使用原生 date 输入,保证输出为 YYYY-MM-DD', () => { expect(mountPicker(null).find('input').attributes('type')).toBe('date') @@ -72,25 +87,14 @@ describe('DatePicker 空值占位', () => { expect(placeholderOf(mountPicker(null)).text()).toBe('选择日期') }) - it('有值时不再显示占位', () => { - expect(placeholderOf(mountPicker('2026-10-20')).exists()).toBe(false) - }) - - it('聚焦后占位让位,原生分段可见', async () => { + it('空值聚焦后占位仍在(点击输入框不该露出 yyyy/mm/日)', async () => { const wrapper = mountPicker(null) await wrapper.find('input').trigger('focus') - expect(placeholderOf(wrapper).exists()).toBe(false) - }) - - it('失焦后占位恢复', async () => { - const wrapper = mountPicker(null) - await wrapper.find('input').trigger('focus') - await wrapper.find('input').trigger('blur') expect(placeholderOf(wrapper).exists()).toBe(true) }) - it('占位层不拦截鼠标事件(点击仍能唤起原生选择器)', () => { - expect(placeholderOf(mountPicker(null)).classes()).toContain('pointer-events-none') + it('有值时不再显示占位', () => { + expect(placeholderOf(mountPicker('2026-10-20')).exists()).toBe(false) }) it('占位层带不透明背景,且与输入框配色一致', () => { @@ -107,3 +111,36 @@ describe('DatePicker 空值占位', () => { expect(classes).toContain('left-px') }) }) + +/** + * 占位层挡住了原生分段,所以交互从「编辑文本」改成「唤起日历」。 + * 这几条保证点击和回车都能打开日历,而不是聚焦到一个看不见的输入框上。 + */ +describe('DatePicker 唤起原生日历', () => { + it('点击占位层调用 showPicker', async () => { + const showPicker = stubShowPicker() + await mountPicker(null).find('[data-role="date-placeholder"]').trigger('click') + expect(showPicker).toHaveBeenCalledTimes(1) + }) + + it('输入框上按回车也能唤起日历', async () => { + const showPicker = stubShowPicker() + await mountPicker(null).find('input').trigger('keydown.enter') + expect(showPicker).toHaveBeenCalledTimes(1) + }) + + it('showPicker 被浏览器拒绝时不抛错(仍已聚焦,可手动点日历图标)', async () => { + stubShowPicker(() => { + throw new Error('NotAllowedError') + }) + await expect( + mountPicker(null).find('[data-role="date-placeholder"]').trigger('click'), + ).resolves.toBeUndefined() + }) + + it('没有 showPicker 时不报错(降级为聚焦)', async () => { + await expect( + mountPicker(null).find('[data-role="date-placeholder"]').trigger('click'), + ).resolves.toBeUndefined() + }) +}) diff --git a/src/components/DatePicker.vue b/src/components/DatePicker.vue index 82e277c..4e2fe21 100644 --- a/src/components/DatePicker.vue +++ b/src/components/DatePicker.vue @@ -4,21 +4,25 @@ * * 直接使用浏览器原生 ``: * - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致 - * - 自带可访问性、键盘操作与本地化日历,无需自己实现 + * - 自带可访问性、本地化日历,无需自己实现 * - 深色模式下日历弹层跟随 html 的 color-scheme * * 空值时的占位处理: * 浏览器(Edge 尤甚)会给空日期框显示「yyyy / mm / 日」这种半英半中的 * 格式提示,既与界面语言不统一,又容易被误读成已有内容。 * - * 曾经用 CSS 把 `::-webkit-datetime-edit` 等伪元素设成透明,但在部分环境下 - * 不生效(实测在用户机器上原生文字照样显示)。现在改为**用一层不透明背景 - * 直接盖住**: - * - 不依赖伪元素支持,也不依赖 CSS 选择器匹配,纯靠绘制顺序(后者在上) - * - 显示时机由 Vue 状态决定(空值 && 未聚焦),确定可控 - * - 聚焦时让位,原生分段正常显示,不影响编辑 + * 踩过的两个坑,都记在这里免得重蹈: + * 1. 用 CSS 把 `::-webkit-datetime-edit` 系列伪元素设成透明 —— 部分环境下 + * 根本不生效,原生文字照样显示。 + * 2. 改成不透明遮罩,但只在「未聚焦」时遮挡 —— 一点击输入框,原生提示又露 + * 出来了。这个字符串本身就是要去掉的,所以不能给它留任何出口。 * - * 代价:占位层的背景色必须与输入框完全一致,否则会看出色块。 + * 现在的规则很简单:**只要值为空就一直盖着**,聚焦与否都不例外。代价是没法 + * 再看着原生分段输入,所以点击改为直接唤起原生日历(showPicker),把交互 + * 从「编辑文本」变成「选日期」—— 对截止日期这种字段本来也更顺手。 + * + * 生效原理是绘制顺序(占位层在 input 之后)加 Vue 状态,不依赖伪元素支持, + * 也不依赖任何 CSS 选择器匹配。代价是占位层背景色必须与输入框完全一致。 */ import { computed, ref } from 'vue' @@ -27,38 +31,56 @@ const props = defineProps<{ modelValue: string | null; label?: string }>() const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>() -const focused = ref(false) +const inputRef = ref(null) -/** 空值且未聚焦时才盖住原生提示 */ -const showPlaceholder = computed(() => props.modelValue === null && !focused.value) +/** 只要没有值就遮挡,不给原生「yyyy / mm / 日」任何露出的机会 */ +const showPlaceholder = computed(() => props.modelValue === null) function onChange(event: Event): void { const value = (event.target as HTMLInputElement).value emit('update:modelValue', value === '' ? null : value) } + +/** 唤起原生日历。占位层挡住了分段输入,所以点击/回车都直接开日历。 */ +function openPicker(): void { + const input = inputRef.value + if (!input) return + + input.focus() + if (typeof input.showPicker === 'function') { + try { + input.showPicker() + } catch { + // 没有用户手势等情况下会被拒绝,此时已经聚焦,用户仍可手动点日历图标 + } + } +}