fix(ui): 日期占位在聚焦后也保持遮挡,点击改为直接唤起日历
上一版只在「未聚焦」时遮挡原生提示,一点击输入框就会露出「yyyy/mm/日」—— 而这个字符串本身就是要去掉的,不能给它留出口。 改为:只要值为空就一直遮挡,聚焦与否都不例外。代价是没法再看着原生分段 输入,所以点击占位层改为直接调用 showPicker 唤起原生日历,把交互从 「编辑文本」改成「选日期」;回车/空格同样可唤起,日历图标区不被遮挡, 仍走原生行为。占位层背景色与输入框严格一致(浅色 white / 深色 slate-800)。 回归测试直接盯住这个场景:空值聚焦后占位层必须仍在。
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,21 +4,25 @@
|
||||
*
|
||||
* 直接使用浏览器原生 `<input type="date">`:
|
||||
* - 其 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<HTMLInputElement | null>(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 {
|
||||
// 没有用户手势等情况下会被拒绝,此时已经聚焦,用户仍可手动点日历图标
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<input
|
||||
ref="inputRef"
|
||||
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"
|
||||
:value="modelValue ?? ''"
|
||||
:aria-label="label ?? '截止日期'"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@change="onChange"
|
||||
@keydown.enter.prevent="openPicker"
|
||||
@keydown.space.prevent="openPicker"
|
||||
/>
|
||||
|
||||
<!--
|
||||
背景色必须与输入框一致(浅色 bg-white / 深色 bg-slate-800),否则盖不干净。
|
||||
左侧内缩 1px 让边框可见;右侧留出日历图标的位置,避免把它也盖住。
|
||||
左侧内缩 1px 让边框可见;右侧留出日历图标的位置,不挡住原生图标。
|
||||
占位层自己接管点击(pointer-events-auto),点哪都能开日历。
|
||||
-->
|
||||
<span
|
||||
v-if="showPlaceholder"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user