Compare commits
6 Commits
bc813190c6
..
v1.7.3
| Author | SHA1 | Date | |
|---|---|---|---|
| b55050b2ad | |||
| 1d66e5dd31 | |||
| c0c39705c7 | |||
| acd531f69e | |||
| 7d7b2fb720 | |||
| 7ca5c5f4c5 |
@@ -12,7 +12,14 @@
|
|||||||
"Bash(mv buttonIcon.story.vue button/)",
|
"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 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 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
|
### 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
|
```html
|
||||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
<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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit |
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 20→31/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>
|
||||||
@@ -33,7 +33,7 @@ const drawerNoDismiss = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2>
|
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
|
||||||
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
||||||
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -45,32 +45,27 @@ const drawerNoDismiss = ref(false)
|
|||||||
<MalioInputText label="Email" />
|
<MalioInputText label="Email" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
||||||
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
||||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Avec footer fixed bottom</h2>
|
<h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
|
||||||
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" />
|
<MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
|
||||||
<MalioDrawer v-model="drawerFixedFooter">
|
<MalioDrawer v-model="drawerFixedFooter">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||||
</template>
|
</template>
|
||||||
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu -->
|
<!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
|
||||||
<div class="flex flex-col gap-4 pb-24">
|
<div class="flex flex-col gap-4">
|
||||||
<p v-for="n in 12" :key="n" class="text-m-text">
|
<p v-for="n in 12" :key="n" class="text-m-text">
|
||||||
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que le footer reste fixé en bas du viewport.
|
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut -->
|
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
|
||||||
<div class="fixed bottom-0 right-0 w-full max-w-md border-t border-m-border bg-white px-5 py-4">
|
|
||||||
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</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-m-btn-action"
|
||||||
|
@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,7 +10,7 @@
|
|||||||
/>
|
/>
|
||||||
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
|
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
|
||||||
</div>
|
</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
|
<MalioInputText
|
||||||
label="Nom du client (Entreprise)"
|
label="Nom du client (Entreprise)"
|
||||||
/>
|
/>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
v-model="multiselectValue"
|
v-model="multiselectValue"
|
||||||
|
error="test"
|
||||||
label="Catégorie"
|
label="Catégorie"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||||
@@ -75,10 +76,13 @@
|
|||||||
<div class="mt-[60px]">
|
<div class="mt-[60px]">
|
||||||
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
||||||
<template #information>
|
<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"/>
|
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
||||||
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
<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" />
|
<MalioInputText label="Nombre de salariés" />
|
||||||
<MalioInputAmount label="CA"/>
|
<MalioInputAmount label="CA"/>
|
||||||
<MalioInputText label="Dirigeant" />
|
<MalioInputText label="Dirigeant" />
|
||||||
@@ -89,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #adresses>
|
<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
|
<MalioButtonIcon
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
aria-label="Supprimer l'adresse"
|
aria-label="Supprimer l'adresse"
|
||||||
@@ -158,6 +162,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, watch} from 'vue'
|
import {ref, computed, watch} from 'vue'
|
||||||
|
import MalioDate from "../../../../app/components/malio/date/Date.vue";
|
||||||
|
|
||||||
type Commune = {
|
type Commune = {
|
||||||
nom: string
|
nom: string
|
||||||
@@ -279,6 +284,7 @@ const onSearchAdresse = async (query: string) => {
|
|||||||
|
|
||||||
const tabsValue = ref('information')
|
const tabsValue = ref('information')
|
||||||
const concurrent = ref('')
|
const concurrent = ref('')
|
||||||
|
const dateCreation = ref<string | null>(null)
|
||||||
|
|
||||||
const informationValid = computed(() => concurrent.value.trim().length > 0)
|
const informationValid = computed(() => concurrent.value.trim().length > 0)
|
||||||
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
v-model="simpleValue"
|
v-model="simpleValue"
|
||||||
label="Pays"
|
label="Pays"
|
||||||
:options="staticOptions"
|
:options="staticOptions"
|
||||||
|
local-filter
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-sm text-m-muted">
|
<p class="mt-2 text-sm text-m-muted">
|
||||||
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
icon-name="mdi:magnify"
|
icon-name="mdi:magnify"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
:options="staticOptions"
|
:options="staticOptions"
|
||||||
|
local-filter
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
@@ -25,6 +25,18 @@ export const navSections: SidebarSection[] = [
|
|||||||
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
{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',
|
label: 'SÉLECTIONS',
|
||||||
icon: 'mdi:form-dropdown',
|
icon: 'mdi:form-dropdown',
|
||||||
@@ -41,7 +53,9 @@ export const navSections: SidebarSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
||||||
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
||||||
|
{label: 'Modal', to: '/composant/modal/modal'},
|
||||||
{label: 'Onglets', to: '/composant/tab/tabList'},
|
{label: 'Onglets', to: '/composant/tab/tabList'},
|
||||||
|
{label: 'Accordéon', to: '/composant/accordion/accordion'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,6 +72,7 @@ export const navSections: SidebarSection[] = [
|
|||||||
{label: 'Heure', to: '/composant/time/time'},
|
{label: 'Heure', to: '/composant/time/time'},
|
||||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||||
|
{label: 'Filtres', to: '/composant/filtre/filtres'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,10 +31,28 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-31] Création d'un composant téléphone
|
* [#MUI-31] Création d'un composant téléphone
|
||||||
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
||||||
* [#MUI-34] Revoir le système de playground
|
* [#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
|
||||||
|
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
|
||||||
|
|
||||||
### Changed
|
### 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`.
|
* [#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
|
### Fixed
|
||||||
|
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
|
||||||
|
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
|
||||||
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
* 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
|
* 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
|
||||||
|
|||||||
+309
-11
@@ -146,7 +146,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
|
|||||||
|
|
||||||
## MalioInputAutocomplete
|
## 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 |
|
| 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` |
|
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
|
||||||
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `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`) |
|
| `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 |
|
| `iconName` | `string` | `''` | Icône Iconify décorative |
|
||||||
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
|
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
|
||||||
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
|
| `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.
|
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- Usage statique -->
|
<!-- Usage statique (filtrage côté client via local-filter) -->
|
||||||
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
|
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
|
||||||
|
|
||||||
<!-- Usage API (parent gère le fetch) -->
|
<!-- Usage API (parent gère le fetch) -->
|
||||||
<MalioInputAutocomplete
|
<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
|
## MalioTime
|
||||||
|
|
||||||
Sélecteur d'heure.
|
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 `00–23`, minutes `00–59`, 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
|
## MalioButton
|
||||||
|
|
||||||
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
||||||
@@ -486,8 +653,11 @@ Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
|||||||
<MalioButton label="Voir plus" variant="tertiary" />
|
<MalioButton label="Voir plus" variant="tertiary" />
|
||||||
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
|
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
|
||||||
<MalioButton label="Pleine largeur" button-class="w-full" />
|
<MalioButton label="Pleine largeur" button-class="w-full" />
|
||||||
|
<MalioButton label="Modifier" button-class="w-m-btn-action" /> <!-- 150px, format bouton d'action -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Token de largeur partagé** : `w-m-btn-action` (150px) est exposé via `tailwind.config.ts` du layer, branché sur la CSS var `--m-btn-action-width`. Pour les boutons d'action (listes, lignes de tableau, footers denses…). Themable côté consommateur en redéfinissant `--m-btn-action-width` dans son propre CSS.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioButtonIcon
|
## MalioButtonIcon
|
||||||
@@ -557,6 +727,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
|
## MalioSidebar
|
||||||
|
|
||||||
Barre latérale de navigation rétractable.
|
Barre latérale de navigation rétractable.
|
||||||
@@ -599,14 +817,14 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
|
|||||||
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
||||||
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
||||||
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
||||||
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) |
|
| `footerClass` | `string` | `''` | Classes CSS du footer fixe (twMerge) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`, `close()`
|
**Events :** `update:modelValue(value: boolean)`, `close()`
|
||||||
|
|
||||||
**Slots :**
|
**Slots :**
|
||||||
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
- `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).
|
- `default` — contenu (zone scrollable : seul le body défile).
|
||||||
- `footer` — rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien).
|
- `footer` — actions (boutons). Rendu en bas du panneau, fixe, hors de la zone scrollable. N'apparaît que si le slot est fourni.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDrawer v-model="isOpen">
|
<MalioDrawer v-model="isOpen">
|
||||||
@@ -622,14 +840,12 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
|
|||||||
<p>Drawer large depuis la gauche</p>
|
<p>Drawer large depuis la gauche</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
|
<!-- Footer d'actions (fixe en bas, hors zone scrollable) -->
|
||||||
<MalioDrawer v-model="isOpen">
|
<MalioDrawer v-model="isOpen">
|
||||||
<template #header><h2>Formulaire</h2></template>
|
<template #header><h2>Formulaire</h2></template>
|
||||||
<MalioInputText label="Nom" />
|
<MalioInputText label="Nom" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="sticky bottom-0 bg-white py-4">
|
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
||||||
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
@@ -642,6 +858,58 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioDataTable
|
## MalioDataTable
|
||||||
|
|
||||||
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
||||||
@@ -695,3 +963,33 @@ Tableau de données presentational avec pagination, filtres par slots et lignes
|
|||||||
v-model:per-page="perPage"
|
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"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
:root {
|
:root {
|
||||||
/* ── Globales ── */
|
/* ── Globales ── */
|
||||||
--m-primary: 34 39 131; /* #222783 - Bleu Malio */
|
--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-bg: 243 244 248; /* #F3F4F8 - Fond de page */
|
||||||
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
|
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
|
||||||
--m-text: 15 23 42; /* #0F172A */
|
--m-text: 15 23 42; /* #0F172A */
|
||||||
@@ -30,6 +31,9 @@
|
|||||||
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
||||||
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
||||||
|
|
||||||
|
/* ── Largeurs Boutons ── */
|
||||||
|
--m-btn-action-width: 150px; /* Boutons d'action (liste, ligne tableau, footer dense…) */
|
||||||
|
|
||||||
/* ── Couleurs de site (usage ponctuel) ── */
|
/* ── Couleurs de site (usage ponctuel) ── */
|
||||||
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
||||||
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -162,7 +162,7 @@ describe('MalioButton', () => {
|
|||||||
it('applies correct dimensions', () => {
|
it('applies correct dimensions', () => {
|
||||||
const wrapper = mountComponent()
|
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]')
|
expect(wrapper.get('button').classes()).toContain('h-[40px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
|||||||
|
|
||||||
const mergedButtonClass = computed(() =>
|
const mergedButtonClass = computed(() =>
|
||||||
twMerge(
|
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,
|
variantClasses.value,
|
||||||
props.buttonClass,
|
props.buttonClass,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="mergedMessageClass"
|
:class="mergedMessageClass"
|
||||||
>
|
>
|
||||||
@@ -121,7 +120,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
|
|
||||||
const mergedMessageClass = computed(() =>
|
const mergedMessageClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'text-xs',
|
'text-xs min-h-[1rem]',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
|
|||||||
@@ -57,25 +57,27 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="totalItems > 0"
|
v-if="totalItems > 0"
|
||||||
class="flex justify-between pt-2"
|
class="flex items-center justify-between pt-2"
|
||||||
data-test="pagination"
|
data-test="pagination"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
|
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
||||||
<MalioSelect
|
<div class="h-12">
|
||||||
:model-value="perPage"
|
<MalioSelect
|
||||||
:options="perPageSelectOptions"
|
:model-value="perPage"
|
||||||
min-width="w-20 !mt-0"
|
:options="perPageSelectOptions"
|
||||||
rounded="rounded"
|
group-class="w-20"
|
||||||
text-field="text-sm"
|
rounded="rounded"
|
||||||
text-value="text-sm"
|
text-field="text-sm"
|
||||||
text-label="text-xs"
|
text-value="text-sm"
|
||||||
data-test="per-page-select"
|
text-label="text-xs"
|
||||||
@update:model-value="onPerPageChange"
|
data-test="per-page-select"
|
||||||
/>
|
@update:model-value="onPerPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
|
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
label="Prev"
|
label="Prev"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -152,12 +152,13 @@ describe('MalioDrawer', () => {
|
|||||||
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the #footer slot inside the body (scrollable zone)', () => {
|
it('renders the #footer slot in a footer pinned below the body', () => {
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent(
|
||||||
{ modelValue: true },
|
{ modelValue: true },
|
||||||
{ footer: '<button data-test="save">Enregistrer</button>' },
|
{ footer: '<button data-test="save">Enregistrer</button>' },
|
||||||
)
|
)
|
||||||
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
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', () => {
|
it('does not render the footer wrapper when no #footer slot', () => {
|
||||||
@@ -170,14 +171,12 @@ describe('MalioDrawer', () => {
|
|||||||
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies footerClass to the footer wrapper', () => {
|
it('applies footerClass to the footer', () => {
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent(
|
||||||
{ modelValue: true, footerClass: 'sticky bottom-0' },
|
{ modelValue: true, footerClass: 'justify-end' },
|
||||||
{ footer: '<span>pied</span>' },
|
{ footer: '<span>pied</span>' },
|
||||||
)
|
)
|
||||||
const footer = wrapper.find('[data-test="footer"]')
|
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
|
||||||
expect(footer.classes()).toContain('sticky')
|
|
||||||
expect(footer.classes()).toContain('bottom-0')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aligns to the right by default', () => {
|
it('aligns to the right by default', () => {
|
||||||
|
|||||||
@@ -64,13 +64,13 @@
|
|||||||
data-test="body"
|
data-test="body"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<div
|
</div>
|
||||||
v-if="$slots.footer"
|
<div
|
||||||
:class="footerClass"
|
v-if="$slots.footer"
|
||||||
data-test="footer"
|
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
|
||||||
>
|
data-test="footer"
|
||||||
<slot name="footer" />
|
>
|
||||||
</div>
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ describe('MalioInputText', () => {
|
|||||||
expect(wrapper.get('input').classes()).toContain('text-black/60')
|
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 () => {
|
it('emits update:modelValue on input change', async () => {
|
||||||
const wrapper = mountInput({modelValue: ''})
|
const wrapper = mountInput({modelValue: ''})
|
||||||
|
|
||||||
@@ -253,6 +260,15 @@ describe('MalioInputText', () => {
|
|||||||
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
|
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', () => {
|
it('does not render label when label prop is missing', () => {
|
||||||
const wrapper = mountInput({labelClass: 'text-red-500'})
|
const wrapper = mountInput({labelClass: 'text-red-500'})
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -109,7 +108,7 @@ const props = withDefaults(
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
iconSize: 24,
|
iconSize: 20,
|
||||||
iconColor: 'text-m-muted',
|
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',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
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
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type InputAutocompleteProps = {
|
|||||||
debounce?: number
|
debounce?: number
|
||||||
minSearchLength?: number
|
minSearchLength?: number
|
||||||
allowCreate?: boolean
|
allowCreate?: boolean
|
||||||
|
localFilter?: boolean
|
||||||
iconName?: string
|
iconName?: string
|
||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
@@ -427,4 +428,82 @@ describe('MalioInputAutocomplete', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('input').element.value).toBe('Custom')
|
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 }}
|
{{ minSearchText }}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-else-if="options.length === 0"
|
v-else-if="filteredOptions.length === 0"
|
||||||
class="px-3 py-2 text-m-muted"
|
class="px-3 py-2 text-m-muted"
|
||||||
data-test="no-results-text"
|
data-test="no-results-text"
|
||||||
>
|
>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<li
|
<li
|
||||||
v-for="(opt, index) in options"
|
v-for="(opt, index) in filteredOptions"
|
||||||
:id="optionId(index)"
|
:id="optionId(index)"
|
||||||
:key="String(opt.value)"
|
:key="String(opt.value)"
|
||||||
data-test="option"
|
data-test="option"
|
||||||
@@ -136,11 +136,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
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 }}
|
{{ hint || error || success }}
|
||||||
@@ -180,6 +179,7 @@ const props = withDefaults(
|
|||||||
debounce?: number
|
debounce?: number
|
||||||
minSearchLength?: number
|
minSearchLength?: number
|
||||||
allowCreate?: boolean
|
allowCreate?: boolean
|
||||||
|
localFilter?: boolean
|
||||||
iconName?: string
|
iconName?: string
|
||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
@@ -207,6 +207,7 @@ const props = withDefaults(
|
|||||||
debounce: 300,
|
debounce: 300,
|
||||||
minSearchLength: 0,
|
minSearchLength: 0,
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
|
localFilter: false,
|
||||||
iconName: '',
|
iconName: '',
|
||||||
iconPosition: 'left',
|
iconPosition: 'left',
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
@@ -253,9 +254,18 @@ const showMinSearch = computed(() =>
|
|||||||
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
|
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 optionId = (index: number) => `${inputId.value}-option-${index}`
|
||||||
const activeOptionId = computed(() =>
|
const activeOptionId = computed(() =>
|
||||||
activeIndex.value >= 0 && props.options[activeIndex.value]
|
activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
|
||||||
? optionId(activeIndex.value)
|
? optionId(activeIndex.value)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
@@ -294,11 +304,6 @@ const iconInputPaddingClass = computed(() => {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusPaddingClass = computed(() => {
|
|
||||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
|
||||||
return 'focus:pl-[11px]'
|
|
||||||
})
|
|
||||||
|
|
||||||
const labelPositionClass = computed(() =>
|
const labelPositionClass = computed(() =>
|
||||||
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
|
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
|
||||||
)
|
)
|
||||||
@@ -315,10 +320,9 @@ const mergedInputClass = computed(() =>
|
|||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: 'focus:border-m-primary',
|
||||||
isOpen.value ? '!rounded-b-none !border-b-0' : '',
|
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
iconInputPaddingClass.value,
|
iconInputPaddingClass.value,
|
||||||
focusPaddingClass.value,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,13 +330,14 @@ const mergedLabelClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
|
||||||
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -432,8 +437,8 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
|
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
|
||||||
onSelect(props.options[activeIndex.value])
|
onSelect(filteredOptions.value[activeIndex.value])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (props.allowCreate && inputValue.value !== '') {
|
if (props.allowCreate && inputValue.value !== '') {
|
||||||
@@ -450,7 +455,7 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
isOpen.value = true
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,12 +486,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grow-height {
|
.grow-height {
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
|
||||||
|
|
||||||
.grow-height:focus {
|
|
||||||
padding-top: 0.625rem;
|
|
||||||
padding-bottom: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -50,7 +49,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ 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',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
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
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -59,7 +58,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'text-xs ml-[2px] ',
|
'text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -55,7 +54,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ 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',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
'left-3',
|
'left-3',
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
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
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -298,6 +298,41 @@ describe('MalioInputPhone', () => {
|
|||||||
expect(wrapper.get('input').classes()).toContain('!pr-10')
|
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 () => {
|
it('applies mask via maska directive', async () => {
|
||||||
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -68,7 +67,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ 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',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
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
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedAddButtonClass = computed(() =>
|
const mergedAddButtonClass = computed(() =>
|
||||||
twMerge(
|
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' : '',
|
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -184,7 +184,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${editorId}-describedby`"
|
:id="`${editorId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -192,7 +191,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px]',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -279,10 +278,11 @@ const mergedLabelClass = computed(() =>
|
|||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: isFocused.value
|
: props.disabled
|
||||||
? 'text-m-primary'
|
? 'text-m-muted'
|
||||||
: 'text-m-text',
|
: isFocused.value
|
||||||
props.disabled ? 'text-black/60' : '',
|
? 'text-m-primary'
|
||||||
|
: 'text-m-text',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ 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',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
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
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -149,4 +149,38 @@ describe('MalioInputTextArea', () => {
|
|||||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,79 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="mergedGroupClass">
|
<div :class="mergedGroupClass">
|
||||||
<textarea
|
<div class="relative w-full flex-1">
|
||||||
:id="inputId"
|
<textarea
|
||||||
:name="name"
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
|
||||||
:autocomplete="autocomplete"
|
: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="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
|
||||||
:class="[
|
:class="[
|
||||||
isFilled ? 'border-black' : 'border-m-muted',
|
isFilled ? 'border-black' : 'border-m-muted',
|
||||||
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
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="[
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'border-m-danger focus:border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'border-m-success focus:border-m-success'
|
||||||
: 'text-m-muted',
|
: 'focus:border-m-primary',
|
||||||
'ml-[2px]',
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -138,7 +141,7 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge('relative w-full', props.groupClass),
|
twMerge('flex flex-col w-full', props.groupClass),
|
||||||
)
|
)
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
@@ -188,4 +191,8 @@ const onInput = (event: Event) => {
|
|||||||
background: white;
|
background: white;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-scrollbar-primary {
|
||||||
|
scrollbar-color: rgb(var(--m-primary)) transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -58,7 +57,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px] min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ 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',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
'left-3',
|
'left-3',
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
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
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? '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,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.find('p.text-m-success').exists()).toBe(false)
|
||||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,19 +13,19 @@
|
|||||||
hasError
|
hasError
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||||
: 'border-m-danger'
|
: 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-success !border-t-0'
|
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||||
: 'border-m-success'
|
: 'border-m-success'
|
||||||
: isOpen
|
: isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-m-muted',
|
: 'border-m-muted',
|
||||||
@@ -73,13 +73,20 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
|
data-test="chevron"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-current'
|
: disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isOpen
|
||||||
|
? 'text-m-primary'
|
||||||
|
: isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
@@ -145,7 +152,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${buttonId}-describedby`"
|
:id="`${buttonId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -153,7 +159,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -330,12 +336,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grow-height {
|
.grow-height {
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
|
||||||
|
|
||||||
.grow-height:focus {
|
|
||||||
padding-top: 0.625rem;
|
|
||||||
padding-bottom: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -182,4 +182,70 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
const root = wrapper.find('button').element.parentElement
|
const root = wrapper.find('button').element.parentElement
|
||||||
expect(root?.className).toContain('mt-4')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,19 +13,19 @@
|
|||||||
hasError
|
hasError
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||||
: 'border-m-danger'
|
: 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-success !border-t-0'
|
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||||
: 'border-m-success'
|
: 'border-m-success'
|
||||||
: isOpen
|
: isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-m-muted',
|
: 'border-m-muted',
|
||||||
@@ -101,13 +101,20 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
|
data-test="chevron"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-current'
|
: disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isOpen
|
||||||
|
? 'text-m-primary'
|
||||||
|
: isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
@@ -194,7 +201,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${buttonId}-describedby`"
|
:id="`${buttonId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -202,7 +208,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -409,12 +415,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grow-height {
|
.grow-height {
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
|
||||||
|
|
||||||
.grow-height:focus {
|
|
||||||
padding-top: 0.625rem;
|
|
||||||
padding-bottom: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -66,7 +65,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs min-h-[1rem]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -45,7 +45,7 @@ const showNoDismiss = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="Avec footer collant">
|
<Variant title="Avec footer d'actions">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
@@ -62,9 +62,7 @@ const showNoDismiss = ref(false)
|
|||||||
<MalioInputText label="Prénom" />
|
<MalioInputText label="Prénom" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
||||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,979 @@
|
|||||||
|
# MalioModal 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 `MalioModal` (dialogue modal centré) autonome à `@malio/layer-ui`, sans modifier le Drawer existant.
|
||||||
|
|
||||||
|
**Architecture:** Composant Vue SFC unique `app/components/malio/modal/Modal.vue`, auto-importé comme `<MalioModal>`. Réimplémente sa propre logique (Teleport, focus-trap, scroll-lock partagé via compteur module-level, pattern contrôlé/non-contrôlé, transition fade+scale) en s'inspirant du Drawer. Structure : header fixe / body scrollable / footer fixe.
|
||||||
|
|
||||||
|
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Tailwind (tokens `m-*`), `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom), Histoire.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-26-modal-design.md`
|
||||||
|
|
||||||
|
**Conventions projet à respecter :**
|
||||||
|
- Commits Conventional **avec espace avant les `:`** : `feat : … (#MUI-36)`, `docs : …`, `test : …`. Type en minuscules, pas de préfixe `[#…]`. Finir par la ligne `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
|
||||||
|
- Le hook pre-commit lance lint + ~595 tests et **time out de façon flaky** sous WSL2. Pattern : réessayer ; après 2 échecs flaky (échecs différents d'un run à l'autre), vérifier les tests ciblés à la main (`npx vitest run <chemin>`) puis committer avec `--no-verify`.
|
||||||
|
- Story : nom de fichier sous un dossier (`story/modal/modal.story.vue`) ; `defineOptions({ name: 'ModalStory' })` pour éviter `vue/multi-word-component-names`.
|
||||||
|
|
||||||
|
**File Structure:**
|
||||||
|
- Create `app/components/malio/modal/Modal.vue` — le composant (≈ taille du Drawer).
|
||||||
|
- Create `app/components/malio/modal/Modal.test.ts` — tests colocalisés.
|
||||||
|
- Create `.playground/pages/composant/modal/modal.vue` — page de démo (route `/composant/modal/modal`).
|
||||||
|
- Modify `.playground/playground.nav.ts` — ajout de l'entrée nav dans la section `NAVIGATION`.
|
||||||
|
- Create `app/story/modal/modal.story.vue` — story Histoire.
|
||||||
|
- Modify `COMPONENTS.md` — section `## MalioModal` (insérée après la section `## MalioDrawer`).
|
||||||
|
- Modify `CHANGELOG.md` — ligne sous `### Added`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Composant MalioModal + suite de tests (cycle TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/malio/modal/Modal.test.ts`
|
||||||
|
- Create: `app/components/malio/modal/Modal.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire la suite de tests qui échoue**
|
||||||
|
|
||||||
|
Create `app/components/malio/modal/Modal.test.ts` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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>' },
|
||||||
|
)
|
||||||
|
// le footer n'est PAS dans la zone scrollable (≠ Drawer)
|
||||||
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/modal/Modal.test.ts`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./Modal.vue"` (le composant n'existe pas encore).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implémenter le composant**
|
||||||
|
|
||||||
|
Create `app/components/malio/modal/Modal.vue` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<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',
|
||||||
|
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 border-t border-m-border 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, opacity 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);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
||||||
|
|
||||||
|
Run: `npx vitest run app/components/malio/modal/Modal.test.ts`
|
||||||
|
Expected: PASS — tous les tests (≈ 32) verts.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 0 erreur sur les fichiers du composant.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/modal/Modal.vue app/components/malio/modal/Modal.test.ts
|
||||||
|
git commit -m "feat : composant Modal (#MUI-36)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
(En cas de timeout flaky pre-commit, voir le pattern conventions en tête de plan : retry ×2 puis `--no-verify` après vérif ciblée `npx vitest run app/components/malio/modal/Modal.test.ts`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Page playground + entrée nav
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.playground/pages/composant/modal/modal.vue`
|
||||||
|
- Modify: `.playground/playground.nav.ts` (section `NAVIGATION`, après le Drawer)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer la page de démo**
|
||||||
|
|
||||||
|
Create `.playground/pages/composant/modal/modal.vue` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from '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">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Détails</h2>
|
||||||
|
</template>
|
||||||
|
<p class="text-m-text">Contenu de la modal. Échap, clic backdrop et croix la ferment.</p>
|
||||||
|
</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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Ajouter l'entrée nav**
|
||||||
|
|
||||||
|
Modify `.playground/playground.nav.ts`, dans la section `NAVIGATION`, ajouter la ligne Modal juste après le Drawer :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
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'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier le lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 0 erreur.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .playground/pages/composant/modal/modal.vue .playground/playground.nav.ts
|
||||||
|
git commit -m "docs : page playground Modal (#MUI-36)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Story Histoire
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/story/modal/modal.story.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer la story**
|
||||||
|
|
||||||
|
Create `app/story/modal/modal.story.vue` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 0 erreur (notamment pas de `vue/multi-word-component-names` grâce au `defineOptions`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/story/modal/modal.story.vue
|
||||||
|
git commit -m "docs : story Histoire Modal (#MUI-36)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `COMPONENTS.md` (insérer après la section `## MalioDrawer`, juste avant `## MalioDataTable`)
|
||||||
|
- Modify: `CHANGELOG.md` (ligne sous `### Added`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter la section dans COMPONENTS.md**
|
||||||
|
|
||||||
|
Dans `COMPONENTS.md`, insérer ce bloc juste après le `---` qui clôt la section `## MalioDrawer` (et avant `## MalioDataTable`) :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Ajouter l'entrée CHANGELOG**
|
||||||
|
|
||||||
|
Dans `CHANGELOG.md`, sous `### Added`, ajouter en dernière ligne de la liste (après la ligne DateTime) :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add COMPONENTS.md CHANGELOG.md
|
||||||
|
git commit -m "docs : documentation du composant Modal (#MUI-36)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérification finale
|
||||||
|
|
||||||
|
- [ ] `npx vitest run app/components/malio/modal/Modal.test.ts` → tous verts.
|
||||||
|
- [ ] `npm run lint` → 0 erreur.
|
||||||
|
- [ ] `npm run dev` → la page `/composant/modal/modal` s'affiche, l'entrée « Modal » est dans la nav sous NAVIGATION, les 4 démos fonctionnent (ouverture, fermeture backdrop/Échap/croix, scroll interne, non-dismissable).
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user