fix(ui): 日期占位在聚焦后也保持遮挡,点击改为直接唤起日历

上一版只在「未聚焦」时遮挡原生提示,一点击输入框就会露出「yyyy/mm/日」——
而这个字符串本身就是要去掉的,不能给它留出口。

改为:只要值为空就一直遮挡,聚焦与否都不例外。代价是没法再看着原生分段
输入,所以点击占位层改为直接调用 showPicker 唤起原生日历,把交互从
「编辑文本」改成「选日期」;回车/空格同样可唤起,日历图标区不被遮挡,
仍走原生行为。占位层背景色与输入框严格一致(浅色 white / 深色 slate-800)。

回归测试直接盯住这个场景:空值聚焦后占位层必须仍在。
This commit is contained in:
chenshoulin
2026-10-18 19:23:01 +08:00
parent c45d2b72b4
commit b27fefb965
2 changed files with 89 additions and 30 deletions
+52 -15
View File
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils' 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' 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 } }) 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', () => { describe('DatePicker', () => {
it('使用原生 date 输入,保证输出为 YYYY-MM-DD', () => { it('使用原生 date 输入,保证输出为 YYYY-MM-DD', () => {
expect(mountPicker(null).find('input').attributes('type')).toBe('date') expect(mountPicker(null).find('input').attributes('type')).toBe('date')
@@ -72,25 +87,14 @@ describe('DatePicker 空值占位', () => {
expect(placeholderOf(mountPicker(null)).text()).toBe('选择日期') expect(placeholderOf(mountPicker(null)).text()).toBe('选择日期')
}) })
it('有值时不再显示占位', () => { it('空值聚焦后占位仍在(点击输入框不该露出 yyyy/mm/日)', async () => {
expect(placeholderOf(mountPicker('2026-10-20')).exists()).toBe(false)
})
it('聚焦后占位让位,原生分段可见', async () => {
const wrapper = mountPicker(null) const wrapper = mountPicker(null)
await wrapper.find('input').trigger('focus') 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) expect(placeholderOf(wrapper).exists()).toBe(true)
}) })
it('占位层不拦截鼠标事件(点击仍能唤起原生选择器)', () => { it('有值时不再显示占位', () => {
expect(placeholderOf(mountPicker(null)).classes()).toContain('pointer-events-none') expect(placeholderOf(mountPicker('2026-10-20')).exists()).toBe(false)
}) })
it('占位层带不透明背景,且与输入框配色一致', () => { it('占位层带不透明背景,且与输入框配色一致', () => {
@@ -107,3 +111,36 @@ describe('DatePicker 空值占位', () => {
expect(classes).toContain('left-px') 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()
})
})
+37 -15
View File
@@ -4,21 +4,25 @@
* *
* 直接使用浏览器原生 `<input type="date">`: * 直接使用浏览器原生 `<input type="date">`:
* - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致 * - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致
* - 自带可访问性、键盘操作与本地化日历,无需自己实现 * - 自带可访问性、本地化日历,无需自己实现
* - 深色模式下日历弹层跟随 html 的 color-scheme * - 深色模式下日历弹层跟随 html 的 color-scheme
* *
* 空值时的占位处理: * 空值时的占位处理:
* 浏览器(Edge 尤甚)会给空日期框显示「yyyy / mm / 日」这种半英半中的 * 浏览器(Edge 尤甚)会给空日期框显示「yyyy / mm / 日」这种半英半中的
* 格式提示,既与界面语言不统一,又容易被误读成已有内容。 * 格式提示,既与界面语言不统一,又容易被误读成已有内容。
* *
* 曾经用 CSS 把 `::-webkit-datetime-edit` 等伪元素设成透明,但在部分环境下 * 踩过的两个坑,都记在这里免得重蹈:
* 不生效(实测在用户机器上原生文字照样显示)。现在改为**用一层不透明背景 * 1. 用 CSS 把 `::-webkit-datetime-edit` 系列伪元素设成透明 —— 部分环境下
* 直接盖住**: * 根本不生效,原生文字照样显示。
* - 不依赖伪元素支持,也不依赖 CSS 选择器匹配,纯靠绘制顺序(后者在上) * 2. 改成不透明遮罩,但只在「未聚焦」时遮挡 —— 一点击输入框,原生提示又露
* - 显示时机由 Vue 状态决定(空值 && 未聚焦),确定可控 * 出来了。这个字符串本身就是要去掉的,所以不能给它留任何出口。
* - 聚焦时让位,原生分段正常显示,不影响编辑
* *
* 代价:占位层的背景色必须与输入框完全一致,否则会看出色块。 * 现在的规则很简单:**只要值为空就一直盖着**,聚焦与否都不例外。代价是没法
* 再看着原生分段输入,所以点击改为直接唤起原生日历(showPicker),把交互
* 从「编辑文本」变成「选日期」—— 对截止日期这种字段本来也更顺手。
*
* 生效原理是绘制顺序(占位层在 input 之后)加 Vue 状态,不依赖伪元素支持,
* 也不依赖任何 CSS 选择器匹配。代价是占位层背景色必须与输入框完全一致。
*/ */
import { computed, ref } from 'vue' 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 emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const focused = ref(false) const inputRef = ref<HTMLInputElement | null>(null)
/** 空值且未聚焦时才盖住原生提示 */ /** 只要没有值就遮挡,不给原生「yyyy / mm / 日」任何露出的机会 */
const showPlaceholder = computed(() => props.modelValue === null && !focused.value) const showPlaceholder = computed(() => props.modelValue === null)
function onChange(event: Event): void { function onChange(event: Event): void {
const value = (event.target as HTMLInputElement).value const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value === '' ? null : 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 {
// 没有用户手势等情况下会被拒绝,此时已经聚焦,用户仍可手动点日历图标
}
}
}
</script> </script>
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="relative min-w-0 flex-1"> <div class="relative min-w-0 flex-1">
<input <input
ref="inputRef"
type="date" type="date"
class="w-full rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-sky-600" class="w-full rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-sky-600"
:value="modelValue ?? ''" :value="modelValue ?? ''"
:aria-label="label ?? '截止日期'" :aria-label="label ?? '截止日期'"
@focus="focused = true"
@blur="focused = false"
@change="onChange" @change="onChange"
@keydown.enter.prevent="openPicker"
@keydown.space.prevent="openPicker"
/> />
<!-- <!--
背景色必须与输入框一致(浅色 bg-white / 深色 bg-slate-800),否则盖不干净。 背景色必须与输入框一致(浅色 bg-white / 深色 bg-slate-800),否则盖不干净。
左侧内缩 1px 让边框可见;右侧留出日历图标的位置,避免把它也盖住。 左侧内缩 1px 让边框可见;右侧留出日历图标的位置,不挡住原生图标。
占位层自己接管点击(pointer-events-auto),点哪都能开日历。
--> -->
<span <span
v-if="showPlaceholder" v-if="showPlaceholder"
data-role="date-placeholder" data-role="date-placeholder"
class="pointer-events-none absolute inset-y-px left-px right-9 flex items-center rounded-l-md bg-white pl-2 text-sm text-slate-400 dark:bg-slate-800 dark:text-slate-500" class="absolute inset-y-px left-px right-9 flex cursor-pointer items-center rounded-l-md bg-white pl-2 text-sm text-slate-400 dark:bg-slate-800 dark:text-slate-500"
@click="openPicker"
> >
选择日期 选择日期
</span> </span>