feat(ui) : MalioDate/DateTime — event update:rawValue pour validation back-autoritative (#MUI-44)

Expose la saisie brute invalide sur un canal séparé (`@update:rawValue`),
sans la faire transiter par `modelValue` (qui reste ISO|null). Émis à chaque
commit : saisie invalide (non parsable ou hors min/max) → texte trimmé tel que
tapé ; saisie valide/vide, clear, sélection au calendrier → ''. Le parent
construit son payload via `valid ? modelValue : rawValue`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:19:19 +02:00
parent 86e8a84535
commit 6c78d511a8
6 changed files with 137 additions and 3 deletions
+54
View File
@@ -471,4 +471,58 @@ describe('MalioDate', () => {
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
})
describe('saisie brute (update:rawValue)', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet le texte brut trimmé sur saisie hors min/max', async () => {
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('25/12/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('19/05/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
})
it('émet rawValue vide sur saisie vidée au blur', async () => {
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide sur clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026'])
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
})
})
+9
View File
@@ -90,6 +90,10 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void
}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
@@ -108,25 +112,30 @@ const onCommit = (text: string) => {
const trimmed = text.trim()
if (trimmed === '') {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', null)
return
}
const iso = parseDisplayToIso(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso)
return
}
setError(props.invalidMessage)
emit('update:rawValue', trimmed)
}
const onClear = () => {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', null)
}
const onSelect = (iso: string, close: () => void) => {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso)
close()
}
@@ -283,4 +283,58 @@ describe('MalioDateTime', () => {
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
})
describe('saisie brute (update:rawValue)', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet le texte brut trimmé sur saisie hors min/max', async () => {
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('25/12/2026 10:00')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026 10:00'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('20/05/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
})
it('émet rawValue vide sur saisie vidée au blur', async () => {
const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide sur clear', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30'])
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
})
})
+10
View File
@@ -103,6 +103,10 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void
}>()
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
@@ -129,6 +133,7 @@ function onSelectDay(iso: string) {
const now = new Date()
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(iso, time))
}
@@ -136,6 +141,7 @@ function onTimeChange(value: string | null) {
if (!value) return
if (datePart.value) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
@@ -147,21 +153,25 @@ function onCommit(text: string) {
const trimmed = text.trim()
if (trimmed === '') {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', null)
return
}
const iso = parseDisplayToIsoDateTime(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso)
return
}
setError(props.invalidMessage)
emit('update:rawValue', trimmed)
}
function onClear() {
setError('')
pendingTime.value = ''
emit('update:rawValue', '')
emit('update:modelValue', null)
}