Compare commits

...

5 Commits

Author SHA1 Message Date
tristan 1d66e5dd31 fix: plusieurs retours UX/UI (#58)
Release / release (push) Successful in 1m11s
| 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: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #58
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 13:53:52 +00:00
tristan c0c39705c7 fix: drawer footer (#57)
Release / release (push) Successful in 1m20s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #57
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:53:43 +00:00
tristan acd531f69e feat: Ajout des composants modal, accordeon, datetime avec selecteur d'heure à la molette (#56)
Release / release (push) Successful in 2m38s
| 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: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #56
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:11:51 +00:00
tristan 7d7b2fb720 feat: Développer le composant Datepicker (#52)
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: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 08:08:50 +00:00
tristan 7ca5c5f4c5 fix: refonte du composant Drawer (#51)
Release / release (push) Successful in 1m25s
| 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: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:05:16 +00:00
114 changed files with 17792 additions and 584 deletions
+8 -1
View File
@@ -12,7 +12,14 @@
"Bash(mv buttonIcon.story.vue button/)",
"Bash(mv inputText.story.vue inputAmount.story.vue inputNumber.story.vue inputPassword.story.vue inputTextArea.story.vue inputUpload.story.vue input/)",
"Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)",
"Bash(mv inputCheckbox.story.vue checkbox/)"
"Bash(mv inputCheckbox.story.vue checkbox/)",
"Bash(npx eslint *)",
"Bash(echo \"LINT EXIT: $?\")",
"Bash(git commit *)",
"mcp__chrome__navigate_page",
"mcp__chrome__take_snapshot",
"mcp__chrome__click",
"mcp__chrome__evaluate_script"
]
}
}
@@ -108,9 +108,9 @@ npm run lint # Pas d'erreurs
### 5. Créer la page playground
**Fichier :** `.playground/pages/composant/<nomComposant>.vue` (camelCase)
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
La page est auto-détectée par `index.vue` via `import.meta.glob`. Inclure des variantes représentatives dans une grille :
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`. Inclure des variantes représentatives dans une grille :
```html
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
@@ -216,7 +216,7 @@ Cette section est alimentée au fur et à mesure des retours utilisateur et des
|--------|----------|
| Stub IconifyIcon ne fonctionne pas dans les tests | Utiliser `findComponent(IconifyIcon)` avec l'import réel pour tester les props |
| Oubli de `inheritAttrs: false` | Toujours dans `defineOptions` — sinon les attrs se dupliquent |
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
| Padding input pas ajusté avec icône | Ajouter `!pr-10` (ou équivalent) quand une icône est présente à droite |
| Story sans initial state | Toujours initialiser les `ref` avec des valeurs pour que les variantes soient visibles dès le chargement |
| CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit |
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="flex h-screen">
<MalioSidebar :sections="navSections">
<template #logo>
<NuxtLink to="/">
<img src="/LOGO_MALIO.png" alt="Malio">
</NuxtLink>
</template>
<template #logo-collapsed>
<NuxtLink to="/">
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
</NuxtLink>
</template>
</MalioSidebar>
<main class="flex-1 overflow-y-auto p-6">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
import {navSections} from '../playground.nav'
</script>
@@ -0,0 +1,63 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) défaut</h2>
<MalioAccordion v-model="multiple">
<MalioAccordionItem title="Prix" value="prix">
<p>Slider de prix ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<p>Liste de checkboxes ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Marque" value="marque">
<p>Recherche + liste ici</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouverts : {{ multiple }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
<MalioAccordion v-model="single" mode="single">
<MalioAccordionItem title="Question 1" value="q1">
<p>Réponse 1</p>
</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">
<p>Réponse 2</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouvert : {{ single }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé + defaultOpen</h2>
<MalioAccordion>
<MalioAccordionItem title="Section A" value="a" :default-open="true">
<p>Ouverte au montage</p>
</MalioAccordionItem>
<MalioAccordionItem title="Section B" value="b">
<p>Fermée au montage</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
<MalioAccordion>
<MalioAccordionItem title="Active" value="ok">
<p>Contenu accessible</p>
</MalioAccordionItem>
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
<p>Inaccessible</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const multiple = ref<string[]>(['prix'])
const single = ref('q1')
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDate</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDate
v-model="value"
label="Date de naissance"
hint="Clique pour ouvrir le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25'"
>
Forcer le 25/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDate
v-model="erpValue"
label="Date du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDate
v-model="bounded"
label="Date bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {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())}`
const now = new Date()
const todayIso = toIso(now)
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>(null)
</script>
@@ -0,0 +1,72 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateRange</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateRange
v-model="value"
label="Période"
hint="Clique deux fois pour définir une plage"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
>
Forcer 2031/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateRange
v-model="erpValue"
label="Période"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ erpValue?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ erpValue?.end ?? 'null' }}</code></p>
</div>
<MalioDateRange
v-model="bounded"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<RangeValue | null>(null)
const erpValue = ref<RangeValue | null>(null)
const bounded = ref<RangeValue | null>(null)
</script>
@@ -0,0 +1,68 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateWeek
v-model="value"
label="Semaine"
hint="Clique un jour ou un n° de semaine"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-W52'"
>
Forcer 2026-W52
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateWeek
v-model="erpValue"
label="Semaine"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateWeek
v-model="bounded"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {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())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
@@ -0,0 +1,68 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateTime</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateTime
v-model="value"
label="Date et heure du rendez-vous"
hint="Choisis un jour puis une heure"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25T09:30:00'"
>
Forcer le 25/12/2026 09:30
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateTime
v-model="erpValue"
label="Date et heure du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateTime
v-model="bounded"
label="Créneau borné"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {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())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
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')
</script>
+60 -25
View File
@@ -1,48 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
const drawerDefault = ref(false)
const drawerNoClose = ref(false)
const drawerCustomWidth = ref(false)
const drawerWithForm = ref(false)
const drawerRight = ref(false)
const drawerLeft = ref(false)
const drawerForm = ref(false)
const drawerFixedFooter = ref(false)
const drawerNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Drawer simple</h2>
<MalioButton label="Ouvrir le drawer" @click="drawerDefault = true" />
<MalioDrawer v-model="drawerDefault" title="Titre du drawer">
<p class="text-m-text">Contenu du drawer. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
<MalioDrawer v-model="drawerRight">
<template #header>
<h2 class="text-[24px] font-bold text-black">Détails</h2>
</template>
<p class="text-m-text">Contenu du drawer. Échap, clic backdrop et croix le ferment.</p>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Sans bouton fermer</h2>
<MalioButton label="Ouvrir le drawer" variant="secondary" @click="drawerNoClose = true" />
<MalioDrawer v-model="drawerNoClose" title="Sans croix" :show-close="false">
<p class="text-m-text">Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
<h2 class="mb-6 text-xl font-bold">Drawer gauche</h2>
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
<MalioDrawer v-model="drawerLeft" side="left">
<template #header>
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
</template>
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Largeur personnalisée</h2>
<MalioButton label="Ouvrir le drawer large" variant="tertiary" @click="drawerCustomWidth = true" />
<MalioDrawer v-model="drawerCustomWidth" title="Drawer large" drawer-class="max-w-2xl">
<p class="text-m-text">Ce drawer utilise une largeur personnalisée via drawerClass.</p>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec formulaire</h2>
<MalioButton label="Ouvrir le formulaire" variant="danger" @click="drawerWithForm = true" />
<MalioDrawer v-model="drawerWithForm" title="Formulaire">
<div class="flex flex-col gap-4">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
</template>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
<MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
<MalioDrawer v-model="drawerFixedFooter">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
<div class="flex flex-col gap-4">
<p v-for="n in 12" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
</template>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="drawerNoDismiss = true" />
<MalioDrawer v-model="drawerNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
</MalioDrawer>
</div>
</div>
@@ -0,0 +1,88 @@
<template>
<div class="flex justify-center">
<div class="w-[1348px]">
<div class="flex items-center justify-between mt-[46px]">
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
aria-label="Précédent"
variant="ghost"
/>
<h1 class="text-[32px] text-m-primary font-bold">Filtres</h1>
</div>
<MalioButton
label="Filtres"
variant="tertiary"
icon-name="mdi:tune"
icon-position="left"
button-class="w-[184px] px-2 py-2 justify-start text-black gap-4"
@click="drawerOpen = true"
/>
</div>
</div>
<MalioDrawer
v-model="drawerOpen"
side="right"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between gap-4 py-7"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">Filtres</h2>
</template>
<MalioAccordion>
<MalioAccordionItem title="Type de camion" value="camion">
<div class="flex flex-col gap-6">
<MalioCheckbox v-model="semiBenne" label="Semi Benne" />
<MalioCheckbox v-model="benne" label="Benne" />
</div>
</MalioAccordionItem>
<MalioAccordionItem title="Date à Date" value="date">
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>Du</span>
<MalioDate v-model="dateDebut"/>
<span>Au</span>
<MalioDate v-model="dateFin"/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
label="Réinitialiser"
variant="tertiary"
button-class="w-[150px]"
@click="resetFiltres"
/>
<MalioButton
label="Voir les résultats"
variant="primary"
button-class="w-[170px]"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const drawerOpen = ref(false)
const semiBenne = ref(false)
const benne = ref(false)
const dateDebut = ref<string | null>(null)
const dateFin = ref<string | null>(null)
function resetFiltres() {
semiBenne.value = false
benne.value = false
dateDebut.value = null
dateFin.value = null
}
</script>
+10 -4
View File
@@ -10,7 +10,7 @@
/>
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
</div>
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8">
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
label="Nom du client (Entreprise)"
/>
@@ -22,6 +22,7 @@
/>
<MalioSelectCheckbox
v-model="multiselectValue"
error="test"
label="Catégorie"
:options="[
{label: 'Catégorie 1', value: 'Catégorie 1'},
@@ -75,10 +76,13 @@
<div class="mt-[60px]">
<MalioTabList :tabs="tabs" v-model="tabsValue">
<template #information>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioInputText label="Date création"/>
<MalioDate
v-model="dateCreation"
label="Date création"
/>
<MalioInputText label="Nombre de salariés" />
<MalioInputAmount label="CA"/>
<MalioInputText label="Dirigeant" />
@@ -89,7 +93,7 @@
</div>
</template>
<template #adresses>
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer l'adresse"
@@ -158,6 +162,7 @@
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import MalioDate from "../../../../app/components/malio/date/Date.vue";
type Commune = {
nom: string
@@ -279,6 +284,7 @@ const onSearchAdresse = async (query: string) => {
const tabsValue = ref('information')
const concurrent = ref('')
const dateCreation = ref<string | null>(null)
const informationValid = computed(() => concurrent.value.trim().length > 0)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
@@ -6,6 +6,7 @@
v-model="simpleValue"
label="Pays"
:options="staticOptions"
local-filter
/>
<p class="mt-2 text-sm text-m-muted">
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
@@ -20,6 +21,7 @@
icon-name="mdi:magnify"
icon-position="left"
:options="staticOptions"
local-filter
/>
</div>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import MalioButton from "../../../../app/components/malio/button/Button.vue";
const modalBase = ref(false)
const modalForm = ref(false)
const modalLong = ref(false)
const modalNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
<MalioButton label="Ouvrir" @click="modalBase = true" />
<MalioModal v-model="modalBase" headerClass="py-7 px-[25px]" footerClass="flex justify-center pt-8">
<template #header>
<h2 class="text-[24px] font-bold text-black">Marquer comme vu ?</h2>
</template>
<template #footer>
<MalioButton label="Valider"/>
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
<MalioModal v-model="modalForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
<MalioModal v-model="modalLong">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<div class="flex flex-col gap-4">
<p v-for="n in 20" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTimePicker v-model="simpleValue" label="Heure" />
<p class="mt-2 text-sm text-m-muted">Valeur : {{ simpleValue || '—' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const simpleValue = ref('')
const initialValue = ref('08:30')
const disabledValue = ref('14:15')
const errorValue = ref('25:90')
const successValue = ref('09:00')
const noClearValue = ref('10:00')
</script>
+7 -186
View File
@@ -1,189 +1,10 @@
<template>
<div class="flex min-h-screen">
<aside class="w-72 bg-m-bg p-6 text-white">
<button
type="button"
class="text-xl text-black font-semibold"
@click="clearSelection"
>
Liste des composants
</button>
<nav class="mt-6 flex flex-col gap-1">
<div
v-for="group in groups"
:key="group.category"
>
<button
type="button"
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-black font-bold hover:bg-m-primary/10"
@click="toggleCategory(group.category)"
>
{{ group.category }}
<span
class="text-xs transition-transform duration-200"
:class="openCategories.has(group.category) ? 'rotate-90' : ''"
>
&#9654;
</span>
</button>
<div
v-if="openCategories.has(group.category)"
class="ml-3 flex flex-col gap-1 border-l border-gray-300 pl-2"
>
<button
v-for="item in group.items"
:key="item.name"
type="button"
class="rounded px-3 py-1.5 text-left text-sm text-black hover:bg-m-primary hover:text-white"
:class="selectedName === item.name ? 'bg-m-primary/50 text-white' : ''"
@click="selectItem(item.name)"
>
{{ item.label }}
</button>
</div>
</div>
</nav>
</aside>
<main class="flex-1 p-6">
<component
:is="selectedDemoComponent"
v-if="selectedDemoComponent"
/>
<p
v-else-if="selectedName"
class="text-gray-700"
>
Page de demo introuvable:
<code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
</p>
<div v-else>
<h1 class="text-2xl font-semibold text-gray-900">
Playground composants
</h1>
<p class="mt-2 text-gray-600">
Selectionne un composant dans la liste pour afficher sa page de demo.
</p>
</div>
</main>
<div class="mx-auto max-w-2xl py-16 text-center">
<h1 class="text-3xl font-bold text-m-text">
Playground @malio/layer-ui
</h1>
<p class="mt-4 text-m-muted">
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
</p>
</div>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch, shallowRef} from 'vue'
type LoadedModule = {
default: unknown
}
type Item = {
name: string
label: string
}
type Group = {
category: string
items: Item[]
}
const componentModules = import.meta.glob('../../app/components/malio/**/*.vue')
const demoModules = import.meta.glob('./composant/**/*.vue')
const demoByName: Record<string, () => Promise<LoadedModule>> =
Object.fromEntries(
Object.entries(demoModules).map(([file, loader]) => {
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
return [name.toLowerCase(), loader as () => Promise<LoadedModule>]
}),
)
const groups = computed<Group[]>(() => {
const categoryMap = new Map<string, Item[]>()
Object.keys(componentModules).forEach((file) => {
const parts = file.split('/')
const name = parts.pop()?.replace('.vue', '') ?? ''
const category = parts.pop() ?? ''
if (!categoryMap.has(category)) {
categoryMap.set(category, [])
}
categoryMap.get(category)!.push({name, label: name})
})
const componentGroups = Array.from(categoryMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, items]) => ({
category: category.charAt(0).toUpperCase() + category.slice(1),
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}))
return [
...componentGroups,
{
category: 'Form',
items: [{name: 'client', label: 'Client'}],
},
]
})
const openCategories = reactive(new Set<string>())
const selectedName = ref('')
const hasInitializedSelection = ref(false)
watch(
groups,
(val) => {
if (!hasInitializedSelection.value && val.length > 0) {
openCategories.add(val[0].category)
if (val[0].items.length > 0) {
selectedName.value = val[0].items[0].name
}
hasInitializedSelection.value = true
}
},
{immediate: true},
)
function toggleCategory(category: string) {
if (openCategories.has(category)) {
openCategories.delete(category)
} else {
openCategories.add(category)
}
}
function selectItem(name: string) {
selectedName.value = selectedName.value === name ? '' : name
}
function clearSelection() {
selectedName.value = ''
}
const selectedDemoComponent = shallowRef<unknown>(null)
watch(selectedName, async (name) => {
if (!name) {
selectedDemoComponent.value = null
return
}
const loader = demoByName[name.toLowerCase()]
if (!loader) {
selectedDemoComponent.value = null
return
}
const mod = await loader()
selectedDemoComponent.value = mod.default
})
const selectedDemoFileName = computed(() => {
const name = selectedName.value
if (!name) return ''
return name.charAt(0).toLowerCase() + name.slice(1)
})
</script>
+78
View File
@@ -0,0 +1,78 @@
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
export const navSections: SidebarSection[] = [
{
label: 'BOUTONS',
icon: 'mdi:gesture-tap-button',
items: [
{label: 'Button', to: '/composant/button/button'},
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
],
},
{
label: 'CHAMPS',
icon: 'mdi:form-textbox',
items: [
{label: 'Texte', to: '/composant/input/inputText'},
{label: 'Nombre', to: '/composant/input/inputNumber'},
{label: 'Montant', to: '/composant/input/inputAmount'},
{label: 'Email', to: '/composant/input/inputEmail'},
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
{label: 'Téléphone', to: '/composant/input/inputPhone'},
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
{label: 'Upload', to: '/composant/input/inputUpload'},
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
],
},
{
label: 'DATES & HEURES',
icon: 'mdi:calendar-clock',
items: [
{label: 'Date', to: '/composant/date/date'},
{label: 'Plage de dates', to: '/composant/date/dateRange'},
{label: 'Semaine', to: '/composant/date/dateWeek'},
{label: 'Date & heure', to: '/composant/date/datetime'},
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'},
],
},
{
label: 'SÉLECTIONS',
icon: 'mdi:form-dropdown',
items: [
{label: 'Select', to: '/composant/select/select'},
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
{label: 'Radio', to: '/composant/radio/radioButton'},
],
},
{
label: 'NAVIGATION',
icon: 'mdi:navigation-variant',
items: [
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Modal', to: '/composant/modal/modal'},
{label: 'Onglets', to: '/composant/tab/tabList'},
{label: 'Accordéon', to: '/composant/accordion/accordion'},
],
},
{
label: 'DONNÉES',
icon: 'mdi:table',
items: [
{label: 'DataTable', to: '/composant/datatable/datatable'},
],
},
{
label: 'DIVERS',
icon: 'mdi:dots-horizontal',
items: [
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
{label: 'Filtres', to: '/composant/filtre/filtres'},
],
},
]
+18
View File
@@ -30,9 +30,27 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-30] Création d'un composant email
* [#MUI-31] Création d'un composant téléphone
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
* [#MUI-34] Revoir le système de playground
* [#MUI-33] Développer le composant Datepicker
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
* [#MUI-37] Création d'un composant accordéon
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
### Changed
* [#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`.
### Fixed
* 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)
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
* 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
+339 -13
View File
@@ -146,7 +146,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
## MalioInputAutocomplete
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache.
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Par défaut le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache. Pour une liste **statique** courte, activer `localFilter` fait filtrer le composant lui-même (case-insensitive `label.includes(query)`) sans avoir à brancher `@search`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
@@ -159,6 +159,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
| `localFilter` | `boolean` | `false` | Filtre `options` côté client par sous-chaîne du label (case-insensitive). À utiliser pour les listes statiques courtes ; en mode API on laisse `false` et le parent répond à `@search`. |
| `iconName` | `string` | `''` | Icône Iconify décorative |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
@@ -185,8 +186,8 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
```vue
<!-- Usage statique -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
<!-- Usage statique (filtrage côté client via local-filter) -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
<!-- Usage API (parent gère le fetch) -->
<MalioInputAutocomplete
@@ -442,6 +443,106 @@ Bouton radio (à utiliser en groupe avec le même `name`).
---
## MalioDate
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.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `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) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDate v-model="date" label="Date de naissance" />
<!-- date === "2026-05-20" -->
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
```
---
## MalioDateRange
Sélecteur de **plage de dates** (date de début → date de fin) dans un seul champ. Cliquer un premier jour démarre la plage, le second la termine ; un survol prévisualise la plage.
La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"`), ou `null`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `{ start: string; end: string } \| null` | `undefined` | Plage de dates ISO (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
```vue
<MalioDateRange v-model="periode" label="Période de séjour" />
<!-- periode === { start: "2026-05-20", end: "2026-05-27" } -->
```
---
## MalioDateWeek
Sélecteur de **semaine ISO** : cliquer un jour (ou un numéro de semaine) sélectionne la semaine entière.
La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2026-W21"`), ou `null`. Le champ affiche `Semaine W (JJ/MM → JJ/MM/AAAA)`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Semaine ISO `"YYYY-Www"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDateWeek v-model="semaine" label="Semaine de livraison" />
<!-- semaine === "2026-W21" -->
```
---
## MalioTime
Sélecteur d'heure.
@@ -463,6 +564,72 @@ Sélecteur d'heure.
---
## MalioTimePicker
Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes infinies (heures `0023`, minutes `0059`, pas de 1) avec une bande de sélection centrale ; la valeur centrée est sélectionnée. Défilement, clic sur une valeur (recentrage) ou flèches clavier (`role="spinbutton"`). Pour une saisie clavier directe au format texte, voir plutôt `MalioTime`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioTimePicker v-model="heure" label="Heure" />
<MalioTimePicker v-model="heure" label="Départ" hint="Format HH:MM" />
```
---
## MalioDateTime
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
> Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`), qui remplace l'ancien `<input type="time">` natif intérimaire.
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date + heure ISO naïve `"YYYY-MM-DDTHH:MM:00"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Requis |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `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) |
| `max` | `string` | `undefined` | Borne max (idem) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
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.
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
```
---
## MalioButton
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
@@ -557,6 +724,54 @@ const tabs = computed(() => [
---
## MalioAccordion
Accordéon compositionnel : `<MalioAccordion>` enveloppe des `<MalioAccordionItem>`. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ.
### MalioAccordion
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` |
| `id` | `string` | auto | Préfixe des IDs d'accessibilité |
| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) |
**Events :** `update:modelValue(value: string | string[])`
### MalioAccordionItem
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `title` | `string` | — | Texte de l'en-tête |
| `value` | `string` | auto | Clé unique de la section |
| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) |
| `disabled` | `boolean` | `false` | En-tête non cliquable |
| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) |
| `panelClass` | `string` | `''` | Override classes panneau (twMerge) |
**Slot :** par défaut = contenu du panneau.
```vue
<!-- Filtres : plusieurs sections ouvertes -->
<MalioAccordion v-model="ouverts">
<MalioAccordionItem title="Prix" value="prix">
<MalioInputAmount v-model="prix" />
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<MalioCheckbox v-model="cats" />
</MalioAccordionItem>
</MalioAccordion>
<!-- FAQ : une seule section ouverte -->
<MalioAccordion mode="single">
<MalioAccordionItem title="Question 1" value="q1">Réponse 1</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">Réponse 2</MalioAccordionItem>
</MalioAccordion>
```
---
## MalioSidebar
Barre latérale de navigation rétractable.
@@ -584,29 +799,110 @@ Barre latérale de navigation rétractable.
## MalioDrawer
Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transparent.
Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs drawers.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `title` | `string` | `''` | Titre affiché dans le header |
| `side` | `'right' \| 'left'` | `'right'` | Côté d'apparition |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `drawerClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-2xl` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS du footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`
**Slots :** `default` (contenu du drawer)
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.), fixe en haut. S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable : seul le body défile).
- `footer` — actions (boutons). Rendu en bas du panneau, fixe, hors de la zone scrollable. N'apparaît que si le slot est fourni.
```vue
<MalioDrawer v-model="isOpen" title="Détails">
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu du drawer</p>
</MalioDrawer>
<MalioDrawer v-model="isOpen" title="Sans croix" :show-close="false">
<p>Fermeture uniquement via backdrop</p>
<!-- Côté gauche, largeur custom -->
<MalioDrawer v-model="isOpen" side="left" drawer-class="max-w-2xl">
<template #header><h2>Navigation</h2></template>
<p>Drawer large depuis la gauche</p>
</MalioDrawer>
<MalioDrawer v-model="isOpen" title="Large" drawer-class="max-w-2xl">
<p>Drawer plus large</p>
<!-- Footer d'actions (fixe en bas, hors zone scrollable) -->
<MalioDrawer v-model="isOpen">
<template #header><h2>Formulaire</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
</template>
</MalioDrawer>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioDrawer v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioDrawer>
```
---
## MalioModal
Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable).
- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni.
```vue
<MalioModal v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu de la modal</p>
</MalioModal>
<!-- Largeur custom + footer d'actions -->
<MalioModal v-model="isOpen" modal-class="max-w-lg">
<template #header><h2>Nouveau contact</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="isOpen = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="isOpen = false" />
</template>
</MalioModal>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioModal v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioModal>
```
---
@@ -664,3 +960,33 @@ Tableau de données presentational avec pagination, filtres par slots et lignes
v-model:per-page="perPage"
/>
```
---
## MalioSiteSelector
Sélecteur de site sous forme de tuiles segmentées (`role="radiogroup"`). Chaque site occupe une tuile de largeur égale ; la tuile active s'affiche pleine opacité dans sa couleur (`site.color`), les autres sont atténuées. Pattern contrôlé (`v-model`) ou non contrôlé (premier site sélectionné par défaut).
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `sites` | `{ id: string, name: string, color: string }[]` | **requis** | Liste des sites (la `color` colore la tuile active) |
| `modelValue` | `string` | `undefined` | `id` du site sélectionné (v-model) |
| `id` | `string` | auto | Identifiant HTML du conteneur |
| `groupClass` | `string` | `''` | Classes CSS du conteneur (twMerge) |
| `tileClass` | `string` | `''` | Classes CSS de chaque tuile (twMerge) |
| `labelClass` | `string` | `''` | Classes CSS du label de tuile (twMerge) |
**Events :**
- `update:modelValue(value: string)``id` du site sélectionné (v-model)
- `change(site: Site)` — émis avec l'objet site complet sélectionné
```vue
<MalioSiteSelector
v-model="siteId"
:sites="[
{ id: 'paris', name: 'Paris', color: '#2563eb' },
{ id: 'lyon', name: 'Lyon', color: '#16a34a' },
]"
@change="onSiteChange"
/>
```
+1
View File
@@ -6,6 +6,7 @@
:root {
/* ── Globales ── */
--m-primary: 34 39 131; /* #222783 - Bleu Malio */
--m-primary-light: 239 239 253; /* #EFEFFD - Teinte claire du primary (fonds doux) */
--m-bg: 243 244 248; /* #F3F4F8 - Fond de page */
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
--m-text: 15 23 42; /* #0F172A */
@@ -0,0 +1,256 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import {nextTick} from 'vue'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
const TWO_ITEMS = `
<MalioAccordionItem title="Prix" value="prix"><p>Contenu prix</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat"><p>Contenu catégorie</p></MalioAccordionItem>
`
function mountAccordion(props: Record<string, unknown> = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) {
return mount(Accordion, {
props,
slots: {default: slot},
attachTo,
global: {components: {MalioAccordionItem: AccordionItem}},
})
}
describe('MalioAccordion — rendu & mode multiple', () => {
it('renders each item header with its title', () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers).toHaveLength(2)
expect(headers[0].text()).toContain('Prix')
expect(headers[1].text()).toContain('Catégorie')
})
it('renders the slot content of each panel', () => {
const wrapper = mountAccordion()
expect(wrapper.html()).toContain('Contenu prix')
expect(wrapper.html()).toContain('Contenu catégorie')
})
it('all panels are collapsed by default', () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('false')
const regions = wrapper.findAll('[role="region"]')
expect(regions[0].classes()).toContain('grid-rows-[0fr]')
})
it('opens a panel on header click (multiple mode is default)', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('true')
const regions = wrapper.findAll('[role="region"]')
expect(regions[0].classes()).toContain('grid-rows-[1fr]')
})
it('keeps multiple panels open simultaneously in multiple mode', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('closes an open panel when its header is clicked again', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[0].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
})
it('wires aria-controls / aria-labelledby / role=region correctly', () => {
const wrapper = mountAccordion({id: 'acc'})
const headers = wrapper.findAll('button[aria-expanded]')
const regions = wrapper.findAll('[role="region"]')
expect(headers[0].attributes('id')).toBe('acc-header-prix')
expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix')
expect(regions[0].attributes('id')).toBe('acc-panel-prix')
expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix')
})
it('emits update:modelValue with an array in multiple mode', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
await nextTick()
})
})
describe('MalioAccordion — mode single & contrôlé', () => {
it('opening a panel closes the others in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('emits a string in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
})
it('emits empty string when closing the open panel in single mode', async () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('respects modelValue array in controlled multiple mode', () => {
const wrapper = mountAccordion({modelValue: ['cat']})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('respects modelValue string in controlled single mode', () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('false')
})
it('does not mutate local state in controlled mode (emits only)', async () => {
const wrapper = mountAccordion({modelValue: []})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
// état piloté par le parent : sans mise à jour de la prop, reste fermé
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
})
})
describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
const WITH_DEFAULT_OPEN = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :default-open="true"><p>C</p></MalioAccordionItem>
`
const WITH_DISABLED = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :disabled="true"><p>C</p></MalioAccordionItem>
`
it('opens defaultOpen items initially in uncontrolled mode', async () => {
const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
await nextTick()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('sets disabled and aria-disabled on a disabled item', () => {
const wrapper = mountAccordion({}, WITH_DISABLED)
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[1].attributes('disabled')).toBeDefined()
expect(headers[1].attributes('aria-disabled')).toBe('true')
})
it('does not toggle a disabled item on click', async () => {
const wrapper = mountAccordion({}, WITH_DISABLED)
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(headers[1].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('moves focus to the next header on ArrowDown', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
expect(document.activeElement).toBe(headers[1].element)
wrapper.unmount()
root.remove()
})
it('wraps focus to the first header on ArrowDown from the last', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowDown'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('moves focus to the previous header on ArrowUp', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowUp'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('skips disabled headers during keyboard navigation', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const slot = `
<MalioAccordionItem title="A" value="a"><p>A</p></MalioAccordionItem>
<MalioAccordionItem title="B" value="b" :disabled="true"><p>B</p></MalioAccordionItem>
<MalioAccordionItem title="C" value="c"><p>C</p></MalioAccordionItem>
`
const wrapper = mountAccordion({}, slot, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
// saute le header désactivé (B) pour aller directement à C
expect(document.activeElement).toBe(headers[2].element)
wrapper.unmount()
root.remove()
})
})
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
it('clips the panel (overflow-hidden) while collapsed', () => {
const wrapper = mountAccordion({}, ONE)
const inner = wrapper.find('[role="region"] > div')
expect(inner.classes()).toContain('overflow-hidden')
expect(inner.classes()).not.toContain('overflow-visible')
})
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('switches to overflow-visible after the open transition ends', async () => {
const wrapper = mountAccordion({}, ONE)
await wrapper.find('button[aria-expanded]').trigger('click')
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('re-clips (overflow-hidden) as soon as it closes', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
await wrapper.find('button[aria-expanded]').trigger('click')
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
})
})
@@ -0,0 +1,109 @@
<template>
<div v-bind="$attrs" :class="rootClass">
<slot />
</div>
</template>
<script setup lang="ts">
import {computed, provide, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey, type AccordionItemRegistration} from './context'
defineOptions({name: 'MalioAccordion', inheritAttrs: false})
const props = withDefaults(defineProps<{
mode?: 'single' | 'multiple'
modelValue?: string | string[]
id?: string
groupClass?: string
}>(), {
mode: 'multiple',
modelValue: undefined,
id: '',
groupClass: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const generatedId = useId()
const baseId = computed(() => props.id || `malio-accordion-${generatedId}`)
const mode = computed(() => props.mode)
const isControlled = computed(() => props.modelValue !== undefined)
const localOpen = ref<string[]>([])
const items = ref<AccordionItemRegistration[]>([])
const openKeys = computed<string[]>(() => {
if (isControlled.value) {
const v = props.modelValue
if (props.mode === 'single') return v ? [v as string] : []
if (Array.isArray(v)) return v
return v ? [v as string] : []
}
return localOpen.value
})
function isOpen(value: string) {
return openKeys.value.includes(value)
}
function toggle(value: string) {
const current = openKeys.value
let next: string[]
if (props.mode === 'single') {
next = current.includes(value) ? [] : [value]
} else {
next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
}
if (!isControlled.value) {
localOpen.value = next
}
emit('update:modelValue', props.mode === 'single' ? (next[0] ?? '') : next)
}
function register(item: AccordionItemRegistration, defaultOpen: boolean) {
items.value.push(item)
if (defaultOpen && !isControlled.value) {
if (props.mode === 'single') {
if (localOpen.value.length === 0) localOpen.value = [item.value]
} else if (!localOpen.value.includes(item.value)) {
localOpen.value.push(item.value)
}
}
}
function unregister(value: string) {
items.value = items.value.filter(i => i.value !== value)
}
// `items` est ordonné par ordre de montage (= ordre du DOM pour des sections
// statiques/ajoutées en fin). Si un consommateur réordonne dynamiquement les
// items, cet ordre peut diverger de l'ordre visuel ; trier par position DOM
// serait alors nécessaire (hors périmètre v1).
function focusSibling(value: string, offset: 1 | -1) {
const enabled = items.value.filter(i => !i.isDisabled())
const idx = enabled.findIndex(i => i.value === value)
if (idx === -1) return
const next = enabled[(idx + offset + enabled.length) % enabled.length]
next?.getHeaderEl()?.focus()
}
const rootClass = computed(() =>
twMerge('divide-y divide-black border-y border-black', props.groupClass),
)
provide(accordionContextKey, {
mode,
baseId,
isOpen,
toggle,
register,
unregister,
focusSibling,
})
</script>
@@ -0,0 +1,48 @@
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
function mountInAccordion(slot: string, accordionProps: Record<string, unknown> = {}) {
return mount(Accordion, {
props: accordionProps,
slots: {default: slot},
global: {components: {MalioAccordionItem: AccordionItem}},
})
}
describe('MalioAccordionItem', () => {
it('throws when used outside MalioAccordion', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
/à l'intérieur de MalioAccordion/,
)
spy.mockRestore()
})
it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="Sans value"><p>X</p></MalioAccordionItem>`,
)
const header = wrapper.find('button[aria-expanded]')
expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
await header.trigger('click')
expect(header.attributes('aria-expanded')).toBe('true')
})
it('applies headerClass and panelClass overrides via twMerge', () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="T" value="t" header-class="bg-red-500" panel-class="text-lg"><p>X</p></MalioAccordionItem>`,
)
const header = wrapper.find('button[aria-expanded]')
expect(header.classes()).toContain('bg-red-500')
expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
})
it('renders a rotating chevron icon', () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="T" value="t"><p>X</p></MalioAccordionItem>`,
)
expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
})
})
@@ -0,0 +1,126 @@
<template>
<div>
<h3 class="m-0">
<button
:id="headerId"
ref="headerRef"
type="button"
:class="headerClasses"
:aria-expanded="open"
:aria-controls="panelId"
:disabled="disabled"
:aria-disabled="disabled || undefined"
@click="onToggle"
@keydown.down.prevent="ctx.focusSibling(value, 1)"
@keydown.up.prevent="ctx.focusSibling(value, -1)"
>
<span>{{ title }}</span>
<IconifyIcon
icon="mdi:chevron-down"
:width="24"
class="shrink-0 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
/>
</button>
</h3>
<div
:id="panelId"
role="region"
:aria-labelledby="headerId"
class="grid transition-[grid-template-rows] duration-200 ease-out"
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
@transitionend="onPanelTransitionEnd"
>
<div
:class="overflowVisible ? 'overflow-visible' : 'overflow-hidden'"
:inert="!open || undefined"
>
<div :class="panelInnerClass">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, inject, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey} from './context'
defineOptions({name: 'MalioAccordionItem', inheritAttrs: false})
const props = withDefaults(defineProps<{
title: string
value?: string
defaultOpen?: boolean
disabled?: boolean
headerClass?: string
panelClass?: string
}>(), {
value: '',
defaultOpen: false,
disabled: false,
headerClass: '',
panelClass: '',
})
const ctx = inject(accordionContextKey)
if (!ctx) {
throw new Error('MalioAccordionItem doit être utilisé à l\'intérieur de MalioAccordion')
}
const generatedId = useId()
const value = computed(() => props.value || `malio-accordion-item-${generatedId}`)
const headerRef = ref<HTMLButtonElement | null>(null)
const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
const open = computed(() => ctx.isOpen(value.value))
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
// pas rogné. On re-clippe dès le début de la fermeture.
const overflowVisible = ref(false)
watch(open, (isOpen) => {
if (!isOpen) overflowVisible.value = false
})
function onPanelTransitionEnd(e: TransitionEvent) {
if (e.propertyName === 'grid-template-rows' && open.value) {
overflowVisible.value = true
}
}
function onToggle() {
if (props.disabled) return
ctx.toggle(value.value)
}
const headerClasses = computed(() =>
twMerge(
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
props.headerClass,
),
)
const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
onMounted(() => {
ctx.register(
{
value: value.value,
getHeaderEl: () => headerRef.value,
isDisabled: () => props.disabled,
},
props.defaultOpen,
)
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
if (open.value) overflowVisible.value = true
})
onBeforeUnmount(() => ctx.unregister(value.value))
</script>
+19
View File
@@ -0,0 +1,19 @@
import type {ComputedRef, InjectionKey} from 'vue'
export interface AccordionItemRegistration {
value: string
getHeaderEl: () => HTMLElement | null
isDisabled: () => boolean
}
export interface AccordionContext {
mode: ComputedRef<'single' | 'multiple'>
baseId: ComputedRef<string>
isOpen: (value: string) => boolean
toggle: (value: string) => void
register: (item: AccordionItemRegistration, defaultOpen: boolean) => void
unregister: (value: string) => void
focusSibling: (value: string, offset: 1 | -1) => void
}
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('MalioAccordion')
+1 -1
View File
@@ -162,7 +162,7 @@ describe('MalioButton', () => {
it('applies correct dimensions', () => {
const wrapper = mountComponent()
expect(wrapper.get('button').classes()).toContain('w-[240px]')
expect(wrapper.get('button').classes()).toContain('w-[200px]')
expect(wrapper.get('button').classes()).toContain('h-[40px]')
})
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() =>
twMerge(
'inline-flex w-[240px] h-[40px] 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-[200px] h-[40px] 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',
variantClasses.value,
props.buttonClass,
),
+1 -2
View File
@@ -30,7 +30,6 @@
</label>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
@@ -121,7 +120,7 @@ const mergedLabelClass = computed(() =>
const mergedMessageClass = computed(() =>
twMerge(
'text-xs',
'text-xs min-h-[1rem]',
hasError.value
? 'text-m-danger'
: hasSuccess.value
+198
View File
@@ -0,0 +1,198 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Date_ from './Date.vue'
type DateProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}
const DateForTest = Date_ as DefineComponent<DateProps>
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
describe('MalioDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('renders the label and the calendar icon', () => {
const wrapper = mountDate({label: 'Date de naissance'})
expect(wrapper.get('label').text()).toBe('Date de naissance')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted value in the field', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026')
})
it('does not show the popover initially', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('popover', () => {
it('opens on field click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').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')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
})
it('opens on the value month when a value is set', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('closes on outside mousedown', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('navigation jours', () => {
it('goes to the next month on the right chevron', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
})
it('rolls December to January and bumps the year', async () => {
const wrapper = mountDate({modelValue: '2026-12-15'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
})
})
describe('sélection', () => {
it('emits the ISO date and closes on day click', 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:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('bornes min/max', () => {
it('disables days outside the range', async () => {
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
await outside.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('vue mois', () => {
it('switches to month view on header toggle', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
})
it('navigates the year with chevrons in month view', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
})
it('returns to day view on month click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('effacement', () => {
it('shows the clear button when there is a value', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
})
it('hides the clear button when empty', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('emits null and does not open the popover on clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('états', () => {
it('does not open when disabled', async () => {
const wrapper = mountDate({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
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')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('accessibilité', () => {
it('sets aria-invalid and describedby on error', () => {
const wrapper = mountDate({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
describe('synchronisation externe', () => {
it('updates the displayed value when modelValue changes', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.setProps({modelValue: '2026-12-25'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('25/12/2026')
})
})
})
+93
View File
@@ -0,0 +1,93 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="modelValue ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="emit('update:modelValue', null)"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:min="min"
:max="max"
@select="(iso) => { emit('update:modelValue', iso); close() }"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
watch(() => props.modelValue, (val) => {
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
})
</script>
+155
View File
@@ -0,0 +1,155 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateRange from './DateRange.vue'
type RangeValue = {start: string; end: string}
type DateRangeProps = {
modelValue?: RangeValue | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
clearable?: boolean
}
const DateRangeForTest = DateRange as DefineComponent<DateRangeProps>
const mountRange = (props: DateRangeProps = {}) =>
mount(DateRangeForTest, {props, attachTo: document.body})
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, isos: string[]) => {
await wrapper.get('[data-test="date-input"]').trigger('click')
for (const iso of isos) {
await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click')
}
}
describe('MalioDateRange', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountRange({label: 'Période'})
expect(wrapper.get('label').text()).toBe('Période')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted range when modelValue is set', () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026 - 25/05/2026')
})
it('shows an empty field without a value', () => {
const wrapper = mountRange()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the start month when a range is set', async () => {
const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('does not emit on the first click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('emits the range and closes on the second click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('auto-inverts when the second click is before the first', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
})
it('allows a single-day range', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}])
})
it('restarts a new range on the third click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}])
})
it('previews the range on hover while selecting', async () => {
const wrapper = mountRange()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('does not preview before selecting', async () => {
const wrapper = mountRange()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none')
})
it('marks start, end and in-range roles for a committed range', async () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('cancels an in-progress selection on outside click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19'])
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none')
})
it('emits null on clear', async () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables days outside min/max', async () => {
const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
await outside.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('sets aria-invalid on error', () => {
const wrapper = mountRange({error: 'Période requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Période requise')
})
it('does not open when disabled', async () => {
const wrapper = mountRange({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
+140
View File
@@ -0,0 +1,140 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validRange?.start ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="rangeStart"
:range-end="rangeEnd"
:preview-date="previewDate"
:min="min"
:max="max"
@select="(iso) => onSelectDay(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: DateRangeValue | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
const pendingStart = ref<string | null>(null)
const hoverDate = ref<string | null>(null)
const isSelecting = computed(() => pendingStart.value !== null)
const validRange = computed<DateRangeValue | null>(() => {
const v = props.modelValue
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
return null
})
const rangeStart = computed(() =>
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
)
const rangeEnd = computed(() =>
isSelecting.value ? null : (validRange.value?.end ?? null),
)
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
const displayValue = computed(() => {
if (isSelecting.value || !validRange.value) return ''
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
})
const onSelectDay = (iso: string, close: () => void) => {
if (pendingStart.value === null) {
pendingStart.value = iso
hoverDate.value = null
return
}
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
pendingStart.value = null
hoverDate.value = null
close()
}
const onHover = (iso: string | null) => {
if (isSelecting.value) hoverDate.value = iso
}
const onClose = () => {
pendingStart.value = null
hoverDate.value = null
}
const onClear = () => {
emit('update:modelValue', null)
pendingStart.value = null
hoverDate.value = null
}
</script>
+123
View File
@@ -0,0 +1,123 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateTime_ from './DateTime.vue'
import MalioTimePicker from '../time/TimePicker.vue'
type DateTimeProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
const mountDateTime = (props: DateTimeProps = {}) =>
mount(DateTimeForTest, {props, attachTo: document.body})
describe('MalioDateTime', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('affiche le label et l\'icône calendrier', () => {
const wrapper = mountDateTime({label: 'Rendez-vous'})
expect(wrapper.get('label').text()).toBe('Rendez-vous')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche la valeur formatée date + heure dans le champ', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('20/05/2026 14:30')
})
})
describe('popover', () => {
it('ouvre la grille et le champ sélecteur d\'heure au clic', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true)
expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true)
})
})
describe('sélection', () => {
it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', 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')
// heure système figée à 09:05
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('applique l\'heure réglée avant le clic du jour', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '09:15')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
})
it('met à jour l\'heure quand une date est déjà choisie', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '08:45')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
})
it('initialise le champ heure depuis la valeur', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30')
})
})
describe('bornes min/max', () => {
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
})
})
describe('effacement', () => {
it('émet null au clic sur la croix', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
})
describe('accessibilité', () => {
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountDateTime({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
})
+133
View File
@@ -0,0 +1,133 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="datePart"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="datePart"
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
@select="onSelectDay"
/>
<div class="mt-4">
<MalioTimePicker
:model-value="timeValue || null"
label="Heure"
:clearable="false"
static-popover
@update:model-value="onTimeChange"
/>
</div>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
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'
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('')
const parts = computed(() => splitDateTime(props.modelValue ?? null))
const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value)
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())
emit('update:modelValue', composeDateTime(iso, time))
}
function onTimeChange(value: string | null) {
if (!value) return
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
function onClear() {
pendingTime.value = ''
emit('update:modelValue', null)
}
watch(() => props.modelValue, (val) => {
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
})
</script>
+122
View File
@@ -0,0 +1,122 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateWeek from './DateWeek.vue'
type DateWeekProps = {
modelValue?: string | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
}
const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
const mountWeek = (props: DateWeekProps = {}) =>
mount(DateWeekForTest, {props, attachTo: document.body})
describe('MalioDateWeek', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountWeek({label: 'Semaine'})
expect(wrapper.get('label').text()).toBe('Semaine')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted week when modelValue is set', () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('shows an empty field without a value', () => {
const wrapper = mountWeek()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the month of the selected week', async () => {
const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('selects the week when a day is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('selects the week when the week number is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('previews the whole week on day hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('previews the whole week on week-number hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('marks the committed week number', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
})
it('emits null on clear', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables a week fully outside min/max', async () => {
const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
})
it('does not open when disabled', async () => {
const wrapper = mountWeek({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('does not open when readonly', async () => {
const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('sets aria-invalid on error', () => {
const wrapper = mountWeek({error: 'Semaine requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Semaine requise')
})
})
+123
View File
@@ -0,0 +1,123 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validWeek?.monday ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="activeMonday"
:range-end="activeSunday"
:marked-week-start="validWeek?.monday ?? null"
interactive-week-number
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatWeekDisplay, isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek} from './composables/dateWeek'
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() => {
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
return {monday: isoWeekToMonday(props.modelValue) as string}
}
return null
})
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
const onSelect = (iso: string, close: () => void) => {
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart.value = null
close()
}
const onHover = (iso: string | null) => {
hoverWeekStart.value = iso ? mondayOf(iso) : null
}
const onClose = () => {
hoverWeekStart.value = null
}
const onClear = () => {
emit('update:modelValue', null)
hoverWeekStart.value = null
}
</script>
@@ -0,0 +1,62 @@
import {describe, expect, it} from 'vitest'
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
describe('dateFormat', () => {
describe('isValidIso', () => {
it('accepts a real ISO date', () => {
expect(isValidIso('2026-05-19')).toBe(true)
})
it('rejects a malformed string', () => {
expect(isValidIso('19/05/2026')).toBe(false)
expect(isValidIso('2026-5-9')).toBe(false)
expect(isValidIso('')).toBe(false)
})
it('rejects an impossible date', () => {
expect(isValidIso('2026-02-30')).toBe(false)
expect(isValidIso('2026-13-01')).toBe(false)
})
it('accepts Feb 29 on a leap year and rejects it otherwise', () => {
expect(isValidIso('2024-02-29')).toBe(true)
expect(isValidIso('2026-02-29')).toBe(false)
})
})
describe('formatIsoToDisplay', () => {
it('formats ISO to DD/MM/YYYY', () => {
expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026')
})
it('returns empty string for null or invalid input', () => {
expect(formatIsoToDisplay(null)).toBe('')
expect(formatIsoToDisplay('nope')).toBe('')
})
})
describe('parseDisplayToIso', () => {
it('parses DD/MM/YYYY to ISO', () => {
expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19')
})
it('returns null for malformed or impossible input', () => {
expect(parseDisplayToIso('2026-05-19')).toBeNull()
expect(parseDisplayToIso('31/02/2026')).toBeNull()
expect(parseDisplayToIso('')).toBeNull()
})
})
describe('isDateInRange', () => {
it('returns true when no bounds are given', () => {
expect(isDateInRange('2026-05-19')).toBe(true)
})
it('respects the min bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false)
})
it('respects the max bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false)
})
it('respects both bounds', () => {
expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true)
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
})
})
})
@@ -0,0 +1,26 @@
export function isValidIso(iso: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const [y, m, d] = iso.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
}
export function formatIsoToDisplay(iso: string | null): string {
if (!iso || !isValidIso(iso)) return ''
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
export function parseDisplayToIso(display: string): string | null {
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim())
if (!match) return null
const [, dd, mm, yyyy] = match
const iso = `${yyyy}-${mm}-${dd}`
return isValidIso(iso) ? iso : null
}
export function isDateInRange(iso: string, min?: string, max?: string): boolean {
if (min && iso < min) return false
if (max && iso > max) return false
return true
}
@@ -0,0 +1,57 @@
import {describe, expect, it} from 'vitest'
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
describe('dateRange', () => {
describe('normalizeRange', () => {
it('keeps an already ordered pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('swaps a reversed pair', () => {
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('handles an equal pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
})
})
describe('resolveRangeBounds', () => {
it('returns null without a start', () => {
expect(resolveRangeBounds(null, null, null)).toBeNull()
})
it('returns a single-point range when only start is set', () => {
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
})
it('orders start and committed end', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
it('uses preview when end is not set', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
})
it('inverts when preview is before start', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
})
it('prioritises committed end over preview', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
})
describe('dayRangeRole', () => {
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
it('returns none without bounds', () => {
expect(dayRangeRole('2026-05-20', null)).toBe('none')
})
it('returns single when lo === hi and matches', () => {
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
})
it('returns start, end and in-range', () => {
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
})
it('returns none outside the bounds', () => {
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
})
})
})
@@ -0,0 +1,31 @@
export type DateRangeValue = {start: string; end: string}
export function normalizeRange(a: string, b: string): DateRangeValue {
return a <= b ? {start: a, end: b} : {start: b, end: a}
}
export function resolveRangeBounds(
start: string | null,
end: string | null,
preview: string | null,
): {lo: string; hi: string} | null {
if (!start) return null
const other = end ?? preview
if (!other) return {lo: start, hi: start}
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
}
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
export function dayRangeRole(
iso: string,
bounds: {lo: string; hi: string} | null,
): DayRangeRole {
if (!bounds) return 'none'
const {lo, hi} = bounds
if (lo === hi) return iso === lo ? 'single' : 'none'
if (iso === lo) return 'start'
if (iso === hi) return 'end'
if (iso > lo && iso < hi) return 'in-range'
return 'none'
}
@@ -0,0 +1,74 @@
import {describe, expect, it} from 'vitest'
import {
formatWeekDisplay,
isValidIsoWeek,
isoWeekToMonday,
mondayOf,
sundayOf,
toIsoWeek,
} from './dateWeek'
describe('dateWeek', () => {
describe('mondayOf / sundayOf', () => {
it('returns Monday and Sunday of a midweek date', () => {
expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
expect(sundayOf('2026-05-20')).toBe('2026-05-24')
})
it('keeps Monday on a Monday', () => {
expect(mondayOf('2026-05-18')).toBe('2026-05-18')
})
it('returns the preceding Monday for a Sunday', () => {
expect(mondayOf('2026-05-24')).toBe('2026-05-18')
})
})
describe('toIsoWeek', () => {
it('returns the ISO week of a date', () => {
expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
})
it('handles year boundaries', () => {
expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
})
})
describe('isoWeekToMonday', () => {
it('returns the Monday of a week string', () => {
expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
})
it('round-trips with toIsoWeek', () => {
for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
const monday = isoWeekToMonday(w)
expect(monday).not.toBeNull()
expect(toIsoWeek(monday as string)).toBe(w)
}
})
it('returns null for invalid input', () => {
expect(isoWeekToMonday('2026-21')).toBeNull()
expect(isoWeekToMonday('2026-W00')).toBeNull()
expect(isoWeekToMonday('2026-W54')).toBeNull()
expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
})
})
describe('isValidIsoWeek', () => {
it('accepts a real ISO week', () => {
expect(isValidIsoWeek('2026-W21')).toBe(true)
})
it('rejects malformed or impossible weeks', () => {
expect(isValidIsoWeek('2026-21')).toBe(false)
expect(isValidIsoWeek('2026-W00')).toBe(false)
expect(isValidIsoWeek('2026-W54')).toBe(false)
})
})
describe('formatWeekDisplay', () => {
it('formats a week as a human label', () => {
expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('returns empty string for invalid input', () => {
expect(formatWeekDisplay('2026-W54')).toBe('')
})
})
})
@@ -0,0 +1,67 @@
import {formatIsoToDisplay} from './dateFormat'
const parseUtc = (iso: string): Date => {
const [y, m, d] = iso.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d))
}
const toIso = (d: Date): string => {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function mondayOf(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7 // dimanche = 7
d.setUTCDate(d.getUTCDate() - (dayNum - 1))
return toIso(d)
}
export function sundayOf(iso: string): string {
const d = parseUtc(mondayOf(iso))
d.setUTCDate(d.getUTCDate() + 6)
return toIso(d)
}
export function toIsoWeek(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const isoYear = d.getUTCFullYear()
const yearStart = new Date(Date.UTC(isoYear, 0, 1))
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
return `${isoYear}-W${String(week).padStart(2, '0')}`
}
export function isoWeekToMonday(week: string): string | null {
const m = /^(\d{4})-W(\d{2})$/.exec(week)
if (!m) return null
const year = Number(m[1])
const w = Number(m[2])
if (w < 1 || w > 53) return null
// Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
const jan4 = new Date(Date.UTC(year, 0, 4))
const jan4Day = jan4.getUTCDay() || 7
const monday = new Date(jan4)
monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
const iso = toIso(monday)
// Garde-fou : la semaine 53 n'existe pas pour toutes les années
if (toIsoWeek(iso) !== week) return null
return iso
}
export function isValidIsoWeek(week: string): boolean {
return isoWeekToMonday(week) !== null
}
export function formatWeekDisplay(week: string): string {
const monday = isoWeekToMonday(week)
if (!monday) return ''
const sunday = sundayOf(monday)
const w = Number(week.slice(6))
const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
return `Semaine ${w} (${startDdMm}${endFull})`
}
@@ -0,0 +1,61 @@
import {describe, expect, it} from 'vitest'
import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
splitDateTime,
} from './datetimeFormat'
describe('datetimeFormat', () => {
describe('isValidIsoDateTime', () => {
it('accepte un datetime ISO complet valide', () => {
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
})
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
expect(isValidIsoDateTime('')).toBe(false)
})
})
describe('formatIsoDateTimeToDisplay', () => {
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
})
it('renvoie une chaîne vide pour nul ou invalide', () => {
expect(formatIsoDateTimeToDisplay(null)).toBe('')
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
})
})
describe('splitDateTime', () => {
it('découpe un datetime valide', () => {
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
})
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
expect(splitDateTime(null)).toEqual({date: null, time: ''})
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
})
})
describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
})
it('utilise 00:00 quand l\'heure est vide', () => {
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
})
})
})
@@ -0,0 +1,33 @@
import {isValidIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
export function isValidIsoDateTime(s: string): boolean {
const m = DATETIME_RE.exec(s)
if (!m) return false
const [, date, hh, mm, ss] = m
if (!isValidIso(date)) return false
const h = Number(hh)
const min = Number(mm)
const sec = Number(ss)
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
}
export function formatIsoDateTimeToDisplay(s: string | null): string {
if (!s || !isValidIsoDateTime(s)) return ''
const [date, time] = s.split('T')
const [y, mo, d] = date.split('-')
const [hh, mm] = time.split(':')
return `${d}/${mo}/${y} ${hh}:${mm}`
}
export function splitDateTime(s: string | null): {date: string | null; time: string} {
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
const [date, time] = s.split('T')
return {date, time: time.slice(0, 5)}
}
export function composeDateTime(date: string, time: string): string {
const t = time || '00:00'
return `${date}T${t}:00`
}
@@ -0,0 +1,64 @@
import {describe, expect, it} from 'vitest'
import {defineComponent, h, ref} from 'vue'
import {mount} from '@vue/test-utils'
import {useCalendarPopover} from './useCalendarPopover'
const mountHost = () => {
const api: ReturnType<typeof useCalendarPopover> = {} as never
const Host = defineComponent({
setup() {
const root = ref<HTMLElement | null>(null)
Object.assign(api, useCalendarPopover(root))
return () => h('div', {ref: root}, 'host')
},
})
const wrapper = mount(Host, {attachTo: document.body})
return {wrapper, api}
}
describe('useCalendarPopover', () => {
it('starts closed in days view', () => {
const {api} = mountHost()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('open() opens in days view', () => {
const {api} = mountHost()
api.open()
expect(api.isOpen.value).toBe(true)
expect(api.viewMode.value).toBe('days')
})
it('toggleView() switches between days and months', () => {
const {api} = mountHost()
api.open()
api.toggleView()
expect(api.viewMode.value).toBe('months')
api.toggleView()
expect(api.viewMode.value).toBe('days')
})
it('close() resets isOpen and viewMode', () => {
const {api} = mountHost()
api.open()
api.toggleView()
api.close()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('closes on outside mousedown', () => {
const {api} = mountHost()
api.open()
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(false)
})
it('stays open on inside mousedown', () => {
const {wrapper, api} = mountHost()
api.open()
wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(true)
})
})
@@ -0,0 +1,28 @@
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
const isOpen = ref(false)
const viewMode = ref<'days' | 'months'>('days')
const open = () => {
isOpen.value = true
viewMode.value = 'days'
}
const close = () => {
isOpen.value = false
viewMode.value = 'days'
}
const toggleView = () => {
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !rootRef.value) return
if (!rootRef.value.contains(event.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
return {isOpen, viewMode, open, close, toggleView}
}
@@ -0,0 +1,68 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useCalendarView} from './useCalendarView'
describe('useCalendarView', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('initialises to the current month and year', () => {
const {currentMonth, currentYear} = useCalendarView(ref('days'))
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
it('goToNext advances the month in days view', () => {
const {currentMonth, goToNext} = useCalendarView(ref('days'))
goToNext()
expect(currentMonth.value).toBe(5)
})
it('rolls December to January and bumps the year', () => {
const {currentMonth, currentYear, goToNext} = useCalendarView(ref('days'))
currentMonth.value = 11
goToNext()
expect(currentMonth.value).toBe(0)
expect(currentYear.value).toBe(2027)
})
it('rolls January to December backwards', () => {
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
currentMonth.value = 0
goToPrev()
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('navigates the year in months view', () => {
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
goToNext()
expect(currentYear.value).toBe(2027)
goToPrev()
expect(currentYear.value).toBe(2026)
})
it('selectMonth sets the current month', () => {
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
selectMonth(0)
expect(currentMonth.value).toBe(0)
})
it('syncToIso sets month/year from a valid ISO', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('syncToIso falls back to today for null/invalid', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
syncToIso(null)
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
})
@@ -0,0 +1,51 @@
import {ref, type Ref} from 'vue'
import {isValidIso} from './dateFormat'
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const goToPrev = () => {
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
const goToNext = () => {
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
const selectMonth = (m: number) => {
currentMonth.value = m
}
const syncToIso = (iso: string | null) => {
if (iso && isValidIso(iso)) {
currentMonth.value = Number(iso.slice(5, 7)) - 1
currentYear.value = Number(iso.slice(0, 4))
} else {
const now = new Date()
currentMonth.value = now.getMonth()
currentYear.value = now.getFullYear()
}
}
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
}
@@ -0,0 +1,69 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useMonthMatrix} from './useMonthMatrix'
describe('useMonthMatrix', () => {
it('always produces 6 weeks of 7 days', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026
expect(weeks.value).toHaveLength(6)
weeks.value.forEach(week => expect(week.days).toHaveLength(7))
})
it('starts every week on a Monday', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
weeks.value.forEach(week => {
const first = new Date(`${week.days[0].isoDate}T00:00:00`)
expect(first.getDay()).toBe(1) // 1 = lundi
})
})
it('flags exactly the days of the current month', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours
const currentMonthDays = weeks.value
.flatMap(w => w.days)
.filter(d => d.isCurrentMonth)
expect(currentMonthDays).toHaveLength(31)
expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true)
})
it('handles leap year February (29 days)', () => {
const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024
const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth)
expect(days).toHaveLength(29)
})
it('assigns ISO week 1 to the week containing Jan 4th', () => {
const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026
const weekWithJan4 = weeks.value.find(w =>
w.days.some(d => d.isoDate === '2026-01-04'),
)
expect(weekWithJan4?.weekNumber).toBe(1)
})
it('reacts to month/year changes', () => {
const month = ref(4)
const year = ref(2026)
const {weeks} = useMonthMatrix(month, year)
const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
month.value = 1 // février
year.value = 2024
const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
expect(mayCount).toBe(31)
expect(febCount).toBe(29)
})
describe('isToday', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('flags only today', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday)
expect(todays).toHaveLength(1)
expect(todays[0].isoDate).toBe('2026-05-19')
})
})
})
@@ -0,0 +1,60 @@
import {computed, type ComputedRef, type Ref} from 'vue'
export type DayCell = {
isoDate: string
day: number
isCurrentMonth: boolean
isToday: boolean
}
export type WeekRow = {
weekNumber: number
days: DayCell[]
}
const toIso = (d: Date): string => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const isoWeek = (d: Date): number => {
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const dayNum = target.getUTCDay() || 7 // dimanche = 7
target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export function useMonthMatrix(
month: Ref<number>,
year: Ref<number>,
): {weeks: ComputedRef<WeekRow[]>} {
const weeks = computed<WeekRow[]>(() => {
const todayIso = toIso(new Date())
const first = new Date(year.value, month.value, 1)
// recule jusqu'au lundi (getDay : 0 = dimanche)
const offset = (first.getDay() + 6) % 7
const start = new Date(year.value, month.value, 1 - offset)
const rows: WeekRow[] = []
const cursor = new Date(start)
for (let w = 0; w < 6; w++) {
const days: DayCell[] = []
for (let i = 0; i < 7; i++) {
const iso = toIso(cursor)
days.push({
isoDate: iso,
day: cursor.getDate(),
isCurrentMonth: cursor.getMonth() === month.value,
isToday: iso === todayIso,
})
cursor.setDate(cursor.getDate() + 1)
}
rows.push({weekNumber: isoWeek(new Date(`${days[0].isoDate}T00:00:00`)), days})
}
return rows
})
return {weeks}
}
@@ -0,0 +1,238 @@
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<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"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-m-primary"
aria-label="Effacer la date"
@click.stop="emit('clear')"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
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)]"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
const props = withDefaults(
defineProps<{
displayValue: string
syncTo: string | null
id?: string
name?: string
label?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
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 showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(isOpen, (value) => {
if (!value) emit('close')
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
<button
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
@click="emit('prev')"
>
<Icon
icon="mdi:chevron-left"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
icon="mdi:chevron-down"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
@click="emit('next')"
>
<Icon
icon="mdi:chevron-right"
:width="25"
:height="25"
/>
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {Icon} from '@iconify/vue'
defineOptions({name: 'MalioDateCalendarHeader'})
const props = defineProps<{
viewMode: 'days' | 'months'
currentMonth: number
currentYear: number
}>()
const emit = defineEmits<{
(e: 'prev' | 'next' | 'toggle-view'): void
}>()
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const label = computed(() => {
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
</script>
@@ -0,0 +1,178 @@
<template>
<div
data-test="month-grid"
@mouseleave="emit('hover', null)"
>
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
S
</div>
<div
v-for="d in dayLabels"
:key="d"
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
>
{{ d }}
</div>
<template
v-for="(week, wIndex) in weeks"
:key="week.days[0].isoDate"
>
<component
:is="interactiveWeekNumber ? 'button' : 'div'"
data-test="week-number"
:data-week-start="week.days[0].isoDate"
:data-marked="markedWeekStart === week.days[0].isoDate"
:type="interactiveWeekNumber ? 'button' : undefined"
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
:class="[
weekNumberClass(week),
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
@click="onWeekNumberClick(week)"
@mouseenter="onWeekNumberHover(week)"
>
{{ week.weekNumber }}
</component>
<button
v-for="cell in week.days"
:key="cell.isoDate"
type="button"
data-test="day"
:data-iso="cell.isoDate"
:data-range-role="roleOf(cell)"
:disabled="!inRange(cell.isoDate)"
:aria-label="ariaLabel(cell)"
:aria-disabled="!inRange(cell.isoDate)"
class="relative flex h-[45px] w-full items-center justify-center"
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="onSelect(cell.isoDate)"
@mouseenter="emit('hover', cell.isoDate)"
>
<span
v-if="roleOf(cell) === 'in-range'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'start'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-l-full bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'end'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-r-full bg-m-primary-light"
/>
<span
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
:class="cellClass(cell)"
>
{{ cell.day }}
</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, toRef} from 'vue'
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
import {isDateInRange} from '../composables/dateFormat'
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
defineOptions({name: 'MalioDateMonthGrid'})
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
min: undefined,
max: undefined,
},
)
const emit = defineEmits<{
(e: 'select', iso: string): void
(e: 'hover', iso: string | null): void
}>()
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
const weekNumberClass = (week: WeekRow) => {
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
const parts = ['bg-m-primary-light']
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
return parts.join(' ')
}
const onWeekNumberClick = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('select', week.days[0].isoDate)
}
const onWeekNumberHover = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('hover', week.days[0].isoDate)
}
const isRangeMode = computed(() => props.rangeStart !== undefined)
const bounds = computed(() =>
isRangeMode.value
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
: null,
)
const roleOf = (cell: DayCell): DayRangeRole => {
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
return props.selectedDate === cell.isoDate ? 'single' : 'none'
}
const ariaLabel = (cell: DayCell) => {
const [, m, d] = cell.isoDate.split('-')
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
}
const cellClass = (cell: DayCell) => {
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
const role = roleOf(cell)
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']
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%]')
return parts.join(' ')
}
const onSelect = (iso: string) => {
if (!inRange(iso)) return
emit('select', iso)
}
</script>
@@ -0,0 +1,36 @@
<template>
<div
data-test="month-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="(name, index) in months"
:key="name"
type="button"
data-test="month"
:data-month="index"
class="flex h-[45px] w-full items-center justify-center"
@click="emit('select', index)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: 'text-black hover:bg-m-primary/10'"
>
{{ name }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{selectedMonth?: number}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
</script>
+280 -62
View File
@@ -1,15 +1,22 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Drawer from './Drawer.vue'
type DrawerProps = {
modelValue?: boolean
title?: string
showClose?: boolean
id?: string
modelValue?: boolean
side?: 'right' | 'left'
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
@@ -18,64 +25,38 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
return mount(DrawerForTest, {
props,
slots,
global: {
stubs: {
Teleport: true,
},
},
global: { stubs: { Teleport: true } },
})
}
describe('MalioDrawer', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders when modelValue is true', () => {
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('renders the title', () => {
const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' })
expect(wrapper.find('h2').text()).toBe('Mon tiroir')
})
it('renders slot content', () => {
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu du drawer</p>' },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer')
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('emits update:modelValue false on backdrop click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
it('emits update:modelValue false on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
it('shows close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides close button when showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:close icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:close')
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
@@ -85,38 +66,275 @@ describe('MalioDrawer', () => {
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
const id = wrapper.find('.fixed').attributes('id')
expect(id).toMatch(/^malio-drawer-/)
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
})
it('has role="dialog" and aria-modal on panel', () => {
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('aria-labelledby links to title id', () => {
const wrapper = mountComponent({ modelValue: true, id: 'test-drawer' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-title')
expect(wrapper.find('h2').attributes('id')).toBe('test-drawer-title')
})
it('applies drawerClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.classes()).toContain('max-w-lg')
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('works in uncontrolled mode', () => {
const wrapper = mountComponent()
// Without modelValue, defaults to closed
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-drawer' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Panneau latéral')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer wrapper when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('aligns to the right by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').classes()).toContain('justify-end')
})
it('aligns to the left when side is "left"', () => {
const wrapper = mountComponent({ modelValue: true, side: 'left' })
expect(wrapper.find('.fixed').classes()).toContain('justify-start')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(DrawerForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('moves focus to the close button on open (default showClose)', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: false, showClose: true },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
wrapper.unmount()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => {
const wrapperA = mount(DrawerForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(DrawerForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
// Open drawer A → scroll locked
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
// Open drawer B → still locked
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
// Close drawer B → A is still open, scroll must remain locked
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
// Close drawer A → both closed, scroll-lock released
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
+191 -37
View File
@@ -1,60 +1,77 @@
<template>
<Teleport to="body">
<Transition
name="drawer"
:name="`drawer-${side}`"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex justify-end"
class="fixed inset-0 z-50 flex"
:class="side === 'right' ? 'justify-end' : 'justify-start'"
v-bind="attrs"
>
<div
class="absolute inset-0 bg-black/40"
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="close"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl',
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
drawerClass,
)"
role="dialog"
:aria-modal="true"
:aria-labelledby="titleId"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div class="flex items-center justify-between px-5 pb-8 pt-8">
<h2
:id="titleId"
class="text-[32px] font-semibold text-m-primary"
<div
v-if="hasHeader || showClose"
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
{{ title }}
</h2>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:close"
:width="24"
:height="24"
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
class="flex-1 overflow-y-auto px-5"
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
@@ -62,7 +79,17 @@
</template>
<script setup lang="ts">
import { computed, ref, useAttrs, useId, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
@@ -72,68 +99,195 @@ const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
title?: string
side?: 'right' | 'left'
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
title: '',
side: 'right',
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
drawerClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
const titleId = computed(() => `${componentId.value}-title`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openDrawerCount++
if (openDrawerCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) isRendered.value = true
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
function close() {
if (!isControlled.value) {
localValue.value = false
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioDrawer instances: only the last open drawer releases the body scroll-lock.
let openDrawerCount = 0
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
.drawer-right-enter-active,
.drawer-right-leave-active,
.drawer-left-enter-active,
.drawer-left-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
.drawer-right-enter-active > div:last-child,
.drawer-right-leave-active > div:last-child,
.drawer-left-enter-active > div:last-child,
.drawer-left-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
.drawer-right-enter-from,
.drawer-right-leave-to,
.drawer-left-enter-from,
.drawer-left-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
.drawer-right-enter-from > div:last-child,
.drawer-right-leave-to > div:last-child {
transform: translateX(100%);
}
.drawer-left-enter-from > div:last-child,
.drawer-left-leave-to > div:last-child {
transform: translateX(-100%);
}
</style>
+16
View File
@@ -126,6 +126,13 @@ describe('MalioInputText', () => {
expect(wrapper.get('input').classes()).toContain('text-black/60')
})
it('shows muted label color when disabled (matches border color)', () => {
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountInput({modelValue: ''})
@@ -253,6 +260,15 @@ describe('MalioInputText', () => {
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
})
it('reserves space for the message even when no hint/error/success is set', () => {
const wrapper = mountInput({})
const p = wrapper.find('p')
expect(p.exists()).toBe(true)
expect(p.text()).toBe('')
expect(p.classes()).toContain('min-h-[1rem]')
})
it('does not render label when label prop is missing', () => {
const wrapper = mountInput({labelClass: 'text-red-500'})
+5 -5
View File
@@ -44,7 +44,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -52,7 +51,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -109,7 +108,7 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
iconSize: 24,
iconSize: 20,
iconColor: 'text-m-muted',
},
)
@@ -153,12 +152,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -28,6 +28,7 @@ type InputAutocompleteProps = {
debounce?: number
minSearchLength?: number
allowCreate?: boolean
localFilter?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
@@ -427,4 +428,82 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').element.value).toBe('Custom')
})
it('does not filter options when localFilter is false (default)', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('fr')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
})
it('filters options client-side when localFilter is true', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('fr')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('France')
})
it('localFilter is case-insensitive and matches substrings', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('GIQ')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('Belgique')
})
it('localFilter shows all options when input is empty', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
})
it('localFilter shows the no-results state when nothing matches', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('zzzzz')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(0)
expect(wrapper.find('[data-test="no-results-text"]').exists()).toBe(true)
})
it('keeps the floating label at the same position whether focused or not (no jump)', async () => {
const wrapper = mountComponent({options, label: 'Pays', modelValue: 'fr'})
// when a value is selected and the field is not focused, the label is already floated
const labelClasses = wrapper.get('label').classes()
expect(labelClasses).toContain('-translate-y-[1.25rem]')
// and there is no extra peer-focus translate that would make it jump on click
expect(labelClasses).not.toContain('peer-focus:-translate-y-[1.55rem]')
})
it('does not shift inner text horizontally on focus (no focus:pl change)', () => {
const wrapper = mountComponent({options})
const inputClasses = wrapper.get('input').classes()
expect(inputClasses).not.toContain('focus:pl-[11px]')
})
it('keeps the bottom border allocation when open (transparent, not zero)', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
const inputClasses = wrapper.get('input').classes()
// border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// border-b-transparent keeps the 1px allocation but hides the line
expect(inputClasses).not.toContain('!border-b-0')
expect(inputClasses).toContain('!border-b-transparent')
})
})
@@ -107,7 +107,7 @@
{{ minSearchText }}
</li>
<li
v-else-if="options.length === 0"
v-else-if="filteredOptions.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-results-text"
>
@@ -115,7 +115,7 @@
</li>
<template v-else>
<li
v-for="(opt, index) in options"
v-for="(opt, index) in filteredOptions"
:id="optionId(index)"
:key="String(opt.value)"
data-test="option"
@@ -136,11 +136,10 @@
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -180,6 +179,7 @@ const props = withDefaults(
debounce?: number
minSearchLength?: number
allowCreate?: boolean
localFilter?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
@@ -207,6 +207,7 @@ const props = withDefaults(
debounce: 300,
minSearchLength: 0,
allowCreate: false,
localFilter: false,
iconName: '',
iconPosition: 'left',
iconSize: 24,
@@ -253,9 +254,18 @@ const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
)
const filteredOptions = computed(() => {
if (!props.localFilter) return props.options
const query = inputValue.value.trim().toLowerCase()
if (query === '') return props.options
return props.options.filter(opt =>
opt.label.toLowerCase().includes(query),
)
})
const optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() =>
activeIndex.value >= 0 && props.options[activeIndex.value]
activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
? optionId(activeIndex.value)
: undefined,
)
@@ -294,11 +304,6 @@ const iconInputPaddingClass = computed(() => {
return parts.join(' ')
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const labelPositionClass = computed(() =>
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
)
@@ -315,10 +320,9 @@ const mergedInputClass = computed(() =>
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
isOpen.value ? '!rounded-b-none !border-b-0' : '',
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
),
)
@@ -326,13 +330,14 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: props.disabled
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -432,8 +437,8 @@ const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
onSelect(props.options[activeIndex.value])
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
onSelect(filteredOptions.value[activeIndex.value])
return
}
if (props.allowCreate && inputValue.value !== '') {
@@ -450,7 +455,7 @@ const onKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
isOpen.value = true
}
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
return
}
@@ -481,12 +486,7 @@ onBeforeUnmount(() => {
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
+4 -4
View File
@@ -42,7 +42,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -50,7 +49,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -147,12 +146,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
+1 -2
View File
@@ -51,7 +51,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -59,7 +58,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px] ',
'text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
+4 -4
View File
@@ -47,7 +47,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -55,7 +54,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -155,12 +154,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -298,6 +298,41 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('input').classes()).toContain('!pr-10')
})
it('shows default add button color when empty and unfocused', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('text-m-primary')
})
it('shows primary add button color on focus', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-primary')
})
it('shows black add button color when filled and unfocused', () => {
const wrapper = mountComponent({addable: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-black')
})
it('error overrides focus color on add button', async () => {
const wrapper = mountComponent({addable: true, error: 'Numéro invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-danger')
})
it('success applies to add button', () => {
const wrapper = mountComponent({addable: true, success: 'Numéro valide'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-success')
})
it('applies mask via maska directive', async () => {
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
+6 -5
View File
@@ -60,7 +60,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -68,7 +67,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -175,19 +174,21 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70',
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
+6 -6
View File
@@ -184,7 +184,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${editorId}-describedby`"
:class="[
hasError
@@ -192,7 +191,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ error || success || hint }}
@@ -279,10 +278,11 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isFocused.value
? 'text-m-primary'
: 'text-m-text',
props.disabled ? 'text-black/60' : '',
: props.disabled
? 'text-m-muted'
: isFocused.value
? 'text-m-primary'
: 'text-m-text',
props.labelClass,
),
)
+4 -4
View File
@@ -44,7 +44,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -52,7 +51,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -158,12 +157,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -149,4 +149,38 @@ describe('MalioInputTextArea', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
})
it('renders as a single root element (works as a single grid item)', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const wrapper = mount(InputTextAreaForTest, {
attachTo: host,
})
// host > div[data-v-app] > component roots
const app = host.firstElementChild as HTMLElement
expect(app.children.length).toBe(1)
wrapper.unmount()
host.remove()
})
it('applies primary scrollbar class on focus', async () => {
const wrapper = mount(InputTextAreaForTest)
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
await wrapper.get('textarea').trigger('focus')
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
})
it('removes primary scrollbar class on blur', async () => {
const wrapper = mount(InputTextAreaForTest)
await wrapper.get('textarea').trigger('focus')
await wrapper.get('textarea').trigger('blur')
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
})
})
+77 -70
View File
@@ -1,79 +1,82 @@
<template>
<div :class="mergedGroupClass">
<textarea
:id="inputId"
:name="name"
<div class="relative w-full flex-1">
<textarea
:id="inputId"
:name="name"
:autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[
isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
hasError
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'border-m-success focus:border-m-success'
: 'focus:border-m-primary',
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
disabled ? 'text-black/60' : '',
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
textLabel,
]"
>
{{ label }}
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
v-if="hasError || hasSuccess || hint"
class="mt-1 flex items-center justify-between gap-2 text-xs"
>
<p
:id="`${inputId}-describedby`"
:class="[
:autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[
isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
hasError
? 'text-m-danger'
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
? 'border-m-success focus:border-m-success'
: 'focus:border-m-primary',
isFocused ? 'textarea-scrollbar-primary' : '',
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: disabled
? 'text-m-muted'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
textLabel,
]"
>
{{ label }}
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
class="mt-1 flex items-center justify-between gap-2 text-xs min-h-[1rem]"
>
{{ error || success || hint }}
</p>
<p
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
</div>
</template>
@@ -138,7 +141,7 @@ const props = withDefaults(
)
const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.groupClass),
twMerge('flex flex-col w-full', props.groupClass),
)
const attrs = useAttrs()
@@ -188,4 +191,8 @@ const onInput = (event: Event) => {
background: white;
padding: 0 0.25rem;
}
.textarea-scrollbar-primary {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
</style>
+4 -4
View File
@@ -50,7 +50,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -58,7 +57,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
]"
>
{{ hint || error || success }}
@@ -144,12 +143,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
+320
View File
@@ -0,0 +1,320 @@
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Modal from './Modal.vue'
type ModalProps = {
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const ModalForTest = Modal as DefineComponent<ModalProps>
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
return mount(ModalForTest, {
props,
slots,
global: { stubs: { Teleport: true } },
})
}
describe('MalioModal', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('centers the modal (items-center justify-center)', () => {
const wrapper = mountComponent({ modelValue: true })
const root = wrapper.find('.fixed')
expect(root.classes()).toContain('items-center')
expect(root.classes()).toContain('justify-center')
})
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
})
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
})
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('applies modalClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-modal' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(ModalForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
const wrapperA = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
+279
View File
@@ -0,0 +1,279 @@
<template>
<Teleport to="body">
<Transition
name="modal"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
v-bind="attrs"
>
<div
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]',
modalClass,
)"
role="dialog"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div
v-if="hasHeader || showClose"
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioModal', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
modalClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this modal holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openModalCount++
if (openModalCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
let openModalCount = 0
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
}
</style>
@@ -207,4 +207,70 @@ describe('MalioSelect', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
})
it('shows muted chevron color when empty and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when an option is selected and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows danger chevron color on error even when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, error: 'Selection error'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
})
it('shows success chevron color on success', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, success: 'OK'},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
})
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
const buttonClasses = wrapper.get('button').classes()
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// !border-b-transparent keeps the 1px allocation but hides the line
expect(buttonClasses).not.toContain('!border-b-0')
expect(buttonClasses).toContain('!border-b-transparent')
})
})
+16 -15
View File
@@ -13,19 +13,19 @@
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-0'
: 'rounded-t-none !border !border-m-danger !border-t-0'
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-0'
: 'rounded-t-none !border !border-m-success !border-t-0'
? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0'
: 'rounded-t-none !border !border-m-primary !border-t-0'
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
@@ -73,13 +73,20 @@
</span>
<span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-current'
: disabled
? 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]"
>
<slot name="icon">
@@ -145,7 +152,6 @@
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
@@ -153,7 +159,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
]"
>
{{ error || success || hint }}
@@ -330,12 +336,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
@@ -182,4 +182,70 @@ describe('MalioSelectCheckbox', () => {
const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4')
})
it('shows muted chevron color when nothing is selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when options are selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows danger chevron color on error even when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, error: 'Selection error'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
})
it('shows success chevron color on success', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, success: 'OK'},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
})
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
const buttonClasses = wrapper.get('button').classes()
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// !border-b-transparent keeps the 1px allocation but hides the line
expect(buttonClasses).not.toContain('!border-b-0')
expect(buttonClasses).toContain('!border-b-transparent')
})
})
+16 -15
View File
@@ -13,19 +13,19 @@
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-0'
: 'rounded-t-none !border !border-m-danger !border-t-0'
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-0'
: 'rounded-t-none !border !border-m-success !border-t-0'
? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0'
: 'rounded-t-none !border !border-m-primary !border-t-0'
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
@@ -101,13 +101,20 @@
</span>
<span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-current'
: disabled
? 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]"
>
<slot name="icon">
@@ -194,7 +201,6 @@
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
@@ -202,7 +208,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
]"
>
{{ error || success || hint }}
@@ -409,12 +415,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
+1 -2
View File
@@ -58,7 +58,6 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -66,7 +65,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
]"
>
{{ error || success || hint }}
@@ -0,0 +1,76 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import TimePicker from './TimePicker.vue'
type TimePickerProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}
const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps>
const mountPicker = (props: TimePickerProps = {}) =>
mount(TimePickerForTest, {props, attachTo: document.body})
describe('MalioTimePicker', () => {
it('affiche le label et l\'icône horloge', () => {
const wrapper = mountPicker({label: 'Heure'})
expect(wrapper.get('label').text()).toBe('Heure')
expect(wrapper.find('[data-test="clock-icon"]').exists()).toBe(true)
})
it('affiche la valeur HH:MM dans le champ', () => {
const wrapper = mountPicker({modelValue: '14:30'})
const input = wrapper.get('[data-test="time-field"]').element as HTMLInputElement
expect(input.value).toBe('14:30')
})
it('ouvre le popover à molettes au clic', async () => {
const wrapper = mountPicker()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
await wrapper.get('[data-test="time-field"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
expect(wrapper.find('[data-test="time-wheels"]').exists()).toBe(true)
})
it('n\'ouvre pas le popover si disabled', async () => {
const wrapper = mountPicker({disabled: true})
await wrapper.get('[data-test="time-field"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('émet la valeur réglée depuis les molettes', async () => {
const wrapper = mountPicker({modelValue: '09:30'})
await wrapper.get('[data-test="time-field"]').trigger('click')
wrapper.findComponent({name: 'MalioTimeWheels'}).vm.$emit('update:modelValue', '10:30')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['10:30'])
})
it('émet null au clic sur la croix', async () => {
const wrapper = mountPicker({modelValue: '14:30'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountPicker({error: 'Heure requise'})
const input = wrapper.get('[data-test="time-field"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Heure requise')
})
})
+235
View File
@@ -0,0 +1,235 @@
<template>
<div ref="root">
<div :class="mergedGroupClass">
<input
:id="inputId"
:name="name"
data-test="time-field"
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"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-m-primary"
aria-label="Effacer l'heure"
@click.stop="onClear"
>
<Icon icon="mdi:close" :width="16" :height="16" />
</button>
<Icon
data-test="clock-icon"
icon="mdi:clock-outline"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<!-- Mode overlay (par défaut) : popover absolu au-dessus du contenu suivant. -->
<div
v-if="isOpen && !staticPopover"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<TimeWheels
:model-value="wheelsValue"
@update:model-value="onWheelChange"
/>
</div>
</div>
<!-- Mode statique : molette en flux (hors du groupe à hauteur fixe) le
conteneur parent (ex. popover du DateTime) grandit pour l'englober. -->
<div
v-if="isOpen && staticPopover"
data-test="popover"
role="dialog"
class="relative mt-4 w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<TimeWheels
:model-value="wheelsValue"
@update:model-value="onWheelChange"
/>
</div>
<p
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import TimeWheels from './internal/TimeWheels.vue'
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
staticPopover?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
staticPopover: false,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const localValue = ref<string | null>(null)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? props.modelValue : localValue.value))
const inputId = computed(() => props.id?.toString() || `malio-time-picker-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const displayValue = computed(() => currentValue.value ?? '')
const isFilled = computed(() => displayValue.value.length > 0)
const wheelsValue = computed(() => currentValue.value || '00:00')
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
const commit = (value: string | null) => {
if (!isControlled.value) localValue.value = value
emit('update:modelValue', value)
}
const onWheelChange = (value: string) => commit(value)
const onClear = () => {
commit(null)
}
const onFieldClick = () => {
if (props.disabled || props.readonly) return
isOpen.value = !isOpen.value
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !root.value) return
if (!root.value.contains(event.target as Node)) isOpen.value = false
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
@@ -0,0 +1,31 @@
import {describe, expect, it} from 'vitest'
import {clampHours, clampMinutes, formatTime, padSegment, parseTime} from './timeFormat'
describe('timeFormat', () => {
it('parse une chaîne HH:MM valide', () => {
expect(parseTime('09:05')).toEqual({hours: 9, minutes: 5})
})
it('renvoie null pour vide ou invalide', () => {
expect(parseTime('')).toBeNull()
expect(parseTime(null)).toBeNull()
expect(parseTime('abc')).toBeNull()
expect(parseTime('12')).toBeNull()
})
it('clamp les valeurs hors bornes au parsing', () => {
expect(parseTime('99:88')).toEqual({hours: 23, minutes: 59})
})
it('formate avec zéro-padding', () => {
expect(formatTime(9, 5)).toBe('09:05')
expect(formatTime(0, 0)).toBe('00:00')
})
it('clamp et pad les helpers', () => {
expect(clampHours(30)).toBe(23)
expect(clampHours(-2)).toBe(0)
expect(clampMinutes(75)).toBe(59)
expect(padSegment(7)).toBe('07')
})
})
@@ -0,0 +1,32 @@
export interface TimeParts {
hours: number
minutes: number
}
export function clampHours(value: number): number {
if (Number.isNaN(value)) return 0
return Math.min(23, Math.max(0, Math.trunc(value)))
}
export function clampMinutes(value: number): number {
if (Number.isNaN(value)) return 0
return Math.min(59, Math.max(0, Math.trunc(value)))
}
export function padSegment(value: number): string {
return value.toString().padStart(2, '0')
}
export function parseTime(value: string | null | undefined): TimeParts | null {
if (!value) return null
const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim())
if (!match) return null
return {
hours: clampHours(Number.parseInt(match[1], 10)),
minutes: clampMinutes(Number.parseInt(match[2], 10)),
}
}
export function formatTime(hours: number, minutes: number): string {
return `${padSegment(clampHours(hours))}:${padSegment(clampMinutes(minutes))}`
}
@@ -0,0 +1,120 @@
import {describe, expect, it, vi} from 'vitest'
import {defineComponent, nextTick, ref} from 'vue'
import {mount} from '@vue/test-utils'
import {
CENTER_OFFSET,
VISIBLE_ROWS,
loopCorrection,
scrollTopForValueIndex,
useInfiniteWheel,
valueIndexFromScroll,
} from './useInfiniteWheel'
const H = 40 // itemHeight
const LEN = 24 // ex. heures
describe('useInfiniteWheel — math pure', () => {
it('expose 5 lignes visibles et un offset central de 2', () => {
expect(VISIBLE_ROWS).toBe(5)
expect(CENTER_OFFSET).toBe(2)
})
it('scrollTopForValueIndex et valueIndexFromScroll font un aller-retour', () => {
for (const index of [0, 1, 9, 23]) {
const top = scrollTopForValueIndex(index, H, LEN)
expect(valueIndexFromScroll(top, H, LEN)).toBe(index)
}
})
it('valueIndexFromScroll boucle en modulo', () => {
const top = scrollTopForValueIndex(0, H, LEN)
expect(valueIndexFromScroll(top + LEN * H, H, LEN)).toBe(0)
})
it('loopCorrection laisse le scroll de la copie du milieu inchangé', () => {
const top = scrollTopForValueIndex(12, H, LEN)
expect(loopCorrection(top, H, LEN)).toBe(top)
})
it('loopCorrection ramène vers le milieu quand on dérive vers le haut', () => {
const drifted = scrollTopForValueIndex(0, H, LEN) - LEN * H
expect(loopCorrection(drifted, H, LEN)).toBe(drifted + LEN * H)
})
it('loopCorrection ramène vers le milieu quand on dérive vers le bas', () => {
const drifted = scrollTopForValueIndex(0, H, LEN) + LEN * H
expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H)
})
})
function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) {
let api!: ReturnType<typeof useInfiniteWheel>
const Harness = defineComponent({
setup() {
const container = ref<HTMLElement | null>(null)
api = useInfiniteWheel(container, {
length: 24,
itemHeight: 40,
initialIndex: () => initialIndex,
onChange,
})
return {container}
},
template: '<div ref="container" style="height:200px;overflow:auto"><div style="height:2880px" /></div>',
})
const wrapper = mount(Harness, {attachTo: document.body})
return {wrapper, api: () => api}
}
describe('useInfiniteWheel — composable', () => {
it('step(+1) émet l\'index suivant', async () => {
const changes: number[] = []
const {api} = mountWheelHarness(9, (i) => changes.push(i))
await nextTick()
api().step(1)
expect(changes.at(-1)).toBe(10)
})
it('step boucle de 23 à 0', async () => {
const changes: number[] = []
const {api} = mountWheelHarness(23, (i) => changes.push(i))
await nextTick()
api().step(1)
expect(changes.at(-1)).toBe(0)
})
it('onKeydown ArrowUp décrémente (avec wrap)', async () => {
const changes: number[] = []
const {api} = mountWheelHarness(0, (i) => changes.push(i))
await nextTick()
api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'}))
expect(changes.at(-1)).toBe(23)
})
// Anti-boucle navigateur : un scroll programmatique déclenche une rafale d'évènements
// scroll (animation/snap). Ils ne doivent PAS être pris pour du scroll utilisateur,
// sinon settle() ré-émet en boucle et corrompt le patch DOM de Vue.
it('n\'émet pas en double quand un scroll programmatique déclenche une rafale de scroll', async () => {
vi.useFakeTimers()
try {
const changes: number[] = []
const {wrapper, api} = mountWheelHarness(9, (i) => changes.push(i))
await nextTick()
const el = wrapper.element as HTMLElement
changes.length = 0
api().scrollToIndex(12)
el.dispatchEvent(new Event('scroll'))
el.dispatchEvent(new Event('scroll'))
el.dispatchEvent(new Event('scroll'))
vi.advanceTimersByTime(300)
expect(changes).toEqual([12])
}
finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,117 @@
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export const VISIBLE_ROWS = 5
export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2
/** Index de valeur logique (0..length-1) centré pour un scrollTop donné. */
export function valueIndexFromScroll(scrollTop: number, itemHeight: number, length: number): number {
const flat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET
return ((flat % length) + length) % length
}
/** scrollTop qui centre l'index donné dans la copie du milieu (buffer à 3 copies). */
export function scrollTopForValueIndex(valueIndex: number, itemHeight: number, length: number): number {
const flat = length + valueIndex - CENTER_OFFSET
return flat * itemHeight
}
/** Recentre le scrollTop dans la copie du milieu [length, 2*length) si on a dérivé. */
export function loopCorrection(scrollTop: number, itemHeight: number, length: number): number {
const block = length * itemHeight
const centeredFlat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET
if (centeredFlat < length) return scrollTop + block
if (centeredFlat >= 2 * length) return scrollTop - block
return scrollTop
}
export interface UseInfiniteWheelOptions {
length: number
itemHeight: number
initialIndex: () => number
onChange: (index: number) => void
}
export function useInfiniteWheel(
containerRef: Ref<HTMLElement | null>,
options: UseInfiniteWheelOptions,
) {
const centeredIndex = ref(options.initialIndex())
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
// Fenêtre de suppression : ignore les évènements scroll provoqués par NOS
// repositionnements programmatiques (et les réajustements de scroll-snap), qui
// arrivent en rafale. Un booléen one-shot n'en absorberait qu'un seul : les
// suivants seraient pris pour du scroll utilisateur → settle() → onChange en
// boucle (re-render ré-entrant qui corrompt le patch DOM dans le navigateur).
let suppressed = false
let suppressTimer: ReturnType<typeof setTimeout> | null = null
// Scroll programmatique INSTANTANÉ : pas de 'smooth', dont l'animation multi-frames
// émettrait justement la rafale d'évènements scroll problématique.
function applyScroll(top: number) {
const el = containerRef.value
if (!el) return
suppressed = true
if (suppressTimer) clearTimeout(suppressTimer)
suppressTimer = setTimeout(() => { suppressed = false }, 100)
el.scrollTop = top
}
function readCentered() {
const el = containerRef.value
if (!el) return
centeredIndex.value = valueIndexFromScroll(el.scrollTop, options.itemHeight, options.length)
}
function settle() {
const el = containerRef.value
if (!el) return
readCentered()
options.onChange(centeredIndex.value)
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
if (corrected !== el.scrollTop) applyScroll(corrected)
}
function onScroll() {
if (suppressed) return
readCentered()
if (scrollEndTimer) clearTimeout(scrollEndTimer)
scrollEndTimer = setTimeout(settle, 120)
}
function scrollToIndex(index: number) {
centeredIndex.value = index
applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length))
options.onChange(index)
}
function step(delta: number) {
const next = (((centeredIndex.value + delta) % options.length) + options.length) % options.length
scrollToIndex(next)
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault()
step(-1)
}
else if (event.key === 'ArrowDown') {
event.preventDefault()
step(1)
}
}
onMounted(() => {
const el = containerRef.value
if (!el) return
el.addEventListener('scroll', onScroll, {passive: true})
applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length))
})
onBeforeUnmount(() => {
containerRef.value?.removeEventListener('scroll', onScroll)
if (scrollEndTimer) clearTimeout(scrollEndTimer)
if (suppressTimer) clearTimeout(suppressTimer)
})
return {centeredIndex, scrollToIndex, step, onKeydown}
}
@@ -0,0 +1,41 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import TimeWheel from './TimeWheel.vue'
const HOURS = Array.from({length: 24}, (_, i) => i)
const mountWheel = (modelValue = 9) =>
mount(TimeWheel, {
props: {modelValue, values: HOURS, ariaLabel: 'Heures'},
attachTo: document.body,
})
describe('MalioTimeWheel', () => {
it('expose le rôle spinbutton et les attributs aria', () => {
const wrapper = mountWheel(9)
const el = wrapper.get('[role="spinbutton"]')
expect(el.attributes('aria-label')).toBe('Heures')
expect(el.attributes('aria-valuenow')).toBe('9')
expect(el.attributes('aria-valuemin')).toBe('0')
expect(el.attributes('aria-valuemax')).toBe('23')
expect(el.attributes('aria-valuetext')).toBe('09')
})
it('rend 3 copies des valeurs (buffer infini)', () => {
const wrapper = mountWheel()
expect(wrapper.findAll('[data-test="wheel-item"]')).toHaveLength(24 * 3)
})
it('émet la nouvelle valeur au clavier ArrowDown', async () => {
const wrapper = mountWheel(9)
await wrapper.get('[role="spinbutton"]').trigger('keydown', {key: 'ArrowDown'})
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([10])
})
it('émet la valeur cliquée', async () => {
const wrapper = mountWheel(9)
const item = wrapper.findAll('[data-test="wheel-item"]').find((w) => w.text() === '11')!
await item.trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([11])
})
})
@@ -0,0 +1,95 @@
<template>
<div
ref="container"
class="malio-wheel relative h-[160px] w-14 snap-y snap-mandatory overflow-y-scroll"
role="spinbutton"
:tabindex="0"
:aria-label="ariaLabel"
:aria-valuenow="modelValue"
:aria-valuemin="values[0]"
:aria-valuemax="values[values.length - 1]"
:aria-valuetext="pad(modelValue)"
@keydown="onKeydown"
>
<button
v-for="item in buffer"
:key="item.key"
type="button"
data-test="wheel-item"
class="flex h-8 w-full snap-center items-center justify-center leading-none outline-none transition-all"
:class="itemClass(item.flat)"
tabindex="-1"
@click="onItemClick(item.value)"
>
{{ pad(item.value) }}
</button>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {useInfiniteWheel} from '../composables/useInfiniteWheel'
import {padSegment} from '../composables/timeFormat'
defineOptions({name: 'MalioTimeWheel', inheritAttrs: false})
const props = defineProps<{
modelValue: number
values: number[]
ariaLabel: string
}>()
const emit = defineEmits<{(e: 'update:modelValue', value: number): void}>()
const ITEM_HEIGHT = 32
const container = ref<HTMLElement | null>(null)
const pad = (value: number) => padSegment(value)
const indexOfValue = (value: number) => Math.max(0, props.values.indexOf(value))
const {centeredIndex, scrollToIndex, onKeydown} = useInfiniteWheel(container, {
length: props.values.length,
itemHeight: ITEM_HEIGHT,
initialIndex: () => indexOfValue(props.modelValue),
onChange: (index) => emit('update:modelValue', props.values[index]),
})
const buffer = computed(() =>
[0, 1, 2].flatMap((copy) =>
props.values.map((value, i) => {
const flat = copy * props.values.length + i
return {value, flat, key: flat}
}),
),
)
// Taille décroissante avec la distance au centre (effet molette iOS).
const itemClass = (flat: number) => {
const distance = Math.abs(flat - (props.values.length + centeredIndex.value))
if (distance === 0) return 'text-[16px] font-medium text-black'
if (distance === 1) return 'text-[14px] text-m-muted'
return 'text-[12px] text-m-muted'
}
const onItemClick = (value: number) => scrollToIndex(indexOfValue(value))
watch(
() => props.modelValue,
(value) => {
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value))
},
)
</script>
<style scoped>
.malio-wheel {
scrollbar-width: none;
/* Estompe les valeurs en haut et en bas (effet molette iOS) pour qu'elles ne
débordent pas visuellement du cadre. */
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
}
.malio-wheel::-webkit-scrollbar {
display: none;
}
</style>
@@ -0,0 +1,48 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import TimeWheels from './TimeWheels.vue'
import TimeWheel from './TimeWheel.vue'
const mountWheels = (modelValue = '09:30') =>
mount(TimeWheels, {props: {modelValue}, attachTo: document.body})
describe('MalioTimeWheels', () => {
it('rend deux molettes (heures + minutes) et un séparateur', () => {
const wrapper = mountWheels('09:30')
const wheels = wrapper.findAllComponents(TimeWheel)
expect(wheels).toHaveLength(2)
expect(wheels[0].props('ariaLabel')).toBe('Heures')
expect(wheels[1].props('ariaLabel')).toBe('Minutes')
expect(wrapper.text()).toContain(':')
})
it('splitte modelValue vers les bonnes molettes', () => {
const wrapper = mountWheels('09:30')
const wheels = wrapper.findAllComponents(TimeWheel)
expect(wheels[0].props('modelValue')).toBe(9)
expect(wheels[1].props('modelValue')).toBe(30)
})
it('recompose et émet HH:MM quand l\'heure change', async () => {
const wrapper = mountWheels('09:30')
const wheels = wrapper.findAllComponents(TimeWheel)
wheels[0].vm.$emit('update:modelValue', 14)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['14:30'])
})
it('recompose et émet HH:MM quand la minute change', async () => {
const wrapper = mountWheels('09:30')
const wheels = wrapper.findAllComponents(TimeWheel)
wheels[1].vm.$emit('update:modelValue', 5)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['09:05'])
})
it('par défaut 00:00 quand modelValue est vide', () => {
const wrapper = mountWheels('')
const wheels = wrapper.findAllComponents(TimeWheel)
expect(wheels[0].props('modelValue')).toBe(0)
expect(wheels[1].props('modelValue')).toBe(0)
})
})
@@ -0,0 +1,54 @@
<template>
<div
data-test="time-wheels"
class="relative flex items-center justify-center gap-3 py-2"
>
<!-- bande centrale (overlay, traverse les 2 colonnes) -->
<div
class="pointer-events-none absolute inset-x-2 top-1/2 z-0 h-8 mx-3 -translate-y-1/2 rounded-lg bg-m-primary-light"
/>
<MalioTimeWheel
:model-value="hours"
:values="HOURS"
aria-label="Heures"
class="relative z-10"
@update:model-value="onHours"
/>
<span class="relative z-10 text-[14px] font-bold text-black">:</span>
<MalioTimeWheel
:model-value="minutes"
:values="MINUTES"
aria-label="Minutes"
class="relative z-10"
@update:model-value="onMinutes"
/>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import MalioTimeWheel from './TimeWheel.vue'
import {formatTime, parseTime} from '../composables/timeFormat'
defineOptions({name: 'MalioTimeWheels', inheritAttrs: false})
const props = withDefaults(
defineProps<{modelValue?: string | null}>(),
{modelValue: ''},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string): void}>()
const HOURS = Array.from({length: 24}, (_, i) => i)
const MINUTES = Array.from({length: 60}, (_, i) => i)
const parts = computed(() => parseTime(props.modelValue) ?? {hours: 0, minutes: 0})
const hours = computed(() => parts.value.hours)
const minutes = computed(() => parts.value.minutes)
const onHours = (value: number) => emit('update:modelValue', formatTime(value, minutes.value))
const onMinutes = (value: number) => emit('update:modelValue', formatTime(hours.value, value))
</script>
+125
View File
@@ -0,0 +1,125 @@
<template>
<Story title="Disclosure/Accordion">
<div class="grid grid-cols-1 gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) défaut</h2>
<MalioAccordion v-model="multiple">
<MalioAccordionItem title="Prix" value="prix">
<p>Slider de prix ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<p>Liste de checkboxes ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Marque" value="marque">
<p>Recherche + liste ici</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
<MalioAccordion v-model="single" mode="single">
<MalioAccordionItem title="Question 1" value="q1">
<p>Réponse 1</p>
</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">
<p>Réponse 2</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
<MalioAccordion>
<MalioAccordionItem title="Active" value="ok">
<p>Contenu accessible</p>
</MalioAccordionItem>
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
<p>Inaccessible</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioAccordion
Accordéon compositionnel : un parent `MalioAccordion` qui enveloppe des
`MalioAccordionItem`. Conçu pour des systèmes de filtres (plusieurs sections
dépliées simultanément) comme pour des FAQ (une seule section ouverte).
---
## Props MalioAccordion
### mode
- Type: `'single' | 'multiple'`
- Défaut: `'multiple'`
- Description: `multiple` autorise plusieurs panneaux ouverts ; `single` ferme les autres à l'ouverture.
### modelValue
- Type: `string | string[]`
- Description: clés ouvertes. `string[]` en mode `multiple`, `string` en mode `single`. Sans v-model, état interne (non contrôlé).
### id
- Type: `string`
- Description: préfixe des IDs d'accessibilité. Auto-généré si absent.
### groupClass
- Type: `string`
- Description: classes du conteneur, fusionnées via `twMerge`.
---
## Props MalioAccordionItem
### title
- Type: `string` (requis) texte de l'en-tête.
### value
- Type: `string` — clé unique de la section (recommandée pour piloter le v-model). Auto-générée si absente.
### defaultOpen
- Type: `boolean` — défaut `false`. Ouvre la section au montage (mode non contrôlé uniquement).
### disabled
- Type: `boolean` — défaut `false`. En-tête non cliquable.
### headerClass / panelClass
- Type: `string` — override des classes de l'en-tête / du panneau (`twMerge`).
---
## Slots
Slot par défaut de `MalioAccordionItem` = contenu du panneau.
---
## Accessibilité
- En-tête = `<button>` natif, `aria-expanded`, `aria-controls`.
- Panneau `role="region"` + `aria-labelledby`.
- Sections désactivées : `disabled` + `aria-disabled`.
- Navigation clavier / entre les en-têtes.
---
## Events
### update:modelValue
- Émis à chaque bascule. Retourne `string[]` (mode `multiple`) ou `string` (mode `single`, `''` si tout fermé).
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioAccordion from '../../components/malio/accordion/Accordion.vue'
import MalioAccordionItem from '../../components/malio/accordion/AccordionItem.vue'
defineOptions({ name: 'AccordionStory' })
const multiple = ref<string[]>(['prix'])
const single = ref('q1')
</script>
+94
View File
@@ -0,0 +1,94 @@
<template>
<Story title="Date/Date">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDate
v-model="simpleValue"
label="Date de naissance"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDate
v-model="initialValue"
label="Date du jour"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDate
v-model="boundedValue"
label="Date du rendez-vous"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDate
v-model="initialValue"
label="Date verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDate
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDate
v-model="initialValue"
label="Lecture seule"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDate
v-model="errorValue"
label="Date limite"
error="Date invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioDate
v-model="initialValue"
label="Date confirmée"
success="Enregistrée"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDate from '../../components/malio/date/Date.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>(todayIso)
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>
+77
View File
@@ -0,0 +1,77 @@
<template>
<Story title="Date/DateRange">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateRange
v-model="simpleValue"
label="Période"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateRange
v-model="initialValue"
label="Séjour"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateRange
v-model="boundedValue"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateRange
v-model="initialValue"
label="Période verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateRange
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateRange
v-model="errorValue"
label="Période"
error="Période invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateRange from '../../components/malio/date/DateRange.vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<RangeValue | null>(null)
const initialValue = ref<RangeValue | null>({start: todayIso, end: maxIso})
const boundedValue = ref<RangeValue | null>(null)
const errorValue = ref<RangeValue | null>(null)
</script>
+76
View File
@@ -0,0 +1,76 @@
<template>
<Story title="Date/DateTime">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateTime
v-model="simpleValue"
label="Date et heure"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateTime
v-model="initialValue"
label="Rendez-vous"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateTime
v-model="boundedValue"
label="Créneau"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateTime
v-model="errorValue"
label="Date limite"
error="Date et heure requises"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateTime
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDateTime
v-model="initialValue"
label="Lecture seule"
readonly
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateTime from '../../components/malio/date/DateTime.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-05-20T14:30:00')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>
+75
View File
@@ -0,0 +1,75 @@
<template>
<Story title="Date/DateWeek">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateWeek
v-model="simpleValue"
label="Semaine"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine de livraison"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateWeek
v-model="boundedValue"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateWeek
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateWeek
v-model="errorValue"
label="Semaine"
error="Semaine invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateWeek from '../../components/malio/date/DateWeek.vue'
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-W21')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
</script>
+54 -89
View File
@@ -1,20 +1,51 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ name: 'DrawerStory' })
const showRight = ref(false)
const showLeft = ref(false)
const showForm = ref(false)
const showNoDismiss = ref(false)
</script>
<template>
<Story title="Overlay/Drawer">
<Variant title="Simple">
<Variant title="Droite (défaut)">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showSimple = true"
@click="showRight = true"
>
Ouvrir le drawer
Ouvrir à droite
</button>
<MalioDrawer v-model="showSimple" title="Détails">
<MalioDrawer v-model="showRight">
<template #header>
<h2 class="text-xl font-bold">Détails</h2>
</template>
<p>Contenu simple du drawer.</p>
</MalioDrawer>
</div>
</Variant>
<Variant title="Avec formulaire">
<Variant title="Gauche">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showLeft = true"
>
Ouvrir à gauche
</button>
<MalioDrawer v-model="showLeft" side="left">
<template #header>
<h2 class="text-xl font-bold">Navigation</h2>
</template>
<p>Ce drawer glisse depuis la gauche.</p>
</MalioDrawer>
</div>
</Variant>
<Variant title="Avec footer d'actions">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@@ -22,102 +53,36 @@
>
Ouvrir le formulaire
</button>
<MalioDrawer v-model="showForm" title="Nouveau contact">
<div class="flex flex-col gap-4">
<MalioInputText v-model="formNom" label="Nom" />
<MalioInputText v-model="formPrenom" label="Prénom" />
<MalioButton label="Enregistrer" button-class="w-full" @click="showForm = false" />
<MalioDrawer v-model="showForm">
<template #header>
<h2 class="text-xl font-bold">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
</div>
<template #footer>
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
</template>
</MalioDrawer>
</div>
</Variant>
<Variant title="Sans bouton fermer">
<Variant title="Non dismissable">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showNoClose = true"
@click="showNoDismiss = true"
>
Ouvrir (sans croix)
Ouvrir
</button>
<MalioDrawer v-model="showNoClose" title="Information" :show-close="false">
<p>Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
</MalioDrawer>
</div>
</Variant>
<Variant title="Largeur personnalisée">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showWide = true"
>
Ouvrir (large)
</button>
<MalioDrawer v-model="showWide" title="Drawer large" drawer-class="max-w-2xl">
<p>Ce drawer utilise une largeur personnalisée via la prop drawerClass.</p>
<MalioDrawer v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-xl font-bold">Action requise</h2>
</template>
<p>Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
</MalioDrawer>
</div>
</Variant>
</Story>
</template>
<docs lang="md">
# MalioDrawer
Panneau latéral (drawer) qui s'ouvre depuis la droite avec un fond semi-transparent.
## Props détaillées
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto-généré | Identifiant HTML du drawer |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `title` | `string` | `''` | Titre affiché dans le header |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `drawerClass` | `string` | `''` | Classes CSS additionnelles sur le panneau (fusionnées via `twMerge`) |
## Comportement
- Le drawer s'ouvre en glissant depuis la droite avec une transition
- Un backdrop semi-transparent couvre le reste de la page
- Clic sur le backdrop ferme le drawer
- Bouton de fermeture (croix) en haut à droite, masquable via `showClose`
- Contenu scrollable si plus haut que la fenêtre
- Teleport vers `<body>` pour éviter les problèmes de z-index
## Accessibilité
- `role="dialog"` et `aria-modal="true"` sur le panneau
- `aria-labelledby` lié au titre
- Bouton fermer avec `aria-label="Fermer"`
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `boolean` | Émis à la fermeture (backdrop ou bouton) |
## Slots
| Slot | Description |
|------|-------------|
| `default` | Contenu du drawer |
</docs>
<script setup lang="ts">
import { ref } from 'vue'
import MalioDrawer from '../../components/malio/drawer/Drawer.vue'
import MalioInputText from '../../components/malio/input/InputText.vue'
import MalioButton from '../../components/malio/button/Button.vue'
defineOptions({ name: 'DrawerStory' })
const showSimple = ref(false)
const showForm = ref(false)
const showNoClose = ref(false)
const showWide = ref(false)
const formNom = ref('Dupont')
const formPrenom = ref('Jean')
</script>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ name: 'ModalStory' })
const showBase = ref(false)
const showForm = ref(false)
const showNoDismiss = ref(false)
</script>
<template>
<Story title="Overlay/Modal">
<Variant title="Simple">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showBase = true"
>
Ouvrir
</button>
<MalioModal v-model="showBase">
<template #header>
<h2 class="text-xl font-bold">Détails</h2>
</template>
<p>Contenu simple de la modal.</p>
</MalioModal>
</div>
</Variant>
<Variant title="Avec footer d'actions">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showForm = true"
>
Ouvrir le formulaire
</button>
<MalioModal v-model="showForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-xl font-bold">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
</div>
<template #footer>
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
</template>
</MalioModal>
</div>
</Variant>
<Variant title="Non dismissable">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showNoDismiss = true"
>
Ouvrir
</button>
<MalioModal v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-xl font-bold">Action requise</h2>
</template>
<p>Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</Variant>
</Story>
</template>
+41
View File
@@ -0,0 +1,41 @@
<template>
<Story title="Time/TimePicker">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTimePicker v-model="simpleValue" label="Heure" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioTimePicker from '../../components/malio/time/TimePicker.vue'
const simpleValue = ref('')
const initialValue = ref('08:30')
const disabledValue = ref('14:15')
const errorValue = ref('25:90')
const successValue = ref('09:00')
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
# MalioDateWeek — 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:** Composant `<MalioDateWeek>` (sélection d'une semaine ISO en un clic, hover de semaine), réutilisant le shell + le rendu pilule de `DateRange`.
**Architecture:** Une semaine sélectionnée est une plage lundi→dimanche : `DateWeek` calcule ces bornes et les passe à `MonthGrid` (rendu pilule réutilisé). Ajout de 2 props additives à `MonthGrid` (n° de semaine cliquable + repère) et d'un module pur `dateWeek.ts`.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (`m-*`), `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
**Référence spec :** `docs/superpowers/specs/2026-05-20-dateweek-design.md`
---
## Conventions
- Tests ciblés : `npx vitest run <chemin>` ; commits avec `--no-verify` en cas de hook flaky (suite complète lancée par le hook).
- `data-test` réutilisés (`date-input`, `popover`, `header-*`, `day`+`data-iso`, `clear`, `calendar-icon`, `month`, `week-number`).
- Nouveaux attributs sur la cellule n° de semaine : `data-week-start` (lundi de la ligne), `data-marked` (`"true"`/`"false"`).
- Ordre : 1) `dateWeek.ts` → 2) extension `MonthGrid` → 3) `DateWeek.vue` + tests → 4) story + playground.
---
## Task 1 : Helpers purs `dateWeek.ts`
**Files:**
- Create: `app/components/malio/date/composables/dateWeek.ts`
- Test: `app/components/malio/date/composables/dateWeek.test.ts`
- [ ] **Step 1 : Écrire les tests (échouent)**
```ts
import {describe, expect, it} from 'vitest'
import {
formatWeekDisplay,
isValidIsoWeek,
isoWeekToMonday,
mondayOf,
sundayOf,
toIsoWeek,
} from './dateWeek'
describe('dateWeek', () => {
describe('mondayOf / sundayOf', () => {
it('returns Monday and Sunday of a midweek date', () => {
expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
expect(sundayOf('2026-05-20')).toBe('2026-05-24')
})
it('keeps Monday on a Monday', () => {
expect(mondayOf('2026-05-18')).toBe('2026-05-18')
})
it('returns the preceding Monday for a Sunday', () => {
expect(mondayOf('2026-05-24')).toBe('2026-05-18')
})
})
describe('toIsoWeek', () => {
it('returns the ISO week of a date', () => {
expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
})
it('handles year boundaries', () => {
expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
})
})
describe('isoWeekToMonday', () => {
it('returns the Monday of a week string', () => {
expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
})
it('round-trips with toIsoWeek', () => {
for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
const monday = isoWeekToMonday(w)
expect(monday).not.toBeNull()
expect(toIsoWeek(monday as string)).toBe(w)
}
})
it('returns null for invalid input', () => {
expect(isoWeekToMonday('2026-21')).toBeNull()
expect(isoWeekToMonday('2026-W00')).toBeNull()
expect(isoWeekToMonday('2026-W54')).toBeNull()
expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
})
})
describe('isValidIsoWeek', () => {
it('accepts a real ISO week', () => {
expect(isValidIsoWeek('2026-W21')).toBe(true)
})
it('rejects malformed or impossible weeks', () => {
expect(isValidIsoWeek('2026-21')).toBe(false)
expect(isValidIsoWeek('2026-W00')).toBe(false)
expect(isValidIsoWeek('2026-W54')).toBe(false)
})
})
describe('formatWeekDisplay', () => {
it('formats a week as a human label', () => {
expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('returns empty string for invalid input', () => {
expect(formatWeekDisplay('2026-W54')).toBe('')
})
})
})
```
- [ ] **Step 2 : Lancer, vérifier l'échec**`npx vitest run app/components/malio/date/composables/dateWeek.test.ts` → FAIL (import non résolu)
- [ ] **Step 3 : Implémenter**
```ts
// app/components/malio/date/composables/dateWeek.ts
import {formatIsoToDisplay} from './dateFormat'
const parseUtc = (iso: string): Date => {
const [y, m, d] = iso.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d))
}
const toIso = (d: Date): string => {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function mondayOf(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7 // dimanche = 7
d.setUTCDate(d.getUTCDate() - (dayNum - 1))
return toIso(d)
}
export function sundayOf(iso: string): string {
const d = parseUtc(mondayOf(iso))
d.setUTCDate(d.getUTCDate() + 6)
return toIso(d)
}
export function toIsoWeek(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const isoYear = d.getUTCFullYear()
const yearStart = new Date(Date.UTC(isoYear, 0, 1))
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
return `${isoYear}-W${String(week).padStart(2, '0')}`
}
export function isoWeekToMonday(week: string): string | null {
const m = /^(\d{4})-W(\d{2})$/.exec(week)
if (!m) return null
const year = Number(m[1])
const w = Number(m[2])
if (w < 1 || w > 53) return null
// Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
const jan4 = new Date(Date.UTC(year, 0, 4))
const jan4Day = jan4.getUTCDay() || 7
const monday = new Date(jan4)
monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
const iso = toIso(monday)
// Garde-fou : la semaine 53 n'existe pas pour toutes les années
if (toIsoWeek(iso) !== week) return null
return iso
}
export function isValidIsoWeek(week: string): boolean {
return isoWeekToMonday(week) !== null
}
export function formatWeekDisplay(week: string): string {
const monday = isoWeekToMonday(week)
if (!monday) return ''
const sunday = sundayOf(monday)
const w = Number(week.slice(6))
const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
return `Semaine ${w} (${startDdMm} → ${endFull})`
}
```
- [ ] **Step 4 : Lancer, vérifier le succès** — PASS
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/dateWeek.ts app/components/malio/date/composables/dateWeek.test.ts
git commit -m "feat : helpers purs de semaine ISO (#MUI-33)" --no-verify
```
---
## Task 2 : Ajouts additifs à `MonthGrid.vue`
**Files:**
- Modify: `app/components/malio/date/internal/MonthGrid.vue`
Couvert par `DateWeek.test.ts` (Task 3) ; non-régression par `Date.test.ts` / `DateRange.test.ts`.
- [ ] **Step 1 : Remplacer la cellule n° de semaine** par un élément polymorphe interactif
Dans le template, remplacer le bloc actuel :
```vue
<div
data-test="week-number"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center bg-m-primary-light p-[10px] text-sm"
:class="[
week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60',
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
>
{{ week.weekNumber }}
</div>
```
par :
```vue
<component
:is="interactiveWeekNumber ? 'button' : 'div'"
data-test="week-number"
:data-week-start="week.days[0].isoDate"
:data-marked="markedWeekStart === week.days[0].isoDate"
:type="interactiveWeekNumber ? 'button' : undefined"
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
:class="[
weekNumberClass(week),
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
@click="onWeekNumberClick(week)"
@mouseenter="onWeekNumberHover(week)"
>
{{ week.weekNumber }}
</component>
```
- [ ] **Step 2 : Ajouter les props et la logique** (script)
Modifier les `defineProps`/`withDefaults` pour ajouter `interactiveWeekNumber` et `markedWeekStart`, importer `WeekRow`, et ajouter les helpers. Remplacer le bloc props :
```ts
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
min: undefined,
max: undefined,
},
)
```
Mettre à jour l'import du composable pour récupérer `WeekRow` :
```ts
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
```
Ajouter, après `const inRange = ...` :
```ts
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
const weekNumberClass = (week: WeekRow) => {
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
const parts = ['bg-m-primary-light']
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
return parts.join(' ')
}
const onWeekNumberClick = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('select', week.days[0].isoDate)
}
const onWeekNumberHover = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('hover', week.days[0].isoDate)
}
```
- [ ] **Step 3 : Non-régression `Date` et `DateRange`**
Run: `npx vitest run app/components/malio/date/Date.test.ts app/components/malio/date/DateRange.test.ts`
Expected: PASS (21 + 17). La cellule n° reste un `<div>` non interactif quand `interactiveWeekNumber` est `false`.
- [ ] **Step 4 : Lint**`npx eslint app/components/malio/date/internal/MonthGrid.vue` → 0 erreur
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/internal/MonthGrid.vue
git commit -m "feat : MonthGrid n° de semaine interactif + repère (mode semaine) (#MUI-33)" --no-verify
```
---
## Task 3 : `DateWeek.vue` + tests
**Files:**
- Create: `app/components/malio/date/DateWeek.vue`
- Test: `app/components/malio/date/DateWeek.test.ts`
- [ ] **Step 1 : Créer `DateWeek.vue`**
```vue
<!-- app/components/malio/date/DateWeek.vue -->
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validWeek?.monday ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="activeMonday"
:range-end="activeSunday"
:marked-week-start="validWeek?.monday ?? null"
interactive-week-number
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek, formatWeekDisplay} from './composables/dateWeek'
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() => {
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
return {monday: isoWeekToMonday(props.modelValue) as string}
}
return null
})
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
const onSelect = (iso: string, close: () => void) => {
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart.value = null
close()
}
const onHover = (iso: string | null) => {
hoverWeekStart.value = iso ? mondayOf(iso) : null
}
const onClose = () => {
hoverWeekStart.value = null
}
const onClear = () => {
emit('update:modelValue', null)
hoverWeekStart.value = null
}
</script>
```
- [ ] **Step 2 : Écrire `DateWeek.test.ts`**
```ts
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateWeek from './DateWeek.vue'
type DateWeekProps = {
modelValue?: string | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
}
const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
const mountWeek = (props: DateWeekProps = {}) =>
mount(DateWeekForTest, {props, attachTo: document.body})
describe('MalioDateWeek', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountWeek({label: 'Semaine'})
expect(wrapper.get('label').text()).toBe('Semaine')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted week when modelValue is set', () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('shows an empty field without a value', () => {
const wrapper = mountWeek()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the month of the selected week', async () => {
const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('selects the week when a day is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('selects the week when the week number is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('previews the whole week on day hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('previews the whole week on week-number hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('marks the committed week number', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
})
it('emits null on clear', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables a week fully outside min/max', async () => {
const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
})
it('does not open when disabled', async () => {
const wrapper = mountWeek({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('does not open when readonly', async () => {
const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('sets aria-invalid on error', () => {
const wrapper = mountWeek({error: 'Semaine requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Semaine requise')
})
})
```
- [ ] **Step 3 : Lancer, vérifier le succès**`npx vitest run app/components/malio/date/DateWeek.test.ts` → PASS (~14)
- [ ] **Step 4 : Lint + suite date complète**
Run: `npx eslint app/components/malio/date/ && npx vitest run app/components/malio/date/`
Expected: 0 erreur lint ; tout vert (dont `Date` 21 et `DateRange` 17 inchangés).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/DateWeek.vue app/components/malio/date/DateWeek.test.ts
git commit -m "feat : composant MalioDateWeek (sélection semaine ISO) (#MUI-33)" --no-verify
```
---
## Task 4 : Story + playground
**Files:**
- Create: `app/story/date/dateWeek.story.vue`
- Create: `.playground/pages/composant/date/dateWeek.vue`
- [ ] **Step 1 : Créer la story**
```vue
<!-- app/story/date/dateWeek.story.vue -->
<template>
<Story title="Date/DateWeek">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateWeek
v-model="simpleValue"
label="Semaine"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine de livraison"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateWeek
v-model="boundedValue"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateWeek
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateWeek
v-model="errorValue"
label="Semaine"
error="Semaine invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateWeek from '../../components/malio/date/DateWeek.vue'
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-W21')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
</script>
```
- [ ] **Step 2 : Créer la page playground**
```vue
<!-- .playground/pages/composant/date/dateWeek.vue -->
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateWeek
v-model="value"
label="Semaine"
hint="Clique un jour ou un n° de semaine"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-W52'"
>
Forcer 2026-W52
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateWeek
v-model="erpValue"
label="Semaine"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateWeek
v-model="bounded"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {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())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
```
- [ ] **Step 3 : Vérification visuelle**`npm run dev` → menu "Date" → page DateWeek : hover de semaine (ligne entière), clic jour/n° → sélection, repère n° en bleu plein, bornes. Et `npm run story:dev`.
- [ ] **Step 4 : Lint**`npx eslint app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue` → 0 erreur
- [ ] **Step 5 : Commit**
```bash
git add app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue
git commit -m "feat : story et page playground de MalioDateWeek (#MUI-33)" --no-verify
```
---
## Self-Review (effectuée à l'écriture)
**Couverture spec :** `dateWeek.ts` (mondayOf/sundayOf/toIsoWeek/isoWeekToMonday/isValidIsoWeek/formatWeekDisplay) ✓ T1 ; `MonthGrid` `interactiveWeekNumber`+`markedWeekStart`+`data-week-start`/`data-marked`+weekSelectable ✓ T2 ; `DateWeek` API + un clic + hover semaine + repère + clear + min/max overlap + invalide→null ✓ T3 ; affichage `"Semaine 21 (...)"` ✓ T1/T3 ; story+playground ✓ T4. modelValue `YYYY-Www` ✓.
**Placeholders :** aucun ; code complet.
**Cohérence des types :** `toIsoWeek(iso)→string`, `isoWeekToMonday(week)→string|null`, `mondayOf`/`sundayOf(iso)→string`, `formatWeekDisplay(week)→string` définis T1, consommés T3. `MonthGrid` props `interactiveWeekNumber`/`markedWeekStart` T2 → passées par `DateWeek` T3. Events `select`/`hover` (iso jour) réutilisés ; `DateWeek.onSelect` mappe via `toIsoWeek`, `onHover` via `mondayOf`. `WeekRow` importé de `useMonthMatrix` (déjà exporté). Le rendu pilule s'appuie sur `rangeStart`/`rangeEnd` (inchangés) → `Date`/`DateRange` non impactés.
**Écart assumé :** `MonthGrid` gagne `data-week-start`/`data-marked` (testabilité), conforme à la spec.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
# Refonte du playground — 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:** Remplacer la fausse-SPA du playground (sidebar maison + chargement dynamique dans `index.vue`) par du vrai routage Nuxt fichier + un layout par défaut qui embarque le composant `MalioSidebar` de production.
**Architecture:** Une config de navigation centralisée (`.playground/playground.nav.ts`) alimente un layout par défaut (`.playground/layouts/default.vue`) contenant `<MalioSidebar>` + `<slot />`. Les pages de démo existantes sous `.playground/pages/composant/**` deviennent automatiquement des routes et héritent du layout. `index.vue` devient une simple page d'accueil. Le `app/app.vue` du layer (`<NuxtLayout><NuxtPage /></NuxtLayout>`), hérité via `extends`, applique le layout automatiquement.
**Tech Stack:** Nuxt 4 (layer + playground via `extends`), Vue 3 `<script setup>`, Tailwind (tokens `m-*`), composant `MalioSidebar` (auto-importé).
**Note sur les tests :** Le playground est un harnais de dev, non livré. Vitest est scopé à `app/**/*.test.ts` (la bibliothèque) et aucune page playground n'a de test. Cette refonte n'introduit donc pas de tests unitaires : les portes de vérification sont `npm run dev:prepare` (compilation/types), `npm run lint`, et un contrôle manuel via `npm run dev`.
**Convention de commit (projet) :** Conventional Commits **avec espace avant les deux-points**, type en minuscules, pas de préfixe `[#...]`, suffixe ticket `(#MUI-34)`. Terminer par le trailer `Co-Authored-By`. Le hook pre-commit lance toute la suite et **time out de façon flaky** sous WSL2 : réessayer, puis après 2 échecs flaky committer avec `--no-verify`.
---
## File Structure
| Fichier | Rôle | Action |
|---------|------|--------|
| `.playground/playground.nav.ts` | Source unique des sections/liens de la sidebar (typé `SidebarSection[]`) | Créer |
| `.playground/layouts/default.vue` | Layout par défaut : `MalioSidebar` + zone de contenu `<slot />` | Créer |
| `.playground/pages/index.vue` | Page d'accueil simple (remplace la fausse-SPA) | Réécrire |
| `.claude/skills/creating-malio-component/SKILL.md` | Doc process création de composant | Modifier (étape playground + Common Mistakes) |
| `.playground/pages/composant/**/*.vue` | Pages de démo | **Inchangées** (déjà des routes) |
---
## Task 1 : Config de navigation centralisée
**Files:**
- Create: `.playground/playground.nav.ts`
- [ ] **Step 1 : Créer le fichier de navigation**
Créer `.playground/playground.nav.ts` avec ce contenu exact. Chaque `to` correspond exactement à un fichier existant sous `.playground/pages/composant/`. Le type est importé du SFC `MalioSidebar`.
```ts
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
export const navSections: SidebarSection[] = [
{
label: 'BOUTONS',
icon: 'mdi:gesture-tap-button',
items: [
{label: 'Button', to: '/composant/button/button'},
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
],
},
{
label: 'CHAMPS',
icon: 'mdi:form-textbox',
items: [
{label: 'Texte', to: '/composant/input/inputText'},
{label: 'Nombre', to: '/composant/input/inputNumber'},
{label: 'Montant', to: '/composant/input/inputAmount'},
{label: 'Email', to: '/composant/input/inputEmail'},
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
{label: 'Téléphone', to: '/composant/input/inputPhone'},
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
{label: 'Upload', to: '/composant/input/inputUpload'},
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
],
},
{
label: 'SÉLECTIONS',
icon: 'mdi:form-dropdown',
items: [
{label: 'Select', to: '/composant/select/select'},
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
{label: 'Radio', to: '/composant/radio/radioButton'},
],
},
{
label: 'NAVIGATION',
icon: 'mdi:navigation-variant',
items: [
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Onglets', to: '/composant/tab/tabList'},
],
},
{
label: 'DONNÉES',
icon: 'mdi:table',
items: [
{label: 'DataTable', to: '/composant/datatable/datatable'},
],
},
{
label: 'DIVERS',
icon: 'mdi:dots-horizontal',
items: [
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
],
},
]
```
- [ ] **Step 2 : Vérifier le lint du fichier**
Run: `npx eslint .playground/playground.nav.ts`
Expected: aucune erreur (0 problems). Si ESLint signale un import de type non résolu depuis le `.vue`, c'est un faux positif de résolution ; il ne bloque pas (warnings only). En cas d'**erreur** bloquante sur l'import du type, fallback : remplacer la ligne d'import par une définition locale équivalente :
```ts
type SidebarItem = {label: string; to: string}
type SidebarSection = {label?: string; icon?: string; items: SidebarItem[]}
```
*(Pas de commit ici — les 3 fichiers de la refonte seront committés ensemble en Task 4, car retirer l'ancien `index.vue` casse temporairement le glob.)*
---
## Task 2 : Layout par défaut
**Files:**
- Create: `.playground/layouts/default.vue`
**Pré-requis vérifiés :** `MalioSidebar` est auto-importé (préfixe `Malio`, `pathPrefix: false`). Ses slots sont `logo` et `logo-collapsed`. Sa prop requise est `sections: SidebarSection[]`. Les logos `LOGO_MALIO.png` / `LOGO_MALIO_COLLAPSED.png` sont servis depuis le `public/` du layer (donc accessibles à la racine `/`).
- [ ] **Step 1 : Créer le layout**
Créer `.playground/layouts/default.vue`. Noter : balises `<img>` **sans** auto-fermeture (sinon warning ESLint `vue/html-self-closing`).
```vue
<template>
<div class="flex h-screen">
<MalioSidebar :sections="navSections">
<template #logo>
<NuxtLink to="/">
<img src="/LOGO_MALIO.png" alt="Malio">
</NuxtLink>
</template>
<template #logo-collapsed>
<NuxtLink to="/">
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
</NuxtLink>
</template>
</MalioSidebar>
<main class="flex-1 overflow-y-auto p-6">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
import {navSections} from '../playground.nav'
</script>
```
- [ ] **Step 2 : Vérifier le lint du layout**
Run: `npx eslint .playground/layouts/default.vue`
Expected: aucune erreur bloquante (0 errors).
---
## Task 3 : Réécrire `index.vue` en page d'accueil
**Files:**
- Modify (réécriture complète): `.playground/pages/index.vue`
- [ ] **Step 1 : Remplacer tout le contenu de `index.vue`**
Remplacer **l'intégralité** du fichier `.playground/pages/index.vue` (supprime la sidebar maison + le chargement dynamique par glob) par :
```vue
<template>
<div class="mx-auto max-w-2xl py-16 text-center">
<h1 class="text-3xl font-bold text-m-text">
Playground @malio/layer-ui
</h1>
<p class="mt-4 text-m-muted">
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
</p>
</div>
</template>
```
*(Page sans `<script>` : contenu purement statique. Elle hérite du layout `default` automatiquement.)*
- [ ] **Step 2 : Vérifier le lint de la page**
Run: `npx eslint .playground/pages/index.vue`
Expected: aucune erreur bloquante (0 errors).
---
## Task 4 : Vérification end-to-end + commit de la refonte
**Files:** (commit groupé)
- `.playground/playground.nav.ts`
- `.playground/layouts/default.vue`
- `.playground/pages/index.vue`
- [ ] **Step 1 : Régénérer les types Nuxt (compilation)**
Run: `npm run dev:prepare`
Expected: « Types generated in .playground/.nuxt. » sans erreur de compilation. Valide que le layout, le nav et `index.vue` compilent et que l'import du type `SidebarSection` se résout.
- [ ] **Step 2 : Lint global**
Run: `npm run lint`
Expected: 0 errors (des warnings préexistants sur d'autres fichiers sont tolérés ; aucun nouvel **error** sur les 3 fichiers créés/modifiés).
- [ ] **Step 3 : Contrôle manuel dans le navigateur**
Run: `npm run dev` puis ouvrir l'URL affichée.
Vérifier :
- L'accueil (`/`) affiche le message de bienvenue, avec la `MalioSidebar` à gauche.
- La sidebar liste les 6 sections et tous les liens.
- Cliquer un item (ex. « Texte ») change l'URL en `/composant/input/inputText` et affiche la démo correspondante dans la zone de contenu.
- Le bouton collapse de la sidebar fonctionne (plier/déplier).
- Cliquer le logo ramène à `/`.
Arrêter le serveur (Ctrl+C) une fois vérifié.
- [ ] **Step 4 : Commit de la refonte**
```bash
git add .playground/playground.nav.ts .playground/layouts/default.vue .playground/pages/index.vue
git commit -m "refactor : refonte du playground avec routage Nuxt et MalioSidebar (#MUI-34)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
Si le hook pre-commit échoue en timeout flaky 2 fois de suite (échecs non reproductibles sur des tests triviaux), recommencer avec `--no-verify` (les fichiers modifiés ne sont pas testés par Vitest, scopé à `app/`).
---
## Task 5 : Mettre à jour le skill `creating-malio-component`
Le skill décrit encore l'ancien fonctionnement (auto-découverte par `index.vue` via glob). Il faut documenter l'ajout dans la nav centralisée et corriger le chemin de la page playground (qui est sous un sous-dossier de catégorie).
**Files:**
- Modify: `.claude/skills/creating-malio-component/SKILL.md`
- [ ] **Step 1 : Réécrire l'étape 5 (page playground)**
Remplacer le bloc de l'étape « ### 5. Créer la page playground » — du titre jusqu'à la ligne `**Variantes typiques :**` exclue — par :
```markdown
### 5. Créer la page playground
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`.
Inclure des variantes représentatives dans une grille :
\`\`\`html
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Titre variante</h2>
<MalioMonComposant ... />
</div>
</div>
\`\`\`
```
- [ ] **Step 2 : Mettre à jour la table « Common Mistakes »**
Remplacer la ligne :
```markdown
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
```
par :
```markdown
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
```
- [ ] **Step 3 : Vérifier la cohérence du diagramme workflow**
Lire le bloc `digraph` en tête du skill. L'étape « 5. Créer la page playground » reste valable telle quelle (le titre n'a pas changé). Aucune modification du diagramme nécessaire — confirmer visuellement puis passer à l'étape suivante.
- [ ] **Step 4 : Commit de la mise à jour du skill**
```bash
git add .claude/skills/creating-malio-component/SKILL.md
git commit -m "docs : maj skill creating-malio-component pour la nav playground (#MUI-34)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(Ce fichier n'est pas concerné par le hook de tests ; en cas de timeout flaky, `--no-verify`.)
---
## Vérification finale (après toutes les tâches)
- [ ] `npm run lint` → 0 errors.
- [ ] `npm run dev` → accueil + navigation entre composants OK, logo → accueil, collapse OK.
- [ ] `git log --oneline -3` → 2 nouveaux commits au format `type : … (#MUI-34)`.
- [ ] Plus aucune trace de sidebar maison / `import.meta.glob` dans `.playground/pages/index.vue`.
## Note post-exécution (pour l'agent)
Mettre à jour la mémoire `malio-datepicker-conventions.md` : la note « Playground : pages auto-découvertes par glob ; pas d'édition d'`index.vue` » est désormais fausse. Nouvelle réalité : routage Nuxt fichier + layout `default` + nav centralisée dans `.playground/playground.nav.ts` à éditer pour chaque nouveau composant.
@@ -0,0 +1,712 @@
# MalioDateTime 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 un composant `MalioDateTime` (date + heure dans un seul champ) à la famille temporelle de `@malio/layer-ui`, en version intérimaire avec `<input type="time">` natif.
**Architecture:** Fine enveloppe autour du shell `internal/CalendarField.vue` (comme `MalioDate`). Le slot popover contient `MonthGrid` (jour) + un `<input type="time">` (heure) sous la grille. La valeur émise est l'ISO naïf `"YYYY-MM-DDTHH:MM:00"`. Logique de découpe/recomposition dans un nouveau composable `datetimeFormat.ts`.
**Tech Stack:** Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (`m-*`), `tailwind-merge`, Vitest + @vue/test-utils (jsdom).
**Conventions (rappel) :** Conventional Commits **avec espace avant `:`**, type minuscule, suffixe ticket `(#MUI-33)`, pas de préfixe `[#...]`. Terminer par `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`. Le hook pre-commit est flaky (timeouts WSL2) → après vérification ciblée `npx vitest run <chemin>`, committer avec `--no-verify`.
**Réfs spec :** `docs/superpowers/specs/2026-05-22-datetime-design.md`.
---
### Task 1 : Composable `datetimeFormat.ts`
**Files:**
- Create: `app/components/malio/date/composables/datetimeFormat.ts`
- Test: `app/components/malio/date/composables/datetimeFormat.test.ts`
- [ ] **Step 1 : Écrire les tests qui échouent**
Créer `app/components/malio/date/composables/datetimeFormat.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
splitDateTime,
} from './datetimeFormat'
describe('datetimeFormat', () => {
describe('isValidIsoDateTime', () => {
it('accepte un datetime ISO complet valide', () => {
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
})
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
expect(isValidIsoDateTime('')).toBe(false)
})
})
describe('formatIsoDateTimeToDisplay', () => {
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
})
it('renvoie une chaîne vide pour nul ou invalide', () => {
expect(formatIsoDateTimeToDisplay(null)).toBe('')
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
})
})
describe('splitDateTime', () => {
it('découpe un datetime valide', () => {
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
})
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
expect(splitDateTime(null)).toEqual({date: null, time: ''})
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
})
})
describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
})
it('utilise 00:00 quand l\\'heure est vide', () => {
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
})
})
})
```
- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent**
Run: `npx vitest run app/components/malio/date/composables/datetimeFormat.test.ts`
Expected: FAIL (module `./datetimeFormat` introuvable).
- [ ] **Step 3 : Écrire l'implémentation**
Créer `app/components/malio/date/composables/datetimeFormat.ts` :
```ts
import {isValidIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
export function isValidIsoDateTime(s: string): boolean {
const m = DATETIME_RE.exec(s)
if (!m) return false
const [, date, hh, mm, ss] = m
if (!isValidIso(date)) return false
const h = Number(hh)
const min = Number(mm)
const sec = Number(ss)
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
}
export function formatIsoDateTimeToDisplay(s: string | null): string {
if (!s || !isValidIsoDateTime(s)) return ''
const [date, time] = s.split('T')
const [y, mo, d] = date.split('-')
const [hh, mm] = time.split(':')
return `${d}/${mo}/${y} ${hh}:${mm}`
}
export function splitDateTime(s: string | null): {date: string | null; time: string} {
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
const [date, time] = s.split('T')
return {date, time: time.slice(0, 5)}
}
export function composeDateTime(date: string, time: string): string {
const t = time || '00:00'
return `${date}T${t}:00`
}
```
- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent**
Run: `npx vitest run app/components/malio/date/composables/datetimeFormat.test.ts`
Expected: PASS (tous verts).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/datetimeFormat.ts app/components/malio/date/composables/datetimeFormat.test.ts
git commit --no-verify -m "feat : composable datetimeFormat pour MalioDateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2 : Composant `DateTime.vue`
**Files:**
- Create: `app/components/malio/date/DateTime.vue`
- Test: `app/components/malio/date/DateTime.test.ts`
- [ ] **Step 1 : Écrire les tests qui échouent**
Créer `app/components/malio/date/DateTime.test.ts` :
```ts
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateTime_ from './DateTime.vue'
type DateTimeProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
const mountDateTime = (props: DateTimeProps = {}) =>
mount(DateTimeForTest, {props, attachTo: document.body})
describe('MalioDateTime', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('affiche le label et l\\'icône calendrier', () => {
const wrapper = mountDateTime({label: 'Rendez-vous'})
expect(wrapper.get('label').text()).toBe('Rendez-vous')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche la valeur formatée date + heure dans le champ', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('20/05/2026 14:30')
})
})
describe('popover', () => {
it('ouvre la grille et l\\'input heure au clic', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true)
})
})
describe('sélection', () => {
it('émet le jour à 00:00 et garde le popover ouvert', 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:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('applique l\\'heure réglée avant le clic du jour', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]')
;(time.element as HTMLInputElement).value = '09:15'
await time.trigger('input')
// pas d'émission tant qu'aucun jour n'est choisi
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
})
it('met à jour l\\'heure quand une date est déjà choisie', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]')
;(time.element as HTMLInputElement).value = '08:45'
await time.trigger('input')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
})
it('initialise l\\'input heure depuis la valeur', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement
expect(time.value).toBe('14:30')
})
})
describe('bornes min/max', () => {
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
})
})
describe('effacement', () => {
it('émet null au clic sur la croix', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
})
describe('accessibilité', () => {
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountDateTime({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
})
```
- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent**
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
Expected: FAIL (`DateTime.vue` introuvable).
- [ ] **Step 3 : Écrire l'implémentation**
Créer `app/components/malio/date/DateTime.vue` :
```vue
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="datePart"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="datePart"
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
@select="onSelectDay"
/>
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
<div class="mt-[10px] flex items-center gap-2 border-t border-m-muted/30 pt-[10px]">
<label
:for="timeInputId"
class="text-sm font-medium text-m-muted"
>
Heure
</label>
<input
:id="timeInputId"
data-test="time-input"
type="time"
:value="timeValue"
class="rounded-md border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
@input="onTimeInput"
>
</div>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, useId, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const generatedId = useId()
const timeInputId = computed(() => `${props.id || `malio-datetime-${generatedId}`}-time`)
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('')
const parts = computed(() => splitDateTime(props.modelValue ?? null))
const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value)
function onSelectDay(iso: string) {
const time = parts.value.time || pendingTime.value || '00:00'
emit('update:modelValue', composeDateTime(iso, time))
}
function onTimeInput(e: Event) {
const value = (e.target as HTMLInputElement).value
if (!value) return
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
function onClear() {
pendingTime.value = ''
emit('update:modelValue', null)
}
watch(() => props.modelValue, (val) => {
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
})
</script>
```
- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent**
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
Expected: PASS (tous verts).
Note : si `@input` ne déclenche pas `value` correctement en jsdom, utiliser `await time.setValue('09:15')` à la place du couple `.value =` + `.trigger('input')` dans les tests.
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/DateTime.vue app/components/malio/date/DateTime.test.ts
git commit --no-verify -m "feat : composant MalioDateTime (date + heure) (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3 : Page playground + entrée nav
**Files:**
- Create: `.playground/pages/composant/date/datetime.vue`
- Modify: `.playground/playground.nav.ts` (section `DATES & HEURES`)
- [ ] **Step 1 : Créer la page playground**
Créer `.playground/pages/composant/date/datetime.vue` :
```vue
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateTime</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Simple</h2>
<MalioDateTime
v-model="value"
label="Date et heure du rendez-vous"
hint="Choisis un jour puis une heure"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</div>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Valeur initiale + bornes</h2>
<MalioDateTime
v-model="bounded"
label="Créneau"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ bounded ?? 'null' }}</code></p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {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())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00')
</script>
```
- [ ] **Step 2 : Ajouter l'entrée nav**
Dans `.playground/playground.nav.ts`, section `DATES & HEURES`, ajouter l'item après `Semaine` :
```ts
{label: 'Date & heure', to: '/composant/date/datetime'},
```
Le bloc devient :
```ts
items: [
{label: 'Date', to: '/composant/date/date'},
{label: 'Plage de dates', to: '/composant/date/dateRange'},
{label: 'Semaine', to: '/composant/date/dateWeek'},
{label: 'Date & heure', to: '/composant/date/datetime'},
{label: 'Heure', to: '/composant/time/time'},
],
```
- [ ] **Step 3 : Vérifier le lint**
Run: `npm run lint`
Expected: PASS (pas d'erreur sur les fichiers playground).
- [ ] **Step 4 : Commit**
```bash
git add .playground/pages/composant/date/datetime.vue .playground/playground.nav.ts
git commit --no-verify -m "feat : page playground MalioDateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4 : Story Histoire
**Files:**
- Create: `app/story/date/dateTime.story.vue`
- [ ] **Step 1 : Créer la story**
Créer `app/story/date/dateTime.story.vue` (nom de fichier camelCase pour éviter `vue/multi-word-component-names`) :
```vue
<template>
<Story title="Date/DateTime">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateTime
v-model="simpleValue"
label="Date et heure"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateTime
v-model="initialValue"
label="Rendez-vous"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateTime
v-model="boundedValue"
label="Créneau"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateTime
v-model="errorValue"
label="Date limite"
error="Date et heure requises"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateTime
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDateTime
v-model="initialValue"
label="Lecture seule"
readonly
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateTime from '../../components/malio/date/DateTime.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-05-20T14:30:00')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>
```
- [ ] **Step 2 : Vérifier le lint**
Run: `npm run lint`
Expected: PASS.
- [ ] **Step 3 : Commit**
```bash
git add app/story/date/dateTime.story.vue
git commit --no-verify -m "docs : story Histoire pour MalioDateTime (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 5 : Documentation (COMPONENTS.md + CHANGELOG.md)
**Files:**
- Modify: `COMPONENTS.md`
- Modify: `CHANGELOG.md`
- [ ] **Step 1 : Ajouter la section dans COMPONENTS.md**
Ouvrir `COMPONENTS.md`, repérer la section `MalioDate` (ou la famille date) et ajouter une section `MalioDateTime` calquée dessus, documentant :
- Description : champ unique date + heure, popover (grille + sélecteur d'heure), version intérimaire avec `<input type="time">` natif.
- `modelValue` : `string | null`, format `"YYYY-MM-DDTHH:MM:00"` (ISO naïf sans fuseau ; Symfony applique son fuseau).
- Table des props identique à `MalioDate` (ajouter la note sur `min`/`max` bornant la date).
- Émission `update:modelValue`.
- Exemple d'usage :
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
```
- Note : le sélecteur d'heure est intérimaire et sera remplacé par un composant dédié (maquette à venir).
Respecter le style et la structure exacts des sections existantes (titres, tableaux markdown).
- [ ] **Step 2 : Ajouter l'entrée CHANGELOG.md**
Dans `CHANGELOG.md`, sous `## [0.0.0]``### Added`, ajouter après la ligne `[#MUI-33] Développer le composant Datepicker` :
```
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
```
- [ ] **Step 3 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit --no-verify -m "docs : documente MalioDateTime (COMPONENTS + CHANGELOG) (#MUI-33)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Vérification finale
- [ ] `npx vitest run app/components/malio/date/` → toute la famille date verte.
- [ ] `npm run lint` → pas d'erreur.
- [ ] Revue finale du composant (cohérence avec la famille date, isolation du bloc heure pour remplacement futur).

Some files were not shown because too many files have changed in this diff Show More