feat(ui) : MalioDate/DateTime exposent update:valid + saisie clavier DateTime (#MUI-43)

- MalioDate : event update:valid (malforme/hors-plage => false), emis au montage
- MalioDateTime : prop editable (saisie JJ/MM/AAAA HH:MM) + meme update:valid
- CalendarField : masque maska configurable via prop mask
- datetimeFormat : nouveau parseur parseDisplayToIsoDateTime
- fix test Date « Entree » (key 'Enter' reel vs trigger keydown.enter)
- doc COMPONENTS.md + CHANGELOG.md + champ editable dans le playground

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:03:04 +02:00
parent 23a9729dcd
commit fee894e895
10 changed files with 350 additions and 18 deletions
@@ -13,6 +13,20 @@
<div class="rounded border p-3 text-sm"> <div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p> <p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</div> </div>
<MalioDateTime
v-model="editableValue"
label="Date et heure (saisie clavier)"
editable
hint="Tape JJ/MM/AAAA HH:MM ou utilise le calendrier"
@update:valid="editableValid = $event"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur éditable (ISO naïf) : <code>{{ editableValue ?? 'null' }}</code></p>
<p>
Saisie valide :
<code :class="editableValid ? 'text-m-success' : 'text-m-danger'">{{ editableValid }}</code>
</p>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
type="button" type="button"
@@ -65,4 +79,6 @@ const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null) const value = ref<string | null>(null)
const erpValue = ref<string | null>(null) const erpValue = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00') const bounded = ref<string | null>('2026-05-20T14:30:00')
const editableValue = ref<string | null>(null)
const editableValid = ref(true)
</script> </script>
+2
View File
@@ -50,6 +50,8 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche * [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace) * [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap * [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
* [#MUI-43] MalioDate : event `update:valid` (booléen) exposant l'état de validité de la saisie (`false` sur date malformée ou hors `min`/`max`, qui n'émet pas `modelValue`) — permet au parent de bloquer le submit ; la validité ne couvre pas `required` (champ vide = valide)
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). CalendarField : masque maska configurable via prop `mask` (défaut `##/##/####` inchangé). Nouveau parseur `parseDisplayToIsoDateTime`.
### Changed ### Changed
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés. * DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
+11 -3
View File
@@ -507,6 +507,8 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) | | `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
@@ -528,7 +530,7 @@ Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. | | `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | | `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)` **Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`
**Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_ **Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_
@@ -537,6 +539,7 @@ Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'
<!-- date === "2026-05-20" --> <!-- date === "2026-05-20" -->
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" /> <MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
<MalioDate v-model="date" label="Date de naissance" editable /> <MalioDate v-model="date" label="Date de naissance" editable />
<MalioDate v-model="date" label="Date de naissance" editable @update:valid="dateValide = $event" />
``` ```
--- ---
@@ -683,19 +686,24 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
| `hint` | `string` | `''` | Texte d'aide | | `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) | | `min` | `string` | `undefined` | Borne min. Borne la grille sur la partie date ; en saisie `editable`, comparée au **datetime complet** (préférer une borne datetime, sinon les heures du jour `max` seraient rejetées). |
| `max` | `string` | `undefined` | Borne max (idem) | | `max` | `string` | `undefined` | Borne max (idem) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement | | `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA HH:MM` (masque maska, validation au blur / sur Entrée) en plus du calendrier |
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. | | `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | | `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)` **Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction. Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`.
```vue ```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" /> <MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" --> <!-- rdv === "2026-05-20T14:30:00" -->
<MalioDateTime v-model="rdv" label="Rendez-vous" editable @update:valid="rdvValide = $event" />
``` ```
--- ---
+72 -1
View File
@@ -338,7 +338,9 @@ describe('MalioDate', () => {
await input.trigger('focus') await input.trigger('focus')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
await input.setValue('19/05/2026') await input.setValue('19/05/2026')
await input.trigger('keydown.enter') // Valeur DOM réelle de la touche Entrée ('Enter') ; `trigger('keydown.enter')`
// produirait `key: 'enter'`, qui ne matche pas le handler manuel `e.key === 'Enter'`.
await input.trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19']) expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
}) })
@@ -351,4 +353,73 @@ describe('MalioDate', () => {
expect(wrapper.text()).toContain('Format incorrect') expect(wrapper.text()).toContain('Format incorrect')
}) })
}) })
describe('état de validité (update:valid)', () => {
it('émet valid=true au montage avec une valeur valide', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true au montage quand le champ est vide', () => {
const wrapper = mountDate()
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true 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:valid')?.at(-1)).toEqual([true])
})
it('émet valid=false 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:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet valid=false 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:valid')?.at(-1)).toEqual([false])
})
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
const wrapper = mountDate({editable: true, required: true, modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('émet valid=true sur clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', 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:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-19'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
})
}) })
+20 -8
View File
@@ -87,44 +87,56 @@ const props = withDefaults(
}, },
) )
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>() const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void
}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null)) const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
const internalError = ref('') const internalError = ref('')
const mergedError = computed(() => props.error || internalError.value) const mergedError = computed(() => props.error || internalError.value)
// La validité ne reflète que la saisie : malformée/hors plage → false. Un champ
// vide est valide (l'obligation `required` reste à la charge du parent).
const setError = (message: string) => {
internalError.value = message
emit('update:valid', message === '')
}
const onCommit = (text: string) => { const onCommit = (text: string) => {
const trimmed = text.trim() const trimmed = text.trim()
if (trimmed === '') { if (trimmed === '') {
internalError.value = '' setError('')
emit('update:modelValue', null) emit('update:modelValue', null)
return return
} }
const iso = parseDisplayToIso(trimmed) const iso = parseDisplayToIso(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) { if (iso && isDateInRange(iso, props.min, props.max)) {
internalError.value = '' setError('')
emit('update:modelValue', iso) emit('update:modelValue', iso)
return return
} }
internalError.value = props.invalidMessage setError(props.invalidMessage)
} }
const onClear = () => { const onClear = () => {
internalError.value = '' setError('')
emit('update:modelValue', null) emit('update:modelValue', null)
} }
const onSelect = (iso: string, close: () => void) => { const onSelect = (iso: string, close: () => void) => {
internalError.value = '' setError('')
emit('update:modelValue', iso) emit('update:modelValue', iso)
close() close()
} }
// immediate : émet aussi la validité au montage, pour que le parent connaisse
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
internalError.value = '' setError('')
if (val && !isValidIso(val) && import.meta.dev) { if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`) console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
} }
}) }, {immediate: true})
</script> </script>
+139
View File
@@ -19,6 +19,8 @@ type DateTimeProps = {
min?: string min?: string
max?: string max?: string
clearable?: boolean clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
@@ -120,4 +122,141 @@ describe('MalioDateTime', () => {
expect(wrapper.text()).toContain('Date requise') expect(wrapper.text()).toContain('Date requise')
}) })
}) })
describe('saisie manuelle (editable)', () => {
it('par défaut (editable=false) l\'input reste readonly', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeDefined()
})
it('editable=true : l\'input n\'est plus readonly', () => {
const wrapper = mountDateTime({editable: true})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
})
it('émet le datetime ISO sur saisie clavier valide au blur', 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:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
})
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', 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:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026 14:30')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
it('passe en erreur si le datetime saisi est 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:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('Date invalide')
})
it('émet null 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:modelValue')?.at(-1)).toEqual([null])
})
it('valide et ferme le popover sur Entrée', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.trigger('focus')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
await input.setValue('20/05/2026 14:30')
await input.trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999 10:00')
await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect')
})
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', 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.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.text()).not.toContain('Date invalide')
})
})
describe('état de validité (update:valid)', () => {
it('émet valid=true au montage avec une valeur valide', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true au montage quand le champ est vide', () => {
const wrapper = mountDateTime()
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true 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:valid')?.at(-1)).toEqual([true])
})
it('émet valid=false 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:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
const wrapper = mountDateTime({editable: true, required: 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:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true sur clear', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', 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:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
})
}) })
+47 -4
View File
@@ -10,14 +10,17 @@
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:hint="hint" :hint="hint"
:error="error" :error="mergedError"
:success="success" :success="success"
:clearable="clearable" :clearable="clearable"
:editable="editable"
mask="##/##/#### ##:##"
:input-class="inputClass" :input-class="inputClass"
:label-class="labelClass" :label-class="labelClass"
:group-class="groupClass" :group-class="groupClass"
v-bind="$attrs" v-bind="$attrs"
@clear="onClear" @clear="onClear"
@commit="onCommit"
> >
<template #default="{ currentMonth, currentYear }"> <template #default="{ currentMonth, currentYear }">
<MonthGrid <MonthGrid
@@ -47,7 +50,8 @@ import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue' import MonthGrid from './internal/MonthGrid.vue'
import MalioTimePicker from '../time/TimePicker.vue' import MalioTimePicker from '../time/TimePicker.vue'
import {formatTime} from '../time/composables/timeFormat' import {formatTime} from '../time/composables/timeFormat'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat' import {isDateInRange} from './composables/dateFormat'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, parseDisplayToIsoDateTime, splitDateTime} from './composables/datetimeFormat'
defineOptions({name: 'MalioDateTime', inheritAttrs: false}) defineOptions({name: 'MalioDateTime', inheritAttrs: false})
@@ -67,6 +71,8 @@ const props = withDefaults(
min?: string min?: string
max?: string max?: string
clearable?: boolean clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
@@ -86,13 +92,18 @@ const props = withDefaults(
min: undefined, min: undefined,
max: undefined, max: undefined,
clearable: true, clearable: true,
editable: false,
invalidMessage: 'Date invalide',
inputClass: '', inputClass: '',
labelClass: '', labelClass: '',
groupClass: '', groupClass: '',
}, },
) )
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>() const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void
}>()
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre). // pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('') const pendingTime = ref('')
@@ -102,17 +113,29 @@ const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null)) const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value) const timeValue = computed(() => parts.value.time || pendingTime.value)
const internalError = ref('')
const mergedError = computed(() => props.error || internalError.value)
// La validité ne reflète que la saisie clavier : malformée/hors plage → false. Un
// champ vide est valide (l'obligation `required` reste à la charge du parent).
const setError = (message: string) => {
internalError.value = message
emit('update:valid', message === '')
}
function onSelectDay(iso: string) { function onSelectDay(iso: string) {
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00). // Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
// (heure courante au moment du clic) // (heure courante au moment du clic)
const now = new Date() const now = new Date()
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes()) const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
setError('')
emit('update:modelValue', composeDateTime(iso, time)) emit('update:modelValue', composeDateTime(iso, time))
} }
function onTimeChange(value: string | null) { function onTimeChange(value: string | null) {
if (!value) return if (!value) return
if (datePart.value) { if (datePart.value) {
setError('')
emit('update:modelValue', composeDateTime(datePart.value, value)) emit('update:modelValue', composeDateTime(datePart.value, value))
} }
else { else {
@@ -120,14 +143,34 @@ function onTimeChange(value: string | null) {
} }
} }
function onCommit(text: string) {
const trimmed = text.trim()
if (trimmed === '') {
setError('')
emit('update:modelValue', null)
return
}
const iso = parseDisplayToIsoDateTime(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) {
setError('')
emit('update:modelValue', iso)
return
}
setError(props.invalidMessage)
}
function onClear() { function onClear() {
setError('')
pendingTime.value = '' pendingTime.value = ''
emit('update:modelValue', null) emit('update:modelValue', null)
} }
// immediate : émet aussi la validité au montage, pour que le parent connaisse
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
setError('')
if (val && !isValidIsoDateTime(val) && import.meta.dev) { if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`) console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
} }
}) }, {immediate: true})
</script> </script>
@@ -3,6 +3,7 @@ import {
composeDateTime, composeDateTime,
formatIsoDateTimeToDisplay, formatIsoDateTimeToDisplay,
isValidIsoDateTime, isValidIsoDateTime,
parseDisplayToIsoDateTime,
splitDateTime, splitDateTime,
} from './datetimeFormat' } from './datetimeFormat'
@@ -49,6 +50,34 @@ describe('datetimeFormat', () => {
}) })
}) })
describe('parseDisplayToIsoDateTime', () => {
it('parse un JJ/MM/AAAA HH:MM valide en datetime ISO', () => {
expect(parseDisplayToIsoDateTime('20/05/2026 14:30')).toBe('2026-05-20T14:30:00')
expect(parseDisplayToIsoDateTime('01/01/2026 00:00')).toBe('2026-01-01T00:00:00')
expect(parseDisplayToIsoDateTime('31/12/2026 23:59')).toBe('2026-12-31T23:59:00')
})
it('tolère les espaces autour', () => {
expect(parseDisplayToIsoDateTime(' 20/05/2026 14:30 ')).toBe('2026-05-20T14:30:00')
})
it('rejette une date malformée', () => {
expect(parseDisplayToIsoDateTime('32/01/2026 10:00')).toBeNull()
expect(parseDisplayToIsoDateTime('10/13/2026 10:00')).toBeNull()
})
it('rejette une heure hors bornes', () => {
expect(parseDisplayToIsoDateTime('20/05/2026 24:00')).toBeNull()
expect(parseDisplayToIsoDateTime('20/05/2026 12:60')).toBeNull()
})
it('rejette un format incomplet ou sans heure', () => {
expect(parseDisplayToIsoDateTime('20/05/2026')).toBeNull()
expect(parseDisplayToIsoDateTime('20/05/2026 14')).toBeNull()
expect(parseDisplayToIsoDateTime('')).toBeNull()
})
})
describe('composeDateTime', () => { describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => { it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00') expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
@@ -1,4 +1,4 @@
import {isValidIso} from './dateFormat' import {isValidIso, parseDisplayToIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/ const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
@@ -27,6 +27,16 @@ export function splitDateTime(s: string | null): {date: string | null; time: str
return {date, time: time.slice(0, 5)} return {date, time: time.slice(0, 5)}
} }
export function parseDisplayToIsoDateTime(display: string): string | null {
const match = /^(\d{2}\/\d{2}\/\d{4}) (\d{2}):(\d{2})$/.exec(display.trim())
if (!match) return null
const [, datePart, hh, mm] = match
const iso = parseDisplayToIso(datePart)
if (!iso) return null
if (Number(hh) > 23 || Number(mm) > 59) return null
return `${iso}T${hh}:${mm}:00`
}
export function composeDateTime(date: string, time: string): string { export function composeDateTime(date: string, time: string): string {
const t = time || '00:00' const t = time || '00:00'
return `${date}T${t}:00` return `${date}T${t}:00`
@@ -137,6 +137,7 @@ const props = withDefaults(
success?: string success?: string
clearable?: boolean clearable?: boolean
editable?: boolean editable?: boolean
mask?: string
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
@@ -155,6 +156,7 @@ const props = withDefaults(
success: '', success: '',
clearable: true, clearable: true,
editable: false, editable: false,
mask: '##/##/####',
inputClass: '', inputClass: '',
labelClass: '', labelClass: '',
groupClass: '', groupClass: '',
@@ -172,7 +174,7 @@ const generatedId = useId()
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const draft = ref(props.displayValue) const draft = ref(props.displayValue)
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined})) const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? props.mask : undefined}))
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled) const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
watch(() => props.displayValue, (value) => { watch(() => props.displayValue, (value) => {