| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: matthieu <matthieu@yuno.malio.fr> Reviewed-on: #72 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #72.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ 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). Nouveau parseur `parseDisplayToIsoDateTime`.
|
||||||
|
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur.
|
||||||
|
* [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas).
|
||||||
|
|
||||||
### 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.
|
||||||
|
|||||||
+12
-4
@@ -505,7 +505,9 @@ Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
|||||||
|
|
||||||
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||||
|
|
||||||
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`). Un **gabarit fantôme** affiche le format `JJ/MM/AAAA` en gris et se remplit au fur et à mesure de la saisie (caractères tapés en noir, reste du gabarit en gris).
|
||||||
|
|
||||||
|
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 |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
@@ -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`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). 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" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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,122 @@ describe('MalioDate', () => {
|
|||||||
expect(wrapper.text()).toContain('Format incorrect')
|
expect(wrapper.text()).toContain('Format incorrect')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('gabarit de saisie (editable)', () => {
|
||||||
|
it('affiche le gabarit complet en gris quand editable + focus + vide', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||||
|
const ghost = wrapper.get('[data-test="format-ghost"]')
|
||||||
|
expect(ghost.text()).toBe('JJ/MM/AAAA')
|
||||||
|
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('19')
|
||||||
|
// eager : le séparateur se pose dès que le groupe est complet (« 19 » → « 19/ »)
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text()).toBe('19/MM/AAAA')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pose le séparateur automatiquement dès qu\'un groupe est complet (eager)', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('1905')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('19/05/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit quand editable mais vide et non focus', () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', 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((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,165 @@ 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('gabarit de saisie (editable)', () => {
|
||||||
|
it('affiche le gabarit date+heure complet en gris quand editable + focus + vide', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('JJ/MM/AAAA HH:MM')
|
||||||
|
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('190520')
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('19/05/20AA HH:MM')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/05/20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
placeholder-template="JJ/MM/AAAA HH:MM"
|
||||||
: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`
|
||||||
|
|||||||
@@ -29,6 +29,19 @@
|
|||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showGhost"
|
||||||
|
data-test="format-ghost"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute left-0 right-0 top-1/2 flex h-10 -translate-y-1/2 items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent pl-3 pr-10 text-lg"
|
||||||
|
><span
|
||||||
|
data-test="ghost-typed"
|
||||||
|
class="text-black"
|
||||||
|
>{{ ghostTyped }}</span><span
|
||||||
|
data-test="ghost-remaining"
|
||||||
|
class="text-m-muted"
|
||||||
|
>{{ ghostRemaining }}</span></div>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
@@ -44,7 +57,7 @@
|
|||||||
data-test="clear"
|
data-test="clear"
|
||||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||||
aria-label="Effacer la date"
|
aria-label="Effacer la date"
|
||||||
@click.stop="emit('clear')"
|
@click.stop="onClearClick"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:close"
|
icon="mdi:close"
|
||||||
@@ -137,6 +150,7 @@ const props = withDefaults(
|
|||||||
success?: string
|
success?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
placeholderTemplate?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -155,6 +169,7 @@ const props = withDefaults(
|
|||||||
success: '',
|
success: '',
|
||||||
clearable: true,
|
clearable: true,
|
||||||
editable: false,
|
editable: false,
|
||||||
|
placeholderTemplate: 'JJ/MM/AAAA',
|
||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
@@ -172,9 +187,22 @@ 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}))
|
// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés).
|
||||||
|
// eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet.
|
||||||
|
const maskaOptions = computed<MaskInputOptions>(() => ({
|
||||||
|
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined,
|
||||||
|
eager: props.editable,
|
||||||
|
}))
|
||||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||||
|
|
||||||
|
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
|
||||||
|
// par-dessus l'input (dont le texte est rendu transparent en mode editable).
|
||||||
|
// Espaces → insécables : un espace en bord de span (flex-item) serait sinon rogné,
|
||||||
|
// collant la suite du gabarit à la date (« 12/12/1999HH:MM »).
|
||||||
|
const nbsp = (s: string) => s.replace(/ /g, ' ')
|
||||||
|
const ghostTyped = computed(() => nbsp(draft.value))
|
||||||
|
const ghostRemaining = computed(() => nbsp(props.placeholderTemplate.slice(draft.value.length)))
|
||||||
|
|
||||||
watch(() => props.displayValue, (value) => {
|
watch(() => props.displayValue, (value) => {
|
||||||
draft.value = value
|
draft.value = value
|
||||||
})
|
})
|
||||||
@@ -189,6 +217,7 @@ const isFilled = computed(() =>
|
|||||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||||
)
|
)
|
||||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
|
||||||
const showClear = computed(() =>
|
const showClear = computed(() =>
|
||||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||||
)
|
)
|
||||||
@@ -229,6 +258,13 @@ const onInput = (event: Event) => {
|
|||||||
draft.value = (event.target as HTMLInputElement).value
|
draft.value = (event.target as HTMLInputElement).value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset local immédiat : sur saisie invalide, modelValue est déjà null, donc le
|
||||||
|
// watch(displayValue) ne se redéclenche pas — il faut vider le draft soi-même.
|
||||||
|
const onClearClick = () => {
|
||||||
|
draft.value = ''
|
||||||
|
emit('clear')
|
||||||
|
}
|
||||||
|
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
if (!props.editable) return
|
if (!props.editable) return
|
||||||
emit('commit', draft.value)
|
emit('commit', draft.value)
|
||||||
@@ -294,6 +330,8 @@ const mergedInputClass = computed(() =>
|
|||||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||||
|
// En mode editable, le texte réel est masqué : c'est le gabarit fantôme qui l'affiche.
|
||||||
|
props.editable ? 'text-transparent caret-black' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
const checkboxInputs = wrapper.findAll('input[type="checkbox"]')
|
// Le toggle se fait au clic sur la ligne d'option (la checkbox est en pointer-events-none).
|
||||||
await checkboxInputs[1].setValue(true)
|
const optionRows = wrapper.findAll('li[role="option"]')
|
||||||
|
await optionRows[1].trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
|
||||||
})
|
})
|
||||||
@@ -149,8 +150,9 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
// La ligne « tout sélectionner » est la première option de la liste.
|
||||||
await checkboxes[0].setValue(true)
|
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
|
||||||
|
await selectAllRow.trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
|
||||||
})
|
})
|
||||||
@@ -162,8 +164,9 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
// La ligne « tout sélectionner » est la première option de la liste.
|
||||||
await checkboxes[0].setValue(false)
|
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
|
||||||
|
await selectAllRow.trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,5 +6,13 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['app/**/*.test.ts'],
|
include: ['app/**/*.test.ts'],
|
||||||
|
// La suite de composants (jsdom + focus/popover/async) est sujette à des
|
||||||
|
// échecs intermittents sous charge : timeouts par contention CPU, et quelques
|
||||||
|
// assertions de timing qui se déclenchent avant stabilisation du DOM.
|
||||||
|
// testTimeout élargi : absorbe la contention (12 workers jsdom concurrents).
|
||||||
|
// retry : rejoue les flaky de timing diffus (ne masque PAS un échec déterministe,
|
||||||
|
// qui rate ses 3 tentatives).
|
||||||
|
testTimeout: 15000,
|
||||||
|
retry: 2,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user