Compare commits

..

6 Commits

Author SHA1 Message Date
tristan b6fcd3c186 fix: component style (#80)
Release / release (push) Successful in 1m56s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] 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: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-19 13:22:12 +00:00
tristan e664731cb8 fix: sidebar style (#78)
Release / release (push) Successful in 1m24s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] 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: #78
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 14:47:56 +00:00
tristan 244d62dc71 fix: malio date (#77)
Release / release (push) Successful in 1m9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] 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: #77
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 09:42:25 +00:00
tristan 29bd6abcfe fix: Date + DateTime new emit update rawValue (#75)
Release / release (push) Successful in 1m8s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] 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: #75
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-12 07:40:13 +00:00
tristan 90ed4a213f fix: MalioDate + MalioDateTime (#72)
Release / release (push) Successful in 1m10s
| 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>
2026-06-11 15:46:43 +00:00
tristan 9f772a84ed fix: accessibilité des composants (#70)
Release / release (push) Successful in 1m9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] 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: #70
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 15:40:44 +00:00
67 changed files with 4839 additions and 201 deletions
+45 -1
View File
@@ -13,6 +13,15 @@
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
</div>
<MalioDate
v-model="editableValue"
label="Date (saisie clavier)"
editable
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
@@ -69,11 +78,29 @@
/>
</div>
</div>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">markedDates + @month-change</h2>
<MalioDate
v-model="markedValue"
label="Calendrier avec statuts par jour"
hint="Jours verts = validés, rouges = à corriger"
:marked-dates="markedDates"
@month-change="onMonthChange"
/>
<div class="rounded border p-3 text-sm">
<p>Mois affiché : <code>{{ shownMonth }}</code></p>
<p class="mt-1 text-m-success"> success : {{ successDays.join(', ') }}</p>
<p class="text-m-danger"> danger : {{ dangerDays.join(', ') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {computed, ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
@@ -85,4 +112,21 @@ const readonlyFilledDate = ref<string | null>('2026-06-15')
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
const editableValue = ref<string | null>(null)
// Démo markedDates : quelques jours du mois courant marqués success / danger.
const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
const successDays = [`${ym}-05`, `${ym}-06`, `${ym}-12`]
const dangerDays = [`${ym}-09`, `${ym}-20`]
const markedDates = computed<Record<string, 'success' | 'danger'>>(() => ({
...Object.fromEntries(successDays.map(d => [d, 'success' as const])),
...Object.fromEntries(dangerDays.map(d => [d, 'danger' as const])),
}))
const markedValue = ref<string | null>(null)
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const shownMonth = ref('—')
const onMonthChange = ({month, year}: {month: number, year: number}) => {
shownMonth.value = `${monthsLong[month]} ${year} (month=${month})`
}
</script>
@@ -13,6 +13,20 @@
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</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">
<button
type="button"
@@ -65,4 +79,6 @@ const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00')
const editableValue = ref<string | null>(null)
const editableValid = ref(true)
</script>
@@ -0,0 +1,294 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">Champs désactivés (disabled)</h1>
<p class="text-sm text-m-muted">
Tous les champs de formulaire dans leur état <code>disabled</code>, vides puis remplis.
Règles : texte + label grisés, <code>cursor-not-allowed</code>, et <strong>aucune affordance
interactive</strong> (pas de bouton « + », pas de croix « x », pas de chevron, pas d'œil ;
tags et valeurs grisés).
</p>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
<div class="space-y-4">
<MalioInputText
label="Référence (vide)"
:disabled="true"
/>
<MalioInputText
model-value="Commande #A-2048"
label="Référence (rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputEmail (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputEmail
label="Adresse email (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPhone (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputPhone
label="Téléphone (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPassword (pas d'œil)</h2>
<div class="space-y-4">
<MalioInputPassword
label="Mot de passe (vide)"
:disabled="true"
/>
<MalioInputPassword
model-value="motdepasse123"
label="Mot de passe (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
<div class="space-y-4">
<MalioInputAmount
label="Montant (vide)"
:disabled="true"
/>
<MalioInputAmount
model-value="1250.00"
label="Montant (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputNumber</h2>
<div class="space-y-4">
<MalioInputNumber
label="Quantité (vide)"
:disabled="true"
/>
<MalioInputNumber
model-value="42"
label="Quantité (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
<div class="space-y-4">
<MalioInputTextArea
label="Description (vide)"
:size="3"
:disabled="true"
/>
<MalioInputTextArea
model-value="Ce texte est désactivé et ne peut pas être modifié."
label="Description (rempli)"
:size="3"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputUpload (pas de croix)</h2>
<div class="space-y-4">
<MalioInputUpload
label="Fichier (vide)"
:disabled="true"
/>
<MalioInputUpload
model-value="document.pdf"
label="Fichier (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete (pas de chevron)</h2>
<div class="space-y-4">
<MalioInputAutocomplete
label="Pays (vide)"
:options="countryOptions"
:disabled="true"
/>
<MalioInputAutocomplete
model-value="de"
label="Pays (rempli)"
:options="countryOptions"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelect (pas de chevron)</h2>
<div class="space-y-4">
<MalioSelect
label="Catégorie (vide)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
<MalioSelect
:model-value="'a'"
label="Catégorie (rempli)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox version tags (grisés)</h2>
<div class="space-y-4">
<MalioSelectCheckbox
label="Catégories (vide)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
<MalioSelectCheckbox
:model-value="['a', 'b', 'c']"
label="Catégories (rempli)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDate (pas de croix)</h2>
<div class="space-y-4">
<MalioDate
label="Date de naissance (vide)"
:disabled="true"
/>
<MalioDate
model-value="2026-06-15"
label="Date de naissance (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
<div class="space-y-4">
<MalioDateTime
label="Date et heure (vide)"
:disabled="true"
/>
<MalioDateTime
model-value="2026-12-25T09:30:00"
label="Date et heure (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
<div class="space-y-4">
<MalioDateRange
label="Période (vide)"
:disabled="true"
/>
<MalioDateRange
:model-value="rangeValue"
label="Période (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
<div class="space-y-4">
<MalioDateWeek
label="Semaine (vide)"
:disabled="true"
/>
<MalioDateWeek
model-value="2026-W52"
label="Semaine (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioTimePicker (pas de croix)</h2>
<div class="space-y-4">
<MalioTimePicker
label="Heure (vide)"
:disabled="true"
/>
<MalioTimePicker
model-value="14:30"
label="Heure (rempli)"
:disabled="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const countryOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const categoryOptions: Option[] = [
{label: 'Catégorie A', value: 'a'},
{label: 'Catégorie B', value: 'b'},
{label: 'Catégorie C', value: 'c'},
]
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
</script>
@@ -14,6 +14,17 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<div class="mt-2 rounded border p-3 text-sm">
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAmount
@@ -77,4 +88,5 @@
import { ref } from 'vue'
const readonlyFilledAmount = ref('1250.00')
const bigValue = ref('1234567.89')
</script>
@@ -14,6 +14,20 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
<div class="space-y-3">
<MalioInputEmail
v-for="(email, index) in emails"
:key="index"
v-model="emails[index]"
label="Adresse email"
addable
@add="emails.push('')"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
<MalioInputEmail
@@ -127,6 +141,7 @@ import { computed, ref } from 'vue'
const readonlyFilledEmail = ref('contact@malio.fr')
const emailValue = ref('')
const emails = ref<string[]>([''])
const dynamicEmail = ref('')
const requiredEmail = ref('')
const lowercaseEmail = ref('')
@@ -14,6 +14,17 @@
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Clearable (croix pour vider)</h2>
<MalioInputUpload
v-model="clearableUpload"
label="Téléverser un document"
clearable
@clear="onClearUpload"
/>
<p class="mt-2 text-sm text-gray-500">Valeur : {{ clearableUpload || '(aucun)' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
<MalioInputUpload
@@ -94,6 +105,11 @@ import { computed, ref } from 'vue'
const readonlyFilledUpload = ref('document.pdf')
const uploadValue = ref('')
const dynamicUpload = ref('')
const clearableUpload = ref('rapport-2026.pdf')
const onClearUpload = () => {
clearableUpload.value = ''
}
const dynamicError = computed(() => {
if (!dynamicUpload.value) return ''
@@ -13,7 +13,7 @@
<MalioSelectCheckbox
v-model="labelValue"
:options="options"
displayTag="true"
:display-tag="true"
empty-option-label=" "
/>
</div>
@@ -22,7 +22,7 @@
<MalioSelectCheckbox
v-model="labelValue1"
:options="options"
displayTag="true"
:display-tag="true"
label="Pays"
empty-option-label=" "
/>
+89 -1
View File
@@ -1,5 +1,65 @@
<template>
<div class="grid grid-cols-1 items-start gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-1 text-xl font-bold">Bac à sable (tous les cas)</h2>
<p class="mb-4 text-sm text-m-muted">
Règle les paramètres puis <strong>redimensionne le cadre en pointillés</strong>
(poignée en bas à droite) pour voir le nombre d'onglets s'adapter et les flèches apparaître.
</p>
<div class="mb-4 flex flex-wrap items-end gap-4 text-sm">
<label class="flex flex-col gap-1">Nb onglets : {{ sbCount }}
<input
v-model.number="sbCount"
type="range"
min="1"
max="15"
class="w-40"
>
</label>
<label class="flex flex-col gap-1">maxVisibleTabs (0 = auto)
<input
v-model.number="sbMax"
type="number"
min="0"
max="15"
class="w-20 rounded border px-2 py-1"
>
</label>
<label class="flex items-center gap-2">
<input
v-model="sbIcons"
type="checkbox"
> Icônes
</label>
<label class="flex items-center gap-2">
<input
v-model="sbLong"
type="checkbox"
> Labels longs
</label>
</div>
<div
class="resize-x overflow-hidden rounded border-2 border-dashed border-m-muted p-3"
style="width: 100%; min-width: 280px;"
>
<MalioTabList
v-model="sbValue"
:tabs="sbTabs"
:max-visible-tabs="sbMaxProp"
>
<template
v-for="t in sbTabs"
#[t.key]
:key="t.key"
>
<p class="p-4">Contenu : {{ t.label }}</p>
</template>
</MalioTabList>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTabList v-model="simpleValue" :tabs="tabs">
@@ -70,7 +130,35 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
// --- Bac à sable interactif ---
const sbCount = ref(9)
const sbMax = ref(0)
const sbIcons = ref(true)
const sbLong = ref(false)
const SB_LABELS = [
'Informations', 'Adresses', 'Contacts', 'Comptabilité', 'Documents',
'Historique', 'Paramètres', 'Qualité', 'Facturation', 'Accueil',
'Notifications', 'Statistiques', 'Équipe', 'Sécurité', 'Étiquettes',
]
const SB_ICONS = [
'mdi:information-outline', 'mdi:map-marker-outline', 'mdi:account-box-outline', 'mdi:web',
'mdi:file-document-outline', 'mdi:history', 'mdi:cog-outline', 'mdi:check-decagram-outline',
'mdi:receipt-text-outline', 'mdi:home', 'mdi:bell-outline', 'mdi:chart-bar',
'mdi:account-group-outline', 'mdi:lock-outline', 'mdi:tag-outline',
]
const sbTabs = computed(() =>
Array.from({ length: sbCount.value }, (_, i) => ({
key: `sb${i}`,
label: sbLong.value ? `${SB_LABELS[i % SB_LABELS.length]} détaillé` : SB_LABELS[i % SB_LABELS.length],
icon: sbIcons.value ? SB_ICONS[i % SB_ICONS.length] : undefined,
})),
)
const sbMaxProp = computed(() => (sbMax.value > 0 ? sbMax.value : undefined))
const sbValue = ref('sb0')
const tabs = [
{ key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' },
+1
View File
@@ -70,6 +70,7 @@ export const navSections: SidebarSection[] = [
icon: 'mdi:dots-horizontal',
items: [
{label: 'Champs readonly', to: '/composant/divers/readonly'},
{label: 'Champs disabled', to: '/composant/divers/disabled'},
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
+26
View File
@@ -41,14 +41,37 @@ Liste des évolutions de la librairie Malio layer UI
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
* [#MUI-42] Anneau de focus clavier standardisé (`outline` 2px `m-primary`, offset 2px) affiché **uniquement** à la navigation clavier (jamais au clic souris), sur l'ensemble des champs et contrôles : inputs (Text, Email, Password, Phone, Amount, Number + boutons ±, Upload, TextArea, Autocomplete), Select, SelectCheckbox, famille Date (Date, DateRange, DateTime, DateWeek), Button, ButtonIcon. Mécanique : composable `useKbdFocusRing` (détection de modalité clavier/souris) + utilitaires CSS `.m-focus-ring` (éléments à `:focus-visible` natif) et `.m-focus-ring-kbd` (champs texte, où `:focus-visible` se déclenche aussi à la souris)
* [#MUI-42] Anneau « combo » : quand un dropdown / calendrier est ouvert (Autocomplete, Select, SelectCheckbox, Date), l'anneau entoure le champ **et** la liste / le calendrier d'un seul tenant, adapté au sens d'ouverture (utilitaires `.m-combo-ring-top` / `.m-combo-ring-bottom`)
* [#MUI-42] Navigation clavier WAI-ARIA APG sur les listes déroulantes : Select et SelectCheckbox gagnent la navigation (flèches, Home/End, Entrée/Espace, Échap, Tab — absente jusque-là), avec scroll automatique de l'option active et `aria-activedescendant` ; InputAutocomplete complété (scroll auto, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme)
* [#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] 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).
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
### Changed
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
* TabList : le nombre d'onglets visibles en mode fenêtré s'**adapte automatiquement à la largeur réelle** (mesure via `ResizeObserver` + ligne de mesure cachée), au lieu d'un `maxVisibleTabs` fixe qui pouvait faire déborder les onglets sur les chevrons. Les chevrons restent fixés aux bords et le nombre affiché est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). `maxVisibleTabs` devient un **plafond optionnel**. Calcul isolé dans une fonction pure testable (`tabFit.ts`, basée sur les largeurs réelles des onglets). Sans layout (SSR), repli sur le plafond / tous les onglets. **Breaking** : la prop `maxWidth` est supprimée (la barre utilise désormais toute la largeur disponible au lieu d'être plafonnée à 1100px).
* TabList : au **survol** d'un onglet inactif, on applique désormais le même style que l'onglet actif — texte `m-primary` plein + barre soulignée `m-primary` (`hover:after:*`) — au lieu du discret `text-m-primary/70`, pour bien marquer la cible.
* Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `<li>` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `<li>` et le `<a>` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du `<a>`, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`).
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
### Fixed
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir).
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
@@ -61,3 +84,6 @@ Liste des évolutions de la librairie Malio layer UI
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
* [#MUI-42] RadioButton : ajout d'un focus visible au clavier (`outline` 2px `m-primary`, offset 2px) — l'input en `appearance-none` n'avait aucun indicateur de focus, seul l'`outline: auto 1px` du navigateur restait, quasi invisible. La navigation native (Tab entre groupes, flèches dans le groupe) reste inchangée
* [#MUI-42] Checkbox : ajout d'un focus visible au clavier sur la case (`outline` 2px `m-primary`, offset 2px) — l'input réel est masqué (`clip-path`), aucun indicateur n'apparaissait à la tabulation
* [#MUI-42] Select : le focus reste sur le bouton après sélection (un `blur()` renvoyait le focus au `body`, cassant la tabulation clavier — un Tab repartait du haut de page)
+65 -9
View File
@@ -4,6 +4,8 @@ Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-mo
> **Champ obligatoire :** sur les composants de formulaire, la prop `required` ajoute un astérisque rouge dans le label. C'est un repère visuel ; la sémantique « obligatoire » est portée par l'attribut natif `required` ou `aria-required`.
> **Focus clavier :** tous les champs et contrôles affichent un anneau de focus (`outline` 2px `m-primary`, offset 2px) **uniquement** à la navigation clavier (Tab), jamais au clic souris. Sur les composants à dropdown/calendrier ouverts, l'anneau entoure le champ et la liste d'un seul tenant. Voir la note « Clavier » de chaque composant pour la navigation détaillée.
---
## MalioInputText
@@ -94,19 +96,25 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
| `iconSize` | `string \| number` | `24` | Taille icône |
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
**Events :** `update:modelValue(value: string)`
**Events :**
- `update:modelValue(value: string)`
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
```vue
<MalioInputEmail v-model="email" label="Adresse email" />
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
```
---
@@ -194,7 +202,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
**Clavier (WAI-ARIA APG) :** `↓` ouvre / option suivante, `↑` précédente (ou ouvre sur la dernière option si fermé), `Début`/`Fin`, scroll automatique de l'option active, `Entrée` sélection (ou création), `Échap` annule, `Tab` ferme. Anneau de focus clavier (combo champ + liste à l'ouverture).
```vue
<!-- Usage statique (filtrage côté client via local-filter) -->
@@ -236,6 +244,8 @@ async function onSearchClients(query: string) {
Champ montant avec icône devise (euro par défaut).
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
@@ -251,6 +261,8 @@ Champ montant avec icône devise (euro par défaut).
```vue
<MalioInputAmount v-model="montant" label="Montant TTC" />
<MalioInputAmount v-model="prix" label="Prix" error="Montant invalide" />
<MalioInputAmount v-model="gros" label="Budget" />
<!-- saisie 1234567.89 affiché "1 234 567,89", modelValue "1234567.89" -->
```
---
@@ -352,16 +364,20 @@ Champ d'upload de fichier.
| `label` | `string` | `''` | Label |
| `accept` | `string` | `''` | Types de fichiers acceptés |
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
| `clearable` | `boolean` | `false` | Affiche une croix (`mdi:close`) focusable qui vide le champ quand un fichier est sélectionné |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `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. |
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`, `clear()`
**Clavier :** `Entrée` / `Espace` ouvrent le sélecteur de fichier. La croix `clearable` est focusable (anneau clavier, `Entrée`/`Espace`).
```vue
<MalioInputUpload v-model="fileName" label="Document" accept=".pdf,.doc" @file-selected="onFile" />
<MalioInputUpload v-model="fileName" label="Document" clearable @clear="onClear" />
```
---
@@ -394,6 +410,8 @@ Liste déroulante.
**Events :** `update:modelValue(value: string | number | null)`
**Slots :** `icon` (icône dropdown custom)
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto de l'option active), `Début`/`Fin`, `Entrée`/`Espace` sélectionnent, `Échap`/`Tab` ferment. Le focus reste sur le bouton après sélection. Anneau de focus clavier (combo bouton + liste à l'ouverture, adapté au sens haut/bas).
```vue
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
@@ -422,6 +440,8 @@ Liste déroulante multi-sélection avec checkboxes.
**Events :** `update:modelValue(value: (string | number)[])`
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto), `Début`/`Fin`, `Entrée`/`Espace` cochent/décochent l'option active (la liste **reste ouverte**), `Échap`/`Tab` ferment. La ligne « Tout sélectionner » est navigable au clavier. Le clic sur toute la ligne (pas que le label) coche/décoche. Anneau de focus clavier (combo bouton + liste à l'ouverture).
```vue
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
@@ -445,6 +465,8 @@ Case à cocher.
**Events :** `update:modelValue(value: boolean)`
**Clavier :** `Espace` coche/décoche. Focus clavier visible sur la case (`outline` 2px `m-primary`).
```vue
<MalioCheckbox v-model="accepte" label="J'accepte les conditions" />
<MalioCheckbox v-model="newsletter" label="Newsletter" disabled />
@@ -468,6 +490,8 @@ Bouton radio (à utiliser en groupe avec le même `name`).
**Events :** `update:modelValue(value: string | number | boolean | null)`
**Clavier :** comportement natif d'un groupe radio (options partageant le même `name`) — `Tab` / `Maj+Tab` entre/sort du groupe (1 seul arrêt par groupe), `↑↓←→` déplacent la sélection entre les options d'un même groupe. Focus clavier visible (`outline` 2px `m-primary`).
```vue
<MalioRadioButton v-model="civilite" name="civilite" value="M" label="Monsieur" />
<MalioRadioButton v-model="civilite" name="civilite" value="Mme" label="Madame" />
@@ -481,6 +505,16 @@ 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.
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La saisie est **bornée par champ** (1er *et* 2e chiffre) : jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`, si bien qu'une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut pas être tapée. Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, dépassement `min`/`max`) restent captées par la validation, en filet de sécurité. 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.
L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour les formulaires en validation back-autoritative (le serveur tranche le format et renvoie un `422`). Il est émis à chaque commit : saisie invalide (non parsable ou hors `min`/`max`) → la chaîne trimmée telle que tapée (ex. `"32/13/2026"`) ; saisie valide ou vide, clear, sélection au calendrier → `''`. Le parent construit alors son payload via `valid ? modelValue : rawValue`. La saisie invalide **ne transite jamais** par `modelValue` (qui reste `string` ISO `| null` pour l'affichage et le round-trip) ; `valid` dit *qu'il y a* une erreur, `rawValue` dit *quoi* envoyer.
La prop `markedDates` permet d'afficher un **statut par jour** dans la grille : un objet `{ "YYYY-MM-DD": "success" | "danger" }` applique un fond tokenisé (`success` → vert clair, `danger` → rouge clair). C'est **purement générique** — aucune logique métier dans le layer : le consommateur fournit la liste des jours à marquer. **Précédence** : un jour sélectionné garde son style primary (fond plein, prime sur la variante marquée) ; le jour courant (`today`) **garde sa bordure** et reçoit **en plus** le fond marqué s'il est dans `markedDates` (vert/rouge bordé) ; sinon, fond marqué simple.
L'event `month-change` remonte le **mois affiché** dans le popover (`{ month: number /* 0-11 */, year: number }`). Il est émis **à l'ouverture** du popover (sur le mois de la valeur, ou le mois courant) **et à chaque navigation** (chevrons, sélection dans la vue mois). Couplé à `markedDates`, il permet à un consommateur (ex. l'écran *Heures* de SIRH) de charger les statuts du mois visible à la volée : on écoute `@month-change` pour fetch, puis on réinjecte le résultat dans `:marked-dates`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
@@ -496,16 +530,33 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
| `markedDates` | `Record<string, 'success' \| 'danger'>` | `undefined` | Statut par jour : ISO `"YYYY-MM-DD"` → fond tokenisé. Générique (fourni par le consommateur). |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) 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. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)`, `month-change(value: { month: number /* 0-11 */, year: number })`
**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.)_
```vue
<MalioDate v-model="date" label="Date de naissance" />
<!-- date === "2026-05-20" -->
<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 @update:valid="dateValide = $event" />
<!-- Validation back-autoritative : on envoie la saisie brute si invalide -->
<MalioDate v-model="date" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
<!-- payload : valide ? date : brut -->
<!-- Statut par jour + chargement du mois visible (ex. SIRH « Heures ») -->
<MalioDate
v-model="date"
:marked-dates="statutsDuMois"
@month-change="({ month, year }) => chargerStatuts(month, year)"
/>
<!-- statutsDuMois === { "2026-05-05": "success", "2026-05-20": "danger" } -->
```
---
@@ -652,19 +703,25 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `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) |
| `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. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)`
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`. L'event `update:rawValue` expose la saisie brute pour la validation back-autoritative (mêmes règles que `MalioDate` : texte trimmé sur saisie invalide, `''` sinon — clear et sélection au calendrier compris).
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
<MalioDateTime v-model="rdv" label="Rendez-vous" editable @update:valid="rdvValide = $event" />
<MalioDateTime v-model="rdv" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
```
---
@@ -729,10 +786,9 @@ Navigation par onglets avec contenu dynamique.
|------|------|--------|-------------|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
| `maxVisibleTabs` | `number` | `undefined` | Nombre max d'onglets affichés à la fois. Au-delà, un carrousel avec flèches gauche/droite apparaît (décalage 1 par 1). Non défini = tous les onglets. |
| `maxWidth` | `number` | `1100` | Largeur max (px) du bloc d'onglets en mode fenêtré. |
| `maxVisibleTabs` | `number` | `undefined` | **Plafond** optionnel du nombre d'onglets visibles. Non défini = uniquement limité par la largeur. |
Quand `maxVisibleTabs` est défini et que le nombre d'onglets le dépasse, la barre passe en mode fenêtré : seuls `maxVisibleTabs` onglets sont visibles à la fois, encadrés par des flèches gauche/droite qui font défiler la fenêtre un onglet à la fois (largeur du bloc bornée par `maxWidth`).
Le nombre d'onglets affichés s'**adapte automatiquement à la largeur disponible** (mesurée au runtime via `ResizeObserver`). Quand tous les onglets ne tiennent pas, la barre passe en mode fenêtré : les flèches gauche/droite (fixées aux bords) font défiler la fenêtre un onglet à la fois, et le nombre visible est choisi pour que les onglets tiennent (jamais de chevauchement ni de rognage). `maxVisibleTabs`, s'il est fourni, plafonne ce nombre.
Type `Tab` :
+35
View File
@@ -2,6 +2,41 @@
@tailwind components;
@tailwind utilities;
@layer components {
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
Deux déclencheurs, même rendu :
- .m-focus-ring s'appuie sur :focus-visible natif. Pour les éléments
:focus-visible se limite déjà au clavier (boutons,
onglets, tuiles, checkbox/radio).
- .m-focus-ring-kbd classe ajoutée en JS (via useKbdFocusRing) uniquement
quand le focus vient du clavier. Pour les champs texte,
:focus-visible natif se déclenche aussi à la souris.
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
`outline-none` des inputs. */
.m-focus-ring:focus-visible,
.m-focus-ring-kbd:focus {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
reste sans contour pour un raccord sans couture. */
.m-combo-ring-top {
box-shadow:
-2px 0 0 0 rgb(var(--m-primary) / 1),
2px 0 0 0 rgb(var(--m-primary) / 1),
0 -2px 0 0 rgb(var(--m-primary) / 1);
}
.m-combo-ring-bottom {
box-shadow:
-2px 0 0 0 rgb(var(--m-primary) / 1),
2px 0 0 0 rgb(var(--m-primary) / 1),
0 2px 0 0 rgb(var(--m-primary) / 1);
}
}
@layer base {
:root {
/* ── Globales ── */
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() =>
twMerge(
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
variantClasses.value,
props.buttonClass,
),
+1 -1
View File
@@ -52,7 +52,7 @@ const isFilled = computed(() => props.variant === 'filled')
const mergedButtonClass = computed(() =>
twMerge(
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
isFilled.value
? props.disabled
? 'bg-m-disabled text-white cursor-not-allowed'
@@ -180,6 +180,11 @@ const onChange = (event: Event) => {
border-color: rgb(0, 0, 0);
}
.inp-cbx:focus-visible + .cbx span:first-child {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
.cbx span:first-child svg {
position: absolute;
top: 2px;
+2 -2
View File
@@ -81,7 +81,7 @@
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
<MalioButton
variant="tertiary"
label="Prev"
label="Préc."
:disabled="page <= 1"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
@@ -112,7 +112,7 @@
<MalioButton
variant="tertiary"
label="Next"
label="Suiv."
:disabled="page >= totalPages"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
+363
View File
@@ -17,7 +17,10 @@ type DateProps = {
success?: string
min?: string
max?: string
markedDates?: Record<string, 'success' | 'danger'>
clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string
labelClass?: string
groupClass?: string
@@ -70,6 +73,18 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on calendar icon click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on calendar icon click in editable mode', async () => {
const wrapper = mountDate({editable: true})
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on the current month when there is no value', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
@@ -107,6 +122,48 @@ describe('MalioDate', () => {
})
})
describe('month-change', () => {
it('émet month-change à l\'ouverture avec le mois courant', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 4, year: 2026}])
})
it('émet month-change sur le mois de la valeur à l\'ouverture', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 11, year: 2025}])
})
it('émet month-change à chaque navigation de mois', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 5, year: 2026}])
await wrapper.get('[data-test="header-prev"]').trigger('click')
await wrapper.get('[data-test="header-prev"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 3, year: 2026}])
})
it('ne ré-émet pas month-change après fermeture', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
const countOpen = wrapper.emitted('month-change')?.length ?? 0
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('month-change')?.length ?? 0).toBe(countOpen)
})
})
describe('markedDates', () => {
it('transmet markedDates à la grille (fond tokenisé)', async () => {
const wrapper = mountDate({markedDates: {'2026-05-20': 'success'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
const pill = wrapper.get('[data-iso="2026-05-20"]').get('span.rounded-full')
expect(pill.classes()).toContain('bg-m-success/15')
})
})
describe('sélection', () => {
it('emits the ISO date and closes on day click', async () => {
const wrapper = mountDate()
@@ -181,6 +238,23 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('disabled : label grisé', () => {
const wrapper = mountDate({disabled: true, label: 'Date'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('disabled : pas de croix d\'effacement même avec une valeur', () => {
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('disabled + rempli : icône calendrier grisée (pas noire)', () => {
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
const icon = wrapper.get('[data-test="calendar-icon"]')
expect(icon.classes()).toContain('text-m-muted')
expect(icon.classes()).not.toContain('text-black')
})
it('does not open when readonly', async () => {
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
await wrapper.get('[data-test="date-input"]').trigger('click')
@@ -258,4 +332,293 @@ describe('MalioDate', () => {
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
describe('saisie manuelle (editable)', () => {
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-19'})
expect(wrapper.text()).not.toContain('Date invalide')
})
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('readonly')).toBeDefined()
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
})
it('editable=true : l\'input n\'est plus readonly', () => {
const wrapper = mountDate({editable: true})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
})
it('émet l\'ISO sur saisie clavier valide au blur', 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:modelValue')?.at(-1)).toEqual(['2026-05-19'])
})
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
it('passe en erreur si la date saisie est 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:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('Date invalide')
})
it('émet null 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:modelValue')?.at(-1)).toEqual([null])
})
it('efface l\'erreur de saisie 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('31/02/2026')
await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.text()).not.toContain('Date invalide')
})
it('valide et ferme le popover sur Entrée', async () => {
const wrapper = mountDate({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('19/05/2026')
// 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.find('[data-test="popover"]').exists()).toBe(false)
})
it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]')
// 31/02/2026 : champs valides (jour ≤ 31, mois ≤ 12) mais le 31 février n'existe pas.
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect')
})
it('empêche la frappe d\'une date absurde (99/99/9999 borné par le masque)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999')
await input.trigger('blur')
// Le bornage refuse « 9 » dès le 1er chiffre du jour/mois : la saisie absurde
// ne s'inscrit jamais et aucune date réelle n'est émise.
expect((input.element as HTMLInputElement).value).not.toContain('99')
const emitted = wrapper.emitted('update:modelValue') ?? []
expect(emitted.every(([value]) => value === null)).toBe(true)
})
it('empêche un jour > 31 ou un mois > 12 (exemple métier 33/19, 2e chiffre borné)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
// 33 (jour) : le 2e « 3 » est refusé → seul « 3 » subsiste.
await input.setValue('33')
expect((input.element as HTMLInputElement).value).toBe('3')
// 19 en mois : après un jour valide, le 2e chiffre du mois (« 9 ») est refusé.
await input.setValue('15/19')
expect((input.element as HTMLInputElement).value).not.toContain('19')
})
})
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('31/02/2026')
await input.trigger('blur')
expect((input.element as HTMLInputElement).value).toBe('31/02/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('31/02/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('31/02/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])
})
})
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('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/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('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/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([''])
})
})
})
+73 -7
View File
@@ -10,33 +10,37 @@
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:error="mergedError"
:success="success"
:clearable="clearable"
:editable="editable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="emit('update:modelValue', null)"
@clear="onClear"
@commit="onCommit"
@month-change="(payload) => emit('month-change', payload)"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:marked-dates="markedDates"
:min="min"
:max="max"
@select="(iso) => { emit('update:modelValue', iso); close() }"
@select="(iso) => onSelect(iso, close)"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, watch} from 'vue'
import {computed, ref, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false})
@@ -55,7 +59,12 @@ const props = withDefaults(
success?: string
min?: string
max?: string
// Statut générique par jour, ISO yyyy-mm-dd variante de fond. Aucune
// logique métier dans le layer : le consommateur fournit la liste.
markedDates?: Record<string, 'success' | 'danger'>
clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string
labelClass?: string
groupClass?: string
@@ -74,20 +83,77 @@ const props = withDefaults(
success: '',
min: undefined,
max: undefined,
markedDates: undefined,
clearable: true,
editable: false,
invalidMessage: 'Date invalide',
inputClass: '',
labelClass: '',
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
// 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
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
(e: 'month-change', value: {month: number, year: number}): void
}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
const internalError = ref('')
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 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()
}
// 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) => {
setError('')
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
})
}, {immediate: true})
</script>
+231
View File
@@ -19,6 +19,8 @@ type DateTimeProps = {
min?: string
max?: string
clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string
labelClass?: string
groupClass?: string
@@ -120,4 +122,233 @@ describe('MalioDateTime', () => {
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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('31/02/2026 14:30')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
it('empêche la frappe d\'un datetime absurde (99/99/9999 99:99 borné par le masque)', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999 99:99')
await input.trigger('blur')
// Le masque borne le 1er chiffre de chaque champ (jour 0-3, mois 0-1,
// heure 0-2, minute 0-5) : « 9 » est rejeté partout, rien ne s'inscrit
// et aucun datetime réel n'est émis.
expect((input.element as HTMLInputElement).value).not.toContain('99')
const emitted = wrapper.emitted('update:modelValue') ?? []
expect(emitted.every(([value]) => value === null)).toBe(true)
})
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"]')
// 31/02 : champs valides mais date inexistante (le masque la laisse passer, la validation la rejette).
await input.setValue('31/02/2026 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('31/02/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('31/02/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('31/02/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])
})
})
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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/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([''])
})
})
})
+57 -4
View File
@@ -10,14 +10,17 @@
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:error="mergedError"
:success="success"
:clearable="clearable"
:editable="editable"
placeholder-template="JJ/MM/AAAA HH:MM"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@commit="onCommit"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
@@ -47,7 +50,8 @@ import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import MalioTimePicker from '../time/TimePicker.vue'
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})
@@ -67,6 +71,8 @@ const props = withDefaults(
min?: string
max?: string
clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string
labelClass?: string
groupClass?: string
@@ -86,13 +92,22 @@ const props = withDefaults(
min: undefined,
max: undefined,
clearable: true,
editable: false,
invalidMessage: 'Date invalide',
inputClass: '',
labelClass: '',
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
// 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).
const pendingTime = ref('')
@@ -102,17 +117,31 @@ const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
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) {
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
// (heure courante au moment du clic)
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))
}
function onTimeChange(value: string | null) {
if (!value) return
if (datePart.value) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
@@ -120,14 +149,38 @@ function onTimeChange(value: string | null) {
}
}
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)
}
// 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) => {
setError('')
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
})
}, {immediate: true})
</script>
@@ -3,6 +3,7 @@ import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
parseDisplayToIsoDateTime,
splitDateTime,
} 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', () => {
it('recompose un datetime ISO avec secondes à 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})$/
@@ -27,6 +27,16 @@ export function splitDateTime(s: string | null): {date: string | null; time: str
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 {
const t = time || '00:00'
return `${date}T${t}:00`
@@ -0,0 +1,44 @@
import {describe, it, expect} from 'vitest'
import {buildBoundedMask} from './maskTemplate'
describe('buildBoundedMask', () => {
it('dérive le masque structurel du gabarit (séparateurs conservés)', () => {
expect(buildBoundedMask('JJ/MM/AAAA').mask).toBe('##/##/####')
expect(buildBoundedMask('JJ/MM/AAAA HH:MM').mask).toBe('##/##/#### ##:##')
})
})
describe('preProcess — bornage de la saisie (1er ET 2e chiffre)', () => {
const pre = (template: string, value: string) => buildBoundedMask(template).preProcess!(value)
it('jour : refuse > 31 et 00, accepte 01-31', () => {
expect(pre('JJ/MM/AAAA', '32')).toBe('3') // 32 impossible → 2e chiffre refusé
expect(pre('JJ/MM/AAAA', '33')).toBe('3') // exemple métier : 33 refusé
expect(pre('JJ/MM/AAAA', '31')).toBe('31')
expect(pre('JJ/MM/AAAA', '00')).toBe('0') // 00 impossible
expect(pre('JJ/MM/AAAA', '09')).toBe('09')
})
it('mois : refuse > 12 et 00 (après un jour valide) — cas 33/19', () => {
expect(pre('JJ/MM/AAAA', '0119')).toBe('011') // 19 (mois) refusé
expect(pre('JJ/MM/AAAA', '0113')).toBe('011')
expect(pre('JJ/MM/AAAA', '0112')).toBe('0112')
expect(pre('JJ/MM/AAAA', '0100')).toBe('010')
})
it('laisse lannée libre', () => {
expect(pre('JJ/MM/AAAA', '01012026')).toBe('01012026')
})
it('heure 00-23 et minute 00-59 (datetime), sans confondre minute et mois', () => {
const t = 'JJ/MM/AAAA HH:MM'
expect(pre(t, '010120262300')).toBe('010120262300') // 23:00 ok
expect(pre(t, '010120262460')).toBe('010120262') // heure 24 refusée
expect(pre(t, '010120261259')).toBe('010120261259') // minute 59 ok (≠ mois)
expect(pre(t, '010120261260')).toBe('0101202612') // minute 60 refusée
})
it('stoppe à la première saisie invalide (99/99/9999 → rien)', () => {
expect(pre('JJ/MM/AAAA', '99/99/9999')).toBe('')
})
})
@@ -0,0 +1,91 @@
import type {MaskInputOptions} from 'maska'
// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée.
interface Field {
length: number
min: number
max: number
}
// Découpe un gabarit (ex. `JJ/MM/AAAA HH:MM`) en champs numériques bornés.
// Le `M` désigne le mois avant les heures, et les minutes après — d'où le suivi
// de `seenHour`, pour ne pas borner les minutes comme un mois (0-59 vs 1-12).
function parseFields(template: string): Field[] {
const fields: Field[] = []
let seenHour = false
let i = 0
while (i < template.length) {
const ch = template[i]!
if (!/[A-Za-z]/.test(ch)) {
i++ // séparateur (/, espace, :)
continue
}
let j = i
while (j < template.length && template[j] === ch) j++
const length = j - i
const letter = ch.toUpperCase()
if (letter === 'H') seenHour = true
if (letter === 'J') fields.push({length, min: 1, max: 31})
else if (letter === 'M') fields.push(seenHour ? {length, min: 0, max: 59} : {length, min: 1, max: 12})
else if (letter === 'H') fields.push({length, min: 0, max: 23})
else fields.push({length, min: 0, max: 10 ** length - 1}) // année (ou autre) : libre
i = j
}
return fields
}
// Un chiffre est accepté tant qu'il existe encore une complétion valide du champ :
// on borne la valeur partielle [min possible (padding 0), max possible (padding 9)]
// et on vérifie qu'elle croise la plage autorisée [field.min, field.max].
function canComplete(partial: string, field: Field): boolean {
const low = Number(partial.padEnd(field.length, '0'))
const high = Number(partial.padEnd(field.length, '9'))
return high >= field.min && low <= field.max
}
// Ne conserve que les chiffres qui gardent chaque champ complétable, et s'arrête
// au premier chiffre invalide (rien de ce qui suit n'est réinterprété). maska
// réinsère ensuite les séparateurs via le masque structurel.
function clampDigits(rawDigits: string, fields: Field[]): string {
let result = ''
let di = 0
for (const field of fields) {
let fieldDigits = ''
while (fieldDigits.length < field.length) {
if (di >= rawDigits.length) return result + fieldDigits // plus de saisie
const candidate = fieldDigits + rawDigits[di]
if (!canComplete(candidate, field)) return result + fieldDigits // 1er chiffre invalide → stop
fieldDigits = candidate
di++
}
result += fieldDigits
}
return result
}
/**
* Construit les options maska d'un champ date/heure à partir d'un gabarit
* d'affichage (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`).
*
* - `mask` : masque structurel (chiffres + séparateurs), pour le formatage/eager.
* - `preProcess` : borne la saisie AVANT masquage, sur le 1er **et** le 2e chiffre
* de chaque champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59), si bien
* qu'une valeur impossible (99/99/9999, 33, 19 en mois) ne peut pas être tapée.
* Les impossibilités calendaires fines (31/02, 29/02 non bissextile) et les
* bornes `min`/`max` restent du ressort de la validation, en filet.
*/
export function buildBoundedMask(template: string): Pick<MaskInputOptions, 'mask' | 'preProcess'> {
const mask = template.replace(/[A-Za-z]/g, '#')
const fields = parseFields(template)
return {
mask,
preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields),
}
}
@@ -6,14 +6,15 @@
>
<input
:id="inputId"
v-maska="maskaOptions"
:name="name"
data-test="date-input"
readonly
:readonly="inputReadonly"
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:value="editable ? draft : displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
@@ -22,8 +23,25 @@
placeholder="_"
type="text"
@click="onFieldClick"
@focus="onFocus(); onKbdFocus()"
@input="onInput"
@blur="onBlur(); onKbdBlur()"
@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
v-if="label"
:for="inputId"
@@ -37,9 +55,9 @@
v-if="showClear"
type="button"
data-test="clear"
class="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"
@click.stop="emit('clear')"
@click.stop="onClearClick"
>
<Icon
icon="mdi:close"
@@ -52,7 +70,8 @@
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
:class="[iconStateClass, (disabled || readonly) ? 'cursor-not-allowed' : 'cursor-pointer']"
@click="onFieldClick"
/>
</div>
@@ -61,6 +80,7 @@
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
>
<CalendarHeader
:view-mode="viewMode"
@@ -102,14 +122,20 @@
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {vMaska} from 'maska/vue'
import type {MaskInputOptions} from 'maska'
import MalioRequiredMark from '../../shared/RequiredMark.vue'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
import {buildBoundedMask} from '../composables/maskTemplate'
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
displayValue: string
@@ -125,6 +151,8 @@ const props = withDefaults(
error?: string
success?: string
clearable?: boolean
editable?: boolean
placeholderTemplate?: string
inputClass?: string
labelClass?: string
groupClass?: string
@@ -142,6 +170,8 @@ const props = withDefaults(
error: '',
success: '',
clearable: true,
editable: false,
placeholderTemplate: 'JJ/MM/AAAA',
inputClass: '',
labelClass: '',
groupClass: '',
@@ -149,20 +179,53 @@ const props = withDefaults(
},
)
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
const emit = defineEmits<{
(e: 'clear' | 'close'): void
(e: 'commit', value: string): void
// Mois affiché (month 0-11) : émis à l'ouverture du popover et à chaque
// navigation, pour qu'un consommateur (ex. SIRH) charge les données du mois.
(e: 'month-change', value: {month: number, year: number}): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const draft = ref(props.displayValue)
// Le masque maska est dérivé du gabarit : masque structurel pour le formatage,
// + preProcess qui borne la saisie (1er ET 2e chiffre : jour 1-31, mois 1-12,
// heure 0-23, minute 0-59) afin qu'une valeur impossible (99/99/9999, 33, mois 19)
// ne puisse pas être tapée. eager : pose les séparateurs dès qu'un groupe est complet.
const maskaOptions = computed<MaskInputOptions>(() => {
if (!props.editable) return {eager: false}
const {mask, preProcess} = buildBoundedMask(props.placeholderTemplate)
return {mask, preProcess, eager: true}
})
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) => {
draft.value = value
})
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => props.displayValue.length > 0)
const isFilled = computed(() =>
(props.editable ? draft.value.length : props.displayValue.length) > 0,
)
const isReadonly = computed(() => props.readonly && !props.disabled)
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
@@ -174,8 +237,21 @@ watch(isOpen, (value) => {
if (!value) emit('close')
})
// Émet le mois affiché tant que le popover est ouvert : une fois à l'ouverture
// (isOpen true, après syncToIso), puis à chaque changement de mois/année.
watch([isOpen, currentMonth, currentYear], () => {
if (isOpen.value) emit('month-change', {month: currentMonth.value, year: currentYear.value})
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (props.editable) {
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
return
}
if (isOpen.value) {
closePopover()
return
@@ -184,6 +260,63 @@ const onFieldClick = () => {
open()
}
const onFocus = () => {
if (props.disabled || props.readonly || !props.editable) return
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
}
const onInput = (event: Event) => {
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 = () => {
if (!props.editable) return
emit('commit', draft.value)
}
const onEnter = () => {
if (!props.editable) return
emit('commit', draft.value)
closePopover()
}
const onKeydown = (e: KeyboardEvent) => {
if (props.disabled || props.readonly) return
if (e.key === 'Escape') {
if (isOpen.value) {
e.preventDefault()
closePopover()
}
return
}
if (props.editable) {
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
if (e.key === 'Enter') {
e.preventDefault()
onEnter()
}
return
}
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onFieldClick()
}
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
@@ -210,6 +343,9 @@ const mergedInputClass = computed(() =>
? 'border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
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,
),
)
@@ -222,11 +358,13 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
: props.disabled
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
@@ -234,6 +372,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
@@ -0,0 +1,72 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import MonthGrid from './MonthGrid.vue'
type MonthGridProps = {
month: number
year: number
selectedDate?: string | null
markedDates?: Record<string, 'success' | 'danger'>
min?: string
max?: string
}
const Grid = MonthGrid as DefineComponent<MonthGridProps>
const mountGrid = (props: MonthGridProps) => mount(Grid, {props, attachTo: document.body})
// Récupère la pastille (span rond) qui porte les classes de `cellClass` pour un jour donné.
const pill = (wrapper: ReturnType<typeof mountGrid>, iso: string) =>
wrapper.get(`[data-iso="${iso}"]`).get('span.rounded-full')
describe('MalioDateMonthGrid — markedDates', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('applique un fond success sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
expect(pill(wrapper, '2026-05-20').classes()).toContain('bg-m-success/15')
})
it('applique un fond danger sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-21': 'danger'}})
expect(pill(wrapper, '2026-05-21').classes()).toContain('bg-m-danger/15')
})
it('ne marque pas les jours absents de markedDates', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).not.toContain('bg-m-success/15')
expect(classes).not.toContain('bg-m-danger/15')
})
it('précédence : la sélection (primary) prime sur la variante marquée', () => {
const wrapper = mountGrid({
month: 4,
year: 2026,
selectedDate: '2026-05-22',
markedDates: {'2026-05-22': 'success'},
})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).toContain('bg-m-primary')
expect(classes).toContain('text-white')
expect(classes).not.toContain('bg-m-success/15')
})
it('today marqué : garde sa bordure ET reçoit le fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-19': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).toContain('bg-m-success/15')
})
it('today non marqué : bordure sans fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).not.toContain('bg-m-success/15')
})
})
@@ -84,6 +84,14 @@ import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composable
defineOptions({name: 'MalioDateMonthGrid'})
// Statut générique par jour : aucune sémantique métier dans le layer, juste un
// fond tokenisé. `success` et `danger` suffisent pour l'instant (MUI-45).
type MarkedVariant = 'success' | 'danger'
const markedBg: Record<MarkedVariant, string> = {
success: 'bg-m-success/15',
danger: 'bg-m-danger/15',
}
const props = withDefaults(
defineProps<{
month: number
@@ -94,6 +102,7 @@ const props = withDefaults(
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
markedDates?: Record<string, MarkedVariant>
min?: string
max?: string
}>(),
@@ -104,6 +113,7 @@ const props = withDefaults(
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
markedDates: undefined,
min: undefined,
max: undefined,
},
@@ -165,6 +175,10 @@ const cellClass = (cell: DayCell) => {
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10']
// Précédence : sélection/range (primary, return ci-dessus) > variante marquée > défaut.
// `today` n'est pas exclusif : il garde sa bordure ET peut recevoir le fond marqué.
const marked = props.markedDates?.[cell.isoDate]
if (marked) parts.push(markedBg[marked])
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]')
+52 -4
View File
@@ -97,7 +97,7 @@ describe('MalioInputAmount', () => {
await wrapper.get('input').setValue('12.5')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
expect(wrapper.get('input').element.value).toBe('12.5')
expect(wrapper.get('input').element.value).toBe('12,5')
})
it('accepts commas but normalizes them to dots', async () => {
@@ -106,7 +106,7 @@ describe('MalioInputAmount', () => {
await wrapper.get('input').setValue('0012,345abc')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
expect(wrapper.get('input').element.value).toBe('12.34')
expect(wrapper.get('input').element.value).toBe('12,34')
})
it('normalizes a leading decimal separator', async () => {
@@ -115,7 +115,7 @@ describe('MalioInputAmount', () => {
await wrapper.get('input').setValue(',5')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
expect(wrapper.get('input').element.value).toBe('0.5')
expect(wrapper.get('input').element.value).toBe('0,5')
})
it('keeps the normalized decimal value on blur', async () => {
@@ -126,7 +126,7 @@ describe('MalioInputAmount', () => {
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
expect(input.element.value).toBe('12.5')
expect(input.element.value).toBe('12,5')
})
it('keeps integer values unchanged on blur', async () => {
@@ -230,4 +230,52 @@ describe('MalioInputAmount', () => {
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
expect(wrapper.get('input').element.value).toBe('1 234 567')
})
it('groupe un grand montant avec décimales', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567,89')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('formate la valeur initiale (modelValue) en groupé', () => {
const wrapper = mountInputAmount({modelValue: '1234567.89'})
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('12345')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.get('input').element.value).toBe('')
})
it('maxLength autorise une valeur à la limite', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('1234')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
expect(wrapper.get('input').element.value).toBe('1 234')
})
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
const wrapper = mountInputAmount({maxLength: 4})
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
})
})
+32 -30
View File
@@ -9,10 +9,9 @@
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:value="formattedValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
@@ -21,7 +20,7 @@
inputmode="decimal"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@focus="isFocused = true; onKbdFocus()"
@blur="onBlur"
>
@@ -66,9 +65,13 @@ import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -126,6 +129,7 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
@@ -145,6 +149,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
@@ -190,40 +195,37 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const normalizeAmount = (value: string) => {
const sanitizedValue = value
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
const decimalPart = decimalParts.join('').slice(0, 2)
if (sanitizedValue.includes('.')) {
return `${integerPart || '0'}.${decimalPart}`
}
return integerPart
}
// Keep the DOM input value, local state, and v-model emission in sync.
const updateValue = (target: HTMLInputElement, value: string) => {
target.value = value
if (!isControlled.value) {
localValue.value = value
}
emit('update:modelValue', value)
}
// Normalize while typing so the field never keeps invalid amount characters.
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
updateValue(target, normalizeAmount(target.value))
const rawText = target.value
const caret = target.selectionStart ?? rawText.length
const model = normalizeAmount(rawText)
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
if (props.maxLength != null && model.length > Number(props.maxLength)) {
target.value = formattedValue.value
const restored = Math.min(Math.max(0, caret - 1), formattedValue.value.length)
target.setSelectionRange(restored, restored)
return
}
const display = formatGroupedAmount(model)
const sig = countSignificant(rawText, caret)
target.value = display
const newCaret = caretFromSignificant(display, sig)
target.setSelectionRange(newCaret, newCaret)
if (!isControlled.value) {
localValue.value = model
}
emit('update:modelValue', model)
}
// Keep the blur handler only for focus-driven UI state.
const onBlur = () => {
isFocused.value = false
onKbdBlur()
}
const iconInputPaddingClass = computed(() => {
@@ -375,6 +375,12 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('hides the chevron when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('sets readonly attribute', () => {
const wrapper = mountComponent({readonly: true})
@@ -24,6 +24,7 @@
type="text"
@input="onInput"
@focus="onFocus"
@blur="onKbdBlur"
@click="onInputClick"
@keydown="onKeydown"
>
@@ -63,7 +64,7 @@
class="animate-spin text-m-primary"
/>
<IconifyIcon
v-else
v-else-if="!disabled"
icon="mdi:chevron-down"
:width="20"
:height="20"
@@ -90,6 +91,7 @@
: hasSuccess
? 'border-m-success select-scrollbar-success'
: 'border-m-primary select-scrollbar-primary',
keyboardFocused ? 'm-combo-ring-bottom' : '',
]"
>
<li
@@ -150,13 +152,16 @@
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string
value: string | number
@@ -321,6 +326,7 @@ const labelPositionClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
@@ -400,6 +406,7 @@ const onInput = (event: Event) => {
}
const onFocus = () => {
onKbdFocus()
if (props.disabled || props.readonly) return
isFocused.value = true
isOpen.value = true
@@ -446,7 +453,20 @@ const closeAndRevert = () => {
isFocused.value = false
}
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
watch(activeIndex, async (index) => {
if (index < 0 || !isOpen.value) return
await nextTick()
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
})
const onKeydown = (event: KeyboardEvent) => {
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
if (event.key === 'Tab') {
if (isOpen.value) closeAndCommit()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeAndRevert()
@@ -479,7 +499,25 @@ const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
// Liste fermée : ouvre et place sur la dernière option (APG)
if (!isOpen.value) {
isOpen.value = true
activeIndex.value = filteredOptions.value.length - 1
return
}
activeIndex.value = Math.max(activeIndex.value - 1, 0)
return
}
// Home / End : première / dernière option quand la liste est ouverte
if (isOpen.value && event.key === 'Home') {
event.preventDefault()
activeIndex.value = 0
return
}
if (isOpen.value && event.key === 'End') {
event.preventDefault()
activeIndex.value = filteredOptions.value.length - 1
}
}
@@ -24,6 +24,9 @@ type InputEmailProps = {
iconSize?: string | number
iconColor?: string
lowercase?: boolean
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
@@ -315,4 +318,70 @@ describe('MalioInputEmail', () => {
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
it('does not render add button by default', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('renders add button when addable is true', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
})
it('emits add event when add button is clicked', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('does not emit add when readonly', async () => {
const wrapper = mountComponent({addable: true, readonly: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('moves the email icon to the left automatically when addable', () => {
const wrapper = mountComponent({addable: true})
const icon = wrapper.get('[data-test="icon"]')
expect(icon.classes()).toContain('left-[10px]')
expect(icon.classes()).not.toContain('right-[10px]')
})
it('keeps the email icon on the right when addable is false', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('uses the default add button aria-label', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
})
it('allows overriding the add button aria-label', () => {
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
})
})
+55 -7
View File
@@ -19,8 +19,8 @@
type="email"
inputmode="email"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
>
<label
@@ -40,6 +40,22 @@
:class="[iconStateClass, iconPositionClass]"
/>
<button
v-if="addable && !disabled"
type="button"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@click="onAdd"
>
<IconifyIcon
:icon="addIconName"
:width="24"
:height="24"
data-test="add-icon"
/>
</button>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
@@ -65,9 +81,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -88,6 +107,9 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
addable?: boolean
addIconName?: string
addButtonLabel?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}>(),
@@ -110,6 +132,9 @@ const props = withDefaults(
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter une adresse email',
lowercase: false,
reserveMessageSpace: true,
},
@@ -141,6 +166,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
@@ -177,6 +203,14 @@ const mergedLabelClass = computed(() =>
),
)
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
@@ -187,6 +221,7 @@ const describedBy = computed(() => {
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'add'): void
}>()
const sanitizeEmail = (v: string) => {
@@ -222,25 +257,38 @@ const onInput = (event: Event) => {
emit('update:modelValue', sanitized)
}
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
const effectiveIconPosition = computed(() =>
props.addable && props.iconName ? 'left' : props.iconPosition,
)
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
const parts: string[] = []
if (leftIcon) parts.push('!pl-11')
if (rightIcon || props.addable) parts.push('!pr-10')
return parts.join(' ')
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-11'
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
+9 -3
View File
@@ -10,6 +10,7 @@
</label>
<button
type="button"
class="m-focus-ring rounded-malio"
:disabled="isMinusDisabled"
@click="decrement"
>
@@ -35,11 +36,12 @@
inputmode="numeric"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
>
<button
type="button"
class="m-focus-ring rounded-malio"
:disabled="isPlusDisabled"
@click="increment"
>
@@ -73,9 +75,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -184,6 +189,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
@@ -199,7 +205,7 @@ const mergedLabelClass = computed(() =>
'cursor-pointer text-black mr-4 text-[18px]',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : '',
props.disabled ? 'cursor-not-allowed text-m-muted' : '',
props.labelClass,
),
)
@@ -91,6 +91,12 @@ describe('MalioInputPassword', () => {
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('hides the eye icon when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('renders icon by default', () => {
const wrapper = mountComponent()
+7 -3
View File
@@ -20,8 +20,8 @@
placeholder="_"
:type="isPasswordVisible ? 'text' : 'password'"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
>
<label
@@ -33,7 +33,7 @@
</label>
<IconifyIcon
v-if="displayIcon"
v-if="displayIcon && !disabled"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24"
:height="24"
@@ -70,9 +70,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -147,6 +150,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
+2 -10
View File
@@ -253,12 +253,10 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit add when disabled', async () => {
it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('does not emit add when readonly', async () => {
@@ -269,12 +267,6 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toBeUndefined()
})
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
+7 -4
View File
@@ -20,8 +20,8 @@
type="tel"
inputmode="tel"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
>
<label
@@ -42,9 +42,8 @@
/>
<button
v-if="addable"
v-if="addable && !disabled"
type="button"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@@ -85,9 +84,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -167,6 +169,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
+6 -2
View File
@@ -21,8 +21,8 @@
placeholder="_"
type="text"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
>
<label
@@ -69,9 +69,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -150,6 +153,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
+6 -2
View File
@@ -19,6 +19,7 @@
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
keyboardFocused ? 'm-focus-ring-kbd' : '',
]"
:required="required"
:maxlength="maxLength"
@@ -32,8 +33,8 @@
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
/>
<label
v-if="label"
@@ -89,9 +90,12 @@
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
+53 -14
View File
@@ -26,8 +26,10 @@
placeholder="_"
type="text"
@click="openFilePicker"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.prevent="openFilePicker"
@keydown.space.prevent="openFilePicker"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
>
<label
@@ -38,17 +40,33 @@
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline"
:width="24"
:height="24"
data-test="icon"
:class="[
iconStateClass,
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
]"
/>
<div
v-if="displayIcon || showClear"
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
>
<button
v-if="showClear"
type="button"
data-test="clear"
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
aria-label="Retirer le fichier"
@click.stop="onClear"
>
<IconifyIcon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<IconifyIcon
v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline"
:width="24"
:height="24"
data-test="icon"
:class="[iconStateClass, 'pointer-events-none']"
/>
</div>
</div>
<p
@@ -75,9 +93,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -94,6 +115,7 @@ const props = withDefaults(
displayIcon?: boolean
accept?: string
required?: boolean
clearable?: boolean
reserveMessageSpace?: boolean
}>(),
{
@@ -111,6 +133,7 @@ const props = withDefaults(
displayIcon: true,
accept: '',
required: false,
clearable: false,
reserveMessageSpace: true,
},
)
@@ -143,6 +166,7 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
@@ -153,7 +177,9 @@ const mergedInputClass = computed(() =>
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
props.displayIcon ? '!pr-10' : '',
showClear.value
? (props.displayIcon ? '!pr-16' : '!pr-10')
: (props.displayIcon ? '!pr-10' : ''),
isReadonly.value ? '' : 'focus:pl-[11px]',
isReadonly.value ? 'cursor-default' : '',
disabled.value ? 'cursor-not-allowed' : '',
@@ -191,8 +217,21 @@ const describedBy = computed(() => {
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'file-selected', file: File): void
(event: 'clear'): void
}>()
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
)
const onClear = () => {
if (props.disabled || isReadonly.value) return
if (!isControlled.value) localValue.value = ''
if (fileInputRef.value) fileInputRef.value.value = ''
emit('update:modelValue', '')
emit('clear')
}
const openFilePicker = () => {
if (props.disabled || props.readonly) return
fileInputRef.value?.click()
@@ -0,0 +1,74 @@
import {describe, expect, it} from 'vitest'
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
describe('normalizeAmount', () => {
it('garde le point décimal', () => {
expect(normalizeAmount('12.5')).toBe('12.5')
})
it('convertit la virgule en point et nettoie', () => {
expect(normalizeAmount('0012,345abc')).toBe('12.34')
})
it('normalise une décimale en tête', () => {
expect(normalizeAmount(',5')).toBe('0.5')
})
it('retire les espaces', () => {
expect(normalizeAmount('1 234 567')).toBe('1234567')
})
it('limite à 2 décimales', () => {
expect(normalizeAmount('1234.567')).toBe('1234.56')
})
it('garde une décimale en cours de saisie', () => {
expect(normalizeAmount('12.')).toBe('12.')
})
it('renvoie une chaîne vide pour une saisie non numérique', () => {
expect(normalizeAmount('abc')).toBe('')
})
it('garde un zéro seul', () => {
expect(normalizeAmount('0')).toBe('0')
})
})
describe('formatGroupedAmount', () => {
it('groupe la partie entière par 3 avec des espaces', () => {
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
})
it('utilise la virgule comme séparateur décimal', () => {
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
})
it('affiche une virgule pour une décimale en cours', () => {
expect(formatGroupedAmount('12.')).toBe('12,')
})
it('gère les valeurs sous 1000 sans séparateur', () => {
expect(formatGroupedAmount('12')).toBe('12')
})
it('groupe avec une décimale en tête', () => {
expect(formatGroupedAmount('0.5')).toBe('0,5')
})
it('renvoie une chaîne vide pour une chaîne vide', () => {
expect(formatGroupedAmount('')).toBe('')
})
it('garde un zéro seul', () => {
expect(formatGroupedAmount('0')).toBe('0')
})
})
describe('countSignificant', () => {
it('compte les caractères hors espaces à gauche du curseur', () => {
expect(countSignificant('1 234', 5)).toBe(4)
})
it('ignore un espace juste avant le curseur', () => {
expect(countSignificant('1 234', 2)).toBe(1)
})
})
describe('caretFromSignificant', () => {
it('place le curseur après le n-ième caractère significatif', () => {
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
})
it('place le curseur en fin si on dépasse', () => {
expect(caretFromSignificant('1 234', 10)).toBe(5)
})
it('place le curseur au début pour 0 caractère significatif', () => {
expect(caretFromSignificant('1 234', 0)).toBe(0)
})
})
@@ -0,0 +1,40 @@
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
export const normalizeAmount = (value: string): string => {
const sanitizedValue = value
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
const decimalPart = decimalParts.join('').slice(0, 2)
if (sanitizedValue.includes('.')) {
return `${integerPart || '0'}.${decimalPart}`
}
return integerPart
}
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
export const formatGroupedAmount = (model: string): string => {
if (model === '') return ''
const hasDot = model.includes('.')
const [integerPart = '', decimalPart = ''] = model.split('.')
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
}
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
export const countSignificant = (str: string, upTo: number): number =>
str.slice(0, upTo).replace(/ /g, '').length
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
export const caretFromSignificant = (display: string, sig: number): number => {
if (sig <= 0) return 0
let seen = 0
for (let i = 0; i < display.length; i++) {
if (display[i] !== ' ') seen++
if (seen >= sig) return i + 1
}
return display.length
}
@@ -179,6 +179,11 @@ const onChange = (event: Event) => {
opacity: 1;
}
.radio-control input[type='radio']:focus-visible {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
.radio-control.is-error input[type='radio'] {
border-color: rgb(var(--m-danger) / 1);
}
+11 -2
View File
@@ -237,12 +237,21 @@ describe('MalioSelect', () => {
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
it('hides the chevron when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the selected value when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', label: 'Pays', options, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('button span.block').classes()).toContain('text-black/60')
})
it('shows danger chevron color on error even when open', async () => {
+91 -12
View File
@@ -37,15 +37,24 @@
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
rounded,
textField,
keyboardFocused
? (isOpen
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
: 'm-focus-ring-kbd')
: '',
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:aria-required="required || undefined"
:aria-readonly="isReadonly || undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@focus="onKbdFocus"
@blur="onKbdBlur"
>
<label
v-if="label"
@@ -56,15 +65,17 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted',
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
@@ -76,13 +87,14 @@
class="block truncate"
:class="[
textValue,
isOptionSelected ? 'text-black' : 'select-none text-transparent'
isOptionSelected ? (disabled ? 'text-black/60' : 'text-black') : 'select-none text-transparent'
]"
>
{{ selectedLabel || '\u00A0' }}
</span>
<span
v-if="!disabled"
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
@@ -134,7 +146,10 @@
? 'border-m-danger'
: hasSuccess
? 'border-m-success'
: 'border-m-primary'
: 'border-m-primary',
keyboardFocused
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
: '',
]"
>
<li
@@ -184,13 +199,16 @@
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioSelect', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string;
value: string | number | null
@@ -344,7 +362,68 @@ function toggle() {
function select(value: string | number | null) {
emit('update:modelValue', value)
close()
buttonRef.value?.blur()
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
// les options ; au clavier le focus n'a jamais quitté le bouton.
buttonRef.value?.focus()
}
// Garde l'option active visible quand on navigue au clavier
watch(activeIndex, async (index) => {
if (index < 0 || !isOpen.value) return
await nextTick()
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
})
function onKeydown(e: KeyboardEvent) {
if (props.disabled || props.readonly) return
// Tab : laisse le focus partir mais ferme la liste
if (e.key === 'Tab') {
if (isOpen.value) close()
return
}
// Liste fermée : ouverture au clavier
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
open()
}
return
}
// Liste ouverte
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const opt = normalizedOptions.value[activeIndex.value]
if (opt) select(opt.value)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
return
}
if (e.key === 'Home') {
e.preventDefault()
activeIndex.value = 0
return
}
if (e.key === 'End') {
e.preventDefault()
activeIndex.value = normalizedOptions.value.length - 1
}
}
function onClickOutside(e: MouseEvent) {
@@ -68,8 +68,9 @@ describe('MalioSelectCheckbox', () => {
})
await wrapper.get('button').trigger('click')
const checkboxInputs = wrapper.findAll('input[type="checkbox"]')
await checkboxInputs[1].setValue(true)
// Le toggle se fait au clic sur la ligne d'option (la checkbox est en pointer-events-none).
const optionRows = wrapper.findAll('li[role="option"]')
await optionRows[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
})
@@ -149,8 +150,9 @@ describe('MalioSelectCheckbox', () => {
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(true)
// La ligne « tout sélectionner » est la première option de la liste.
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
await selectAllRow.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
})
@@ -162,8 +164,9 @@ describe('MalioSelectCheckbox', () => {
await wrapper.get('button').trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(false)
// La ligne « tout sélectionner » est la première option de la liste.
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
await selectAllRow.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
})
@@ -224,12 +227,23 @@ describe('MalioSelectCheckbox', () => {
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
it('hides the chevron when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the tags when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], label: 'Pays', options, displayTag: true, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const tag = wrapper.findAll('span.inline-flex')[0]
expect(tag.classes()).toContain('text-black/60')
expect(tag.classes()).not.toContain('text-black')
})
it('shows danger chevron color on error even when open', async () => {
+111 -20
View File
@@ -37,15 +37,24 @@
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
keyboardFocused
? (isOpen
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
: 'm-focus-ring-kbd')
: '',
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="!isOpen ? undefined : (activeIndex === -1 ? selectAllId : (activeIndex >= 0 ? optionId(activeIndex) : undefined))"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:aria-required="required || undefined"
:aria-readonly="isReadonly || undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@focus="onKbdFocus"
@blur="onKbdBlur"
>
<label
v-if="label"
@@ -56,15 +65,17 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted',
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
@@ -80,7 +91,8 @@
<span
v-for="option in selectedOptions"
:key="String(option.value)"
class="inline-flex max-w-full items-center rounded-md border border-black px-2 text-sm leading-none text-black"
class="inline-flex max-w-full items-center rounded-md border px-2 text-sm leading-none"
:class="disabled ? 'border-black/40 text-black/60' : 'border-black text-black'"
>
<span class="truncate pb-[2px]">{{ option.label }}</span>
</span>
@@ -104,13 +116,14 @@
:class="[
textValue,
label ? 'pl-24' : '',
isOptionSelected ? 'text-black' : 'text-m-muted'
disabled ? 'text-black/60' : isOptionSelected ? 'text-black' : 'text-m-muted'
]"
>
{{ selectionSummary }}
</span>
<span
v-if="!disabled"
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
@@ -162,7 +175,10 @@
? 'border-m-danger'
: hasSuccess
? 'border-m-success'
: 'border-m-primary'
: 'border-m-primary',
keyboardFocused
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
: '',
]"
>
<li
@@ -174,18 +190,23 @@
</li>
<li
v-if="displaySelectAll && normalizedOptions.length > 0"
class="border-b border-m-muted/30 px-3 py-2"
:id="selectAllId"
role="option"
:aria-selected="allSelected"
class="cursor-pointer border-b border-m-muted/30 px-3 py-2"
:class="[activeIndex === -1 ? 'bg-m-muted/10' : '']"
@mouseenter="activeIndex = -1"
@mousedown.prevent
@click="toggleAll"
>
<Checkbox
:model-value="allSelected"
:label="selectAllLabel"
:disabled="disabled"
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer font-semibold"
group-class="!mt-0 pointer-events-none"
label-class="option-checkbox w-full font-semibold"
tabindex="-1"
:reserve-message-space="false"
@update:model-value="toggleAll"
/>
</li>
<li
@@ -194,7 +215,7 @@
:key="String(opt.value)"
role="option"
:aria-selected="isChecked(opt.value)"
class="px-3 py-2"
class="cursor-pointer px-3 py-2"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
@@ -202,16 +223,16 @@
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
@click="toggleOption(opt.value)"
>
<Checkbox
:model-value="isChecked(opt.value)"
:label="opt.label || '\u00A0'"
:disabled="disabled"
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer"
group-class="!mt-0 pointer-events-none"
label-class="option-checkbox w-full"
tabindex="-1"
:reserve-message-space="false"
@update:model-value="toggleOption(opt.value)"
/>
</li>
</ul>
@@ -235,14 +256,17 @@
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import Checkbox from '../checkbox/Checkbox.vue'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string;
value: string | number
@@ -302,6 +326,9 @@ const openDirection = ref<'down' | 'up'>('down')
const uid = useId()
const buttonId = `custom-select-btn-${uid}`
const listboxId = `custom-select-listbox-${uid}`
const selectAllId = `custom-select-all-${uid}`
// Index actif le plus bas : -1 cible la ligne « tout sélectionner » quand elle est affichée
const lowestIndex = computed(() => (props.displaySelectAll && normalizedOptions.value.length > 0 ? -1 : 0))
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => props.options)
@@ -427,6 +454,70 @@ function toggleAll() {
nextTick(() => buttonRef.value?.focus())
}
// Garde l'option active visible quand on navigue au clavier
watch(activeIndex, async (index) => {
if (!isOpen.value) return
await nextTick()
const id = index === -1 ? selectAllId : (index >= 0 ? optionId(index) : null)
if (id) document.getElementById(id)?.scrollIntoView({block: 'nearest'})
})
function onKeydown(e: KeyboardEvent) {
if (props.disabled || props.readonly) return
// Tab : laisse le focus partir mais ferme la liste
if (e.key === 'Tab') {
if (isOpen.value) close()
return
}
// Liste fermée : ouverture au clavier
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
open()
}
return
}
// Liste ouverte (multi-select : Entrée/Espace togglent et la liste reste ouverte)
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
// -1 = ligne « tout sélectionner »
if (activeIndex.value === -1) {
toggleAll()
return
}
const opt = normalizedOptions.value[activeIndex.value]
if (opt) toggleOption(opt.value)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, lowestIndex.value)
return
}
if (e.key === 'Home') {
e.preventDefault()
activeIndex.value = lowestIndex.value
return
}
if (e.key === 'End') {
e.preventDefault()
activeIndex.value = normalizedOptions.value.length - 1
}
}
function onClickOutside(e: MouseEvent) {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
@@ -0,0 +1,48 @@
import {ref} from 'vue'
/**
* Détection de la modalité de focus (clavier vs souris/tactile).
*
* Sur les champs texte, `:focus-visible` natif se déclenche AUSSI au clic souris
* (le navigateur suppose qu'on va taper). Pour n'afficher l'anneau de focus qu'à
* la navigation clavier (Tab), on suit la dernière interaction au niveau document
* et on n'arme l'anneau que si le focus a é précédé d'un évènement clavier.
*
* Le visuel « champ actif » existant (grossissement, label flottant, bordure bleue)
* reste piloté par `:focus` et n'est pas affecté : ce composable ne gère QUE l'anneau.
*/
let hadKeyboardEvent = false
let listenersAttached = false
function ensureGlobalListeners() {
if (listenersAttached || typeof document === 'undefined') return
listenersAttached = true
// capture=true pour observer l'évènement avant qu'il n'atteigne sa cible
document.addEventListener('keydown', () => {
hadKeyboardEvent = true
}, true)
const markPointer = () => {
hadKeyboardEvent = false
}
document.addEventListener('mousedown', markPointer, true)
document.addEventListener('pointerdown', markPointer, true)
document.addEventListener('touchstart', markPointer, true)
}
export function useKbdFocusRing() {
ensureGlobalListeners()
const keyboardFocused = ref(false)
const onFocus = () => {
keyboardFocused.value = hadKeyboardEvent
}
const onBlur = () => {
keyboardFocused.value = false
}
return {keyboardFocused, onFocus, onBlur}
}
+24 -1
View File
@@ -62,7 +62,7 @@ describe('MalioSidebar', () => {
it('renders expanded by default', () => {
const wrapper = mountComponent({sections})
const aside = wrapper.find('aside')
expect(aside.classes()).toContain('w-[280px]')
expect(aside.classes()).toContain('w-[232px]')
})
it('renders section labels with icons when expanded', () => {
@@ -89,6 +89,29 @@ describe('MalioSidebar', () => {
expect(links[2].attributes('href')).toBe('/fournisseurs')
})
it('hover : fond + couleur + semi-bold tous portés par le <li> (texte non figé sur le <a>)', () => {
const wrapper = mountComponent({sections})
const li = wrapper.find('li')
expect(li.classes()).toContain('hover:bg-m-primary/10')
expect(li.classes()).toContain('hover:text-m-primary')
expect(li.classes()).toContain('hover:font-semibold')
expect(li.classes()).toContain('text-black')
expect(li.classes()).toContain('pt-1')
expect(li.classes()).toContain('pb-1')
// Le <a> ne fige PAS sa couleur (sinon le texte resterait noir sur les bandes
// pt-1/pb-1 hors du <a> alors que le fond du <li> est bleu).
expect(wrapper.find('a').classes()).not.toContain('text-black')
expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary')
})
it('actif : texte primary + semi-bold, sans fond, via active-class', () => {
const wrapper = mountComponent({sections})
const activeClass = wrapper.find('a').attributes('active-class') ?? ''
expect(activeClass).toContain('text-m-primary')
expect(activeClass).toContain('font-semibold')
expect(activeClass).not.toContain('bg-')
})
it('renders section icons via IconifyIcon', () => {
const wrapper = mountComponent({sections})
const icons = wrapper.findAllComponents(IconifyIcon)
+6 -5
View File
@@ -3,7 +3,7 @@
:id="componentId"
:class="twMerge(
'relative flex h-full flex-col bg-m-bg',
collapsed ? 'w-[72px]' : 'w-[280px]',
collapsed ? 'w-[72px]' : 'w-[232px]',
sidebarClass,
)"
v-bind="$attrs"
@@ -28,7 +28,7 @@
<div
v-if="section.label"
:class="[
'flex items-center gap-2 px-[10px] pt-2 pb-3',
'flex items-center gap-2 pt-2 pb-2',
collapsed ? 'justify-center pt-[40px]' : '',
]"
>
@@ -49,13 +49,14 @@
<li
v-for="item in section.items"
:key="item.to"
:class="collapsed ? '' : 'pb-2 last:pb-1'"
:class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary pt-1 pb-1'"
>
<NuxtLink
:to="item.to"
active-class="!text-m-primary font-semibold"
:class="twMerge(
'block truncate rounded-md text-[15px] text-m-text text-black transition-colors hover:bg-m-surface leading-[150%]',
collapsed ? 'px-3 text-center' : 'pl-[42px] pr-3',
'block truncate text-[15px] leading-[150%]',
collapsed ? 'px-3 text-center' : 'pl-[32px]',
)"
>
<span v-if="!collapsed">{{ item.label }}</span>
+10 -12
View File
@@ -16,7 +16,6 @@ type TabListProps = {
modelValue?: string
id?: string
maxVisibleTabs?: number
maxWidth?: number
}
const TabListForTest = TabList as DefineComponent<TabListProps>
@@ -157,7 +156,16 @@ describe('MalioTabList', () => {
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].classes()).toContain('cursor-not-allowed')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary')
})
it('hover sur un onglet inactif applique le même style que l\'actif (texte plein + barre)', () => {
const wrapper = mountComponent({tabs})
const inactive = wrapper.findAll('[role="tab"]')[1]
expect(inactive.attributes('aria-selected')).toBe('false')
expect(inactive.classes()).toContain('hover:text-m-primary')
expect(inactive.classes()).toContain('hover:after:bg-m-primary')
expect(inactive.classes()).toContain('hover:after:h-[3px]')
})
it('does not emit update:modelValue when clicking a disabled tab', async () => {
@@ -199,16 +207,6 @@ describe('MalioTabList — fenêtrage maxVisibleTabs', () => {
{key: 't7', label: 'Tab 7'},
]
it('applies the default maxWidth (1100px) on the tabs container when windowed', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px')
})
it('applies a custom maxWidth on the tabs container', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200})
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px')
})
it('renders only maxVisibleTabs buttons and disables prev at start', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
const buttons = wrapper.findAll('[role="tab"]')
+84 -14
View File
@@ -1,5 +1,30 @@
<template>
<div v-bind="$attrs">
<div
ref="rootRef"
v-bind="$attrs"
>
<!-- Ligne de mesure cachée : largeur réelle de chaque onglet (mêmes classes de
layout, placeholder à la place de l'icône pour ne pas fausser les tests),
afin de calculer combien d'onglets tiennent. Invisible et hors flux. -->
<div
ref="measureRef"
aria-hidden="true"
class="pointer-events-none invisible absolute left-0 top-0 flex"
>
<span
v-for="tab in tabs"
:key="tab.key"
class="flex items-center gap-[18px] text-[24px] font-[600]"
>
<span
v-if="tab.icon"
class="inline-block shrink-0"
:style="{ width: `${tab.iconSize ?? 24}px`, height: `${tab.iconSize ?? 24}px` }"
/>
{{ tab.label }}
</span>
</div>
<div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary">
<button
type="button"
@@ -20,7 +45,6 @@
<div
role="tablist"
class="flex flex-1 justify-center gap-[60px]"
:style="{ maxWidth: `${maxWidth}px` }"
>
<button
v-for="tab in visibleTabs"
@@ -39,7 +63,7 @@
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
: tab.disabled
? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
: 'cursor-pointer text-m-primary/50 hover:text-m-primary hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
]"
@click="selectTab(tab.key)"
>
@@ -91,7 +115,7 @@
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
: tab.disabled
? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
: 'cursor-pointer text-m-primary/50 hover:text-m-primary hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
]"
@click="selectTab(tab.key)"
>
@@ -119,8 +143,9 @@
</template>
<script setup lang="ts">
import {computed, ref, useId, watch} from 'vue'
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {computeVisibleCount} from './tabFit'
defineOptions({name: 'MalioTabList', inheritAttrs: false})
@@ -137,12 +162,10 @@ const props = withDefaults(defineProps<{
modelValue?: string
id?: string
maxVisibleTabs?: number
maxWidth?: number
}>(), {
modelValue: undefined,
id: '',
maxVisibleTabs: undefined,
maxWidth: 1100,
})
const emit = defineEmits<{
@@ -159,22 +182,69 @@ const activeTab = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isWindowed = computed(() =>
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs,
)
const TAB_GAP = 60
const CHEVRON_RESERVE = 110
const maxStartIndex = computed(() =>
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0,
)
const rootRef = ref<HTMLElement | null>(null)
const measureRef = ref<HTMLElement | null>(null)
const containerWidth = ref(0)
const tabWidths = ref<number[]>([])
// Nombre d'onglets affichés, calculé pour qu'ils tiennent dans la largeur réelle
// (cf. tabFit.ts) la structure « flèches fixes » est conservée, et comme le
// nombre est choisi pour tenir, pas de débordement sur les flèches ni de rognage.
// `maxVisibleTabs` reste un plafond optionnel. Sans mesure (SSR/jsdom), repli sur
// ce plafond / tous les onglets.
const visibleCount = computed(() => computeVisibleCount({
count: props.tabs.length,
containerWidth: containerWidth.value,
tabWidths: tabWidths.value,
gap: TAB_GAP,
chevronReserve: CHEVRON_RESERVE,
maxVisibleTabs: props.maxVisibleTabs,
}))
const isWindowed = computed(() => props.tabs.length > visibleCount.value)
const maxStartIndex = computed(() => Math.max(0, props.tabs.length - visibleCount.value))
const startIndex = ref(0)
const visibleTabs = computed(() =>
isWindowed.value
? props.tabs.slice(startIndex.value, startIndex.value + props.maxVisibleTabs!)
? props.tabs.slice(startIndex.value, startIndex.value + visibleCount.value)
: props.tabs,
)
function measureTabWidths() {
const el = measureRef.value
if (!el) return
tabWidths.value = Array.from(el.children).map(c => (c as HTMLElement).offsetWidth)
}
function measureContainer() {
if (rootRef.value) containerWidth.value = rootRef.value.clientWidth
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
measureTabWidths()
measureContainer()
if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
resizeObserver = new ResizeObserver(() => measureContainer())
resizeObserver.observe(rootRef.value)
}
})
onBeforeUnmount(() => resizeObserver?.disconnect())
// Re-mesure quand la liste change (labels/icônes largeurs différentes).
watch(() => props.tabs, () => nextTick(() => {
measureTabWidths()
measureContainer()
}), {deep: true})
const focusedKey = computed(() => {
if (!isWindowed.value) return activeTab.value
const inView = visibleTabs.value.some(t => t.key === activeTab.value)
+43
View File
@@ -0,0 +1,43 @@
import {describe, it, expect} from 'vitest'
import {computeVisibleCount} from './tabFit'
const base = {gap: 60, chevronReserve: 110}
const widths = (n: number, w = 180) => Array.from({length: n}, () => w)
describe('computeVisibleCount', () => {
it('sans layout : respecte maxVisibleTabs', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: [], maxVisibleTabs: 3})).toBe(3)
})
it('sans layout ni maxVisibleTabs : tous les onglets', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: []})).toBe(5)
})
it('tout tient : retourne le total (pas de chevrons)', () => {
// 4×180 + 3×60 = 900 <= 1000
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: widths(4)})).toBe(4)
})
it('trop large : additionne les vraies largeurs (pas la pire) — pas d\'effondrement à 1', () => {
// total 7×180+6×60=1620 > 1400 ; avail=1400-110=1290 ; 180,420,660,900,1140,(1380>1290) → 5
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7)})).toBe(5)
})
it('largeur étroite : montre ce qui tient (≥ 2 ici, pas 1)', () => {
// avail=570-110=460 ; 180,(420),(660>460) → 2
expect(computeVisibleCount({...base, count: 7, containerWidth: 570, tabWidths: widths(7)})).toBe(2)
})
it('maxVisibleTabs plafonne le résultat', () => {
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7), maxVisibleTabs: 3})).toBe(3)
})
it('au moins 1 onglet si rien ne tient', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 150, tabWidths: widths(5, 300)})).toBe(1)
})
it('gère des largeurs hétérogènes', () => {
// total 1080 > 1000 → fenêtré ; avail=1000-110=890 ; 300,560,820,(1080>890) → 3
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: [300, 200, 200, 200]})).toBe(3)
})
})
+49
View File
@@ -0,0 +1,49 @@
// Calcule combien d'onglets afficher pour qu'ils tiennent dans la largeur dispo,
// en gardant la structure « flèches fixes aux bords » : le nombre est choisi pour
// que les onglets visibles tiennent → pas de débordement sur les flèches, pas de
// rognage, barre d'onglet actif intacte.
//
// On additionne les VRAIES largeurs d'onglets (pas la pire), donc le résultat
// n'est pas sur-conservateur (évite de tomber à 1 onglet inutilement).
//
// Fonction pure → testable sans DOM. Sans layout (SSR / jsdom : largeurs à 0),
// on retombe sur le plafond `maxVisibleTabs` (ou tous les onglets).
export interface TabFitInput {
count: number // nombre total d'onglets
containerWidth: number // largeur dispo mesurée (0 si inconnue)
tabWidths: number[] // largeur mesurée de chaque onglet (vide si inconnu)
gap: number // espace entre onglets (px)
chevronReserve: number // place des chevrons + marges quand fenêtré (px)
maxVisibleTabs?: number // plafond optionnel imposé par le consommateur
}
export function computeVisibleCount(input: TabFitInput): number {
const {count, containerWidth, tabWidths, gap, chevronReserve, maxVisibleTabs} = input
// Pas d'info de layout : on respecte le plafond explicite, sinon tout afficher.
if (containerWidth <= 0 || tabWidths.length === 0) {
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, count) : count
}
const fullAvail = containerWidth
const total = tabWidths.reduce((s, w) => s + w, 0) + gap * Math.max(0, count - 1)
let fit: number
if (total <= fullAvail) {
fit = count // tout tient, pas de chevrons
}
else {
const avail = fullAvail - chevronReserve
let used = 0
let n = 0
for (const w of tabWidths) {
const add = w + (n > 0 ? gap : 0)
if (used + add > avail) break
used += add
n++
}
fit = Math.max(1, n)
}
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, fit) : fit
}
@@ -53,6 +53,14 @@ describe('MalioTimePicker', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('disabled + rempli : label et icône horloge grisés', () => {
const wrapper = mountPicker({disabled: true, label: 'Heure', modelValue: '14:30'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const icon = wrapper.get('[data-test="clock-icon"]')
expect(icon.classes()).toContain('text-m-muted')
expect(icon.classes()).not.toContain('text-black')
})
it('n\'ouvre pas le popover si readonly', async () => {
const wrapper = mountPicker({readonly: true})
await wrapper.get('[data-test="time-field"]').trigger('click')
+8 -5
View File
@@ -219,11 +219,13 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
: props.disabled
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass,
),
)
@@ -231,6 +233,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
+41
View File
@@ -28,6 +28,16 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
<MalioDate
v-model="editableValue"
label="Date de naissance"
editable
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDate
@@ -72,6 +82,20 @@
success="Enregistrée"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Statuts par jour (markedDates) + @month-change</h2>
<MalioDate
v-model="markedValue"
label="Jours validés / à corriger"
hint="Ouvre le calendrier : jours verts (success) et rouges (danger)"
:marked-dates="markedDates"
@month-change="onMonthChange"
/>
<p class="mt-2 text-sm text-m-muted">
Mois affiché : <code>{{ shownMonth }}</code>
</p>
</div>
</div>
</Story>
</template>
@@ -91,4 +115,21 @@ const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>(todayIso)
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
const editableValue = ref<string | null>(null)
const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
const markedDates = ref<Record<string, 'success' | 'danger'>>({
[`${ym}-05`]: 'success',
[`${ym}-06`]: 'success',
[`${ym}-12`]: 'success',
[`${ym}-09`]: 'danger',
[`${ym}-20`]: 'danger',
})
const markedValue = ref<string | null>(null)
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const shownMonth = ref('—')
const onMonthChange = ({month, year}: {month: number, year: number}) => {
shownMonth.value = `${monthsLong[month]} ${year}`
}
</script>
+12
View File
@@ -9,6 +9,17 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<p class="mt-2 text-sm text-m-muted">
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputAmount
@@ -251,6 +262,7 @@ import {ref} from 'vue'
import MalioInputAmount from '../../components/malio/input/InputAmount.vue'
const simpleValue = ref('')
const bigValue = ref('1234567.89')
const hintValue = ref('')
const disabledValue = ref('1500.00')
const readonlyValue = ref('2450.75')
+16
View File
@@ -18,6 +18,19 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
<MalioInputEmail
v-model="addableValue"
label="Adresse email"
addable
@add="onAdd"
/>
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
Bouton cliqué {{ addClicks }} fois
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
<MalioInputEmail
@@ -251,6 +264,9 @@ import {ref} from 'vue'
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
const simpleValue = ref('')
const addableValue = ref('')
const addClicks = ref(0)
const onAdd = () => { addClicks.value += 1 }
const leftIconValue = ref('')
const noIconValue = ref('')
const hintValue = ref('')
@@ -0,0 +1,532 @@
# MalioInputAmount — séparateurs de milliers — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Afficher les montants groupés à la française (`1 234 567,89`) en temps réel dans `MalioInputAmount`, tout en émettant un `modelValue` propre inchangé (`1234567.89`).
**Architecture:** Extraction des fonctions pures (`normalizeAmount` déplacé + `formatGroupedAmount` + helpers curseur) dans `composables/amountFormat.ts`. Le composant binde l'affichage groupé (`formatGroupedAmount(currentValue)`) et, à la frappe, parse vers le modèle propre (émis), reformate, et repositionne le curseur en comptant les caractères significatifs. `maxLength` borne la longueur du modèle (plus de `maxlength` natif).
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Vitest + @vue/test-utils (jsdom).
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md`
---
## File Structure
- **Create** `app/components/malio/input/composables/amountFormat.ts` — fonctions pures : `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant`.
- **Create** `app/components/malio/input/composables/amountFormat.test.ts` — tests unitaires des fonctions pures.
- **Modify** `app/components/malio/input/InputAmount.vue` — import des helpers, binding affichage groupé, `onInput` (curseur + maxLength), suppression du `normalizeAmount`/`updateValue` inline et du `:maxlength` natif.
- **Modify** `app/components/malio/input/InputAmount.test.ts` — assertions d'affichage (brut → groupé), nouveaux tests.
- **Modify** `COMPONENTS.md` — note affichage groupé + contrat modèle + `maxLength`.
- **Modify** `CHANGELOG.md` — entrée.
- **Modify** `app/story/input/inputAmount.story.vue` + `.playground/pages/composant/input/inputAmount.vue` — exemple grand montant.
**Note hooks pré-commit :** `make pre-commit` lance lint + suite complète (~900 tests), KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Stager des fichiers explicites — **jamais** `git add -A` (`nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
**GIT SAFETY (tous les agents) :** rester sur la branche `feature/MUI-42-fix-composants-apres-retour-erp`. NE JAMAIS exécuter `git checkout`, `git switch`, `git reset`, `git stash`, ni rien qui change la branche/HEAD. Uniquement `git add <fichiers>` et `git commit`.
---
## Task 1 : `amountFormat.ts` — fonctions pures (TDD)
**Files:**
- Create: `app/components/malio/input/composables/amountFormat.ts`
- Create: `app/components/malio/input/composables/amountFormat.test.ts`
- [ ] **Step 1 : Écrire les tests (échouent car le module n'existe pas)**
Créer `app/components/malio/input/composables/amountFormat.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
describe('normalizeAmount', () => {
it('garde le point décimal', () => {
expect(normalizeAmount('12.5')).toBe('12.5')
})
it('convertit la virgule en point et nettoie', () => {
expect(normalizeAmount('0012,345abc')).toBe('12.34')
})
it('normalise une décimale en tête', () => {
expect(normalizeAmount(',5')).toBe('0.5')
})
it('retire les espaces', () => {
expect(normalizeAmount('1 234 567')).toBe('1234567')
})
it('limite à 2 décimales', () => {
expect(normalizeAmount('1234.567')).toBe('1234.56')
})
it('garde une décimale en cours de saisie', () => {
expect(normalizeAmount('12.')).toBe('12.')
})
it('renvoie une chaîne vide pour une saisie non numérique', () => {
expect(normalizeAmount('abc')).toBe('')
})
it('garde un zéro seul', () => {
expect(normalizeAmount('0')).toBe('0')
})
})
describe('formatGroupedAmount', () => {
it('groupe la partie entière par 3 avec des espaces', () => {
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
})
it('utilise la virgule comme séparateur décimal', () => {
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
})
it('affiche une virgule pour une décimale en cours', () => {
expect(formatGroupedAmount('12.')).toBe('12,')
})
it('gère les valeurs sous 1000 sans séparateur', () => {
expect(formatGroupedAmount('12')).toBe('12')
})
it('groupe avec une décimale en tête', () => {
expect(formatGroupedAmount('0.5')).toBe('0,5')
})
it('renvoie une chaîne vide pour une chaîne vide', () => {
expect(formatGroupedAmount('')).toBe('')
})
it('garde un zéro seul', () => {
expect(formatGroupedAmount('0')).toBe('0')
})
})
describe('countSignificant', () => {
it('compte les caractères hors espaces à gauche du curseur', () => {
// "1 234|" → curseur en position 5, 4 caractères significatifs (1,2,3,4)
expect(countSignificant('1 234', 5)).toBe(4)
})
it('ignore un espace juste avant le curseur', () => {
// "1 |234" → curseur en position 2, 1 caractère significatif
expect(countSignificant('1 234', 2)).toBe(1)
})
})
describe('caretFromSignificant', () => {
it('place le curseur après le n-ième caractère significatif', () => {
// 4 caractères significatifs dans "1 234 567" → après le "4" (index 5)
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
})
it('place le curseur en fin si on dépasse', () => {
expect(caretFromSignificant('1 234', 10)).toBe(5)
})
it('place le curseur au début pour 0 caractère significatif', () => {
expect(caretFromSignificant('1 234', 0)).toBe(0)
})
})
```
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
Run : `npm run test -- amountFormat.test.ts`
Expected : FAIL (le module `./amountFormat` n'existe pas).
- [ ] **Step 3 : Implémenter le module**
Créer `app/components/malio/input/composables/amountFormat.ts` :
```ts
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
export const normalizeAmount = (value: string): string => {
const sanitizedValue = value
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
const decimalPart = decimalParts.join('').slice(0, 2)
if (sanitizedValue.includes('.')) {
return `${integerPart || '0'}.${decimalPart}`
}
return integerPart
}
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
export const formatGroupedAmount = (model: string): string => {
if (model === '') return ''
const hasDot = model.includes('.')
const [integerPart = '', decimalPart = ''] = model.split('.')
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
}
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
export const countSignificant = (str: string, upTo: number): number =>
str.slice(0, upTo).replace(/ /g, '').length
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
export const caretFromSignificant = (display: string, sig: number): number => {
if (sig <= 0) return 0
let seen = 0
for (let i = 0; i < display.length; i++) {
if (display[i] !== ' ') seen++
if (seen >= sig) return i + 1
}
return display.length
}
```
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
Run : `npm run test -- amountFormat.test.ts`
Expected : PASS (tous).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/input/composables/amountFormat.ts app/components/malio/input/composables/amountFormat.test.ts
git commit -m "feat(amount) : helpers amountFormat (normalize, group, curseur)"
```
---
## Task 2 : Brancher `InputAmount.vue` sur les helpers
**Files:**
- Modify: `app/components/malio/input/InputAmount.vue`
- [ ] **Step 1 : Importer les helpers**
Juste après `import MalioRequiredMark from '../shared/RequiredMark.vue'`, ajouter :
```ts
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
```
- [ ] **Step 2 : Ajouter la computed `formattedValue`**
Juste après la ligne `const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))`, ajouter :
```ts
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
```
- [ ] **Step 3 : Binder l'affichage groupé et retirer le `maxlength` natif**
Dans le `<template>`, sur l'`<input>` :
- Remplacer `:value="currentValue"` par `:value="formattedValue"`.
- **Supprimer** la ligne `:maxlength="maxLength"` (le plafond est géré en JS). Garder `:minlength="minLength"`.
- [ ] **Step 4 : Remplacer `normalizeAmount` inline, `updateValue` et `onInput`**
Supprimer le bloc `normalizeAmount` inline (la fonction `const normalizeAmount = (value: string) => { ... }`) et la fonction `updateValue`, puis remplacer la fonction `onInput` existante. Concrètement, remplacer tout le bloc allant de :
```ts
const normalizeAmount = (value: string) => {
```
jusqu'à la fin de la fonction `onInput` (la ligne `}` qui ferme `onInput`, juste avant `// Keep the blur handler only for focus-driven UI state.`), par :
```ts
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const rawText = target.value
const caret = target.selectionStart ?? rawText.length
const model = normalizeAmount(rawText)
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
if (props.maxLength != null && model.length > Number(props.maxLength)) {
target.value = formattedValue.value
const restored = Math.max(0, caret - 1)
target.setSelectionRange(restored, restored)
return
}
const display = formatGroupedAmount(model)
const sig = countSignificant(rawText, caret)
target.value = display
const newCaret = caretFromSignificant(display, sig)
target.setSelectionRange(newCaret, newCaret)
if (!isControlled.value) {
localValue.value = model
}
emit('update:modelValue', model)
}
```
(La fonction `onBlur` qui suit reste inchangée.)
- [ ] **Step 5 : Vérifier la compilation et lancer les tests existants (ils vont en partie échouer — c'est attendu, ils sont mis à jour en Task 3)**
Run : `npm run test -- amountFormat.test.ts`
Expected : PASS (le module est intact).
Run : `npm run lint`
Expected : 0 erreur sur `InputAmount.vue` (pas de variable inutilisée, imports utilisés).
Note : `npm run test -- InputAmount.test.ts` affichera des échecs sur les assertions d'affichage (`'12.5'``'12,5'`) — c'est normal, la Task 3 met à jour ces tests. Ne pas « corriger » le composant pour les faire passer.
- [ ] **Step 6 : Commit**
```bash
git add app/components/malio/input/InputAmount.vue
git commit -m "feat(amount) : affichage groupé temps réel (séparateurs de milliers)"
```
---
## Task 3 : Mettre à jour `InputAmount.test.ts`
**Files:**
- Modify: `app/components/malio/input/InputAmount.test.ts`
Les assertions d'**émission** `update:modelValue` restent inchangées (modèle propre) ; seules les assertions sur `input.element.value` passent à l'affichage groupé.
- [ ] **Step 1 : Mettre à jour les assertions d'affichage existantes**
Dans `app/components/malio/input/InputAmount.test.ts`, appliquer ces remplacements exacts :
Test « keeps dots as the decimal separator on input » :
```ts
expect(wrapper.get('input').element.value).toBe('12.5')
```
devient :
```ts
expect(wrapper.get('input').element.value).toBe('12,5')
```
Test « accepts commas but normalizes them to dots » :
```ts
expect(wrapper.get('input').element.value).toBe('12.34')
```
devient :
```ts
expect(wrapper.get('input').element.value).toBe('12,34')
```
Test « normalizes a leading decimal separator » :
```ts
expect(wrapper.get('input').element.value).toBe('0.5')
```
devient :
```ts
expect(wrapper.get('input').element.value).toBe('0,5')
```
Test « keeps the normalized decimal value on blur » :
```ts
expect(input.element.value).toBe('12.5')
```
devient :
```ts
expect(input.element.value).toBe('12,5')
```
(Les tests « keeps integer values unchanged on blur » → `'12'` et « keeps an empty value empty on blur » → `''` restent corrects tels quels, car `formatGroupedAmount('12') === '12'` et `formatGroupedAmount('') === ''`.)
- [ ] **Step 2 : Ajouter les tests de groupement et de maxLength**
Juste avant la `})` finale qui ferme le `describe('MalioInputAmount', ...)`, ajouter :
```ts
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
expect(wrapper.get('input').element.value).toBe('1 234 567')
})
it('groupe un grand montant avec décimales', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567,89')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('formate la valeur initiale (modelValue) en groupé', () => {
const wrapper = mountInputAmount({modelValue: '1234567.89'})
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('12345')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.get('input').element.value).toBe('')
})
it('maxLength autorise une valeur à la limite', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('1234')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
expect(wrapper.get('input').element.value).toBe('1 234')
})
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
const wrapper = mountInputAmount({maxLength: 4})
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
})
```
- [ ] **Step 3 : Lancer la suite**
Run : `npm run test -- InputAmount.test.ts`
Expected : PASS (tous : existants mis à jour + 6 nouveaux).
Si un test de `setValue` échoue parce que jsdom ne déclenche pas `setSelectionRange` comme attendu, vérifier la valeur réelle via `console.log(wrapper.get('input').element.value)` — l'affichage attendu suit `formatGroupedAmount`. Ne pas affaiblir une assertion sans comprendre.
- [ ] **Step 4 : Commit**
```bash
git add app/components/malio/input/InputAmount.test.ts
git commit -m "test(amount) : affichage groupé + maxLength sur le modèle"
```
---
## Task 4 : Documentation
**Files:**
- Modify: `COMPONENTS.md`
- Modify: `CHANGELOG.md`
- [ ] **Step 1 : Note dans `COMPONENTS.md`**
Dans la section `## MalioInputAmount`, remplacer la ligne de description :
```markdown
Champ montant avec icône devise (euro par défaut).
```
par :
```markdown
Champ montant avec icône devise (euro par défaut).
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
```
- [ ] **Step 2 : Mettre à jour l'exemple de la section**
Dans le bloc ```vue de la section `## MalioInputAmount`, ajouter avant la fence fermante :
```vue
<MalioInputAmount v-model="gros" label="Budget" />
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
```
- [ ] **Step 3 : Entrée CHANGELOG**
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (après la dernière puce existante du bloc Added) :
```markdown
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
```
- [ ] **Step 4 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(amount) : documente l'affichage groupé des milliers"
```
---
## Task 5 : Story + playground
**Files:**
- Modify: `app/story/input/inputAmount.story.vue`
- Modify: `.playground/pages/composant/input/inputAmount.vue`
- [ ] **Step 1 : Carte « grand montant » dans la story**
Dans `app/story/input/inputAmount.story.vue`, juste après la carte « Simple » (le `<div class="rounded-lg border p-4">` contenant `v-model="simpleValue"`, qui se termine par `</div>` avant la carte « Avec hint »), insérer :
```html
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<p class="mt-2 text-sm text-m-muted">
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
</p>
</div>
```
- [ ] **Step 2 : Déclarer la ref dans la story**
Dans le `<script setup>` de `app/story/input/inputAmount.story.vue`, après `const simpleValue = ref('')`, ajouter :
```ts
const bigValue = ref('1234567.89')
```
- [ ] **Step 3 : Exemple « grand montant » dans le playground**
Dans `.playground/pages/composant/input/inputAmount.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` contenant `name="amount"`, qui se termine par `</div>` avant la carte « Désactivé »), insérer :
```html
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<div class="mt-2 rounded border p-3 text-sm">
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
</div>
</div>
```
- [ ] **Step 4 : Déclarer la ref dans le playground**
Dans le `<script setup>` de `.playground/pages/composant/input/inputAmount.vue`, ajouter (créer le bloc s'il n'existe pas déjà — vérifier la présence d'un `<script setup lang="ts">` ; sinon l'ajouter en bas du fichier) :
```ts
const bigValue = ref('1234567.89')
```
Si le `<script setup>` n'importe pas encore `ref`, ajouter `import {ref} from 'vue'` en tête du script.
- [ ] **Step 5 : Vérifier le lint**
Run : `npm run lint`
Expected : 0 erreur sur les deux fichiers modifiés (warnings pré-existants ailleurs tolérés).
- [ ] **Step 6 : Commit**
```bash
git add app/story/input/inputAmount.story.vue .playground/pages/composant/input/inputAmount.vue
git commit -m "docs(amount) : exemple grand montant groupé (story + playground)"
```
---
## Task 6 : Vérification finale
- [ ] **Step 1 : Suites amount**
Run : `npm run test -- amountFormat.test.ts InputAmount.test.ts`
Expected : PASS (helpers + composant).
- [ ] **Step 2 : Lint global**
Run : `npm run lint`
Expected : 0 erreur.
- [ ] **Step 3 : Vérification manuelle (playground)**
Run : `npm run dev`, ouvrir `composant/input/inputAmount`, carte « Grand montant ».
Vérifier :
- Taper `1234567` → affiche `1 234 567` au fil de la frappe ; `modelValue` affiché = `1234567`.
- Taper une virgule + décimales → `1 234 567,89` ; `modelValue` = `1234567.89`.
- Le curseur reste cohérent quand un séparateur s'insère (taper au milieu d'un nombre).
- La valeur initiale `1234567.89` s'affiche groupée au montage.
---
## Self-Review
**Spec coverage :**
- Modèle propre, séparateurs visuels → Task 2 (binding `formattedValue`, emit `model`).
- Temps réel + curseur → Task 2 Step 4 (`onInput` avec `countSignificant`/`caretFromSignificant`).
- Format FR (espace + virgule) → Task 1 (`formatGroupedAmount`).
- Par défaut sur tous (pas de prop) → aucune prop ajoutée.
- `maxLength` sur le modèle + suppression `maxlength` natif → Task 2 Steps 3-4, tests Task 3.
- Extraction `amountFormat.ts` → Task 1.
- Table de vérité → Task 1 tests + Task 3 tests.
- Docs + story + playground → Tasks 4, 5.
**Placeholder scan :** aucun TODO/TBD ; code fourni intégralement.
**Type consistency :** `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant` (signatures identiques entre Task 1, leur usage Task 2, et les imports). `formattedValue` (computed) utilisée dans le binding et le chemin de rejet maxLength. `model` = sortie de `normalizeAmount`, émis tel quel.
@@ -0,0 +1,458 @@
# MalioInputEmail — bouton « + » d'ajout — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter à `MalioInputEmail` un bouton « + » optionnel (prop `addable`) qui émet un event `add`, calqué sur `MalioInputPhone`, sans toucher à la logique de sanitisation email existante.
**Architecture:** Recopie du pattern `addable` de `InputPhone.vue` dans `InputEmail.vue` (props `addable`/`addIconName`/`addButtonLabel`, event `add`, bouton `data-test="add-button"`). L'icône email étant à droite par défaut, une nouvelle computed `effectiveIconPosition` la force à gauche quand `addable` est actif, libérant la droite pour le bouton. Aucune modification de `onInput`/`sanitizeEmail`/`lowercase`.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `@iconify/vue` (Icon), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputemail-bouton-ajout-design.md`
---
## File Structure
- **Modify** `app/components/malio/input/InputEmail.vue` — props `addable`/`addIconName`/`addButtonLabel`, event `add`, `effectiveIconPosition`, 4 computeds repointées, bouton + handler `onAdd` + `mergedAddButtonClass`.
- **Modify** `app/components/malio/input/InputEmail.test.ts` — tests du bouton + repositionnement icône.
- **Modify** `COMPONENTS.md` — props + event + exemple.
- **Modify** `CHANGELOG.md` — entrée de version.
- **Modify** `app/story/input/inputEmail.story.vue` — carte « addable ».
- **Modify** `.playground/pages/composant/input/inputEmail.vue` — exemple d'ajout dynamique.
**Note hooks pré-commit :** le repo a un hook `make pre-commit` (lint + suite complète ~888 tests) KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout de test sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Toujours stager des fichiers explicites — **jamais** `git add -A` (le `nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
Le composant de référence est `app/components/malio/input/InputPhone.vue` (le pattern `addable` y est déjà implémenté à l'identique).
---
## Task 1 : `InputEmail.vue` — bouton addable + icône effective
**Files:**
- Modify: `app/components/malio/input/InputEmail.vue`
Comportement attendu : `addable=false` (défaut) ⇒ rendu strictement inchangé ; `addable=true` ⇒ bouton « + » à droite, icône email à gauche, event `add` émis au clic (sauf `disabled`/`readonly`).
- [ ] **Step 1 : Ajouter les props `addable`/`addIconName`/`addButtonLabel`**
Dans `defineProps<{...}>()`, ajouter ces trois lignes juste après `iconColor?: string` :
```ts
addable?: boolean
addIconName?: string
addButtonLabel?: string
```
Dans `withDefaults(..., { ... })`, ajouter juste après `iconColor: 'text-m-muted',` :
```ts
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter une adresse email',
```
- [ ] **Step 2 : Ajouter l'event `add`**
Remplacer :
```ts
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
```
par :
```ts
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'add'): void
}>()
```
- [ ] **Step 3 : Ajouter le handler `onAdd`**
Juste après la fonction `onInput` (après son `}` de fermeture, avant `const iconInputPaddingClass`), ajouter :
```ts
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
```
- [ ] **Step 4 : Ajouter `effectiveIconPosition` et réécrire `iconInputPaddingClass`**
Remplacer le bloc actuel :
```ts
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
})
```
par :
```ts
const effectiveIconPosition = computed(() =>
props.addable && props.iconName ? 'left' : props.iconPosition,
)
const iconInputPaddingClass = computed(() => {
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
const parts: string[] = []
if (leftIcon) parts.push('!pl-11')
if (rightIcon || props.addable) parts.push('!pr-10')
return parts.join(' ')
})
```
- [ ] **Step 5 : Repointer `labelPositionClass`, `focusPaddingClass`, `iconPositionClass` sur `effectiveIconPosition`**
Remplacer :
```ts
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
```
par :
```ts
const labelPositionClass = computed(() => {
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
```
- [ ] **Step 6 : Ajouter la computed `mergedAddButtonClass`**
Juste après la computed `mergedLabelClass` (après son `)` de fermeture, avant `const describedBy`), ajouter :
```ts
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
```
- [ ] **Step 7 : Ajouter le bouton dans le template**
Dans le template, juste après le bloc `<IconifyIcon v-if="iconName" ... />` (sa balise fermante `/>`) et avant la `</div>` qui ferme le conteneur du champ, insérer :
```html
<button
v-if="addable"
type="button"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@click="onAdd"
>
<IconifyIcon
:icon="addIconName"
:width="24"
:height="24"
data-test="add-icon"
/>
</button>
```
- [ ] **Step 8 : Vérifier la non-régression**
Run : `npm run test -- InputEmail.test.ts`
Expected : PASS — tous les tests existants passent toujours (le cas `addable=false` est strictement inchangé : icône à droite, paddings identiques).
- [ ] **Step 9 : Commit**
```bash
git add app/components/malio/input/InputEmail.vue
git commit -m "feat(email) : bouton + d'ajout (event add) sur MalioInputEmail"
```
---
## Task 2 : Tests du bouton addable
**Files:**
- Modify: `app/components/malio/input/InputEmail.test.ts`
Le fichier utilise déjà un helper `mountComponent(props)` qui stub `IconifyIcon` en `<span data-test="icon" v-bind="$attrs" />`. L'icône email rend `data-test="icon"` ; le `<button>` rend `data-test="add-button"` et son icône interne `data-test="add-icon"` — donc `[data-test="icon"]` ne matche que l'icône email.
- [ ] **Step 1 : Étendre le type `InputEmailProps`**
Dans le type `InputEmailProps` (en tête de fichier), ajouter après `lowercase?: boolean` :
```ts
addable?: boolean
addIconName?: string
addButtonLabel?: string
```
- [ ] **Step 2 : Ajouter les tests addable**
À l'intérieur du `describe('MalioInputEmail', () => { ... })`, juste avant la `})` finale qui ferme ce describe, ajouter :
```ts
it('does not render add button by default', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('renders add button when addable is true', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
})
it('emits add event when add button is clicked', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit add when disabled', async () => {
const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('does not emit add when readonly', async () => {
const wrapper = mountComponent({addable: true, readonly: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('moves the email icon to the left automatically when addable', () => {
const wrapper = mountComponent({addable: true})
const icon = wrapper.get('[data-test="icon"]')
expect(icon.classes()).toContain('left-[10px]')
expect(icon.classes()).not.toContain('right-[10px]')
})
it('keeps the email icon on the right when addable is false', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('uses the default add button aria-label', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
})
it('allows overriding the add button aria-label', () => {
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
})
```
- [ ] **Step 3 : Lancer les tests**
Run : `npm run test -- InputEmail.test.ts`
Expected : PASS — tests existants + 11 nouveaux.
Si le test `moves the email icon to the left` échoue parce que `get('[data-test="icon"]')` trouve plusieurs éléments, c'est que le stub du bouton-icône a rendu `data-test="icon"` au lieu de `add-icon` ; debug en loggant `wrapper.findAll('[data-test="icon"]').length`. Ne PAS affaiblir l'assertion sans comprendre : `data-test="add-icon"` doit primer via `v-bind="$attrs"`.
- [ ] **Step 4 : Commit**
```bash
git add app/components/malio/input/InputEmail.test.ts
git commit -m "test(email) : couvre le bouton + d'ajout de MalioInputEmail"
```
---
## Task 3 : Documentation (COMPONENTS.md + CHANGELOG.md)
**Files:**
- Modify: `COMPONENTS.md`
- Modify: `CHANGELOG.md`
- [ ] **Step 1 : Ajouter les props au tableau `MalioInputEmail`**
Dans `COMPONENTS.md`, section `## MalioInputEmail`, dans le tableau des props, insérer ces lignes juste après la ligne `| \`iconColor\` | \`string\` | \`'text-m-muted'\` | Classe couleur icône |` :
```markdown
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
```
- [ ] **Step 2 : Documenter l'event `add` et ajouter un exemple**
Dans la même section, remplacer la ligne :
```markdown
**Events :** `update:modelValue(value: string)`
```
par :
```markdown
**Events :**
- `update:modelValue(value: string)`
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
```
Puis, dans le bloc d'exemple ```vue de cette section, ajouter cette ligne juste avant la fence fermante ``` :
```vue
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
```
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (juste après `* [#MUI-41] InputEmail : sanitisation à la saisie ...`) :
```markdown
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
```
- [ ] **Step 4 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(email) : documente le bouton + d'ajout de MalioInputEmail"
```
---
## Task 4 : Story + playground
**Files:**
- Modify: `app/story/input/inputEmail.story.vue`
- Modify: `.playground/pages/composant/input/inputEmail.vue`
- [ ] **Step 1 : Ajouter une carte « addable » dans la story**
Dans `app/story/input/inputEmail.story.vue`, juste après la carte « Icône à gauche » (le `<div class="rounded-lg border p-4">` qui se termine ligne 19, contenant `icon-position="left"`) et avant la carte « Sans icône », insérer :
```html
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
<MalioInputEmail
v-model="addableValue"
label="Adresse email"
addable
@add="onAdd"
/>
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
Bouton cliqué {{ addClicks }} fois
</p>
</div>
```
- [ ] **Step 2 : Déclarer les refs/handler dans le `<script setup>` de la story**
Dans le `<script setup>` de `app/story/input/inputEmail.story.vue`, après la ligne `const simpleValue = ref('')`, ajouter :
```ts
const addableValue = ref('')
const addClicks = ref(0)
const onAdd = () => { addClicks.value += 1 }
```
- [ ] **Step 3 : Ajouter un exemple d'ajout dynamique dans le playground**
Dans `.playground/pages/composant/input/inputEmail.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` qui se termine ligne 15) et avant la carte « Icône à gauche », insérer :
```html
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
<div class="space-y-3">
<MalioInputEmail
v-for="(email, index) in emails"
:key="index"
v-model="emails[index]"
label="Adresse email"
addable
@add="emails.push('')"
/>
</div>
</div>
```
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` du playground**
Dans le `<script setup>` de `.playground/pages/composant/input/inputEmail.vue`, après la ligne `const emailValue = ref('')`, ajouter :
```ts
const emails = ref<string[]>([''])
```
- [ ] **Step 5 : Vérifier le lint**
Run : `npm run lint`
Expected : 0 erreur sur les deux fichiers modifiés (des warnings pré-existants sur d'AUTRES fichiers sont tolérés).
- [ ] **Step 6 : Commit**
```bash
git add app/story/input/inputEmail.story.vue .playground/pages/composant/input/inputEmail.vue
git commit -m "docs(email) : exemples bouton + d'ajout (story + playground)"
```
---
## Task 5 : Vérification finale
- [ ] **Step 1 : Suite InputEmail**
Run : `npm run test -- InputEmail.test.ts`
Expected : PASS (existants + 11 nouveaux).
- [ ] **Step 2 : Lint global**
Run : `npm run lint`
Expected : 0 erreur.
- [ ] **Step 3 : Vérification manuelle (recommandée)**
Run : `npm run dev`, ouvrir `composant/input/inputEmail`.
Vérifier :
- Carte « Ajout dynamique » : cliquer « + » ajoute un nouveau champ email en dessous.
- Avec `addable`, l'icône email est à gauche et le « + » à droite, sans chevauchement.
- Le bouton « + » est grisé/inactif en `disabled`.
- Les autres cartes email (sans `addable`) sont inchangées (icône à droite).
---
## Self-Review
**Spec coverage :**
- Props `addable`/`addIconName`/`addButtonLabel` (défauts `false`/`'mdi:plus'`/`'Ajouter une adresse email'`) → Task 1 Step 1.
- Event `add` → Task 1 Step 2.
- `effectiveIconPosition` (icône à gauche si addable) + 4 computeds repointées → Task 1 Steps 4-5.
- `iconInputPaddingClass` aligné Phone (pr-10 si addable) → Task 1 Step 4.
- Bouton template + `mergedAddButtonClass` + `onAdd` (garde disabled/readonly) → Task 1 Steps 3, 6, 7.
- Logique email existante intacte (`onInput`/`sanitizeEmail`/`lowercase` non touchés) → aucune tâche ne les modifie.
- Tests (présence, émission, gardes disabled/readonly, repositionnement icône, libellé) → Task 2.
- Docs COMPONENTS.md + CHANGELOG.md → Task 3 ; story + playground → Task 4.
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
**Type consistency :** `addable`/`addIconName`/`addButtonLabel` (props), `add` (event), `onAdd`/`effectiveIconPosition`/`mergedAddButtonClass`/`iconStateClass` (composant) — noms cohérents entre tâches. Les `data-test` (`add-button`, `add-icon`, `icon`) concordent entre composant (Task 1) et tests (Task 2). `iconStateClass` et `twMerge` existent déjà dans `InputEmail.vue`.
@@ -0,0 +1,635 @@
# MalioDate — saisie manuelle au clavier — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre la saisie clavier `JJ/MM/AAAA` dans `MalioDate` (opt-in via prop `editable`), en plus de la sélection au calendrier, avec validation au blur et état d'erreur visuel.
**Architecture:** `CalendarField` (interne, partagé) gagne un mode `editable` : input non `readonly`, masque `maska`, buffer local `draft` synchronisé sur `displayValue`, émission d'un event `commit(text)` au blur / à Entrée. `MalioDate` conserve toute la logique date : parse (`parseDisplayToIso`), validation bornes (`isDateInRange`), état d'erreur interne fusionné avec la prop `error` du consommateur. `CalendarField` reste agnostique au format.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `maska` (directive `v-maska`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
**Référence spec :** `docs/superpowers/specs/2026-06-09-maliodate-saisie-manuelle-design.md`
---
## File Structure
- **Modify** `app/components/malio/date/internal/CalendarField.vue` — mode `editable` : prop, masque, buffer `draft`, handlers focus/input/blur/enter, event `commit`.
- **Modify** `app/components/malio/date/Date.vue` — props `editable` / `invalidMessage`, état `internalError`, handler `onCommit`, fusion `mergedError`, nettoyage erreur à la sélection/clear.
- **Modify** `app/components/malio/date/Date.test.ts` — tests de saisie manuelle + non-régression.
- **Modify** `COMPONENTS.md` — documentation des props.
- **Modify** `CHANGELOG.md` — entrée de version.
- **Modify** `.playground/pages/composant/date/date.vue` — exemple éditable.
- **Modify** `app/story/date/datePicker.story.vue` — exemple éditable.
**Note hooks pré-commit :** le projet a un hook `make pre-commit` (lint + 888 tests) parfois lent/flaky. Si un commit échoue sur un timeout de test sans rapport, relancer ; en dernier recours `--no-verify`. Toujours stager des fichiers explicites, **jamais** `git add -A` (le `nuxt.config.ts` modifié localement ne doit pas être committé).
---
## Task 1 : `CalendarField` — prop `editable`, masque et buffer
**Files:**
- Modify: `app/components/malio/date/internal/CalendarField.vue`
Cette tâche ajoute l'infrastructure du mode éditable. On la valide via les tests de la Task 3 (le comportement observable passe par `MalioDate`). Ici on vérifie surtout la non-régression : `editable=false` ⇒ input `readonly`, valeur affichée intacte.
- [ ] **Step 1 : Ajouter les imports `maska`**
Dans le bloc `<script setup>`, juste après la ligne `import {twMerge} from 'tailwind-merge'` (ligne 104), ajouter :
```ts
import {vMaska} from 'maska/vue'
import type {MaskInputOptions} from 'maska'
```
- [ ] **Step 2 : Ajouter la prop `editable` à l'interface et aux défauts**
Dans `defineProps<{...}>()`, ajouter la ligne après `clearable?: boolean` :
```ts
editable?: boolean
```
Dans le bloc `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
```ts
editable: false,
```
- [ ] **Step 3 : Déclarer l'event `commit`**
Remplacer la ligne (≈152) :
```ts
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
```
par :
```ts
const emit = defineEmits<{
(e: 'clear' | 'close'): void
(e: 'commit', value: string): void
}>()
```
- [ ] **Step 4 : Ajouter le buffer `draft`, le masque et l'état `readonly` calculé**
Juste après la ligne `const root = ref<HTMLElement | null>(null)` (≈156), ajouter :
```ts
const draft = ref(props.displayValue)
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
watch(() => props.displayValue, (value) => {
draft.value = value
})
```
(Note : `mask: undefined` désactive le masquage de `maska` — la valeur passe intacte. Ne **pas** utiliser `''`, qui viderait la valeur.)
- [ ] **Step 5 : Mettre à jour le computed `isFilled` pour tenir compte du buffer**
Remplacer (≈164) :
```ts
const isFilled = computed(() => props.displayValue.length > 0)
```
par :
```ts
const isFilled = computed(() =>
(props.editable ? draft.value.length : props.displayValue.length) > 0,
)
```
- [ ] **Step 6 : Remplacer `onFieldClick` et ajouter les handlers éditables**
Remplacer le bloc `onFieldClick` (≈177-185) :
```ts
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
```
par :
```ts
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (props.editable) {
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
return
}
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
const onFocus = () => {
if (props.disabled || props.readonly || !props.editable) return
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
}
const onInput = (event: Event) => {
draft.value = (event.target as HTMLInputElement).value
}
const onBlur = () => {
if (!props.editable) return
emit('commit', draft.value)
}
const onEnter = () => {
if (!props.editable) return
emit('commit', draft.value)
closePopover()
}
```
- [ ] **Step 7 : Mettre à jour l'`<input>` dans le template**
Remplacer le bloc `<input>` (≈7-25) :
```html
<input
:id="inputId"
:name="name"
data-test="date-input"
readonly
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
>
```
par :
```html
<input
:id="inputId"
v-maska="maskaOptions"
:name="name"
data-test="date-input"
:readonly="inputReadonly"
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="editable ? draft : displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
@focus="onFocus"
@input="onInput"
@blur="onBlur"
@keydown.enter.prevent="onEnter"
>
```
- [ ] **Step 8 : Lancer la suite Date pour vérifier la non-régression**
Run : `npm run test -- Date.test.ts`
Expected : PASS (tous les tests existants de `MalioDate` passent toujours ; l'input par défaut reste `readonly` et affiche la valeur formatée).
- [ ] **Step 9 : Commit**
```bash
git add app/components/malio/date/internal/CalendarField.vue
git commit -m "feat(date) : mode editable dans CalendarField (saisie clavier)"
```
---
## Task 2 : `MalioDate` — parsing, validation et état d'erreur
**Files:**
- Modify: `app/components/malio/date/Date.vue`
- [ ] **Step 1 : Étendre les imports de `dateFormat`**
Remplacer (≈39) :
```ts
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
```
par :
```ts
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
```
Et compléter l'import Vue (≈36) pour disposer de `ref` :
```ts
import {computed, ref, watch} from 'vue'
```
- [ ] **Step 2 : Ajouter les props `editable` et `invalidMessage`**
Dans `defineProps<{...}>()`, ajouter après `clearable?: boolean` :
```ts
editable?: boolean
invalidMessage?: string
```
Dans `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
```ts
editable: false,
invalidMessage: 'Date invalide',
```
- [ ] **Step 3 : Ajouter l'état d'erreur interne, la fusion, et les handlers**
Juste après la ligne `const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))` (≈86), ajouter :
```ts
const internalError = ref('')
const mergedError = computed(() => props.error || internalError.value)
const onCommit = (text: string) => {
const trimmed = text.trim()
if (trimmed === '') {
internalError.value = ''
emit('update:modelValue', null)
return
}
const iso = parseDisplayToIso(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) {
internalError.value = ''
emit('update:modelValue', iso)
return
}
internalError.value = props.invalidMessage
}
const onClear = () => {
internalError.value = ''
emit('update:modelValue', null)
}
const onSelect = (iso: string, close: () => void) => {
internalError.value = ''
emit('update:modelValue', iso)
close()
}
```
- [ ] **Step 4 : Brancher les props et events sur `CalendarField` dans le template**
Dans `<CalendarField ...>`, remplacer `:error="error"` (≈13) par :
```html
:error="mergedError"
```
Ajouter, juste après `:clearable="clearable"` (≈15) :
```html
:editable="editable"
```
Remplacer `@clear="emit('update:modelValue', null)"` (≈20) par :
```html
@clear="onClear"
@commit="onCommit"
```
- [ ] **Step 5 : Brancher la sélection calendrier sur `onSelect`**
Remplacer (≈29) :
```html
@select="(iso) => { emit('update:modelValue', iso); close() }"
```
par :
```html
@select="(iso) => onSelect(iso, close)"
```
- [ ] **Step 6 : Lancer la suite Date pour vérifier la non-régression**
Run : `npm run test -- Date.test.ts`
Expected : PASS (les tests existants passent ; `mergedError` se comporte comme `error` tant qu'aucune saisie invalide n'est faite).
- [ ] **Step 7 : Commit**
```bash
git add app/components/malio/date/Date.vue
git commit -m "feat(date) : saisie manuelle MalioDate (parse, validation, erreur)"
```
---
## Task 3 : Tests de la saisie manuelle
**Files:**
- Modify: `app/components/malio/date/Date.test.ts`
- [ ] **Step 1 : Étendre le type de props de test**
Dans le type `DateProps` (≈6-25), ajouter après `groupClass?: string` :
```ts
editable?: boolean
invalidMessage?: string
```
- [ ] **Step 2 : Écrire le bloc de tests `saisie manuelle (editable)`**
Ajouter, juste avant la fermeture du `describe('MalioDate', ...)` (avant la dernière `})` du fichier, après le bloc `describe('reserveMessageSpace', ...)`), le bloc suivant :
```ts
describe('saisie manuelle (editable)', () => {
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('readonly')).toBeDefined()
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
})
it('editable=true : l\'input n\'est plus readonly', () => {
const wrapper = mountDate({editable: true})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
})
it('émet l\'ISO sur saisie clavier valide au blur', 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:modelValue')?.at(-1)).toEqual(['2026-05-19'])
})
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', 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:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
it('passe en erreur si la date saisie est 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:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('Date invalide')
})
it('émet null 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:modelValue')?.at(-1)).toEqual([null])
})
it('efface l\'erreur de saisie 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.text()).toContain('Date invalide')
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.text()).not.toContain('Date invalide')
})
it('valide et ferme le popover sur Entrée', async () => {
const wrapper = mountDate({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('19/05/2026')
await input.trigger('keydown.enter')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999')
await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect')
})
})
```
- [ ] **Step 3 : Lancer les nouveaux tests**
Run : `npm run test -- Date.test.ts`
Expected : PASS (tous, anciens + nouveaux).
Si un test de saisie échoue parce que `maska` a reformaté la valeur en jsdom autrement qu'attendu, inspecter la valeur réelle via un `console.log((input.element as HTMLInputElement).value)` et ajuster l'assertion (le masque `##/##/####` laisse les chiffres tels quels ; une entrée déjà bien formée n'est pas modifiée).
- [ ] **Step 4 : Commit**
```bash
git add app/components/malio/date/Date.test.ts
git commit -m "test(date) : couvre la saisie manuelle de MalioDate"
```
---
## Task 4 : Documentation (COMPONENTS.md + CHANGELOG.md)
**Files:**
- Modify: `COMPONENTS.md`
- Modify: `CHANGELOG.md`
- [ ] **Step 1 : Ajouter les props au tableau `MalioDate` de `COMPONENTS.md`**
Dans la section `## MalioDate`, dans le tableau des props, insérer juste après la ligne `| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |` :
```markdown
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
```
- [ ] **Step 2 : Compléter la description et l'exemple `MalioDate`**
Dans la section `## MalioDate`, juste après la ligne de description `La valeur est une chaîne ISO ...`, ajouter le paragraphe :
```markdown
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`).
```
Dans le bloc d'exemple ```vue de cette section, ajouter une ligne avant la fermeture ``` :
```vue
<MalioDate v-model="date" label="Date de naissance" editable />
```
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
Dans `CHANGELOG.md`, sous `### Added`, ajouter à la fin de la liste (après la dernière puce `* [#MUI-41] InputEmail : ...`) :
```markdown
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
```
- [ ] **Step 4 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(date) : documente la saisie manuelle de MalioDate"
```
---
## Task 5 : Exemples playground + story
**Files:**
- Modify: `.playground/pages/composant/date/date.vue`
- Modify: `app/story/date/datePicker.story.vue`
- [ ] **Step 1 : Ajouter un bloc éditable dans la page playground**
Dans `.playground/pages/composant/date/date.vue`, dans la première colonne `Large (480px)`, juste après le `<div class="rounded border p-3 text-sm">...</div>` qui affiche la valeur ISO (≈13-15), ajouter :
```html
<MalioDate
v-model="editableValue"
label="Date (saisie clavier)"
editable
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
</div>
```
- [ ] **Step 2 : Déclarer la ref dans le `<script setup>` de la page playground**
Dans le `<script setup>` du même fichier, après `const bounded = ref<string | null>(null)`, ajouter :
```ts
const editableValue = ref<string | null>(null)
```
- [ ] **Step 3 : Ajouter un exemple éditable dans la story**
Dans `app/story/date/datePicker.story.vue`, ajouter une nouvelle carte juste après le bloc `<!-- Avec min/max -->` (le `<div class="rounded-lg border p-4">` qui contient « Avec min/max »), avant le bloc « Non effaçable » :
```html
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
<MalioDate
v-model="editableValue"
label="Date de naissance"
editable
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
/>
</div>
```
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` de la story**
Dans le `<script setup>` du même fichier, après `const errorValue = ref<string | null>(null)`, ajouter :
```ts
const editableValue = ref<string | null>(null)
```
- [ ] **Step 5 : Vérifier que rien ne casse (lint + build des types)**
Run : `npm run lint`
Expected : PASS (aucune erreur sur les fichiers modifiés).
- [ ] **Step 6 : Commit**
```bash
git add .playground/pages/composant/date/date.vue app/story/date/datePicker.story.vue
git commit -m "docs(date) : exemples saisie manuelle (playground + story)"
```
---
## Task 6 : Vérification finale
- [ ] **Step 1 : Lancer toute la suite de tests**
Run : `npm run test -- Date.test.ts`
Expected : PASS — l'ensemble du fichier (anciens + 9 nouveaux tests).
- [ ] **Step 2 : Lancer le lint global**
Run : `npm run lint`
Expected : PASS.
- [ ] **Step 3 : Vérification manuelle dans le playground (optionnel mais recommandé)**
Run : `npm run dev` puis ouvrir la page `composant/date`.
Vérifier :
- Taper `19/05/2026` puis cliquer ailleurs → la valeur ISO affichée devient `2026-05-19`.
- Taper `32/13/2026` puis blur → le texte reste, le champ passe en rouge avec « Date invalide ».
- Avec une saisie invalide, ouvrir le calendrier et choisir un jour → l'erreur disparaît, la valeur se met à jour.
- Le focus dans le champ ouvre bien le calendrier, et taper reste possible.
- Sur le champ `editable=false` existant : aucun changement (lecture seule).
---
## Self-Review
**Spec coverage :**
- Prop `editable` opt-in (défaut false) → Task 1 Step 2, Task 2 Step 2.
- Masque `##/##/####` + focus ouvre le popover → Task 1 Steps 4/6/7.
- Validation au blur, pas à la frappe → Task 1 (onInput ne valide pas) + Task 2 (onCommit).
- Saisie invalide : garde le texte + erreur visuelle → Task 2 Step 3 + Task 3 test dédié.
- Message par défaut « Date invalide », surchargeable → Task 2 Step 2.
- Touche Entrée commit + ferme popover → Task 1 Step 6 (`onEnter`) + Task 3 test.
- Hors min/max = invalide → Task 2 (`isDateInRange`) + Task 3 test.
- Sélection calendrier efface l'erreur → Task 2 Step 5 (`onSelect`) + Task 3 test.
- `disabled`/`readonly` priment → Task 1 (`inputReadonly`, gardes dans handlers).
- Non-régression `editable=false` → Task 1 Step 8 + Task 3 test readonly.
- Docs COMPONENTS.md + CHANGELOG.md + playground/story → Tasks 4 et 5.
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
**Type consistency :** `editable`/`invalidMessage` (props), `commit` (event CalendarField), `onCommit`/`onClear`/`onSelect`/`internalError`/`mergedError` (MalioDate), `draft`/`maskaOptions`/`inputReadonly`/`onFocus`/`onInput`/`onBlur`/`onEnter` (CalendarField) — noms cohérents entre tâches. `onCommit(text: string)` correspond à l'event `commit(value: string)`. `onSelect(iso: string, close: () => void)` correspond à la signature du slot (`close` exposé par `CalendarField`).
@@ -0,0 +1,117 @@
# MalioInputAmount — séparateurs de milliers (affichage groupé FR)
**Date :** 2026-06-09
**Statut :** Validé, prêt pour plan d'implémentation
**Périmètre :** `MalioInputAmount` uniquement.
## Objectif
Afficher les montants avec des **séparateurs de milliers** (espaces) et une **virgule décimale**, à la française : `1 234 567,89`. Demande du client ERP. Le formatage est **purement visuel** ; la valeur émise reste une chaîne numérique propre.
## Décisions validées
| Sujet | Décision |
|-------|----------|
| Valeur émise (`modelValue`) | Reste **propre** : point décimal, sans espaces (`'1234567.89'`). Contrat consommateur inchangé. Les séparateurs ne sont qu'à l'affichage. |
| Moment du formatage | **Temps réel** (à la frappe), avec gestion du curseur. |
| Format affiché | Français : **espace** pour les milliers, **virgule** pour les décimales (`1 234 567,89`). |
| Activation | **Par défaut sur tous** les `MalioInputAmount` (pas de prop opt-in). |
| `maxLength` | S'applique à la **longueur du `modelValue`** (chaîne propre), **pas** à l'affichage. Le `maxlength` natif (qui compterait les espaces) est retiré ; le plafond est appliqué en JS. |
| Extraction | Les fonctions pures vont dans `app/components/malio/input/composables/amountFormat.ts` (testables en isolation). |
## Conception détaillée
### 1. Contrat de valeur & flux de données
- **Modèle (émis)** : sortie inchangée de `normalizeAmount` — point décimal, max 2 décimales, zéros de tête retirés, `''` si vide. Ex. `1234567.89`.
- **Affichage** : `formatGroupedAmount(model)` groupe la partie entière par 3 avec des espaces et remplace le point par une virgule. Ex. `1 234 567,89`. Si le modèle finit par `.` (décimale en cours, ex. `12.`), l'affichage finit par `,` (`12,`).
- **Binding** : l'input affiche `formatGroupedAmount(currentValue)` au lieu de `currentValue`.
- **Parse** : à la frappe, le texte saisi (avec espaces/virgule) repasse par `normalizeAmount` → modèle propre → émis.
### 2. Temps réel & gestion du curseur
À chaque `@input` :
1. Lire `rawText = target.value` et `caret = target.selectionStart`.
2. `model = normalizeAmount(rawText)`.
3. **Plafond `maxLength`** (cf. §3) : si dépassement, ignorer le keystroke (restaurer l'affichage précédent, ne pas émettre).
4. Sinon : `display = formatGroupedAmount(model)` ; écrire `target.value = display` ; **émettre** `update:modelValue(model)`.
5. **Repositionner le curseur** : compter les caractères significatifs (tout sauf les espaces de groupement) à gauche du curseur dans `rawText`, puis placer le curseur après ce même nombre de caractères significatifs dans `display`.
Helpers curseur (purs) :
```ts
export const countSignificant = (str: string, upTo: number): number =>
str.slice(0, upTo).replace(/ /g, '').length
export const caretFromSignificant = (display: string, sig: number): number => {
let seen = 0
for (let i = 0; i < display.length; i++) {
if (display[i] !== ' ') seen++
if (seen >= sig) return i + 1
}
return display.length
}
```
`<input type="text">` supporte l'API de sélection, donc `setSelectionRange` fonctionne directement (pas de try/catch nécessaire).
### 3. `maxLength` sur le modèle
- On **retire** le binding `:maxlength="maxLength"` natif de l'`<input>` (il compterait les espaces de l'affichage).
- Dans `onInput`, après `model = normalizeAmount(rawText)` : si `props.maxLength != null` **et** `model.length > Number(props.maxLength)`, on **ignore** le keystroke :
- on restaure `target.value = formatGroupedAmount(currentValue)` (modèle précédent),
- on replace le curseur à `max(0, caret - 1)` (le caractère refusé n'est pas inséré),
- on **n'émet pas**.
- `maxLength` borne donc la **longueur de la chaîne `modelValue`** (point décimal inclus). Ce point est documenté explicitement.
- `minLength` : laissé tel quel (attribut natif de validation). Connu : il s'évalue sur le texte affiché ; hors périmètre de cette évolution.
### 4. Helpers extraits — `composables/amountFormat.ts`
- `normalizeAmount(value: string): string`**déplacé tel quel** depuis le composant (parse).
- `formatGroupedAmount(model: string): string` — nouveau (format groupé FR). Algorithme :
- Si `model === ''``''`.
- Séparer sur `.``integerPart`, `decimalPart` (présent ssi le modèle contient `.`).
- Grouper `integerPart` par paquets de 3 depuis la droite avec une espace `' '`.
- Si le modèle contient `.``groupedInteger + ',' + decimalPart` (decimalPart éventuellement vide).
- Sinon → `groupedInteger`.
- `countSignificant`, `caretFromSignificant` — helpers curseur (purs).
Le composant importe ces helpers ; la logique DOM (lecture `target.value`, `setSelectionRange`) reste dans `InputAmount.vue`.
### 5. Table de vérité (format/parse)
| Saisie utilisateur | `modelValue` émis | Affichage (`formatGroupedAmount`) |
|---|---|---|
| `1234567` | `1234567` | `1 234 567` |
| `1234,56` ou `1234.56` | `1234.56` | `1 234,56` |
| `12.` (décimale en cours) | `12.` | `12,` |
| `,5` | `0.5` | `0,5` |
| `0012345abc` | `12345` | `12 345` |
| `1234.567` (3 décimales) | `1234.56` | `1 234,56` |
| `` (vide) | `` | `` |
| `0` | `0` | `0` |
## Tests
**`composables/amountFormat.test.ts`** (nouveau) :
- `normalizeAmount` : reprise des cas existants (espaces, virgule→point, zéros de tête, 2 décimales max, vide, décimale en tête).
- `formatGroupedAmount` : table §5 (groupement par 3, virgule décimale, `12.``12,`, vide→vide, nombres < 1000 inchangés).
- `countSignificant` / `caretFromSignificant` : positions de curseur clés (avant/après un espace inséré, en fin de chaîne).
**`InputAmount.test.ts`** (mis à jour) :
- Les assertions `input.element.value` passent de la valeur brute (`1234.56`) à la valeur groupée (`1 234,56`).
- Les assertions d'émission `update:modelValue` restent **inchangées** (modèle propre : `'1234.56'`, `'0.5'`, `''`…).
- Nouveaux tests : groupement à la frappe d'un grand montant (`1234567` → affichage `1 234 567`, émis `1234567`) ; `maxLength` plafonne le modèle (un keystroke au-delà est ignoré, pas d'émission supplémentaire) ; position du curseur après insertion d'un séparateur.
## Livrables documentaires
- `COMPONENTS.md` : note dans la section `## MalioInputAmount` — affichage groupé FR (`1 234 567,89`), `modelValue` reste propre (`'1234567.89'`), `maxLength` borne la longueur du modèle.
- `CHANGELOG.md` : entrée sous `### Added` / `### Changed`.
- Story `app/story/input/inputAmount.story.vue` : exemple grand montant montrant les séparateurs.
- Playground `.playground/pages/composant/input/inputAmount.vue` : exemple grand montant + affichage de la valeur ISO/propre émise.
## Hors périmètre
- Internationalisation configurable (autres locales / séparateurs paramétrables) — on fige le format FR.
- `minLength` sur le modèle (reste natif sur l'affichage).
- Passage à `maska` en mode number (approche écartée au profit du `normalizeAmount` existant).
- Devises / symboles dynamiques (l'icône € existante est conservée telle quelle).
@@ -0,0 +1,157 @@
# MalioInputEmail — bouton « + » d'ajout (event `add`)
**Date :** 2026-06-09
**Statut :** Validé, prêt pour plan d'implémentation
**Périmètre :** `MalioInputEmail` uniquement.
## Objectif
Ajouter à `MalioInputEmail` le même bouton « + » que `MalioInputPhone` : un bouton optionnel à droite du champ qui émet un event `add`, permettant au consommateur d'ajouter dynamiquement un autre champ email (ou toute autre action).
La logique email existante (ajoutée en MUI-41) est **conservée intégralement** : `sanitizeEmail` (suppression des espaces), prop `lowercase`, gestion du caret dans `onInput`, `type="email"` / `inputmode="email"`. Ce travail n'y touche pas.
## Décisions validées
| Sujet | Décision |
|-------|----------|
| API | Calquée sur `MalioInputPhone` : props `addable` / `addIconName` / `addButtonLabel`, event `add`, `data-test="add-button"`. |
| Collision icône/bouton | L'icône email étant à droite par défaut, quand `addable` est actif l'icône passe **automatiquement à gauche** et le « + » occupe la droite (disposition éprouvée de Phone). |
| Libellé par défaut | `addButtonLabel` défaut `'Ajouter une adresse email'`. |
| Garde désactivé | Le bouton n'émet pas `add` si `disabled` ou `readonly` (comme Phone). |
| Approche | Recopier le pattern de Phone dans Email (pas d'extraction d'un composant partagé — noté comme cleanup futur possible, hors scope). |
## Conception détaillée
### 1. Props ajoutées (interface + défauts)
```ts
addable?: boolean // défaut: false
addIconName?: string // défaut: 'mdi:plus'
addButtonLabel?: string // défaut: 'Ajouter une adresse email'
```
### 2. Event ajouté
L'`defineEmits` passe de :
```ts
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
```
à :
```ts
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'add'): void
}>()
```
### 3. Position d'icône « effective »
Nouvelle règle, unique source du repositionnement :
```ts
const effectiveIconPosition = computed(() =>
props.addable && props.iconName ? 'left' : props.iconPosition,
)
```
Les quatre computeds qui dépendent aujourd'hui de `props.iconPosition` utilisent désormais `effectiveIconPosition.value` :
- `iconInputPaddingClass`
- `iconPositionClass`
- `labelPositionClass`
- `focusPaddingClass`
Conséquence :
- `addable=false``effectiveIconPosition === props.iconPosition` → comportement **strictement identique** à aujourd'hui.
- `addable=true` (avec une icône) → icône à gauche + espace réservé à droite pour le bouton.
### 4. `iconInputPaddingClass` aligné sur Phone
Remplacement de l'implémentation actuelle d'Email par la forme éprouvée de Phone (rendu identique dans le cas non-addable car la classe de base `pl-3 pr-3` est commune aux deux composants) :
```ts
const iconInputPaddingClass = computed(() => {
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
const parts: string[] = []
if (leftIcon) parts.push('!pl-11')
if (rightIcon || props.addable) parts.push('!pr-10')
return parts.join(' ')
})
```
### 5. Bouton dans le template
Inséré après le bloc `<IconifyIcon v-if="iconName">`, à l'identique de Phone :
```html
<button
v-if="addable"
type="button"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@click="onAdd"
>
<IconifyIcon
:icon="addIconName"
:width="24"
:height="24"
data-test="add-icon"
/>
</button>
```
### 6. `mergedAddButtonClass` (copie de Phone)
```ts
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
```
(`iconStateClass` existe déjà dans Email.)
### 7. Handler `onAdd` (copie de Phone)
```ts
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
```
## Quatre computeds à modifier — détail
Aujourd'hui dans `InputEmail.vue` ils référencent `props.iconPosition` ; ils doivent référencer `effectiveIconPosition.value`. Les corps restent identiques par ailleurs :
- `iconPositionClass` : `effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'`
- `labelPositionClass` : `props.iconName && effectiveIconPosition.value === 'left' ? 'left-11' : 'left-3'`
- `focusPaddingClass` : `props.iconName && effectiveIconPosition.value === 'left' ? 'focus:!pl-11' : 'focus:pl-[11px]'`
- `iconInputPaddingClass` : remplacé par la version §4.
## Tests (`InputEmail.test.ts`)
Ajouts (mirroring `InputPhone.test.ts`), sans toucher aux tests existants de sanitisation/lowercase :
- `addable=false` (défaut) → pas de `[data-test="add-button"]`.
- `addable=true``[data-test="add-button"]` présent.
- Clic sur le bouton → un event `add` émis (longueur 1).
- `addable + disabled` → clic n'émet pas `add` ; le bouton a l'attribut `disabled`.
- `addable + readonly` → clic n'émet pas `add` ; le bouton n'a PAS l'attribut natif `disabled` (la garde `onAdd` bloque).
- `addable=true` avec icône → l'icône email est positionnée à gauche (`left-[10px]` présent / `right-[10px]` absent sur l'icône `[data-test="icon"]`).
- Non-régression : avec `addable=false`, l'icône reste à droite (`right-[10px]`).
- `addButtonLabel` personnalisé → `aria-label` respecté ; défaut → `'Ajouter une adresse email'`.
## Livrables documentaires
- `COMPONENTS.md` : ajouter les lignes `addable` / `addIconName` / `addButtonLabel` et l'event `add()` dans la section `## MalioInputEmail`, plus un exemple `<MalioInputEmail ... addable @add="..." />`.
- `CHANGELOG.md` : entrée sous `### Added`.
- Story `app/story/input/inputEmail.story.vue` : une carte « Addable » avec `@add` (calquée sur `inputPhone.story.vue`).
- Playground `.playground/pages/composant/input/inputEmail.vue` : un exemple `addable` avec un handler qui illustre l'ajout d'un champ.
## Hors périmètre
- Extraction d'un composant/bouton partagé entre Phone et Email (refactor dédié futur).
- Gestion réelle de la liste de champs email côté composant (c'est au consommateur de réagir à l'event `add`, comme pour Phone).
- Toute modification de la logique de sanitisation / `lowercase` / caret existante.
@@ -0,0 +1,118 @@
# MalioDate — saisie manuelle au clavier
**Date :** 2026-06-09
**Statut :** Validé, prêt pour plan d'implémentation
**Périmètre :** `MalioDate` uniquement (la famille `DateTime`/`DateRange`/`DateWeek` n'est pas concernée pour l'instant).
## Objectif
Permettre à l'utilisateur de saisir une date au clavier (`JJ/MM/AAAA`) dans `MalioDate`, en plus de la sélection via le calendrier. Aujourd'hui l'`<input>` de `CalendarField` est codé en dur en `readonly` : seule la sélection au calendrier est possible.
## Décisions d'UX (validées)
| Sujet | Décision |
|-------|----------|
| Ouverture du popover | Le **focus** (ou le clic) ouvre le calendrier, tout en laissant taper en même temps. |
| Masque / validation | Masque `maska` `##/##/####` pendant la frappe ; **validation au blur** (pas à chaque touche). |
| Activation | **Opt-in** via une prop `editable` (défaut `false`). Aucune régression pour les consommateurs existants. |
| Saisie invalide au blur | On **garde le texte tapé** et on affiche un **état d'erreur visuel** (bordure rouge + message). |
| Message d'erreur par défaut | « **Date invalide** » (couvre aussi le hors-bornes min/max), surchargeable via prop. |
| Touche Entrée | Déclenche le `commit` (parse immédiat) + ferme le popover. |
## Approche retenue
**Mode `editable` dans `CalendarField`, parsing dans `MalioDate`.**
`CalendarField` reste agnostique au format : il expose un mode éditable (input non `readonly`, masque, buffer local) et émet du texte brut. `MalioDate` conserve toute la logique propre à la date (parse, validation `min`/`max`, état d'erreur). Cela évite de coupler `CalendarField` à un format date spécifique et garde le terrain prêt pour une éventuelle extension future à la famille Date.
Approches écartées :
- **`CalendarField` générique avec fonction `parse` injectée** : trop générique pour le périmètre actuel (YAGNI).
- **`MalioDate` gère son propre `<input>`** : duplication du rendu / label flottant / styles de `CalendarField`.
## Conception détaillée
### 1. `MalioDate` — props ajoutées
- `editable?: boolean` — défaut `false`. Active la saisie clavier.
- `invalidMessage?: string` — défaut `'Date invalide'`. Message affiché en cas de saisie invalide/hors-bornes.
Quand `editable === false`, le comportement est **strictement identique** à aujourd'hui (lecture seule, sélection calendrier uniquement).
### 2. `CalendarField` — mode éditable
Ajout d'une prop `editable?: boolean` (défaut `false`). Quand `true` :
- L'`<input>` perd l'attribut `readonly` et reçoit `v-maska="'##/##/####'"`.
- Un buffer local `draft` (ref) alimente l'input : `:value="editable ? draft : displayValue"`.
- `draft` est **resynchronisé** sur `displayValue` via un `watch` → couvre la sélection au calendrier, le clear, et tout changement externe de `modelValue`. Cette resynchro **efface aussi l'état d'erreur** côté `MalioDate` (via le nouveau `displayValue` émis).
- À la frappe (`@input`) : met à jour `draft` et émet `input(text)`. **Pas de validation** à ce stade.
- Au blur (`@blur`) : émet `commit(text)`.
- À la touche Entrée (`@keydown.enter`) : émet `commit(text)` + ferme le popover.
- `@focus` ouvre le popover, tout en laissant taper (input non `readonly`).
Quand `editable === false`, aucun de ces comportements ne s'applique : le chemin de code actuel reste inchangé.
`disabled` et `readonly` priment toujours sur `editable` (champ non éditable).
### 3. `MalioDate` — parsing, validation, état d'erreur
Une ref locale `internalError` est fusionnée avec la prop `error` du consommateur et transmise à `CalendarField` :
`:error="error || internalError"` (l'erreur métier du consommateur reste prioritaire).
Sur réception de `commit(text)` :
- **Texte vide**`emit('update:modelValue', null)` ; `internalError = ''`.
- **Valide** (`parseDisplayToIso(text)` non `null` **et** `isDateInRange(iso, min, max)`) → `emit('update:modelValue', iso)` ; `internalError = ''`.
- **Invalide ou hors-bornes** → on **n'émet pas** de nouveau `modelValue` ; `internalError = props.invalidMessage`. Le texte tapé reste affiché.
L'état d'erreur s'efface dès qu'une saisie valide ou une sélection calendrier ultérieure produit un nouveau `displayValue`.
## Flux de données
```
Frappe clavier
└─ CalendarField: maj draft + émet input(text) (pas de validation)
Blur / Entrée
└─ CalendarField: émet commit(text)
└─ MalioDate: parseDisplayToIso + isDateInRange
├─ valide → emit update:modelValue(iso) ; internalError=''
├─ vide → emit update:modelValue(null) ; internalError=''
└─ invalide→ internalError = invalidMessage ; (texte conservé)
Sélection calendrier
└─ emit update:modelValue(iso)
└─ displayValue change → CalendarField resync draft → erreur effacée
```
## Réutilisation de l'existant
Les helpers nécessaires existent déjà dans `app/components/malio/date/composables/dateFormat.ts` :
- `parseDisplayToIso(display)``string | null`
- `isValidIso(iso)``boolean`
- `isDateInRange(iso, min?, max?)``boolean`
- `formatIsoToDisplay(iso)``string`
`maska` est déjà une dépendance du projet (utilisée par `InputText`/`InputPhone` via `v-maska` + `vMaska` de `maska/vue`).
## Tests (`Date.test.ts`)
- Frappe valide + blur → émet l'ISO attendu.
- Saisie invalide (`32/13/2026`) au blur → texte conservé, message « Date invalide », `aria-invalid`.
- Date valide hors `min`/`max` au blur → état d'erreur.
- Saisie vide au blur → émet `null`.
- Sélection au calendrier après une saisie invalide → erreur effacée, valeur mise à jour.
- Touche Entrée → commit + fermeture popover.
- `editable=false` (défaut) → input reste `readonly`, aucun nouveau comportement (non-régression).
- `invalidMessage` personnalisé → message affiché respecté.
## Livrables documentaires
- Mise à jour de `COMPONENTS.md` (props `editable`, `invalidMessage`).
- Entrée dans `CHANGELOG.md`.
- Mise à jour de la story Histoire + page playground de `MalioDate` pour exposer la prop `editable`.
## Hors périmètre
- Extension de la saisie manuelle à `DateTime`, `DateRange`, `DateWeek`.
- Saisie partielle « intelligente » (auto-complétion d'année, etc.).
- Validation à la frappe (on reste sur validation au blur).
+8
View File
@@ -6,5 +6,13 @@ export default defineConfig({
test: {
environment: 'jsdom',
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,
},
})