21 KiB
日期组件手动输入实现方案
Context
src/components/ui/date 下的 date-picker.vue(单选)与 range-picker.vue(区间)目前触发框是
type="text" readonly,值只能来自日历面板 calendar-panel.vue 的 @pick,用户无法键入。
目标:两个组件都支持手动键入,始终开启(不加 prop 开关)。
用户对「保持浏览器关于日期的语义」的说明是「主要是希望键盘操作流畅」,因此:
- 保留
type="text",不改用原生<input type="date">,以保住宅现有的 dayjs token 格式体系 (YYYY年MM月DD日、YYYY/MM/DD、hh:mm A、format数组等,这些原生控件无法无损表达)。 - 用「自由整串输入 + ↑/↓ 微调当前段」模拟浏览器日期输入框的键盘手感: 整串任意编辑不被分段规则打断,↑/↓ 对光标所在段 ±1。
- 非法输入在失焦/回车时回退到上次已提交的合法值并短暂标记错误态,
modelValue永不出现非法值。
键盘语义对照(本次核心契约)
| 按键 | 现在 | 改后 |
|---|---|---|
| 可打印字符 / Space | 无效(readonly);Space 被用于开面板 | 正常输入,不做任何拦截 |
ArrowUp / ArrowDown |
↓ 打开面板 | 光标所在段 ±1(年/月/日/时/分/秒/AM-PM);不打开面板 |
Alt + ArrowDown |
— | 打开面板并把焦点交给日历网格(保留键盘可达性) |
Enter |
切换面板开合 | 提交当前文本并关闭面板 |
Escape |
关面板 | 还原为已提交文本;面板开着则一并关闭 |
←/→/Home/End/Tab/Backspace |
原生 | 不变 |
改动清单
1. src/components/ui/date/date-utils.js(新增 3 组纯函数)
a. 光标 → 日期段
把 format 编译成「全串锚定 + 具名捕获 + d 标志」的正则,捕获组按 FORMAT_TOKENS 顺序生成,
每个组记录自己的 field;非可编辑 token(dddd/ZZ/Z 等)编译为不透明片段 .+?,
[...] 字面量按 dayjs 规则原样转义,空格放宽为 \s+(容忍手打多个空格)。
正则按 format 字符串做 Map 缓存。
段模式表(SEGMENT_PATTERNS):
| token | field | 正则片段 |
|---|---|---|
YYYY |
year | \d{4} |
YY |
year | \d{1,2} |
MM M |
month | \d{1,2} |
MMM MMMM |
month | [A-Za-z]+ |
DD D |
day | \d{1,2} |
Do |
day | \d{1,2}(?:st|nd|rd|th)? |
HH/H/hh/h |
hour | \d{2} / \d{1,2} |
mm m |
minute | \d{1,2} |
ss s |
second | \d{1,2} |
SSS |
millisecond | \d{1,3} |
A a |
meridiem | [AaPp][Mm] |
meridiem 用
[AaPp][Mm]而非AM\|PM,避免用户打成小写导致整串不匹配、连带失去所有段的微调能力。
导出:
listDateSegments(text, format)→[{ field, start, end }](按start升序;不匹配返回[])resolveDateSegment(text, caret, format)→{ field, start, end } | null规则:取最靠左的、闭区间[start, end]包含光标的段。 闭区间 + 左优先 ⇒ 光标停在分隔符(-、年)上归左侧段;光标在文本末尾归最后一段;caret越界时 clamp 到文本长度。
b. 按段步进
const SEGMENT_UNITS = { year:'year', month:'month', day:'date', hour:'hour',
minute:'minute', second:'second', millisecond:'millisecond' }
const WRAP_SIZES = { hour:24, minute:60, second:60 }
export function stepDateSegment(value, field, delta = 1) {
const base = toDayjs(value); if (!base) return null
if (field === 'meridiem') return base.add(delta * 12, 'hour').toDate()
const unit = SEGMENT_UNITS[field]; if (!unit) return base.toDate()
const size = WRAP_SIZES[field]
if (size) {
const cur = base[field]() // hour() / minute() / second()
return base[field]((((cur + delta) % size) + size) % size).toDate()
}
return base.add(delta, unit).toDate()
}
溢出语义(有意为之):
year/month/day走 dayjsadd:月末自动夹取(2026-01-31 +1m → 2026-02-28)、 闰日夹取(2024-02-29 +1y → 2025-02-28)、日跨月进位(09-30 +1d → 10-01)。hour/minute/second回绕而非进位(14:59 +1min → 14:00),避免微调时间时日期被悄悄推进到次日。 若产品后续要进位语义,删掉WRAP_SIZES分支即可。
c. 文本 → 待提交值
export function canonicalizeDateText(text) // 年月→'-'、日→' '、[./]→'-'、压缩空白、trim
export function parseDateInput(text, format) // → { date, isEmpty, isValid }
''/ 纯空白 →{ date:null, isEmpty:true, isValid:true }(组件按「清空值」处理,不进错误态)- 无任何数字 →
isValid:false - 解析两轮:
parseDate(raw, format)→ 失败再parseDate(canonicalizeDateText(raw), format)(复用既有parseDate,它已含严格多格式匹配 + 宽松 ISO 兜底) format传props.format原文(不是primaryFormat),让format数组的多格式容错也生效
| 输入 | format | 结果 |
|---|---|---|
2026-09-26 |
YYYY-MM-DD |
严格命中 |
2026/09/26、2026.09.26 |
YYYY-MM-DD |
宽松/归一化后命中 |
2026年09月26日 |
YYYY-MM-DD |
canonicalize 后命中 |
2026-09-26 14:30 |
YYYY-MM-DD HH:mm |
严格命中 |
2026-09 |
YYYY-MM-DD |
命中,日补 1(与面板选月的归一化一致) |
abc、2026-13-45 |
任意 | isValid:false → 回退 + 错误态 |
提交前的精度归一化仍由组件现有的 normalizeDraft(normalizeDateForFormat)+ toOutgoing(formatDate(resolvedValueFormat))负责,
date-utils.js 不再新增其它封装,保持单一职责。
2. src/components/ui/date/date-picker.vue
状态(加在 panelRef 之后)
const INVALID_STATE_DURATION = 1500
let invalidTimer = 0
/** null = 非编辑态(显示 displayText);字符串 = 用户正在编辑的原始文本 */
const editingText = ref(null)
const invalid = ref(false)
const inputDisplayText = computed(() => editingText.value ?? displayText.value)
inputDisplayText 是纯响应式派生 ⇒ 非编辑态自动跟随外部 modelValue,编辑态自动忽略外部变化,
天然实现「打字过程中不被覆盖」,无需额外 watch(modelValue)。
编辑态与错误态
beginEditing():editingText ??= displayText,并clearInvalid()resetEditing():只把editingText置 null(不清错误态,保证回退后的红框能被看到)markInvalid()/clearInvalid():invalid置真并起 1500ms 定时器自动清除;clearInvalid清定时器handleInput(e):只写editingText = e.target.value,绝不 emit,并clearInvalid()onBeforeUnmount清理invalidTimer
提交 / 回退
function submitInputText() {
if (editingText.value === null) return
const { date, isEmpty, isValid } = parseDateInput(editingText.value, props.format)
if (isEmpty) { if (committedValue.value) commit(null); return }
if (!isValid || !date) { markInvalid(); return } // 回退:不改 editingText
const normalized = normalizeDraft(date)
if (committedValue.value && compareDate(normalized, committedValue.value) === 0) return // 判等,避免重复 emit
commit(normalized)
}
blur 的三种走向(relatedTarget 用 closest 判断,Popover 浮层容器类名是 .popover,已确认)
- 焦点落入面板(
closest('.popover'))→ 让给handlePick/handleConfirm,只resetEditing()不提交 - 焦点落入清除按钮(
closest('.fms-date-clear'))→ 让给handleClear,只resetEditing()不提交 - 其余(含点外部、Tab 离开)→
submitInputText()+resetEditing()(保留错误态)
Safari 点击面板按钮不会让输入框失焦,因此
handlePick/handleConfirm/handleClear末尾也要显式resetEditing()+clearInvalid()。
handleTriggerKeydown 重写(替换 date-picker.vue#L104-L118)
if (key === 'Escape') { preventDefault(); resetEditing(); clearInvalid(); open.value = false; return }
if (key === 'Enter') { preventDefault(); submitInputText(); resetEditing(); open.value = false; return }
if (key === 'ArrowUp' || key === 'ArrowDown') {
if (event.metaKey || event.ctrlKey) return
if (event.altKey) { preventDefault(); openAndFocusPanel(); return } // 保留日历键盘可达性
stepSegment(event, key === 'ArrowUp' ? 1 : -1)
return
}
// 其余(含 Space、所有可打印字符)完全不拦截
Enter 必须 preventDefault(),阻止 <form> 隐式提交。
↑/↓ 微调
function stepSegment(event, delta) {
const el = event.currentTarget
const format = pickerConfig.value.primaryFormat
const text = editingText.value ?? displayText.value
const caret = typeof el?.selectionStart === 'number' ? el.selectionStart : text.length
const segment = resolveDateSegment(text, caret, format)
if (!segment) return // 光标不在任何段上:不拦截,走原生行为
event.preventDefault()
const parsed = parseDateInput(text, props.format)
const base = (parsed.isValid && parsed.date) || committedValue.value || normalizeDraft(new Date())
const next = stepDateSegment(base, segment.field, delta)
if (!next) return
const nextText = formatDate(normalizeDraft(next), format)
if (nextText === text) return
const target = listDateSegments(nextText, format).find((s) => s.field === segment.field)
const pos = target ? target.end : Math.min(caret, nextText.length)
editingText.value = nextText
clearInvalid()
if (open.value) draftValue.value = normalizeDraft(next) // 面板同步预览
nextTick(() => el?.setSelectionRange(pos, pos)) // 光标停在改动段末尾
}
用 nextTick 恢复光标,不依赖 Vue 的 DOM patch 细节(:value 变短会重置光标到末尾,nextTick 里再修正)。
watch 调整
watch(open)不动(只维护draftValue,与editingText解耦)watch(() => props.disabled)追加resetEditing()+clearInvalid()watch(pickerConfig)开头追加resetEditing()+clearInvalid()
模板
- 根节点 class 增加
'is-invalid': invalid - 输入框:去掉
readonly,保留type="text";加autocomplete="off"spellcheck="false":aria-invalid="invalid || undefined",:value="inputDisplayText"(受控 text,不用 v-model——v-model会把非法文本写回modelValue) - 加
@input="handleInput"@focus="beginEditing"@blur="handleBlur"@click.stop="handleInputClick" handleInputClick():beginEditing()+ 面板未开则open.value = true⇒ 「点输入框 = 打开面板 + 光标落位 + 可继续打字」,第二次点击只移动光标不关面板@click.stop不影响focusout(不同事件),form-item.vue#L157-L174的失焦校验链路不变- 外层
.fms-date-trigger的@click="toggle"保留(点外壳空白/后缀仍可开合面板)
3. src/components/ui/date/range-picker.vue
结构对称,差异点:
- 状态:
editingStart/editingEnd(各自null= 非编辑态)、invalidSide('' | 'start' | 'end')、 共享一个invalidTimer;invalid = computed(() => invalidSide.value !== '')⇒ 错误只标在出错的那一端(:aria-invalid分端判断),但红框画在共享外壳上 - 辅助函数
editorOf(side)/committedOf(side);beginEditing(side)/resetEditing(side)/submitSide(side)都以side为参数,避免两套重复代码 - 每个输入框独立绑定
@click.stop/@input/@focus/@blur/@keydown,都带side - 提交单端:
function submitSide(side) {
const editor = editorOf(side)
if (editor.value === null) return
const { date, isEmpty, isValid } = parseDateInput(editor.value, props.format)
let next = null
if (!isEmpty) {
if (!isValid || !date) { markInvalid(side); return } // 回退:不改 editor
next = normalizeDraft(date)
}
let start = side === 'start' ? next : committedStart.value
let end = side === 'end' ? next : committedEnd.value
if (start && end && compareDate(start, end) > 0) [start, end] = [end, start] // 与 orderDraftRange 同语义
if (sameDatePoint(start, committedStart.value) && sameDatePoint(end, committedEnd.value)) return
commit(start, end) // 一次 emit 完整字符串数组
if (open.value) { draftStart.value = start; draftEnd.value = end } // 面板高亮跟随
}
orderDraftRange()(range-picker.vue#L75-L81)保持原样专供面板 handlePick/handleConfirm 使用。
commit(start, end)里的toOutgoing(null)已返回'',因此「两端都清空」自然 emit['', ''], 与handleClear产出完全一致,hasValue变 false、清除按钮的v-if自然消失,无需额外特判handlePick/handleConfirm/handleClear末尾追加resetEditing('start')+resetEditing('end')+clearInvalid()handleStartChange/handleEndChange(面板时间选择回调)保持不动
Tab 链路契约:点开始框 → 输入 → Tab 到结束框
① 输入只写 editingStart,不 emit;② 开始框 blur,relatedTarget 是结束框 ⇒ 两个 defer 判断都为 false ⇒
submitSide('start') 提交 ['2026-09-26', ''] ⇒ resetEditing('start');
③ 结束框 focus ⇒ beginEditing('end') 预填已提交文本;④ 结束框 blur 时读已回流的 committedStart,排序后一次 emit。
注意不要把「焦点转移到同组件的兄弟输入框」误判为 defer,否则 Tab 链路不会提交。
4. src/components/ui/date/index.scss
.fms-date-input, .fms-range-input(#L38-L42)的cursor: pointer改cursor: text(外层.fms-date-trigger的cursor: pointer保留;禁用态#L111-L114权重更高,仍是not-allowed)- 错误态红框画在 shell 上(input 是透明内嵌、无边框),插在
#L36后:
.fms-date-picker.is-invalid .fms-date-trigger,
.fms-range-picker.is-invalid .fms-range-trigger {
border-color: var(--fms-danger);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-danger) 20%, transparent), var(--fms-shadow);
}
权重 (0,3,0) 高于 :focus-within 的 (0,2,0),编辑中的聚焦光环不会盖住错误提示。
--fms-danger 已存在于 src/theme/tokens.css(亮 #ef4444 / 暗 #f87171),无需新增变量。
- 加
::selection(本文件是脚本 import 的全局样式,非 scoped):输入框背景透明,默认选区色对比偏弱, 手打整串时选区可见性重要 - 不加
:focus-visible额外轮廓:control-field已outline: none,聚焦可见性由 shell 的:focus-within3px 光环承担,再加会双重描边 - 不动
.fms-range-input { text-align: center }(避免视觉回归,如需改左右对齐另行确认)
5. 测试
新建以下 spec(现有范式照抄 tests/components/select.spec.js:Popover 不 mock、内容 Teleport 到 body、
attachTo: document.body、document.body.querySelector('.popover-content') 取浮层、末尾 wrapper.unmount()):
tests/unit/date-utils.spec.js(纯函数,无需 DOM)
resolveDateSegment:2026-09-26/2026/09/26/2026年09月26日的段区间; 含时分的2026-09-26 14:30:00;hh:mm A下02:30 PM的 meridiem 段覆盖整段PM;Do的26th;MMM的Sep;YYYYMMDD相邻段相接时边界归左段; 文本与 format 不匹配 / 空文本返回null;caret越界被 clampstepDateSegment:day +1跨月、month +1月末夹取、year +1闰日夹取、hour/minute/second回绕且日期不变、hour 00 -1 → 23、meridiem ±1只翻转上下午、非法 field / 非法日期parseDateInput:空串/纯空白 →isEmpty;2026-09-26、2026/09/26、2026.09.26、2026年09月26日、2026-09、abc、2026-13-45、format数组多格式
tests/components/date-picker.spec.js(宿主用真 v-model,才能验证 emit 出字符串并回流)
- 输入框不再
readonly,type仍为text - 键入合法整串 + blur → emit
'2026-09-26' - Enter 提交并关闭面板
format='YYYY-MM-DD HH:mm'+valueFormat='YYYY-MM-DD HH:mm:ss',输入2026-09-26 14:30→ emit 秒级字符串'2026-09-26 14:30:00'- 非法输入 blur → 不 emit,文本回到已提交值,外壳
is-invalid+aria-invalid="true"(vi.useFakeTimers()+advanceTimersByTime(1500)断言自动清除) - 文本等同已提交值时 blur 不重复 emit(
emitted('update:modelValue')长度为 0) ArrowUp在年份段 →2027-09-26;ArrowDown在月份段/日段(跨月);时分秒段只改当前段且日期不变- 光标落在分隔符上按 ↑/↓ 不改文本
- Escape 还原并关面板(面板开 / 关两种)
- Space 作为普通字符输入,不打开面板
- 点击 trigger 打开面板且可继续输入;面板点选后输入框显示新值、错误态清空;清除按钮仍 emit
'' disabled下不进入错误态、keydown/input 均不改变状态format数组下任一写法都能提交
tests/components/range-picker.spec.js
- 两端都不再
readonly;只编辑开始端 →['2026-09-26', ''];只编辑结束端 →['', '2026-09-26'] - 两端成序:输入
start > end后 emit 为有序区间 - 开始端非法 → 不 emit,
aria-invalid只出现在开始端 - Tab 链路:开始端输入 → blur(
relatedTarget= 结束端)提交 → 结束端 focus 预填已提交文本 - 两端清空后 emit
['',''],清除按钮消失;Escape 只还原当前聚焦端 - 两端各自独立的 ↑/↓ 微调;datetime 端点提交出秒级字符串
- 回归:面板选值 + 确定仍提交数组
跑测试:npx vitest run tests/components/date-picker.spec.js(或 npm run test:file -- <path>),
最后跑一次 npm run test 确认无回归。
6. 验证(端到端)
npm run dev,打开/demo的 DatePicker 区块(src/components/ui/demo/date.vue),逐个验证:YYYY-MM-DD、YYYY/MM/DD、format数组、YYYY-MM-DD HH:mm+valueFormat秒级、RangePicker 两个例子- 手测键盘:整串输入、↑/↓ 微调各段、Enter 提交、Esc 还原、Alt+↓ 打开面板后方向键在日历中移动
- 手测业务路径:模块列表查询区(
FmsQueryControl)日期区间手打开始日期,确认结束端日历禁用范围立刻收窄; 编辑弹窗(FmsModuleEditModal)datetime 字段手打后提交,确认存的是YYYY-MM-DD HH:mm:ss - 手测表单:必填日期留空失焦仍报红(
form-item.vue的@focusout链路未变);日期框内 Enter 不触发整表提交 - 手测弹窗内日期框按 Esc 仍能关闭外层 Modal(我们不对 Escape 调
stopPropagation)
已知边界(写进代码注释,不修)
- 整串不匹配则全串无段:如 format
YYYY年MM月DD日而用户敲了2026-09-26,resolveDateSegment返回null⇒ ↑/↓ 走原生光标行为。Enter/blur 仍能提交(parseDateInput走宽松解析)。 同理缺失尾部字段(2026-09-26 14对带秒 format)也拿不到段;但2026-09-2、2026-09-26 14:3这类 「位数不足但字段齐全」的中间态可以正常匹配。 - 无字面量分隔的相邻段(
YYYYMMDD→20260926):段边界相接,按左优先归属,边界位归左段。 MMM/MMMM用[A-Za-z]+而非枚举月名,不使用 locale(英文匹配)。- 受控组件契约:父级必须监听
update:modelValue。若只传:model-value而不回写,提交后输入框会 回落到 props 旧值 —— 与现有handleClear的行为一致,属既有契约。 hour/minute/second采用回绕而非进位(见stepDateSegment)。2026-09-26 14:30:00提交到format='YYYY-MM-DD HH:mm'时秒会被normalizeDateForFormat抹掉, 这是既有归一化契约(面板选值同样如此);要保留秒须把format设为带秒格式。
Escape 与 Popover 全局 Escape
popover.vue#L293-L295 在打开期间于 document 上监听 keydown,按 Escape 直接 setOpen(false)。
结论:不需要 stopPropagation,两条路径天然收敛。 事件顺序:输入框 @keydown(target 阶段)先跑,
我们 preventDefault() + 还原文本 + open.value = false;事件继续冒泡到 document 时 Popover 再执行
setOpen(false),父级 open 本就是 false,结果幂等。面板未打开时 Popover 根本没注册监听。
即使未来 Popover 改成捕获阶段先执行,行为也不变。
不要对 Escape 调 stopPropagation():它会截断祖先链路(外层 Modal 的 Escape 关闭、表格编辑单元格的
Escape 取消),在日期框内按 Esc 会导致 Modal 关不掉,属净负收益。