fix(ui): 日期占位在聚焦后也保持遮挡,点击改为直接唤起日历
上一版只在「未聚焦」时遮挡原生提示,一点击输入框就会露出「yyyy/mm/日」—— 而这个字符串本身就是要去掉的,不能给它留出口。 改为:只要值为空就一直遮挡,聚焦与否都不例外。代价是没法再看着原生分段 输入,所以点击占位层改为直接调用 showPicker 唤起原生日历,把交互从 「编辑文本」改成「选日期」;回车/空格同样可唤起,日历图标区不被遮挡, 仍走原生行为。占位层背景色与输入框严格一致(浅色 white / 深色 slate-800)。 回归测试直接盯住这个场景:空值聚焦后占位层必须仍在。
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user