Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7b2fb720 | |||
| 7ca5c5f4c5 | |||
| f3a18ace1d | |||
| d9023a0ddc | |||
| c646df9fe3 | |||
| 7fc072ad08 | |||
| f30619a497 | |||
| d7bf038fdd | |||
| 2059556ffe | |||
| a95cf8cdfb | |||
| ba2ecb5768 | |||
| 87940481d6 | |||
| 66fbbf8abe | |||
| 8de950c402 | |||
| 1a14629404 | |||
| 6720e3062a | |||
| e38255341d | |||
| 1bbe77d391 | |||
| ccc8410da0 |
@@ -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>
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<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>
|
|
||||||
<MalioCheckbox
|
|
||||||
v-model="simpleValue"
|
|
||||||
label="Accepter les conditions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Coche par default</h2>
|
|
||||||
<MalioCheckbox
|
|
||||||
v-model="checkedValue"
|
|
||||||
label="Recevoir la newsletter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Hint</h2>
|
|
||||||
<MalioCheckbox
|
|
||||||
v-model="hintValue"
|
|
||||||
label="J'accepte le traitement des donnees"
|
|
||||||
hint="Vous pouvez retirer votre consentement a tout moment."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
|
||||||
<MalioCheckbox
|
|
||||||
:model-value="false"
|
|
||||||
label="Accepter les CGU"
|
|
||||||
error="Ce champ est obligatoire."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
|
||||||
<MalioCheckbox
|
|
||||||
:model-value="true"
|
|
||||||
label="Adresse vérifiée"
|
|
||||||
success="Choix valide."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Disabled et Readonly</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<MalioCheckbox
|
|
||||||
:model-value="true"
|
|
||||||
label="Option désactivée"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<MalioCheckbox
|
|
||||||
:model-value="true"
|
|
||||||
label="Option readonly"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Plusieurs checkbox</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<MalioCheckbox
|
|
||||||
label="Option 1"
|
|
||||||
/>
|
|
||||||
<MalioCheckbox
|
|
||||||
label="Option 2"
|
|
||||||
/>
|
|
||||||
<MalioCheckbox
|
|
||||||
label="Option 3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Plusieurs checkbox avec v-for</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-for="option in options"
|
|
||||||
:key="option"
|
|
||||||
:label="option"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref} from 'vue'
|
|
||||||
import MalioCheckbox from '../../../app/components/malio/Checkbox.vue'
|
|
||||||
const simpleValue = ref(false)
|
|
||||||
const checkedValue = ref(true)
|
|
||||||
const hintValue = ref(false)
|
|
||||||
const options = [
|
|
||||||
'Option A',
|
|
||||||
'Option B',
|
|
||||||
'Option C',
|
|
||||||
'Option D',
|
|
||||||
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
92
.playground/pages/composant/datatable/datatable.vue
Normal file
92
.playground/pages/composant/datatable/datatable.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const filtreNom = ref('')
|
||||||
|
const filtreVille = ref<string | number | null>(null)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'prenom', label: 'Prénom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
{ key: 'montant', label: 'Montant' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const allItems = [
|
||||||
|
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
|
||||||
|
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
|
||||||
|
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
|
||||||
|
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
|
||||||
|
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
|
||||||
|
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
|
||||||
|
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
|
||||||
|
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
|
||||||
|
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
|
||||||
|
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
|
||||||
|
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
|
||||||
|
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
|
||||||
|
{ id: 13, nom: 'Roux', prenom: 'Hugo', ville: 'Paris', montant: 2800 },
|
||||||
|
{ id: 14, nom: 'David', prenom: 'Léa', ville: 'Lyon', montant: 670 },
|
||||||
|
{ id: 15, nom: 'Bertrand', prenom: 'Lucas', ville: 'Marseille', montant: 1950 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
return allItems.filter((item) => {
|
||||||
|
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
|
||||||
|
if (filtreVille.value && item.ville !== filtreVille.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage.value
|
||||||
|
return filteredItems.value.slice(start, start + perPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onRowClick(item: Record<string, unknown>) {
|
||||||
|
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">DataTable avec filtres et pagination</h2>
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="paginatedItems"
|
||||||
|
:total-items="filteredItems.length"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<template #header-nom>
|
||||||
|
<input
|
||||||
|
v-model="filtreNom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom"
|
||||||
|
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 outline-none text-[20px]"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header-ville>
|
||||||
|
<select
|
||||||
|
:value="filtreVille ?? ''"
|
||||||
|
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-[20px] outline-none"
|
||||||
|
@change="filtreVille = ($event.target as HTMLSelectElement).value || null"
|
||||||
|
>
|
||||||
|
<option value="">Ville</option>
|
||||||
|
<option value="Paris">Paris</option>
|
||||||
|
<option value="Lyon">Lyon</option>
|
||||||
|
<option value="Marseille">Marseille</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-montant="{ item }">
|
||||||
|
<strong>{{ item.montant }} €</strong>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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>
|
||||||
68
.playground/pages/composant/date/datetime.vue
Normal file
68
.playground/pages/composant/date/datetime.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 p-4">
|
||||||
|
<h1 class="text-2xl font-bold">MalioDateTime</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-start gap-10">
|
||||||
|
<div class="w-[480px] space-y-3">
|
||||||
|
<h2 class="font-semibold">Large (480px)</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="value"
|
||||||
|
label="Date et heure du rendez-vous"
|
||||||
|
hint="Choisis un jour puis une heure"
|
||||||
|
/>
|
||||||
|
<div class="rounded border p-3 text-sm">
|
||||||
|
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||||
|
@click="value = '2026-12-25T09:30:00'"
|
||||||
|
>
|
||||||
|
Forcer le 25/12/2026 09:30
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-3 py-1.5"
|
||||||
|
@click="value = null"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-[396px] space-y-3">
|
||||||
|
<h2 class="font-semibold">ERP (396px)</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="erpValue"
|
||||||
|
label="Date et heure du rendez-vous"
|
||||||
|
hint="Largeur cible ERP"
|
||||||
|
/>
|
||||||
|
<div class="rounded border p-3 text-sm">
|
||||||
|
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
|
||||||
|
</div>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="bounded"
|
||||||
|
label="Créneau borné"
|
||||||
|
:min="todayIso"
|
||||||
|
:max="maxIso"
|
||||||
|
hint="Entre aujourd'hui et +30 jours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||||
|
const now = new Date()
|
||||||
|
const todayIso = toIso(now)
|
||||||
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||||
|
|
||||||
|
const value = ref<string | null>(null)
|
||||||
|
const erpValue = ref<string | null>(null)
|
||||||
|
const bounded = ref<string | null>('2026-05-20T14:30:00')
|
||||||
|
</script>
|
||||||
@@ -1,48 +1,88 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const drawerDefault = ref(false)
|
const drawerRight = ref(false)
|
||||||
const drawerNoClose = ref(false)
|
const drawerLeft = ref(false)
|
||||||
const drawerCustomWidth = ref(false)
|
const drawerForm = ref(false)
|
||||||
const drawerWithForm = ref(false)
|
const drawerFixedFooter = ref(false)
|
||||||
|
const drawerNoDismiss = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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">
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Drawer simple</h2>
|
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
|
||||||
<MalioButton label="Ouvrir le drawer" @click="drawerDefault = true" />
|
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
|
||||||
<MalioDrawer v-model="drawerDefault" title="Titre du drawer">
|
<MalioDrawer v-model="drawerRight">
|
||||||
<p class="text-m-text">Contenu du drawer. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Détails</h2>
|
||||||
|
</template>
|
||||||
|
<p class="text-m-text">Contenu du drawer. Échap, clic backdrop et croix le ferment.</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Sans bouton fermer</h2>
|
<h2 class="mb-6 text-xl font-bold">Drawer gauche</h2>
|
||||||
<MalioButton label="Ouvrir le drawer" variant="secondary" @click="drawerNoClose = true" />
|
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
|
||||||
<MalioDrawer v-model="drawerNoClose" title="Sans croix" :show-close="false">
|
<MalioDrawer v-model="drawerLeft" side="left">
|
||||||
<p class="text-m-text">Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
|
||||||
|
</template>
|
||||||
|
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Largeur personnalisée</h2>
|
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2>
|
||||||
<MalioButton label="Ouvrir le drawer large" variant="tertiary" @click="drawerCustomWidth = true" />
|
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
||||||
<MalioDrawer v-model="drawerCustomWidth" title="Drawer large" drawer-class="max-w-2xl">
|
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
||||||
<p class="text-m-text">Ce drawer utilise une largeur personnalisée via drawerClass.</p>
|
<template #header>
|
||||||
</MalioDrawer>
|
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
||||||
</div>
|
</template>
|
||||||
|
<div class="flex flex-col gap-4 py-2">
|
||||||
<div class="rounded-lg border p-6">
|
|
||||||
<h2 class="mb-6 text-xl font-bold">Avec formulaire</h2>
|
|
||||||
<MalioButton label="Ouvrir le formulaire" variant="danger" @click="drawerWithForm = true" />
|
|
||||||
<MalioDrawer v-model="drawerWithForm" title="Formulaire">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<MalioInputText label="Nom" />
|
<MalioInputText label="Nom" />
|
||||||
<MalioInputText label="Prénom" />
|
<MalioInputText label="Prénom" />
|
||||||
<MalioInputText label="Email" />
|
<MalioInputText label="Email" />
|
||||||
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
||||||
|
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
||||||
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Avec footer fixed bottom</h2>
|
||||||
|
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" />
|
||||||
|
<MalioDrawer v-model="drawerFixedFooter">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||||
|
</template>
|
||||||
|
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu -->
|
||||||
|
<div class="flex flex-col gap-4 pb-24">
|
||||||
|
<p v-for="n in 12" :key="n" class="text-m-text">
|
||||||
|
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que le footer reste fixé en bas du viewport.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut -->
|
||||||
|
<div class="fixed bottom-0 right-0 w-full max-w-md border-t border-m-border bg-white px-5 py-4">
|
||||||
|
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
|
||||||
|
<MalioButton label="Ouvrir" variant="danger" @click="drawerNoDismiss = true" />
|
||||||
|
<MalioDrawer v-model="drawerNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
|
||||||
|
</template>
|
||||||
|
<p class="text-m-text">Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
322
.playground/pages/composant/form/client.vue
Normal file
322
.playground/pages/composant/form/client.vue
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-[1348px]">
|
||||||
|
<div class="flex gap-3 mt-[46px]">
|
||||||
|
<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
|
||||||
|
label="Nom du client (Entreprise)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
label="Nom du contact principal"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
label="Prénom du contact principal"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="multiselectValue"
|
||||||
|
label="Catégorie"
|
||||||
|
:options="[
|
||||||
|
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||||
|
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-for="(_, index) in phones"
|
||||||
|
:key="index"
|
||||||
|
v-model="phones[index]"
|
||||||
|
label="Téléphone"
|
||||||
|
add-icon-name="mdi:plus"
|
||||||
|
:addable="phones.length === 1"
|
||||||
|
@add="addPhoneInput"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
label="Email"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="distributeur"
|
||||||
|
value=""
|
||||||
|
label="Distributeur / Courtier"
|
||||||
|
:options="[
|
||||||
|
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
|
||||||
|
{label: 'Distributeur', value: 'Distributeur'},
|
||||||
|
{label: 'Courtier', value: 'Courtier'},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="nomCourtier"
|
||||||
|
value=""
|
||||||
|
label="Nom du courtier"
|
||||||
|
:options="[
|
||||||
|
{label: 'Nom 1', value: 'Nom 1'}
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="nomDistributeur"
|
||||||
|
value=""
|
||||||
|
label="Nom du distributeur"
|
||||||
|
:options="[
|
||||||
|
{label: 'Nom 1', value: 'Nom 1'}
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton label="Valider" variant="primary"/>
|
||||||
|
</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 class="mt-12 flex justify-center">
|
||||||
|
<MalioButton label="Valider" variant="primary"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #adresses>
|
||||||
|
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
aria-label="Supprimer l'adresse"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
@click="onDeleteAdresse"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox label="Prospect" groupClass="self-center"/>
|
||||||
|
<MalioCheckbox label="Adresse de livraison" groupClass="self-center"/>
|
||||||
|
<MalioCheckbox label="Facturation" groupClass="self-center"/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="multiselectValue"
|
||||||
|
label="Catégorie"
|
||||||
|
:options="[
|
||||||
|
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||||
|
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
label="Pays"
|
||||||
|
v-model="pays"
|
||||||
|
:options="[
|
||||||
|
{label: 'France', value: 'France'},
|
||||||
|
{label: 'Espagne', value: 'Espagne'}
|
||||||
|
]"/>
|
||||||
|
<MalioInputText v-model="codePostal" label="Code postal" />
|
||||||
|
<MalioSelect
|
||||||
|
v-model="ville"
|
||||||
|
label="Ville"
|
||||||
|
:options="villeOptions"
|
||||||
|
:no-options-text="villeNoOptionsText"
|
||||||
|
/>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="adresse"
|
||||||
|
label="Adresse"
|
||||||
|
:options="adresseOptions"
|
||||||
|
: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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioTabList>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, watch} from 'vue'
|
||||||
|
import MalioDate from "../../../../app/components/malio/date/Date.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 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>
|
||||||
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>
|
||||||
91
.playground/pages/composant/input/inputRichText.vue
Normal file
91
.playground/pages/composant/input/inputRichText.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 p-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Note"
|
||||||
|
placeholder="Écrire ici…"
|
||||||
|
/>
|
||||||
|
<pre class="mt-3 overflow-auto rounded bg-m-bg p-2 text-xs">{{ simpleValue }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec contenu initial + hint</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Description"
|
||||||
|
hint="Tu peux mettre en forme avec la barre d'outils"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Compte-rendu"
|
||||||
|
error="Le compte-rendu doit faire au moins 20 caractères"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="successValue"
|
||||||
|
label="Compte-rendu"
|
||||||
|
success="Compte-rendu validé"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="readonlyValue"
|
||||||
|
label="Note (lecture seule)"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Disabled</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="disabledValue"
|
||||||
|
label="Note (désactivée)"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Affichage seul (editable=false)</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
:model-value="readonlyValue"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sortie HTML</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="htmlValue"
|
||||||
|
label="Article"
|
||||||
|
output-format="html"
|
||||||
|
min-height="200px"
|
||||||
|
placeholder="Tape ici, la sortie sera en HTML…"
|
||||||
|
/>
|
||||||
|
<pre class="mt-3 overflow-auto rounded bg-m-bg p-2 text-xs">{{ htmlValue }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioInputRichText from '../../../../app/components/malio/input/InputRichText.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const hintValue = ref('## Titre\n\nUn paragraphe avec du **gras**, de l\'*italique* et un [lien](https://malio.fr).')
|
||||||
|
const errorValue = ref('Trop court')
|
||||||
|
const successValue = ref('Tout est bon de mon côté.')
|
||||||
|
const readonlyValue = ref('## Compte-rendu\n\n- Point 1\n- Point 2\n\n> Citation importante')
|
||||||
|
const disabledValue = ref('Contenu indisponible.')
|
||||||
|
const htmlValue = ref('<p>Contenu <strong>riche</strong>.</p>')
|
||||||
|
</script>
|
||||||
@@ -82,6 +82,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="shortListValue"
|
||||||
|
:options="shortOptions"
|
||||||
|
label="Civilite"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4 md:col-span-2">
|
<div class="rounded-lg border p-4 md:col-span-2">
|
||||||
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -121,6 +131,11 @@ const options = [
|
|||||||
{label: 'Portugal', value: 'pt'},
|
{label: 'Portugal', value: 'pt'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const shortOptions = [
|
||||||
|
{label: 'Monsieur', value: 'M'},
|
||||||
|
{label: 'Madame', value: 'Mme'},
|
||||||
|
]
|
||||||
|
|
||||||
const longOptions = [
|
const longOptions = [
|
||||||
...options,
|
...options,
|
||||||
{label: 'Pays-Bas', value: 'nl'},
|
{label: 'Pays-Bas', value: 'nl'},
|
||||||
@@ -144,6 +159,7 @@ const errorValue = ref<string | number | null>(null)
|
|||||||
const successValue = ref<string | number | null>('be')
|
const successValue = ref<string | number | null>('be')
|
||||||
const disabledValue = ref<string | number | null>('ca')
|
const disabledValue = ref<string | number | null>('ca')
|
||||||
const emptyValue = ref<string | number | null>(null)
|
const emptyValue = ref<string | number | null>(null)
|
||||||
|
const shortListValue = ref<string | number | null>(null)
|
||||||
const longListValue = ref<string | number | null>(null)
|
const longListValue = ref<string | number | null>(null)
|
||||||
const bottomValue = ref<string | number | null>(null)
|
const bottomValue = ref<string | number | null>(null)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
67
.playground/pages/composant/site/siteSelector.vue
Normal file
67
.playground/pages/composant/site/siteSelector.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple (3 sites) + event change</h2>
|
||||||
|
<MalioSiteSelector v-model="simpleValue" :sites="sites" @change="onSiteChange" />
|
||||||
|
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ simpleValue }}</code></p>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Dernier event <code>change</code> : <code>{{ lastChange }}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
|
||||||
|
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
|
||||||
|
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ twoValue }}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Cinq sites (largeur proportionnelle)</h2>
|
||||||
|
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
|
||||||
|
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ fiveValue }}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non contrôlé (sans v-model)</h2>
|
||||||
|
<MalioSiteSelector :sites="sites" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Largeur contrainte</h2>
|
||||||
|
<div class="w-[480px]">
|
||||||
|
<MalioSiteSelector v-model="constrainedValue" :sites="sites" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const sites = [
|
||||||
|
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
|
||||||
|
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
|
||||||
|
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sitesTwo = [
|
||||||
|
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
|
||||||
|
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sitesFive = [
|
||||||
|
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
|
||||||
|
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
|
||||||
|
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
|
||||||
|
{ id: 's4', name: 'Site 4', color: '#ec4899' },
|
||||||
|
{ id: 's5', name: 'Site 5', color: '#6366f1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const simpleValue = ref('chatellerault')
|
||||||
|
const twoValue = ref('nord')
|
||||||
|
const fiveValue = ref('s3')
|
||||||
|
const constrainedValue = ref('saint-jean')
|
||||||
|
const lastChange = ref<string>('—')
|
||||||
|
|
||||||
|
function onSiteChange(site: { id: string; name: string; color: string }) {
|
||||||
|
lastChange.value = JSON.stringify(site)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,181 +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})
|
|
||||||
})
|
|
||||||
|
|
||||||
return 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)),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|||||||
74
.playground/playground.nav.ts
Normal file
74
.playground/playground.nav.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
|
||||||
|
|
||||||
|
export const navSections: SidebarSection[] = [
|
||||||
|
{
|
||||||
|
label: 'BOUTONS',
|
||||||
|
icon: 'mdi:gesture-tap-button',
|
||||||
|
items: [
|
||||||
|
{label: 'Button', to: '/composant/button/button'},
|
||||||
|
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CHAMPS',
|
||||||
|
icon: 'mdi:form-textbox',
|
||||||
|
items: [
|
||||||
|
{label: 'Texte', to: '/composant/input/inputText'},
|
||||||
|
{label: 'Nombre', to: '/composant/input/inputNumber'},
|
||||||
|
{label: 'Montant', to: '/composant/input/inputAmount'},
|
||||||
|
{label: 'Email', to: '/composant/input/inputEmail'},
|
||||||
|
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
|
||||||
|
{label: 'Téléphone', to: '/composant/input/inputPhone'},
|
||||||
|
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
|
||||||
|
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
|
||||||
|
{label: 'Upload', to: '/composant/input/inputUpload'},
|
||||||
|
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DATES & HEURES',
|
||||||
|
icon: 'mdi:calendar-clock',
|
||||||
|
items: [
|
||||||
|
{label: 'Date', to: '/composant/date/date'},
|
||||||
|
{label: 'Plage de dates', to: '/composant/date/dateRange'},
|
||||||
|
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
||||||
|
{label: 'Date & heure', to: '/composant/date/datetime'},
|
||||||
|
{label: 'Heure', to: '/composant/time/time'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SÉ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'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -2,8 +2,26 @@
|
|||||||
"branches": ["main", "master"],
|
"branches": ["main", "master"],
|
||||||
"repositoryUrl": "https://gitea.malio.fr/MALIO-DEV/malio-layer-ui.git",
|
"repositoryUrl": "https://gitea.malio.fr/MALIO-DEV/malio-layer-ui.git",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
[
|
||||||
"@semantic-release/commit-analyzer",
|
"@semantic-release/commit-analyzer",
|
||||||
|
{
|
||||||
|
"preset": "angular",
|
||||||
|
"parserOpts": {
|
||||||
|
"headerPattern": "^(\\w+)(?:\\(([\\w$.\\-* ]+)\\))?\\s*:\\s+(.+)$",
|
||||||
|
"headerCorrespondence": ["type", "scope", "subject"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
"@semantic-release/release-notes-generator",
|
"@semantic-release/release-notes-generator",
|
||||||
|
{
|
||||||
|
"preset": "angular",
|
||||||
|
"parserOpts": {
|
||||||
|
"headerPattern": "^(\\w+)(?:\\(([\\w$.\\-* ]+)\\))?\\s*:\\s+(.+)$",
|
||||||
|
"headerCorrespondence": ["type", "scope", "subject"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"@semantic-release/npm"
|
"@semantic-release/npm"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -24,7 +24,19 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-10] Création d'un composant bouton
|
* [#MUI-10] Création d'un composant bouton
|
||||||
* [#MUI-2] Faire un MCP pour la librairie de composant
|
* [#MUI-2] Faire un MCP pour la librairie de composant
|
||||||
* [#MUI-15] Création d'un composant drawer
|
* [#MUI-15] Création d'un composant drawer
|
||||||
|
* [#MUI-22] Création d'un composant datatable
|
||||||
|
* [#MUI-27] Création d'un composant sélection de site
|
||||||
|
* 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
|
||||||
|
* [#MUI-33] Développer le composant Datepicker
|
||||||
|
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
||||||
|
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
|
||||||
|
|||||||
476
COMPONENTS.md
476
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)`
|
||||||
|
|
||||||
@@ -132,6 +287,41 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioInputRichText
|
||||||
|
|
||||||
|
Éditeur de texte riche basé sur **TipTap v3** + **StarterKit** + **tiptap-markdown** + **TextStyle/Color/Highlight**. Toolbar avec gras, italique, barré, titres H2/H3, listes, citation, code, code-block, lien, **couleur du texte**, **surlignage**, undo/redo. Sortie en HTML (par défaut) ou markdown.
|
||||||
|
|
||||||
|
> Couleurs et surlignages ne sont **pas persistés en markdown**. Pour les conserver au save/reload, utiliser `output-format="html"`.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `label` | `string` | `''` | Label affiché au-dessus de l'éditeur |
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Contenu (v-model) |
|
||||||
|
| `placeholder` | `string` | `''` | Texte affiché quand vide |
|
||||||
|
| `minHeight` | `string` | `'160px'` | Hauteur min de la zone d'édition |
|
||||||
|
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) |
|
||||||
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `outputFormat` | `'markdown' \| 'html'` | `'html'` | Format émis dans `update:modelValue` |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||||
|
| `labelClass` | `string` | `''` | Classes CSS label (twMerge) |
|
||||||
|
| `editorClass` | `string` | `''` | Classes CSS wrapper éditeur (twMerge) |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioInputRichText v-model="note" label="Note" placeholder="Écrire ici…" />
|
||||||
|
<MalioInputRichText v-model="cr" label="Compte-rendu" error="Trop court" />
|
||||||
|
<MalioInputRichText v-model="article" label="Article" min-height="240px" />
|
||||||
|
<MalioInputRichText :model-value="content" :editable="false" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioInputUpload
|
## MalioInputUpload
|
||||||
|
|
||||||
Champ d'upload de fichier.
|
Champ d'upload de fichier.
|
||||||
@@ -163,8 +353,16 @@ Liste déroulante.
|
|||||||
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options disponibles |
|
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options disponibles |
|
||||||
| `emptyOptionLabel` | `string` | `''` | Placeholder option vide |
|
| `emptyOptionLabel` | `string` | `''` | Placeholder option vide |
|
||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||||
|
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
|
||||||
|
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||||
|
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
||||||
|
| `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)
|
||||||
@@ -172,6 +370,7 @@ Liste déroulante.
|
|||||||
```vue
|
```vue
|
||||||
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
|
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
|
||||||
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
|
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
|
||||||
|
<MalioSelect v-model="civilite" label="Civilité" :options="civilites" group-class="mt-0" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -189,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)[])`
|
||||||
|
|
||||||
@@ -242,6 +442,106 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioDate
|
||||||
|
|
||||||
|
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
||||||
|
|
||||||
|
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
|
||||||
|
| `id` | `string` | `''` | Id du champ |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||||
|
| `required` | `boolean` | `false` | Requis |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
|
||||||
|
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
|
||||||
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string | null)`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioDate v-model="date" label="Date de naissance" />
|
||||||
|
<!-- date === "2026-05-20" -->
|
||||||
|
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MalioDateRange
|
||||||
|
|
||||||
|
Sélecteur de **plage de dates** (date de début → date de fin) dans un seul champ. Cliquer un premier jour démarre la plage, le second la termine ; un survol prévisualise la plage.
|
||||||
|
|
||||||
|
La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"`), ou `null`.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `modelValue` | `{ start: string; end: string } \| null` | `undefined` | Plage de dates ISO (v-model) |
|
||||||
|
| `id` | `string` | `''` | Id du champ |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||||
|
| `required` | `boolean` | `false` | Requis |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||||
|
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||||
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioDateRange v-model="periode" label="Période de séjour" />
|
||||||
|
<!-- periode === { start: "2026-05-20", end: "2026-05-27" } -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MalioDateWeek
|
||||||
|
|
||||||
|
Sélecteur de **semaine ISO** : cliquer un jour (ou un numéro de semaine) sélectionne la semaine entière.
|
||||||
|
|
||||||
|
La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2026-W21"`), ou `null`. Le champ affiche `Semaine W (JJ/MM → JJ/MM/AAAA)`.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Semaine ISO `"YYYY-Www"` (v-model) |
|
||||||
|
| `id` | `string` | `''` | Id du champ |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||||
|
| `required` | `boolean` | `false` | Requis |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||||
|
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||||
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string | null)`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioDateWeek v-model="semaine" label="Semaine de livraison" />
|
||||||
|
<!-- semaine === "2026-W21" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioTime
|
## MalioTime
|
||||||
|
|
||||||
Sélecteur d'heure.
|
Sélecteur d'heure.
|
||||||
@@ -263,6 +563,43 @@ Sélecteur d'heure.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioDateTime
|
||||||
|
|
||||||
|
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
|
||||||
|
|
||||||
|
> ⚠️ **Version intérimaire** : le sélecteur d'heure est un `<input type="time">` natif, en attendant la maquette d'un sélecteur d'heure dédié. Le bloc heure est isolé pour être remplacé sans impact sur le reste.
|
||||||
|
|
||||||
|
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Date + heure ISO naïve `"YYYY-MM-DDTHH:MM:00"` (v-model) |
|
||||||
|
| `id` | `string` | `''` | Id du champ |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
|
||||||
|
| `required` | `boolean` | `false` | Requis |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) |
|
||||||
|
| `max` | `string` | `undefined` | Borne max (idem) |
|
||||||
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string | null)`
|
||||||
|
|
||||||
|
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
||||||
|
<!-- rdv === "2026-05-20T14:30:00" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioButton
|
## MalioButton
|
||||||
|
|
||||||
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
||||||
@@ -319,18 +656,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
|
||||||
@@ -360,27 +721,114 @@ Barre latérale de navigation rétractable.
|
|||||||
|
|
||||||
## MalioDrawer
|
## MalioDrawer
|
||||||
|
|
||||||
Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transparent.
|
Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs drawers.
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `id` | `string` | auto | Identifiant HTML |
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
||||||
| `title` | `string` | `''` | Titre affiché dans le header |
|
| `side` | `'right' \| 'left'` | `'right'` | Côté d'apparition |
|
||||||
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
||||||
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
|
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
|
||||||
|
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
|
||||||
|
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
|
||||||
|
| `drawerClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-2xl` (twMerge) |
|
||||||
|
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
||||||
|
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
||||||
|
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
||||||
|
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`
|
**Events :** `update:modelValue(value: boolean)`, `close()`
|
||||||
**Slots :** `default` (contenu du drawer)
|
|
||||||
|
**Slots :**
|
||||||
|
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
||||||
|
- `default` — contenu (zone scrollable).
|
||||||
|
- `footer` — rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien).
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDrawer v-model="isOpen" title="Détails">
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">Détails</h2>
|
||||||
|
</template>
|
||||||
<p>Contenu du drawer</p>
|
<p>Contenu du drawer</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
<MalioDrawer v-model="isOpen" title="Sans croix" :show-close="false">
|
|
||||||
<p>Fermeture uniquement via backdrop</p>
|
<!-- Côté gauche, largeur custom -->
|
||||||
|
<MalioDrawer v-model="isOpen" side="left" drawer-class="max-w-2xl">
|
||||||
|
<template #header><h2>Navigation</h2></template>
|
||||||
|
<p>Drawer large depuis la gauche</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
<MalioDrawer v-model="isOpen" title="Large" drawer-class="max-w-2xl">
|
|
||||||
<p>Drawer plus large</p>
|
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
|
||||||
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header><h2>Formulaire</h2></template>
|
||||||
|
<MalioInputText label="Nom" />
|
||||||
|
<template #footer>
|
||||||
|
<div class="sticky bottom-0 bg-white py-4">
|
||||||
|
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
|
||||||
|
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
|
||||||
|
<MalioDrawer v-model="isOpen" :dismissable="false" :close-on-escape="false">
|
||||||
|
<template #header><h2>Action requise</h2></template>
|
||||||
|
<p>Fermeture via la croix uniquement</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MalioDataTable
|
||||||
|
|
||||||
|
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `columns` | `{ key: string, label: string }[]` | **requis** | Définition des colonnes |
|
||||||
|
| `items` | `Record<string, unknown>[]` | **requis** | Données à afficher |
|
||||||
|
| `totalItems` | `number` | **requis** | Total pour la pagination |
|
||||||
|
| `page` | `number` | `1` | Page courante (v-model) |
|
||||||
|
| `perPage` | `number` | `10` | Lignes par page (v-model) |
|
||||||
|
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
|
||||||
|
| `rowClickable` | `boolean` | `true` | Lignes cliquables (cursor pointer + hover) |
|
||||||
|
| `tableClass` | `string` | `''` | Classes CSS sur `<table>` (twMerge) |
|
||||||
|
| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide |
|
||||||
|
|
||||||
|
**Events :** `update:page(value: number)`, `update:per-page(value: number)`, `row-click(item: Record<string, unknown>)`
|
||||||
|
**Slots :** `#header-{key}` (filtre dans le `<th>`, placeholder = label), `#cell-{key}` (contenu du `<td>`), `#empty` (état vide)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Avec filtres et pagination -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="[{ key: 'nom', label: 'Nom' }, { key: 'ville', label: 'Ville' }]"
|
||||||
|
:items="data"
|
||||||
|
:total-items="total"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
@row-click="router.push(`/contact/${$event.id}`)"
|
||||||
|
>
|
||||||
|
<template #header-nom>
|
||||||
|
<input v-model="filtreNom" placeholder="Nom" class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none">
|
||||||
|
</template>
|
||||||
|
<template #header-ville>
|
||||||
|
<select v-model="filtreVille" class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none">
|
||||||
|
<option value="">Ville</option>
|
||||||
|
<option v-for="v in villes" :key="v" :value="v">{{ v }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
<template #cell-nom="{ item }">
|
||||||
|
<strong>{{ item.nom }}</strong>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<!-- Simple sans filtres -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="data"
|
||||||
|
:total-items="total"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -35,6 +36,6 @@
|
|||||||
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
||||||
--m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */
|
--m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */
|
||||||
|
|
||||||
--m-radius: 8px;
|
--m-radius: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import {describe, expect, it} from 'vitest'
|
|
||||||
import {mount} from '@vue/test-utils'
|
|
||||||
import type {DefineComponent} from 'vue'
|
|
||||||
import Checkbox from './Checkbox.vue'
|
|
||||||
|
|
||||||
type CheckboxProps = {
|
|
||||||
id?: string
|
|
||||||
label?: string
|
|
||||||
name?: string
|
|
||||||
modelValue?: boolean | null
|
|
||||||
inputClass?: string
|
|
||||||
labelClass?: string
|
|
||||||
groupClass?: string
|
|
||||||
required?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
hint?: string
|
|
||||||
error?: string
|
|
||||||
success?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
|
|
||||||
|
|
||||||
const mountCheckbox = (props: CheckboxProps = {}) =>
|
|
||||||
mount(CheckboxForTest, {props})
|
|
||||||
|
|
||||||
describe('MalioCheckbox', () => {
|
|
||||||
it('renders a checkbox input', () => {
|
|
||||||
const wrapper = mountCheckbox()
|
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('type')).toBe('checkbox')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the label text', () => {
|
|
||||||
const wrapper = mountCheckbox({label: 'Accept terms'})
|
|
||||||
|
|
||||||
expect(wrapper.get('label').text()).toContain('Accept terms')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses a provided id on input and label', () => {
|
|
||||||
const wrapper = mountCheckbox({
|
|
||||||
id: 'checkbox-id',
|
|
||||||
label: 'Accept terms',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('id')).toBe('checkbox-id')
|
|
||||||
expect(wrapper.get('label').attributes('for')).toBe('checkbox-id')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('generates an id when none is provided', () => {
|
|
||||||
const wrapper = mountCheckbox({label: 'Accept terms'})
|
|
||||||
const inputId = wrapper.get('input').attributes('id')
|
|
||||||
|
|
||||||
expect(inputId?.startsWith('malio-checkbox-')).toBe(true)
|
|
||||||
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies the name attribute', () => {
|
|
||||||
const wrapper = mountCheckbox({name: 'terms'})
|
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('name')).toBe('terms')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reflects the checked state from modelValue', () => {
|
|
||||||
const wrapper = mountCheckbox({modelValue: true})
|
|
||||||
|
|
||||||
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits update:modelValue when toggled', async () => {
|
|
||||||
const wrapper = mountCheckbox({modelValue: false})
|
|
||||||
const input = wrapper.get('input')
|
|
||||||
|
|
||||||
await input.setValue(true)
|
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not emit when readonly', async () => {
|
|
||||||
const wrapper = mountCheckbox({
|
|
||||||
modelValue: true,
|
|
||||||
readonly: true,
|
|
||||||
})
|
|
||||||
const input = wrapper.get('input')
|
|
||||||
|
|
||||||
await input.setValue(false)
|
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
||||||
expect((input.element as HTMLInputElement).checked).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets disabled and required attributes', () => {
|
|
||||||
const wrapper = mountCheckbox({
|
|
||||||
disabled: true,
|
|
||||||
required: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
|
||||||
expect(wrapper.get('input').attributes('required')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a hint message and links it with aria-describedby', () => {
|
|
||||||
const wrapper = mountCheckbox({hint: 'Required field'})
|
|
||||||
const inputId = wrapper.get('input').attributes('id')
|
|
||||||
|
|
||||||
expect(wrapper.get('p').text()).toBe('Required field')
|
|
||||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows an error state and message', () => {
|
|
||||||
const wrapper = mountCheckbox({
|
|
||||||
label: 'Accept terms',
|
|
||||||
error: 'You must accept',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
|
||||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
|
||||||
expect(wrapper.get('p').text()).toBe('You must accept')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows success only when there is no error', () => {
|
|
||||||
const wrapper = mountCheckbox({
|
|
||||||
success: 'Valid',
|
|
||||||
error: 'Invalid',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.get('p').text()).toBe('Invalid')
|
|
||||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows success styles and message when there is no error', () => {
|
|
||||||
const wrapper = mountCheckbox({
|
|
||||||
label: 'Accept terms',
|
|
||||||
success: 'Valid',
|
|
||||||
modelValue: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.get('label').classes()).toContain('text-m-success')
|
|
||||||
expect(wrapper.get('p').text()).toBe('Valid')
|
|
||||||
expect(wrapper.get('p').classes()).toContain('text-m-success')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div :class="mergedGroupClass">
|
|
||||||
<input
|
|
||||||
:id="inputId"
|
|
||||||
:name="name"
|
|
||||||
:checked="isChecked"
|
|
||||||
:required="required"
|
|
||||||
:disabled="disabled"
|
|
||||||
:aria-invalid="!!error"
|
|
||||||
:aria-describedby="describedBy"
|
|
||||||
:class="mergedInputClass"
|
|
||||||
v-bind="attrs"
|
|
||||||
type="checkbox"
|
|
||||||
@change="onChange"
|
|
||||||
>
|
|
||||||
|
|
||||||
<label
|
|
||||||
v-if="label"
|
|
||||||
:for="inputId"
|
|
||||||
:class="mergedLabelClass"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<svg width="12" height="10" viewBox="0 0 12 10" aria-hidden="true">
|
|
||||||
<polyline points="1.5 6 4.5 9 10.5 1"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ label }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="hint || hasError || hasSuccess"
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="mergedMessageClass"
|
|
||||||
>
|
|
||||||
{{ error || success || hint }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {computed, useAttrs, useId} from 'vue'
|
|
||||||
import {twMerge} from 'tailwind-merge'
|
|
||||||
|
|
||||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
id?: string
|
|
||||||
label?: string
|
|
||||||
name?: string
|
|
||||||
modelValue?: boolean | null | undefined
|
|
||||||
inputClass?: string
|
|
||||||
labelClass?: string
|
|
||||||
groupClass?: string
|
|
||||||
required?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
hint?: string
|
|
||||||
error?: string
|
|
||||||
success?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
label: '',
|
|
||||||
name: '',
|
|
||||||
modelValue: undefined,
|
|
||||||
inputClass: '',
|
|
||||||
labelClass: '',
|
|
||||||
groupClass: '',
|
|
||||||
required: false,
|
|
||||||
disabled: false,
|
|
||||||
readonly: false,
|
|
||||||
hint: '',
|
|
||||||
error: '',
|
|
||||||
success: '',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const attrs = useAttrs()
|
|
||||||
const generatedId = useId()
|
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
|
|
||||||
const isChecked = computed(() => !!props.modelValue)
|
|
||||||
const hasError = computed(() => !!props.error)
|
|
||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|
||||||
const disabled = computed(() => props.disabled)
|
|
||||||
|
|
||||||
const describedBy = computed(() => {
|
|
||||||
if (!props.hint && !hasError.value && !hasSuccess.value) return undefined
|
|
||||||
return `${inputId.value}-describedby`
|
|
||||||
})
|
|
||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
|
||||||
twMerge(
|
|
||||||
'checkbox-wrapper-4 mt-4 w-full',
|
|
||||||
props.groupClass,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedInputClass = computed(() =>
|
|
||||||
twMerge(
|
|
||||||
'inp-cbx peer',
|
|
||||||
props.inputClass,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
|
||||||
twMerge(
|
|
||||||
'cbx text-black',
|
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
|
||||||
hasError.value ? 'text-m-error' : '',
|
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
|
||||||
props.labelClass,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedMessageClass = computed(() =>
|
|
||||||
twMerge(
|
|
||||||
'text-xs',
|
|
||||||
hasError.value
|
|
||||||
? 'text-m-error'
|
|
||||||
: hasSuccess.value
|
|
||||||
? 'text-m-success'
|
|
||||||
: 'text-m-muted',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'update:modelValue', value: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const onChange = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
|
|
||||||
if (props.readonly) {
|
|
||||||
target.checked = isChecked.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:modelValue', target.checked)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.cbx {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cbx span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cbx span:first-child {
|
|
||||||
position: relative;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex: 0 0 18px;
|
|
||||||
transform: scale(1);
|
|
||||||
border: 2px solid rgb(0, 0, 0);
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cbx span:first-child svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 1px;
|
|
||||||
fill: none;
|
|
||||||
stroke: #000000;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-dasharray: 16px;
|
|
||||||
stroke-dashoffset: 16px;
|
|
||||||
transition: all 0.125s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cbx span:last-child {
|
|
||||||
padding-left: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inp-cbx {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inp-cbx:checked + .cbx span:first-child svg {
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inp-cbx + .cbx.text-m-error span:first-child {
|
|
||||||
border-color: rgb(var(--m-error) / 1);
|
|
||||||
}
|
|
||||||
.cbx.text-m-error span:first-child svg {
|
|
||||||
stroke: rgb(var(--m-error) / 1);
|
|
||||||
}
|
|
||||||
.inp-cbx:checked + .cbx.text-m-error span:first-child {
|
|
||||||
border-color: rgb(var(--m-error) / 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inp-cbx + .cbx.text-m-success span:first-child {
|
|
||||||
border-color: rgb(var(--m-success) / 1);
|
|
||||||
}
|
|
||||||
.cbx.text-m-success span:first-child svg {
|
|
||||||
stroke: rgb(var(--m-success) / 1);
|
|
||||||
}
|
|
||||||
.inp-cbx:checked + .cbx.text-m-success span:first-child {
|
|
||||||
border-color: rgb(var(--m-success) / 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inp-cbx:disabled + .cbx {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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)
|
||||||
@@ -94,7 +96,7 @@ const describedBy = computed(() => {
|
|||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'checkbox-wrapper-4 mt-4 w-full',
|
'checkbox-wrapper-4 w-full',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -108,7 +110,8 @@ const mergedInputClass = computed(() =>
|
|||||||
|
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'cbx text-black',
|
'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-danger' : '',
|
hasError.value ? 'text-m-danger' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
@@ -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;
|
||||||
|
|||||||
278
app/components/malio/datatable/DataTable.test.ts
Normal file
278
app/components/malio/datatable/DataTable.test.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
import DataTable from './DataTable.vue'
|
||||||
|
|
||||||
|
type DataTableProps = {
|
||||||
|
id?: string
|
||||||
|
columns?: { key: string; label: string }[]
|
||||||
|
items?: Record<string, unknown>[]
|
||||||
|
totalItems?: number
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
perPageOptions?: number[]
|
||||||
|
rowClickable?: boolean
|
||||||
|
tableClass?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultItems = [
|
||||||
|
{ nom: 'Dupont', ville: 'Paris' },
|
||||||
|
{ nom: 'Martin', ville: 'Lyon' },
|
||||||
|
{ nom: 'Bernard', ville: 'Marseille' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function mountComponent(props: DataTableProps = {}, slots?: Record<string, unknown>) {
|
||||||
|
return mount(DataTableForTest, {
|
||||||
|
props: {
|
||||||
|
columns: defaultColumns,
|
||||||
|
items: defaultItems,
|
||||||
|
totalItems: 3,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
slots,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioSelect: {
|
||||||
|
name: 'MalioSelect',
|
||||||
|
template: '<div data-test="malio-select"><slot /></div>',
|
||||||
|
props: ['modelValue', 'options'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
},
|
||||||
|
MalioButton: {
|
||||||
|
template: '<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
|
||||||
|
props: ['label', 'disabled', 'variant', 'buttonClass'],
|
||||||
|
emits: ['click'],
|
||||||
|
inheritAttrs: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioDataTable', () => {
|
||||||
|
describe('Table rendering', () => {
|
||||||
|
it('renders column headers as text when no header slot', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const headers = wrapper.findAll('th')
|
||||||
|
expect(headers).toHaveLength(2)
|
||||||
|
expect(headers[0].text()).toBe('Nom')
|
||||||
|
expect(headers[1].text()).toBe('Ville')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders header slot when provided', () => {
|
||||||
|
const wrapper = mountComponent({}, {
|
||||||
|
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
|
||||||
|
})
|
||||||
|
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders items as rows', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const rows = wrapper.findAll('[data-test="row"]')
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].text()).toContain('Dupont')
|
||||||
|
expect(rows[0].text()).toContain('Paris')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cell slot when provided', () => {
|
||||||
|
const wrapper = mountComponent({}, {
|
||||||
|
'cell-nom': ({ item }: { item: Record<string, unknown> }) => h('strong', String(item.nom)),
|
||||||
|
})
|
||||||
|
const firstRow = wrapper.findAll('[data-test="row"]')[0]
|
||||||
|
expect(firstRow.find('strong').text()).toBe('Dupont')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty message when items is empty', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||||
|
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom empty message', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
|
||||||
|
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty slot when provided', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ items: [], totalItems: 0 },
|
||||||
|
{ empty: '<p data-test="custom-empty">Vide</p>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty row has colspan equal to columns length', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||||
|
const td = wrapper.find('[data-test="empty-row"] td')
|
||||||
|
expect(td.attributes('colspan')).toBe('2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Row click', () => {
|
||||||
|
it('emits row-click with item on row click', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
||||||
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits row-click on Enter key', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
|
||||||
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits row-click on Space key', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
|
||||||
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows have tabindex when clickable', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows have cursor-pointer when clickable', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows are not clickable when rowClickable is false', async () => {
|
||||||
|
const wrapper = mountComponent({ rowClickable: false })
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
||||||
|
expect(wrapper.emitted('row-click')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows have no tabindex when not clickable', () => {
|
||||||
|
const wrapper = mountComponent({ rowClickable: false })
|
||||||
|
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('th elements have scope="col"', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const ths = wrapper.findAll('th')
|
||||||
|
ths.forEach(th => {
|
||||||
|
expect(th.attributes('scope')).toBe('col')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when not provided', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const id = wrapper.find('div').attributes('id')
|
||||||
|
expect(id).toMatch(/^malio-datatable-/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses custom id when provided', () => {
|
||||||
|
const wrapper = mountComponent({ id: 'my-table' })
|
||||||
|
expect(wrapper.find('div').attributes('id')).toBe('my-table')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('hides pagination when totalItems is 0', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||||
|
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows pagination when totalItems > 0', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all pages when totalPages <= 5', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights current page', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||||
|
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:page on page button click', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||||
|
await wrapper.find('[data-test="page-3"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prev button is disabled on page 1', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||||
|
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Next button is disabled on last page', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
|
||||||
|
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prev button emits update:page with page - 1', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||||
|
await wrapper.find('[data-test="prev-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Next button emits update:page with page + 1', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||||
|
await wrapper.find('[data-test="next-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows ellipsis for truncated pages (> 5 pages)', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||||
|
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
|
||||||
|
expect(ellipsis.length).toBeGreaterThan(0)
|
||||||
|
expect(ellipsis[0].text()).toBe('…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always shows first and last page when > 5 pages', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||||
|
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows 1 neighbor on each side of current page', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||||
|
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pagination nav has aria-label', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prev button has aria-label "Page précédente"', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Next button has aria-label "Page suivante"', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Per-page selector', () => {
|
||||||
|
it('emits update:per-page and reset page to 1 on change', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
|
||||||
|
const select = wrapper.findComponent({ name: 'MalioSelect' })
|
||||||
|
select.vm.$emit('update:modelValue', 25)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
222
app/components/malio/datatable/DataTable.vue
Normal file
222
app/components/malio/datatable/DataTable.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<template>
|
||||||
|
<div :id="componentId" class="w-full" v-bind="attrs">
|
||||||
|
<table :class="twMerge('w-full border-separate border-spacing-0 border border-black rounded-malio overflow-hidden', tableClass)">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-m-surface">
|
||||||
|
<th
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
scope="col"
|
||||||
|
class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
v-if="$slots[`header-${col.key}`]"
|
||||||
|
:name="`header-${col.key}`"
|
||||||
|
:column="col"
|
||||||
|
/>
|
||||||
|
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
:class="rowClickable ? 'cursor-pointer hover:bg-m-bg' : ''"
|
||||||
|
:tabindex="rowClickable ? 0 : undefined"
|
||||||
|
data-test="row"
|
||||||
|
@click="rowClickable ? emit('row-click', item) : undefined"
|
||||||
|
@keydown.enter="rowClickable ? emit('row-click', item) : undefined"
|
||||||
|
@keydown.space.prevent="rowClickable ? emit('row-click', item) : undefined"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
class="px-3 py-4 text-[18px] text-m-primary"
|
||||||
|
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
v-if="$slots[`cell-${col.key}`]"
|
||||||
|
:name="`cell-${col.key}`"
|
||||||
|
:item="item"
|
||||||
|
:column="col"
|
||||||
|
/>
|
||||||
|
<template v-else>{{ item[col.key] }}</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!items.length" data-test="empty-row">
|
||||||
|
<td
|
||||||
|
:colspan="columns.length"
|
||||||
|
class="px-3 py-4 text-center text-m-muted"
|
||||||
|
>
|
||||||
|
<slot name="empty">{{ emptyMessage }}</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="totalItems > 0"
|
||||||
|
class="flex justify-between pt-2"
|
||||||
|
data-test="pagination"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="perPage"
|
||||||
|
:options="perPageSelectOptions"
|
||||||
|
min-width="w-20 !mt-0"
|
||||||
|
rounded="rounded"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
text-label="text-xs"
|
||||||
|
data-test="per-page-select"
|
||||||
|
@update:model-value="onPerPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Prev"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
||||||
|
aria-label="Page précédente"
|
||||||
|
data-test="prev-button"
|
||||||
|
@click="goToPage(page - 1)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-for="(p, idx) in visiblePages" :key="idx">
|
||||||
|
<span
|
||||||
|
v-if="p === '...'"
|
||||||
|
class="px-1 text-sm text-m-muted"
|
||||||
|
aria-hidden="true"
|
||||||
|
>…</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||||
|
:class="p === page
|
||||||
|
? 'bg-m-btn-primary text-white font-semibold'
|
||||||
|
: 'text-m-text hover:bg-m-bg'"
|
||||||
|
:aria-current="p === page ? 'page' : undefined"
|
||||||
|
:data-test="`page-${p}`"
|
||||||
|
@click="goToPage(p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Next"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
||||||
|
aria-label="Page suivante"
|
||||||
|
data-test="next-button"
|
||||||
|
@click="goToPage(page + 1)"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useAttrs, useId } from 'vue'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import MalioSelect from '../select/Select.vue'
|
||||||
|
import MalioButton from '../button/Button.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MalioDataTable', inheritAttrs: false })
|
||||||
|
|
||||||
|
type DataTableColumn = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
columns: DataTableColumn[]
|
||||||
|
items: Record<string, unknown>[]
|
||||||
|
totalItems: number
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
perPageOptions?: number[]
|
||||||
|
rowClickable?: boolean
|
||||||
|
tableClass?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
page: 1,
|
||||||
|
perPage: 10,
|
||||||
|
perPageOptions: () => [10, 25, 50],
|
||||||
|
rowClickable: true,
|
||||||
|
tableClass: '',
|
||||||
|
emptyMessage: 'Aucune donnée',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:page' | 'update:per-page', value: number): void
|
||||||
|
(e: 'row-click', item: Record<string, unknown>): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
|
||||||
|
|
||||||
|
const perPageSelectOptions = computed(() =>
|
||||||
|
props.perPageOptions.map(n => ({ label: String(n), value: n }))
|
||||||
|
)
|
||||||
|
|
||||||
|
function onPerPageChange(value: string | number | null) {
|
||||||
|
if (value !== null) {
|
||||||
|
emit('update:per-page', Number(value))
|
||||||
|
emit('update:page', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
emit('update:page', page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const total = totalPages.value
|
||||||
|
const current = props.page
|
||||||
|
|
||||||
|
if (total <= 5) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | '...')[] = []
|
||||||
|
pages.push(1)
|
||||||
|
|
||||||
|
if (current > 3) {
|
||||||
|
pages.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(2, current - 1)
|
||||||
|
const end = Math.min(total - 1, current + 1)
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < total - 2) {
|
||||||
|
pages.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 1) {
|
||||||
|
pages.push(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
</script>
|
||||||
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>
|
||||||
120
app/components/malio/date/DateTime.test.ts
Normal file
120
app/components/malio/date/DateTime.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import DateTime_ from './DateTime.vue'
|
||||||
|
|
||||||
|
type DateTimeProps = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
|
||||||
|
const mountDateTime = (props: DateTimeProps = {}) =>
|
||||||
|
mount(DateTimeForTest, {props, attachTo: document.body})
|
||||||
|
|
||||||
|
describe('MalioDateTime', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||||
|
})
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
describe('rendu', () => {
|
||||||
|
it('affiche le label et l\'icône calendrier', () => {
|
||||||
|
const wrapper = mountDateTime({label: 'Rendez-vous'})
|
||||||
|
expect(wrapper.get('label').text()).toBe('Rendez-vous')
|
||||||
|
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche la valeur formatée date + heure dans le champ', () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('20/05/2026 14:30')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('popover', () => {
|
||||||
|
it('ouvre la grille et l\'input heure au clic', async () => {
|
||||||
|
const wrapper = mountDateTime()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sélection', () => {
|
||||||
|
it('émet le jour à 00:00 et garde le popover ouvert', async () => {
|
||||||
|
const wrapper = mountDateTime()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applique l\'heure réglée avant le clic du jour', async () => {
|
||||||
|
const wrapper = mountDateTime()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="time-input"]').setValue('09:15')
|
||||||
|
// pas d'émission tant qu'aucun jour n'est choisi
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met à jour l\'heure quand une date est déjà choisie', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="time-input"]').setValue('08:45')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initialise l\'input heure depuis la valeur', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement
|
||||||
|
expect(time.value).toBe('14:30')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bornes min/max', () => {
|
||||||
|
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
|
||||||
|
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||||
|
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('effacement', () => {
|
||||||
|
it('émet null au clic sur la croix', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('accessibilité', () => {
|
||||||
|
it('positionne aria-invalid et describedby sur erreur', () => {
|
||||||
|
const wrapper = mountDateTime({error: 'Date requise'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
expect(input.attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||||
|
expect(wrapper.text()).toContain('Date requise')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
134
app/components/malio/date/DateTime.vue
Normal file
134
app/components/malio/date/DateTime.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<CalendarField
|
||||||
|
:id="id"
|
||||||
|
:display-value="displayValue"
|
||||||
|
:sync-to="datePart"
|
||||||
|
:name="name"
|
||||||
|
:label="label"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:hint="hint"
|
||||||
|
:error="error"
|
||||||
|
:success="success"
|
||||||
|
:clearable="clearable"
|
||||||
|
:input-class="inputClass"
|
||||||
|
:label-class="labelClass"
|
||||||
|
:group-class="groupClass"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@clear="onClear"
|
||||||
|
>
|
||||||
|
<template #default="{ currentMonth, currentYear }">
|
||||||
|
<MonthGrid
|
||||||
|
:month="currentMonth"
|
||||||
|
:year="currentYear"
|
||||||
|
:selected-date="datePart"
|
||||||
|
:min="min?.slice(0, 10)"
|
||||||
|
:max="max?.slice(0, 10)"
|
||||||
|
@select="onSelectDay"
|
||||||
|
/>
|
||||||
|
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
|
||||||
|
<div class="mt-[26px] flex-col items-center gap-2">
|
||||||
|
<input
|
||||||
|
:id="timeInputId"
|
||||||
|
data-test="time-input"
|
||||||
|
type="time"
|
||||||
|
:value="timeValue"
|
||||||
|
class="w-full border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
|
||||||
|
@input="onTimeInput"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CalendarField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, useId, watch} from 'vue'
|
||||||
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
|
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
placeholder: 'JJ/MM/AAAA HH:MM',
|
||||||
|
required: false,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
|
clearable: true,
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const timeInputId = computed(() => `${props.id || `malio-datetime-${generatedId}`}-time`)
|
||||||
|
|
||||||
|
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
|
||||||
|
const pendingTime = ref('')
|
||||||
|
|
||||||
|
const parts = computed(() => splitDateTime(props.modelValue ?? null))
|
||||||
|
const datePart = computed(() => parts.value.date)
|
||||||
|
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
|
||||||
|
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
||||||
|
|
||||||
|
function onSelectDay(iso: string) {
|
||||||
|
const time = parts.value.time || pendingTime.value || '00:00'
|
||||||
|
emit('update:modelValue', composeDateTime(iso, time))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimeInput(e: Event) {
|
||||||
|
const value = (e.target as HTMLInputElement).value
|
||||||
|
if (!value) return
|
||||||
|
if (datePart.value) {
|
||||||
|
emit('update:modelValue', composeDateTime(datePart.value, value))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pendingTime.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClear() {
|
||||||
|
pendingTime.value = ''
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
|
||||||
|
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
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})`
|
||||||
|
}
|
||||||
61
app/components/malio/date/composables/datetimeFormat.test.ts
Normal file
61
app/components/malio/date/composables/datetimeFormat.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {
|
||||||
|
composeDateTime,
|
||||||
|
formatIsoDateTimeToDisplay,
|
||||||
|
isValidIsoDateTime,
|
||||||
|
splitDateTime,
|
||||||
|
} from './datetimeFormat'
|
||||||
|
|
||||||
|
describe('datetimeFormat', () => {
|
||||||
|
describe('isValidIsoDateTime', () => {
|
||||||
|
it('accepte un datetime ISO complet valide', () => {
|
||||||
|
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
|
||||||
|
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
|
||||||
|
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
|
||||||
|
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
|
||||||
|
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
|
||||||
|
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
|
||||||
|
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
|
||||||
|
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
|
||||||
|
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
|
||||||
|
expect(isValidIsoDateTime('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatIsoDateTimeToDisplay', () => {
|
||||||
|
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
|
||||||
|
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie une chaîne vide pour nul ou invalide', () => {
|
||||||
|
expect(formatIsoDateTimeToDisplay(null)).toBe('')
|
||||||
|
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
|
||||||
|
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('splitDateTime', () => {
|
||||||
|
it('découpe un datetime valide', () => {
|
||||||
|
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
|
||||||
|
expect(splitDateTime(null)).toEqual({date: null, time: ''})
|
||||||
|
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
|
||||||
|
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('composeDateTime', () => {
|
||||||
|
it('recompose un datetime ISO avec secondes à 00', () => {
|
||||||
|
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('utilise 00:00 quand l\'heure est vide', () => {
|
||||||
|
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
33
app/components/malio/date/composables/datetimeFormat.ts
Normal file
33
app/components/malio/date/composables/datetimeFormat.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {isValidIso} from './dateFormat'
|
||||||
|
|
||||||
|
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
|
||||||
|
|
||||||
|
export function isValidIsoDateTime(s: string): boolean {
|
||||||
|
const m = DATETIME_RE.exec(s)
|
||||||
|
if (!m) return false
|
||||||
|
const [, date, hh, mm, ss] = m
|
||||||
|
if (!isValidIso(date)) return false
|
||||||
|
const h = Number(hh)
|
||||||
|
const min = Number(mm)
|
||||||
|
const sec = Number(ss)
|
||||||
|
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIsoDateTimeToDisplay(s: string | null): string {
|
||||||
|
if (!s || !isValidIsoDateTime(s)) return ''
|
||||||
|
const [date, time] = s.split('T')
|
||||||
|
const [y, mo, d] = date.split('-')
|
||||||
|
const [hh, mm] = time.split(':')
|
||||||
|
return `${d}/${mo}/${y} ${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitDateTime(s: string | null): {date: string | null; time: string} {
|
||||||
|
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
|
||||||
|
const [date, time] = s.split('T')
|
||||||
|
return {date, time: time.slice(0, 5)}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeDateTime(date: string, time: string): string {
|
||||||
|
const t = time || '00:00'
|
||||||
|
return `${date}T${t}:00`
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {defineComponent, h, ref} from 'vue'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import {useCalendarPopover} from './useCalendarPopover'
|
||||||
|
|
||||||
|
const mountHost = () => {
|
||||||
|
const api: ReturnType<typeof useCalendarPopover> = {} as never
|
||||||
|
const Host = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
Object.assign(api, useCalendarPopover(root))
|
||||||
|
return () => h('div', {ref: root}, 'host')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const wrapper = mount(Host, {attachTo: document.body})
|
||||||
|
return {wrapper, api}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCalendarPopover', () => {
|
||||||
|
it('starts closed in days view', () => {
|
||||||
|
const {api} = mountHost()
|
||||||
|
expect(api.isOpen.value).toBe(false)
|
||||||
|
expect(api.viewMode.value).toBe('days')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open() opens in days view', () => {
|
||||||
|
const {api} = mountHost()
|
||||||
|
api.open()
|
||||||
|
expect(api.isOpen.value).toBe(true)
|
||||||
|
expect(api.viewMode.value).toBe('days')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggleView() switches between days and months', () => {
|
||||||
|
const {api} = mountHost()
|
||||||
|
api.open()
|
||||||
|
api.toggleView()
|
||||||
|
expect(api.viewMode.value).toBe('months')
|
||||||
|
api.toggleView()
|
||||||
|
expect(api.viewMode.value).toBe('days')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close() resets isOpen and viewMode', () => {
|
||||||
|
const {api} = mountHost()
|
||||||
|
api.open()
|
||||||
|
api.toggleView()
|
||||||
|
api.close()
|
||||||
|
expect(api.isOpen.value).toBe(false)
|
||||||
|
expect(api.viewMode.value).toBe('days')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on outside mousedown', () => {
|
||||||
|
const {api} = mountHost()
|
||||||
|
api.open()
|
||||||
|
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||||
|
expect(api.isOpen.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays open on inside mousedown', () => {
|
||||||
|
const {wrapper, api} = mountHost()
|
||||||
|
api.open()
|
||||||
|
wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||||
|
expect(api.isOpen.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
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>
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from 'vue'
|
||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import Drawer from './Drawer.vue'
|
import Drawer from './Drawer.vue'
|
||||||
|
|
||||||
type DrawerProps = {
|
type DrawerProps = {
|
||||||
modelValue?: boolean
|
|
||||||
title?: string
|
|
||||||
showClose?: boolean
|
|
||||||
id?: string
|
id?: string
|
||||||
|
modelValue?: boolean
|
||||||
|
side?: 'right' | 'left'
|
||||||
|
showClose?: boolean
|
||||||
|
dismissable?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
drawerClass?: string
|
drawerClass?: string
|
||||||
|
overlayClass?: string
|
||||||
|
headerClass?: string
|
||||||
|
bodyClass?: string
|
||||||
|
footerClass?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
|
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
|
||||||
@@ -18,64 +25,38 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
|
|||||||
return mount(DrawerForTest, {
|
return mount(DrawerForTest, {
|
||||||
props,
|
props,
|
||||||
slots,
|
slots,
|
||||||
global: {
|
global: { stubs: { Teleport: true } },
|
||||||
stubs: {
|
|
||||||
Teleport: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MalioDrawer', () => {
|
describe('MalioDrawer', () => {
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
|
||||||
it('does not render when modelValue is false', () => {
|
it('does not render when modelValue is false', () => {
|
||||||
const wrapper = mountComponent({ modelValue: false })
|
const wrapper = mountComponent({ modelValue: false })
|
||||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders when modelValue is true', () => {
|
it('renders the panel when modelValue is true', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the title', () => {
|
it('renders default slot in the body', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' })
|
|
||||||
expect(wrapper.find('h2').text()).toBe('Mon tiroir')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders slot content', () => {
|
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent(
|
||||||
{ modelValue: true },
|
{ modelValue: true },
|
||||||
{ default: '<p data-test="content">Contenu du drawer</p>' },
|
{ default: '<p data-test="content">Contenu</p>' },
|
||||||
)
|
)
|
||||||
expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer')
|
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits update:modelValue false on backdrop click', async () => {
|
it('works in uncontrolled mode (defaults closed)', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent()
|
||||||
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits update:modelValue false on close button click', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
await wrapper.find('[data-test="close-button"]').trigger('click')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows close button by default', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides close button when showClose is false', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
|
||||||
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('close button renders mdi:close icon', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
const icon = wrapper.findComponent(IconifyIcon)
|
|
||||||
expect(icon.props('icon')).toBe('mdi:close')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses custom id when provided', () => {
|
it('uses custom id when provided', () => {
|
||||||
@@ -85,38 +66,276 @@ describe('MalioDrawer', () => {
|
|||||||
|
|
||||||
it('generates an id when not provided', () => {
|
it('generates an id when not provided', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
const id = wrapper.find('.fixed').attributes('id')
|
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
|
||||||
expect(id).toMatch(/^malio-drawer-/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has role="dialog" and aria-modal on panel', () => {
|
it('has role="dialog" and aria-modal on the panel', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
const panel = wrapper.find('[data-test="panel"]')
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
expect(panel.attributes('role')).toBe('dialog')
|
expect(panel.attributes('role')).toBe('dialog')
|
||||||
expect(panel.attributes('aria-modal')).toBe('true')
|
expect(panel.attributes('aria-modal')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aria-labelledby links to title id', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, id: 'test-drawer' })
|
|
||||||
const panel = wrapper.find('[data-test="panel"]')
|
|
||||||
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-title')
|
|
||||||
expect(wrapper.find('h2').attributes('id')).toBe('test-drawer-title')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies drawerClass to the panel', () => {
|
it('applies drawerClass to the panel', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' })
|
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
|
||||||
const panel = wrapper.find('[data-test="panel"]')
|
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
||||||
expect(panel.classes()).toContain('max-w-lg')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('works in uncontrolled mode', () => {
|
it('renders the #header slot inside the header bar', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent(
|
||||||
// Without modelValue, defaults to closed
|
{ modelValue: true },
|
||||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
{ header: '<h2 data-test="title">Titre</h2>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the header bar when showClose is true even without #header', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the header bar when no #header and showClose is false', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
||||||
|
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the close button by default', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the close button when showClose is false', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, showClose: false },
|
||||||
|
{ header: '<h2>Titre</h2>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close button renders mdi:cancel-bold icon', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const icon = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(icon.props('icon')).toBe('mdi:cancel-bold')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('close button has aria-label "Fermer"', () => {
|
it('close button has aria-label "Fermer"', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false and close on close button click', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="close-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-labelledby to the header id when #header is provided', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, id: 'test-drawer' },
|
||||||
|
{ header: '<h2>Titre</h2>' },
|
||||||
|
)
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header')
|
||||||
|
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-label from ariaLabel when no #header is provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' })
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-label')).toBe('Panneau latéral')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies headerClass to the header bar', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
|
||||||
|
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the #footer slot inside the body (scrollable zone)', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ footer: '<button data-test="save">Enregistrer</button>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the footer wrapper when no #footer slot', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies bodyClass to the body', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
|
||||||
|
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies footerClass to the footer wrapper', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, footerClass: 'sticky bottom-0' },
|
||||||
|
{ footer: '<span>pied</span>' },
|
||||||
|
)
|
||||||
|
const footer = wrapper.find('[data-test="footer"]')
|
||||||
|
expect(footer.classes()).toContain('sticky')
|
||||||
|
expect(footer.classes()).toContain('bottom-0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aligns to the right by default', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('.fixed').classes()).toContain('justify-end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aligns to the left when side is "left"', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, side: 'left' })
|
||||||
|
expect(wrapper.find('.fixed').classes()).toContain('justify-start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close on backdrop click when dismissable is false', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, dismissable: false })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies overlayClass to the backdrop', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
|
||||||
|
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape key when closeOnEscape is true', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close on Escape when closeOnEscape is false', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks body scroll when opened and restores it when closed', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
await wrapper.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus into the panel when opened', async () => {
|
||||||
|
const wrapper = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: false, showClose: false },
|
||||||
|
slots: { default: '<button data-test="first">OK</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const first = wrapper.find('[data-test="first"]').element
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores focus to the trigger when closed', async () => {
|
||||||
|
const trigger = document.createElement('button')
|
||||||
|
document.body.appendChild(trigger)
|
||||||
|
trigger.focus()
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
|
||||||
|
const wrapper = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
slots: { default: '<button>OK</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
await wrapper.setProps({ modelValue: false })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
trigger.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus to the close button on open (default showClose)', async () => {
|
||||||
|
const wrapper = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: false, showClose: true },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
|
||||||
|
const wrapper = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: true, showClose: false },
|
||||||
|
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
|
||||||
|
last.focus()
|
||||||
|
expect(document.activeElement).toBe(last)
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
|
||||||
|
const wrapper = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: true, showClose: false },
|
||||||
|
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
|
||||||
|
first.focus()
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => {
|
||||||
|
const wrapperA = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
const wrapperB = mount(DrawerForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open drawer A → scroll locked
|
||||||
|
await wrapperA.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
// Open drawer B → still locked
|
||||||
|
await wrapperB.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
// Close drawer B → A is still open, scroll must remain locked
|
||||||
|
await wrapperB.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
// Close drawer A → both closed, scroll-lock released
|
||||||
|
await wrapperA.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,59 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition
|
<Transition
|
||||||
name="drawer"
|
:name="`drawer-${side}`"
|
||||||
appear
|
appear
|
||||||
@after-leave="isRendered = false"
|
@after-leave="isRendered = false"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isRendered && isOpen"
|
v-if="isRendered && isOpen"
|
||||||
:id="componentId"
|
:id="componentId"
|
||||||
class="fixed inset-0 z-50 flex justify-end"
|
class="fixed inset-0 z-50 flex"
|
||||||
|
:class="side === 'right' ? 'justify-end' : 'justify-start'"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/40"
|
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
||||||
data-test="backdrop"
|
data-test="backdrop"
|
||||||
@click="close"
|
@click="onBackdropClick"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref="panelRef"
|
||||||
:class="twMerge(
|
:class="twMerge(
|
||||||
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl',
|
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
|
||||||
drawerClass,
|
drawerClass,
|
||||||
)"
|
)"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
:aria-modal="true"
|
aria-modal="true"
|
||||||
:aria-labelledby="titleId"
|
:aria-labelledby="hasHeader ? headerId : undefined"
|
||||||
|
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
||||||
|
tabindex="-1"
|
||||||
data-test="panel"
|
data-test="panel"
|
||||||
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between px-5 pb-8 pt-8">
|
<div
|
||||||
<h2
|
v-if="hasHeader || showClose"
|
||||||
:id="titleId"
|
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
||||||
class="text-[32px] font-semibold text-m-primary"
|
data-test="header"
|
||||||
>
|
>
|
||||||
{{ title }}
|
<div
|
||||||
</h2>
|
:id="headerId"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
data-test="header-content"
|
||||||
|
>
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="showClose"
|
v-if="showClose"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Fermer"
|
aria-label="Fermer"
|
||||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
||||||
data-test="close-button"
|
data-test="close-button"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
icon="mdi:close"
|
icon="mdi:cancel-bold"
|
||||||
:width="24"
|
:width="16"
|
||||||
:height="24"
|
:height="16"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 overflow-y-auto px-5"
|
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
||||||
|
data-test="body"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
:class="footerClass"
|
||||||
|
data-test="footer"
|
||||||
|
>
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +79,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useAttrs, useId, watch } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
useId,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
@@ -72,68 +99,195 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
title?: string
|
side?: 'right' | 'left'
|
||||||
showClose?: boolean
|
showClose?: boolean
|
||||||
|
dismissable?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
drawerClass?: string
|
drawerClass?: string
|
||||||
|
overlayClass?: string
|
||||||
|
headerClass?: string
|
||||||
|
bodyClass?: string
|
||||||
|
footerClass?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
modelValue: undefined,
|
modelValue: undefined,
|
||||||
title: '',
|
side: 'right',
|
||||||
showClose: true,
|
showClose: true,
|
||||||
|
dismissable: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
ariaLabel: '',
|
||||||
drawerClass: '',
|
drawerClass: '',
|
||||||
|
overlayClass: '',
|
||||||
|
headerClass: '',
|
||||||
|
bodyClass: '',
|
||||||
|
footerClass: '',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
|
|
||||||
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
||||||
const titleId = computed(() => `${componentId.value}-title`)
|
|
||||||
|
const slots = useSlots()
|
||||||
|
const headerId = computed(() => `${componentId.value}-header`)
|
||||||
|
const hasHeader = computed(() => !!slots.header)
|
||||||
|
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const localValue = ref(false)
|
const localValue = ref(false)
|
||||||
|
|
||||||
const isOpen = computed(() =>
|
const isOpen = computed(() =>
|
||||||
isControlled.value ? props.modelValue! : localValue.value,
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isRendered = ref(isOpen.value)
|
const isRendered = ref(isOpen.value)
|
||||||
|
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let previouslyFocused: HTMLElement | null = null
|
||||||
|
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
|
||||||
|
let lockedByThisInstance = false
|
||||||
|
|
||||||
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
|
||||||
|
),
|
||||||
|
).filter((el) => el.tabIndex !== -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
||||||
|
if (!lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = true
|
||||||
|
openDrawerCount++
|
||||||
|
if (openDrawerCount === 1) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
const panel = panelRef.value
|
||||||
|
if (!panel) return
|
||||||
|
const focusable = getFocusable(panel)
|
||||||
|
;(focusable[0] ?? panel).focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = false
|
||||||
|
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
||||||
|
if (openDrawerCount === 0) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previouslyFocused?.focus?.()
|
||||||
|
previouslyFocused = null
|
||||||
|
}
|
||||||
|
|
||||||
watch(isOpen, (val) => {
|
watch(isOpen, (val) => {
|
||||||
if (val) isRendered.value = true
|
if (val) {
|
||||||
|
isRendered.value = true
|
||||||
|
onOpen()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
onMounted(() => {
|
||||||
if (!isControlled.value) {
|
if (isOpen.value) onOpen()
|
||||||
localValue.value = false
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// If this instance is still holding a scroll-lock slot, release it.
|
||||||
|
if (lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = false
|
||||||
|
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
||||||
|
if (openDrawerCount === 0) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onBackdropClick() {
|
||||||
|
if (props.dismissable) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && props.closeOnEscape) {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
const panel = panelRef.value
|
||||||
|
if (!panel) return
|
||||||
|
const focusable = getFocusable(panel)
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
panel.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const first = focusable[0]!
|
||||||
|
const last = focusable[focusable.length - 1]!
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isControlled.value) localValue.value = false
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Shared across all MalioDrawer instances: only the last open drawer releases the body scroll-lock.
|
||||||
|
let openDrawerCount = 0
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.drawer-enter-active,
|
.drawer-right-enter-active,
|
||||||
.drawer-leave-active {
|
.drawer-right-leave-active,
|
||||||
|
.drawer-left-enter-active,
|
||||||
|
.drawer-left-leave-active {
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-enter-active > div:last-child,
|
.drawer-right-enter-active > div:last-child,
|
||||||
.drawer-leave-active > div:last-child {
|
.drawer-right-leave-active > div:last-child,
|
||||||
|
.drawer-left-enter-active > div:last-child,
|
||||||
|
.drawer-left-leave-active > div:last-child {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-enter-from,
|
.drawer-right-enter-from,
|
||||||
.drawer-leave-to {
|
.drawer-right-leave-to,
|
||||||
|
.drawer-left-enter-from,
|
||||||
|
.drawer-left-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-enter-from > div:last-child,
|
.drawer-right-enter-from > div:last-child,
|
||||||
.drawer-leave-to > div:last-child {
|
.drawer-right-leave-to > div:last-child {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-left-enter-from > div:last-child,
|
||||||
|
.drawer-left-leave-to > div:last-child {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
:class="mergedGroupClass"
|
:class="mergedGroupClass"
|
||||||
>
|
>
|
||||||
@@ -38,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>
|
||||||
@@ -62,6 +57,7 @@
|
|||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -133,13 +129,13 @@ const isFilled = computed(() => currentValue.value.trim().length > 0)
|
|||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -220,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'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div :class="mergedGroupClass" >
|
<div :class="mergedGroupClass" >
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -170,7 +172,7 @@ const isPlusDisabled = computed(() =>
|
|||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
:class="mergedGroupClass"
|
:class="mergedGroupClass"
|
||||||
>
|
>
|
||||||
@@ -38,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"
|
||||||
@@ -62,6 +60,7 @@
|
|||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -132,13 +131,13 @@ const hasSuccess = computed(() => !!props.success)
|
|||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -187,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>
|
||||||
165
app/components/malio/input/InputRichText.test.ts
Normal file
165
app/components/malio/input/InputRichText.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import {afterEach, describe, expect, it} from 'vitest'
|
||||||
|
import {flushPromises, mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import InputRichText from './InputRichText.vue'
|
||||||
|
|
||||||
|
type InputRichTextProps = {
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
minHeight?: string
|
||||||
|
editable?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
outputFormat?: 'markdown' | 'html'
|
||||||
|
groupClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
editorClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
|
||||||
|
|
||||||
|
const mountComponent = async (props: InputRichTextProps = {}) => {
|
||||||
|
const wrapper = mount(InputRichTextForTest, {
|
||||||
|
props,
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioInputRichText', () => {
|
||||||
|
it('renders the label and reuses a provided id', async () => {
|
||||||
|
const wrapper = await mountComponent({id: 'custom-rt-id', label: 'Description'})
|
||||||
|
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
expect(label.text()).toBe('Description')
|
||||||
|
expect(label.attributes('for')).toBe('custom-rt-id')
|
||||||
|
expect(wrapper.get('#custom-rt-id').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when missing', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Description'})
|
||||||
|
|
||||||
|
const labelFor = wrapper.get('label').attributes('for')
|
||||||
|
expect(labelFor?.startsWith('malio-input-rich-text-')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the toolbar buttons in editable mode', async () => {
|
||||||
|
const wrapper = await mountComponent({modelValue: ''})
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('button[type="button"]')
|
||||||
|
expect(buttons.length).toBeGreaterThanOrEqual(13)
|
||||||
|
expect(wrapper.find('button[title="Gras"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[title="Italique"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[title="Lien"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[title="Couleur du texte"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[title="Surlignage"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[title="Annuler"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[title="Rétablir"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens and closes the text color palette', async () => {
|
||||||
|
const wrapper = await mountComponent({modelValue: ''})
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
|
||||||
|
|
||||||
|
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
|
||||||
|
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
|
||||||
|
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the highlight palette and closes the color palette', async () => {
|
||||||
|
const wrapper = await mountComponent({modelValue: ''})
|
||||||
|
|
||||||
|
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
|
||||||
|
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('button[title="Surlignage"]').trigger('click')
|
||||||
|
expect(wrapper.find('[aria-label="Palette de surlignage"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables color and highlight buttons when readonly', async () => {
|
||||||
|
const wrapper = await mountComponent({readonly: true, modelValue: ''})
|
||||||
|
|
||||||
|
expect(wrapper.get('button[title="Couleur du texte"]').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('button[title="Surlignage"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the toolbar in readonly display mode (editable=false)', async () => {
|
||||||
|
const wrapper = await mountComponent({editable: false, modelValue: '**hi**'})
|
||||||
|
|
||||||
|
expect(wrapper.find('button[title="Gras"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables toolbar buttons when disabled', async () => {
|
||||||
|
const wrapper = await mountComponent({disabled: true, modelValue: ''})
|
||||||
|
|
||||||
|
const boldBtn = wrapper.get('button[title="Gras"]')
|
||||||
|
expect(boldBtn.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables toolbar buttons when readonly', async () => {
|
||||||
|
const wrapper = await mountComponent({readonly: true, modelValue: ''})
|
||||||
|
|
||||||
|
const boldBtn = wrapper.get('button[title="Gras"]')
|
||||||
|
expect(boldBtn.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows hint message in muted color', async () => {
|
||||||
|
const wrapper = await mountComponent({hint: 'Helpful hint'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-muted').text()).toBe('Helpful hint')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error state on wrapper, label and message', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Description', error: 'Editor error'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||||
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
|
||||||
|
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success state on wrapper, label and message', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Description', success: 'Editor success'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-success')
|
||||||
|
expect(wrapper.get('p.text-m-success').text()).toBe('Editor success')
|
||||||
|
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prioritizes error over success', async () => {
|
||||||
|
const wrapper = await mountComponent({error: 'Editor error', success: 'Editor success'})
|
||||||
|
|
||||||
|
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
|
||||||
|
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||||
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-invalid and aria-describedby on the editor content when error', async () => {
|
||||||
|
const wrapper = await mountComponent({id: 'rt-aria', error: 'Boom'})
|
||||||
|
|
||||||
|
const editorContent = wrapper.find('[aria-invalid="true"]')
|
||||||
|
expect(editorContent.exists()).toBe(true)
|
||||||
|
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders initial markdown content visually', async () => {
|
||||||
|
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
|
||||||
|
|
||||||
|
const html = wrapper.html()
|
||||||
|
expect(html).toContain('Mon titre')
|
||||||
|
expect(html).toContain('Un paragraphe.')
|
||||||
|
})
|
||||||
|
})
|
||||||
574
app/components/malio/input/InputRichText.vue
Normal file
574
app/components/malio/input/InputRichText.vue
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="mergedGroupClass">
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="editorId"
|
||||||
|
:class="mergedLabelClass"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Mode lecture seule (rendu uniquement) -->
|
||||||
|
<div
|
||||||
|
v-if="!editable"
|
||||||
|
:id="editorId"
|
||||||
|
:class="mergedReadonlyClass"
|
||||||
|
>
|
||||||
|
<EditorContent :editor="editor" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode éditable -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:id="editorId"
|
||||||
|
:class="mergedEditorWrapperClass"
|
||||||
|
@click="focusEditor"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-0.5 border-b border-m-border bg-m-bg p-1"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="btn in toolbarButtons"
|
||||||
|
:key="btn.key"
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:class="btn.isActive() ? 'bg-m-primary/15 text-m-primary' : ''"
|
||||||
|
:title="btn.title"
|
||||||
|
:disabled="disabled || readonly"
|
||||||
|
:aria-label="btn.title"
|
||||||
|
:aria-pressed="btn.isActive()"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="btn.action()"
|
||||||
|
>
|
||||||
|
<IconifyIcon :icon="btn.icon" :width="18" :height="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="mx-1 h-5 w-px bg-m-border" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 flex-col items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:class="colorPickerOpen ? 'bg-m-primary/15 text-m-primary' : ''"
|
||||||
|
title="Couleur du texte"
|
||||||
|
aria-label="Couleur du texte"
|
||||||
|
:aria-expanded="colorPickerOpen"
|
||||||
|
:disabled="disabled || readonly"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="toggleColorPicker"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:format-color-text" :width="18" :height="18" />
|
||||||
|
<span
|
||||||
|
class="-mt-0.5 block h-1 w-4 rounded-sm"
|
||||||
|
:style="{ backgroundColor: currentTextColor ?? 'transparent' }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="colorPickerOpen"
|
||||||
|
class="absolute left-0 top-full z-10 mt-1 flex w-44 flex-col gap-2 rounded-md border border-m-border bg-white p-2 shadow-lg"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Palette couleur du texte"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-4 gap-1">
|
||||||
|
<button
|
||||||
|
v-for="swatch in textColorSwatches"
|
||||||
|
:key="swatch.value"
|
||||||
|
type="button"
|
||||||
|
class="h-7 w-7 rounded border border-m-border transition-transform hover:scale-110"
|
||||||
|
:class="currentTextColor === swatch.value ? 'ring-2 ring-m-primary ring-offset-1' : ''"
|
||||||
|
:style="{ backgroundColor: swatch.value }"
|
||||||
|
:title="swatch.label"
|
||||||
|
:aria-label="swatch.label"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="applyTextColor(swatch.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center gap-1 rounded border border-m-border px-2 py-1 text-xs text-m-text transition-colors hover:bg-m-bg"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="applyTextColor(null)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:format-color-marker-cancel" :width="14" :height="14" />
|
||||||
|
Aucune couleur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 flex-col items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:class="highlightPickerOpen ? 'bg-m-primary/15 text-m-primary' : ''"
|
||||||
|
title="Surlignage"
|
||||||
|
aria-label="Surlignage"
|
||||||
|
:aria-expanded="highlightPickerOpen"
|
||||||
|
:disabled="disabled || readonly"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="toggleHighlightPicker"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:marker" :width="18" :height="18" />
|
||||||
|
<span
|
||||||
|
class="-mt-0.5 block h-1 w-4 rounded-sm"
|
||||||
|
:style="{ backgroundColor: currentHighlightColor ?? 'transparent' }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="highlightPickerOpen"
|
||||||
|
class="absolute left-0 top-full z-10 mt-1 flex w-44 flex-col gap-2 rounded-md border border-m-border bg-white p-2 shadow-lg"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Palette de surlignage"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-4 gap-1">
|
||||||
|
<button
|
||||||
|
v-for="swatch in highlightSwatches"
|
||||||
|
:key="swatch.value"
|
||||||
|
type="button"
|
||||||
|
class="h-7 w-7 rounded border border-m-border transition-transform hover:scale-110"
|
||||||
|
:class="currentHighlightColor === swatch.value ? 'ring-2 ring-m-primary ring-offset-1' : ''"
|
||||||
|
:style="{ backgroundColor: swatch.value }"
|
||||||
|
:title="swatch.label"
|
||||||
|
:aria-label="swatch.label"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="applyHighlight(swatch.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center gap-1 rounded border border-m-border px-2 py-1 text-xs text-m-text transition-colors hover:bg-m-bg"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="applyHighlight(null)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:format-color-marker-cancel" :width="14" :height="14" />
|
||||||
|
Aucun surlignage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="mx-1 h-5 w-px bg-m-border" aria-hidden="true" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Annuler"
|
||||||
|
aria-label="Annuler"
|
||||||
|
:disabled="disabled || readonly || !editor?.can().undo()"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="editor?.chain().focus().undo().run()"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:undo" :width="18" :height="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Rétablir"
|
||||||
|
aria-label="Rétablir"
|
||||||
|
:disabled="disabled || readonly || !editor?.can().redo()"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="editor?.chain().focus().redo().run()"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:redo" :width="18" :height="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorContent
|
||||||
|
:editor="editor"
|
||||||
|
class="malio-rich-text flex flex-1 cursor-text"
|
||||||
|
:style="{ minHeight }"
|
||||||
|
:aria-invalid="hasError || undefined"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${editorId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ error || success || hint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
|
import { TextStyle } from '@tiptap/extension-text-style'
|
||||||
|
import Color from '@tiptap/extension-color'
|
||||||
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
|
import { Markdown } from 'tiptap-markdown'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
|
||||||
|
|
||||||
|
type OutputFormat = 'markdown' | 'html'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null | undefined
|
||||||
|
placeholder?: string
|
||||||
|
minHeight?: string
|
||||||
|
editable?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
outputFormat?: OutputFormat
|
||||||
|
groupClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
editorClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
placeholder: '',
|
||||||
|
minHeight: '160px',
|
||||||
|
editable: true,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
outputFormat: 'html',
|
||||||
|
groupClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
editorClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const editor = shallowRef<Editor>()
|
||||||
|
const isFocused = shallowRef(false)
|
||||||
|
|
||||||
|
const editorId = computed(() => props.id?.toString() || `malio-input-rich-text-${generatedId}`)
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
|
const isInteractionLocked = computed(() => props.disabled || props.readonly)
|
||||||
|
|
||||||
|
const describedBy = computed(() =>
|
||||||
|
hasError.value || hasSuccess.value || props.hint ? `${editorId.value}-describedby` : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedGroupClass = computed(() => twMerge('w-full', props.groupClass))
|
||||||
|
|
||||||
|
const mergedLabelClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'mb-1 block text-sm font-medium',
|
||||||
|
hasError.value
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'text-m-success'
|
||||||
|
: isFocused.value
|
||||||
|
? 'text-m-primary'
|
||||||
|
: 'text-m-text',
|
||||||
|
props.disabled ? 'text-black/60' : '',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedEditorWrapperClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'rich-text-wrapper flex flex-col overflow-hidden rounded-md border bg-white transition-colors',
|
||||||
|
hasError.value
|
||||||
|
? 'border-m-danger focus-within:border-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'border-m-success focus-within:border-m-success'
|
||||||
|
: isFocused.value
|
||||||
|
? 'border-m-primary'
|
||||||
|
: 'border-m-muted hover:border-m-text/60',
|
||||||
|
props.disabled ? 'cursor-not-allowed bg-m-bg/50 opacity-70' : '',
|
||||||
|
props.editorClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedReadonlyClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'malio-rich-text prose prose-sm max-w-none rounded-md border border-m-border bg-white p-3',
|
||||||
|
'prose-headings:font-semibold prose-a:text-m-primary',
|
||||||
|
'prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none',
|
||||||
|
'prose-pre:bg-m-text prose-pre:text-white',
|
||||||
|
props.editorClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const focusEditor = () => {
|
||||||
|
if (isInteractionLocked.value) return
|
||||||
|
closePickers()
|
||||||
|
editor.value?.commands.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlPattern = /<\/?[a-z][\s\S]*>/i
|
||||||
|
|
||||||
|
const normalizeEditorInput = (value: string | null | undefined): string => {
|
||||||
|
const content = (value ?? '').replace(/\r\n?/g, '\n')
|
||||||
|
if (htmlPattern.test(content)) return content
|
||||||
|
return content.split('\n').join('\n\n').replace(/\n{3,}/g, '\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptForLink = () => {
|
||||||
|
if (!editor.value) return
|
||||||
|
const previous = editor.value.getAttributes('link').href as string | undefined
|
||||||
|
const url = window.prompt('URL du lien (vide pour retirer)', previous ?? '')
|
||||||
|
if (url === null) return
|
||||||
|
if (url === '') {
|
||||||
|
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorSwatch = { label: string; value: string }
|
||||||
|
|
||||||
|
const textColorSwatches: ColorSwatch[] = [
|
||||||
|
{ label: 'Rouge', value: '#bf2600' },
|
||||||
|
{ label: 'Orange', value: '#ff8b00' },
|
||||||
|
{ label: 'Jaune', value: '#ffc400' },
|
||||||
|
{ label: 'Vert', value: '#00875a' },
|
||||||
|
{ label: 'Turquoise', value: '#00a3bf' },
|
||||||
|
{ label: 'Bleu', value: '#0747a6' },
|
||||||
|
{ label: 'Violet', value: '#5243aa' },
|
||||||
|
{ label: 'Gris', value: '#42526e' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const highlightSwatches: ColorSwatch[] = [
|
||||||
|
{ label: 'Rouge', value: '#fdd0c8' },
|
||||||
|
{ label: 'Orange', value: '#ffe2c2' },
|
||||||
|
{ label: 'Jaune', value: '#fff0b3' },
|
||||||
|
{ label: 'Vert', value: '#c6edd0' },
|
||||||
|
{ label: 'Turquoise', value: '#c1ecf0' },
|
||||||
|
{ label: 'Bleu', value: '#cce0ff' },
|
||||||
|
{ label: 'Violet', value: '#dfd8fa' },
|
||||||
|
{ label: 'Gris', value: '#dfe1e6' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const colorPickerOpen = ref(false)
|
||||||
|
const highlightPickerOpen = ref(false)
|
||||||
|
|
||||||
|
const closePickers = () => {
|
||||||
|
colorPickerOpen.value = false
|
||||||
|
highlightPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleColorPicker = () => {
|
||||||
|
highlightPickerOpen.value = false
|
||||||
|
colorPickerOpen.value = !colorPickerOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleHighlightPicker = () => {
|
||||||
|
colorPickerOpen.value = false
|
||||||
|
highlightPickerOpen.value = !highlightPickerOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTextColor = (value: string | null) => {
|
||||||
|
if (!editor.value) return
|
||||||
|
if (value === null) {
|
||||||
|
editor.value.chain().focus().unsetColor().run()
|
||||||
|
} else {
|
||||||
|
editor.value.chain().focus().setColor(value).run()
|
||||||
|
}
|
||||||
|
colorPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyHighlight = (value: string | null) => {
|
||||||
|
if (!editor.value) return
|
||||||
|
if (value === null) {
|
||||||
|
editor.value.chain().focus().unsetHighlight().run()
|
||||||
|
} else {
|
||||||
|
editor.value.chain().focus().setHighlight({ color: value }).run()
|
||||||
|
}
|
||||||
|
highlightPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTextColor = computed(() => {
|
||||||
|
const attrs = editor.value?.getAttributes('textStyle') as { color?: string } | undefined
|
||||||
|
return attrs?.color ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentHighlightColor = computed(() => {
|
||||||
|
const attrs = editor.value?.getAttributes('highlight') as { color?: string } | undefined
|
||||||
|
return attrs?.color ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolbarButtons = computed(() => {
|
||||||
|
const e = editor.value
|
||||||
|
return [
|
||||||
|
{ key: 'bold', icon: 'mdi:format-bold', title: 'Gras', isActive: () => !!e?.isActive('bold'), action: () => e?.chain().focus().toggleBold().run() },
|
||||||
|
{ key: 'italic', icon: 'mdi:format-italic', title: 'Italique', isActive: () => !!e?.isActive('italic'), action: () => e?.chain().focus().toggleItalic().run() },
|
||||||
|
{ key: 'strike', icon: 'mdi:format-strikethrough', title: 'Barré', isActive: () => !!e?.isActive('strike'), action: () => e?.chain().focus().toggleStrike().run() },
|
||||||
|
{ key: 'h2', icon: 'mdi:format-header-2', title: 'Titre H2', isActive: () => !!e?.isActive('heading', { level: 2 }), action: () => e?.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||||
|
{ key: 'h3', icon: 'mdi:format-header-3', title: 'Titre H3', isActive: () => !!e?.isActive('heading', { level: 3 }), action: () => e?.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||||
|
{ key: 'bulletList', icon: 'mdi:format-list-bulleted', title: 'Liste à puces', isActive: () => !!e?.isActive('bulletList'), action: () => e?.chain().focus().toggleBulletList().run() },
|
||||||
|
{ key: 'orderedList', icon: 'mdi:format-list-numbered', title: 'Liste numérotée', isActive: () => !!e?.isActive('orderedList'), action: () => e?.chain().focus().toggleOrderedList().run() },
|
||||||
|
{ key: 'blockquote', icon: 'mdi:format-quote-close', title: 'Citation', isActive: () => !!e?.isActive('blockquote'), action: () => e?.chain().focus().toggleBlockquote().run() },
|
||||||
|
{ key: 'code', icon: 'mdi:code-tags', title: 'Code inline', isActive: () => !!e?.isActive('code'), action: () => e?.chain().focus().toggleCode().run() },
|
||||||
|
{ key: 'codeBlock', icon: 'mdi:code-braces-box', title: 'Bloc de code', isActive: () => !!e?.isActive('codeBlock'), action: () => e?.chain().focus().toggleCodeBlock().run() },
|
||||||
|
{ key: 'link', icon: 'mdi:link-variant', title: 'Lien', isActive: () => !!e?.isActive('link'), action: promptForLink },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCurrentValue = (): string => {
|
||||||
|
if (!editor.value) return ''
|
||||||
|
if (props.outputFormat === 'html') return editor.value.getHTML()
|
||||||
|
const storage = (editor.value.storage as unknown as Record<string, { getMarkdown?: () => string } | undefined>).markdown
|
||||||
|
return storage?.getMarkdown ? storage.getMarkdown() : editor.value.getHTML()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentMousedown = (event: MouseEvent) => {
|
||||||
|
if (!colorPickerOpen.value && !highlightPickerOpen.value) return
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!target) return
|
||||||
|
const popovers = document.querySelectorAll(`#${editorId.value} [role="dialog"]`)
|
||||||
|
const triggers = document.querySelectorAll(`#${editorId.value} [aria-expanded]`)
|
||||||
|
for (const node of [...popovers, ...triggers]) {
|
||||||
|
if (node.contains(target)) return
|
||||||
|
}
|
||||||
|
closePickers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && (colorPickerOpen.value || highlightPickerOpen.value)) {
|
||||||
|
closePickers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', handleDocumentMousedown)
|
||||||
|
document.addEventListener('keydown', handleDocumentKeydown)
|
||||||
|
|
||||||
|
editor.value = new Editor({
|
||||||
|
content: normalizeEditorInput(props.modelValue),
|
||||||
|
editable: props.editable && !props.disabled && !props.readonly,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [2, 3] },
|
||||||
|
link: {
|
||||||
|
openOnClick: false,
|
||||||
|
autolink: true,
|
||||||
|
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color.configure({ types: ['textStyle'] }),
|
||||||
|
Highlight.configure({ multicolor: true }),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
}),
|
||||||
|
Markdown.configure({
|
||||||
|
html: true,
|
||||||
|
tightLists: true,
|
||||||
|
bulletListMarker: '-',
|
||||||
|
linkify: true,
|
||||||
|
breaks: false,
|
||||||
|
transformPastedText: true,
|
||||||
|
transformCopiedText: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: () => {
|
||||||
|
emit('update:modelValue', getCurrentValue())
|
||||||
|
},
|
||||||
|
onFocus: () => {
|
||||||
|
isFocused.value = true
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
isFocused.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousedown', handleDocumentMousedown)
|
||||||
|
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||||
|
editor.value?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (incoming) => {
|
||||||
|
if (!editor.value) return
|
||||||
|
if ((incoming ?? '') === getCurrentValue()) return
|
||||||
|
editor.value.commands.setContent(normalizeEditorInput(incoming), { emitUpdate: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => [props.editable, props.disabled, props.readonly], () => {
|
||||||
|
editor.value?.setEditable(props.editable && !props.disabled && !props.readonly)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.malio-rich-text :deep(.ProseMirror) {
|
||||||
|
outline: none;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(.ProseMirror > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(.ProseMirror > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: rgb(var(--m-muted));
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(h2) {
|
||||||
|
margin: 0.75rem 0 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(var(--m-text));
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(h3) {
|
||||||
|
margin: 0.65rem 0 0.4rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(var(--m-text));
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(p) {
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(ul),
|
||||||
|
.malio-rich-text :deep(ol) {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(ul) {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(ol) {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
.malio-rich-text :deep(blockquote) {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
border-left: 3px solid rgb(var(--m-border));
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
color: rgb(var(--m-muted));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
:class="mergedGroupClass"
|
:class="mergedGroupClass"
|
||||||
>
|
>
|
||||||
@@ -38,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>
|
||||||
@@ -62,6 +57,7 @@
|
|||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -138,13 +134,13 @@ const hasSuccess = computed(() => !!props.success)
|
|||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -200,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'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -213,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,21 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="mergedGroupClass">
|
||||||
class="relative mt-4 w-full"
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:name="name"
|
:name="name"
|
||||||
|
|
||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 overflow-auto"
|
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
|
||||||
:class="[
|
:class="[
|
||||||
isFilled ? 'border-black' : 'border-m-muted',
|
isFilled ? 'border-black' : 'border-m-muted',
|
||||||
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||||
hasError
|
hasError
|
||||||
? 'border-m-danger focus:border-m-danger focus:pl-[11px]'
|
? 'border-m-danger focus:border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success focus:border-m-success focus:pl-[11px]'
|
? 'border-m-success focus:border-m-success'
|
||||||
: 'focus:border-m-primary focus:pl-[11px]',
|
: 'focus:border-m-primary',
|
||||||
textInput,
|
textInput,
|
||||||
showCounterComputed ? 'pb-6' : '',
|
showCounterComputed ? 'pb-6' : '',
|
||||||
rounded,
|
rounded,
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
:class="mergedGroupClass"
|
:class="mergedGroupClass"
|
||||||
>
|
>
|
||||||
@@ -42,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',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -65,6 +63,7 @@
|
|||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -121,13 +120,13 @@ const hasSuccess = computed(() => !!props.success)
|
|||||||
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
|
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative mt-4 flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
props.groupClass,
|
props.groupClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -187,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>
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ type SelectProps = {
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
minWidth?: string
|
|
||||||
maxWidth?: string
|
|
||||||
textField?: string
|
textField?: string
|
||||||
textValue?: string
|
textValue?: string
|
||||||
textLabel?: string
|
textLabel?: string
|
||||||
@@ -88,11 +86,46 @@ describe('MalioSelect', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
await wrapper.findAll('li')[2].trigger('click')
|
await wrapper.findAll('li')[1].trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not render empty option when emptyOptionLabel is empty', async () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {
|
||||||
|
modelValue: null,
|
||||||
|
options: [
|
||||||
|
{label: 'AM', value: 'am'},
|
||||||
|
{label: 'PM', value: 'pm'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('li[role="option"]')
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items[0].text()).toBe('AM')
|
||||||
|
expect(items[1].text()).toBe('PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty option when emptyOptionLabel is provided', async () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {
|
||||||
|
modelValue: null,
|
||||||
|
options: [{label: 'AM', value: 'am'}],
|
||||||
|
emptyOptionLabel: 'Choisir...',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('li[role="option"]')
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items[0].text()).toBe('Choisir...')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the empty option with muted text style', async () => {
|
it('renders the empty option with muted text style', async () => {
|
||||||
const wrapper = mount(SelectForTest, {
|
const wrapper = mount(SelectForTest, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
ref="root"
|
ref="root"
|
||||||
:class="mergedGroupClass"
|
:class="mergedGroupClass"
|
||||||
>
|
>
|
||||||
<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',
|
||||||
@@ -97,11 +99,11 @@
|
|||||||
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%-2px)] rounded-b-md border-t-0'
|
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||||
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
|
: 'bottom-[calc(100%-4px)] rounded-t-md border-b-0',
|
||||||
hasError
|
hasError
|
||||||
? 'select-scrollbar-error'
|
? 'select-scrollbar-error'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
@@ -114,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"
|
||||||
@@ -148,6 +158,7 @@
|
|||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -169,14 +180,13 @@ const props = withDefaults(defineProps<{
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
minWidth?: string
|
|
||||||
maxWidth?: string
|
|
||||||
textField?: string
|
textField?: string
|
||||||
textValue?: string
|
textValue?: string
|
||||||
textLabel?: string
|
textLabel?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
noOptionsText?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
@@ -184,20 +194,20 @@ const props = withDefaults(defineProps<{
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
minWidth: 'w-96',
|
|
||||||
maxWidth: '',
|
|
||||||
textField: 'text-lg',
|
textField: 'text-lg',
|
||||||
textValue: 'text-lg',
|
textValue: 'text-lg',
|
||||||
textLabel: 'text-sm',
|
textLabel: 'text-sm',
|
||||||
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')
|
||||||
@@ -206,12 +216,12 @@ const buttonId = `custom-select-btn-${uid}`
|
|||||||
const listboxId = `custom-select-listbox-${uid}`
|
const listboxId = `custom-select-listbox-${uid}`
|
||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
const listHeight = ref(0)
|
const listHeight = ref(0)
|
||||||
const normalizedOptions = computed<Option[]>(() => [
|
const normalizedOptions = computed<Option[]>(() => {
|
||||||
{label: props.emptyOptionLabel, value: null},
|
if (!props.emptyOptionLabel) return props.options
|
||||||
...props.options,
|
return [{label: props.emptyOptionLabel, value: null}, ...props.options]
|
||||||
])
|
})
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge('relative mt-4 w-full', props.minWidth, props.maxWidth, props.groupClass),
|
twMerge('relative w-full h-12 flex items-center', props.groupClass),
|
||||||
)
|
)
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
@@ -301,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) {
|
||||||
@@ -318,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ type SelectCheckboxProps = {
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
minWidth?: string
|
|
||||||
maxWidth?: string
|
|
||||||
textField?: string
|
textField?: string
|
||||||
textValue?: string
|
textValue?: string
|
||||||
textLabel?: string
|
textLabel?: string
|
||||||
@@ -26,6 +24,7 @@ type SelectCheckboxProps = {
|
|||||||
displaySelectAll?: boolean
|
displaySelectAll?: boolean
|
||||||
selectAllLabel?: string
|
selectAllLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
groupClass?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
||||||
@@ -175,4 +174,12 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false)
|
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies groupClass via twMerge', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options: [], groupClass: 'mt-4'},
|
||||||
|
})
|
||||||
|
const root = wrapper.find('button').element.parentElement
|
||||||
|
expect(root?.className).toContain('mt-4')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
ref="root"
|
ref="root"
|
||||||
class="relative mt-4 w-full"
|
:class="mergedGroupClass"
|
||||||
:class="[minWidth, maxWidth]"
|
|
||||||
>
|
>
|
||||||
<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',
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
v-if="label"
|
v-if="label"
|
||||||
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
|
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
|
||||||
:class="[
|
:class="[
|
||||||
shouldFloatLabel ? 'top-2 z-30' : 'top-1/2 -translate-y-1/2',
|
isOpen ? 'top-2 z-30' : 'top-2',
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
@@ -126,11 +127,11 @@
|
|||||||
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%-2px)] rounded-b-md border-t-0'
|
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||||
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
|
: 'bottom-[calc(100%-4px)] rounded-t-md border-b-0',
|
||||||
hasError
|
hasError
|
||||||
? 'select-scrollbar-error'
|
? 'select-scrollbar-error'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
@@ -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
|
||||||
>
|
>
|
||||||
@@ -199,11 +207,13 @@
|
|||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
import Checkbox from '../checkbox/Checkbox.vue'
|
import Checkbox from '../checkbox/Checkbox.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||||
@@ -220,8 +230,6 @@ const props = withDefaults(defineProps<{
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
minWidth?: string
|
|
||||||
maxWidth?: string
|
|
||||||
textField?: string
|
textField?: string
|
||||||
textValue?: string
|
textValue?: string
|
||||||
textLabel?: string
|
textLabel?: string
|
||||||
@@ -230,6 +238,8 @@ const props = withDefaults(defineProps<{
|
|||||||
displaySelectAll?: boolean
|
displaySelectAll?: boolean
|
||||||
selectAllLabel?: string
|
selectAllLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
groupClass?: string
|
||||||
|
noOptionsText?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
@@ -237,8 +247,6 @@ const props = withDefaults(defineProps<{
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
minWidth: 'w-96',
|
|
||||||
maxWidth: '',
|
|
||||||
textField: 'text-lg',
|
textField: 'text-lg',
|
||||||
textValue: 'text-lg',
|
textValue: 'text-lg',
|
||||||
textLabel: 'text-sm',
|
textLabel: 'text-sm',
|
||||||
@@ -247,12 +255,15 @@ const props = withDefaults(defineProps<{
|
|||||||
displaySelectAll: false,
|
displaySelectAll: false,
|
||||||
selectAllLabel: 'Tout sélectionner',
|
selectAllLabel: 'Tout sélectionner',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
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')
|
||||||
@@ -262,6 +273,9 @@ const listboxId = `custom-select-listbox-${uid}`
|
|||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
const listHeight = ref(0)
|
const listHeight = ref(0)
|
||||||
const normalizedOptions = computed<Option[]>(() => props.options)
|
const normalizedOptions = computed<Option[]>(() => props.options)
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge('relative w-full h-12 flex items-center', props.groupClass),
|
||||||
|
)
|
||||||
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 isOptionSelected = computed(() =>
|
const isOptionSelected = computed(() =>
|
||||||
@@ -279,6 +293,10 @@ const shouldFloatLabel = computed(() =>
|
|||||||
const selectionSummary = computed(() =>
|
const selectionSummary = computed(() =>
|
||||||
`${props.modelValue.length}/${normalizedOptions.value.length}`
|
`${props.modelValue.length}/${normalizedOptions.value.length}`
|
||||||
)
|
)
|
||||||
|
const allSelected = computed(() =>
|
||||||
|
normalizedOptions.value.length > 0
|
||||||
|
&& normalizedOptions.value.every(opt => props.modelValue.includes(opt.value)),
|
||||||
|
)
|
||||||
const describedBy = computed(() =>
|
const describedBy = computed(() =>
|
||||||
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
|
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
|
||||||
)
|
)
|
||||||
@@ -318,18 +336,22 @@ function open() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labelTransformStyle = computed(() => {
|
const labelTransformStyle = computed(() => {
|
||||||
|
// label non flottant
|
||||||
if (!shouldFloatLabel.value) {
|
if (!shouldFloatLabel.value) {
|
||||||
return undefined
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fermé ou ouverture vers le bas : comportement classique
|
||||||
if (!isOpen.value || openDirection.value === 'down') {
|
if (!isOpen.value || openDirection.value === 'down') {
|
||||||
return {
|
return {
|
||||||
transform: 'translateY(-1.15rem) scale(0.9)',
|
transform: 'translateY(-1.15rem) scale(0.9)',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ouverture vers le haut : on remonte en fonction de la hauteur de la liste
|
||||||
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
|
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
|
||||||
const total = 4 +listHeight.value + extraOffset
|
const total = 4 +listHeight.value + extraOffset
|
||||||
|
// 18 ≈ 1.15rem pour garder la même base que votre flottant actuel
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transform: `translateY(-${total}px) scale(0.9)`,
|
transform: `translateY(-${total}px) scale(0.9)`,
|
||||||
@@ -349,19 +371,6 @@ function toggle() {
|
|||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSelected = computed(() =>
|
|
||||||
normalizedOptions.value.length > 0
|
|
||||||
&& normalizedOptions.value.every(opt => props.modelValue.includes(opt.value)),
|
|
||||||
)
|
|
||||||
|
|
||||||
function toggleAll() {
|
|
||||||
if (allSelected.value) {
|
|
||||||
emit('update:modelValue', [])
|
|
||||||
} else {
|
|
||||||
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChecked(value: string | number) {
|
function isChecked(value: string | number) {
|
||||||
return props.modelValue.includes(value)
|
return props.modelValue.includes(value)
|
||||||
}
|
}
|
||||||
@@ -369,10 +378,19 @@ 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])
|
||||||
|
}
|
||||||
|
nextTick(() => buttonRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('update:modelValue', [...props.modelValue, value])
|
function toggleAll() {
|
||||||
|
if (allSelected.value) {
|
||||||
|
emit('update:modelValue', [])
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
|
||||||
|
}
|
||||||
|
nextTick(() => buttonRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
@@ -390,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;
|
||||||
|
|||||||
154
app/components/malio/site/SiteSelector.test.ts
Normal file
154
app/components/malio/site/SiteSelector.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import SiteSelector from './SiteSelector.vue'
|
||||||
|
|
||||||
|
type Site = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteSelectorProps = {
|
||||||
|
sites: Site[]
|
||||||
|
modelValue?: string
|
||||||
|
id?: string
|
||||||
|
groupClass?: string
|
||||||
|
tileClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SiteSelectorForTest = SiteSelector as DefineComponent<SiteSelectorProps>
|
||||||
|
|
||||||
|
const sites: Site[] = [
|
||||||
|
{id: 'chatellerault', name: 'Châtellerault', color: '#2563eb'},
|
||||||
|
{id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a'},
|
||||||
|
{id: 'pommevic', name: 'Pommevic', color: '#dc2626'},
|
||||||
|
]
|
||||||
|
|
||||||
|
function mountComponent(props: SiteSelectorProps) {
|
||||||
|
return mount(SiteSelectorForTest, {props})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioSiteSelector', () => {
|
||||||
|
it('renders one tile per site with the site name', () => {
|
||||||
|
const wrapper = mountComponent({sites})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
expect(tiles).toHaveLength(3)
|
||||||
|
expect(tiles[0]!.text()).toBe('Châtellerault')
|
||||||
|
expect(tiles[1]!.text()).toBe('Saint-Jean')
|
||||||
|
expect(tiles[2]!.text()).toBe('Pommevic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="radiogroup" on the wrapper', () => {
|
||||||
|
const wrapper = mountComponent({sites})
|
||||||
|
expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects the first site by default in uncontrolled mode', () => {
|
||||||
|
const wrapper = mountComponent({sites})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
expect(tiles[0]!.attributes('aria-checked')).toBe('true')
|
||||||
|
expect(tiles[1]!.attributes('aria-checked')).toBe('false')
|
||||||
|
expect(tiles[2]!.attributes('aria-checked')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paints all tiles with the selected site color', () => {
|
||||||
|
const wrapper = mountComponent({sites, modelValue: 'saint-jean'})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
for (const tile of tiles) {
|
||||||
|
expect(tile.attributes('style')).toContain('background-color: rgb(22, 163, 74)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies opacity 1 on the selected tile and 0.4 on the others', () => {
|
||||||
|
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
expect(tiles[0]!.attributes('style')).toContain('opacity: 1')
|
||||||
|
expect(tiles[1]!.attributes('style')).toContain('opacity: 0.4')
|
||||||
|
expect(tiles[2]!.attributes('style')).toContain('opacity: 0.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the shared color when the selection changes', async () => {
|
||||||
|
const wrapper = mountComponent({sites})
|
||||||
|
let tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
expect(tiles[0]!.attributes('style')).toContain('background-color: rgb(37, 99, 235)')
|
||||||
|
|
||||||
|
await tiles[2]!.trigger('click')
|
||||||
|
tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
for (const tile of tiles) {
|
||||||
|
expect(tile.attributes('style')).toContain('background-color: rgb(220, 38, 38)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue with the clicked site id', async () => {
|
||||||
|
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
|
||||||
|
await tiles[1]!.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['saint-jean'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits change with the full selected site object', async () => {
|
||||||
|
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
|
||||||
|
await tiles[2]!.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('change')?.[0]).toEqual([
|
||||||
|
{id: 'pommevic', name: 'Pommevic', color: '#dc2626'},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects modelValue in controlled mode', () => {
|
||||||
|
const wrapper = mountComponent({sites, modelValue: 'pommevic'})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
expect(tiles[0]!.attributes('aria-checked')).toBe('false')
|
||||||
|
expect(tiles[1]!.attributes('aria-checked')).toBe('false')
|
||||||
|
expect(tiles[2]!.attributes('aria-checked')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches selection on click in uncontrolled mode', async () => {
|
||||||
|
const wrapper = mountComponent({sites})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
|
||||||
|
await tiles[1]!.trigger('click')
|
||||||
|
|
||||||
|
expect(tiles[0]!.attributes('aria-checked')).toBe('false')
|
||||||
|
expect(tiles[1]!.attributes('aria-checked')).toBe('true')
|
||||||
|
expect(tiles[2]!.attributes('aria-checked')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets roving tabindex (active = 0, others = -1)', () => {
|
||||||
|
const wrapper = mountComponent({sites, modelValue: 'saint-jean'})
|
||||||
|
const tiles = wrapper.findAll('[role="radio"]')
|
||||||
|
expect(tiles[0]!.attributes('tabindex')).toBe('-1')
|
||||||
|
expect(tiles[1]!.attributes('tabindex')).toBe('0')
|
||||||
|
expect(tiles[2]!.attributes('tabindex')).toBe('-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges groupClass, tileClass and labelClass via twMerge', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
sites,
|
||||||
|
groupClass: 'rounded-none bg-black',
|
||||||
|
tileClass: 'py-10',
|
||||||
|
labelClass: 'text-xs',
|
||||||
|
})
|
||||||
|
const group = wrapper.find('[role="radiogroup"]')
|
||||||
|
expect(group.classes()).toContain('rounded-none')
|
||||||
|
expect(group.classes()).toContain('bg-black')
|
||||||
|
|
||||||
|
const tile = wrapper.find('[role="radio"]')
|
||||||
|
expect(tile.classes()).toContain('py-10')
|
||||||
|
expect(tile.classes()).not.toContain('py-4')
|
||||||
|
|
||||||
|
const label = tile.find('span')
|
||||||
|
expect(label.classes()).toContain('text-xs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a custom id when provided', () => {
|
||||||
|
const wrapper = mountComponent({sites, id: 'my-selector'})
|
||||||
|
expect(wrapper.find('[role="radiogroup"]').attributes('id')).toBe('my-selector')
|
||||||
|
})
|
||||||
|
})
|
||||||
104
app/components/malio/site/SiteSelector.vue
Normal file
104
app/components/malio/site/SiteSelector.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-bind="$attrs"
|
||||||
|
:id="componentId"
|
||||||
|
role="radiogroup"
|
||||||
|
:class="mergedGroupClass"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="activeId === site.id"
|
||||||
|
:tabindex="activeId === site.id ? 0 : -1"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: activeColor,
|
||||||
|
opacity: activeId === site.id ? 1 : 0.4,
|
||||||
|
}"
|
||||||
|
:class="mergedTileClass"
|
||||||
|
@click="select(site.id)"
|
||||||
|
>
|
||||||
|
<span :class="mergedLabelClass">{{ site.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, useId} from 'vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioSiteSelector', inheritAttrs: false})
|
||||||
|
|
||||||
|
type Site = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
sites: Site[]
|
||||||
|
modelValue?: string
|
||||||
|
id?: string
|
||||||
|
groupClass?: string
|
||||||
|
tileClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
}>(), {
|
||||||
|
modelValue: undefined,
|
||||||
|
id: '',
|
||||||
|
groupClass: '',
|
||||||
|
tileClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'change', site: Site): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const componentId = computed(() => props.id || `malio-site-selector-${generatedId}`)
|
||||||
|
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const localValue = ref(props.sites.length > 0 ? props.sites[0]!.id : '')
|
||||||
|
|
||||||
|
const activeId = computed(() =>
|
||||||
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeColor = computed(() =>
|
||||||
|
props.sites.find((s) => s.id === activeId.value)?.color ?? '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'flex w-full',
|
||||||
|
props.groupClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedTileClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'flex-1 cursor-pointer px-6 py-4 text-center transition-opacity focus:outline-none',
|
||||||
|
props.tileClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedLabelClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'text-white font-bold uppercase tracking-wide',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function select(id: string) {
|
||||||
|
const site = props.sites.find((s) => s.id === id)
|
||||||
|
if (!site) return
|
||||||
|
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = id
|
||||||
|
}
|
||||||
|
emit('update:modelValue', id)
|
||||||
|
emit('change', site)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
195
app/story/datatable/datatable.story.vue
Normal file
195
app/story/datatable/datatable.story.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Data/DataTable">
|
||||||
|
<Variant title="Avec filtres et pagination">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="paginatedItems"
|
||||||
|
:total-items="filteredItems.length"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<template #header-nom>
|
||||||
|
<input
|
||||||
|
v-model="filtreNom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom"
|
||||||
|
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template #header-ville>
|
||||||
|
<select
|
||||||
|
:value="filtreVille ?? ''"
|
||||||
|
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-sm outline-none"
|
||||||
|
@change="filtreVille = ($event.target as HTMLSelectElement).value || null"
|
||||||
|
>
|
||||||
|
<option value="">Ville</option>
|
||||||
|
<option value="Paris">Paris</option>
|
||||||
|
<option value="Lyon">Lyon</option>
|
||||||
|
<option value="Marseille">Marseille</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
<template #cell-montant="{ item }">
|
||||||
|
<strong>{{ item.montant }} €</strong>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Sans filtres">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columnsSimple"
|
||||||
|
:items="simpleItems"
|
||||||
|
:total-items="simpleItems.length"
|
||||||
|
v-model:page="pageSimple"
|
||||||
|
v-model:per-page="perPageSimple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="État vide">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="[]"
|
||||||
|
:total-items="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Lignes non cliquables">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columnsSimple"
|
||||||
|
:items="simpleItems.slice(0, 3)"
|
||||||
|
:total-items="3"
|
||||||
|
:row-clickable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Sans filtre ni pagination">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columnsSimple"
|
||||||
|
:items="simpleItems.slice(0, 5)"
|
||||||
|
:total-items="0"
|
||||||
|
:row-clickable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioDataTable
|
||||||
|
|
||||||
|
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto-généré | Identifiant HTML |
|
||||||
|
| `columns` | `{ key: string, label: string }[]` | **requis** | Définition des colonnes |
|
||||||
|
| `items` | `Record<string, any>[]` | **requis** | Données à afficher |
|
||||||
|
| `totalItems` | `number` | **requis** | Total pour la pagination |
|
||||||
|
| `page` | `number` | `1` | Page courante (v-model) |
|
||||||
|
| `perPage` | `number` | `10` | Lignes par page (v-model) |
|
||||||
|
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
|
||||||
|
| `rowClickable` | `boolean` | `true` | Lignes cliquables |
|
||||||
|
| `tableClass` | `string` | `''` | Classes CSS sur le wrapper (twMerge) |
|
||||||
|
| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide |
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
| Slot | Scope | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `#header-{key}` | `{ column }` | Filtre dans le `<th>` (placeholder = label). Fallback : texte du label |
|
||||||
|
| `#cell-{key}` | `{ item, column }` | Contenu du `<td>`. Fallback : `item[key]` |
|
||||||
|
| `#empty` | — | Contenu état vide. Fallback : `emptyMessage` |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `update:page` | `number` | Changement de page |
|
||||||
|
| `update:per-page` | `number` | Changement du nb de lignes (reset page à 1) |
|
||||||
|
| `row-click` | `Record<string, any>` | Clic sur une ligne |
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
- ≤ 5 pages : toutes affichées
|
||||||
|
- \> 5 pages : page 1 … [voisin] **[courante]** [voisin] … dernière
|
||||||
|
- Boutons Prev/Next toujours visibles, désactivés aux extrêmes
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `<th scope="col">` sur chaque en-tête
|
||||||
|
- `<nav aria-label="Pagination">` autour de la pagination
|
||||||
|
- Page courante avec `aria-current="page"`
|
||||||
|
- Lignes cliquables : `tabindex="0"` + Enter/Space
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import MalioDataTable from '../../components/malio/datatable/DataTable.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'DataTableStory' })
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'prenom', label: 'Prénom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
{ key: 'montant', label: 'Montant' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columnsSimple = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const allItems = [
|
||||||
|
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
|
||||||
|
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
|
||||||
|
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
|
||||||
|
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
|
||||||
|
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
|
||||||
|
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
|
||||||
|
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
|
||||||
|
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
|
||||||
|
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
|
||||||
|
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
|
||||||
|
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
|
||||||
|
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const simpleItems = allItems.map(i => ({ nom: i.nom, ville: i.ville }))
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(5)
|
||||||
|
const filtreNom = ref('')
|
||||||
|
const filtreVille = ref<string | number | null>(null)
|
||||||
|
|
||||||
|
const pageSimple = ref(1)
|
||||||
|
const perPageSimple = ref(10)
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
return allItems.filter((item) => {
|
||||||
|
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
|
||||||
|
if (filtreVille.value && item.ville !== filtreVille.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage.value
|
||||||
|
return filteredItems.value.slice(start, start + perPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onRowClick(item: Record<string, unknown>) {
|
||||||
|
alert(`Clic sur ${item.nom} ${item.prenom}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
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>
|
||||||
76
app/story/date/dateTime.story.vue
Normal file
76
app/story/date/dateTime.story.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Date/DateTime">
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Date et heure"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Rendez-vous"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="boundedValue"
|
||||||
|
label="Créneau"
|
||||||
|
:min="todayIso"
|
||||||
|
:max="maxIso"
|
||||||
|
hint="Entre aujourd'hui et +30 jours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Date limite"
|
||||||
|
error="Date et heure requises"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Désactivé"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="initialValue"
|
||||||
|
label="Lecture seule"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioDateTime from '../../components/malio/date/DateTime.vue'
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const todayIso = toIso(now)
|
||||||
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||||
|
|
||||||
|
const simpleValue = ref<string | null>(null)
|
||||||
|
const initialValue = ref<string | null>('2026-05-20T14:30:00')
|
||||||
|
const boundedValue = ref<string | null>(null)
|
||||||
|
const errorValue = ref<string | null>(null)
|
||||||
|
</script>
|
||||||
75
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>
|
||||||
@@ -1,20 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'DrawerStory' })
|
||||||
|
|
||||||
|
const showRight = ref(false)
|
||||||
|
const showLeft = ref(false)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const showNoDismiss = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Story title="Overlay/Drawer">
|
<Story title="Overlay/Drawer">
|
||||||
<Variant title="Simple">
|
<Variant title="Droite (défaut)">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
@click="showSimple = true"
|
@click="showRight = true"
|
||||||
>
|
>
|
||||||
Ouvrir le drawer
|
Ouvrir à droite
|
||||||
</button>
|
</button>
|
||||||
<MalioDrawer v-model="showSimple" title="Détails">
|
<MalioDrawer v-model="showRight">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">Détails</h2>
|
||||||
|
</template>
|
||||||
<p>Contenu simple du drawer.</p>
|
<p>Contenu simple du drawer.</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="Avec formulaire">
|
<Variant title="Gauche">
|
||||||
|
<div class="p-4">
|
||||||
|
<button
|
||||||
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
|
@click="showLeft = true"
|
||||||
|
>
|
||||||
|
Ouvrir à gauche
|
||||||
|
</button>
|
||||||
|
<MalioDrawer v-model="showLeft" side="left">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">Navigation</h2>
|
||||||
|
</template>
|
||||||
|
<p>Ce drawer glisse depuis la gauche.</p>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Avec footer collant">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
@@ -22,102 +53,38 @@
|
|||||||
>
|
>
|
||||||
Ouvrir le formulaire
|
Ouvrir le formulaire
|
||||||
</button>
|
</button>
|
||||||
<MalioDrawer v-model="showForm" title="Nouveau contact">
|
<MalioDrawer v-model="showForm">
|
||||||
<div class="flex flex-col gap-4">
|
<template #header>
|
||||||
<MalioInputText v-model="formNom" label="Nom" />
|
<h2 class="text-xl font-bold">Nouveau contact</h2>
|
||||||
<MalioInputText v-model="formPrenom" label="Prénom" />
|
</template>
|
||||||
<MalioButton label="Enregistrer" button-class="w-full" @click="showForm = false" />
|
<div class="flex flex-col gap-4 py-2">
|
||||||
|
<MalioInputText label="Nom" />
|
||||||
|
<MalioInputText label="Prénom" />
|
||||||
</div>
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
||||||
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="Sans bouton fermer">
|
<Variant title="Non dismissable">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
@click="showNoClose = true"
|
@click="showNoDismiss = true"
|
||||||
>
|
>
|
||||||
Ouvrir (sans croix)
|
Ouvrir
|
||||||
</button>
|
</button>
|
||||||
<MalioDrawer v-model="showNoClose" title="Information" :show-close="false">
|
<MalioDrawer v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||||
<p>Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
<template #header>
|
||||||
</MalioDrawer>
|
<h2 class="text-xl font-bold">Action requise</h2>
|
||||||
</div>
|
</template>
|
||||||
</Variant>
|
<p>Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
||||||
|
|
||||||
<Variant title="Largeur personnalisée">
|
|
||||||
<div class="p-4">
|
|
||||||
<button
|
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
|
||||||
@click="showWide = true"
|
|
||||||
>
|
|
||||||
Ouvrir (large)
|
|
||||||
</button>
|
|
||||||
<MalioDrawer v-model="showWide" title="Drawer large" drawer-class="max-w-2xl">
|
|
||||||
<p>Ce drawer utilise une largeur personnalisée via la prop drawerClass.</p>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
</Story>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<docs lang="md">
|
|
||||||
# MalioDrawer
|
|
||||||
|
|
||||||
Panneau latéral (drawer) qui s'ouvre depuis la droite avec un fond semi-transparent.
|
|
||||||
|
|
||||||
## Props détaillées
|
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
|
||||||
|------|------|--------|-------------|
|
|
||||||
| `id` | `string` | auto-généré | Identifiant HTML du drawer |
|
|
||||||
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
|
||||||
| `title` | `string` | `''` | Titre affiché dans le header |
|
|
||||||
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
|
||||||
| `drawerClass` | `string` | `''` | Classes CSS additionnelles sur le panneau (fusionnées via `twMerge`) |
|
|
||||||
|
|
||||||
## Comportement
|
|
||||||
|
|
||||||
- Le drawer s'ouvre en glissant depuis la droite avec une transition
|
|
||||||
- Un backdrop semi-transparent couvre le reste de la page
|
|
||||||
- Clic sur le backdrop ferme le drawer
|
|
||||||
- Bouton de fermeture (croix) en haut à droite, masquable via `showClose`
|
|
||||||
- Contenu scrollable si plus haut que la fenêtre
|
|
||||||
- Teleport vers `<body>` pour éviter les problèmes de z-index
|
|
||||||
|
|
||||||
## Accessibilité
|
|
||||||
|
|
||||||
- `role="dialog"` et `aria-modal="true"` sur le panneau
|
|
||||||
- `aria-labelledby` lié au titre
|
|
||||||
- Bouton fermer avec `aria-label="Fermer"`
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
| Event | Payload | Description |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `update:modelValue` | `boolean` | Émis à la fermeture (backdrop ou bouton) |
|
|
||||||
|
|
||||||
## Slots
|
|
||||||
|
|
||||||
| Slot | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `default` | Contenu du drawer |
|
|
||||||
</docs>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import MalioDrawer from '../../components/malio/drawer/Drawer.vue'
|
|
||||||
import MalioInputText from '../../components/malio/input/InputText.vue'
|
|
||||||
import MalioButton from '../../components/malio/button/Button.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'DrawerStory' })
|
|
||||||
|
|
||||||
const showSimple = ref(false)
|
|
||||||
const showForm = ref(false)
|
|
||||||
const showNoClose = ref(false)
|
|
||||||
const showWide = ref(false)
|
|
||||||
|
|
||||||
const formNom = ref('Dupont')
|
|
||||||
const formPrenom = ref('Jean')
|
|
||||||
</script>
|
|
||||||
|
|||||||
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>
|
||||||
221
app/story/input/inputRichText.story.vue
Normal file
221
app/story/input/inputRichText.story.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Input/RichText">
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Note"
|
||||||
|
placeholder="Écrire ici…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec contenu initial + hint</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Description"
|
||||||
|
hint="Mise en forme via la barre d'outils"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Compte-rendu"
|
||||||
|
error="Le compte-rendu doit faire au moins 20 caractères"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="successValue"
|
||||||
|
label="Compte-rendu"
|
||||||
|
success="Compte-rendu validé"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="disabledValue"
|
||||||
|
label="Note"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="readonlyValue"
|
||||||
|
label="Note"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 lg:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Affichage seul (editable=false)</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
:model-value="readonlyValue"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 lg:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sortie HTML</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="htmlValue"
|
||||||
|
label="Article"
|
||||||
|
output-format="html"
|
||||||
|
min-height="200px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 lg:col-span-2">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Couleurs & surlignage</h2>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="colorValue"
|
||||||
|
label="Note colorée"
|
||||||
|
output-format="html"
|
||||||
|
min-height="180px"
|
||||||
|
hint="Tester les boutons couleur du texte et surlignage (palettes Jira-like)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioInputRichText
|
||||||
|
|
||||||
|
Éditeur de texte riche basé sur **TipTap v3** + **StarterKit** + **tiptap-markdown**.
|
||||||
|
Sortie en **HTML** (par défaut) ou en **markdown**. Aligné sur le thème Malio
|
||||||
|
(couleurs `m-*`, icônes `mdi:*`, états error / success / hint).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
### id
|
||||||
|
|
||||||
|
- Type: `string`
|
||||||
|
- Description: Identifiant HTML.
|
||||||
|
- Comportement: Généré automatiquement si non fourni (`malio-input-rich-text-…`).
|
||||||
|
|
||||||
|
### label
|
||||||
|
|
||||||
|
- Type: `string`
|
||||||
|
- Description: Label affiché au-dessus de l'éditeur.
|
||||||
|
- Comportement: Change de couleur selon l'état (focus → `m-primary`, error → `m-danger`, success → `m-success`).
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
|
||||||
|
- Type: `string | null | undefined`
|
||||||
|
- Description: Contenu de l'éditeur (markdown ou HTML selon `outputFormat`).
|
||||||
|
- Comportement: `v-model` ; sync bidirectionnelle.
|
||||||
|
|
||||||
|
### placeholder
|
||||||
|
|
||||||
|
- Type: `string`
|
||||||
|
- Défaut: `''`
|
||||||
|
- Description: Texte affiché quand l'éditeur est vide.
|
||||||
|
|
||||||
|
### minHeight
|
||||||
|
|
||||||
|
- Type: `string`
|
||||||
|
- Défaut: `160px`
|
||||||
|
- Description: Hauteur minimale de la zone d'édition (CSS valid value).
|
||||||
|
|
||||||
|
### editable
|
||||||
|
|
||||||
|
- Type: `boolean`
|
||||||
|
- Défaut: `true`
|
||||||
|
- Description: `false` → mode affichage seul, **toolbar masquée**, contenu rendu en `prose`.
|
||||||
|
|
||||||
|
### disabled
|
||||||
|
|
||||||
|
- Type: `boolean`
|
||||||
|
- Défaut: `false`
|
||||||
|
- Description: Désactive l'édition et la toolbar (opacité réduite).
|
||||||
|
|
||||||
|
### readonly
|
||||||
|
|
||||||
|
- Type: `boolean`
|
||||||
|
- Défaut: `false`
|
||||||
|
- Description: Lecture seule (toolbar visible mais désactivée, pas de saisie).
|
||||||
|
|
||||||
|
### hint / error / success
|
||||||
|
|
||||||
|
- Type: `string`
|
||||||
|
- Description: Messages contextuels affichés sous l'éditeur.
|
||||||
|
- Priorité: `error` > `success` > `hint`.
|
||||||
|
|
||||||
|
### outputFormat
|
||||||
|
|
||||||
|
- Type: `'markdown' | 'html'`
|
||||||
|
- Défaut: `'html'`
|
||||||
|
- Description: Format émis dans `update:modelValue`.
|
||||||
|
- `html` : utilise `editor.getHTML()`.
|
||||||
|
- `markdown` : utilise `tiptap-markdown` (`getMarkdown()`).
|
||||||
|
|
||||||
|
### groupClass / labelClass / editorClass
|
||||||
|
|
||||||
|
- Type: `string`
|
||||||
|
- Description: Classes Tailwind additionnelles fusionnées via `twMerge` pour override.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Toolbar
|
||||||
|
|
||||||
|
Boutons (icônes `mdi:*`) :
|
||||||
|
|
||||||
|
- Gras, Italique, Barré
|
||||||
|
- Titre H2, Titre H3
|
||||||
|
- Liste à puces, Liste numérotée
|
||||||
|
- Citation
|
||||||
|
- Code inline, Bloc de code
|
||||||
|
- Lien (prompt URL ; vide pour retirer)
|
||||||
|
- Couleur du texte (palette de 8 swatches + reset)
|
||||||
|
- Surlignage (palette de 8 swatches + reset)
|
||||||
|
- Annuler / Rétablir
|
||||||
|
|
||||||
|
Les palettes couleur/surlignage s'ouvrent en popover sous leur bouton.
|
||||||
|
Fermeture : clic sur un swatch, clic en dehors, ou touche **Échap**.
|
||||||
|
|
||||||
|
> Les couleurs et surlignages ne sont **pas persistés en markdown** (spec Markdown ne couvre pas la couleur). Pour préserver les couleurs au save/reload, utiliser `output-format="html"`.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- Le label est lié à la zone d'édition via `for` / `id`.
|
||||||
|
- `aria-invalid="true"` sur la zone d'édition en cas d'erreur.
|
||||||
|
- `aria-describedby` référence le message d'erreur / succès / hint.
|
||||||
|
- Boutons toolbar : `aria-pressed` reflète l'état actif, `aria-label` pour l'usage screen-reader.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
|
||||||
|
- Émis à chaque modification du contenu.
|
||||||
|
- Payload : `string` (markdown ou HTML selon `outputFormat`).
|
||||||
|
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioInputRichText from '../../components/malio/input/InputRichText.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const hintValue = ref('## Titre\n\nUn paragraphe avec du **gras**, de l\'*italique* et un [lien](https://malio.fr).')
|
||||||
|
const errorValue = ref('Trop court')
|
||||||
|
const successValue = ref('Tout est bon de mon côté.')
|
||||||
|
const disabledValue = ref('Contenu indisponible.')
|
||||||
|
const readonlyValue = ref('## Compte-rendu\n\n- Point 1\n- Point 2\n\n> Citation importante')
|
||||||
|
const htmlValue = ref('<p>Contenu <strong>riche</strong>.</p>')
|
||||||
|
const colorValue = ref('<p>Sélectionner du texte puis utiliser les boutons <span style="color: #bf2600">couleur</span> ou <mark data-color="#fff0b3" style="background-color: #fff0b3">surlignage</mark>.</p>')
|
||||||
|
</script>
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Story title="Input/Checkbox">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-model="simpleValue"
|
|
||||||
label="Accepter les conditions"
|
|
||||||
/>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<docs lang="md">
|
|
||||||
# MalioCheckbox
|
|
||||||
|
|
||||||
Composant checkbox custom avec `v-model`, message d'aide, et états visuels
|
|
||||||
`error` / `success`.
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### id
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Identifiant HTML du checkbox.
|
|
||||||
- Comportement: si absent, un id unique est généré automatiquement.
|
|
||||||
|
|
||||||
### label
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Texte affiche a cote de la case.
|
|
||||||
|
|
||||||
### name
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Attribut `name` du champ.
|
|
||||||
|
|
||||||
### modelValue
|
|
||||||
|
|
||||||
- Type: `boolean | null | undefined`
|
|
||||||
- Description: État coche du composant.
|
|
||||||
|
|
||||||
### inputClass
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Classes supplémentaires appliquées a l'input natif.
|
|
||||||
|
|
||||||
### labelClass
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Classes supplémentaires appliquées au label.
|
|
||||||
|
|
||||||
### groupClass
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Classes supplémentaires appliquées au conteneur.
|
|
||||||
|
|
||||||
### required
|
|
||||||
|
|
||||||
- Type: `boolean`
|
|
||||||
- Description: Ajoute l'attribut HTML `required`.
|
|
||||||
|
|
||||||
### disabled
|
|
||||||
|
|
||||||
- Type: `boolean`
|
|
||||||
- Description: Désactive le composant.
|
|
||||||
|
|
||||||
### readonly
|
|
||||||
|
|
||||||
- Type: `boolean`
|
|
||||||
- Description: Empêche la mise a jour du `v-model` tout en gardant
|
|
||||||
l'affichage courant.
|
|
||||||
|
|
||||||
### hint
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Message d'aide affiche sous le checkbox.
|
|
||||||
|
|
||||||
### error
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Message d'erreur.
|
|
||||||
- Effet: prioritaire sur `success`, applique `aria-invalid` et la couleur
|
|
||||||
d'erreur au texte et a la case.
|
|
||||||
|
|
||||||
### success
|
|
||||||
|
|
||||||
- Type: `string`
|
|
||||||
- Description: Message de succès.
|
|
||||||
- Effet: applique la couleur de succès au texte et a la case si `error`
|
|
||||||
est absent.
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## Accessibilité
|
|
||||||
|
|
||||||
- `aria-invalid` est active si `error` existe.
|
|
||||||
- `aria-describedby` pointe vers le message affiche.
|
|
||||||
- L'input natif reste present pour conserver le comportement formulaire.
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## Event
|
|
||||||
|
|
||||||
### update:modelValue
|
|
||||||
|
|
||||||
- Émis a chaque changement de l'état coche.
|
|
||||||
- Retourne un booléen `true` ou `false`.
|
|
||||||
</docs>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref} from 'vue'
|
|
||||||
import MalioCheckbox from '../components/malio/Checkbox.vue'
|
|
||||||
|
|
||||||
const simpleValue = ref(false)
|
|
||||||
</script>
|
|
||||||
116
app/story/site/siteSelector.story.vue
Normal file
116
app/story/site/siteSelector.story.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Site/Selector">
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Trois sites</h2>
|
||||||
|
<MalioSiteSelector v-model="threeValue" :sites="sites" />
|
||||||
|
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ threeValue }}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
|
||||||
|
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Cinq sites</h2>
|
||||||
|
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non contrôlé</h2>
|
||||||
|
<MalioSiteSelector :sites="sites" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioSiteSelector
|
||||||
|
|
||||||
|
Sélecteur horizontal pour choisir **un site** (usine ou lieu) parmi une liste. Les tuiles occupent une largeur proportionnelle du conteneur. La couleur du site sélectionné est appliquée à toutes les tuiles ; la tuile active est opaque (opacité 1), les autres sont atténuées (opacité 0.4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
### sites
|
||||||
|
|
||||||
|
- Type : `Array<{ id: string; name: string; color: string }>`
|
||||||
|
- Requis : oui
|
||||||
|
- Description : Liste des sites à afficher. `color` est un hex (ex : `'#0055ff'`). La couleur du site actuellement sélectionné est appliquée à toutes les tuiles.
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
|
||||||
|
- Type : `string`
|
||||||
|
- Description : `id` du site sélectionné (v-model). Sans `v-model`, le premier site est sélectionné par défaut (mode non contrôlé).
|
||||||
|
|
||||||
|
### id
|
||||||
|
|
||||||
|
- Type : `string`
|
||||||
|
- Description : Identifiant HTML du conteneur. Auto-généré si absent.
|
||||||
|
|
||||||
|
### groupClass / tileClass / labelClass
|
||||||
|
|
||||||
|
- Type : `string`
|
||||||
|
- Description : Classes Tailwind additionnelles fusionnées via `twMerge` sur, respectivement, le conteneur `<div role="radiogroup">`, chaque tuile et le libellé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
|
||||||
|
- **Toujours un site sélectionné.** Re-cliquer sur la tuile active ne la désélectionne pas.
|
||||||
|
- **Couleur partagée.** Le `background-color` de toutes les tuiles suit la couleur du site sélectionné. Changer de site met à jour instantanément la couleur de la bande.
|
||||||
|
- **Pas de gestion d'overflow** : les tuiles se répartissent proportionnellement sur toute la largeur disponible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `role="radiogroup"` sur le conteneur.
|
||||||
|
- `role="radio"` avec `aria-checked` sur chaque tuile.
|
||||||
|
- Roving `tabindex` : la tuile active est focusable (`tabindex="0"`), les autres sont exclues du tab order (`tabindex="-1"`).
|
||||||
|
- Activation par Enter/Space via l'élément `<button>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
|
||||||
|
- Émis au clic sur une tuile.
|
||||||
|
- Retourne l'`id` (`string`) du site sélectionné.
|
||||||
|
|
||||||
|
### change
|
||||||
|
|
||||||
|
- Émis au clic sur une tuile, en complément de `update:modelValue`.
|
||||||
|
- Retourne l'objet `Site` complet (`{ id, name, color }`) — utile pour déclencher des actions (appel API, filtrage…) sans avoir à relire le tableau `sites` côté consommateur.
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import MalioSiteSelector from '../../components/malio/site/SiteSelector.vue'
|
||||||
|
|
||||||
|
const sites = [
|
||||||
|
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
|
||||||
|
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
|
||||||
|
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sitesTwo = [
|
||||||
|
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
|
||||||
|
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sitesFive = [
|
||||||
|
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
|
||||||
|
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
|
||||||
|
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
|
||||||
|
{ id: 's4', name: 'Site 4', color: '#ec4899' },
|
||||||
|
{ id: 's5', name: 'Site 5', color: '#6366f1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const threeValue = ref('chatellerault')
|
||||||
|
const twoValue = ref('nord')
|
||||||
|
const fiveValue = ref('s3')
|
||||||
|
</script>
|
||||||
966
docs/superpowers/plans/2026-03-24-datatable.md
Normal file
966
docs/superpowers/plans/2026-03-24-datatable.md
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
# MalioDataTable 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:** Create a presentational data table component with pagination, slot-based column filters, and clickable rows.
|
||||||
|
|
||||||
|
**Architecture:** Single component `MalioDataTable` in `app/components/malio/datatable/DataTable.vue`. Uses `MalioSelect` internally for the per-page selector and `MalioButton variant="tertiary"` for Prev/Next pagination buttons. All data is provided by the parent via props; the component emits events for page/perPage changes and row clicks.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 Composition API, TypeScript, Tailwind CSS, tailwind-merge, Vitest + @vue/test-utils
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-datatable-design.md`
|
||||||
|
|
||||||
|
**Skill:** Follow `creating-malio-component` workflow (component → tests → playground → story → CHANGELOG → COMPONENTS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|---------------|
|
||||||
|
| `app/components/malio/datatable/DataTable.vue` | Create | Main component |
|
||||||
|
| `app/components/malio/datatable/DataTable.test.ts` | Create | Unit tests |
|
||||||
|
| `.playground/pages/composant/datatable/datatable.vue` | Create | Playground page |
|
||||||
|
| `app/story/datatable/datatable.story.vue` | Create | Histoire story + docs |
|
||||||
|
| `CHANGELOG.md` | Modify | Add entry |
|
||||||
|
| `COMPONENTS.md` | Modify | Add documentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Write DataTable component — table rendering (no pagination yet)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/malio/datatable/DataTable.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component with table rendering only**
|
||||||
|
|
||||||
|
The component renders a `<table>` with `<thead>` and `<tbody>`. No pagination yet — just the table structure, columns, items, slots, and row click.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div :id="componentId" class="w-full" v-bind="attrs">
|
||||||
|
<table :class="twMerge('w-full border-collapse', tableClass)">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-m-surface">
|
||||||
|
<th
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
scope="col"
|
||||||
|
class="border-b-2 border-m-border px-3 py-2 text-left align-middle"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
v-if="$slots[`header-${col.key}`]"
|
||||||
|
:name="`header-${col.key}`"
|
||||||
|
:column="col"
|
||||||
|
/>
|
||||||
|
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
:class="rowClickable ? 'cursor-pointer hover:bg-m-bg' : ''"
|
||||||
|
:tabindex="rowClickable ? 0 : undefined"
|
||||||
|
data-test="row"
|
||||||
|
@click="rowClickable ? emit('row-click', item) : undefined"
|
||||||
|
@keydown.enter="rowClickable ? emit('row-click', item) : undefined"
|
||||||
|
@keydown.space.prevent="rowClickable ? emit('row-click', item) : undefined"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
class="border-b border-m-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
v-if="$slots[`cell-${col.key}`]"
|
||||||
|
:name="`cell-${col.key}`"
|
||||||
|
:item="item"
|
||||||
|
:column="col"
|
||||||
|
/>
|
||||||
|
<template v-else>{{ item[col.key] }}</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!items.length" data-test="empty-row">
|
||||||
|
<td
|
||||||
|
:colspan="columns.length"
|
||||||
|
class="px-3 py-8 text-center text-m-muted"
|
||||||
|
>
|
||||||
|
<slot name="empty">{{ emptyMessage }}</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useAttrs, useId } from 'vue'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MalioDataTable', inheritAttrs: false })
|
||||||
|
|
||||||
|
type DataTableColumn = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
columns: DataTableColumn[]
|
||||||
|
items: Record<string, any>[]
|
||||||
|
totalItems: number
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
perPageOptions?: number[]
|
||||||
|
rowClickable?: boolean
|
||||||
|
tableClass?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
page: 1,
|
||||||
|
perPage: 10,
|
||||||
|
perPageOptions: () => [10, 25, 50],
|
||||||
|
rowClickable: true,
|
||||||
|
tableClass: '',
|
||||||
|
emptyMessage: 'Aucune donnée',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:page', value: number): void
|
||||||
|
(e: 'update:per-page', value: number): void
|
||||||
|
(e: 'row-click', item: Record<string, any>): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file was created**
|
||||||
|
|
||||||
|
Run: `ls app/components/malio/datatable/DataTable.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Write tests for table rendering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/malio/datatable/DataTable.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests for table rendering, slots, row click, empty state**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
import DataTable from './DataTable.vue'
|
||||||
|
|
||||||
|
type DataTableProps = {
|
||||||
|
id?: string
|
||||||
|
columns?: { key: string; label: string }[]
|
||||||
|
items?: Record<string, any>[]
|
||||||
|
totalItems?: number
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
perPageOptions?: number[]
|
||||||
|
rowClickable?: boolean
|
||||||
|
tableClass?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultItems = [
|
||||||
|
{ nom: 'Dupont', ville: 'Paris' },
|
||||||
|
{ nom: 'Martin', ville: 'Lyon' },
|
||||||
|
{ nom: 'Bernard', ville: 'Marseille' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function mountComponent(props: DataTableProps = {}, slots?: Record<string, any>) {
|
||||||
|
return mount(DataTableForTest, {
|
||||||
|
props: {
|
||||||
|
columns: defaultColumns,
|
||||||
|
items: defaultItems,
|
||||||
|
totalItems: 3,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
slots,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioSelect: {
|
||||||
|
template: '<div data-test="malio-select"><slot /></div>',
|
||||||
|
props: ['modelValue', 'options'],
|
||||||
|
},
|
||||||
|
MalioButton: {
|
||||||
|
template: '<button data-test="malio-button" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
|
||||||
|
props: ['label', 'disabled', 'variant', 'buttonClass'],
|
||||||
|
emits: ['click'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioDataTable', () => {
|
||||||
|
describe('Table rendering', () => {
|
||||||
|
it('renders column headers as text when no header slot', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const headers = wrapper.findAll('th')
|
||||||
|
expect(headers).toHaveLength(2)
|
||||||
|
expect(headers[0].text()).toBe('Nom')
|
||||||
|
expect(headers[1].text()).toBe('Ville')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders header slot when provided', () => {
|
||||||
|
const wrapper = mountComponent({}, {
|
||||||
|
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
|
||||||
|
})
|
||||||
|
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders items as rows', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const rows = wrapper.findAll('[data-test="row"]')
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].text()).toContain('Dupont')
|
||||||
|
expect(rows[0].text()).toContain('Paris')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cell slot when provided', () => {
|
||||||
|
const wrapper = mountComponent({}, {
|
||||||
|
'cell-nom': ({ item }: any) => `<strong>${item.nom}</strong>`,
|
||||||
|
})
|
||||||
|
const firstRow = wrapper.findAll('[data-test="row"]')[0]
|
||||||
|
expect(firstRow.find('strong').text()).toBe('Dupont')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty message when items is empty', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||||
|
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom empty message', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
|
||||||
|
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty slot when provided', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ items: [], totalItems: 0 },
|
||||||
|
{ empty: '<p data-test="custom-empty">Vide</p>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty row has colspan equal to columns length', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||||
|
const td = wrapper.find('[data-test="empty-row"] td')
|
||||||
|
expect(td.attributes('colspan')).toBe('2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Row click', () => {
|
||||||
|
it('emits row-click with item on row click', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
||||||
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits row-click on Enter key', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
|
||||||
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits row-click on Space key', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
|
||||||
|
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows have tabindex when clickable', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows have cursor-pointer when clickable', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows are not clickable when rowClickable is false', async () => {
|
||||||
|
const wrapper = mountComponent({ rowClickable: false })
|
||||||
|
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
|
||||||
|
expect(wrapper.emitted('row-click')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rows have no tabindex when not clickable', () => {
|
||||||
|
const wrapper = mountComponent({ rowClickable: false })
|
||||||
|
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('th elements have scope="col"', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const ths = wrapper.findAll('th')
|
||||||
|
ths.forEach(th => {
|
||||||
|
expect(th.attributes('scope')).toBe('col')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when not provided', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
const id = wrapper.find('div').attributes('id')
|
||||||
|
expect(id).toMatch(/^malio-datatable-/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses custom id when provided', () => {
|
||||||
|
const wrapper = mountComponent({ id: 'my-table' })
|
||||||
|
expect(wrapper.find('div').attributes('id')).toBe('my-table')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run app/components/malio/datatable/DataTable.test.ts`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix any failures and re-run**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add pagination to the component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/datatable/DataTable.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add pagination computed logic and template**
|
||||||
|
|
||||||
|
Add these computed properties to the `<script>`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import MalioSelect from '../select/Select.vue'
|
||||||
|
import MalioButton from '../button/Button.vue'
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
|
||||||
|
|
||||||
|
const perPageSelectOptions = computed(() =>
|
||||||
|
props.perPageOptions.map(n => ({ label: String(n), value: n }))
|
||||||
|
)
|
||||||
|
|
||||||
|
function onPerPageChange(value: string | number | null) {
|
||||||
|
if (value !== null) {
|
||||||
|
emit('update:per-page', Number(value))
|
||||||
|
emit('update:page', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
emit('update:page', page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const total = totalPages.value
|
||||||
|
const current = props.page
|
||||||
|
|
||||||
|
if (total <= 5) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | '...')[] = []
|
||||||
|
pages.push(1)
|
||||||
|
|
||||||
|
if (current > 3) {
|
||||||
|
pages.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(2, current - 1)
|
||||||
|
const end = Math.min(total - 1, current + 1)
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < total - 2) {
|
||||||
|
pages.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 1) {
|
||||||
|
pages.push(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this template block after `</table>` and before closing `</div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
v-if="totalItems > 0"
|
||||||
|
class="flex items-center justify-between border-t border-m-border px-3 py-2"
|
||||||
|
data-test="pagination"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-m-muted">Lignes</span>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="perPage"
|
||||||
|
:options="perPageSelectOptions"
|
||||||
|
min-width="w-20"
|
||||||
|
rounded="rounded"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
text-label="text-xs"
|
||||||
|
data-test="per-page-select"
|
||||||
|
@update:model-value="onPerPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Prev"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
button-class="h-8 w-auto min-w-0 px-3 text-sm"
|
||||||
|
aria-label="Page précédente"
|
||||||
|
data-test="prev-button"
|
||||||
|
@click="goToPage(page - 1)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-for="(p, idx) in visiblePages" :key="idx">
|
||||||
|
<span
|
||||||
|
v-if="p === '...'"
|
||||||
|
class="px-1 text-sm text-m-muted"
|
||||||
|
aria-hidden="true"
|
||||||
|
>…</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="h-8 min-w-[2rem] rounded px-2 text-sm transition-colors"
|
||||||
|
:class="p === page
|
||||||
|
? 'bg-m-btn-primary text-white font-semibold'
|
||||||
|
: 'text-m-text hover:bg-m-bg'"
|
||||||
|
:aria-current="p === page ? 'page' : undefined"
|
||||||
|
:data-test="`page-${p}`"
|
||||||
|
@click="goToPage(p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Next"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
button-class="h-8 w-auto min-w-0 px-3 text-sm"
|
||||||
|
aria-label="Page suivante"
|
||||||
|
data-test="next-button"
|
||||||
|
@click="goToPage(page + 1)"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify component renders without errors**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run app/components/malio/datatable/DataTable.test.ts`
|
||||||
|
Expected: Existing tests still pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Write pagination tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/datatable/DataTable.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add pagination test suite**
|
||||||
|
|
||||||
|
Add these test blocks to the existing test file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('hides pagination when totalItems is 0', () => {
|
||||||
|
const wrapper = mountComponent({ items: [], totalItems: 0 })
|
||||||
|
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows pagination when totalItems > 0', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all pages when totalPages <= 5', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights current page', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||||
|
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:page on page button click', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||||
|
await wrapper.find('[data-test="page-3"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prev button is disabled on page 1', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||||
|
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Next button is disabled on last page', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
|
||||||
|
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prev button emits update:page with page - 1', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||||
|
await wrapper.find('[data-test="prev-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Next button emits update:page with page + 1', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||||
|
await wrapper.find('[data-test="next-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows ellipsis for truncated pages (> 5 pages)', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||||
|
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
|
||||||
|
expect(ellipsis.length).toBeGreaterThan(0)
|
||||||
|
expect(ellipsis[0].text()).toBe('…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always shows first and last page when > 5 pages', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||||
|
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows 1 neighbor on each side of current page', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||||
|
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pagination nav has aria-label', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prev button has aria-label "Page précédente"', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Next button has aria-label "Page suivante"', () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 30 })
|
||||||
|
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Per-page selector', () => {
|
||||||
|
it('emits update:per-page and reset page to 1 on change', async () => {
|
||||||
|
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
|
||||||
|
const select = wrapper.findComponent({ name: 'MalioSelect' })
|
||||||
|
select.vm.$emit('update:modelValue', 25)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
|
||||||
|
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all tests**
|
||||||
|
|
||||||
|
Run: `npm run test -- --run app/components/malio/datatable/DataTable.test.ts`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix any failures and re-run**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Run full test suite + lint
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all project tests**
|
||||||
|
|
||||||
|
Run: `npm run test`
|
||||||
|
Expected: All tests pass
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix any issues and re-run**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create playground page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.playground/pages/composant/datatable/datatable.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create playground page with demo variants**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const filtreNom = ref('')
|
||||||
|
const filtreVille = ref<string | number | null>(null)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'prenom', label: 'Prénom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
{ key: 'montant', label: 'Montant' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const allItems = [
|
||||||
|
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
|
||||||
|
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
|
||||||
|
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
|
||||||
|
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
|
||||||
|
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
|
||||||
|
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
|
||||||
|
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
|
||||||
|
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
|
||||||
|
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
|
||||||
|
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
|
||||||
|
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
|
||||||
|
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
|
||||||
|
{ id: 13, nom: 'Roux', prenom: 'Hugo', ville: 'Paris', montant: 2800 },
|
||||||
|
{ id: 14, nom: 'David', prenom: 'Léa', ville: 'Lyon', montant: 670 },
|
||||||
|
{ id: 15, nom: 'Bertrand', prenom: 'Lucas', ville: 'Marseille', montant: 1950 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const villeOptions = [
|
||||||
|
{ label: 'Paris', value: 'Paris' },
|
||||||
|
{ label: 'Lyon', value: 'Lyon' },
|
||||||
|
{ label: 'Marseille', value: 'Marseille' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
return allItems.filter((item) => {
|
||||||
|
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
|
||||||
|
if (filtreVille.value && item.ville !== filtreVille.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage.value
|
||||||
|
return filteredItems.value.slice(start, start + perPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onRowClick(item: Record<string, any>) {
|
||||||
|
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">DataTable avec filtres et pagination</h2>
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="paginatedItems"
|
||||||
|
:total-items="filteredItems.length"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<template #header-nom>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="filtreNom"
|
||||||
|
placeholder="Nom"
|
||||||
|
group-class="mt-0"
|
||||||
|
input-class="border-0 border-b border-m-border rounded-none bg-transparent px-0 text-sm"
|
||||||
|
label-class="hidden"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header-ville>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="filtreVille"
|
||||||
|
:options="villeOptions"
|
||||||
|
empty-option-label="Ville"
|
||||||
|
min-width="w-full"
|
||||||
|
rounded="rounded-none"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-montant="{ item }">
|
||||||
|
<strong>{{ item.montant }} €</strong>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify page renders**
|
||||||
|
|
||||||
|
Run: `npm run dev` and navigate to `/composant/datatable/datatable`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Create Histoire story
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/story/datatable/datatable.story.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create story with variants and docs**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Story title="Data/DataTable">
|
||||||
|
<Variant title="Avec filtres et pagination">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="paginatedItems"
|
||||||
|
:total-items="filteredItems.length"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<template #header-nom>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="filtreNom"
|
||||||
|
placeholder="Nom"
|
||||||
|
group-class="mt-0"
|
||||||
|
input-class="border-0 border-b border-m-border rounded-none bg-transparent px-0 text-sm"
|
||||||
|
label-class="hidden"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-ville>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="filtreVille"
|
||||||
|
:options="villeOptions"
|
||||||
|
empty-option-label="Ville"
|
||||||
|
min-width="w-full"
|
||||||
|
rounded="rounded-none"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell-montant="{ item }">
|
||||||
|
<strong>{{ item.montant }} €</strong>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Sans filtres">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columnsSimple"
|
||||||
|
:items="simpleItems"
|
||||||
|
:total-items="simpleItems.length"
|
||||||
|
v-model:page="pageSimple"
|
||||||
|
v-model:per-page="perPageSimple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="État vide">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="[]"
|
||||||
|
:total-items="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Lignes non cliquables">
|
||||||
|
<div class="p-4">
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columnsSimple"
|
||||||
|
:items="simpleItems.slice(0, 3)"
|
||||||
|
:total-items="3"
|
||||||
|
:row-clickable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioDataTable
|
||||||
|
|
||||||
|
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto-généré | Identifiant HTML |
|
||||||
|
| `columns` | `{ key: string, label: string }[]` | **requis** | Définition des colonnes |
|
||||||
|
| `items` | `Record<string, any>[]` | **requis** | Données à afficher |
|
||||||
|
| `totalItems` | `number` | **requis** | Total pour la pagination |
|
||||||
|
| `page` | `number` | `1` | Page courante (v-model) |
|
||||||
|
| `perPage` | `number` | `10` | Lignes par page (v-model) |
|
||||||
|
| `perPageOptions` | `number[]` | `[10, 25, 50]` | Options du sélecteur de lignes |
|
||||||
|
| `rowClickable` | `boolean` | `true` | Lignes cliquables |
|
||||||
|
| `tableClass` | `string` | `''` | Classes CSS sur le wrapper (twMerge) |
|
||||||
|
| `emptyMessage` | `string` | `'Aucune donnée'` | Message si items vide |
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
| Slot | Scope | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `#header-{key}` | `{ column }` | Filtre dans le `<th>` (placeholder = label). Fallback : texte du label |
|
||||||
|
| `#cell-{key}` | `{ item, column }` | Contenu du `<td>`. Fallback : `item[key]` |
|
||||||
|
| `#empty` | — | Contenu état vide. Fallback : `emptyMessage` |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `update:page` | `number` | Changement de page |
|
||||||
|
| `update:per-page` | `number` | Changement du nb de lignes (reset page à 1) |
|
||||||
|
| `row-click` | `Record<string, any>` | Clic sur une ligne |
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
- ≤ 5 pages : toutes affichées
|
||||||
|
- \> 5 pages : page 1 … [voisin] **[courante]** [voisin] … dernière
|
||||||
|
- Boutons Prev/Next toujours visibles, désactivés aux extrêmes
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `<th scope="col">` sur chaque en-tête
|
||||||
|
- `<nav aria-label="Pagination">` autour de la pagination
|
||||||
|
- Page courante avec `aria-current="page"`
|
||||||
|
- Lignes cliquables : `tabindex="0"` + Enter/Space
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import MalioDataTable from '../../components/malio/datatable/DataTable.vue'
|
||||||
|
import MalioInputText from '../../components/malio/input/InputText.vue'
|
||||||
|
import MalioSelect from '../../components/malio/select/Select.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'DataTableStory' })
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'prenom', label: 'Prénom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
{ key: 'montant', label: 'Montant' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columnsSimple = [
|
||||||
|
{ key: 'nom', label: 'Nom' },
|
||||||
|
{ key: 'ville', label: 'Ville' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const allItems = [
|
||||||
|
{ id: 1, nom: 'Dupont', prenom: 'Jean', ville: 'Paris', montant: 1200 },
|
||||||
|
{ id: 2, nom: 'Martin', prenom: 'Marie', ville: 'Lyon', montant: 850 },
|
||||||
|
{ id: 3, nom: 'Bernard', prenom: 'Pierre', ville: 'Marseille', montant: 2100 },
|
||||||
|
{ id: 4, nom: 'Petit', prenom: 'Sophie', ville: 'Paris', montant: 950 },
|
||||||
|
{ id: 5, nom: 'Robert', prenom: 'Paul', ville: 'Lyon', montant: 1800 },
|
||||||
|
{ id: 6, nom: 'Richard', prenom: 'Claire', ville: 'Marseille', montant: 3200 },
|
||||||
|
{ id: 7, nom: 'Durand', prenom: 'Luc', ville: 'Paris', montant: 750 },
|
||||||
|
{ id: 8, nom: 'Moreau', prenom: 'Anne', ville: 'Lyon', montant: 1100 },
|
||||||
|
{ id: 9, nom: 'Simon', prenom: 'Marc', ville: 'Marseille', montant: 2400 },
|
||||||
|
{ id: 10, nom: 'Laurent', prenom: 'Julie', ville: 'Paris', montant: 1650 },
|
||||||
|
{ id: 11, nom: 'Lefebvre', prenom: 'Thomas', ville: 'Lyon', montant: 900 },
|
||||||
|
{ id: 12, nom: 'Leroy', prenom: 'Emma', ville: 'Marseille', montant: 1400 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const simpleItems = allItems.map(i => ({ nom: i.nom, ville: i.ville }))
|
||||||
|
|
||||||
|
const villeOptions = [
|
||||||
|
{ label: 'Paris', value: 'Paris' },
|
||||||
|
{ label: 'Lyon', value: 'Lyon' },
|
||||||
|
{ label: 'Marseille', value: 'Marseille' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(5)
|
||||||
|
const filtreNom = ref('')
|
||||||
|
const filtreVille = ref<string | number | null>(null)
|
||||||
|
|
||||||
|
const pageSimple = ref(1)
|
||||||
|
const perPageSimple = ref(10)
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
return allItems.filter((item) => {
|
||||||
|
if (filtreNom.value && !item.nom.toLowerCase().includes(filtreNom.value.toLowerCase())) return false
|
||||||
|
if (filtreVille.value && item.ville !== filtreVille.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage.value
|
||||||
|
return filteredItems.value.slice(start, start + perPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onRowClick(item: Record<string, any>) {
|
||||||
|
alert(`Clic sur ${item.nom} ${item.prenom}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify story renders**
|
||||||
|
|
||||||
|
Run: `npm run story:dev`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update CHANGELOG.md and COMPONENTS.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
- Modify: `COMPONENTS.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add CHANGELOG entry**
|
||||||
|
|
||||||
|
Add to `### Added` section:
|
||||||
|
```
|
||||||
|
* [#MUI-22] Création d'un composant datatable
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add COMPONENTS.md section**
|
||||||
|
|
||||||
|
Add a `## MalioDataTable` section after `## MalioDrawer` with the component documentation: props table, events, slots, pagination behavior, and 2 usage examples (with filters, simple).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit all changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/datatable/ app/story/datatable/ .playground/pages/composant/datatable/ CHANGELOG.md COMPONENTS.md
|
||||||
|
git commit -m "feat(MUI-22): création du composant MalioDataTable"
|
||||||
|
```
|
||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user