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 {
+ // 没有用户手势等情况下会被拒绝,此时已经聚焦,用户仍可手动点日历图标
+ }
+ }
+}