fix(natural-date-input): preserve input on edit, use reliable update event

Two reliability bugs in the natural-language date input:

1. Clicking Edit on a saved-date pill and changing the value immediately
   re-showed the saved value. clearAndEdit pre-fills the input with the
   formatted saved date so the admin doesn't start over, but chrono
   parses that string on the very first keystroke, re-sets parsedDate,
   and the auto-hide template flips the pill back. Added an isEditing
   flag that keeps the input visible across re-parses and clears on
   blur once we have a valid parse.

2. Typing "tomorrow at 2pm" sometimes committed "tomorrow at <current
   time>". UInput's template spreads \$attrs onto the inner <input>
   alongside its own @input="onInput", and Vue's listener-array merge
   intermittently drops the fall-through @input mid-typing — in the
   reproduction, the final 'm' never reached parseNaturalInput, so
   chrono's last successful read was "tomorrow at 2p" matching just
   "tomorrow". Switched to @update:model-value (a declared emit on
   UInput, so it goes through the reliable component-emit path) and
   made onBlur always re-parse the final value as a backup.
This commit is contained in:
Jennie Robinson Faber 2026-05-19 12:19:35 +01:00
parent 9e4030ccfd
commit 96470a604a

View file

@ -1,8 +1,8 @@
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<div v-if="!parsedDate || hasError" class="relative"> <div v-if="!parsedDate || hasError || isEditing" class="relative">
<UInput <UInput
v-model="naturalInput" :model-value="naturalInput"
:placeholder="placeholder" :placeholder="placeholder"
:color=" :color="
hasError && naturalInput.trim() hasError && naturalInput.trim()
@ -11,7 +11,7 @@
? 'success' ? 'success'
: undefined : undefined
" "
@input="parseNaturalInput" @update:model-value="onNaturalInputChange"
@blur="onBlur" @blur="onBlur"
> >
<template #trailing> <template #trailing>
@ -32,7 +32,7 @@
</div> </div>
<div <div
v-if="parsedDate && isValidParse" v-if="parsedDate && isValidParse && !isEditing"
class="text-sm px-3 py-2" class="text-sm px-3 py-2"
style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)" style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
@ -108,6 +108,7 @@ const isValidParse = ref(false);
const hasError = ref(false); const hasError = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const datetimeValue = ref(""); const datetimeValue = ref("");
const isEditing = ref(false);
// Initialize with current value // Initialize with current value
onMounted(() => { onMounted(() => {
@ -132,6 +133,7 @@ watch(
datetimeValue.value = formatForDatetimeLocal(date); datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true; isValidParse.value = true;
naturalInput.value = ""; // Clear natural input when set externally naturalInput.value = ""; // Clear natural input when set externally
isEditing.value = false;
} }
} else if (!newValue) { } else if (!newValue) {
reset(); reset();
@ -175,11 +177,23 @@ const parseNaturalInput = () => {
} }
}; };
// UInput's fall-through @input listener fires unreliably (the Nuxt UI Input
// template merges $attrs with an explicit @input and can drop keystrokes).
// update:model-value is a declared emit and fires for every change.
const onNaturalInputChange = (value) => {
naturalInput.value = value;
parseNaturalInput();
};
const onBlur = () => { const onBlur = () => {
// If we have a valid parse but the input changed, try to parse again // Always re-parse on blur so the final typed value wins, even if an
if (naturalInput.value.trim() && !isValidParse.value) { // intermediate state produced a stale parse.
if (naturalInput.value.trim()) {
parseNaturalInput(); parseNaturalInput();
} }
if (isValidParse.value) {
isEditing.value = false;
}
}; };
const onDatetimeChange = () => { const onDatetimeChange = () => {
@ -190,6 +204,7 @@ const onDatetimeChange = () => {
isValidParse.value = true; isValidParse.value = true;
hasError.value = false; hasError.value = false;
naturalInput.value = ""; // Clear natural input when using traditional picker naturalInput.value = ""; // Clear natural input when using traditional picker
isEditing.value = false;
emit("update:modelValue", datetimeValue.value); emit("update:modelValue", datetimeValue.value);
} }
} else { } else {
@ -227,6 +242,9 @@ const clearAndEdit = () => {
parsedDate.value = null; parsedDate.value = null;
isValidParse.value = false; isValidParse.value = false;
hasError.value = false; hasError.value = false;
// Prevent auto-hide while editing: chrono re-parses the pre-filled string
// on the first keystroke, which would otherwise hide the input.
isEditing.value = true;
}; };
const formatForDatetimeLocal = (date) => { const formatForDatetimeLocal = (date) => {