Compare commits
96 Commits
main
...
feature/MU
| Author | SHA1 | Date | |
|---|---|---|---|
| e82840e552 | |||
| 7d83909214 | |||
| e4f4771020 | |||
| a49993bcf5 | |||
| e2dabb0a26 | |||
| f7d4b923f4 | |||
| 34ac08f153 | |||
| d093923a63 | |||
| 4021240df3 | |||
| 9263cb3722 | |||
| 1a5ed60912 | |||
| fe9e127b85 | |||
| 8e6b08400a | |||
| 9a752d08ad | |||
| 4fb23302be | |||
| b764f27186 | |||
| 3495c2f63e | |||
| beb0e32b7e | |||
| 19a1bb5e50 | |||
| 6e683f714d | |||
| ccc1cae6a8 | |||
| 13b0ea685a | |||
| 840a5c6c52 | |||
| c96cb5112d | |||
| 9479c649be | |||
| d65884dc44 | |||
| 29d7eff203 | |||
| c208551a44 | |||
| 7ab2219764 | |||
| 2ce444ec65 | |||
| ac06ed9ae6 | |||
| b2e3a83bb9 | |||
| 9ed094ba86 | |||
| 1ffe63827d | |||
| eb21827686 | |||
| 6938e730b6 | |||
| 174f1f9a64 | |||
| 30efd482d8 | |||
| 7dec45b374 | |||
| ea92acff3a | |||
| a3421c02e9 | |||
| 5563d89743 | |||
| 640ff90187 | |||
| 2eb7a5247a | |||
| 3336ff0c69 | |||
| da3a4cb349 | |||
| 0ddae4dd70 | |||
| 23210e6868 | |||
| 1c0fcd24e3 | |||
| d74f3acc97 | |||
| 014a057196 | |||
| 73483b0573 | |||
| 4855923008 | |||
| fc844078a6 | |||
| 02495245a5 | |||
| 330fb2130b | |||
| 5acefc1d59 | |||
| e77bf49146 | |||
| f59f866354 | |||
| 660c3787fd | |||
| e9741ff38d | |||
| 32608c8f71 | |||
| e1965db04e | |||
| 0ad344bab9 | |||
| 96719be78d | |||
| b90baec571 | |||
| 384f86a3b3 | |||
| e8ddf4e083 | |||
| 7ee64289a8 | |||
| f09f8a91ac | |||
| bcadd46ce2 | |||
| e76337502a | |||
| 968b7087b5 | |||
| 3deba3f369 | |||
| cf46ab0c85 | |||
| 09cc3edf6f | |||
| c95a3657c0 | |||
| 9843f4d032 | |||
| 9d9b9c9dc4 | |||
| 187ef52865 | |||
| 9925f1ced4 | |||
| ded414ba1a | |||
| 11d60e687b | |||
| d3038994c3 | |||
| 0d350e12c6 | |||
| c6acaace27 | |||
| 927c7c3c70 | |||
| bf0aa92497 | |||
| 88dd76a0e4 | |||
| cc04114f89 | |||
| f456ea4ddf | |||
| 77364daa67 | |||
| 1ab7b2427a | |||
| 82ecc9cfe2 | |||
| 65d9060e26 | |||
| ec4c157226 |
@@ -12,7 +12,9 @@
|
|||||||
"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: $?\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
24
.playground/layouts/default.vue
Normal file
24
.playground/layouts/default.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-screen">
|
||||||
|
<MalioSidebar :sections="navSections">
|
||||||
|
<template #logo>
|
||||||
|
<NuxtLink to="/">
|
||||||
|
<img src="/LOGO_MALIO.png" alt="Malio">
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template #logo-collapsed>
|
||||||
|
<NuxtLink to="/">
|
||||||
|
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</MalioSidebar>
|
||||||
|
|
||||||
|
<main class="flex-1 overflow-y-auto p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {navSections} from '../playground.nav'
|
||||||
|
</script>
|
||||||
68
.playground/pages/composant/date/date.vue
Normal file
68
.playground/pages/composant/date/date.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 p-4">
|
||||||
|
<h1 class="text-2xl font-bold">MalioDate</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-start gap-10">
|
||||||
|
<div class="w-[480px] space-y-3">
|
||||||
|
<h2 class="font-semibold">Large (480px)</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="value"
|
||||||
|
label="Date de naissance"
|
||||||
|
hint="Clique pour ouvrir le calendrier"
|
||||||
|
/>
|
||||||
|
<div class="rounded border p-3 text-sm">
|
||||||
|
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||||
|
@click="value = '2026-12-25'"
|
||||||
|
>
|
||||||
|
Forcer le 25/12/2026
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-3 py-1.5"
|
||||||
|
@click="value = null"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-[396px] space-y-3">
|
||||||
|
<h2 class="font-semibold">ERP (396px)</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="erpValue"
|
||||||
|
label="Date du rendez-vous"
|
||||||
|
hint="Largeur cible ERP"
|
||||||
|
/>
|
||||||
|
<div class="rounded border p-3 text-sm">
|
||||||
|
<p>Valeur (ISO) : <code>{{ erpValue ?? 'null' }}</code></p>
|
||||||
|
</div>
|
||||||
|
<MalioDate
|
||||||
|
v-model="bounded"
|
||||||
|
label="Date bornée"
|
||||||
|
:min="todayIso"
|
||||||
|
:max="maxIso"
|
||||||
|
hint="Entre aujourd'hui et +30 jours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
const now = new Date()
|
||||||
|
const todayIso = toIso(now)
|
||||||
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||||
|
|
||||||
|
const value = ref<string | null>(null)
|
||||||
|
const erpValue = ref<string | null>(null)
|
||||||
|
const bounded = ref<string | null>(null)
|
||||||
|
</script>
|
||||||
72
.playground/pages/composant/date/dateRange.vue
Normal file
72
.playground/pages/composant/date/dateRange.vue
Normal file
@@ -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>
|
||||||
68
.playground/pages/composant/date/dateWeek.vue
Normal file
68
.playground/pages/composant/date/dateWeek.vue
Normal file
@@ -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>
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex justify-center">
|
||||||
<div class="flex justify-center mt-8">
|
<div class="w-[1348px]">
|
||||||
<div>
|
<div class="flex gap-3 mt-[46px]">
|
||||||
<div class="w-[1348px] grid grid-cols-3 gap-x-[160px] gap-y-8">
|
<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">Ajouter un client</h1>
|
||||||
|
</div>
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
label="Nom du client (Entreprise)"
|
label="Nom du client (Entreprise)"
|
||||||
/>
|
/>
|
||||||
@@ -13,20 +21,27 @@
|
|||||||
label="Prénom du contact principal"
|
label="Prénom du contact principal"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
label="Catégorie"
|
|
||||||
v-model="multiselectValue"
|
v-model="multiselectValue"
|
||||||
|
label="Catégorie"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPhone
|
||||||
|
v-for="(_, index) in phones"
|
||||||
|
:key="index"
|
||||||
|
v-model="phones[index]"
|
||||||
label="Téléphone"
|
label="Téléphone"
|
||||||
|
add-icon-name="mdi:plus"
|
||||||
|
:addable="phones.length === 1"
|
||||||
|
@add="addPhoneInput"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputEmail
|
||||||
label="Email"
|
label="Email"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-model="distributeur"
|
||||||
value=""
|
value=""
|
||||||
label="Distributeur / Courtier"
|
label="Distributeur / Courtier"
|
||||||
:options="[
|
:options="[
|
||||||
@@ -36,6 +51,7 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-model="nomCourtier"
|
||||||
value=""
|
value=""
|
||||||
label="Nom du courtier"
|
label="Nom du courtier"
|
||||||
:options="[
|
:options="[
|
||||||
@@ -43,83 +59,264 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-model="nomDistributeur"
|
||||||
value=""
|
value=""
|
||||||
label="Distributeur / Courtier"
|
label="Nom du distributeur"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Nom 1', value: 'Nom 1'}
|
{label: 'Nom 1', value: 'Nom 1'}
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox label="Prestation de triage"/>
|
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-16 flex justify-center">
|
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
<MalioButton label="Valider" variant="primary"/>
|
<MalioButton label="Valider" variant="primary"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-[60px]">
|
||||||
|
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
||||||
|
<template #information>
|
||||||
|
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||||
|
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
||||||
|
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
||||||
|
<MalioDate
|
||||||
|
v-model="dateCreation"
|
||||||
|
label="Date création"
|
||||||
|
/>
|
||||||
|
<MalioInputText label="Nombre de salariés" />
|
||||||
|
<MalioInputAmount label="CA"/>
|
||||||
|
<MalioInputText label="Dirigeant" />
|
||||||
|
<MalioInputText label="Résultat" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton label="Valider" variant="primary"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center mt-16">
|
</template>
|
||||||
<div>
|
<template #adresses>
|
||||||
<div class="w-[1348px] grid grid-cols-3 gap-x-[80px] gap-y-8">
|
<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]">
|
||||||
<MalioInputText
|
<MalioButtonIcon
|
||||||
label="Nom du client (Entreprise)"
|
icon="mdi:delete-outline"
|
||||||
/>
|
aria-label="Supprimer l'adresse"
|
||||||
<MalioInputText
|
variant="ghost"
|
||||||
label="Nom du contact principal"
|
button-class="absolute top-3 right-3"
|
||||||
/>
|
@click="onDeleteAdresse"
|
||||||
<MalioInputText
|
|
||||||
label="Prénom du contact principal"
|
|
||||||
/>
|
/>
|
||||||
|
<MalioCheckbox label="Prospect" groupClass="self-center"/>
|
||||||
|
<MalioCheckbox label="Adresse de livraison" groupClass="self-center"/>
|
||||||
|
<MalioCheckbox label="Facturation" groupClass="self-center"/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
label="Catégorie"
|
|
||||||
v-model="multiselectValue"
|
v-model="multiselectValue"
|
||||||
|
label="Catégorie"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
|
||||||
label="Téléphone"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
label="Email"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
value=""
|
label="Pays"
|
||||||
label="Distributeur / Courtier"
|
v-model="pays"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
|
{label: 'France', value: 'France'},
|
||||||
{label: 'Distributeur', value: 'Distributeur'},
|
{label: 'Espagne', value: 'Espagne'}
|
||||||
{label: 'Courtier', value: 'Courtier'},
|
]"/>
|
||||||
]"
|
<MalioInputText v-model="codePostal" label="Code postal" />
|
||||||
/>
|
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
value=""
|
v-model="ville"
|
||||||
label="Nom du courtier"
|
label="Ville"
|
||||||
:options="[
|
:options="villeOptions"
|
||||||
{label: 'Nom 1', value: 'Nom 1'}
|
:no-options-text="villeNoOptionsText"
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioInputAutocomplete
|
||||||
value=""
|
v-model="adresse"
|
||||||
label="Distributeur / Courtier"
|
label="Adresse"
|
||||||
:options="[
|
:options="adresseOptions"
|
||||||
{label: 'Nom 1', value: 'Nom 1'}
|
:loading="adresseLoading"
|
||||||
]"
|
:min-search-length="2"
|
||||||
|
:no-results-text="adresseNoResultsText"
|
||||||
|
:min-search-text="adresseMinSearchText"
|
||||||
|
@search="onSearchAdresse"
|
||||||
|
/>
|
||||||
|
<MalioInputText label="Adresse complémentaire"/>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="dep in departements"
|
||||||
|
:key="dep"
|
||||||
|
v-model="departementsSelected[dep]"
|
||||||
|
:label="dep"
|
||||||
|
group-class="w-auto self-center"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox label="Prestation de triage"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-16 flex justify-center">
|
<MalioSelect label="Contact" :options="[]"/>
|
||||||
|
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-12 flex justify-center gap-6">
|
||||||
|
<MalioButton label="Nouvelle Adresse" variant="secondary" icon-name="mdi:add-bold" icon-position="left"/>
|
||||||
<MalioButton label="Valider" variant="primary"/>
|
<MalioButton label="Valider" variant="primary"/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioTabList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref} from "vue";
|
import {ref, computed, watch} from 'vue'
|
||||||
import MalioSelect from "../../../../app/components/malio/select/Select.vue";
|
import MalioDate from "../../../../app/components/malio/date/Date.vue";
|
||||||
import MalioCheckbox from "../../../../app/components/malio/checkbox/Checkbox.vue";
|
|
||||||
import MalioButton from "../../../../app/components/malio/button/Button.vue";
|
type Commune = {
|
||||||
|
nom: string
|
||||||
|
code: string
|
||||||
|
codesPostaux: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BanFeature = {
|
||||||
|
properties: {
|
||||||
|
label: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
housenumber?: string
|
||||||
|
street?: string
|
||||||
|
postcode: string
|
||||||
|
citycode: string
|
||||||
|
city: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const multiselectValue = ref<Array<string | number>>([])
|
const multiselectValue = ref<Array<string | number>>([])
|
||||||
|
const distributeur = ref<string>('')
|
||||||
|
const phones = ref<string[]>([''])
|
||||||
|
const nomDistributeur = ref<string>('')
|
||||||
|
const nomCourtier = ref<string>('')
|
||||||
|
|
||||||
|
function addPhoneInput() {
|
||||||
|
phones.value.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteAdresse() {
|
||||||
|
console.log('Supprimer cette adresse')
|
||||||
|
}
|
||||||
|
|
||||||
|
const departements = ['86', '17', '82']
|
||||||
|
const departementsSelected = ref<Record<string, boolean>>({86: false, 17: false, 82: false})
|
||||||
|
|
||||||
|
const pays = ref<string>('France')
|
||||||
|
const codePostal = ref<string>('')
|
||||||
|
const ville = ref<string | number | null>(null)
|
||||||
|
const villeOptions = ref<Array<{label: string; value: string}>>([])
|
||||||
|
const villeLoading = ref(false)
|
||||||
|
|
||||||
|
const villeNoOptionsText = computed(() => {
|
||||||
|
if (villeLoading.value) return 'Chargement…'
|
||||||
|
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir un code postal (5 chiffres)'
|
||||||
|
return 'Aucune ville pour ce code postal'
|
||||||
|
})
|
||||||
|
|
||||||
|
let villeFetchId = 0
|
||||||
|
watch(codePostal, async (cp) => {
|
||||||
|
ville.value = null
|
||||||
|
villeOptions.value = []
|
||||||
|
adresse.value = null
|
||||||
|
adresseOptions.value = []
|
||||||
|
if (!/^\d{5}$/.test(cp)) {
|
||||||
|
villeLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const requestId = ++villeFetchId
|
||||||
|
villeLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://geo.api.gouv.fr/communes?codePostal=${cp}`)
|
||||||
|
const data = await response.json() as Commune[]
|
||||||
|
if (requestId !== villeFetchId) return
|
||||||
|
villeOptions.value = data.map(c => ({label: c.nom, value: c.code}))
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== villeFetchId) return
|
||||||
|
villeOptions.value = []
|
||||||
|
console.error('Erreur lors du chargement des villes', err)
|
||||||
|
} finally {
|
||||||
|
if (requestId === villeFetchId) villeLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const adresse = ref<string | number | null>(null)
|
||||||
|
const adresseOptions = ref<Array<{label: string; value: string}>>([])
|
||||||
|
const adresseLoading = ref(false)
|
||||||
|
|
||||||
|
const adresseMinSearchText = computed(() => {
|
||||||
|
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
|
||||||
|
return 'Tapez au moins 3 caractères'
|
||||||
|
})
|
||||||
|
const adresseNoResultsText = computed(() => {
|
||||||
|
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
|
||||||
|
return 'Aucune adresse trouvée'
|
||||||
|
})
|
||||||
|
|
||||||
|
let adresseFetchId = 0
|
||||||
|
const onSearchAdresse = async (query: string) => {
|
||||||
|
if (!/^\d{5}$/.test(codePostal.value) || query.length < 3) {
|
||||||
|
adresseOptions.value = []
|
||||||
|
adresseLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const requestId = ++adresseFetchId
|
||||||
|
adresseLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
postcode: codePostal.value,
|
||||||
|
type: 'housenumber',
|
||||||
|
})
|
||||||
|
const response = await fetch(`https://api-adresse.data.gouv.fr/search/?${params.toString()}`)
|
||||||
|
const data = await response.json() as {features: BanFeature[]}
|
||||||
|
if (requestId !== adresseFetchId) return
|
||||||
|
adresseOptions.value = data.features.map(f => ({
|
||||||
|
label: f.properties.name,
|
||||||
|
value: f.properties.name,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== adresseFetchId) return
|
||||||
|
adresseOptions.value = []
|
||||||
|
console.error('Erreur lors du chargement des adresses', err)
|
||||||
|
} finally {
|
||||||
|
if (requestId === adresseFetchId) adresseLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsValue = ref('information')
|
||||||
|
const concurrent = ref('')
|
||||||
|
const dateCreation = ref<string | null>(null)
|
||||||
|
|
||||||
|
const informationValid = computed(() => concurrent.value.trim().length > 0)
|
||||||
|
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'information',
|
||||||
|
label: 'Information',
|
||||||
|
icon: 'mdi:account-outline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contacts',
|
||||||
|
label: 'Contacts',
|
||||||
|
icon: 'mdi:account-box-plus-outline',
|
||||||
|
disabled: !informationValid.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adresses',
|
||||||
|
label: 'Adresses',
|
||||||
|
icon: 'mdi:map-marker-outline',
|
||||||
|
disabled: !informationValid.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transport',
|
||||||
|
label: 'Transport',
|
||||||
|
icon: 'mdi:truck-delivery-outline',
|
||||||
|
disabled: !informationValid.value || !adressesValid.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comptabilité',
|
||||||
|
label: 'Comptabilité',
|
||||||
|
icon: 'mdi:bank-circle-outline',
|
||||||
|
disabled: !informationValid.value || !adressesValid.value,
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
180
.playground/pages/composant/input/inputAutocomplete.vue
Normal file
180
.playground/pages/composant/input/inputAutocomplete.vue
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<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 (statique)</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-m-muted">
|
||||||
|
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="leftIconValue"
|
||||||
|
label="Recherche"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
icon-position="left"
|
||||||
|
:options="staticOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Branché sur une API (simulé)</h2>
|
||||||
|
<p class="mb-3 text-sm text-m-muted">
|
||||||
|
Le parent écoute l'event <code>search</code> et alimente <code>options</code> + <code>loading</code>.
|
||||||
|
Tapez au moins 2 caractères.
|
||||||
|
</p>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="apiValue"
|
||||||
|
label="Client"
|
||||||
|
:options="apiOptions"
|
||||||
|
:loading="apiLoading"
|
||||||
|
:min-search-length="2"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
icon-position="left"
|
||||||
|
@search="onSearchApi"
|
||||||
|
@select="onSelectApi"
|
||||||
|
/>
|
||||||
|
<p v-if="apiSelected" class="mt-2 text-sm text-m-muted">
|
||||||
|
Sélection : <code>{{ apiSelected.label }} (id={{ apiSelected.value }})</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="createValue"
|
||||||
|
label="Catégorie"
|
||||||
|
:options="staticOptions"
|
||||||
|
allow-create
|
||||||
|
hint="Taper Entrée pour créer une nouvelle valeur"
|
||||||
|
@create="onCreate"
|
||||||
|
/>
|
||||||
|
<p v-if="createdItems.length > 0" class="mt-2 text-sm text-m-muted">
|
||||||
|
Créés : {{ createdItems.join(', ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
model-value="fr"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
model-value="fr"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
hint="Sélectionne un pays dans la liste"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
model-value="fr"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
error="Sélection invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
model-value="fr"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
success="Sélection valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Liste vide</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="emptyValue"
|
||||||
|
label="Recherche"
|
||||||
|
:options="[]"
|
||||||
|
no-results-text="Aucun élément disponible"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
type Option = {label: string; value: string | number}
|
||||||
|
|
||||||
|
const staticOptions: Option[] = [
|
||||||
|
{label: 'France', value: 'fr'},
|
||||||
|
{label: 'Belgique', value: 'be'},
|
||||||
|
{label: 'Canada', value: 'ca'},
|
||||||
|
{label: 'Suisse', value: 'ch'},
|
||||||
|
{label: 'Luxembourg', value: 'lu'},
|
||||||
|
{label: 'Allemagne', value: 'de'},
|
||||||
|
{label: 'Espagne', value: 'es'},
|
||||||
|
{label: 'Italie', value: 'it'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const simpleValue = ref<string | number | null>(null)
|
||||||
|
const leftIconValue = ref<string | number | null>(null)
|
||||||
|
const createValue = ref<string | number | null>(null)
|
||||||
|
const hintValue = ref<string | number | null>(null)
|
||||||
|
const emptyValue = ref<string | number | null>(null)
|
||||||
|
|
||||||
|
const createdItems = ref<string[]>([])
|
||||||
|
const onCreate = (value: string) => {
|
||||||
|
createdItems.value.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiValue = ref<string | number | null>(null)
|
||||||
|
const apiOptions = ref<Option[]>([])
|
||||||
|
const apiLoading = ref(false)
|
||||||
|
const apiSelected = ref<Option | null>(null)
|
||||||
|
|
||||||
|
const fakeClients: Option[] = [
|
||||||
|
{label: 'Yuno Malio', value: 1},
|
||||||
|
{label: 'Yuna Corp', value: 2},
|
||||||
|
{label: 'Yum Foods', value: 3},
|
||||||
|
{label: 'Yumi Studio', value: 4},
|
||||||
|
{label: 'Acme Inc.', value: 5},
|
||||||
|
{label: 'Globex Corp', value: 6},
|
||||||
|
{label: 'Initech', value: 7},
|
||||||
|
{label: 'Soylent Corp', value: 8},
|
||||||
|
]
|
||||||
|
|
||||||
|
const onSearchApi = async (query: string) => {
|
||||||
|
apiLoading.value = true
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 400))
|
||||||
|
apiOptions.value = fakeClients.filter(c =>
|
||||||
|
c.label.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
)
|
||||||
|
apiLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectApi = (option: Option | null) => {
|
||||||
|
apiSelected.value = option
|
||||||
|
}
|
||||||
|
</script>
|
||||||
106
.playground/pages/composant/input/inputEmail.vue
Normal file
106
.playground/pages/composant/input/inputEmail.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<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>
|
||||||
|
<MalioInputEmail />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="emailValue"
|
||||||
|
label="Adresse email"
|
||||||
|
name="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
label="Adresse email"
|
||||||
|
icon-position="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
label="Adresse email"
|
||||||
|
:icon-name="''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
model-value="contact@malio.fr"
|
||||||
|
disabled
|
||||||
|
label="Adresse email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
model-value="readonly@malio.fr"
|
||||||
|
readonly
|
||||||
|
label="Adresse email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
label="Adresse email"
|
||||||
|
hint="ex: prenom.nom@malio.fr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
model-value="pas-un-email"
|
||||||
|
label="Adresse email"
|
||||||
|
error="Adresse email invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
model-value="contact@malio.fr"
|
||||||
|
label="Adresse email"
|
||||||
|
success="Adresse email valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="dynamicEmail"
|
||||||
|
label="Adresse email"
|
||||||
|
hint="Saisir une adresse au format prenom@domaine.tld"
|
||||||
|
:error="dynamicError"
|
||||||
|
:success="dynamicSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const emailValue = ref('')
|
||||||
|
const dynamicEmail = ref('')
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
|
||||||
|
const dynamicError = computed(() => {
|
||||||
|
if (!dynamicEmail.value) return ''
|
||||||
|
return isDynamicValid.value ? '' : 'Adresse email invalide'
|
||||||
|
})
|
||||||
|
const dynamicSuccess = computed(() => {
|
||||||
|
if (!dynamicEmail.value) return ''
|
||||||
|
return isDynamicValid.value ? 'Adresse email valide' : ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
141
.playground/pages/composant/input/inputPhone.vue
Normal file
141
.playground/pages/composant/input/inputPhone.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<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>
|
||||||
|
<MalioInputPhone />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="phoneValue"
|
||||||
|
label="Téléphone"
|
||||||
|
name="phone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="phoneAddable"
|
||||||
|
label="Téléphone"
|
||||||
|
addable
|
||||||
|
@add="onAdd"
|
||||||
|
/>
|
||||||
|
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||||
|
Bouton cliqué {{ addClicks }} fois
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Icône à droite (sans bouton +)</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
label="Téléphone"
|
||||||
|
icon-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
label="Téléphone"
|
||||||
|
:icon-name="''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec masque français</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="phoneFrench"
|
||||||
|
label="Téléphone (FR)"
|
||||||
|
mask="+33 # ## ## ## ##"
|
||||||
|
hint="Saisir uniquement les chiffres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé (avec addable)</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
model-value="+33 6 12 34 56 78"
|
||||||
|
addable
|
||||||
|
disabled
|
||||||
|
label="Téléphone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (avec addable)</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
model-value="+33 6 12 34 56 78"
|
||||||
|
addable
|
||||||
|
readonly
|
||||||
|
label="Téléphone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
label="Téléphone"
|
||||||
|
hint="Format international recommandé"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
model-value="abc"
|
||||||
|
label="Téléphone"
|
||||||
|
error="Numéro de téléphone invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
model-value="+33 6 12 34 56 78"
|
||||||
|
label="Téléphone"
|
||||||
|
success="Numéro valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Cas ERP — liste de téléphones (max 2)</h2>
|
||||||
|
<p class="mb-3 text-sm text-m-muted">
|
||||||
|
Le bouton + s'affiche sur le dernier champ tant que la liste contient moins de {{ MAX_PHONES }} numéros.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<MalioInputPhone
|
||||||
|
v-for="(phone, index) in phones"
|
||||||
|
:key="index"
|
||||||
|
v-model="phones[index]"
|
||||||
|
:label="`Téléphone ${index + 1}`"
|
||||||
|
:addable="index === phones.length - 1 && phones.length < MAX_PHONES"
|
||||||
|
@add="addPhone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const phoneValue = ref('')
|
||||||
|
const phoneAddable = ref('')
|
||||||
|
const phoneFrench = ref('')
|
||||||
|
const addClicks = ref(0)
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
addClicks.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PHONES = 2
|
||||||
|
const phones = ref<string[]>([''])
|
||||||
|
|
||||||
|
const addPhone = () => {
|
||||||
|
if (phones.value.length < MAX_PHONES) {
|
||||||
|
phones.value.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,189 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen">
|
<div class="mx-auto max-w-2xl py-16 text-center">
|
||||||
<aside class="w-72 bg-m-bg p-6 text-white">
|
<h1 class="text-3xl font-bold text-m-text">
|
||||||
<button
|
Playground @malio/layer-ui
|
||||||
type="button"
|
|
||||||
class="text-xl text-black font-semibold"
|
|
||||||
@click="clearSelection"
|
|
||||||
>
|
|
||||||
Liste des composants
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<nav class="mt-6 flex flex-col gap-1">
|
|
||||||
<div
|
|
||||||
v-for="group in groups"
|
|
||||||
:key="group.category"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-black font-bold hover:bg-m-primary/10"
|
|
||||||
@click="toggleCategory(group.category)"
|
|
||||||
>
|
|
||||||
{{ group.category }}
|
|
||||||
<span
|
|
||||||
class="text-xs transition-transform duration-200"
|
|
||||||
:class="openCategories.has(group.category) ? 'rotate-90' : ''"
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="openCategories.has(group.category)"
|
|
||||||
class="ml-3 flex flex-col gap-1 border-l border-gray-300 pl-2"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="item in group.items"
|
|
||||||
:key="item.name"
|
|
||||||
type="button"
|
|
||||||
class="rounded px-3 py-1.5 text-left text-sm text-black hover:bg-m-primary hover:text-white"
|
|
||||||
:class="selectedName === item.name ? 'bg-m-primary/50 text-white' : ''"
|
|
||||||
@click="selectItem(item.name)"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="flex-1 p-6">
|
|
||||||
<component
|
|
||||||
:is="selectedDemoComponent"
|
|
||||||
v-if="selectedDemoComponent"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-else-if="selectedName"
|
|
||||||
class="text-gray-700"
|
|
||||||
>
|
|
||||||
Page de demo introuvable:
|
|
||||||
<code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
|
|
||||||
</p>
|
|
||||||
<div v-else>
|
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">
|
|
||||||
Playground composants
|
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2 text-gray-600">
|
<p class="mt-4 text-m-muted">
|
||||||
Selectionne un composant dans la liste pour afficher sa page de demo.
|
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {computed, reactive, ref, watch, shallowRef} from 'vue'
|
|
||||||
|
|
||||||
type LoadedModule = {
|
|
||||||
default: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
type Item = {
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Group = {
|
|
||||||
category: string
|
|
||||||
items: Item[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const componentModules = import.meta.glob('../../app/components/malio/**/*.vue')
|
|
||||||
const demoModules = import.meta.glob('./composant/**/*.vue')
|
|
||||||
|
|
||||||
const demoByName: Record<string, () => Promise<LoadedModule>> =
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(demoModules).map(([file, loader]) => {
|
|
||||||
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
|
|
||||||
return [name.toLowerCase(), loader as () => Promise<LoadedModule>]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const groups = computed<Group[]>(() => {
|
|
||||||
const categoryMap = new Map<string, Item[]>()
|
|
||||||
|
|
||||||
Object.keys(componentModules).forEach((file) => {
|
|
||||||
const parts = file.split('/')
|
|
||||||
const name = parts.pop()?.replace('.vue', '') ?? ''
|
|
||||||
const category = parts.pop() ?? ''
|
|
||||||
|
|
||||||
if (!categoryMap.has(category)) {
|
|
||||||
categoryMap.set(category, [])
|
|
||||||
}
|
|
||||||
categoryMap.get(category)!.push({name, label: name})
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentGroups = Array.from(categoryMap.entries())
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([category, items]) => ({
|
|
||||||
category: category.charAt(0).toUpperCase() + category.slice(1),
|
|
||||||
items: items.sort((a, b) => a.label.localeCompare(b.label)),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return [
|
|
||||||
...componentGroups,
|
|
||||||
{
|
|
||||||
category: 'Form',
|
|
||||||
items: [{name: 'client', label: 'Client'}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const openCategories = reactive(new Set<string>())
|
|
||||||
const selectedName = ref('')
|
|
||||||
const hasInitializedSelection = ref(false)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
groups,
|
|
||||||
(val) => {
|
|
||||||
if (!hasInitializedSelection.value && val.length > 0) {
|
|
||||||
openCategories.add(val[0].category)
|
|
||||||
if (val[0].items.length > 0) {
|
|
||||||
selectedName.value = val[0].items[0].name
|
|
||||||
}
|
|
||||||
hasInitializedSelection.value = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{immediate: true},
|
|
||||||
)
|
|
||||||
|
|
||||||
function toggleCategory(category: string) {
|
|
||||||
if (openCategories.has(category)) {
|
|
||||||
openCategories.delete(category)
|
|
||||||
} else {
|
|
||||||
openCategories.add(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectItem(name: string) {
|
|
||||||
selectedName.value = selectedName.value === name ? '' : name
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
selectedName.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedDemoComponent = shallowRef<unknown>(null)
|
|
||||||
|
|
||||||
watch(selectedName, async (name) => {
|
|
||||||
if (!name) {
|
|
||||||
selectedDemoComponent.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const loader = demoByName[name.toLowerCase()]
|
|
||||||
if (!loader) {
|
|
||||||
selectedDemoComponent.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = await loader()
|
|
||||||
selectedDemoComponent.value = mod.default
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedDemoFileName = computed(() => {
|
|
||||||
const name = selectedName.value
|
|
||||||
if (!name) return ''
|
|
||||||
return name.charAt(0).toLowerCase() + name.slice(1)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|||||||
72
.playground/playground.nav.ts
Normal file
72
.playground/playground.nav.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
|
||||||
|
|
||||||
|
export const navSections: SidebarSection[] = [
|
||||||
|
{
|
||||||
|
label: 'BOUTONS',
|
||||||
|
icon: 'mdi:gesture-tap-button',
|
||||||
|
items: [
|
||||||
|
{label: 'Button', to: '/composant/button/button'},
|
||||||
|
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CHAMPS',
|
||||||
|
icon: 'mdi:form-textbox',
|
||||||
|
items: [
|
||||||
|
{label: 'Texte', to: '/composant/input/inputText'},
|
||||||
|
{label: 'Nombre', to: '/composant/input/inputNumber'},
|
||||||
|
{label: 'Montant', to: '/composant/input/inputAmount'},
|
||||||
|
{label: 'Email', to: '/composant/input/inputEmail'},
|
||||||
|
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
|
||||||
|
{label: 'Téléphone', to: '/composant/input/inputPhone'},
|
||||||
|
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
|
||||||
|
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
|
||||||
|
{label: 'Upload', to: '/composant/input/inputUpload'},
|
||||||
|
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DATES & HEURES',
|
||||||
|
icon: 'mdi:calendar-clock',
|
||||||
|
items: [
|
||||||
|
{label: 'Date', to: '/composant/date/date'},
|
||||||
|
{label: 'Plage de dates', to: '/composant/date/dateRange'},
|
||||||
|
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
||||||
|
{label: 'Heure', to: '/composant/time/time'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SÉLECTIONS',
|
||||||
|
icon: 'mdi:form-dropdown',
|
||||||
|
items: [
|
||||||
|
{label: 'Select', to: '/composant/select/select'},
|
||||||
|
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
||||||
|
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
||||||
|
{label: 'Radio', to: '/composant/radio/radioButton'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'NAVIGATION',
|
||||||
|
icon: 'mdi:navigation-variant',
|
||||||
|
items: [
|
||||||
|
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
||||||
|
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
||||||
|
{label: 'Onglets', to: '/composant/tab/tabList'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DONNÉES',
|
||||||
|
icon: 'mdi:table',
|
||||||
|
items: [
|
||||||
|
{label: 'DataTable', to: '/composant/datatable/datatable'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DIVERS',
|
||||||
|
icon: 'mdi:dots-horizontal',
|
||||||
|
items: [
|
||||||
|
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||||
|
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -27,6 +27,10 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-22] Création d'un composant datatable
|
* [#MUI-22] Création d'un composant datatable
|
||||||
* [#MUI-27] Création d'un composant sélection de site
|
* [#MUI-27] Création d'un composant sélection de site
|
||||||
* Création d'un composant rich text (TipTap) avec sortie markdown / HTML
|
* Création d'un composant rich text (TipTap) avec sortie markdown / HTML
|
||||||
|
* [#MUI-30] Création d'un composant email
|
||||||
|
* [#MUI-31] Création d'un composant téléphone
|
||||||
|
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
||||||
|
* [#MUI-34] Revoir le système de playground
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
187
COMPONENTS.md
187
COMPONENTS.md
@@ -66,6 +66,160 @@ Champ mot de passe avec toggle visibilité.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioInputEmail
|
||||||
|
|
||||||
|
Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outline` à droite par défaut.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `label` | `string` | `''` | Label du champ |
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis |
|
||||||
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||||
|
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
||||||
|
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||||
|
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
||||||
|
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||||
|
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioInputEmail v-model="email" label="Adresse email" />
|
||||||
|
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
|
||||||
|
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
|
||||||
|
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MalioInputPhone
|
||||||
|
|
||||||
|
Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outline` à gauche par défaut et bouton `+` optionnel à droite pour gérer une liste de numéros côté parent.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `label` | `string` | `''` | Label du champ |
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis |
|
||||||
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||||
|
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
|
||||||
|
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||||
|
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
||||||
|
| `mask` | `string \| MaskInputOptions` | `undefined` | Masque maska (aucun par défaut, utile pour mono-pays) |
|
||||||
|
| `addable` | `boolean` | `false` | Affiche un bouton à droite qui émet l'event `add` |
|
||||||
|
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||||
|
| `addButtonLabel` | `string` | `'Ajouter un numéro'` | aria-label du bouton d'ajout |
|
||||||
|
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||||
|
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `update:modelValue(value: string)`
|
||||||
|
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioInputPhone v-model="phone" label="Téléphone" />
|
||||||
|
<MalioInputPhone v-model="phone" label="Téléphone (FR)" mask="+33 # ## ## ## ##" />
|
||||||
|
<MalioInputPhone v-model="phone" label="Téléphone" addable @add="addPhoneField" />
|
||||||
|
<MalioInputPhone v-model="phone" label="Téléphone" error="Numéro invalide" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `modelValue` | `string \| number \| null` | `undefined` | Valeur sélectionnée (v-model) |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `options` | `{label: string; value: string\|number}[]` | `[]` | Liste affichée dans le dropdown |
|
||||||
|
| `loading` | `boolean` | `false` | Affiche un spinner + un message de chargement |
|
||||||
|
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
|
||||||
|
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
|
||||||
|
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
|
||||||
|
| `iconName` | `string` | `''` | Icône Iconify décorative |
|
||||||
|
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
|
||||||
|
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
|
||||||
|
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur de l'icône |
|
||||||
|
| `noResultsText` | `string` | `'Aucun résultat'` | Texte affiché quand `options` est vide |
|
||||||
|
| `loadingText` | `string` | `'Chargement…'` | Texte affiché pendant le chargement |
|
||||||
|
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis |
|
||||||
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||||
|
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `update:modelValue(value: string \| number \| null)` — valeur sélectionnée (v-model)
|
||||||
|
- `search(query: string)` — émis (après debounce + minSearchLength) avec le texte tapé ; le parent l'écoute pour lancer son fetch API
|
||||||
|
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
|
||||||
|
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
|
||||||
|
|
||||||
|
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Usage statique -->
|
||||||
|
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
|
||||||
|
|
||||||
|
<!-- Usage API (parent gère le fetch) -->
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="clientId"
|
||||||
|
label="Client"
|
||||||
|
:options="clientOptions"
|
||||||
|
:loading="isFetching"
|
||||||
|
:min-search-length="2"
|
||||||
|
@search="onSearchClients"
|
||||||
|
@select="onSelectClient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Avec création libre -->
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="category"
|
||||||
|
label="Catégorie"
|
||||||
|
:options="categories"
|
||||||
|
allow-create
|
||||||
|
@create="onCreateCategory"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function onSearchClients(query: string) {
|
||||||
|
isFetching.value = true
|
||||||
|
const res = await $fetch('/api/clients', {params: {q: query}})
|
||||||
|
clientOptions.value = res.map(c => ({label: c.name, value: c.id}))
|
||||||
|
isFetching.value = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioInputAmount
|
## MalioInputAmount
|
||||||
|
|
||||||
Champ montant avec icône devise (euro par défaut).
|
Champ montant avec icône devise (euro par défaut).
|
||||||
@@ -122,6 +276,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
|||||||
| `showCounter` | `boolean` | `false` | Afficher le compteur |
|
| `showCounter` | `boolean` | `false` | Afficher le compteur |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
@@ -207,6 +362,7 @@ Liste déroulante.
|
|||||||
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||||
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
||||||
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
|
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
|
||||||
|
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | number | null)`
|
**Events :** `update:modelValue(value: string | number | null)`
|
||||||
**Slots :** `icon` (icône dropdown custom)
|
**Slots :** `icon` (icône dropdown custom)
|
||||||
@@ -232,6 +388,7 @@ Liste déroulante multi-sélection avec checkboxes.
|
|||||||
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
|
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
|
||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: (string | number)[])`
|
**Events :** `update:modelValue(value: (string | number)[])`
|
||||||
|
|
||||||
@@ -362,18 +519,42 @@ Navigation par onglets avec contenu dynamique.
|
|||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
||||||
| `tabs` | `{ key: string, label: string, icon?: string }[]` | **requis** | Liste des onglets |
|
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
Type `Tab` :
|
||||||
|
|
||||||
|
| Propriété | Type | Défaut | Description |
|
||||||
|
|-----------|------|--------|-------------|
|
||||||
|
| `key` | `string` | — | Identifiant unique (utilisé pour le slot et le v-model) |
|
||||||
|
| `label` | `string` | — | Texte de l'onglet |
|
||||||
|
| `icon` | `string` | — | Nom Iconify (optionnel) |
|
||||||
|
| `iconSize` | `string` | `24` | Taille de l'icône |
|
||||||
|
| `disabled` | `boolean` | `false` | Onglet désactivé : grisé et non cliquable. Le parent calcule cet état selon sa logique de validation |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string)` — émis uniquement quand l'onglet cible n'est pas `disabled`
|
||||||
**Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet
|
**Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioTabList v-model="activeTab" :tabs="[{ key: 'infos', label: 'Informations' }, { key: 'docs', label: 'Documents', icon: 'mdi:file' }]">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<template #infos>Contenu infos</template>
|
<template #infos>Contenu infos</template>
|
||||||
<template #docs>Contenu docs</template>
|
<template #docs>Contenu docs</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Pattern de gating progressif** (déverrouille les onglets quand les précédents sont valides) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const informationValid = computed(() => name.value && email.value)
|
||||||
|
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ key: 'information', label: 'Information' },
|
||||||
|
{ key: 'contacts', label: 'Contacts', disabled: !informationValid.value },
|
||||||
|
{ key: 'adresses', label: 'Adresses', disabled: !informationValid.value },
|
||||||
|
{ key: 'transport', label: 'Transport', disabled: !informationValid.value || !adressesValid.value },
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioSidebar
|
## MalioSidebar
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('MalioCheckbox', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||||
expect(wrapper.get('p').text()).toBe('You must accept')
|
expect(wrapper.get('p').text()).toBe('You must accept')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ describe('MalioCheckbox', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('p').text()).toBe('Invalid')
|
expect(wrapper.get('p').text()).toBe('Invalid')
|
||||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows success styles and message when there is no error', () => {
|
it('shows success styles and message when there is no error', () => {
|
||||||
@@ -139,4 +139,26 @@ describe('MalioCheckbox', () => {
|
|||||||
expect(wrapper.get('p').text()).toBe('Valid')
|
expect(wrapper.get('p').text()).toBe('Valid')
|
||||||
expect(wrapper.get('p').classes()).toContain('text-m-success')
|
expect(wrapper.get('p').classes()).toContain('text-m-success')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses muted label color when unchecked', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Accept terms', modelValue: false})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses black label color when checked', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Accept terms', modelValue: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Accept terms'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue(true)
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
||||||
@@ -80,9 +80,11 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
|
const localChecked = ref(false)
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
|
||||||
const isChecked = computed(() => !!props.modelValue)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const isChecked = computed(() => (isControlled.value ? !!props.modelValue : localChecked.value))
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
@@ -108,9 +110,10 @@ const mergedInputClass = computed(() =>
|
|||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'cbx text-black text-lg',
|
'cbx text-lg',
|
||||||
|
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||||
hasError.value ? 'text-m-error' : '',
|
hasError.value ? 'text-m-danger' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
@@ -120,7 +123,7 @@ const mergedMessageClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-error'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
@@ -139,6 +142,10 @@ const onChange = (event: Event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localChecked.value = target.checked
|
||||||
|
}
|
||||||
|
|
||||||
emit('update:modelValue', target.checked)
|
emit('update:modelValue', target.checked)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -161,10 +168,14 @@ const onChange = (event: Event) => {
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
flex: 0 0 18px;
|
flex: 0 0 18px;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
border: 2px solid rgb(0, 0, 0);
|
border: 2px solid rgb(var(--m-muted) / 1);
|
||||||
transition: all 0.1s ease;
|
transition: all 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inp-cbx:checked + .cbx span:first-child {
|
||||||
|
border-color: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
.cbx span:first-child svg {
|
.cbx span:first-child svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -200,14 +211,14 @@ const onChange = (event: Event) => {
|
|||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inp-cbx + .cbx.text-m-error span:first-child {
|
.inp-cbx + .cbx.text-m-danger span:first-child {
|
||||||
border-color: rgb(var(--m-error) / 1);
|
border-color: rgb(var(--m-danger) / 1);
|
||||||
}
|
}
|
||||||
.cbx.text-m-error span:first-child svg {
|
.cbx.text-m-danger span:first-child svg {
|
||||||
stroke: rgb(var(--m-error) / 1);
|
stroke: rgb(var(--m-danger) / 1);
|
||||||
}
|
}
|
||||||
.inp-cbx:checked + .cbx.text-m-error span:first-child {
|
.inp-cbx:checked + .cbx.text-m-danger span:first-child {
|
||||||
border-color: rgb(var(--m-error) / 1);
|
border-color: rgb(var(--m-danger) / 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inp-cbx + .cbx.text-m-success span:first-child {
|
.inp-cbx + .cbx.text-m-success span:first-child {
|
||||||
|
|||||||
198
app/components/malio/date/Date.test.ts
Normal file
198
app/components/malio/date/Date.test.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import Date_ from './Date.vue'
|
||||||
|
|
||||||
|
type DateProps = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateForTest = Date_ as DefineComponent<DateProps>
|
||||||
|
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
|
||||||
|
|
||||||
|
describe('MalioDate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||||
|
})
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
describe('rendu', () => {
|
||||||
|
it('renders the label and the calendar icon', () => {
|
||||||
|
const wrapper = mountDate({label: 'Date de naissance'})
|
||||||
|
expect(wrapper.get('label').text()).toBe('Date de naissance')
|
||||||
|
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the formatted value in the field', () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('19/05/2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show the popover initially', () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('popover', () => {
|
||||||
|
it('opens on field click', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on the current month when there is no value', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on the value month when a value is set', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2025-12-25'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on outside mousedown', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('navigation jours', () => {
|
||||||
|
it('goes to the next month on the right chevron', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls December to January and bumps the year', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-12-15'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sélection', () => {
|
||||||
|
it('emits the ISO date and closes on day click', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bornes min/max', () => {
|
||||||
|
it('disables days outside the range', async () => {
|
||||||
|
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||||
|
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
await outside.trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('vue mois', () => {
|
||||||
|
it('switches to month view on header toggle', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates the year with chevrons in month view', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns to day view on month click', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('effacement', () => {
|
||||||
|
it('shows the clear button when there is a value', () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the clear button when empty', () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null and does not open the popover on clear', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('états', () => {
|
||||||
|
it('does not open when disabled', async () => {
|
||||||
|
const wrapper = mountDate({disabled: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open when readonly', async () => {
|
||||||
|
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('accessibilité', () => {
|
||||||
|
it('sets aria-invalid and describedby on error', () => {
|
||||||
|
const wrapper = mountDate({error: 'Date requise'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
expect(input.attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||||
|
expect(wrapper.text()).toContain('Date requise')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('synchronisation externe', () => {
|
||||||
|
it('updates the displayed value when modelValue changes', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
await wrapper.setProps({modelValue: '2026-12-25'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('25/12/2026')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
93
app/components/malio/date/Date.vue
Normal file
93
app/components/malio/date/Date.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<CalendarField
|
||||||
|
:id="id"
|
||||||
|
:display-value="displayValue"
|
||||||
|
:sync-to="modelValue ?? null"
|
||||||
|
:name="name"
|
||||||
|
:label="label"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:hint="hint"
|
||||||
|
:error="error"
|
||||||
|
:success="success"
|
||||||
|
:clearable="clearable"
|
||||||
|
:input-class="inputClass"
|
||||||
|
:label-class="labelClass"
|
||||||
|
:group-class="groupClass"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@clear="emit('update:modelValue', null)"
|
||||||
|
>
|
||||||
|
<template #default="{ currentMonth, currentYear, close }">
|
||||||
|
<MonthGrid
|
||||||
|
:month="currentMonth"
|
||||||
|
:year="currentYear"
|
||||||
|
:selected-date="modelValue ?? null"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CalendarField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, watch} from 'vue'
|
||||||
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
|
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
placeholder: 'JJ/MM/AAAA',
|
||||||
|
required: false,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
|
clearable: true,
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
||||||
|
|
||||||
|
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val && !isValidIso(val) && import.meta.dev) {
|
||||||
|
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
155
app/components/malio/date/DateRange.test.ts
Normal file
155
app/components/malio/date/DateRange.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import DateRange from './DateRange.vue'
|
||||||
|
|
||||||
|
type RangeValue = {start: string; end: string}
|
||||||
|
type DateRangeProps = {
|
||||||
|
modelValue?: RangeValue | null
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
error?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateRangeForTest = DateRange as DefineComponent<DateRangeProps>
|
||||||
|
const mountRange = (props: DateRangeProps = {}) =>
|
||||||
|
mount(DateRangeForTest, {props, attachTo: document.body})
|
||||||
|
|
||||||
|
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, isos: string[]) => {
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
for (const iso of isos) {
|
||||||
|
await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioDateRange', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||||
|
})
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
it('renders the label and calendar icon', () => {
|
||||||
|
const wrapper = mountRange({label: 'Période'})
|
||||||
|
expect(wrapper.get('label').text()).toBe('Période')
|
||||||
|
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the formatted range when modelValue is set', () => {
|
||||||
|
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('19/05/2026 - 25/05/2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty field without a value', () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on the start month when a range is set', async () => {
|
||||||
|
const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit on the first click', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await openAndClickDays(wrapper, ['2026-05-19'])
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the range and closes on the second click', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-inverts when the second click is before the first', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19'])
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a single-day range', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19'])
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restarts a new range on the third click', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the range on hover while selecting', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not preview before selecting', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks start, end and in-range roles for a committed range', async () => {
|
||||||
|
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels an in-progress selection on outside click', async () => {
|
||||||
|
const wrapper = mountRange()
|
||||||
|
await openAndClickDays(wrapper, ['2026-05-19'])
|
||||||
|
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null on clear', async () => {
|
||||||
|
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables days outside min/max', async () => {
|
||||||
|
const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||||
|
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
await outside.trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-invalid on error', () => {
|
||||||
|
const wrapper = mountRange({error: 'Période requise'})
|
||||||
|
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(wrapper.text()).toContain('Période requise')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open when disabled', async () => {
|
||||||
|
const wrapper = mountRange({disabled: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
140
app/components/malio/date/DateRange.vue
Normal file
140
app/components/malio/date/DateRange.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<CalendarField
|
||||||
|
:id="id"
|
||||||
|
:display-value="displayValue"
|
||||||
|
:sync-to="validRange?.start ?? null"
|
||||||
|
:name="name"
|
||||||
|
:label="label"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:hint="hint"
|
||||||
|
:error="error"
|
||||||
|
:success="success"
|
||||||
|
:clearable="clearable"
|
||||||
|
:input-class="inputClass"
|
||||||
|
:label-class="labelClass"
|
||||||
|
:group-class="groupClass"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@clear="onClear"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<template #default="{ currentMonth, currentYear, close }">
|
||||||
|
<MonthGrid
|
||||||
|
:month="currentMonth"
|
||||||
|
:year="currentYear"
|
||||||
|
:range-start="rangeStart"
|
||||||
|
:range-end="rangeEnd"
|
||||||
|
:preview-date="previewDate"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
@select="(iso) => onSelectDay(iso, close)"
|
||||||
|
@hover="onHover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CalendarField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref} from 'vue'
|
||||||
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
|
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||||
|
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: DateRangeValue | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
placeholder: 'JJ/MM/AAAA',
|
||||||
|
required: false,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
|
clearable: true,
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
|
||||||
|
|
||||||
|
const pendingStart = ref<string | null>(null)
|
||||||
|
const hoverDate = ref<string | null>(null)
|
||||||
|
const isSelecting = computed(() => pendingStart.value !== null)
|
||||||
|
|
||||||
|
const validRange = computed<DateRangeValue | null>(() => {
|
||||||
|
const v = props.modelValue
|
||||||
|
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const rangeStart = computed(() =>
|
||||||
|
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
|
||||||
|
)
|
||||||
|
const rangeEnd = computed(() =>
|
||||||
|
isSelecting.value ? null : (validRange.value?.end ?? null),
|
||||||
|
)
|
||||||
|
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
if (isSelecting.value || !validRange.value) return ''
|
||||||
|
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSelectDay = (iso: string, close: () => void) => {
|
||||||
|
if (pendingStart.value === null) {
|
||||||
|
pendingStart.value = iso
|
||||||
|
hoverDate.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
|
||||||
|
pendingStart.value = null
|
||||||
|
hoverDate.value = null
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onHover = (iso: string | null) => {
|
||||||
|
if (isSelecting.value) hoverDate.value = iso
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
pendingStart.value = null
|
||||||
|
hoverDate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
pendingStart.value = null
|
||||||
|
hoverDate.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
122
app/components/malio/date/DateWeek.test.ts
Normal file
122
app/components/malio/date/DateWeek.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import DateWeek from './DateWeek.vue'
|
||||||
|
|
||||||
|
type DateWeekProps = {
|
||||||
|
modelValue?: string | null
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
error?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
|
||||||
|
const mountWeek = (props: DateWeekProps = {}) =>
|
||||||
|
mount(DateWeekForTest, {props, attachTo: document.body})
|
||||||
|
|
||||||
|
describe('MalioDateWeek', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||||
|
})
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
it('renders the label and calendar icon', () => {
|
||||||
|
const wrapper = mountWeek({label: 'Semaine'})
|
||||||
|
expect(wrapper.get('label').text()).toBe('Semaine')
|
||||||
|
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the formatted week when modelValue is set', () => {
|
||||||
|
const wrapper = mountWeek({modelValue: '2026-W21'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty field without a value', () => {
|
||||||
|
const wrapper = mountWeek()
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens on the month of the selected week', async () => {
|
||||||
|
const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects the week when a day is clicked', async () => {
|
||||||
|
const wrapper = mountWeek()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects the week when the week number is clicked', async () => {
|
||||||
|
const wrapper = mountWeek()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the whole week on day hover', async () => {
|
||||||
|
const wrapper = mountWeek()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the whole week on week-number hover', async () => {
|
||||||
|
const wrapper = mountWeek()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the committed week number', async () => {
|
||||||
|
const wrapper = mountWeek({modelValue: '2026-W21'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
|
||||||
|
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits null on clear', async () => {
|
||||||
|
const wrapper = mountWeek({modelValue: '2026-W21'})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables a week fully outside min/max', async () => {
|
||||||
|
const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
|
||||||
|
expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
|
||||||
|
expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open when disabled', async () => {
|
||||||
|
const wrapper = mountWeek({disabled: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open when readonly', async () => {
|
||||||
|
const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-invalid on error', () => {
|
||||||
|
const wrapper = mountWeek({error: 'Semaine requise'})
|
||||||
|
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(wrapper.text()).toContain('Semaine requise')
|
||||||
|
})
|
||||||
|
})
|
||||||
123
app/components/malio/date/DateWeek.vue
Normal file
123
app/components/malio/date/DateWeek.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<CalendarField
|
||||||
|
:id="id"
|
||||||
|
:display-value="displayValue"
|
||||||
|
:sync-to="validWeek?.monday ?? null"
|
||||||
|
:name="name"
|
||||||
|
:label="label"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:hint="hint"
|
||||||
|
:error="error"
|
||||||
|
:success="success"
|
||||||
|
:clearable="clearable"
|
||||||
|
:input-class="inputClass"
|
||||||
|
:label-class="labelClass"
|
||||||
|
:group-class="groupClass"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@clear="onClear"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<template #default="{ currentMonth, currentYear, close }">
|
||||||
|
<MonthGrid
|
||||||
|
:month="currentMonth"
|
||||||
|
:year="currentYear"
|
||||||
|
:range-start="activeMonday"
|
||||||
|
:range-end="activeSunday"
|
||||||
|
:marked-week-start="validWeek?.monday ?? null"
|
||||||
|
interactive-week-number
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
@select="(iso) => onSelect(iso, close)"
|
||||||
|
@hover="onHover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CalendarField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref} from 'vue'
|
||||||
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
|
import {formatWeekDisplay, isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek} from './composables/dateWeek'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
placeholder: 'JJ/MM/AAAA',
|
||||||
|
required: false,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
|
clearable: true,
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
||||||
|
|
||||||
|
const hoverWeekStart = ref<string | null>(null)
|
||||||
|
|
||||||
|
const validWeek = computed(() => {
|
||||||
|
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
|
||||||
|
return {monday: isoWeekToMonday(props.modelValue) as string}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
|
||||||
|
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
|
||||||
|
|
||||||
|
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
|
||||||
|
|
||||||
|
const onSelect = (iso: string, close: () => void) => {
|
||||||
|
emit('update:modelValue', toIsoWeek(iso))
|
||||||
|
hoverWeekStart.value = null
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onHover = (iso: string | null) => {
|
||||||
|
hoverWeekStart.value = iso ? mondayOf(iso) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
hoverWeekStart.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
hoverWeekStart.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
62
app/components/malio/date/composables/dateFormat.test.ts
Normal file
62
app/components/malio/date/composables/dateFormat.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
26
app/components/malio/date/composables/dateFormat.ts
Normal file
26
app/components/malio/date/composables/dateFormat.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
57
app/components/malio/date/composables/dateRange.test.ts
Normal file
57
app/components/malio/date/composables/dateRange.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
31
app/components/malio/date/composables/dateRange.ts
Normal file
31
app/components/malio/date/composables/dateRange.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
74
app/components/malio/date/composables/dateWeek.test.ts
Normal file
74
app/components/malio/date/composables/dateWeek.test.ts
Normal file
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
67
app/components/malio/date/composables/dateWeek.ts
Normal file
67
app/components/malio/date/composables/dateWeek.ts
Normal file
@@ -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,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
28
app/components/malio/date/composables/useCalendarPopover.ts
Normal file
28
app/components/malio/date/composables/useCalendarPopover.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
51
app/components/malio/date/composables/useCalendarView.ts
Normal file
51
app/components/malio/date/composables/useCalendarView.ts
Normal file
@@ -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}
|
||||||
|
}
|
||||||
69
app/components/malio/date/composables/useMonthMatrix.test.ts
Normal file
69
app/components/malio/date/composables/useMonthMatrix.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
60
app/components/malio/date/composables/useMonthMatrix.ts
Normal file
60
app/components/malio/date/composables/useMonthMatrix.ts
Normal file
@@ -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}
|
||||||
|
}
|
||||||
239
app/components/malio/date/internal/CalendarField.vue
Normal file
239
app/components/malio/date/internal/CalendarField.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<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
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||||
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ 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>
|
||||||
70
app/components/malio/date/internal/CalendarHeader.vue
Normal file
70
app/components/malio/date/internal/CalendarHeader.vue
Normal file
@@ -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>
|
||||||
178
app/components/malio/date/internal/MonthGrid.vue
Normal file
178
app/components/malio/date/internal/MonthGrid.vue
Normal file
@@ -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>
|
||||||
36
app/components/malio/date/internal/MonthPicker.vue
Normal file
36
app/components/malio/date/internal/MonthPicker.vue
Normal file
@@ -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>
|
||||||
@@ -279,7 +279,7 @@ describe('MalioInputText', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
expect(wrapper.get('label').classes()).toContain('left-11')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes icon size props to icon component', () => {
|
it('passes icon size props to icon component', () => {
|
||||||
@@ -294,4 +294,18 @@ describe('MalioInputText', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows primary icon color on focus', async () => {
|
||||||
|
const wrapper = mountInput({iconName: 'mdi:key-outline'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black icon color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountInput({iconName: 'mdi:key-outline', modelValue: 'hello'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -158,6 +158,20 @@ describe('MalioInputAmount', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
expect(wrapper.get('label').classes()).toContain('left-11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows primary icon color on focus', async () => {
|
||||||
|
const wrapper = mountInputAmount()
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black icon color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountInputAmount({modelValue: '12,50'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,13 +39,7 @@
|
|||||||
:width="iconSize"
|
:width="iconSize"
|
||||||
:height="iconSize"
|
:height="iconSize"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[iconStateClass, iconPositionClass]"
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : iconColor,
|
|
||||||
iconPositionClass,
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +135,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
@@ -222,7 +216,7 @@ const iconInputPaddingClass = computed(() => {
|
|||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
const labelPositionClass = computed(() => {
|
const labelPositionClass = computed(() => {
|
||||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||||
return 'left-3'
|
return 'left-3'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -235,6 +229,15 @@ const iconPositionClass = computed(() => {
|
|||||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return props.iconColor
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
430
app/components/malio/input/InputAutocomplete.test.ts
Normal file
430
app/components/malio/input/InputAutocomplete.test.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import {describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
import InputAutocomplete from './InputAutocomplete.vue'
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputAutocompleteProps = {
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
modelValue?: string | number | null
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
options?: Option[]
|
||||||
|
loading?: boolean
|
||||||
|
debounce?: number
|
||||||
|
minSearchLength?: number
|
||||||
|
allowCreate?: boolean
|
||||||
|
iconName?: string
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
iconSize?: string | number
|
||||||
|
iconColor?: string
|
||||||
|
noResultsText?: string
|
||||||
|
loadingText?: string
|
||||||
|
minSearchText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{label: 'France', value: 'fr'},
|
||||||
|
{label: 'Belgique', value: 'be'},
|
||||||
|
{label: 'Canada', value: 'ca'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mountComponent = (props: InputAutocompleteProps = {}) =>
|
||||||
|
mount(InputAutocompleteForTest, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
IconifyIcon: {
|
||||||
|
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioInputAutocomplete', () => {
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Pays'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').text()).toBe('Pays')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with type combobox role', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('role')).toBe('combobox')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders input with provided modelValue label when option matches', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'fr', options})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('France')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown on focus', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.get('input').attributes('aria-expanded')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open dropdown on focus when disabled', async () => {
|
||||||
|
const wrapper = mountComponent({options, disabled: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open dropdown on focus when readonly', async () => {
|
||||||
|
const wrapper = mountComponent({options, readonly: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all options in dropdown', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="option"]')
|
||||||
|
expect(items).toHaveLength(3)
|
||||||
|
expect(items[0].text()).toBe('France')
|
||||||
|
expect(items[1].text()).toBe('Belgique')
|
||||||
|
expect(items[2].text()).toBe('Canada')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue with option value when option is selected', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.findAll('[data-test="option"]')[1].trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits select with full option object', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('select')?.[0]).toEqual([options[0]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dropdown after selecting an option', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills input with selected option label after selection', async () => {
|
||||||
|
const wrapper = mountComponent({options, modelValue: null})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.findAll('[data-test="option"]')[1].trigger('click')
|
||||||
|
|
||||||
|
await wrapper.setProps({modelValue: 'be'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('Belgique')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits search after debounce when user types', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const wrapper = mountComponent({options, debounce: 300})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('fra')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('search')).toBeUndefined()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
|
||||||
|
expect(wrapper.emitted('search')?.[0]).toEqual(['fra'])
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit search until minSearchLength is reached', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const wrapper = mountComponent({minSearchLength: 3, debounce: 300})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('fr')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
|
||||||
|
expect(wrapper.emitted('search')).toBeUndefined()
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('fra')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
|
||||||
|
expect(wrapper.emitted('search')?.[0]).toEqual(['fra'])
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows minSearch text in dropdown when minSearchLength not reached', async () => {
|
||||||
|
const wrapper = mountComponent({minSearchLength: 3, minSearchText: 'Tapez 3 caractères'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="min-search-text"]').text()).toBe('Tapez 3 caractères')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading text in dropdown when loading', async () => {
|
||||||
|
const wrapper = mountComponent({loading: true, loadingText: 'En cours…'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="loading-text"]').text()).toBe('En cours…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading icon when loading', async () => {
|
||||||
|
const wrapper = mountComponent({loading: true})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="loading-icon"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no results text when options is empty', async () => {
|
||||||
|
const wrapper = mountComponent({options: [], noResultsText: 'Rien trouvé'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="no-results-text"]').text()).toBe('Rien trouvé')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears selection when typing different value', async () => {
|
||||||
|
const wrapper = mountComponent({options, modelValue: 'fr'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('belg')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null])
|
||||||
|
expect(wrapper.emitted('select')?.[0]).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits create event with typed value when allowCreate and Enter pressed', async () => {
|
||||||
|
const wrapper = mountComponent({options, allowCreate: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('Custom')
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
|
||||||
|
|
||||||
|
expect(wrapper.emitted('create')?.[0]).toEqual(['Custom'])
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.some(e => e[0] === 'Custom')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit create when allowCreate is false', async () => {
|
||||||
|
const wrapper = mountComponent({options, allowCreate: false})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('Custom')
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
|
||||||
|
|
||||||
|
expect(wrapper.emitted('create')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects option on Enter with active index', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['fr'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates options with ArrowDown', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('aria-activedescendant')).toContain('-option-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dropdown on Escape', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'Escape'})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reverts input value on Escape', async () => {
|
||||||
|
const wrapper = mountComponent({options, modelValue: 'fr'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('xyz')
|
||||||
|
await wrapper.get('input').trigger('keydown', {key: 'Escape'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('France')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message and styles', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Champ invalide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Champ invalide')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||||
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success message and styles', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Champ valide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-success').text()).toBe('Champ valide')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows hint message', () => {
|
||||||
|
const wrapper = mountComponent({hint: 'Tapez pour rechercher'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-muted').text()).toBe('Tapez pour rechercher')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders left icon when iconName provided with left position', () => {
|
||||||
|
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'left'})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders right icon when iconName provided with right position', () => {
|
||||||
|
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'right'})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render icon when iconName is empty', () => {
|
||||||
|
const wrapper = mountComponent({iconName: ''})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses left padding when icon is left', () => {
|
||||||
|
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'left'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses extra right padding when icon is right', () => {
|
||||||
|
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'right'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').classes()).toContain('!pr-16')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the chevron with default icon', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const icons = wrapper.findAllComponents(IconifyIcon)
|
||||||
|
const chevron = icons[icons.length - 1]
|
||||||
|
expect(chevron.props('icon')).toBe('mdi:chevron-down')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rotates the chevron when dropdown is open', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('rotate-0')
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('rotate-180')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets disabled attribute', () => {
|
||||||
|
const wrapper = mountComponent({disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets readonly attribute', () => {
|
||||||
|
const wrapper = mountComponent({readonly: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links label to input via for/id', () => {
|
||||||
|
const wrapper = mountComponent({id: 'country', label: 'Pays'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('id')).toBe('country')
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe('country')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when missing and reuses it on label', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Pays'})
|
||||||
|
|
||||||
|
const inputId = wrapper.get('input').attributes('id')
|
||||||
|
|
||||||
|
expect(inputId?.startsWith('malio-input-autocomplete-')).toBe(true)
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria-invalid is false when no error', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the option matching modelValue as aria-selected', async () => {
|
||||||
|
const wrapper = mountComponent({options, modelValue: 'be'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="option"]')
|
||||||
|
expect(items[0].attributes('aria-selected')).toBe('false')
|
||||||
|
expect(items[1].attributes('aria-selected')).toBe('true')
|
||||||
|
expect(items[2].attributes('aria-selected')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates inputValue when modelValue changes externally', async () => {
|
||||||
|
const wrapper = mountComponent({options, modelValue: 'fr'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('France')
|
||||||
|
|
||||||
|
await wrapper.setProps({modelValue: 'ca'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('Canada')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears inputValue when modelValue is cleared externally', async () => {
|
||||||
|
const wrapper = mountComponent({options, modelValue: 'fr'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('France')
|
||||||
|
|
||||||
|
await wrapper.setProps({modelValue: null})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses allowCreate modelValue as inputValue when no match in options', async () => {
|
||||||
|
const wrapper = mountComponent({options, allowCreate: true, modelValue: 'Custom'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('Custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
513
app/components/malio/input/InputAutocomplete.vue
Normal file
513
app/components/malio/input/InputAutocomplete.vue
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref="root"
|
||||||
|
:class="mergedGroupClass"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
autocomplete="off"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:value="inputValue"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:aria-controls="listboxId"
|
||||||
|
:aria-activedescendant="activeOptionId"
|
||||||
|
role="combobox"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
type="text"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="onFocus"
|
||||||
|
@click="onInputClick"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="inputId"
|
||||||
|
:class="mergedLabelClass"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="iconName && iconPosition === 'left'"
|
||||||
|
:icon="iconName"
|
||||||
|
:width="iconSize"
|
||||||
|
:height="iconSize"
|
||||||
|
data-test="icon-left"
|
||||||
|
:class="[iconStateClass, 'pointer-events-none absolute left-[10px] top-1/2 -translate-y-1/2']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="iconName && iconPosition === 'right'"
|
||||||
|
:icon="iconName"
|
||||||
|
:width="iconSize"
|
||||||
|
:height="iconSize"
|
||||||
|
data-test="icon-right"
|
||||||
|
:class="[iconStateClass]"
|
||||||
|
/>
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="loading"
|
||||||
|
icon="mdi:loading"
|
||||||
|
:width="20"
|
||||||
|
:height="20"
|
||||||
|
data-test="loading-icon"
|
||||||
|
class="animate-spin text-m-primary"
|
||||||
|
/>
|
||||||
|
<IconifyIcon
|
||||||
|
v-else
|
||||||
|
icon="mdi:chevron-down"
|
||||||
|
:width="20"
|
||||||
|
:height="20"
|
||||||
|
data-test="chevron"
|
||||||
|
class="transition-transform duration-300"
|
||||||
|
:class="[
|
||||||
|
isOpen ? 'rotate-180' : 'rotate-0',
|
||||||
|
chevronColorClass,
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="isOpen"
|
||||||
|
:id="listboxId"
|
||||||
|
ref="listRef"
|
||||||
|
data-test="dropdown"
|
||||||
|
role="listbox"
|
||||||
|
:aria-labelledby="inputId"
|
||||||
|
class="absolute left-0 right-0 top-[calc(100%-4px)] z-20 max-h-60 w-full overflow-auto rounded-b-md border border-t-0 bg-white"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'border-m-danger select-scrollbar-error'
|
||||||
|
: hasSuccess
|
||||||
|
? 'border-m-success select-scrollbar-success'
|
||||||
|
: 'border-m-primary select-scrollbar-primary',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-if="loading"
|
||||||
|
class="px-3 py-2 text-m-muted"
|
||||||
|
data-test="loading-text"
|
||||||
|
>
|
||||||
|
{{ loadingText }}
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-else-if="showMinSearch"
|
||||||
|
class="px-3 py-2 text-m-muted"
|
||||||
|
data-test="min-search-text"
|
||||||
|
>
|
||||||
|
{{ minSearchText }}
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-else-if="options.length === 0"
|
||||||
|
class="px-3 py-2 text-m-muted"
|
||||||
|
data-test="no-results-text"
|
||||||
|
>
|
||||||
|
{{ noResultsText }}
|
||||||
|
</li>
|
||||||
|
<template v-else>
|
||||||
|
<li
|
||||||
|
v-for="(opt, index) in options"
|
||||||
|
:id="optionId(index)"
|
||||||
|
:key="String(opt.value)"
|
||||||
|
data-test="option"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="opt.value === modelValue"
|
||||||
|
class="cursor-pointer px-3 py-2 text-black"
|
||||||
|
:class="[
|
||||||
|
index === activeIndex ? 'bg-m-muted/10' : '',
|
||||||
|
opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
|
||||||
|
]"
|
||||||
|
@mouseenter="activeIndex = index"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="onSelect(opt)"
|
||||||
|
>
|
||||||
|
{{ opt.label || '\u00A0' }}
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||||
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||||
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
modelValue?: string | number | null
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
options?: Option[]
|
||||||
|
loading?: boolean
|
||||||
|
debounce?: number
|
||||||
|
minSearchLength?: number
|
||||||
|
allowCreate?: boolean
|
||||||
|
iconName?: string
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
iconSize?: string | number
|
||||||
|
iconColor?: string
|
||||||
|
noResultsText?: string
|
||||||
|
loadingText?: string
|
||||||
|
minSearchText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
label: '',
|
||||||
|
required: false,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
options: () => [],
|
||||||
|
loading: false,
|
||||||
|
debounce: 300,
|
||||||
|
minSearchLength: 0,
|
||||||
|
allowCreate: false,
|
||||||
|
iconName: '',
|
||||||
|
iconPosition: 'left',
|
||||||
|
iconSize: 24,
|
||||||
|
iconColor: 'text-m-muted',
|
||||||
|
noResultsText: 'Aucun résultat',
|
||||||
|
loadingText: 'Chargement…',
|
||||||
|
minSearchText: 'Tapez pour rechercher',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | number | null): void
|
||||||
|
(e: 'search' | 'create', value: string): void
|
||||||
|
(e: 'select', option: Option | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
const inputValue = ref<string>('')
|
||||||
|
const isFocused = ref(false)
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const activeIndex = ref(-1)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const inputId = computed(() => props.id?.toString() || `malio-input-autocomplete-${generatedId}`)
|
||||||
|
const listboxId = computed(() => `${inputId.value}-listbox`)
|
||||||
|
|
||||||
|
const selectedOption = computed(() =>
|
||||||
|
props.options.find(o => o.value === props.modelValue) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasSelection = computed(() =>
|
||||||
|
props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
|
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
|
||||||
|
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)
|
||||||
|
|
||||||
|
const showMinSearch = computed(() =>
|
||||||
|
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
|
||||||
|
)
|
||||||
|
|
||||||
|
const optionId = (index: number) => `${inputId.value}-option-${index}`
|
||||||
|
const activeOptionId = computed(() =>
|
||||||
|
activeIndex.value >= 0 && props.options[activeIndex.value]
|
||||||
|
? optionId(activeIndex.value)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const describedBy = computed(() =>
|
||||||
|
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.modelValue, () => props.options],
|
||||||
|
() => {
|
||||||
|
if (isFocused.value) return
|
||||||
|
if (selectedOption.value) {
|
||||||
|
inputValue.value = selectedOption.value.label
|
||||||
|
} else if (props.allowCreate && typeof props.modelValue === 'string' && props.modelValue !== '') {
|
||||||
|
inputValue.value = props.modelValue
|
||||||
|
} else if (!hasSelection.value) {
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge('relative flex h-12 w-full items-center', props.groupClass),
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconInputPaddingClass = computed(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (props.iconName && props.iconPosition === 'left') parts.push('!pl-11')
|
||||||
|
|
||||||
|
const hasCustomRight = !!props.iconName && props.iconPosition === 'right'
|
||||||
|
if (hasCustomRight) parts.push('!pr-16')
|
||||||
|
else parts.push('!pr-10')
|
||||||
|
|
||||||
|
return parts.join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusPaddingClass = computed(() => {
|
||||||
|
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||||
|
return 'focus:pl-[11px]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelPositionClass = computed(() =>
|
||||||
|
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedInputClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
|
props.disabled
|
||||||
|
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
|
||||||
|
: 'cursor-text',
|
||||||
|
hasError.value
|
||||||
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
|
: 'focus:border-m-primary',
|
||||||
|
isOpen.value ? '!rounded-b-none !border-b-0' : '',
|
||||||
|
props.inputClass,
|
||||||
|
iconInputPaddingClass.value,
|
||||||
|
focusPaddingClass.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedLabelClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
|
labelPositionClass.value,
|
||||||
|
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||||
|
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||||
|
hasError.value
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (props.disabled) return props.iconColor
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return props.iconColor
|
||||||
|
})
|
||||||
|
|
||||||
|
const chevronColorClass = 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'
|
||||||
|
})
|
||||||
|
|
||||||
|
const scheduleSearch = () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
if (showMinSearch.value) return
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
emit('search', inputValue.value)
|
||||||
|
}, props.debounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
inputValue.value = target.value
|
||||||
|
if (!isOpen.value) isOpen.value = true
|
||||||
|
activeIndex.value = -1
|
||||||
|
|
||||||
|
if (hasSelection.value && target.value !== selectedOption.value?.label) {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
emit('select', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
isFocused.value = true
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputClick = () => {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (option: Option) => {
|
||||||
|
inputValue.value = option.label
|
||||||
|
activeIndex.value = -1
|
||||||
|
emit('update:modelValue', option.value)
|
||||||
|
emit('select', option)
|
||||||
|
isOpen.value = false
|
||||||
|
isFocused.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAndCommit = () => {
|
||||||
|
if (
|
||||||
|
props.allowCreate
|
||||||
|
&& inputValue.value !== ''
|
||||||
|
&& inputValue.value !== selectedOption.value?.label
|
||||||
|
) {
|
||||||
|
emit('update:modelValue', inputValue.value)
|
||||||
|
emit('create', inputValue.value)
|
||||||
|
} else if (selectedOption.value) {
|
||||||
|
inputValue.value = selectedOption.value.label
|
||||||
|
} else if (!props.allowCreate) {
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
isOpen.value = false
|
||||||
|
isFocused.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAndRevert = () => {
|
||||||
|
if (selectedOption.value) {
|
||||||
|
inputValue.value = selectedOption.value.label
|
||||||
|
} else {
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
isOpen.value = false
|
||||||
|
isFocused.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
closeAndRevert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
|
||||||
|
onSelect(props.options[activeIndex.value])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (props.allowCreate && inputValue.value !== '') {
|
||||||
|
emit('update:modelValue', inputValue.value)
|
||||||
|
emit('create', inputValue.value)
|
||||||
|
isOpen.value = false
|
||||||
|
isFocused.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!isOpen.value) {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!root.value) return
|
||||||
|
if (!root.value.contains(event.target as Node)) {
|
||||||
|
closeAndCommit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('mousedown', onClickOutside))
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-label {
|
||||||
|
background: white;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height {
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height:focus {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grow-height {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul[role="listbox"]) {
|
||||||
|
scrollbar-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-scrollbar-primary) {
|
||||||
|
scrollbar-color: rgb(var(--m-primary)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-scrollbar-error) {
|
||||||
|
scrollbar-color: #000000 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-scrollbar-success) {
|
||||||
|
scrollbar-color: #000000 transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
228
app/components/malio/input/InputEmail.test.ts
Normal file
228
app/components/malio/input/InputEmail.test.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import InputEmail from './InputEmail.vue'
|
||||||
|
|
||||||
|
type InputEmailProps = {
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
autocomplete?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
iconName?: string
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
iconSize?: string | number
|
||||||
|
iconColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
||||||
|
|
||||||
|
const mountComponent = (props: InputEmailProps = {}) =>
|
||||||
|
mount(InputEmailForTest, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
IconifyIcon: {
|
||||||
|
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioInputEmail', () => {
|
||||||
|
it('renders the initial input value', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'user@example.com'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('user@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Adresse email'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').text()).toBe('Adresse email')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has type email', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('type')).toBe('email')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has inputmode email', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('inputmode')).toBe('email')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the default email icon', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(iconComponent.props('icon')).toBe('mdi:email-outline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows overriding the icon', () => {
|
||||||
|
const wrapper = mountComponent({iconName: 'mdi:at'})
|
||||||
|
|
||||||
|
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(iconComponent.props('icon')).toBe('mdi:at')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render icon when iconName is empty', () => {
|
||||||
|
const wrapper = mountComponent({iconName: ''})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places icon on the right by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places icon on the left when iconPosition is left', () => {
|
||||||
|
const wrapper = mountComponent({iconPosition: 'left'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue on input change', async () => {
|
||||||
|
const wrapper = mountComponent({modelValue: ''})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('new@example.com')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new@example.com'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets disabled styles when true', () => {
|
||||||
|
const wrapper = mountComponent({disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets readonly when true', () => {
|
||||||
|
const wrapper = mountComponent({readonly: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message and styles', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Email invalide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Email invalide')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||||
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error style on icon', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Error'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success message and styles', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Email valide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-success').text()).toBe('Email valide')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success style on icon', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Success'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default icon color when empty and unfocused', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows primary icon color on focus', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black icon color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'user@example.com'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps primary icon color when filled and focused', async () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'user@example.com'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps default icon color when disabled, even if filled', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'user@example.com', disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error overrides focus color on icon', async () => {
|
||||||
|
const wrapper = mountComponent({error: 'Email invalide'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows hint message', () => {
|
||||||
|
const wrapper = mountComponent({hint: 'ex: prenom.nom@malio.fr'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-muted').text()).toBe('ex: prenom.nom@malio.fr')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links label to input via for/id', () => {
|
||||||
|
const wrapper = mountComponent({id: 'email-field', label: 'Email'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('id')).toBe('email-field')
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe('email-field')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when missing and reuses it on label', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Email'})
|
||||||
|
|
||||||
|
const inputId = wrapper.get('input').attributes('id')
|
||||||
|
|
||||||
|
expect(inputId?.startsWith('malio-input-email-')).toBe(true)
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria-invalid is false when no error', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses autocomplete off by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('autocomplete')).toBe('off')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows overriding autocomplete', () => {
|
||||||
|
const wrapper = mountComponent({autocomplete: 'email'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
|
||||||
|
})
|
||||||
|
})
|
||||||
229
app/components/malio/input/InputEmail.vue
Normal file
229
app/components/malio/input/InputEmail.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
:class="mergedGroupClass"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentValue"
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
type="email"
|
||||||
|
inputmode="email"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="inputId"
|
||||||
|
:class="mergedLabelClass"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="iconName"
|
||||||
|
:icon="iconName"
|
||||||
|
:width="iconSize"
|
||||||
|
:height="iconSize"
|
||||||
|
data-test="icon"
|
||||||
|
:class="[iconStateClass, iconPositionClass]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
autocomplete?: string
|
||||||
|
modelValue?: string | null | undefined
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
iconName?: string
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
iconSize?: string | number
|
||||||
|
iconColor?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
autocomplete: 'off',
|
||||||
|
modelValue: undefined,
|
||||||
|
iconName: 'mdi:email-outline',
|
||||||
|
iconPosition: 'right',
|
||||||
|
label: '',
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
required: false,
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
iconSize: 24,
|
||||||
|
iconColor: 'text-m-muted',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
const localValue = ref('')
|
||||||
|
const isFocused = ref(false)
|
||||||
|
|
||||||
|
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
|
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
const hasSuccess = computed(() => !!props.success)
|
||||||
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'relative flex h-12 w-full items-center',
|
||||||
|
props.groupClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const mergedInputClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
|
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||||
|
hasError.value
|
||||||
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
|
: 'focus:border-m-primary',
|
||||||
|
props.inputClass,
|
||||||
|
iconInputPaddingClass.value,
|
||||||
|
focusPaddingClass.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const mergedLabelClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
|
labelPositionClass.value,
|
||||||
|
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||||
|
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||||
|
hasError.value
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const describedBy = computed(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||||
|
if (hasError.value) ids.push(`${inputId.value}-error`)
|
||||||
|
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
|
||||||
|
return ids.length ? ids.join(' ') : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = target.value
|
||||||
|
}
|
||||||
|
emit('update:modelValue', target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconInputPaddingClass = computed(() => {
|
||||||
|
if (!props.iconName) return ''
|
||||||
|
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
||||||
|
})
|
||||||
|
|
||||||
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
|
const labelPositionClass = computed(() => {
|
||||||
|
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||||
|
return 'left-3'
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusPaddingClass = computed(() => {
|
||||||
|
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||||
|
return 'focus:pl-[11px]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconPositionClass = computed(() => {
|
||||||
|
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||||
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return props.iconColor
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-label {
|
||||||
|
background: white;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height {
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height:focus {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grow-height { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -171,4 +171,18 @@ describe('MalioInputPassword', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows primary icon color on focus', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black icon color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'secret'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,10 +39,7 @@
|
|||||||
:height="24"
|
:height="24"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
iconStateClass,
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : 'text-m-muted',
|
|
||||||
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
|
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||||
]"
|
]"
|
||||||
@click="toggleVisibility"
|
@click="toggleVisibility"
|
||||||
@@ -140,7 +137,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
@@ -189,6 +186,15 @@ const onInput = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (disabled.value) return 'text-m-muted'
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return 'text-m-muted'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
308
app/components/malio/input/InputPhone.test.ts
Normal file
308
app/components/malio/input/InputPhone.test.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import InputPhone from './InputPhone.vue'
|
||||||
|
|
||||||
|
type InputPhoneProps = {
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
autocomplete?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
iconName?: string
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
iconSize?: string | number
|
||||||
|
iconColor?: string
|
||||||
|
mask?: string
|
||||||
|
addable?: boolean
|
||||||
|
addIconName?: string
|
||||||
|
addButtonLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
|
||||||
|
|
||||||
|
const mountComponent = (props: InputPhoneProps = {}) =>
|
||||||
|
mount(InputPhoneForTest, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
IconifyIcon: {
|
||||||
|
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioInputPhone', () => {
|
||||||
|
it('renders the initial input value', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: '+33 6 12 34 56 78'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('+33 6 12 34 56 78')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Téléphone'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').text()).toBe('Téléphone')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has type tel', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('type')).toBe('tel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has inputmode tel', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('inputmode')).toBe('tel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the default phone icon', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(iconComponent.props('icon')).toBe('mdi:phone-outline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows overriding the icon', () => {
|
||||||
|
const wrapper = mountComponent({iconName: 'mdi:cellphone'})
|
||||||
|
|
||||||
|
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(iconComponent.props('icon')).toBe('mdi:cellphone')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render icon when iconName is empty', () => {
|
||||||
|
const wrapper = mountComponent({iconName: ''})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places icon on the left by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places icon on the right when iconPosition is right', () => {
|
||||||
|
const wrapper = mountComponent({iconPosition: 'right'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue on input change', async () => {
|
||||||
|
const wrapper = mountComponent({modelValue: ''})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('+33612345678')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['+33612345678'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets disabled styles when true', () => {
|
||||||
|
const wrapper = mountComponent({disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets readonly when true', () => {
|
||||||
|
const wrapper = mountComponent({readonly: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message and styles', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Numéro invalide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Numéro invalide')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||||
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error style on icon', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Error'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success message and styles', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Numéro valide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-success').text()).toBe('Numéro valide')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success style on icon', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Success'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows default icon color when empty and unfocused', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows primary icon color on focus', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black icon color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: '+33612345678'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps default icon color when disabled, even if filled', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: '+33612345678', disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error overrides focus color on icon', async () => {
|
||||||
|
const wrapper = mountComponent({error: 'Numéro invalide'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows hint message', () => {
|
||||||
|
const wrapper = mountComponent({hint: 'Format international recommandé'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-muted').text()).toBe('Format international recommandé')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links label to input via for/id', () => {
|
||||||
|
const wrapper = mountComponent({id: 'phone-field', label: 'Téléphone'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('id')).toBe('phone-field')
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe('phone-field')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when missing and reuses it on label', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Téléphone'})
|
||||||
|
|
||||||
|
const inputId = wrapper.get('input').attributes('id')
|
||||||
|
|
||||||
|
expect(inputId?.startsWith('malio-input-phone-')).toBe(true)
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria-invalid is false when no error', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses autocomplete off by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('autocomplete')).toBe('off')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows overriding autocomplete', () => {
|
||||||
|
const wrapper = mountComponent({autocomplete: 'tel'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('autocomplete')).toBe('tel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render add button by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders add button when addable is true', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits add event when add button is clicked', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit add when disabled', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true, disabled: true})
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('add')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit add when readonly', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true, readonly: true})
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('add')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables add button when disabled', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables add button when readonly', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, readonly: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the default add icon (mdi:plus)', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
const icons = wrapper.findAllComponents(IconifyIcon)
|
||||||
|
const addIcon = icons[icons.length - 1]
|
||||||
|
expect(addIcon.props('icon')).toBe('mdi:plus')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows overriding the add icon', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, addIconName: 'mdi:phone-plus'})
|
||||||
|
|
||||||
|
const icons = wrapper.findAllComponents(IconifyIcon)
|
||||||
|
const addIcon = icons[icons.length - 1]
|
||||||
|
expect(addIcon.props('icon')).toBe('mdi:phone-plus')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes aria-label on add button', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un autre numéro'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un autre numéro')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds right padding to input when addable', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').classes()).toContain('!pr-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies mask via maska directive', async () => {
|
||||||
|
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('33612345678')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
274
app/components/malio/input/InputPhone.vue
Normal file
274
app/components/malio/input/InputPhone.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
:class="mergedGroupClass"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
v-maska="mask"
|
||||||
|
:name="name"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentValue"
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="inputId"
|
||||||
|
:class="mergedLabelClass"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="iconName"
|
||||||
|
:icon="iconName"
|
||||||
|
:width="iconSize"
|
||||||
|
:height="iconSize"
|
||||||
|
data-test="icon"
|
||||||
|
:class="[iconStateClass, iconPositionClass]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="addable"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || readonly"
|
||||||
|
:aria-label="addButtonLabel"
|
||||||
|
data-test="add-button"
|
||||||
|
:class="mergedAddButtonClass"
|
||||||
|
@click="onAdd"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
:icon="addIconName"
|
||||||
|
:width="24"
|
||||||
|
:height="24"
|
||||||
|
data-test="add-icon"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import type {MaskInputOptions} from 'maska'
|
||||||
|
import {vMaska} from 'maska/vue'
|
||||||
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
name?: string
|
||||||
|
autocomplete?: string
|
||||||
|
modelValue?: string | null | undefined
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
iconName?: string
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
iconSize?: string | number
|
||||||
|
iconColor?: string
|
||||||
|
mask?: string | MaskInputOptions
|
||||||
|
addable?: boolean
|
||||||
|
addIconName?: string
|
||||||
|
addButtonLabel?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
autocomplete: 'off',
|
||||||
|
modelValue: undefined,
|
||||||
|
iconName: 'mdi:phone-outline',
|
||||||
|
iconPosition: 'left',
|
||||||
|
label: '',
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
required: false,
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
iconSize: 24,
|
||||||
|
iconColor: 'text-m-muted',
|
||||||
|
mask: undefined,
|
||||||
|
addable: false,
|
||||||
|
addIconName: 'mdi:plus',
|
||||||
|
addButtonLabel: 'Ajouter un numéro',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
const localValue = ref('')
|
||||||
|
const isFocused = ref(false)
|
||||||
|
|
||||||
|
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
|
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
const hasSuccess = computed(() => !!props.success)
|
||||||
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'relative flex h-12 w-full items-center',
|
||||||
|
props.groupClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const mergedInputClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
|
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||||
|
hasError.value
|
||||||
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
|
: 'focus:border-m-primary',
|
||||||
|
props.inputClass,
|
||||||
|
iconInputPaddingClass.value,
|
||||||
|
focusPaddingClass.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const mergedLabelClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
|
labelPositionClass.value,
|
||||||
|
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||||
|
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||||
|
hasError.value
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedAddButtonClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70',
|
||||||
|
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const describedBy = computed(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||||
|
if (hasError.value) ids.push(`${inputId.value}-error`)
|
||||||
|
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
|
||||||
|
return ids.length ? ids.join(' ') : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
(event: 'add'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = target.value
|
||||||
|
}
|
||||||
|
emit('update:modelValue', target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
emit('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconInputPaddingClass = computed(() => {
|
||||||
|
const leftIcon = props.iconName && props.iconPosition === 'left'
|
||||||
|
const rightIcon = props.iconName && props.iconPosition === 'right'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (leftIcon) parts.push('!pl-11')
|
||||||
|
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||||
|
return parts.join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
|
const labelPositionClass = computed(() => {
|
||||||
|
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||||
|
return 'left-3'
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusPaddingClass = computed(() => {
|
||||||
|
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||||
|
return 'focus:pl-[11px]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconPositionClass = computed(() => {
|
||||||
|
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||||
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return props.iconColor
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-label {
|
||||||
|
background: white;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height {
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height:focus {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grow-height { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -39,13 +39,7 @@
|
|||||||
:width="iconSize"
|
:width="iconSize"
|
||||||
:height="iconSize"
|
:height="iconSize"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[iconStateClass, iconPositionClass]"
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : iconColor,
|
|
||||||
iconPositionClass,
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +140,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
@@ -202,7 +196,7 @@ const iconInputPaddingClass = computed(() => {
|
|||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
const labelPositionClass = computed(() => {
|
const labelPositionClass = computed(() => {
|
||||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||||
return 'left-3'
|
return 'left-3'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,6 +209,15 @@ const iconPositionClass = computed(() => {
|
|||||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return props.iconColor
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="mergedGroupClass">
|
||||||
class="relative w-full"
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:name="name"
|
:name="name"
|
||||||
|
|
||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
class="floating-input peer w-full border-2 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',
|
||||||
@@ -81,6 +79,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -108,6 +107,7 @@ const props = withDefaults(
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
|
groupClass?: string
|
||||||
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -133,9 +133,14 @@ const props = withDefaults(
|
|||||||
maxResizeWidth: 640,
|
maxResizeWidth: 640,
|
||||||
minResizeHeight: 40,
|
minResizeHeight: 40,
|
||||||
maxResizeHeight: 320,
|
maxResizeHeight: 320,
|
||||||
|
groupClass: '',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge('relative w-full', props.groupClass),
|
||||||
|
)
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
const localValue = ref('')
|
const localValue = ref('')
|
||||||
|
|||||||
@@ -172,4 +172,18 @@ describe('MalioInputUpload', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe('.pdf,.doc')
|
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe('.pdf,.doc')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows primary icon color on focus', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
await wrapper.get('input[type="text"]').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black icon color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'document.pdf'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,10 +43,7 @@
|
|||||||
:height="24"
|
:height="24"
|
||||||
data-test="icon"
|
data-test="icon"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
iconStateClass,
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success' : 'text-m-muted',
|
|
||||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -129,7 +126,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
|
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
|
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
|
||||||
hasError.value
|
hasError.value
|
||||||
@@ -189,6 +186,15 @@ const onFileChange = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (disabled.value) return 'text-m-muted'
|
||||||
|
if (isFocused.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return 'text-m-muted'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -153,4 +153,33 @@ describe('MalioRadioButton', () => {
|
|||||||
expect(wrapper.get('input').classes()).toContain('border-red-500')
|
expect(wrapper.get('input').classes()).toContain('border-red-500')
|
||||||
expect(wrapper.get('.radio-text').classes()).toContain('font-bold')
|
expect(wrapper.get('.radio-text').classes()).toContain('font-bold')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses muted label color and muted border when unchecked', () => {
|
||||||
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'b'})
|
||||||
|
|
||||||
|
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses black label color when checked', () => {
|
||||||
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
|
||||||
|
|
||||||
|
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has checked:border-black on input', () => {
|
||||||
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||||
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
||||||
|
|
||||||
|
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('change')
|
||||||
|
|
||||||
|
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||||
@@ -86,9 +86,13 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
|
const localValue = ref<string | number | boolean | null | undefined>(undefined)
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
||||||
const isChecked = computed(() => props.modelValue === props.value)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const isChecked = computed(() =>
|
||||||
|
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
|
||||||
|
)
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
@@ -117,14 +121,15 @@ const mergedControlClass = computed(() =>
|
|||||||
|
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-black',
|
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-m-muted checked:border-black',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'radio-text mt-px cursor-pointer text-black',
|
'radio-text mt-px cursor-pointer',
|
||||||
|
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||||
hasError.value ? 'text-m-danger' : '',
|
hasError.value ? 'text-m-danger' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||||
@@ -160,6 +165,10 @@ const onChange = (event: Event) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = props.value
|
||||||
|
}
|
||||||
|
|
||||||
emit('update:modelValue', props.value)
|
emit('update:modelValue', props.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,25 +6,26 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:id="buttonId"
|
:id="buttonId"
|
||||||
|
ref="buttonRef"
|
||||||
type="button"
|
type="button"
|
||||||
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
|
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
||||||
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
|
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
||||||
: 'border-m-danger'
|
: 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
|
? 'rounded-b-none !border !border-m-success !border-b-0'
|
||||||
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
|
: 'rounded-t-none !border !border-m-success !border-t-0'
|
||||||
: 'border-m-success'
|
: 'border-m-success'
|
||||||
: isOpen
|
: isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
|
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
||||||
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
|
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-m-muted',
|
: 'border-m-muted',
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
ref="listRef"
|
ref="listRef"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
:aria-labelledby="buttonId"
|
:aria-labelledby="buttonId"
|
||||||
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
|
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
|
||||||
:class="[
|
:class="[
|
||||||
openDirection === 'down'
|
openDirection === 'down'
|
||||||
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||||
@@ -115,8 +116,16 @@
|
|||||||
: 'border-m-primary'
|
: 'border-m-primary'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
<li
|
||||||
|
v-if="normalizedOptions.length === 0"
|
||||||
|
class="px-3 py-2 text-m-muted"
|
||||||
|
data-test="no-options-text"
|
||||||
|
>
|
||||||
|
{{ noOptionsText }}
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
v-for="(opt, index) in normalizedOptions"
|
v-for="(opt, index) in normalizedOptions"
|
||||||
|
v-else
|
||||||
:id="optionId(index)"
|
:id="optionId(index)"
|
||||||
:key="String(opt.value)"
|
:key="String(opt.value)"
|
||||||
role="option"
|
role="option"
|
||||||
@@ -177,6 +186,7 @@ const props = withDefaults(defineProps<{
|
|||||||
rounded?: string
|
rounded?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
noOptionsText?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
@@ -190,12 +200,14 @@ const props = withDefaults(defineProps<{
|
|||||||
rounded: 'rounded-md',
|
rounded: 'rounded-md',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
noOptionsText: 'Aucune option disponible',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: string | number | null): void
|
(e: 'update:modelValue', v: string | number | null): void
|
||||||
}>()
|
}>()
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const activeIndex = ref(-1)
|
const activeIndex = ref(-1)
|
||||||
const openDirection = ref<'down' | 'up'>('down')
|
const openDirection = ref<'down' | 'up'>('down')
|
||||||
@@ -299,6 +311,7 @@ function toggle() {
|
|||||||
function select(value: string | number | null) {
|
function select(value: string | number | null) {
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
close()
|
close()
|
||||||
|
buttonRef.value?.blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
@@ -316,6 +329,21 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grow-height {
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height:focus {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grow-height {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:deep(ul[role="listbox"]) {
|
:deep(ul[role="listbox"]) {
|
||||||
scrollbar-width: auto;
|
scrollbar-width: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,26 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:id="buttonId"
|
:id="buttonId"
|
||||||
|
ref="buttonRef"
|
||||||
type="button"
|
type="button"
|
||||||
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
|
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
||||||
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
|
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
||||||
: 'border-m-danger'
|
: 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
|
? 'rounded-b-none !border !border-m-success !border-b-0'
|
||||||
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
|
: 'rounded-t-none !border !border-m-success !border-t-0'
|
||||||
: 'border-m-success'
|
: 'border-m-success'
|
||||||
: isOpen
|
: isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
|
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
||||||
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
|
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-m-muted',
|
: 'border-m-muted',
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
ref="listRef"
|
ref="listRef"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
:aria-labelledby="buttonId"
|
:aria-labelledby="buttonId"
|
||||||
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
|
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
|
||||||
:class="[
|
:class="[
|
||||||
openDirection === 'down'
|
openDirection === 'down'
|
||||||
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||||
@@ -144,7 +145,14 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-if="displaySelectAll"
|
v-if="normalizedOptions.length === 0"
|
||||||
|
class="px-3 py-2 text-m-muted"
|
||||||
|
data-test="no-options-text"
|
||||||
|
>
|
||||||
|
{{ noOptionsText }}
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="displaySelectAll && normalizedOptions.length > 0"
|
||||||
class="border-b border-m-muted/30 px-3 py-2"
|
class="border-b border-m-muted/30 px-3 py-2"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
>
|
>
|
||||||
@@ -231,6 +239,7 @@ const props = withDefaults(defineProps<{
|
|||||||
selectAllLabel?: string
|
selectAllLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
noOptionsText?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
@@ -247,12 +256,14 @@ const props = withDefaults(defineProps<{
|
|||||||
selectAllLabel: 'Tout sélectionner',
|
selectAllLabel: 'Tout sélectionner',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
noOptionsText: 'Aucune option disponible',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: Array<string | number>): void
|
(e: 'update:modelValue', v: Array<string | number>): void
|
||||||
}>()
|
}>()
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const activeIndex = ref(-1)
|
const activeIndex = ref(-1)
|
||||||
const openDirection = ref<'down' | 'up'>('down')
|
const openDirection = ref<'down' | 'up'>('down')
|
||||||
@@ -367,10 +378,11 @@ function isChecked(value: string | number) {
|
|||||||
function toggleOption(value: string | number) {
|
function toggleOption(value: string | number) {
|
||||||
if (isChecked(value)) {
|
if (isChecked(value)) {
|
||||||
emit('update:modelValue', props.modelValue.filter(item => item !== value))
|
emit('update:modelValue', props.modelValue.filter(item => item !== value))
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
emit('update:modelValue', [...props.modelValue, value])
|
emit('update:modelValue', [...props.modelValue, value])
|
||||||
}
|
}
|
||||||
|
nextTick(() => buttonRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
if (allSelected.value) {
|
if (allSelected.value) {
|
||||||
@@ -378,6 +390,7 @@ function toggleAll() {
|
|||||||
} else {
|
} else {
|
||||||
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
|
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
|
||||||
}
|
}
|
||||||
|
nextTick(() => buttonRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
@@ -395,6 +408,21 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grow-height {
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height:focus {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grow-height {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:deep(ul[role="listbox"]) {
|
:deep(ul[role="listbox"]) {
|
||||||
scrollbar-width: auto;
|
scrollbar-width: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Tab = {
|
|||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabListProps = {
|
type TabListProps = {
|
||||||
@@ -134,4 +135,53 @@ describe('MalioTabList', () => {
|
|||||||
expect(icons[0].props('icon')).toBe('mdi:home')
|
expect(icons[0].props('icon')).toBe('mdi:home')
|
||||||
expect(icons[1].props('icon')).toBe('mdi:account')
|
expect(icons[1].props('icon')).toBe('mdi:account')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sets disabled attribute and aria-disabled on disabled tabs', () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons[1].attributes('disabled')).toBeDefined()
|
||||||
|
expect(buttons[1].attributes('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies cursor-not-allowed on disabled tabs', () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons[1].classes()).toContain('cursor-not-allowed')
|
||||||
|
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit update:modelValue when clicking a disabled tab', async () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs, modelValue: 'a'})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
|
||||||
|
await buttons[1].trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change active tab in uncontrolled mode when clicking disabled tab', async () => {
|
||||||
|
const disabledTabs: Tab[] = [
|
||||||
|
{key: 'a', label: 'A'},
|
||||||
|
{key: 'b', label: 'B', disabled: true},
|
||||||
|
]
|
||||||
|
const wrapper = mountComponent({tabs: disabledTabs})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
|
||||||
|
await buttons[1].trigger('click')
|
||||||
|
|
||||||
|
expect(buttons[0].attributes('aria-selected')).toBe('true')
|
||||||
|
expect(buttons[1].attributes('aria-selected')).toBe('false')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,19 +12,23 @@
|
|||||||
type="button"
|
type="button"
|
||||||
:aria-selected="activeTab === tab.key"
|
:aria-selected="activeTab === tab.key"
|
||||||
:aria-controls="`${componentId}-panel-${tab.key}`"
|
:aria-controls="`${componentId}-panel-${tab.key}`"
|
||||||
|
:aria-disabled="!!tab.disabled"
|
||||||
:tabindex="activeTab === tab.key ? 0 : -1"
|
:tabindex="activeTab === tab.key ? 0 : -1"
|
||||||
|
:disabled="tab.disabled"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-[18px] text-[24px] font-medium transition-colors cursor-pointer',
|
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'border-b-2 border-m-primary text-m-primary font-bold outline-b'
|
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
|
||||||
: 'border-transparent text-m-primary/50 hover:text-m-primary/70',
|
: tab.disabled
|
||||||
|
? 'cursor-not-allowed text-m-primary/50'
|
||||||
|
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
|
||||||
]"
|
]"
|
||||||
@click="selectTab(tab.key)"
|
@click="selectTab(tab.key)"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="tab.icon"
|
v-if="tab.icon"
|
||||||
:icon="tab.icon"
|
:icon="tab.icon"
|
||||||
:width="20"
|
:width="tab.iconSize ?? 24"
|
||||||
/>
|
/>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
@@ -53,6 +57,8 @@ type Tab = {
|
|||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
iconSize?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -79,6 +85,8 @@ const activeTab = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function selectTab(key: string) {
|
function selectTab(key: string) {
|
||||||
|
const tab = props.tabs.find(t => t.key === key)
|
||||||
|
if (tab?.disabled) return
|
||||||
if (!isControlled.value) {
|
if (!isControlled.value) {
|
||||||
localValue.value = key
|
localValue.value = key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,11 +197,11 @@ const mergedInputClass = (field: 'hours' | 'minutes') =>
|
|||||||
'h-[30px] w-10 border bg-white text-center text-[18px] outline-none rounded-md placeholder:text-m-muted',
|
'h-[30px] w-10 border bg-white text-center text-[18px] outline-none rounded-md placeholder:text-m-muted',
|
||||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'focus:border-2 border-m-danger focus:border-m-danger'
|
? 'border-m-danger focus:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'focus:border-2 border-m-success focus:border-m-success'
|
? 'border-m-success focus:border-m-success'
|
||||||
: activeField.value === field
|
: activeField.value === field
|
||||||
? 'border-2 border-m-primary text-m-primary'
|
? 'border-m-primary text-m-primary'
|
||||||
: 'border-black text-black',
|
: 'border-black text-black',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
)
|
)
|
||||||
|
|||||||
94
app/story/date/datePicker.story.vue
Normal file
94
app/story/date/datePicker.story.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Date/Date">
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Date de naissance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Date du jour"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="boundedValue"
|
||||||
|
label="Date du rendez-vous"
|
||||||
|
:min="todayIso"
|
||||||
|
:max="maxIso"
|
||||||
|
hint="Entre aujourd'hui et +30 jours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Date verrouillée"
|
||||||
|
:clearable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Désactivé"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Lecture seule"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Date limite"
|
||||||
|
error="Date invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Date confirmée"
|
||||||
|
success="Enregistrée"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioDate from '../../components/malio/date/Date.vue'
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const todayIso = toIso(now)
|
||||||
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||||
|
|
||||||
|
const simpleValue = ref<string | null>(null)
|
||||||
|
const initialValue = ref<string | null>(todayIso)
|
||||||
|
const boundedValue = ref<string | null>(null)
|
||||||
|
const errorValue = ref<string | null>(null)
|
||||||
|
</script>
|
||||||
77
app/story/date/dateRange.story.vue
Normal file
77
app/story/date/dateRange.story.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Date/DateRange">
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioDateRange
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Période"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||||
|
<MalioDateRange
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Séjour"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
||||||
|
<MalioDateRange
|
||||||
|
v-model="boundedValue"
|
||||||
|
label="Plage bornée"
|
||||||
|
:min="todayIso"
|
||||||
|
:max="maxIso"
|
||||||
|
hint="Entre aujourd'hui et +30 jours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||||
|
<MalioDateRange
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Période verrouillée"
|
||||||
|
:clearable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioDateRange
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Désactivé"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioDateRange
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Période"
|
||||||
|
error="Période invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioDateRange from '../../components/malio/date/DateRange.vue'
|
||||||
|
|
||||||
|
type RangeValue = {start: string; end: string}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
const now = new Date()
|
||||||
|
const todayIso = toIso(now)
|
||||||
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||||
|
|
||||||
|
const simpleValue = ref<RangeValue | null>(null)
|
||||||
|
const initialValue = ref<RangeValue | null>({start: todayIso, end: maxIso})
|
||||||
|
const boundedValue = ref<RangeValue | null>(null)
|
||||||
|
const errorValue = ref<RangeValue | null>(null)
|
||||||
|
</script>
|
||||||
75
app/story/date/dateWeek.story.vue
Normal file
75
app/story/date/dateWeek.story.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Date/DateWeek">
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioDateWeek
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Semaine"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||||
|
<MalioDateWeek
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Semaine de livraison"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
||||||
|
<MalioDateWeek
|
||||||
|
v-model="boundedValue"
|
||||||
|
label="Semaine bornée"
|
||||||
|
:min="todayIso"
|
||||||
|
:max="maxIso"
|
||||||
|
hint="Entre aujourd'hui et +60 jours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||||
|
<MalioDateWeek
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Semaine verrouillée"
|
||||||
|
:clearable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioDateWeek
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Désactivé"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioDateWeek
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Semaine"
|
||||||
|
error="Semaine invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioDateWeek from '../../components/malio/date/DateWeek.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref<string | null>(null)
|
||||||
|
const initialValue = ref<string | null>('2026-W21')
|
||||||
|
const boundedValue = ref<string | null>(null)
|
||||||
|
const errorValue = ref<string | null>(null)
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
const now = new Date()
|
||||||
|
const todayIso = toIso(now)
|
||||||
|
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
|
||||||
|
</script>
|
||||||
294
app/story/input/inputAutocomplete.story.vue
Normal file
294
app/story/input/inputAutocomplete.story.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Input/Autocomplete">
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple (statique)</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="leftIconValue"
|
||||||
|
label="Recherche"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
icon-position="left"
|
||||||
|
:options="staticOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Branché sur une API (simulé)</h2>
|
||||||
|
<p class="mb-3 text-sm text-m-muted">
|
||||||
|
Tapez au moins 2 caractères. Le parent écoute <code>@search</code> et alimente <code>options</code> + <code>loading</code>.
|
||||||
|
</p>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="apiValue"
|
||||||
|
label="Client"
|
||||||
|
:options="apiOptions"
|
||||||
|
:loading="apiLoading"
|
||||||
|
:min-search-length="2"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
icon-position="left"
|
||||||
|
@search="onSearchApi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Création libre (allowCreate)</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="createValue"
|
||||||
|
label="Catégorie"
|
||||||
|
:options="staticOptions"
|
||||||
|
allow-create
|
||||||
|
hint="Taper Entrée pour créer une nouvelle valeur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="disabledValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="readonlyValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
hint="Sélectionne un pays dans la liste"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
error="Sélection invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="successValue"
|
||||||
|
label="Pays"
|
||||||
|
:options="staticOptions"
|
||||||
|
success="Sélection valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# 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). Combine le pattern floating-label des autres inputs avec un dropdown inspiré de `MalioSelect`. Conçu pour les cas ERP où la liste vient d'un appel API (auth, transformation, cache gérés par le parent).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
### id
|
||||||
|
- Type: string
|
||||||
|
- Description: Identifiant HTML de l'input. Auto-généré si non fourni (préfixe `malio-input-autocomplete-`).
|
||||||
|
|
||||||
|
### label
|
||||||
|
- Type: string
|
||||||
|
- Description: Texte affiché comme label flottant.
|
||||||
|
|
||||||
|
### name
|
||||||
|
- Type: string
|
||||||
|
- Description: Attribut name de l'input (formulaires).
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
- Type: `string | number | null | undefined`
|
||||||
|
- Description: Valeur sélectionnée. Doit correspondre à un `option.value` (ou être un texte libre si `allowCreate`).
|
||||||
|
|
||||||
|
### options
|
||||||
|
- Type: `{ label: string; value: string | number }[]`
|
||||||
|
- Défaut: `[]`
|
||||||
|
- Description: Liste affichée dans le dropdown. Le parent alimente cette liste (statique ou via API en réponse à l'event `search`).
|
||||||
|
|
||||||
|
### loading
|
||||||
|
- Type: boolean
|
||||||
|
- Défaut: `false`
|
||||||
|
- Description: Affiche un spinner à la place du chevron et un message dans le dropdown.
|
||||||
|
|
||||||
|
### debounce
|
||||||
|
- Type: number
|
||||||
|
- Défaut: `300`
|
||||||
|
- Description: Délai (ms) avant émission de l'event `search` après une frappe. Évite de spammer l'API.
|
||||||
|
|
||||||
|
### minSearchLength
|
||||||
|
- Type: number
|
||||||
|
- Défaut: `0`
|
||||||
|
- Description: Nombre minimum de caractères avant d'émettre `search`. Pratique pour API : ne pas appeler avec query vide.
|
||||||
|
|
||||||
|
### allowCreate
|
||||||
|
- Type: boolean
|
||||||
|
- Défaut: `false`
|
||||||
|
- Description: Si vrai, l'utilisateur peut valider (Entrée ou clic ailleurs) une valeur libre non présente dans `options` ; émet l'event `create`.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Icône
|
||||||
|
|
||||||
|
### iconName
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `''` (aucune)
|
||||||
|
- Description: Nom Iconify de l'icône décorative.
|
||||||
|
|
||||||
|
### iconPosition
|
||||||
|
- Type: `'left' | 'right'`
|
||||||
|
- Défaut: `left`
|
||||||
|
- Description: Côté d'affichage de l'icône. À droite, l'icône s'aligne avec le chevron.
|
||||||
|
|
||||||
|
### iconSize / iconColor
|
||||||
|
- Type: number / string
|
||||||
|
- Défaut: `24` / `text-m-muted`
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Textes du dropdown
|
||||||
|
|
||||||
|
### noResultsText
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `Aucun résultat`
|
||||||
|
- Description: Affiché quand `options` est vide.
|
||||||
|
|
||||||
|
### loadingText
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `Chargement…`
|
||||||
|
- Description: Affiché pendant que `loading=true`.
|
||||||
|
|
||||||
|
### minSearchText
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `Tapez pour rechercher`
|
||||||
|
- Description: Affiché quand l'utilisateur n'a pas atteint `minSearchLength`.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Apparence & Style
|
||||||
|
|
||||||
|
### inputClass / labelClass / groupClass
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées respectivement à l'input, au label et au conteneur (fusionnées via `twMerge`).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Validation & Contraintes
|
||||||
|
|
||||||
|
### required / disabled / readonly
|
||||||
|
- Type: boolean
|
||||||
|
- Description: Attributs HTML standards. `disabled` et `readonly` empêchent l'ouverture du dropdown.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## États & Messages
|
||||||
|
|
||||||
|
### hint / error / success
|
||||||
|
- Type: string
|
||||||
|
- Description: Messages affichés sous le champ. `error` est prioritaire et active `aria-invalid`.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Clavier
|
||||||
|
|
||||||
|
- `↓` / `↑` : naviguer dans les options
|
||||||
|
- `Entrée` : sélectionner l'option active (ou créer si `allowCreate`)
|
||||||
|
- `Échap` : fermer le dropdown et revenir à la dernière sélection
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `role="combobox"` sur l'input avec `aria-expanded`, `aria-controls`, `aria-activedescendant`.
|
||||||
|
- `role="listbox"` sur le dropdown, `role="option"` sur chaque entrée, `aria-selected` reflète `modelValue`.
|
||||||
|
- `aria-invalid` activé si `error` existe.
|
||||||
|
- `aria-describedby` référence dynamiquement le message affiché.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
- Émis quand l'utilisateur sélectionne une option. Permet l'utilisation avec v-model.
|
||||||
|
|
||||||
|
### search
|
||||||
|
- Émis (après debounce + minSearchLength) avec la query texte tapée. C'est ce que le parent écoute pour lancer l'appel API.
|
||||||
|
|
||||||
|
### select
|
||||||
|
- Émis avec l'objet `Option` complet (ou `null` à la réinitialisation). Utile pour récupérer le `label` côté parent en plus du `value`.
|
||||||
|
|
||||||
|
### create
|
||||||
|
- Émis avec la chaîne saisie quand `allowCreate` est vrai et que l'utilisateur valide une valeur libre.
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioInputAutocomplete from '../../components/malio/input/InputAutocomplete.vue'
|
||||||
|
|
||||||
|
type Option = {label: string; value: string | number}
|
||||||
|
|
||||||
|
const staticOptions: Option[] = [
|
||||||
|
{label: 'France', value: 'fr'},
|
||||||
|
{label: 'Belgique', value: 'be'},
|
||||||
|
{label: 'Canada', value: 'ca'},
|
||||||
|
{label: 'Suisse', value: 'ch'},
|
||||||
|
{label: 'Luxembourg', value: 'lu'},
|
||||||
|
{label: 'Allemagne', value: 'de'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const simpleValue = ref<string | number | null>('fr')
|
||||||
|
const leftIconValue = ref<string | number | null>(null)
|
||||||
|
const createValue = ref<string | number | null>(null)
|
||||||
|
const disabledValue = ref<string | number | null>('fr')
|
||||||
|
const readonlyValue = ref<string | number | null>('be')
|
||||||
|
const hintValue = ref<string | number | null>(null)
|
||||||
|
const errorValue = ref<string | number | null>('fr')
|
||||||
|
const successValue = ref<string | number | null>('fr')
|
||||||
|
|
||||||
|
const apiValue = ref<string | number | null>(null)
|
||||||
|
const apiOptions = ref<Option[]>([])
|
||||||
|
const apiLoading = ref(false)
|
||||||
|
|
||||||
|
const fakeClients: Option[] = [
|
||||||
|
{label: 'Yuno Malio', value: 1},
|
||||||
|
{label: 'Yuna Corp', value: 2},
|
||||||
|
{label: 'Yum Foods', value: 3},
|
||||||
|
{label: 'Acme Inc.', value: 4},
|
||||||
|
{label: 'Globex Corp', value: 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
const onSearchApi = async (query: string) => {
|
||||||
|
apiLoading.value = true
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 400))
|
||||||
|
apiOptions.value = fakeClients.filter(c =>
|
||||||
|
c.label.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
)
|
||||||
|
apiLoading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
261
app/story/input/inputEmail.story.vue
Normal file
261
app/story/input/inputEmail.story.vue
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Input/Email">
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Adresse email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="leftIconValue"
|
||||||
|
label="Adresse email"
|
||||||
|
icon-position="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="noIconValue"
|
||||||
|
label="Adresse email"
|
||||||
|
:icon-name="''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Adresse email"
|
||||||
|
hint="ex: prenom.nom@malio.fr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="disabledValue"
|
||||||
|
label="Adresse email"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="readonlyValue"
|
||||||
|
label="Adresse email"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Adresse email"
|
||||||
|
error="Adresse email invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="successValue"
|
||||||
|
label="Adresse email"
|
||||||
|
success="Adresse email valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioInputEmail
|
||||||
|
|
||||||
|
Champ email avec label flottant, icône email par défaut, états visuels
|
||||||
|
(erreur / succès) et accessibilité. Basé sur InputText mais ciblé sur la
|
||||||
|
saisie d'une adresse email (`type="email"` + `inputmode="email"`).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
### id
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Identifiant HTML de l'input.
|
||||||
|
- Comportement: Si non fourni, un id unique est généré automatiquement
|
||||||
|
(préfixe `malio-input-email-`).
|
||||||
|
|
||||||
|
### label
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Texte affiché comme label flottant.
|
||||||
|
- Comportement: Si absent, aucun label n'est rendu.
|
||||||
|
|
||||||
|
### name
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Attribut name de l'input (utile pour les formulaires).
|
||||||
|
|
||||||
|
### autocomplete
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `off`
|
||||||
|
- Description: Active ou configure l'autocomplétion navigateur. La
|
||||||
|
valeur par défaut est `off` pour les formulaires de création d'ERP.
|
||||||
|
Passer `email` pour permettre au navigateur de suggérer l'adresse
|
||||||
|
de l'utilisateur (formulaires de connexion / inscription).
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
|
||||||
|
- Type: string | null | undefined
|
||||||
|
- Description: Valeur contrôlée du composant.
|
||||||
|
- Comportement:
|
||||||
|
- Si défini → composant contrôlé (v-model).
|
||||||
|
- Sinon → gestion interne de l'état.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Apparence & Style
|
||||||
|
|
||||||
|
### inputClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées à l'input.
|
||||||
|
|
||||||
|
### labelClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées au label.
|
||||||
|
|
||||||
|
### groupClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées au conteneur.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Validation & Contraintes
|
||||||
|
|
||||||
|
### required
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Description: Ajoute l'attribut HTML required.
|
||||||
|
|
||||||
|
### disabled
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Description: Désactive complètement le champ.
|
||||||
|
|
||||||
|
### readonly
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Description: Rend le champ non modifiable mais focusable.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## États & Messages
|
||||||
|
|
||||||
|
### hint
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Message d'aide affiché sous le champ.
|
||||||
|
|
||||||
|
### error
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Message d'erreur.
|
||||||
|
- Effet:
|
||||||
|
- Active l'état visuel erreur.
|
||||||
|
- aria-invalid=true
|
||||||
|
- Prioritaire sur success et hint.
|
||||||
|
|
||||||
|
### success
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Message de succès.
|
||||||
|
- Effet:
|
||||||
|
- Actif uniquement si error est absent.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Icône
|
||||||
|
|
||||||
|
### iconName
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `mdi:email-outline`
|
||||||
|
- Description: Nom Iconify de l'icône affichée. Passer une chaîne
|
||||||
|
vide pour ne pas afficher d'icône.
|
||||||
|
|
||||||
|
### iconPosition
|
||||||
|
|
||||||
|
- Type: `'left' | 'right'`
|
||||||
|
- Défaut: `right`
|
||||||
|
|
||||||
|
### iconSize
|
||||||
|
|
||||||
|
- Type: string | number
|
||||||
|
- Défaut: `24`
|
||||||
|
|
||||||
|
### iconColor
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `text-m-muted`
|
||||||
|
- Description: Classe Tailwind de couleur. Surchargée automatiquement
|
||||||
|
par les états erreur / succès.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
|
||||||
|
- Aucune validation interne — le composant ne vérifie pas le format
|
||||||
|
de l'email. Utiliser la validation HTML native (`type="email"`) ou
|
||||||
|
piloter `error` / `success` depuis le parent.
|
||||||
|
- `inputmode="email"` est appliqué pour adapter le clavier mobile.
|
||||||
|
|
||||||
|
## Priorité visuelle
|
||||||
|
|
||||||
|
1. error
|
||||||
|
2. success
|
||||||
|
3. neutre
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- aria-invalid est activé si error existe.
|
||||||
|
- aria-describedby référence dynamiquement le message affiché.
|
||||||
|
- Fonctionne avec ou sans v-model.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
|
||||||
|
- Émis à chaque modification de l'input.
|
||||||
|
- Permet l'utilisation avec v-model.
|
||||||
|
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const leftIconValue = ref('')
|
||||||
|
const noIconValue = ref('')
|
||||||
|
const hintValue = ref('')
|
||||||
|
const disabledValue = ref('contact@malio.fr')
|
||||||
|
const readonlyValue = ref('readonly@malio.fr')
|
||||||
|
const errorValue = ref('pas-un-email')
|
||||||
|
const successValue = ref('contact@malio.fr')
|
||||||
|
</script>
|
||||||
285
app/story/input/inputPhone.story.vue
Normal file
285
app/story/input/inputPhone.story.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Input/Phone">
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Téléphone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="addableValue"
|
||||||
|
label="Téléphone"
|
||||||
|
addable
|
||||||
|
@add="onAdd"
|
||||||
|
/>
|
||||||
|
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||||
|
Bouton cliqué {{ addClicks }} fois
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Icône à droite</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="rightIconValue"
|
||||||
|
label="Téléphone"
|
||||||
|
icon-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="noIconValue"
|
||||||
|
label="Téléphone"
|
||||||
|
:icon-name="''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec masque français</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="maskedValue"
|
||||||
|
label="Téléphone (FR)"
|
||||||
|
mask="+33 # ## ## ## ##"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Téléphone"
|
||||||
|
hint="Format international recommandé"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé (avec addable)</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="disabledValue"
|
||||||
|
label="Téléphone"
|
||||||
|
addable
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="readonlyValue"
|
||||||
|
label="Téléphone"
|
||||||
|
addable
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Téléphone"
|
||||||
|
error="Numéro de téléphone invalide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="successValue"
|
||||||
|
label="Téléphone"
|
||||||
|
success="Numéro valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioInputPhone
|
||||||
|
|
||||||
|
Champ téléphone avec label flottant, icône phone par défaut (à gauche),
|
||||||
|
états visuels (erreur / succès), accessibilité et bouton « + » optionnel
|
||||||
|
pour gérer une liste de numéros côté parent (`type="tel"` +
|
||||||
|
`inputmode="tel"`).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
### id
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Identifiant HTML de l'input.
|
||||||
|
- Comportement: Si non fourni, un id unique est généré automatiquement
|
||||||
|
(préfixe `malio-input-phone-`).
|
||||||
|
|
||||||
|
### label
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Texte affiché comme label flottant.
|
||||||
|
|
||||||
|
### name
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Attribut name de l'input (utile pour les formulaires).
|
||||||
|
|
||||||
|
### autocomplete
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `off`
|
||||||
|
- Description: Active ou configure l'autocomplétion navigateur. La
|
||||||
|
valeur par défaut est `off` pour les formulaires de création d'ERP.
|
||||||
|
Passer `tel` pour permettre au navigateur de suggérer un numéro
|
||||||
|
enregistré.
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
|
||||||
|
- Type: string | null | undefined
|
||||||
|
- Description: Valeur contrôlée du composant.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Apparence & Style
|
||||||
|
|
||||||
|
### inputClass / labelClass / groupClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées respectivement à l'input, au
|
||||||
|
label et au conteneur.
|
||||||
|
|
||||||
|
### mask
|
||||||
|
|
||||||
|
- Type: string | MaskInputOptions
|
||||||
|
- Description: Masque maska à appliquer. Aucun masque par défaut —
|
||||||
|
les formats téléphoniques varient trop selon les pays. À activer
|
||||||
|
pour un usage mono-pays.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Validation & Contraintes
|
||||||
|
|
||||||
|
### required / disabled / readonly
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Description: Attributs HTML standards.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## États & Messages
|
||||||
|
|
||||||
|
### hint / error / success
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Messages affichés sous le champ.
|
||||||
|
- `error` est prioritaire sur `success` et active `aria-invalid`.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Icône
|
||||||
|
|
||||||
|
### iconName
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `mdi:phone-outline`
|
||||||
|
- Description: Nom Iconify de l'icône affichée. Passer une chaîne
|
||||||
|
vide pour ne pas afficher d'icône.
|
||||||
|
|
||||||
|
### iconPosition
|
||||||
|
|
||||||
|
- Type: `'left' | 'right'`
|
||||||
|
- Défaut: `left` (laisse la droite libre pour le bouton +).
|
||||||
|
|
||||||
|
### iconSize / iconColor
|
||||||
|
|
||||||
|
- Type: number / string
|
||||||
|
- Défaut: `24` / `text-m-muted`
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Bouton « ajouter »
|
||||||
|
|
||||||
|
### addable
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Défaut: `false`
|
||||||
|
- Description: Affiche un bouton à droite du champ. Au clic, le
|
||||||
|
composant émet l'event `add` — c'est au parent de gérer l'ajout
|
||||||
|
d'un nouveau champ téléphone.
|
||||||
|
|
||||||
|
### addIconName
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `mdi:plus`
|
||||||
|
- Description: Nom Iconify de l'icône du bouton d'ajout.
|
||||||
|
|
||||||
|
### addButtonLabel
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Défaut: `Ajouter un numéro`
|
||||||
|
- Description: Attribut `aria-label` du bouton (accessibilité).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
|
||||||
|
- Aucune validation interne — le composant ne vérifie pas le format
|
||||||
|
du numéro. Piloter `error` / `success` depuis le parent.
|
||||||
|
- `inputmode="tel"` adapte le clavier mobile.
|
||||||
|
- Le bouton `+` est désactivé quand `disabled` ou `readonly` est
|
||||||
|
actif et n'émet pas l'event dans ce cas.
|
||||||
|
|
||||||
|
## Priorité visuelle
|
||||||
|
|
||||||
|
1. error
|
||||||
|
2. success
|
||||||
|
3. neutre
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `aria-invalid` activé si `error` existe.
|
||||||
|
- `aria-describedby` référence dynamiquement le message affiché.
|
||||||
|
- Le bouton `+` expose un `aria-label` configurable.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
|
||||||
|
- Émis à chaque modification de l'input. Permet l'utilisation avec
|
||||||
|
v-model.
|
||||||
|
|
||||||
|
### add
|
||||||
|
|
||||||
|
- Émis au clic du bouton `+` (uniquement si `addable` est vrai et
|
||||||
|
que le champ n'est ni `disabled` ni `readonly`).
|
||||||
|
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioInputPhone from '../../components/malio/input/InputPhone.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const addableValue = ref('')
|
||||||
|
const rightIconValue = ref('')
|
||||||
|
const noIconValue = ref('')
|
||||||
|
const maskedValue = ref('')
|
||||||
|
const hintValue = ref('')
|
||||||
|
const disabledValue = ref('+33 6 12 34 56 78')
|
||||||
|
const readonlyValue = ref('+33 6 12 34 56 78')
|
||||||
|
const errorValue = ref('abc')
|
||||||
|
const successValue = ref('+33 6 12 34 56 78')
|
||||||
|
|
||||||
|
const addClicks = ref(0)
|
||||||
|
const onAdd = () => {
|
||||||
|
addClicks.value++
|
||||||
|
}
|
||||||
|
</script>
|
||||||
1440
docs/superpowers/plans/2026-05-20-datepicker.md
Normal file
1440
docs/superpowers/plans/2026-05-20-datepicker.md
Normal file
File diff suppressed because it is too large
Load Diff
1362
docs/superpowers/plans/2026-05-20-daterange.md
Normal file
1362
docs/superpowers/plans/2026-05-20-daterange.md
Normal file
File diff suppressed because it is too large
Load Diff
780
docs/superpowers/plans/2026-05-20-dateweek.md
Normal file
780
docs/superpowers/plans/2026-05-20-dateweek.md
Normal file
@@ -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.
|
||||||
302
docs/superpowers/plans/2026-05-21-refonte-playground.md
Normal file
302
docs/superpowers/plans/2026-05-21-refonte-playground.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Refonte du playground — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remplacer la fausse-SPA du playground (sidebar maison + chargement dynamique dans `index.vue`) par du vrai routage Nuxt fichier + un layout par défaut qui embarque le composant `MalioSidebar` de production.
|
||||||
|
|
||||||
|
**Architecture:** Une config de navigation centralisée (`.playground/playground.nav.ts`) alimente un layout par défaut (`.playground/layouts/default.vue`) contenant `<MalioSidebar>` + `<slot />`. Les pages de démo existantes sous `.playground/pages/composant/**` deviennent automatiquement des routes et héritent du layout. `index.vue` devient une simple page d'accueil. Le `app/app.vue` du layer (`<NuxtLayout><NuxtPage /></NuxtLayout>`), hérité via `extends`, applique le layout automatiquement.
|
||||||
|
|
||||||
|
**Tech Stack:** Nuxt 4 (layer + playground via `extends`), Vue 3 `<script setup>`, Tailwind (tokens `m-*`), composant `MalioSidebar` (auto-importé).
|
||||||
|
|
||||||
|
**Note sur les tests :** Le playground est un harnais de dev, non livré. Vitest est scopé à `app/**/*.test.ts` (la bibliothèque) et aucune page playground n'a de test. Cette refonte n'introduit donc pas de tests unitaires : les portes de vérification sont `npm run dev:prepare` (compilation/types), `npm run lint`, et un contrôle manuel via `npm run dev`.
|
||||||
|
|
||||||
|
**Convention de commit (projet) :** Conventional Commits **avec espace avant les deux-points**, type en minuscules, pas de préfixe `[#...]`, suffixe ticket `(#MUI-34)`. Terminer par le trailer `Co-Authored-By`. Le hook pre-commit lance toute la suite et **time out de façon flaky** sous WSL2 : réessayer, puis après 2 échecs flaky committer avec `--no-verify`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Fichier | Rôle | Action |
|
||||||
|
|---------|------|--------|
|
||||||
|
| `.playground/playground.nav.ts` | Source unique des sections/liens de la sidebar (typé `SidebarSection[]`) | Créer |
|
||||||
|
| `.playground/layouts/default.vue` | Layout par défaut : `MalioSidebar` + zone de contenu `<slot />` | Créer |
|
||||||
|
| `.playground/pages/index.vue` | Page d'accueil simple (remplace la fausse-SPA) | Réécrire |
|
||||||
|
| `.claude/skills/creating-malio-component/SKILL.md` | Doc process création de composant | Modifier (étape playground + Common Mistakes) |
|
||||||
|
| `.playground/pages/composant/**/*.vue` | Pages de démo | **Inchangées** (déjà des routes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Config de navigation centralisée
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.playground/playground.nav.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer le fichier de navigation**
|
||||||
|
|
||||||
|
Créer `.playground/playground.nav.ts` avec ce contenu exact. Chaque `to` correspond exactement à un fichier existant sous `.playground/pages/composant/`. Le type est importé du SFC `MalioSidebar`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
|
||||||
|
|
||||||
|
export const navSections: SidebarSection[] = [
|
||||||
|
{
|
||||||
|
label: 'BOUTONS',
|
||||||
|
icon: 'mdi:gesture-tap-button',
|
||||||
|
items: [
|
||||||
|
{label: 'Button', to: '/composant/button/button'},
|
||||||
|
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CHAMPS',
|
||||||
|
icon: 'mdi:form-textbox',
|
||||||
|
items: [
|
||||||
|
{label: 'Texte', to: '/composant/input/inputText'},
|
||||||
|
{label: 'Nombre', to: '/composant/input/inputNumber'},
|
||||||
|
{label: 'Montant', to: '/composant/input/inputAmount'},
|
||||||
|
{label: 'Email', to: '/composant/input/inputEmail'},
|
||||||
|
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
|
||||||
|
{label: 'Téléphone', to: '/composant/input/inputPhone'},
|
||||||
|
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
|
||||||
|
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
|
||||||
|
{label: 'Upload', to: '/composant/input/inputUpload'},
|
||||||
|
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SÉLECTIONS',
|
||||||
|
icon: 'mdi:form-dropdown',
|
||||||
|
items: [
|
||||||
|
{label: 'Select', to: '/composant/select/select'},
|
||||||
|
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
||||||
|
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
||||||
|
{label: 'Radio', to: '/composant/radio/radioButton'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'NAVIGATION',
|
||||||
|
icon: 'mdi:navigation-variant',
|
||||||
|
items: [
|
||||||
|
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
||||||
|
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
||||||
|
{label: 'Onglets', to: '/composant/tab/tabList'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DONNÉES',
|
||||||
|
icon: 'mdi:table',
|
||||||
|
items: [
|
||||||
|
{label: 'DataTable', to: '/composant/datatable/datatable'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DIVERS',
|
||||||
|
icon: 'mdi:dots-horizontal',
|
||||||
|
items: [
|
||||||
|
{label: 'Heure', to: '/composant/time/time'},
|
||||||
|
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||||
|
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier le lint du fichier**
|
||||||
|
|
||||||
|
Run: `npx eslint .playground/playground.nav.ts`
|
||||||
|
Expected: aucune erreur (0 problems). Si ESLint signale un import de type non résolu depuis le `.vue`, c'est un faux positif de résolution ; il ne bloque pas (warnings only). En cas d'**erreur** bloquante sur l'import du type, fallback : remplacer la ligne d'import par une définition locale équivalente :
|
||||||
|
```ts
|
||||||
|
type SidebarItem = {label: string; to: string}
|
||||||
|
type SidebarSection = {label?: string; icon?: string; items: SidebarItem[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
*(Pas de commit ici — les 3 fichiers de la refonte seront committés ensemble en Task 4, car retirer l'ancien `index.vue` casse temporairement le glob.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Layout par défaut
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.playground/layouts/default.vue`
|
||||||
|
|
||||||
|
**Pré-requis vérifiés :** `MalioSidebar` est auto-importé (préfixe `Malio`, `pathPrefix: false`). Ses slots sont `logo` et `logo-collapsed`. Sa prop requise est `sections: SidebarSection[]`. Les logos `LOGO_MALIO.png` / `LOGO_MALIO_COLLAPSED.png` sont servis depuis le `public/` du layer (donc accessibles à la racine `/`).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer le layout**
|
||||||
|
|
||||||
|
Créer `.playground/layouts/default.vue`. Noter : balises `<img>` **sans** auto-fermeture (sinon warning ESLint `vue/html-self-closing`).
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen">
|
||||||
|
<MalioSidebar :sections="navSections">
|
||||||
|
<template #logo>
|
||||||
|
<NuxtLink to="/">
|
||||||
|
<img src="/LOGO_MALIO.png" alt="Malio">
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template #logo-collapsed>
|
||||||
|
<NuxtLink to="/">
|
||||||
|
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</MalioSidebar>
|
||||||
|
|
||||||
|
<main class="flex-1 overflow-y-auto p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {navSections} from '../playground.nav'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier le lint du layout**
|
||||||
|
|
||||||
|
Run: `npx eslint .playground/layouts/default.vue`
|
||||||
|
Expected: aucune erreur bloquante (0 errors).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Réécrire `index.vue` en page d'accueil
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (réécriture complète): `.playground/pages/index.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Remplacer tout le contenu de `index.vue`**
|
||||||
|
|
||||||
|
Remplacer **l'intégralité** du fichier `.playground/pages/index.vue` (supprime la sidebar maison + le chargement dynamique par glob) par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-2xl py-16 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-m-text">
|
||||||
|
Playground @malio/layer-ui
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-m-muted">
|
||||||
|
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
*(Page sans `<script>` : contenu purement statique. Elle hérite du layout `default` automatiquement.)*
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier le lint de la page**
|
||||||
|
|
||||||
|
Run: `npx eslint .playground/pages/index.vue`
|
||||||
|
Expected: aucune erreur bloquante (0 errors).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Vérification end-to-end + commit de la refonte
|
||||||
|
|
||||||
|
**Files:** (commit groupé)
|
||||||
|
- `.playground/playground.nav.ts`
|
||||||
|
- `.playground/layouts/default.vue`
|
||||||
|
- `.playground/pages/index.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Régénérer les types Nuxt (compilation)**
|
||||||
|
|
||||||
|
Run: `npm run dev:prepare`
|
||||||
|
Expected: « Types generated in .playground/.nuxt. » sans erreur de compilation. Valide que le layout, le nav et `index.vue` compilent et que l'import du type `SidebarSection` se résout.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lint global**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 0 errors (des warnings préexistants sur d'autres fichiers sont tolérés ; aucun nouvel **error** sur les 3 fichiers créés/modifiés).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Contrôle manuel dans le navigateur**
|
||||||
|
|
||||||
|
Run: `npm run dev` puis ouvrir l'URL affichée.
|
||||||
|
Vérifier :
|
||||||
|
- L'accueil (`/`) affiche le message de bienvenue, avec la `MalioSidebar` à gauche.
|
||||||
|
- La sidebar liste les 6 sections et tous les liens.
|
||||||
|
- Cliquer un item (ex. « Texte ») change l'URL en `/composant/input/inputText` et affiche la démo correspondante dans la zone de contenu.
|
||||||
|
- Le bouton collapse de la sidebar fonctionne (plier/déplier).
|
||||||
|
- Cliquer le logo ramène à `/`.
|
||||||
|
|
||||||
|
Arrêter le serveur (Ctrl+C) une fois vérifié.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit de la refonte**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .playground/playground.nav.ts .playground/layouts/default.vue .playground/pages/index.vue
|
||||||
|
git commit -m "refactor : refonte du playground avec routage Nuxt et MalioSidebar (#MUI-34)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Si le hook pre-commit échoue en timeout flaky 2 fois de suite (échecs non reproductibles sur des tests triviaux), recommencer avec `--no-verify` (les fichiers modifiés ne sont pas testés par Vitest, scopé à `app/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Mettre à jour le skill `creating-malio-component`
|
||||||
|
|
||||||
|
Le skill décrit encore l'ancien fonctionnement (auto-découverte par `index.vue` via glob). Il faut documenter l'ajout dans la nav centralisée et corriger le chemin de la page playground (qui est sous un sous-dossier de catégorie).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude/skills/creating-malio-component/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Réécrire l'étape 5 (page playground)**
|
||||||
|
|
||||||
|
Remplacer le bloc de l'étape « ### 5. Créer la page playground » — du titre jusqu'à la ligne `**Variantes typiques :**` exclue — par :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 5. Créer la page playground
|
||||||
|
|
||||||
|
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
|
||||||
|
|
||||||
|
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`.
|
||||||
|
|
||||||
|
Inclure des variantes représentatives dans une grille :
|
||||||
|
|
||||||
|
\`\`\`html
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Titre variante</h2>
|
||||||
|
<MalioMonComposant ... />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Mettre à jour la table « Common Mistakes »**
|
||||||
|
|
||||||
|
Remplacer la ligne :
|
||||||
|
```markdown
|
||||||
|
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
|
||||||
|
```
|
||||||
|
par :
|
||||||
|
```markdown
|
||||||
|
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Vérifier la cohérence du diagramme workflow**
|
||||||
|
|
||||||
|
Lire le bloc `digraph` en tête du skill. L'étape « 5. Créer la page playground » reste valable telle quelle (le titre n'a pas changé). Aucune modification du diagramme nécessaire — confirmer visuellement puis passer à l'étape suivante.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit de la mise à jour du skill**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .claude/skills/creating-malio-component/SKILL.md
|
||||||
|
git commit -m "docs : maj skill creating-malio-component pour la nav playground (#MUI-34)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Ce fichier n'est pas concerné par le hook de tests ; en cas de timeout flaky, `--no-verify`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérification finale (après toutes les tâches)
|
||||||
|
|
||||||
|
- [ ] `npm run lint` → 0 errors.
|
||||||
|
- [ ] `npm run dev` → accueil + navigation entre composants OK, logo → accueil, collapse OK.
|
||||||
|
- [ ] `git log --oneline -3` → 2 nouveaux commits au format `type : … (#MUI-34)`.
|
||||||
|
- [ ] Plus aucune trace de sidebar maison / `import.meta.glob` dans `.playground/pages/index.vue`.
|
||||||
|
|
||||||
|
## Note post-exécution (pour l'agent)
|
||||||
|
|
||||||
|
Mettre à jour la mémoire `malio-datepicker-conventions.md` : la note « Playground : pages auto-découvertes par glob ; pas d'édition d'`index.vue` » est désormais fausse. Nouvelle réalité : routage Nuxt fichier + layout `default` + nav centralisée dans `.playground/playground.nav.ts` à éditer pour chaque nouveau composant.
|
||||||
373
docs/superpowers/specs/2026-05-19-datepicker-design.md
Normal file
373
docs/superpowers/specs/2026-05-19-datepicker-design.md
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
# MalioDate — Design Spec
|
||||||
|
|
||||||
|
Composant de sélection de date avec champ + popover calendrier. Première brique d'une famille de pickers temporels (futurs `DateRange`, `DateTime`).
|
||||||
|
|
||||||
|
**Ticket :** MUI-33
|
||||||
|
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
|
||||||
|
|
||||||
|
## Périmètre v1
|
||||||
|
|
||||||
|
Sélection d'une date unique via un calendrier. Le champ est **readonly** (clic uniquement, pas de saisie clavier en v1). Locale FR hardcodée, semaine commençant le lundi.
|
||||||
|
|
||||||
|
**Inclus en v1 :**
|
||||||
|
- Affichage `JJ/MM/AAAA` dans le champ, valeur ISO `YYYY-MM-DD` en `modelValue`
|
||||||
|
- Surlignage du jour sélectionné et du jour "aujourd'hui"
|
||||||
|
- Jours du mois précédent/suivant affichés grisés mais cliquables (naviguent vers le mois cible)
|
||||||
|
- Bornes `min` / `max` (jours hors bornes désactivés)
|
||||||
|
- Bouton effacer (croix) si `clearable`
|
||||||
|
- Vue mois (grille 4×3) accessible via clic sur `Mois Année ⌄` dans le header
|
||||||
|
- Numéros de semaine ISO 8601 dans une colonne à fond `m-primary/10`
|
||||||
|
|
||||||
|
**Reporté à plus tard :**
|
||||||
|
- Saisie clavier dans le champ (parsing `JJ/MM/AAAA` manuel)
|
||||||
|
- Navigation clavier dans la grille (flèches, Enter, Escape)
|
||||||
|
- Vue années (sélection rapide d'une année)
|
||||||
|
- Prop `disabledDates` (prédicat ou array)
|
||||||
|
- i18n (autres langues)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Composant public unique `<MalioDate>` (autoimporté depuis `app/components/malio/date/Date.vue`), composé de sous-composants internes et de modules utilitaires colocalisés.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/components/malio/date/
|
||||||
|
Date.vue # composant public (orchestration)
|
||||||
|
Date.test.ts
|
||||||
|
internal/
|
||||||
|
CalendarHeader.vue # header mois/année + chevrons + toggle vue
|
||||||
|
MonthGrid.vue # grille 6×7 jours + colonne semaine
|
||||||
|
MonthPicker.vue # grille 4×3 mois
|
||||||
|
composables/
|
||||||
|
useMonthMatrix.ts # calcule la matrice 6×7 + n° semaines ISO
|
||||||
|
dateFormat.ts # fonctions pures de format/parsing/validation
|
||||||
|
useCalendarPopover.ts # état ouvert/fermé + click outside
|
||||||
|
```
|
||||||
|
|
||||||
|
Les sous-composants `internal/` ne sont pas destinés à être consommés directement. Ils seront réutilisés par `DateRange` et `DateTime` à venir.
|
||||||
|
|
||||||
|
## Type `modelValue`
|
||||||
|
|
||||||
|
`string | null`, au format ISO `YYYY-MM-DD`. Le composant interne convertit en affichage `JJ/MM/AAAA` via `dateFormat.formatIsoToDisplay()`. Cette représentation a été retenue pour :
|
||||||
|
- Cohérence avec `<MalioTime>` qui émet déjà une string (`"HH:MM"`)
|
||||||
|
- Sérialisation directe vers une API REST/JSON sans conversion
|
||||||
|
- Pas de piège de fuseau horaire (un objet `Date` JS porte une heure + un fuseau)
|
||||||
|
- Comparaison lexicographique = comparaison chronologique (utile pour `min`/`max`)
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto-généré | Identifiant HTML du champ |
|
||||||
|
| `name` | `string` | `''` | Attribut `name` pour les `<form>` |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Date ISO `YYYY-MM-DD` (v-model) |
|
||||||
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder du champ |
|
||||||
|
| `required` | `boolean` | `false` | Attribut required |
|
||||||
|
| `disabled` | `boolean` | `false` | Verrouille champ et calendrier |
|
||||||
|
| `readonly` | `boolean` | `false` | Affiche la valeur mais bloque l'ouverture |
|
||||||
|
| `hint` | `string` | `''` | Texte d'aide sous le champ |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur (bordure et texte rouges) |
|
||||||
|
| `success` | `string` | `''` | Message succès (bordure et texte verts) |
|
||||||
|
| `min` | `string` | `undefined` | Borne inférieure incluse, format ISO |
|
||||||
|
| `max` | `string` | `undefined` | Borne supérieure incluse, format ISO |
|
||||||
|
| `clearable` | `boolean` | `true` | Affiche une croix pour effacer la valeur |
|
||||||
|
| `inputClass` | `string` | `''` | Override classes input (twMerge) |
|
||||||
|
| `labelClass` | `string` | `''` | Override classes label (twMerge) |
|
||||||
|
| `groupClass` | `string` | `''` | Override classes wrapper (twMerge) |
|
||||||
|
|
||||||
|
Si `min`/`max` sont invalides (format incorrect ou `min > max`), ils sont ignorés silencieusement avec un warning console en dev.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `update:modelValue` | `string \| null` | Date ISO sélectionnée, ou `null` si effacée |
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
Aucun slot en v1. L'icône calendrier est fixée (`mdi:calendar-outline`).
|
||||||
|
|
||||||
|
## Sous-composants internes
|
||||||
|
|
||||||
|
### `CalendarHeader.vue`
|
||||||
|
|
||||||
|
Affiche la barre du haut du popover : `[‹] Mois Année [⌄] [›]`.
|
||||||
|
|
||||||
|
**Props :**
|
||||||
|
- `viewMode: 'days' | 'months'`
|
||||||
|
- `currentMonth: number` (0-11)
|
||||||
|
- `currentYear: number`
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `prev` — chevron gauche (interprété par le parent : mois précédent en vue jours, année précédente en vue mois)
|
||||||
|
- `next` — chevron droit (idem)
|
||||||
|
- `toggle-view` — clic sur le bouton central
|
||||||
|
|
||||||
|
### `MonthGrid.vue`
|
||||||
|
|
||||||
|
Rend la grille 6 lignes × 8 colonnes (semaine + 7 jours).
|
||||||
|
|
||||||
|
**Props :**
|
||||||
|
- `month: number` (0-11)
|
||||||
|
- `year: number`
|
||||||
|
- `selectedDate?: string | null` (ISO)
|
||||||
|
- `min?: string` (ISO)
|
||||||
|
- `max?: string` (ISO)
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `select` payload `string` — date ISO `YYYY-MM-DD` du jour cliqué
|
||||||
|
|
||||||
|
Utilise `useMonthMatrix(month, year)` pour générer les 6 lignes. La grille fait toujours 6 lignes (forcé) pour stabiliser la hauteur du popover entre les mois.
|
||||||
|
|
||||||
|
### `MonthPicker.vue`
|
||||||
|
|
||||||
|
Rend la grille 4×3 des mois.
|
||||||
|
|
||||||
|
**Props :**
|
||||||
|
- `selectedMonth?: number` (0-11, mois courant à surligner)
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `select` payload `number` (0-11)
|
||||||
|
|
||||||
|
Pas de gestion `min`/`max` au niveau mois en v1 — `MonthGrid` filtrera les jours hors bornes au retour vue jours.
|
||||||
|
|
||||||
|
## Composables
|
||||||
|
|
||||||
|
### `useMonthMatrix.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DayCell = {
|
||||||
|
isoDate: string // "YYYY-MM-DD"
|
||||||
|
day: number // 1-31
|
||||||
|
isCurrentMonth: boolean
|
||||||
|
isToday: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeekRow = {
|
||||||
|
weekNumber: number // ISO 8601, 1-53
|
||||||
|
days: DayCell[] // toujours 7, Lun → Dim
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMonthMatrix(
|
||||||
|
month: Ref<number>,
|
||||||
|
year: Ref<number>
|
||||||
|
): { weeks: ComputedRef<WeekRow[]> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Le premier jour de la grille est le lundi de la semaine contenant le 1er du mois affiché. La grille fait **toujours** 6 lignes (`WeekRow[]` de longueur 6), au besoin en débordant sur le mois suivant.
|
||||||
|
|
||||||
|
Les numéros de semaine suivent **ISO 8601** : la semaine 1 contient le premier jeudi de l'année.
|
||||||
|
|
||||||
|
### `dateFormat.ts`
|
||||||
|
|
||||||
|
Module de fonctions pures, **pas un composable réactif**. Le nommage sans préfixe `use` reflète sa nature.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function formatIsoToDisplay(iso: string | null): string
|
||||||
|
// "2026-05-19" → "19/05/2026", null/invalide → ""
|
||||||
|
|
||||||
|
function parseDisplayToIso(display: string): string | null
|
||||||
|
// "19/05/2026" → "2026-05-19", invalide → null
|
||||||
|
|
||||||
|
function isValidIso(iso: string): boolean
|
||||||
|
// "2026-05-19" → true, "2026-13-45" → false
|
||||||
|
|
||||||
|
function isDateInRange(iso: string, min?: string, max?: string): boolean
|
||||||
|
// Comparaison lexicographique (= chronologique pour ISO)
|
||||||
|
```
|
||||||
|
|
||||||
|
`parseDisplayToIso` est écrit dès la v1 même si non utilisé (le champ est readonly) — il sera réutilisé en v2 quand on rendra le champ éditable.
|
||||||
|
|
||||||
|
### `useCalendarPopover.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function useCalendarPopover(rootRef: Ref<HTMLElement | null>): {
|
||||||
|
isOpen: Ref<boolean>
|
||||||
|
viewMode: Ref<'days' | 'months'>
|
||||||
|
open: () => void
|
||||||
|
close: () => void
|
||||||
|
toggleView: () => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `isOpen` et `viewMode` reset à `false` / `'days'` à la fermeture
|
||||||
|
- Listener `mousedown` global attaché à `onMounted`, retiré à `onBeforeUnmount`
|
||||||
|
- Fermeture si le clic est hors de `rootRef`
|
||||||
|
- Pas de gestion clavier en v1
|
||||||
|
|
||||||
|
## Comportements détaillés
|
||||||
|
|
||||||
|
### Ouverture du popover
|
||||||
|
|
||||||
|
Clic sur le champ ou l'icône calendrier (sauf si `disabled` ou `readonly`) → `open()`. Vue initiale :
|
||||||
|
- Si `modelValue` valide → grille du mois de cette date
|
||||||
|
- Sinon → grille du mois courant (`new Date()`)
|
||||||
|
|
||||||
|
Le champ passe en mode "popover ouvert" : bordure du bas retirée, `rounded-b-none`, bordure latérale colorée (`m-primary` ou `m-danger`/`m-success` selon état).
|
||||||
|
|
||||||
|
### Sélection d'un jour (vue jours)
|
||||||
|
|
||||||
|
Clic sur une cellule jour cliquable :
|
||||||
|
1. Émission `update:modelValue` avec la date ISO
|
||||||
|
2. Fermeture du popover
|
||||||
|
3. Réaffichage du champ avec la valeur formatée `JJ/MM/AAAA`
|
||||||
|
|
||||||
|
Cas spéciaux :
|
||||||
|
- Jour hors mois courant : sélection normale, le popover se ferme (peu importe que la vue interne saute au mois cible, elle n'est plus visible)
|
||||||
|
- Jour hors `min`/`max` : non cliquable, `cursor-not-allowed`, pas d'émission
|
||||||
|
- Re-clic sur la date déjà sélectionnée : ré-émission de la même valeur, popover ferme
|
||||||
|
|
||||||
|
### Navigation chevrons (vue jours)
|
||||||
|
|
||||||
|
- Chevron gauche : `currentMonth -= 1` (décembre + `year -= 1` si on était en janvier)
|
||||||
|
- Chevron droit : symétrique
|
||||||
|
- Pas de bornage de navigation par `min`/`max` — on peut naviguer où on veut, seuls les jours sont désactivés
|
||||||
|
|
||||||
|
### Bascule vers la vue mois
|
||||||
|
|
||||||
|
Clic sur `Mois Année ⌄` → `toggleView()` → `viewMode = 'months'`.
|
||||||
|
|
||||||
|
En vue mois :
|
||||||
|
- Header inchangé visuellement, mais les chevrons naviguent désormais l'**année** (`year ± 1`)
|
||||||
|
- Le bouton central reste cliquable : un nouveau clic ramène à `viewMode = 'days'` (toggle binaire, validé Q4b)
|
||||||
|
- Clic sur un mois dans la grille 4×3 → `currentMonth = mois cliqué`, retour `viewMode = 'days'` sans sélection de date
|
||||||
|
|
||||||
|
### Fermeture sans sélection
|
||||||
|
|
||||||
|
Clic en dehors du champ ET du popover → `close()`. `modelValue` inchangé. L'état interne (`currentMonth`, `currentYear`, `viewMode`) est **reset à la prochaine ouverture** selon la règle "Ouverture du popover" (pas de mémorisation).
|
||||||
|
|
||||||
|
### Bouton effacer
|
||||||
|
|
||||||
|
Si `modelValue !== null && clearable && !disabled && !readonly` :
|
||||||
|
- Une croix `mdi:close` apparaît à gauche de l'icône calendrier
|
||||||
|
- Clic émet `null` et `stopPropagation` pour ne pas ouvrir le popover
|
||||||
|
|
||||||
|
### États
|
||||||
|
|
||||||
|
- `disabled` : opacity réduite, curseur not-allowed, clic sans effet, croix masquée
|
||||||
|
- `readonly` : affichage normal, clic sans effet sur l'ouverture, croix masquée
|
||||||
|
|
||||||
|
### Synchronisation `modelValue` externe
|
||||||
|
|
||||||
|
Si le parent change `modelValue` programmatiquement :
|
||||||
|
- Le champ se met à jour (re-format)
|
||||||
|
- Si le popover est ouvert, la vue saute au mois de la nouvelle valeur
|
||||||
|
- Si la nouvelle valeur a un format invalide, le composant traite comme `null` et log un warning console en dev
|
||||||
|
|
||||||
|
## Style / CSS
|
||||||
|
|
||||||
|
### Popover
|
||||||
|
|
||||||
|
- `min-w-[320px]`, hauteur fixe ~`360px` (6 semaines × ~38px + header)
|
||||||
|
- Position : `absolute top-[calc(100%-4px)] left-0 z-20`
|
||||||
|
- `bg-white border border-t-0` (couleur selon état : `m-primary` / `m-danger` / `m-success`)
|
||||||
|
- `rounded-b-md`
|
||||||
|
- Transition : `opacity` 150ms à l'apparition, respect `prefers-reduced-motion`
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
- Hauteur `h-12`, `border-b border-m-primary/20`
|
||||||
|
- Chevrons (`mdi:chevron-left` / `mdi:chevron-right`) : 20px, padding cliquable 8px, `hover:bg-m-primary/10 rounded`
|
||||||
|
- Texte central : `text-base font-medium`, cliquable, `mdi:chevron-down` 16px à côté
|
||||||
|
|
||||||
|
### Grille jours
|
||||||
|
|
||||||
|
- En-tête `Sem | Lun | Mar | Mer | Jeu | Ven | Sam | Dim` : `text-xs uppercase text-m-muted font-medium`, 32px de hauteur
|
||||||
|
- Cellule : `w-10 h-10 text-sm`, centrée
|
||||||
|
- Colonne semaine : `bg-m-primary/10`, `text-m-primary/70`, non cliquable
|
||||||
|
- Jour du mois courant : `text-black`
|
||||||
|
- Jour hors mois : `text-m-muted/50`
|
||||||
|
- Jour "aujourd'hui" : `border border-m-primary`, `text-m-primary font-semibold`
|
||||||
|
- Jour sélectionné : `bg-m-primary text-white font-medium rounded-full` (prime sur "aujourd'hui")
|
||||||
|
- Jour hors `min`/`max` : `text-m-muted/30 cursor-not-allowed`, non cliquable
|
||||||
|
- Hover : `hover:bg-m-primary/10 rounded-full`
|
||||||
|
|
||||||
|
### Grille mois (MonthPicker)
|
||||||
|
|
||||||
|
- `grid grid-cols-4 gap-2 p-3`
|
||||||
|
- Cellule : `py-3 text-sm rounded`
|
||||||
|
- Libellés : `Janv | Févr | Mars | Avr | Mai | Juin | Juil | Août | Sept | Oct | Nov | Déc`
|
||||||
|
- Mois sélectionné : `bg-m-primary text-white`
|
||||||
|
- Hover : `hover:bg-m-primary/10`
|
||||||
|
|
||||||
|
### Champ
|
||||||
|
|
||||||
|
Reprend le pattern de `<MalioInputAutocomplete>` : label flottant, bordure `m-muted` au repos, `m-primary` au focus/open, `m-danger`/`m-success` selon état.
|
||||||
|
|
||||||
|
- Icône calendrier `mdi:calendar-outline` 20px, à droite, couleur dynamique selon état
|
||||||
|
- Croix d'effacement `mdi:close` 16px, à gauche de l'icône, `text-m-muted hover:text-black`
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `aria-invalid` synchronisé sur `error`
|
||||||
|
- `aria-describedby` lié au texte de `hint`/`error`/`success`
|
||||||
|
- `aria-expanded` sur le champ pour signaler l'état du popover
|
||||||
|
- `aria-haspopup="dialog"` sur le champ
|
||||||
|
- Label `<label for>` lié au champ
|
||||||
|
- Cellules jour : `role="button"`, `aria-label="19 mai 2026"` (jour en toutes lettres pour les lecteurs d'écran)
|
||||||
|
- Cellules désactivées : `aria-disabled="true"`
|
||||||
|
- Navigation clavier dans la grille : **reportée v2** (Escape, flèches, Enter)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### `Date.test.ts` (~30 cas)
|
||||||
|
|
||||||
|
Tests groupés par `describe` :
|
||||||
|
- **Rendu** : label, placeholder, icône calendrier, affichage de la valeur formatée
|
||||||
|
- **Popover** : ouverture au clic, fermeture au click outside, vue initiale selon `modelValue`
|
||||||
|
- **Navigation** : chevrons en vue jours, passage décembre↔janvier avec changement d'année
|
||||||
|
- **Sélection** : émission ISO correcte, fermeture après sélection, sélection d'un jour hors mois
|
||||||
|
- **Bornes** : jours hors `min`/`max` non cliquables, comparaison ISO
|
||||||
|
- **Vue mois** : bascule, chevrons en vue mois naviguent l'année, clic mois retourne en vue jours
|
||||||
|
- **Clearable** : présence/absence de la croix, émission `null`, pas d'ouverture
|
||||||
|
- **États** : `disabled` et `readonly` bloquent l'ouverture
|
||||||
|
- **A11y** : `aria-invalid`, `aria-describedby`
|
||||||
|
- **Synchro externe** : changement de `modelValue` programmatique
|
||||||
|
|
||||||
|
### `useMonthMatrix.test.ts` (~10 cas)
|
||||||
|
|
||||||
|
- Mois standard (mai 2026) produit 6×7 cellules
|
||||||
|
- Mois commençant un lundi (toutes les cases du premier lundi sont du mois courant)
|
||||||
|
- Mois finissant un dimanche
|
||||||
|
- Année bissextile (février 2024 : 29 jours)
|
||||||
|
- Numéro de semaine ISO en début d'année (janvier 2026 commence en semaine 1 ou 52/53 de 2025 ?)
|
||||||
|
- Numéro de semaine ISO en fin d'année
|
||||||
|
|
||||||
|
### `dateFormat.test.ts` (~10 cas)
|
||||||
|
|
||||||
|
- `formatIsoToDisplay` : nominal, null, format invalide
|
||||||
|
- `parseDisplayToIso` : nominal, format invalide, jour ou mois hors borne
|
||||||
|
- `isValidIso` : nominal, faux positifs (32 jours, mois 13)
|
||||||
|
- `isDateInRange` : sans bornes, avec min seul, avec max seul, avec les deux
|
||||||
|
|
||||||
|
Helper `mountComponent(props)` reprend le pattern existant des autres tests Malio. Environnement Vitest + jsdom (déjà configurés).
|
||||||
|
|
||||||
|
## Story Histoire `Date.story.vue`
|
||||||
|
|
||||||
|
Dans `app/story/date/Date.story.vue`. Variants :
|
||||||
|
1. **Default** — vierge, label "Date de naissance"
|
||||||
|
2. **Avec valeur initiale** — `modelValue="2026-05-19"`
|
||||||
|
3. **Avec min/max** — borné aujourd'hui → +30 jours, label "Date du rendez-vous"
|
||||||
|
4. **États** — disabled, readonly, error, success, hint
|
||||||
|
5. **Non-clearable** — `clearable=false`
|
||||||
|
6. **Required** — avec error si vide
|
||||||
|
7. **Override de classes** — `inputClass`, `groupClass` custom
|
||||||
|
|
||||||
|
## Playground `.playground/pages/composant/date.vue`
|
||||||
|
|
||||||
|
Page de test dev :
|
||||||
|
- Un `<MalioDate>` standalone
|
||||||
|
- Affichage de la valeur courante en dessous
|
||||||
|
- Boutons pour reset (`value = null`) et forcer une date (`value = '2026-12-25'`)
|
||||||
|
- Un cas avec `min`/`max`
|
||||||
|
|
||||||
|
Lien ajouté dans `.playground/pages/index.vue`.
|
||||||
|
|
||||||
|
## Découpage de l'implémentation
|
||||||
|
|
||||||
|
Le plan d'implémentation (généré ensuite via `writing-plans`) découpera en étapes ordonnées :
|
||||||
|
1. Composables purs (`dateFormat`, `useMonthMatrix`, `useCalendarPopover`) + leurs tests
|
||||||
|
2. Sous-composants internes (`CalendarHeader`, `MonthGrid`, `MonthPicker`)
|
||||||
|
3. Composant public `Date.vue`
|
||||||
|
4. Tests d'intégration `Date.test.ts`
|
||||||
|
5. Story Histoire + page playground
|
||||||
243
docs/superpowers/specs/2026-05-20-daterange-design.md
Normal file
243
docs/superpowers/specs/2026-05-20-daterange-design.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# MalioDateRange — Design Spec
|
||||||
|
|
||||||
|
Composant de sélection d'une **période** (date début / date fin) via un champ + popover calendrier. Deuxième brique de la famille temporelle, construite sur un shell partagé extrait de `MalioDate`.
|
||||||
|
|
||||||
|
**Ticket :** MUI-33 (suite)
|
||||||
|
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
|
||||||
|
**Spec liée :** `docs/superpowers/specs/2026-05-19-datepicker-design.md`
|
||||||
|
|
||||||
|
## Contexte & roadmap
|
||||||
|
|
||||||
|
`MalioDate` (sélection simple) est déjà livré. Suivront `DateWeek` et `DateTime`. Les 4 composants partagent le **même champ + popover + header + vue mois** ; seule la sélection diffère. On extrait donc un shell réutilisable `CalendarField` (Approche 3 retenue parce que la famille comptera 4 variantes — la duplication de coquille ×4 et la maintenance front en parallèle sont le vrai risque).
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
Sélection d'une période sur **un seul mois** affiché (popover = largeur du champ, comme `Date`). Visuellement identique à `Date`, sauf :
|
||||||
|
- on sélectionne **deux dates** (début/fin)
|
||||||
|
- les jours **entre** les bornes ont un fond `bg-m-primary-light` (bleu clair)
|
||||||
|
- les bornes (start/end) gardent le cercle plein `bg-m-primary`
|
||||||
|
- **aperçu au survol** (hover preview) de la plage pendant la sélection
|
||||||
|
|
||||||
|
**Inclus :** sélection 2 clics, auto-inversion, hover preview, surlignage de plage (demi-barre aux bornes), bornes `min`/`max`, effacement, vue mois conservée.
|
||||||
|
|
||||||
|
**Reporté :** deux mois côte à côte, ajustement de la borne la plus proche au 3e clic (on garde le reset standard), saisie clavier, navigation clavier.
|
||||||
|
|
||||||
|
## Architecture (Approche 3 — shell partagé)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/components/malio/date/
|
||||||
|
Date.vue # ENVELOPPE (refacto) — sélection simple
|
||||||
|
DateRange.vue # ENVELOPPE (nouveau) — sélection période
|
||||||
|
Date.test.ts # inchangé (filet de sécurité du refacto)
|
||||||
|
DateRange.test.ts # nouveau
|
||||||
|
internal/
|
||||||
|
CalendarField.vue # NOUVEAU — shell : champ + popover + header + MonthPicker
|
||||||
|
CalendarHeader.vue # inchangé
|
||||||
|
MonthGrid.vue # étendu : props range + émission hover + data-range-role
|
||||||
|
MonthPicker.vue # inchangé
|
||||||
|
composables/
|
||||||
|
useCalendarView.ts # NOUVEAU — état mois/année + navigation (extrait de Date.vue)
|
||||||
|
useCalendarView.test.ts
|
||||||
|
useCalendarPopover.ts # inchangé
|
||||||
|
dateRange.ts # NOUVEAU — helpers purs de plage
|
||||||
|
dateRange.test.ts
|
||||||
|
dateFormat.ts # inchangé
|
||||||
|
useMonthMatrix.ts # inchangé
|
||||||
|
app/story/date/dateRange.story.vue
|
||||||
|
.playground/pages/composant/date/dateRange.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
Flux :
|
||||||
|
|
||||||
|
```
|
||||||
|
DateRange.vue (enveloppe)
|
||||||
|
├─ état de sélection range ({start,end} + pendingStart + hoverDate)
|
||||||
|
├─ displayValue ("19/05/2026 - 25/05/2026")
|
||||||
|
└─ <CalendarField :display-value :sync-to=start ... @clear @close>
|
||||||
|
├─ champ + popover (useCalendarPopover) + navigation (useCalendarView)
|
||||||
|
├─ header + MonthPicker (viewMode='months')
|
||||||
|
└─ <slot :current-month :current-year :close> ← viewMode='days'
|
||||||
|
└─ <MonthGrid> mode range (rangeStart/rangeEnd/previewDate, @select, @hover)
|
||||||
|
```
|
||||||
|
|
||||||
|
`CalendarField` ne connaît **rien** de la sélection : il gère champ, ouverture, navigation, et expose `{ currentMonth, currentYear, close }` au slot. Chaque enveloppe branche son `MonthGrid` et décide quand appeler `close()`.
|
||||||
|
|
||||||
|
## `useCalendarView.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function useCalendarView(viewMode: Ref<'days' | 'months'>): {
|
||||||
|
currentMonth: Ref<number> // 0-11
|
||||||
|
currentYear: Ref<number>
|
||||||
|
goToPrev: () => void // viewMode==='months' ? année-1 : mois-1 (roulement déc↔jan)
|
||||||
|
goToNext: () => void // idem +1
|
||||||
|
selectMonth: (m: number) => void // currentMonth = m
|
||||||
|
syncToIso: (iso: string | null) => void // mois/année depuis un ISO valide, sinon mois courant
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reprend la logique `onPrev`/`onNext`/`syncViewToValue` actuelle de `Date.vue`. Pur, testable seul.
|
||||||
|
|
||||||
|
## `CalendarField.vue` (shell)
|
||||||
|
|
||||||
|
**Props :**
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `displayValue` | `string` | **requis** | Texte affiché dans le champ (`''` si rien/incomplet) |
|
||||||
|
| `syncTo` | `string \| null` | **requis** | ISO servant à caler le mois à l'ouverture |
|
||||||
|
| `id`,`name`,`label`,`placeholder` | `string` | `''` / `'JJ/MM/AAAA'` | Champ |
|
||||||
|
| `required`,`disabled`,`readonly` | `boolean` | `false` | États |
|
||||||
|
| `hint`,`error`,`success` | `string` | `''` | Messages |
|
||||||
|
| `clearable` | `boolean` | `true` | Croix d'effacement |
|
||||||
|
| `inputClass`,`labelClass`,`groupClass` | `string` | `''` | Overrides twMerge |
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `clear` | — | Croix cliquée → l'enveloppe met son `modelValue` à `null` |
|
||||||
|
| `close` | — | Popover fermé (clic dehors ou programmatique) → l'enveloppe annule sa sélection en cours |
|
||||||
|
|
||||||
|
**Slot par défaut** (scoped, rendu quand `viewMode==='days'`) : `{ currentMonth: number, currentYear: number, close: () => void }`.
|
||||||
|
|
||||||
|
**Comportement** (repris à l'identique de l'actuel `Date.vue`) : input readonly, label flottant (bleu à l'ouverture), icône calendrier, croix (si `clearable && displayValue && !disabled && !readonly`), grossissement calibré 48px, bordures/états, popover ombré largeur champ collé sous le champ, header (`prev`/`next`→`useCalendarView`, `toggle`→`useCalendarPopover.toggleView`), `MonthPicker` en vue mois (clic mois → `selectMonth` + retour vue jours), `syncToIso(syncTo)` à l'ouverture + watch resync. `isFilled` dérivé de `displayValue.length > 0`.
|
||||||
|
|
||||||
|
## `dateRange.ts` (helpers purs)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DateRangeValue = { start: string; end: string }
|
||||||
|
|
||||||
|
function normalizeRange(a: string, b: string): DateRangeValue
|
||||||
|
// réordonne pour garantir start ≤ end
|
||||||
|
|
||||||
|
function resolveRangeBounds(
|
||||||
|
start: string | null, end: string | null, preview: string | null,
|
||||||
|
): { lo: string; hi: string } | null
|
||||||
|
// pas de start → null ; end committé prioritaire, sinon preview, sinon {lo:start,hi:start}
|
||||||
|
|
||||||
|
type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
|
||||||
|
function dayRangeRole(iso: string, bounds: { lo: string; hi: string } | null): DayRangeRole
|
||||||
|
```
|
||||||
|
|
||||||
|
## `MonthGrid.vue` — extension
|
||||||
|
|
||||||
|
**Nouvelles props (optionnelles) :** `rangeStart?`, `rangeEnd?`, `previewDate?` (ISO ou null). Mode plage actif dès que `rangeStart` est passé ; sinon mode simple (`selectedDate`, comportement actuel inchangé).
|
||||||
|
|
||||||
|
**Nouvel event :** `hover` payload `string | null` — `mouseenter` d'un jour → ISO, `mouseleave` de la grille → `null`.
|
||||||
|
|
||||||
|
**Attribut testabilité :** chaque bouton jour porte `:data-range-role="role"` (`none`/`single`/`start`/`end`/`in-range`).
|
||||||
|
|
||||||
|
**Rendu d'un jour en mode plage** — bouton `relative` superposant 2 couches :
|
||||||
|
1. Barre de fond absolue `bg-m-primary-light` : `in-range` → pleine largeur (`inset-0`) ; `start` → moitié droite (`left-1/2 right-0`) ; `end` → moitié gauche (`left-0 right-1/2`) ; `single`/`none` → aucune.
|
||||||
|
2. Cercle (span `h-10 w-10`) au-dessus : `start`/`end`/`single` → `bg-m-primary` blanc ; `in-range` → transparent, texte noir ; `none` → rendu simple actuel (aujourd'hui, hors-mois…).
|
||||||
|
|
||||||
|
La barre passe sous les cercles, colonnes jointives → plage continue démarrant/finissant au centre des cercles.
|
||||||
|
|
||||||
|
## `DateRange.vue` (enveloppe)
|
||||||
|
|
||||||
|
**Props :** identiques à `Date` sauf `modelValue?: { start: string; end: string } | null`. (`id`,`name`,`label`,`placeholder`,`required`,`disabled`,`readonly`,`hint`,`error`,`success`,`min`,`max`,`clearable`,`inputClass`,`labelClass`,`groupClass`.)
|
||||||
|
|
||||||
|
**Emit :** `update:modelValue` → `{ start: string; end: string } | null`.
|
||||||
|
|
||||||
|
**État interne :**
|
||||||
|
```ts
|
||||||
|
pendingStart = ref<string | null>(null) // 1er clic en attente du 2e
|
||||||
|
hoverDate = ref<string | null>(null) // survol pour le preview
|
||||||
|
const isSelecting = computed(() => pendingStart.value !== null)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Passé au `<MonthGrid>` :**
|
||||||
|
```ts
|
||||||
|
rangeStart = isSelecting ? pendingStart : (modelValue?.start ?? null)
|
||||||
|
rangeEnd = isSelecting ? null : (modelValue?.end ?? null)
|
||||||
|
previewDate = isSelecting ? hoverDate : null
|
||||||
|
// + :min :max :month :year (slot)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`displayValue` :** `''` pendant la sélection (1 seul jour choisi) ; `"JJ/MM/AAAA - JJ/MM/AAAA"` si plage complète ; `''` sinon. **`syncTo`** = `modelValue?.start ?? null`.
|
||||||
|
|
||||||
|
**Machine à états :**
|
||||||
|
```
|
||||||
|
onSelectDay(iso):
|
||||||
|
si pendingStart === null: # 1er clic (ou reset après plage complète)
|
||||||
|
pendingStart = iso ; hoverDate = null
|
||||||
|
sinon: # 2e clic → complète
|
||||||
|
{ start, end } = normalizeRange(pendingStart, iso) # auto-inversion
|
||||||
|
emit('update:modelValue', { start, end })
|
||||||
|
pendingStart = null ; hoverDate = null
|
||||||
|
close() # ferme le popover (slot)
|
||||||
|
|
||||||
|
onHover(iso): # émis par MonthGrid
|
||||||
|
si isSelecting: hoverDate = iso # preview seulement pendant la sélection
|
||||||
|
|
||||||
|
onClose(): # CalendarField émet 'close'
|
||||||
|
pendingStart = null ; hoverDate = null # annule la sélection en cours, modelValue inchangé
|
||||||
|
|
||||||
|
onClear(): # CalendarField émet 'clear'
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
pendingStart = null ; hoverDate = null
|
||||||
|
```
|
||||||
|
|
||||||
|
- **3e clic** (plage complète) : `pendingStart===null` → nouveau `start`, ancienne plage masquée pendant la sélection (`rangeEnd=null`), remplacée à la complétion.
|
||||||
|
- **min/max** : `MonthGrid` désactive les jours hors bornes → les 2 clics sont contraints.
|
||||||
|
- **modelValue invalide** (start/end mal formés) : traité comme `null` + warning dev.
|
||||||
|
|
||||||
|
## Refacto `Date.vue`
|
||||||
|
|
||||||
|
API publique **inchangée**. Devient une enveloppe (~80 lignes) :
|
||||||
|
```vue
|
||||||
|
<CalendarField :display-value="displayValue" :sync-to="modelValue ?? null" ...props
|
||||||
|
@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>
|
||||||
|
```
|
||||||
|
`displayValue = formatIsoToDisplay(modelValue)`. Watch modelValue invalide → warning dev (conservé). Mode simple : pas de `@close` (rien à annuler), pas de `@hover`.
|
||||||
|
|
||||||
|
**Les 21 tests de `Date.test.ts` doivent passer sans modification** : tous les `data-test` sont rendus par `CalendarField`/`MonthGrid`, donc présents dans le DOM monté de `Date`. C'est le filet de sécurité du refacto.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### `dateRange.test.ts` (~12)
|
||||||
|
- `normalizeRange` : ordonné, inversé, égal
|
||||||
|
- `resolveRangeBounds` : pas de start → null ; start seul → `{lo,hi}=start` ; start+end ordonné ; start+preview ; preview avant start (inversion) ; end prioritaire sur preview
|
||||||
|
- `dayRangeRole` : none (pas de bornes / hors plage), single (lo===hi), start, end, in-range
|
||||||
|
|
||||||
|
### `useCalendarView.test.ts` (~8, fake timers)
|
||||||
|
- mois/année initiaux = aujourd'hui ; `goToNext`/`goToPrev` vue jours (+ roulement déc↔jan avec année) ; `goToNext`/`goToPrev` vue mois (année ±1) ; `selectMonth` ; `syncToIso` valide / null
|
||||||
|
|
||||||
|
### `Date.test.ts`
|
||||||
|
Inchangé — doit rester vert (filet du refacto).
|
||||||
|
|
||||||
|
### `DateRange.test.ts` (~18)
|
||||||
|
- Rendu : label, icône, `"19/05/2026 - 25/05/2026"` si modelValue, champ vide sinon
|
||||||
|
- Ouverture popover, vue sur le mois du `start`
|
||||||
|
- 1er clic → pas d'émission ; 2e clic → émet `{start,end}` + ferme
|
||||||
|
- 2e clic avant le 1er → auto-inversion (start ≤ end)
|
||||||
|
- Même jour ×2 → `{start:x, end:x}`
|
||||||
|
- 3e clic → repart sur un nouveau start (pas d'émission avant le 2e)
|
||||||
|
- Hover pendant sélection → `data-range-role="in-range"` sur jours intermédiaires ; pas de preview hors sélection
|
||||||
|
- Rôles : `start`/`end`/`in-range` corrects via `data-range-role`
|
||||||
|
- Clic dehors pendant sélection → annulation, `modelValue` inchangé
|
||||||
|
- `clear` → émet `null`
|
||||||
|
- min/max → jours hors bornes non cliquables
|
||||||
|
- a11y : `aria-invalid` sur error
|
||||||
|
|
||||||
|
### Story `dateRange.story.vue`
|
||||||
|
Default vide, plage initiale, min/max, états (disabled/readonly/error/success), non-clearable.
|
||||||
|
|
||||||
|
### Playground `.playground/pages/composant/date/dateRange.vue`
|
||||||
|
`<MalioDateRange>` standalone + affichage `start → end`, boutons set/reset, cas borné.
|
||||||
|
|
||||||
|
## Découpage d'implémentation
|
||||||
|
|
||||||
|
1. Helpers purs `dateRange.ts` + tests
|
||||||
|
2. Composable `useCalendarView.ts` + tests
|
||||||
|
3. Shell `CalendarField.vue` (extraction depuis `Date.vue`)
|
||||||
|
4. Refacto `Date.vue` en enveloppe → `Date.test.ts` doit rester vert
|
||||||
|
5. Extension `MonthGrid.vue` (range + hover + data-range-role)
|
||||||
|
6. `DateRange.vue` + `DateRange.test.ts`
|
||||||
|
7. Story + playground
|
||||||
168
docs/superpowers/specs/2026-05-20-dateweek-design.md
Normal file
168
docs/superpowers/specs/2026-05-20-dateweek-design.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# MalioDateWeek — Design Spec
|
||||||
|
|
||||||
|
Composant de sélection d'une **semaine ISO complète** (lundi→dimanche) via le shell calendrier partagé. Troisième brique de la famille temporelle.
|
||||||
|
|
||||||
|
**Ticket :** MUI-33 (suite)
|
||||||
|
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
|
||||||
|
**Specs liées :** `2026-05-19-datepicker-design.md`, `2026-05-20-daterange-design.md`
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
Sélection d'une semaine entière en **un clic** (sur n'importe quel jour OU le numéro de semaine). Visuellement : la ligne de la semaine se surligne en pilule `bg-m-primary-light` (lundi arrondi gauche, dimanche arrondi droit), exactement comme une plage `DateRange` figée lun→dim. Survol = aperçu de la semaine. La cellule n° de la semaine sélectionnée passe en `bg-m-primary` (repère).
|
||||||
|
|
||||||
|
**Inclus :** clic jour/n° → semaine, hover de semaine, surlignage pilule, repère n° semaine, bornes `min`/`max` (semaine sélectionnable si elle chevauche), effacement, vue mois conservée.
|
||||||
|
|
||||||
|
**Reporté :** deux mois, saisie/navigation clavier.
|
||||||
|
|
||||||
|
## Donnée retournée
|
||||||
|
|
||||||
|
`modelValue: string | null` au format **ISO 8601 semaine `YYYY-Www`** (ex. `"2026-W21"`), comme l'`<input type="week">` natif. L'année est l'**année ISO de numérotation** (peut différer de l'année calendaire aux bords d'année). Affichage humain dans le champ : `"Semaine 21 (18/05 → 24/05/2026)"` (le `modelValue` reste `2026-W21`).
|
||||||
|
|
||||||
|
## Architecture (Approche 1 — réutilisation du rendu plage)
|
||||||
|
|
||||||
|
Une semaine sélectionnée **est** une plage lundi→dimanche : on réutilise le rendu pilule de `MonthGrid` (mode plage) en passant les bornes de la semaine active. Les events `select`/`hover` (jour) sont réutilisés ; l'enveloppe `DateWeek` mappe jour → semaine.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/components/malio/date/
|
||||||
|
DateWeek.vue # NOUVEAU — enveloppe
|
||||||
|
DateWeek.test.ts # nouveau
|
||||||
|
internal/
|
||||||
|
MonthGrid.vue # étendu : interactiveWeekNumber + markedWeekStart (additifs)
|
||||||
|
CalendarField.vue # inchangé (shell réutilisé)
|
||||||
|
CalendarHeader.vue # inchangé
|
||||||
|
MonthPicker.vue # inchangé
|
||||||
|
composables/
|
||||||
|
dateWeek.ts # NOUVEAU — helpers semaine ISO (purs)
|
||||||
|
dateWeek.test.ts # nouveau
|
||||||
|
dateRange.ts # inchangé (rendu pilule réutilisé)
|
||||||
|
dateFormat.ts # inchangé
|
||||||
|
useCalendarView.ts # inchangé
|
||||||
|
useCalendarPopover.ts # inchangé
|
||||||
|
useMonthMatrix.ts # inchangé
|
||||||
|
app/story/date/dateWeek.story.vue
|
||||||
|
.playground/pages/composant/date/dateWeek.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
Flux :
|
||||||
|
|
||||||
|
```
|
||||||
|
DateWeek.vue (enveloppe)
|
||||||
|
├─ état : hoverWeekStart (lundi de la semaine survolée)
|
||||||
|
├─ validWeek = isValidIsoWeek(modelValue) ? { monday: isoWeekToMonday(modelValue) } : null
|
||||||
|
├─ activeMonday = hoverWeekStart ?? validWeek.monday → activeSunday = sundayOf(activeMonday)
|
||||||
|
├─ displayValue = formatWeekDisplay(modelValue)
|
||||||
|
└─ <CalendarField :display-value :sync-to=validWeek.monday @clear @close>
|
||||||
|
└─ <MonthGrid
|
||||||
|
:range-start=activeMonday :range-end=activeSunday ← pilule lun→dim réutilisée
|
||||||
|
:marked-week-start=validWeek.monday ← repère n° semaine
|
||||||
|
interactive-week-number ← n° semaine cliquable/hoverable
|
||||||
|
:min :max
|
||||||
|
@select="(iso)=>onSelect(iso, close)" @hover="onHover" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## `dateWeek.ts` (helpers purs)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function mondayOf(iso: string): string // "2026-05-20" → "2026-05-18"
|
||||||
|
function sundayOf(iso: string): string // "2026-05-20" → "2026-05-24"
|
||||||
|
function toIsoWeek(iso: string): string // "2026-05-20" → "2026-W21" (année ISO + n° semaine)
|
||||||
|
function isoWeekToMonday(week: string): string | null // "2026-W21" → "2026-05-18" ; invalide → null
|
||||||
|
function isValidIsoWeek(week: string): boolean // "2026-W21" → true ; "2026-W54"/"2026-21" → false
|
||||||
|
function formatWeekDisplay(week: string): string // "2026-W21" → "Semaine 21 (18/05 → 24/05/2026)" ; invalide → ""
|
||||||
|
```
|
||||||
|
|
||||||
|
- Algo ISO 8601 : jeudi de la semaine pour l'année de numérotation ; lundi de la semaine contenant le 4 janvier = semaine 1.
|
||||||
|
- `formatWeekDisplay` : `Semaine {n° sans zéro} ({JJ/MM lundi} → {JJ/MM/AAAA dimanche})`, réutilise `formatIsoToDisplay`.
|
||||||
|
- Cas pièges testés : `2025-12-31` → `2026-W01`, `2027-01-01` → `2026-W53`, `2026-01-01` → `2026-W01`.
|
||||||
|
|
||||||
|
## `MonthGrid.vue` — ajouts additifs
|
||||||
|
|
||||||
|
Nouvelles props optionnelles (n'altèrent pas les modes simple/plage) :
|
||||||
|
```ts
|
||||||
|
interactiveWeekNumber?: boolean // défaut false
|
||||||
|
markedWeekStart?: string | null // défaut null — lundi de la semaine repère
|
||||||
|
```
|
||||||
|
|
||||||
|
Quand `interactiveWeekNumber === true` :
|
||||||
|
- La cellule n° de semaine devient un `<button>` : `@click` émet `select(week.days[0].isoDate)`, `@mouseenter` émet `hover(week.days[0].isoDate)`. `:disabled` si `!weekSelectable`, où `weekSelectable = week.days.some(d => inRange(d.isoDate))`. `cursor-pointer` si sélectionnable.
|
||||||
|
- Repère : si `week.days[0].isoDate === markedWeekStart`, la cellule n° passe en `bg-m-primary text-white` (au lieu de `bg-m-primary-light`).
|
||||||
|
|
||||||
|
Toujours (inoffensif hors mode semaine) : la cellule n° porte `:data-week-start="week.days[0].isoDate"` et `:data-marked="week.days[0].isoDate === markedWeekStart"`.
|
||||||
|
|
||||||
|
Inchangé : rendu pilule des jours piloté par `rangeStart`/`rangeEnd` ; events `select`/`hover` jour ; quand `interactiveWeekNumber` est `false`, la cellule n° reste un `<div>` non cliquable (aucune régression `Date`/`DateRange`).
|
||||||
|
|
||||||
|
## `DateWeek.vue` (enveloppe)
|
||||||
|
|
||||||
|
**Props :** identiques à `Date` sauf `modelValue?: string | null` (`YYYY-Www`).
|
||||||
|
**Emit :** `update:modelValue` → `string | null`.
|
||||||
|
|
||||||
|
**État :**
|
||||||
|
```ts
|
||||||
|
hoverWeekStart = ref<string | null>(null)
|
||||||
|
const validWeek = computed(() =>
|
||||||
|
(props.modelValue && isValidIsoWeek(props.modelValue))
|
||||||
|
? {monday: isoWeekToMonday(props.modelValue) as string}
|
||||||
|
: null)
|
||||||
|
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
|
||||||
|
const activeSunday = computed(() => activeMonday.value ? sundayOf(activeMonday.value) : null)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Passé à `MonthGrid` :** `range-start=activeMonday`, `range-end=activeSunday`, `marked-week-start=validWeek?.monday ?? null`, `interactive-week-number`, `min`, `max`, month/year du slot.
|
||||||
|
|
||||||
|
**`displayValue`** = `validWeek ? formatWeekDisplay(modelValue) : ''`. **`syncTo`** = `validWeek?.monday ?? null`.
|
||||||
|
|
||||||
|
**Comportement (1 clic) :**
|
||||||
|
```
|
||||||
|
onSelect(iso, close): # jour OU n° de semaine (= lundi)
|
||||||
|
emit('update:modelValue', toIsoWeek(iso))
|
||||||
|
hoverWeekStart = null
|
||||||
|
close()
|
||||||
|
|
||||||
|
onHover(iso): # jour/n° survolé ; null au mouseleave
|
||||||
|
hoverWeekStart = iso ? mondayOf(iso) : null
|
||||||
|
|
||||||
|
onClose(): hoverWeekStart = null
|
||||||
|
onClear(): emit('update:modelValue', null) ; hoverWeekStart = null
|
||||||
|
```
|
||||||
|
|
||||||
|
- Survol → toute la ligne en pilule via `activeMonday`.
|
||||||
|
- Sélection en un clic → `YYYY-Www` + fermeture.
|
||||||
|
- Repère de la semaine committée conservé pendant le survol d'une autre.
|
||||||
|
- `modelValue` invalide → traité comme `null` + warning dev.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### `dateWeek.test.ts` (~14)
|
||||||
|
- `mondayOf`/`sundayOf` : mercredi, lundi (idempotent), dimanche
|
||||||
|
- `toIsoWeek` : nominal + bords d'année (`2025-12-31`→`2026-W01`, `2027-01-01`→`2026-W53`, `2026-01-01`→`2026-W01`)
|
||||||
|
- `isoWeekToMonday` : `2026-W21`→`2026-05-18` ; round-trip ; invalide → null
|
||||||
|
- `isValidIsoWeek` : valide / `W00` / `W54` / format faux
|
||||||
|
- `formatWeekDisplay` : `2026-W21`→`"Semaine 21 (18/05 → 24/05/2026)"` ; invalide → `""`
|
||||||
|
|
||||||
|
### `DateWeek.test.ts` (~14, `setSystemTime(2026-05-19)`)
|
||||||
|
- Rendu label/icône, affichage `"Semaine ..."` si modelValue, champ vide sinon
|
||||||
|
- Ouverture sur le mois de la semaine du modelValue
|
||||||
|
- Clic d'un jour → émet le `YYYY-Www` de sa semaine + ferme
|
||||||
|
- Clic du n° de semaine (`[data-test="week-number"][data-week-start="..."]`) → émet + ferme
|
||||||
|
- Hover d'un jour → `data-range-role` start/in-range/end sur les 7 jours de la ligne ; autre ligne `none`
|
||||||
|
- Hover du n° de semaine → même surlignage
|
||||||
|
- Semaine committée → roles corrects + `data-marked="true"` sur la cellule n°
|
||||||
|
- `clear` → émet `null`
|
||||||
|
- min/max : semaine hors bornes n° désactivé + jours non cliquables ; semaine qui chevauche reste sélectionnable
|
||||||
|
- `disabled`/`readonly` → pas d'ouverture
|
||||||
|
- a11y : `aria-invalid` sur error
|
||||||
|
|
||||||
|
`Date.test.ts` / `DateRange.test.ts` restent verts (props additives).
|
||||||
|
|
||||||
|
### Story `dateWeek.story.vue`
|
||||||
|
Default vide, semaine initiale, min/max, états (disabled/readonly/error/success), non-clearable.
|
||||||
|
|
||||||
|
### Playground `.playground/pages/composant/date/dateWeek.vue`
|
||||||
|
Comparatif Large (480px) / ERP (396px), affichage `modelValue` + bornes du champ, boutons set/reset, cas borné.
|
||||||
|
|
||||||
|
## Découpage d'implémentation
|
||||||
|
|
||||||
|
1. `dateWeek.ts` (purs) + tests
|
||||||
|
2. Extension `MonthGrid.vue` (interactiveWeekNumber + markedWeekStart + data attrs)
|
||||||
|
3. `DateWeek.vue` + `DateWeek.test.ts`
|
||||||
|
4. Story + playground
|
||||||
124
docs/superpowers/specs/2026-05-21-refonte-playground-design.md
Normal file
124
docs/superpowers/specs/2026-05-21-refonte-playground-design.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Refonte du système de playground
|
||||||
|
|
||||||
|
Date : 2026-05-21
|
||||||
|
Branche : `feature/MUI-34-revoir-le-systeme-de-playground`
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le playground actuel (`.playground/`) est une **fausse SPA** : une unique page
|
||||||
|
`index.vue` contient une sidebar codée à la main et charge dynamiquement les
|
||||||
|
pages de démo via `import.meta.glob` + `<component :is>`. Il n'y a ni vrai
|
||||||
|
routage, ni layout, et la sidebar ne réutilise pas le composant `MalioSidebar`
|
||||||
|
de la bibliothèque.
|
||||||
|
|
||||||
|
Les pages de démo existent déjà dans `.playground/pages/composant/<catégorie>/<nom>.vue`
|
||||||
|
mais ne sont pas exploitées comme de vraies routes.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Refondre le playground autour du **vrai routage fichier de Nuxt** et d'un
|
||||||
|
**layout par défaut** qui embarque le composant `MalioSidebar` de production
|
||||||
|
(dogfooding du composant).
|
||||||
|
|
||||||
|
## Décisions validées
|
||||||
|
|
||||||
|
| Sujet | Décision |
|
||||||
|
|-------|----------|
|
||||||
|
| Navigation | Vrai routage Nuxt + layout dédié |
|
||||||
|
| Construction de la sidebar | Liste manuelle centralisée |
|
||||||
|
| Habillage du layout | Sidebar + contenu seul (épuré, chaque page gère son titre) |
|
||||||
|
| Page d'accueil | Page de bienvenue simple |
|
||||||
|
| Surbrillance lien actif | Hors périmètre (MalioSidebar inchangé) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Config de navigation centralisée
|
||||||
|
|
||||||
|
Nouveau fichier `.playground/playground.nav.ts` exportant un tableau
|
||||||
|
`SidebarSection[]` (type exporté par `MalioSidebar`). Les sections sont
|
||||||
|
définies manuellement ; chaque item est un `{ label, to }` pointant vers la
|
||||||
|
route de démo.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { SidebarSection } from '../app/components/malio/sidebar/Sidebar.vue'
|
||||||
|
|
||||||
|
export const navSections: SidebarSection[] = [
|
||||||
|
{
|
||||||
|
label: 'BOUTONS',
|
||||||
|
icon: 'mdi:gesture-tap-button',
|
||||||
|
items: [
|
||||||
|
{ label: 'Button', to: '/composant/button/button' },
|
||||||
|
{ label: 'Button Icon', to: '/composant/button/buttonIcon' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// … autres sections (Champs, Sélections, Navigation, Données, Divers)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Les routes correspondent exactement aux fichiers existants dans
|
||||||
|
`.playground/pages/composant/`. Liste à couvrir :
|
||||||
|
|
||||||
|
- **button/** : `button`, `buttonIcon`
|
||||||
|
- **checkbox/** : `checkbox`
|
||||||
|
- **radio/** : `radioButton`
|
||||||
|
- **input/** : `inputText`, `inputNumber`, `inputAmount`, `inputEmail`,
|
||||||
|
`inputPassword`, `inputPhone`, `inputTextArea`, `inputAutocomplete`,
|
||||||
|
`inputUpload`, `inputRichText`
|
||||||
|
- **select/** : `select`, `selectCheckbox`
|
||||||
|
- **time/** : `time`
|
||||||
|
- **tab/** : `tabList`
|
||||||
|
- **sidebar/** : `sidebar`
|
||||||
|
- **drawer/** : `drawer`
|
||||||
|
- **datatable/** : `datatable`
|
||||||
|
- **site/** : `siteSelector`
|
||||||
|
- **form/** : `client`
|
||||||
|
|
||||||
|
Le regroupement en sections et les libellés affichés sont au choix du
|
||||||
|
développeur (manuel). Les routes, elles, sont imposées par les fichiers.
|
||||||
|
|
||||||
|
### 2. Layout par défaut
|
||||||
|
|
||||||
|
Nouveau fichier `.playground/layouts/default.vue` :
|
||||||
|
|
||||||
|
- Conteneur `flex` pleine hauteur (`h-screen`).
|
||||||
|
- `<MalioSidebar :sections="navSections">` à gauche.
|
||||||
|
- Slots `logo` / `logo-collapsed` : logos `LOGO_MALIO.png` /
|
||||||
|
`LOGO_MALIO_COLLAPSED.png` (servis depuis le `public/` du layer),
|
||||||
|
enveloppés dans un `<NuxtLink to="/">` pour revenir à l'accueil.
|
||||||
|
- Collapse géré en interne par le composant (mode non-contrôlé).
|
||||||
|
- `<main class="flex-1 overflow-y-auto p-6"><slot /></main>` à droite.
|
||||||
|
|
||||||
|
Le layout `default` s'applique automatiquement à toutes les pages du
|
||||||
|
playground — aucune page n'a besoin de `definePageMeta({ layout })`.
|
||||||
|
|
||||||
|
### 3. Page d'accueil
|
||||||
|
|
||||||
|
`.playground/pages/index.vue` réécrite en page de bienvenue simple :
|
||||||
|
titre + invitation à choisir un composant dans la sidebar. Toute la logique
|
||||||
|
de glob / chargement dynamique / sidebar maison est supprimée.
|
||||||
|
|
||||||
|
### 4. Pages de démo
|
||||||
|
|
||||||
|
**Inchangées.** Elles sont déjà des routes `/composant/<cat>/<nom>` et
|
||||||
|
hériteront automatiquement du layout `default`.
|
||||||
|
|
||||||
|
### 5. Mise à jour du skill `creating-malio-component`
|
||||||
|
|
||||||
|
Ajouter une étape au skill : lors de la création d'un nouveau composant,
|
||||||
|
ajouter son entrée dans `.playground/playground.nav.ts` pour qu'il apparaisse
|
||||||
|
dans la sidebar.
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
- Surbrillance de l'item actif dans `MalioSidebar` (ticket dédié si besoin).
|
||||||
|
- Toute autre évolution de `MalioSidebar`.
|
||||||
|
- Refonte du contenu des pages de démo existantes.
|
||||||
|
|
||||||
|
## Critères de réussite
|
||||||
|
|
||||||
|
- `npm run dev` lance le playground avec `MalioSidebar` dans un layout.
|
||||||
|
- Cliquer sur un item de la sidebar change l'URL et affiche la bonne démo.
|
||||||
|
- Le logo ramène à l'accueil ; l'accueil affiche le message de bienvenue.
|
||||||
|
- Plus aucune trace de la sidebar maison ni du chargement dynamique dans
|
||||||
|
`index.vue`.
|
||||||
|
- `npm run lint` et `npm run test` passent.
|
||||||
@@ -12,6 +12,7 @@ export default defineNuxtConfig({
|
|||||||
path: join(dir, 'app/components/malio'),
|
path: join(dir, 'app/components/malio'),
|
||||||
prefix: 'Malio',
|
prefix: 'Malio',
|
||||||
pathPrefix: false,
|
pathPrefix: false,
|
||||||
|
extensions: ['vue'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default {
|
|||||||
colors: {
|
colors: {
|
||||||
m: {
|
m: {
|
||||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||||
|
'primary-light': 'rgb(var(--m-primary-light) / <alpha-value>)',
|
||||||
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
||||||
border: 'rgb(var(--m-border) / <alpha-value>)',
|
border: 'rgb(var(--m-border) / <alpha-value>)',
|
||||||
text: 'rgb(var(--m-text) / <alpha-value>)',
|
text: 'rgb(var(--m-text) / <alpha-value>)',
|
||||||
|
|||||||
Reference in New Issue
Block a user