Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90ed4a213f | |||
| 9f772a84ed | |||
| 1131420960 | |||
| 2a818a0c77 | |||
| 59230bbc7e | |||
| 49a5dc5252 | |||
| 9ff3e83c03 | |||
| b55050b2ad | |||
| 1d66e5dd31 | |||
| c0c39705c7 | |||
| acd531f69e |
@@ -14,7 +14,12 @@
|
|||||||
"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(npx eslint *)",
|
||||||
"Bash(echo \"LINT EXIT: $?\")"
|
"Bash(echo \"LINT EXIT: $?\")",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"mcp__chrome__navigate_page",
|
||||||
|
"mcp__chrome__take_snapshot",
|
||||||
|
"mcp__chrome__click",
|
||||||
|
"mcp__chrome__evaluate_script"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) — défaut</h2>
|
||||||
|
<MalioAccordion v-model="multiple">
|
||||||
|
<MalioAccordionItem title="Prix" value="prix">
|
||||||
|
<p>Slider de prix ici…</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Catégorie" value="cat">
|
||||||
|
<p>Liste de checkboxes ici…</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Marque" value="marque">
|
||||||
|
<p>Recherche + liste ici…</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Ouverts : {{ multiple }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
|
||||||
|
<MalioAccordion v-model="single" mode="single">
|
||||||
|
<MalioAccordionItem title="Question 1" value="q1">
|
||||||
|
<p>Réponse 1</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Question 2" value="q2">
|
||||||
|
<p>Réponse 2</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Ouvert : {{ single }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non contrôlé + defaultOpen</h2>
|
||||||
|
<MalioAccordion>
|
||||||
|
<MalioAccordionItem title="Section A" value="a" :default-open="true">
|
||||||
|
<p>Ouverte au montage</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Section B" value="b">
|
||||||
|
<p>Fermée au montage</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
|
||||||
|
<MalioAccordion>
|
||||||
|
<MalioAccordionItem title="Active" value="ok">
|
||||||
|
<p>Contenu accessible</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
|
||||||
|
<p>Inaccessible</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const multiple = ref<string[]>(['prix'])
|
||||||
|
const single = ref('q1')
|
||||||
|
</script>
|
||||||
@@ -13,6 +13,15 @@
|
|||||||
<div class="rounded border p-3 text-sm">
|
<div class="rounded border p-3 text-sm">
|
||||||
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
|
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
|
||||||
</div>
|
</div>
|
||||||
|
<MalioDate
|
||||||
|
v-model="editableValue"
|
||||||
|
label="Date (saisie clavier)"
|
||||||
|
editable
|
||||||
|
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||||
|
/>
|
||||||
|
<div class="rounded border p-3 text-sm">
|
||||||
|
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -50,6 +59,25 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-start gap-10">
|
||||||
|
<div class="w-[396px] space-y-3">
|
||||||
|
<h2 class="font-semibold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioDate
|
||||||
|
label="Date de naissance (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-[396px] space-y-3">
|
||||||
|
<h2 class="font-semibold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="readonlyFilledDate"
|
||||||
|
label="Date de naissance (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -62,7 +90,9 @@ const now = new Date()
|
|||||||
const todayIso = toIso(now)
|
const todayIso = toIso(now)
|
||||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||||
|
|
||||||
|
const readonlyFilledDate = ref<string | null>('2026-06-15')
|
||||||
const value = ref<string | null>(null)
|
const value = ref<string | null>(null)
|
||||||
const erpValue = ref<string | null>(null)
|
const erpValue = ref<string | null>(null)
|
||||||
const bounded = ref<string | null>(null)
|
const bounded = ref<string | null>(null)
|
||||||
|
const editableValue = ref<string | null>(null)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,6 +13,20 @@
|
|||||||
<div class="rounded border p-3 text-sm">
|
<div class="rounded border p-3 text-sm">
|
||||||
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
|
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
|
||||||
</div>
|
</div>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="editableValue"
|
||||||
|
label="Date et heure (saisie clavier)"
|
||||||
|
editable
|
||||||
|
hint="Tape JJ/MM/AAAA HH:MM ou utilise le calendrier"
|
||||||
|
@update:valid="editableValid = $event"
|
||||||
|
/>
|
||||||
|
<div class="rounded border p-3 text-sm">
|
||||||
|
<p>Valeur éditable (ISO naïf) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||||
|
<p>
|
||||||
|
Saisie valide :
|
||||||
|
<code :class="editableValid ? 'text-m-success' : 'text-m-danger'">{{ editableValid }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -65,4 +79,6 @@ const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
|||||||
const value = ref<string | null>(null)
|
const value = ref<string | null>(null)
|
||||||
const erpValue = ref<string | null>(null)
|
const erpValue = ref<string | null>(null)
|
||||||
const bounded = ref<string | null>('2026-05-20T14:30:00')
|
const bounded = ref<string | null>('2026-05-20T14:30:00')
|
||||||
|
const editableValue = ref<string | null>(null)
|
||||||
|
const editableValid = ref(true)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 p-4">
|
||||||
|
<h1 class="text-2xl font-bold">Champs en lecture seule (readonly)</h1>
|
||||||
|
<p class="text-sm text-m-muted">
|
||||||
|
Tous les champs de formulaire dans leur état <code>readonly</code>, vides puis remplis.
|
||||||
|
Règles : bordure noire même vide, label et icône gris quand vide → noir quand rempli,
|
||||||
|
pas de focus bleu ni de grossissement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
label="Référence (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
model-value="Commande #A-2048"
|
||||||
|
label="Référence (rempli)"
|
||||||
|
icon-name="mdi:lock-outline"
|
||||||
|
icon-size="20"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputEmail</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputEmail
|
||||||
|
label="Adresse email (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
model-value="contact@malio.fr"
|
||||||
|
label="Adresse email (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputAmount
|
||||||
|
label="Montant (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
model-value="1250.00"
|
||||||
|
label="Montant (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
label="Pays (vide)"
|
||||||
|
:options="countryOptions"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
model-value="de"
|
||||||
|
label="Pays (rempli)"
|
||||||
|
icon-name="mdi:lock-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:options="countryOptions"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputPassword</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputPassword
|
||||||
|
label="Mot de passe (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputPassword
|
||||||
|
model-value="motdepasse123"
|
||||||
|
label="Mot de passe (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputTextArea
|
||||||
|
label="Description (vide)"
|
||||||
|
:size="3"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputTextArea
|
||||||
|
model-value="Ce texte est en lecture seule et ne peut pas être modifié."
|
||||||
|
label="Description (rempli)"
|
||||||
|
:size="3"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputPhone</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputPhone
|
||||||
|
label="Téléphone (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
model-value="+33 6 12 34 56 78"
|
||||||
|
label="Téléphone (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioInputUpload</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioInputUpload
|
||||||
|
label="Fichier (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioInputUpload
|
||||||
|
model-value="document.pdf"
|
||||||
|
label="Fichier (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioSelect</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioSelect
|
||||||
|
label="Catégorie (readonly vide)"
|
||||||
|
:options="categoryOptions"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="'a'"
|
||||||
|
label="Catégorie (readonly rempli)"
|
||||||
|
:options="categoryOptions"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
label="Catégories (readonly vide)"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="['a']"
|
||||||
|
label="Catégories (readonly rempli)"
|
||||||
|
:options="categoryOptions"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioDate</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioDate
|
||||||
|
label="Date de naissance (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
model-value="2026-06-15"
|
||||||
|
label="Date de naissance (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioDateTime
|
||||||
|
label="Date et heure (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioDateTime
|
||||||
|
model-value="2026-12-25T09:30:00"
|
||||||
|
label="Date et heure (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioDateRange
|
||||||
|
label="Période (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioDateRange
|
||||||
|
:model-value="rangeValue"
|
||||||
|
label="Période (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioDateWeek
|
||||||
|
label="Semaine (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioDateWeek
|
||||||
|
model-value="2026-W52"
|
||||||
|
label="Semaine (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">MalioTimePicker</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<MalioTimePicker
|
||||||
|
label="Heure (vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
<MalioTimePicker
|
||||||
|
model-value="14:30"
|
||||||
|
label="Heure (rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
type Option = {label: string; value: string | number}
|
||||||
|
|
||||||
|
const countryOptions: Option[] = [
|
||||||
|
{label: 'France', value: 'fr'},
|
||||||
|
{label: 'Belgique', value: 'be'},
|
||||||
|
{label: 'Canada', value: 'ca'},
|
||||||
|
{label: 'Suisse', value: 'ch'},
|
||||||
|
{label: 'Luxembourg', value: 'lu'},
|
||||||
|
{label: 'Allemagne', value: 'de'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryOptions: Option[] = [
|
||||||
|
{label: 'Catégorie A', value: 'a'},
|
||||||
|
{label: 'Catégorie B', value: 'b'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
|
||||||
|
</script>
|
||||||
@@ -33,7 +33,7 @@ const drawerNoDismiss = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2>
|
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
|
||||||
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
||||||
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -45,32 +45,27 @@ const drawerNoDismiss = ref(false)
|
|||||||
<MalioInputText label="Email" />
|
<MalioInputText label="Email" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
||||||
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
||||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Avec footer fixed bottom</h2>
|
<h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
|
||||||
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" />
|
<MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
|
||||||
<MalioDrawer v-model="drawerFixedFooter">
|
<MalioDrawer v-model="drawerFixedFooter">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||||
</template>
|
</template>
|
||||||
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu -->
|
<!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
|
||||||
<div class="flex flex-col gap-4 pb-24">
|
<div class="flex flex-col gap-4">
|
||||||
<p v-for="n in 12" :key="n" class="text-m-text">
|
<p v-for="n in 12" :key="n" class="text-m-text">
|
||||||
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que le footer reste fixé en bas du viewport.
|
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut -->
|
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
|
||||||
<div class="fixed bottom-0 right-0 w-full max-w-md border-t border-m-border bg-white px-5 py-4">
|
|
||||||
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-[1348px]">
|
||||||
|
<div class="flex items-center justify-between mt-[46px]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
aria-label="Précédent"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[32px] text-m-primary font-bold">Filtres</h1>
|
||||||
|
</div>
|
||||||
|
<MalioButton
|
||||||
|
label="Filtres"
|
||||||
|
variant="tertiary"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-[184px] px-2 py-2 justify-start text-black gap-4"
|
||||||
|
@click="drawerOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
side="right"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between gap-4 py-7"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">Filtres</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<MalioAccordionItem title="Type de camion" value="camion">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<MalioCheckbox v-model="semiBenne" label="Semi Benne" />
|
||||||
|
<MalioCheckbox v-model="benne" label="Benne" />
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Date à Date" value="date">
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||||
|
<span>Du</span>
|
||||||
|
<MalioDate v-model="dateDebut"/>
|
||||||
|
<span>Au</span>
|
||||||
|
<MalioDate v-model="dateFin"/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
label="Réinitialiser"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFiltres"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
label="Voir les résultats"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
|
||||||
|
const semiBenne = ref(false)
|
||||||
|
const benne = ref(false)
|
||||||
|
|
||||||
|
const dateDebut = ref<string | null>(null)
|
||||||
|
const dateFin = ref<string | null>(null)
|
||||||
|
|
||||||
|
function resetFiltres() {
|
||||||
|
semiBenne.value = false
|
||||||
|
benne.value = false
|
||||||
|
dateDebut.value = null
|
||||||
|
dateFin.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
/>
|
/>
|
||||||
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
|
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8">
|
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
label="Nom du client (Entreprise)"
|
label="Nom du client (Entreprise)"
|
||||||
/>
|
/>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
v-model="multiselectValue"
|
v-model="multiselectValue"
|
||||||
|
error="test"
|
||||||
label="Catégorie"
|
label="Catégorie"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
<div class="mt-[60px]">
|
<div class="mt-[60px]">
|
||||||
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
||||||
<template #information>
|
<template #information>
|
||||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||||
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
|
||||||
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
||||||
<MalioDate
|
<MalioDate
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #adresses>
|
<template #adresses>
|
||||||
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
aria-label="Supprimer l'adresse"
|
aria-label="Supprimer l'adresse"
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="bigValue"
|
||||||
|
label="Budget"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 rounded border p-3 text-sm">
|
||||||
|
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
@@ -36,6 +47,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputAmount
|
||||||
|
label="Montant (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="readonlyFilledAmount"
|
||||||
|
label="Montant (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
|
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -57,4 +85,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledAmount = ref('1250.00')
|
||||||
|
const bigValue = ref('1234567.89')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
v-model="simpleValue"
|
v-model="simpleValue"
|
||||||
label="Pays"
|
label="Pays"
|
||||||
:options="staticOptions"
|
:options="staticOptions"
|
||||||
|
local-filter
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-sm text-m-muted">
|
<p class="mt-2 text-sm text-m-muted">
|
||||||
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
icon-name="mdi:magnify"
|
icon-name="mdi:magnify"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
:options="staticOptions"
|
:options="staticOptions"
|
||||||
|
local-filter
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,6 +82,25 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
label="Pays (readonly vide)"
|
||||||
|
:options="staticOptions"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-model="readonlyFilledAutocomplete"
|
||||||
|
label="Pays (readonly rempli)"
|
||||||
|
:options="staticOptions"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
@@ -138,6 +159,7 @@ const staticOptions: Option[] = [
|
|||||||
{label: 'Italie', value: 'it'},
|
{label: 'Italie', value: 'it'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const readonlyFilledAutocomplete = ref<string | number | null>('de')
|
||||||
const simpleValue = ref<string | number | null>(null)
|
const simpleValue = ref<string | number | null>(null)
|
||||||
const leftIconValue = ref<string | number | null>(null)
|
const leftIconValue = ref<string | number | null>(null)
|
||||||
const createValue = ref<string | number | null>(null)
|
const createValue = ref<string | number | null>(null)
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<MalioInputEmail
|
||||||
|
v-for="(email, index) in emails"
|
||||||
|
:key="index"
|
||||||
|
v-model="emails[index]"
|
||||||
|
label="Adresse email"
|
||||||
|
addable
|
||||||
|
@add="emails.push('')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
@@ -48,6 +62,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
label="Adresse email (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="readonlyFilledEmail"
|
||||||
|
label="Adresse email (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
@@ -84,14 +115,36 @@
|
|||||||
:success="dynamicSuccess"
|
:success="dynamicSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Email obligatoire</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="requiredEmail"
|
||||||
|
label="Email obligatoire"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Email normalisé (minuscules)</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="lowercaseEmail"
|
||||||
|
label="Email normalisé (minuscules)"
|
||||||
|
:lowercase="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledEmail = ref('contact@malio.fr')
|
||||||
const emailValue = ref('')
|
const emailValue = ref('')
|
||||||
|
const emails = ref<string[]>([''])
|
||||||
const dynamicEmail = ref('')
|
const dynamicEmail = ref('')
|
||||||
|
const requiredEmail = ref('')
|
||||||
|
const lowercaseEmail = ref('')
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
|
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
|
||||||
|
|||||||
@@ -41,6 +41,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
label="Mot de passe (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
v-model="readonlyFilledPassword"
|
||||||
|
label="Mot de passe (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
<MalioInputPassword
|
<MalioInputPassword
|
||||||
@@ -83,6 +100,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledPassword = ref('motdepasse123')
|
||||||
const passwordValue = ref('')
|
const passwordValue = ref('')
|
||||||
const dynamicPassword = ref('')
|
const dynamicPassword = ref('')
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
label="Téléphone (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="readonlyFilledPhone"
|
||||||
|
label="Téléphone (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
<MalioInputPhone
|
<MalioInputPhone
|
||||||
@@ -121,6 +138,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledPhone = ref('+33 6 12 34 56 78')
|
||||||
const phoneValue = ref('')
|
const phoneValue = ref('')
|
||||||
const phoneAddable = ref('')
|
const phoneAddable = ref('')
|
||||||
const phoneFrench = ref('')
|
const phoneFrench = ref('')
|
||||||
|
|||||||
@@ -108,6 +108,33 @@
|
|||||||
icon-size="20"
|
icon-size="20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputText
|
||||||
|
label="Référence (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="readonlyFilledValue"
|
||||||
|
label="Référence (readonly rempli)"
|
||||||
|
icon-name="mdi:lock-outline"
|
||||||
|
icon-size="20"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Champ obligatoire</h2>
|
||||||
|
<MalioInputText
|
||||||
|
label="Champ obligatoire"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -154,6 +181,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
const readonlyFilledValue = ref('Commande #A-2048')
|
||||||
const nameValue = ref('')
|
const nameValue = ref('')
|
||||||
const searchValue = ref('')
|
const searchValue = ref('')
|
||||||
const codeValue = ref('')
|
const codeValue = ref('')
|
||||||
|
|||||||
@@ -61,6 +61,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputTextArea
|
||||||
|
label="Description (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
:size="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="readonlyFilledTextArea"
|
||||||
|
label="Description (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
:size="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
|
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
@@ -94,6 +113,7 @@
|
|||||||
import {ref} from 'vue'
|
import {ref} from 'vue'
|
||||||
import MalioInputTextArea from '../../../../app/components/malio/input/InputTextArea.vue'
|
import MalioInputTextArea from '../../../../app/components/malio/input/InputTextArea.vue'
|
||||||
|
|
||||||
|
const readonlyFilledTextArea = ref('Ce texte est en lecture seule et ne peut pas être modifié.')
|
||||||
const hintValue = ref('')
|
const hintValue = ref('')
|
||||||
const iconValue = ref('')
|
const iconValue = ref('')
|
||||||
const errorValue = ref('abc')
|
const errorValue = ref('abc')
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Clearable (croix pour vider)</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="clearableUpload"
|
||||||
|
label="Téléverser un document"
|
||||||
|
clearable
|
||||||
|
@clear="onClearUpload"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Valeur : {{ clearableUpload || '(aucun)' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
@@ -31,6 +42,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
label="Fichier (readonly vide)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="readonlyFilledUpload"
|
||||||
|
label="Fichier (readonly rempli)"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
@@ -74,8 +102,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledUpload = ref('document.pdf')
|
||||||
const uploadValue = ref('')
|
const uploadValue = ref('')
|
||||||
const dynamicUpload = ref('')
|
const dynamicUpload = ref('')
|
||||||
|
const clearableUpload = ref('rapport-2026.pdf')
|
||||||
|
|
||||||
|
const onClearUpload = () => {
|
||||||
|
clearableUpload.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicError = computed(() => {
|
const dynamicError = computed(() => {
|
||||||
if (!dynamicUpload.value) return ''
|
if (!dynamicUpload.value) return ''
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import MalioButton from "../../../../app/components/malio/button/Button.vue";
|
||||||
|
|
||||||
|
const modalBase = ref(false)
|
||||||
|
const modalForm = ref(false)
|
||||||
|
const modalLong = ref(false)
|
||||||
|
const modalNoDismiss = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
|
||||||
|
<MalioButton label="Ouvrir" @click="modalBase = true" />
|
||||||
|
<MalioModal v-model="modalBase" headerClass="py-7 px-[25px]" footerClass="flex justify-center pt-8">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Marquer comme vu ?</h2>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton label="Valider"/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
|
||||||
|
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
|
||||||
|
<MalioModal v-model="modalForm" modal-class="max-w-lg">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4 py-2">
|
||||||
|
<MalioInputText label="Nom" />
|
||||||
|
<MalioInputText label="Prénom" />
|
||||||
|
<MalioInputText label="Email" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
|
||||||
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
|
||||||
|
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
|
||||||
|
<MalioModal v-model="modalLong">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p v-for="n in 20" :key="n" class="text-m-text">
|
||||||
|
Paragraphe {{ n }} — contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
|
||||||
|
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
|
||||||
|
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
|
||||||
|
</template>
|
||||||
|
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -82,6 +82,17 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sélection obligatoire</h2>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="requiredValue"
|
||||||
|
:options="options"
|
||||||
|
label="Sélection obligatoire"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -92,6 +103,28 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="readonlyEmptyValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="readonlyFilledValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
@@ -151,6 +184,7 @@ const longOptions = [
|
|||||||
{label: 'Republique tcheque', value: 'cz'},
|
{label: 'Republique tcheque', value: 'cz'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const requiredValue = ref<string | number | null>(null)
|
||||||
const basicValue = ref<string | number | null>(null)
|
const basicValue = ref<string | number | null>(null)
|
||||||
const labelValue = ref<string | number | null>(null)
|
const labelValue = ref<string | number | null>(null)
|
||||||
const selectedValue = ref<string | number | null>('fr')
|
const selectedValue = ref<string | number | null>('fr')
|
||||||
@@ -162,4 +196,6 @@ const emptyValue = ref<string | number | null>(null)
|
|||||||
const shortListValue = 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)
|
||||||
|
const readonlyEmptyValue = ref<string | number | null>(null)
|
||||||
|
const readonlyFilledValue = ref<string | number | null>('fr')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
v-model="labelValue"
|
v-model="labelValue"
|
||||||
:options="options"
|
:options="options"
|
||||||
displayTag="true"
|
:display-tag="true"
|
||||||
empty-option-label=" "
|
empty-option-label=" "
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
v-model="labelValue1"
|
v-model="labelValue1"
|
||||||
:options="options"
|
:options="options"
|
||||||
displayTag="true"
|
:display-tag="true"
|
||||||
label="Pays"
|
label="Pays"
|
||||||
empty-option-label=" "
|
empty-option-label=" "
|
||||||
/>
|
/>
|
||||||
@@ -123,6 +123,28 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="readonlyEmptyValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="readonlyFilledValue"
|
||||||
|
:options="options"
|
||||||
|
label="Pays"
|
||||||
|
empty-option-label="Aucune selection"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
@@ -145,6 +167,7 @@
|
|||||||
empty-option-label="Aucune selection"
|
empty-option-label="Aucune selection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -190,4 +213,6 @@ const selectAllValue = ref<Array<string | number>>([])
|
|||||||
const selectAllCustomValue = ref<Array<string | number>>([])
|
const selectAllCustomValue = ref<Array<string | number>>([])
|
||||||
const longListValue = ref<Array<string | number>>([])
|
const longListValue = ref<Array<string | number>>([])
|
||||||
const bottomValue = ref<Array<string | number>>([])
|
const bottomValue = ref<Array<string | number>>([])
|
||||||
|
const readonlyEmptyValue = ref<Array<string | number>>([])
|
||||||
|
const readonlyFilledValue = ref<Array<string | number>>(['fr'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -36,6 +36,36 @@
|
|||||||
<template #details><p class="p-4">Détails avancés</p></template>
|
<template #details><p class="p-4">Détails avancés</p></template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-1 text-xl font-bold">Beaucoup d'onglets (fenêtré)</h2>
|
||||||
|
<p class="mb-4 text-sm text-m-muted">
|
||||||
|
7 onglets avec <code>:max-visible-tabs="5"</code> — flèches gauche/droite pour faire défiler
|
||||||
|
(1 par 1). L'onglet actif reste sélectionné même hors fenêtre.
|
||||||
|
</p>
|
||||||
|
<MalioTabList v-model="manyValue" :tabs="manyTabs" :max-visible-tabs="5">
|
||||||
|
<template #infos><p class="p-4">Contenu Informations</p></template>
|
||||||
|
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||||
|
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||||
|
<template #compta><p class="p-4">Contenu Comptabilité</p></template>
|
||||||
|
<template #documents><p class="p-4">Contenu Documents</p></template>
|
||||||
|
<template #historique><p class="p-4">Contenu Historique</p></template>
|
||||||
|
<template #parametres><p class="p-4">Contenu Paramètres</p></template>
|
||||||
|
</MalioTabList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-1 text-xl font-bold">Peu d'onglets avec maxVisibleTabs</h2>
|
||||||
|
<p class="mb-4 text-sm text-m-muted">
|
||||||
|
3 onglets avec <code>:max-visible-tabs="5"</code> — le fenêtrage ne s'active pas
|
||||||
|
(onglets ≤ max), donc pas de flèches, affichage normal centré.
|
||||||
|
</p>
|
||||||
|
<MalioTabList v-model="fewValue" :tabs="fewTabs" :max-visible-tabs="5">
|
||||||
|
<template #general><p class="p-4">Contenu Général</p></template>
|
||||||
|
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||||
|
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||||
|
</MalioTabList>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -60,7 +90,25 @@ const tabsTwo = [
|
|||||||
{ key: 'details', label: 'Détails', icon: 'mdi:cog-outline' },
|
{ key: 'details', label: 'Détails', icon: 'mdi:cog-outline' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const manyTabs = [
|
||||||
|
{ key: 'infos', label: 'Informations', icon: 'mdi:information-outline' },
|
||||||
|
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||||
|
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||||
|
{ key: 'compta', label: 'Comptabilité', icon: 'mdi:web' },
|
||||||
|
{ key: 'documents', label: 'Documents', icon: 'mdi:file-document-outline' },
|
||||||
|
{ key: 'historique', label: 'Historique', icon: 'mdi:history' },
|
||||||
|
{ key: 'parametres', label: 'Paramètres', icon: 'mdi:cog-outline' },
|
||||||
|
]
|
||||||
|
|
||||||
const simpleValue = ref('qualimat')
|
const simpleValue = ref('qualimat')
|
||||||
const noIconValue = ref('tab1')
|
const noIconValue = ref('tab1')
|
||||||
const twoTabValue = ref('general')
|
const twoTabValue = ref('general')
|
||||||
|
const manyValue = ref('infos')
|
||||||
|
|
||||||
|
const fewTabs = [
|
||||||
|
{ key: 'general', label: 'Général', icon: 'mdi:information-outline' },
|
||||||
|
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||||
|
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||||
|
]
|
||||||
|
const fewValue = ref('general')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioTimePicker v-model="simpleValue" label="Heure" />
|
||||||
|
<p class="mt-2 text-sm text-m-muted">Valeur : {{ simpleValue || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||||
|
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||||
|
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||||
|
<MalioTimePicker label="Heure (readonly vide)" :readonly="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||||
|
<MalioTimePicker v-model="readonlyFilledTime" label="Heure (readonly rempli)" :readonly="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledTime = ref('14:30')
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const initialValue = ref('08:30')
|
||||||
|
const disabledValue = ref('14:15')
|
||||||
|
const errorValue = ref('25:90')
|
||||||
|
const successValue = ref('09:00')
|
||||||
|
const noClearValue = ref('10:00')
|
||||||
|
</script>
|
||||||
@@ -34,6 +34,7 @@ export const navSections: SidebarSection[] = [
|
|||||||
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
||||||
{label: 'Date & heure', to: '/composant/date/datetime'},
|
{label: 'Date & heure', to: '/composant/date/datetime'},
|
||||||
{label: 'Heure', to: '/composant/time/time'},
|
{label: 'Heure', to: '/composant/time/time'},
|
||||||
|
{label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,7 +53,9 @@ export const navSections: SidebarSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
||||||
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
||||||
|
{label: 'Modal', to: '/composant/modal/modal'},
|
||||||
{label: 'Onglets', to: '/composant/tab/tabList'},
|
{label: 'Onglets', to: '/composant/tab/tabList'},
|
||||||
|
{label: 'Accordéon', to: '/composant/accordion/accordion'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,9 +69,11 @@ export const navSections: SidebarSection[] = [
|
|||||||
label: 'DIVERS',
|
label: 'DIVERS',
|
||||||
icon: 'mdi:dots-horizontal',
|
icon: 'mdi:dots-horizontal',
|
||||||
items: [
|
items: [
|
||||||
|
{label: 'Champs readonly', to: '/composant/divers/readonly'},
|
||||||
{label: 'Heure', to: '/composant/time/time'},
|
{label: 'Heure', to: '/composant/time/time'},
|
||||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||||
|
{label: 'Filtres', to: '/composant/filtre/filtres'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,10 +33,49 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-34] Revoir le système de playground
|
* [#MUI-34] Revoir le système de playground
|
||||||
* [#MUI-33] Développer le composant Datepicker
|
* [#MUI-33] Développer le composant Datepicker
|
||||||
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
||||||
|
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
|
||||||
|
* [#MUI-37] Création d'un composant accordéon
|
||||||
|
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
|
||||||
|
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
|
||||||
|
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
|
||||||
|
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
|
||||||
|
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
|
||||||
|
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||||
|
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||||
|
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
|
||||||
|
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||||
|
* [#MUI-42] Anneau de focus clavier standardisé (`outline` 2px `m-primary`, offset 2px) affiché **uniquement** à la navigation clavier (jamais au clic souris), sur l'ensemble des champs et contrôles : inputs (Text, Email, Password, Phone, Amount, Number + boutons ±, Upload, TextArea, Autocomplete), Select, SelectCheckbox, famille Date (Date, DateRange, DateTime, DateWeek), Button, ButtonIcon. Mécanique : composable `useKbdFocusRing` (détection de modalité clavier/souris) + utilitaires CSS `.m-focus-ring` (éléments à `:focus-visible` natif) et `.m-focus-ring-kbd` (champs texte, où `:focus-visible` se déclenche aussi à la souris)
|
||||||
|
* [#MUI-42] Anneau « combo » : quand un dropdown / calendrier est ouvert (Autocomplete, Select, SelectCheckbox, Date), l'anneau entoure le champ **et** la liste / le calendrier d'un seul tenant, adapté au sens d'ouverture (utilitaires `.m-combo-ring-top` / `.m-combo-ring-bottom`)
|
||||||
|
* [#MUI-42] Navigation clavier WAI-ARIA APG sur les listes déroulantes : Select et SelectCheckbox gagnent la navigation (flèches, Home/End, Entrée/Espace, Échap, Tab — absente jusque-là), avec scroll automatique de l'option active et `aria-activedescendant` ; InputAutocomplete complété (scroll auto, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme)
|
||||||
|
* [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche
|
||||||
|
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
|
||||||
|
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
|
||||||
|
* [#MUI-43] MalioDate : event `update:valid` (booléen) exposant l'état de validité de la saisie (`false` sur date malformée ou hors `min`/`max`, qui n'émet pas `modelValue`) — permet au parent de bloquer le submit ; la validité ne couvre pas `required` (champ vide = valide)
|
||||||
|
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`.
|
||||||
|
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur.
|
||||||
|
* [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
|
||||||
|
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
|
||||||
|
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
|
||||||
|
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
|
||||||
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||||
|
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
|
||||||
|
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
|
||||||
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
||||||
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
|
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
|
||||||
|
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
|
||||||
|
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
|
||||||
|
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
|
||||||
|
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
|
||||||
|
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
|
||||||
|
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
|
||||||
|
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
|
||||||
|
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
|
||||||
|
* [#MUI-42] RadioButton : ajout d'un focus visible au clavier (`outline` 2px `m-primary`, offset 2px) — l'input en `appearance-none` n'avait aucun indicateur de focus, seul l'`outline: auto 1px` du navigateur restait, quasi invisible. La navigation native (Tab entre groupes, flèches dans le groupe) reste inchangée
|
||||||
|
* [#MUI-42] Checkbox : ajout d'un focus visible au clavier sur la case (`outline` 2px `m-primary`, offset 2px) — l'input réel est masqué (`clip-path`), aucun indicateur n'apparaissait à la tabulation
|
||||||
|
* [#MUI-42] Select : le focus reste sur le bouton après sélection (un `blur()` renvoyait le focus au `body`, cassant la tabulation clavier — un Tab repartait du haut de page)
|
||||||
|
|||||||
+270
-27
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-model` pour le binding bidirectionnel sur les composants de formulaire.
|
Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-model` pour le binding bidirectionnel sur les composants de formulaire.
|
||||||
|
|
||||||
|
> **Champ obligatoire :** sur les composants de formulaire, la prop `required` ajoute un astérisque rouge dans le label. C'est un repère visuel ; la sémantique « obligatoire » est portée par l'attribut natif `required` ou `aria-required`.
|
||||||
|
|
||||||
|
> **Focus clavier :** tous les champs et contrôles affichent un anneau de focus (`outline` 2px `m-primary`, offset 2px) **uniquement** à la navigation clavier (Tab), jamais au clic souris. Sur les composants à dropdown/calendrier ouverts, l'anneau entoure le champ et la liste d'un seul tenant. Voir la note « Clavier » de chaque composant pour la navigation détaillée.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioInputText
|
## MalioInputText
|
||||||
@@ -15,10 +19,11 @@ Champ texte avec label, icône optionnelle et support de masque de saisie.
|
|||||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `required` | `boolean` | `false` | Champ requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `iconName` | `string` | `''` | Icône Iconify (ex: `mdi:magnify`) |
|
| `iconName` | `string` | `''` | Icône Iconify (ex: `mdi:magnify`) |
|
||||||
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
||||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||||
@@ -53,9 +58,11 @@ Champ mot de passe avec toggle visibilité.
|
|||||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
|
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
@@ -79,25 +86,35 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
|||||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
|
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
|
||||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `required` | `boolean` | `false` | Champ requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
|
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
|
||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||||
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
||||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||||
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
||||||
|
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
|
||||||
|
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||||
|
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
|
||||||
| `inputClass` | `string` | `''` | Classes CSS input |
|
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `update:modelValue(value: string)`
|
||||||
|
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioInputEmail v-model="email" label="Adresse email" />
|
<MalioInputEmail v-model="email" label="Adresse email" />
|
||||||
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
|
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
|
||||||
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
|
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
|
||||||
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
|
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
|
||||||
|
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -115,10 +132,11 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
|
|||||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
|
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
|
||||||
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
|
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
|
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
|
||||||
| `required` | `boolean` | `false` | Champ requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||||
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
|
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
|
||||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||||
@@ -146,7 +164,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
|
|||||||
|
|
||||||
## MalioInputAutocomplete
|
## MalioInputAutocomplete
|
||||||
|
|
||||||
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache.
|
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Par défaut le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache. Pour une liste **statique** courte, activer `localFilter` fait filtrer le composant lui-même (case-insensitive `label.includes(query)`) sans avoir à brancher `@search`.
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
@@ -159,6 +177,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
|||||||
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
|
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
|
||||||
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
|
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
|
||||||
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
|
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
|
||||||
|
| `localFilter` | `boolean` | `false` | Filtre `options` côté client par sous-chaîne du label (case-insensitive). À utiliser pour les listes statiques courtes ; en mode API on laisse `false` et le parent répond à `@search`. |
|
||||||
| `iconName` | `string` | `''` | Icône Iconify décorative |
|
| `iconName` | `string` | `''` | Icône Iconify décorative |
|
||||||
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
|
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
|
||||||
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
|
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
|
||||||
@@ -168,10 +187,11 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
|||||||
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
|
| `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 |
|
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
|
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
|
||||||
| `required` | `boolean` | `false` | Champ requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
|
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `inputClass` | `string` | `''` | Classes CSS input |
|
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||||
@@ -182,11 +202,11 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
|||||||
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
|
- `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
|
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
|
||||||
|
|
||||||
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
**Clavier (WAI-ARIA APG) :** `↓` ouvre / option suivante, `↑` précédente (ou ouvre sur la dernière option si fermé), `Début`/`Fin`, scroll automatique de l'option active, `Entrée` sélection (ou création), `Échap` annule, `Tab` ferme. Anneau de focus clavier (combo champ + liste à l'ouverture).
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- Usage statique -->
|
<!-- Usage statique (filtrage côté client via local-filter) -->
|
||||||
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
|
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
|
||||||
|
|
||||||
<!-- Usage API (parent gère le fetch) -->
|
<!-- Usage API (parent gère le fetch) -->
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
@@ -224,19 +244,25 @@ async function onSearchClients(query: string) {
|
|||||||
|
|
||||||
Champ montant avec icône devise (euro par défaut).
|
Champ montant avec icône devise (euro par défaut).
|
||||||
|
|
||||||
|
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
|
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioInputAmount v-model="montant" label="Montant TTC" />
|
<MalioInputAmount v-model="montant" label="Montant TTC" />
|
||||||
<MalioInputAmount v-model="prix" label="Prix" error="Montant invalide" />
|
<MalioInputAmount v-model="prix" label="Prix" error="Montant invalide" />
|
||||||
|
<MalioInputAmount v-model="gros" label="Budget" />
|
||||||
|
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -252,7 +278,9 @@ Champ numérique avec boutons +/-.
|
|||||||
| `min` | `number \| string` | — | Valeur minimum |
|
| `min` | `number \| string` | — | Valeur minimum |
|
||||||
| `max` | `number \| string` | — | Valeur maximum |
|
| `max` | `number \| string` | — | Valeur maximum |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
@@ -275,7 +303,9 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
|||||||
| `maxLength` | `number` | `800` | Longueur max |
|
| `maxLength` | `number` | `800` | Longueur max |
|
||||||
| `showCounter` | `boolean` | `false` | Afficher le compteur |
|
| `showCounter` | `boolean` | `false` | Afficher le compteur |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
|
| `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)`
|
||||||
@@ -303,9 +333,11 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
|||||||
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
|
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
|
||||||
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
|
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) |
|
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `outputFormat` | `'markdown' \| 'html'` | `'html'` | Format émis dans `update:modelValue` |
|
| `outputFormat` | `'markdown' \| 'html'` | `'html'` | Format émis dans `update:modelValue` |
|
||||||
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||||
| `labelClass` | `string` | `''` | Classes CSS label (twMerge) |
|
| `labelClass` | `string` | `''` | Classes CSS label (twMerge) |
|
||||||
@@ -332,13 +364,20 @@ Champ d'upload de fichier.
|
|||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `accept` | `string` | `''` | Types de fichiers acceptés |
|
| `accept` | `string` | `''` | Types de fichiers acceptés |
|
||||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
|
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
|
||||||
|
| `clearable` | `boolean` | `false` | Affiche une croix (`mdi:close`) focusable qui vide le champ quand un fichier est sélectionné |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
|
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`, `clear()`
|
||||||
|
|
||||||
|
**Clavier :** `Entrée` / `Espace` ouvrent le sélecteur de fichier. La croix `clearable` est focusable (anneau clavier, `Entrée`/`Espace`).
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioInputUpload v-model="fileName" label="Document" accept=".pdf,.doc" @file-selected="onFile" />
|
<MalioInputUpload v-model="fileName" label="Document" accept=".pdf,.doc" @file-selected="onFile" />
|
||||||
|
<MalioInputUpload v-model="fileName" label="Document" clearable @clear="onClear" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -356,17 +395,23 @@ Liste déroulante.
|
|||||||
| `hint` | `string` | `''` | Message d'aide |
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||||
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
|
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
|
||||||
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||||
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
||||||
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
|
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
|
||||||
|
| `fieldClass` | `string` | `''` | Classes supplémentaires sur le field (override hauteur, ex. `h-[30px]`) |
|
||||||
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
| `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)
|
||||||
|
|
||||||
|
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto de l'option active), `Début`/`Fin`, `Entrée`/`Espace` sélectionnent, `Échap`/`Tab` ferment. Le focus reste sur le bouton après sélection. Anneau de focus clavier (combo bouton + liste à l'ouverture, adapté au sens haut/bas).
|
||||||
|
|
||||||
```vue
|
```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..." />
|
||||||
@@ -381,17 +426,22 @@ Liste déroulante multi-sélection avec checkboxes.
|
|||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `modelValue` | `(string \| number)[]` | **requis** | Valeurs sélectionnées (v-model) |
|
| `modelValue` | `(string \| number)[]` | `[]` | Valeurs sélectionnées (v-model) |
|
||||||
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options |
|
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options |
|
||||||
| `displayTag` | `boolean` | `false` | Afficher les tags sélectionnés |
|
| `displayTag` | `boolean` | `false` | Afficher les tags sélectionnés |
|
||||||
| `displaySelectAll` | `boolean` | `false` | Afficher "Tout sélectionner" |
|
| `displaySelectAll` | `boolean` | `false` | Afficher "Tout sélectionner" |
|
||||||
| `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é |
|
||||||
|
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
| `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)[])`
|
||||||
|
|
||||||
|
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto), `Début`/`Fin`, `Entrée`/`Espace` cochent/décochent l'option active (la liste **reste ouverte**), `Échap`/`Tab` ferment. La ligne « Tout sélectionner » est navigable au clavier. Le clic sur toute la ligne (pas que le label) coche/décoche. Anneau de focus clavier (combo bouton + liste à l'ouverture).
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
|
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
|
||||||
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
|
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
|
||||||
@@ -409,10 +459,14 @@ Case à cocher.
|
|||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`
|
**Events :** `update:modelValue(value: boolean)`
|
||||||
|
|
||||||
|
**Clavier :** `Espace` coche/décoche. Focus clavier visible sur la case (`outline` 2px `m-primary`).
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioCheckbox v-model="accepte" label="J'accepte les conditions" />
|
<MalioCheckbox v-model="accepte" label="J'accepte les conditions" />
|
||||||
<MalioCheckbox v-model="newsletter" label="Newsletter" disabled />
|
<MalioCheckbox v-model="newsletter" label="Newsletter" disabled />
|
||||||
@@ -432,9 +486,12 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
|||||||
| `name` | `string` | `''` | Nom du groupe radio |
|
| `name` | `string` | `''` | Nom du groupe radio |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
||||||
|
|
||||||
|
**Clavier :** comportement natif d'un groupe radio (options partageant le même `name`) — `Tab` / `Maj+Tab` entre/sort du groupe (1 seul arrêt par groupe), `↑↓←→` déplacent la sélection entre les options d'un même groupe. Focus clavier visible (`outline` 2px `m-primary`).
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioRadioButton v-model="civilite" name="civilite" value="M" label="Monsieur" />
|
<MalioRadioButton v-model="civilite" name="civilite" value="M" label="Monsieur" />
|
||||||
<MalioRadioButton v-model="civilite" name="civilite" value="Mme" label="Madame" />
|
<MalioRadioButton v-model="civilite" name="civilite" value="Mme" label="Madame" />
|
||||||
@@ -448,6 +505,10 @@ 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.
|
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||||
|
|
||||||
|
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format `JJ/MM/AAAA` en gris et se remplit au fur et à mesure de la saisie (caractères tapés en noir, reste du gabarit en gris).
|
||||||
|
|
||||||
|
L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent.
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
|
||||||
@@ -455,7 +516,7 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
|
|||||||
| `name` | `string` | `''` | Attribut name |
|
| `name` | `string` | `''` | Attribut name |
|
||||||
| `label` | `string` | `''` | Label flottant |
|
| `label` | `string` | `''` | Label flottant |
|
||||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||||
| `required` | `boolean` | `false` | Requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `hint` | `string` | `''` | Texte d'aide |
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
@@ -464,14 +525,21 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
|
|||||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivé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) |
|
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
|
||||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
|
||||||
|
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | null)`
|
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`
|
||||||
|
|
||||||
|
**Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDate v-model="date" label="Date de naissance" />
|
<MalioDate v-model="date" label="Date de naissance" />
|
||||||
<!-- date === "2026-05-20" -->
|
<!-- date === "2026-05-20" -->
|
||||||
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
|
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
|
||||||
|
<MalioDate v-model="date" label="Date de naissance" editable />
|
||||||
|
<MalioDate v-model="date" label="Date de naissance" editable @update:valid="dateValide = $event" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -489,7 +557,7 @@ La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"
|
|||||||
| `name` | `string` | `''` | Attribut name |
|
| `name` | `string` | `''` | Attribut name |
|
||||||
| `label` | `string` | `''` | Label flottant |
|
| `label` | `string` | `''` | Label flottant |
|
||||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||||
| `required` | `boolean` | `false` | Requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `hint` | `string` | `''` | Texte d'aide |
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
@@ -498,6 +566,7 @@ La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"
|
|||||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
|
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
|
||||||
@@ -522,7 +591,7 @@ La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2
|
|||||||
| `name` | `string` | `''` | Attribut name |
|
| `name` | `string` | `''` | Attribut name |
|
||||||
| `label` | `string` | `''` | Label flottant |
|
| `label` | `string` | `''` | Label flottant |
|
||||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||||
| `required` | `boolean` | `false` | Requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `hint` | `string` | `''` | Texte d'aide |
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
@@ -531,6 +600,7 @@ La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2
|
|||||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | null)`
|
**Events :** `update:modelValue(value: string | null)`
|
||||||
@@ -552,7 +622,9 @@ Sélecteur d'heure.
|
|||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
@@ -563,11 +635,41 @@ Sélecteur d'heure.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioTimePicker
|
||||||
|
|
||||||
|
Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes infinies (heures `00–23`, minutes `00–59`, pas de 1) avec une bande de sélection centrale ; la valeur centrée est sélectionnée. Défilement, clic sur une valeur (recentrage) ou flèches clavier (`role="spinbutton"`). Pour une saisie clavier directe au format texte, voir plutôt `MalioTime`.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `name` | `string` | `''` | Attribut name |
|
||||||
|
| `label` | `string` | `''` | Label flottant |
|
||||||
|
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
|
||||||
|
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
|
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||||
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `hint` | `string` | `''` | Message d'aide |
|
||||||
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
|
| `success` | `string` | `''` | Message de succès |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string | null)`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioTimePicker v-model="heure" label="Heure" />
|
||||||
|
<MalioTimePicker v-model="heure" label="Départ" hint="Format HH:MM" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioDateTime
|
## MalioDateTime
|
||||||
|
|
||||||
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
|
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.
|
> Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`), qui remplace l'ancien `<input type="time">` natif intérimaire.
|
||||||
|
|
||||||
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
|
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.
|
||||||
|
|
||||||
@@ -578,24 +680,30 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
|
|||||||
| `name` | `string` | `''` | Attribut name |
|
| `name` | `string` | `''` | Attribut name |
|
||||||
| `label` | `string` | `''` | Label flottant |
|
| `label` | `string` | `''` | Label flottant |
|
||||||
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
|
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
|
||||||
| `required` | `boolean` | `false` | Requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `hint` | `string` | `''` | Texte d'aide |
|
| `hint` | `string` | `''` | Texte d'aide |
|
||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) |
|
| `min` | `string` | `undefined` | Borne min. Borne la grille sur la partie date ; en saisie `editable`, comparée au **datetime complet** (préférer une borne datetime, sinon les heures du jour `max` seraient rejetées). |
|
||||||
| `max` | `string` | `undefined` | Borne max (idem) |
|
| `max` | `string` | `undefined` | Borne max (idem) |
|
||||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
|
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA HH:MM` (masque maska, validation au blur / sur Entrée) en plus du calendrier |
|
||||||
|
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||||
|
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string | null)`
|
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`
|
||||||
|
|
||||||
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.
|
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
|
||||||
|
|
||||||
|
Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
||||||
<!-- rdv === "2026-05-20T14:30:00" -->
|
<!-- rdv === "2026-05-20T14:30:00" -->
|
||||||
|
<MalioDateTime v-model="rdv" label="Rendez-vous" editable @update:valid="rdvValide = $event" />
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -623,8 +731,11 @@ Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
|||||||
<MalioButton label="Voir plus" variant="tertiary" />
|
<MalioButton label="Voir plus" variant="tertiary" />
|
||||||
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
|
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
|
||||||
<MalioButton label="Pleine largeur" button-class="w-full" />
|
<MalioButton label="Pleine largeur" button-class="w-full" />
|
||||||
|
<MalioButton label="Modifier" button-class="w-m-btn-action" /> <!-- 150px, format bouton d'action -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Token de largeur partagé** : `w-m-btn-action` (150px) est exposé via `tailwind.config.ts` du layer, branché sur la CSS var `--m-btn-action-width`. Pour les boutons d'action (listes, lignes de tableau, footers denses…). Themable côté consommateur en redéfinissant `--m-btn-action-width` dans son propre CSS.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioButtonIcon
|
## MalioButtonIcon
|
||||||
@@ -657,6 +768,10 @@ Navigation par onglets avec contenu dynamique.
|
|||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
||||||
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
|
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
|
||||||
|
| `maxVisibleTabs` | `number` | `undefined` | Nombre max d'onglets affichés à la fois. Au-delà, un carrousel avec flèches gauche/droite apparaît (décalage 1 par 1). Non défini = tous les onglets. |
|
||||||
|
| `maxWidth` | `number` | `1100` | Largeur max (px) du bloc d'onglets en mode fenêtré. |
|
||||||
|
|
||||||
|
Quand `maxVisibleTabs` est défini et que le nombre d'onglets le dépasse, la barre passe en mode fenêtré : seuls `maxVisibleTabs` onglets sont visibles à la fois, encadrés par des flèches gauche/droite qui font défiler la fenêtre un onglet à la fois (largeur du bloc bornée par `maxWidth`).
|
||||||
|
|
||||||
Type `Tab` :
|
Type `Tab` :
|
||||||
|
|
||||||
@@ -694,6 +809,54 @@ const tabs = computed(() => [
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioAccordion
|
||||||
|
|
||||||
|
Accordéon compositionnel : `<MalioAccordion>` enveloppe des `<MalioAccordionItem>`. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ.
|
||||||
|
|
||||||
|
### MalioAccordion
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
|
||||||
|
| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` |
|
||||||
|
| `id` | `string` | auto | Préfixe des IDs d'accessibilité |
|
||||||
|
| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string | string[])`
|
||||||
|
|
||||||
|
### MalioAccordionItem
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `title` | `string` | — | Texte de l'en-tête |
|
||||||
|
| `value` | `string` | auto | Clé unique de la section |
|
||||||
|
| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) |
|
||||||
|
| `disabled` | `boolean` | `false` | En-tête non cliquable |
|
||||||
|
| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) |
|
||||||
|
| `panelClass` | `string` | `''` | Override classes panneau (twMerge) |
|
||||||
|
|
||||||
|
**Slot :** par défaut = contenu du panneau.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Filtres : plusieurs sections ouvertes -->
|
||||||
|
<MalioAccordion v-model="ouverts">
|
||||||
|
<MalioAccordionItem title="Prix" value="prix">
|
||||||
|
<MalioInputAmount v-model="prix" />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Catégorie" value="cat">
|
||||||
|
<MalioCheckbox v-model="cats" />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<!-- FAQ : une seule section ouverte -->
|
||||||
|
<MalioAccordion mode="single">
|
||||||
|
<MalioAccordionItem title="Question 1" value="q1">Réponse 1</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Question 2" value="q2">Réponse 2</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioSidebar
|
## MalioSidebar
|
||||||
|
|
||||||
Barre latérale de navigation rétractable.
|
Barre latérale de navigation rétractable.
|
||||||
@@ -736,14 +899,14 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
|
|||||||
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
||||||
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
||||||
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
||||||
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) |
|
| `footerClass` | `string` | `''` | Classes CSS du footer fixe (twMerge) |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`, `close()`
|
**Events :** `update:modelValue(value: boolean)`, `close()`
|
||||||
|
|
||||||
**Slots :**
|
**Slots :**
|
||||||
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
- `header` — en-tête (titre, etc.), fixe en haut. S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
||||||
- `default` — contenu (zone scrollable).
|
- `default` — contenu (zone scrollable : seul le body défile).
|
||||||
- `footer` — rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien).
|
- `footer` — actions (boutons). Rendu en bas du panneau, fixe, hors de la zone scrollable. N'apparaît que si le slot est fourni.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDrawer v-model="isOpen">
|
<MalioDrawer v-model="isOpen">
|
||||||
@@ -759,14 +922,12 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
|
|||||||
<p>Drawer large depuis la gauche</p>
|
<p>Drawer large depuis la gauche</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
|
<!-- Footer d'actions (fixe en bas, hors zone scrollable) -->
|
||||||
<MalioDrawer v-model="isOpen">
|
<MalioDrawer v-model="isOpen">
|
||||||
<template #header><h2>Formulaire</h2></template>
|
<template #header><h2>Formulaire</h2></template>
|
||||||
<MalioInputText label="Nom" />
|
<MalioInputText label="Nom" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="sticky bottom-0 bg-white py-4">
|
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
||||||
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
@@ -779,6 +940,58 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MalioModal
|
||||||
|
|
||||||
|
Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe.
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
|
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
||||||
|
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
||||||
|
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
|
||||||
|
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
|
||||||
|
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
|
||||||
|
| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) |
|
||||||
|
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
||||||
|
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
||||||
|
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
||||||
|
| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: boolean)`, `close()`
|
||||||
|
|
||||||
|
**Slots :**
|
||||||
|
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
||||||
|
- `default` — contenu (zone scrollable).
|
||||||
|
- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioModal v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">Détails</h2>
|
||||||
|
</template>
|
||||||
|
<p>Contenu de la modal</p>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- Largeur custom + footer d'actions -->
|
||||||
|
<MalioModal v-model="isOpen" modal-class="max-w-lg">
|
||||||
|
<template #header><h2>Nouveau contact</h2></template>
|
||||||
|
<MalioInputText label="Nom" />
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="isOpen = false" />
|
||||||
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="isOpen = false" />
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
|
||||||
|
<MalioModal v-model="isOpen" :dismissable="false" :close-on-escape="false">
|
||||||
|
<template #header><h2>Action requise</h2></template>
|
||||||
|
<p>Fermeture via la croix uniquement</p>
|
||||||
|
</MalioModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioDataTable
|
## MalioDataTable
|
||||||
|
|
||||||
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
||||||
@@ -832,3 +1045,33 @@ Tableau de données presentational avec pagination, filtres par slots et lignes
|
|||||||
v-model:per-page="perPage"
|
v-model:per-page="perPage"
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MalioSiteSelector
|
||||||
|
|
||||||
|
Sélecteur de site sous forme de tuiles segmentées (`role="radiogroup"`). Chaque site occupe une tuile de largeur égale ; la tuile active s'affiche pleine opacité dans sa couleur (`site.color`), les autres sont atténuées. Pattern contrôlé (`v-model`) ou non contrôlé (premier site sélectionné par défaut).
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `sites` | `{ id: string, name: string, color: string }[]` | **requis** | Liste des sites (la `color` colore la tuile active) |
|
||||||
|
| `modelValue` | `string` | `undefined` | `id` du site sélectionné (v-model) |
|
||||||
|
| `id` | `string` | auto | Identifiant HTML du conteneur |
|
||||||
|
| `groupClass` | `string` | `''` | Classes CSS du conteneur (twMerge) |
|
||||||
|
| `tileClass` | `string` | `''` | Classes CSS de chaque tuile (twMerge) |
|
||||||
|
| `labelClass` | `string` | `''` | Classes CSS du label de tuile (twMerge) |
|
||||||
|
|
||||||
|
**Events :**
|
||||||
|
- `update:modelValue(value: string)` — `id` du site sélectionné (v-model)
|
||||||
|
- `change(site: Site)` — émis avec l'objet site complet sélectionné
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioSiteSelector
|
||||||
|
v-model="siteId"
|
||||||
|
:sites="[
|
||||||
|
{ id: 'paris', name: 'Paris', color: '#2563eb' },
|
||||||
|
{ id: 'lyon', name: 'Lyon', color: '#16a34a' },
|
||||||
|
]"
|
||||||
|
@change="onSiteChange"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|||||||
@@ -2,6 +2,41 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
|
||||||
|
Deux déclencheurs, même rendu :
|
||||||
|
- .m-focus-ring → s'appuie sur :focus-visible natif. Pour les éléments
|
||||||
|
où :focus-visible se limite déjà au clavier (boutons,
|
||||||
|
onglets, tuiles, checkbox/radio…).
|
||||||
|
- .m-focus-ring-kbd → classe ajoutée en JS (via useKbdFocusRing) uniquement
|
||||||
|
quand le focus vient du clavier. Pour les champs texte,
|
||||||
|
où :focus-visible natif se déclenche aussi à la souris.
|
||||||
|
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
|
||||||
|
`outline-none` des inputs. */
|
||||||
|
.m-focus-ring:focus-visible,
|
||||||
|
.m-focus-ring-kbd:focus {
|
||||||
|
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
|
||||||
|
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
|
||||||
|
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
|
||||||
|
reste sans contour pour un raccord sans couture. */
|
||||||
|
.m-combo-ring-top {
|
||||||
|
box-shadow:
|
||||||
|
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||||
|
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||||
|
0 -2px 0 0 rgb(var(--m-primary) / 1);
|
||||||
|
}
|
||||||
|
.m-combo-ring-bottom {
|
||||||
|
box-shadow:
|
||||||
|
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||||
|
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||||
|
0 2px 0 0 rgb(var(--m-primary) / 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* ── Globales ── */
|
/* ── Globales ── */
|
||||||
@@ -31,6 +66,9 @@
|
|||||||
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
||||||
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
||||||
|
|
||||||
|
/* ── Largeurs Boutons ── */
|
||||||
|
--m-btn-action-width: 150px; /* Boutons d'action (liste, ligne tableau, footer dense…) */
|
||||||
|
|
||||||
/* ── Couleurs de site (usage ponctuel) ── */
|
/* ── Couleurs de site (usage ponctuel) ── */
|
||||||
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
||||||
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import {nextTick} from 'vue'
|
||||||
|
import Accordion from './Accordion.vue'
|
||||||
|
import AccordionItem from './AccordionItem.vue'
|
||||||
|
|
||||||
|
const TWO_ITEMS = `
|
||||||
|
<MalioAccordionItem title="Prix" value="prix"><p>Contenu prix</p></MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Catégorie" value="cat"><p>Contenu catégorie</p></MalioAccordionItem>
|
||||||
|
`
|
||||||
|
|
||||||
|
function mountAccordion(props: Record<string, unknown> = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) {
|
||||||
|
return mount(Accordion, {
|
||||||
|
props,
|
||||||
|
slots: {default: slot},
|
||||||
|
attachTo,
|
||||||
|
global: {components: {MalioAccordionItem: AccordionItem}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioAccordion — rendu & mode multiple', () => {
|
||||||
|
it('renders each item header with its title', () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
expect(headers).toHaveLength(2)
|
||||||
|
expect(headers[0].text()).toContain('Prix')
|
||||||
|
expect(headers[1].text()).toContain('Catégorie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the slot content of each panel', () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
expect(wrapper.html()).toContain('Contenu prix')
|
||||||
|
expect(wrapper.html()).toContain('Contenu catégorie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all panels are collapsed by default', () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('false')
|
||||||
|
const regions = wrapper.findAll('[role="region"]')
|
||||||
|
expect(regions[0].classes()).toContain('grid-rows-[0fr]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a panel on header click (multiple mode is default)', async () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('true')
|
||||||
|
const regions = wrapper.findAll('[role="region"]')
|
||||||
|
expect(regions[0].classes()).toContain('grid-rows-[1fr]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps multiple panels open simultaneously in multiple mode', async () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
await headers[1].trigger('click')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('true')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes an open panel when its header is clicked again', async () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wires aria-controls / aria-labelledby / role=region correctly', () => {
|
||||||
|
const wrapper = mountAccordion({id: 'acc'})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
const regions = wrapper.findAll('[role="region"]')
|
||||||
|
expect(headers[0].attributes('id')).toBe('acc-header-prix')
|
||||||
|
expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix')
|
||||||
|
expect(regions[0].attributes('id')).toBe('acc-panel-prix')
|
||||||
|
expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue with an array in multiple mode', async () => {
|
||||||
|
const wrapper = mountAccordion()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
|
||||||
|
await nextTick()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioAccordion — mode single & contrôlé', () => {
|
||||||
|
it('opening a panel closes the others in single mode', async () => {
|
||||||
|
const wrapper = mountAccordion({mode: 'single'})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
await headers[1].trigger('click')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits a string in single mode', async () => {
|
||||||
|
const wrapper = mountAccordion({mode: 'single'})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[1].trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits empty string when closing the open panel in single mode', async () => {
|
||||||
|
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects modelValue array in controlled multiple mode', () => {
|
||||||
|
const wrapper = mountAccordion({modelValue: ['cat']})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects modelValue string in controlled single mode', () => {
|
||||||
|
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('true')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mutate local state in controlled mode (emits only)', async () => {
|
||||||
|
const wrapper = mountAccordion({modelValue: []})
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[0].trigger('click')
|
||||||
|
// état piloté par le parent : sans mise à jour de la prop, reste fermé
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
|
||||||
|
const WITH_DEFAULT_OPEN = `
|
||||||
|
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Catégorie" value="cat" :default-open="true"><p>C</p></MalioAccordionItem>
|
||||||
|
`
|
||||||
|
const WITH_DISABLED = `
|
||||||
|
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Catégorie" value="cat" :disabled="true"><p>C</p></MalioAccordionItem>
|
||||||
|
`
|
||||||
|
|
||||||
|
it('opens defaultOpen items initially in uncontrolled mode', async () => {
|
||||||
|
const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
|
||||||
|
await nextTick()
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
expect(headers[0].attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets disabled and aria-disabled on a disabled item', () => {
|
||||||
|
const wrapper = mountAccordion({}, WITH_DISABLED)
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
expect(headers[1].attributes('disabled')).toBeDefined()
|
||||||
|
expect(headers[1].attributes('aria-disabled')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not toggle a disabled item on click', async () => {
|
||||||
|
const wrapper = mountAccordion({}, WITH_DISABLED)
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
await headers[1].trigger('click')
|
||||||
|
expect(headers[1].attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus to the next header on ArrowDown', async () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const wrapper = mountAccordion({}, TWO_ITEMS, root)
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
;(headers[0].element as HTMLElement).focus()
|
||||||
|
await headers[0].trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
expect(document.activeElement).toBe(headers[1].element)
|
||||||
|
wrapper.unmount()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps focus to the first header on ArrowDown from the last', async () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const wrapper = mountAccordion({}, TWO_ITEMS, root)
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
;(headers[1].element as HTMLElement).focus()
|
||||||
|
await headers[1].trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
expect(document.activeElement).toBe(headers[0].element)
|
||||||
|
wrapper.unmount()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus to the previous header on ArrowUp', async () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const wrapper = mountAccordion({}, TWO_ITEMS, root)
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
;(headers[1].element as HTMLElement).focus()
|
||||||
|
await headers[1].trigger('keydown', {key: 'ArrowUp'})
|
||||||
|
expect(document.activeElement).toBe(headers[0].element)
|
||||||
|
wrapper.unmount()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips disabled headers during keyboard navigation', async () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const slot = `
|
||||||
|
<MalioAccordionItem title="A" value="a"><p>A</p></MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="B" value="b" :disabled="true"><p>B</p></MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="C" value="c"><p>C</p></MalioAccordionItem>
|
||||||
|
`
|
||||||
|
const wrapper = mountAccordion({}, slot, root)
|
||||||
|
const headers = wrapper.findAll('button[aria-expanded]')
|
||||||
|
;(headers[0].element as HTMLElement).focus()
|
||||||
|
await headers[0].trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
// saute le header désactivé (B) pour aller directement à C
|
||||||
|
expect(document.activeElement).toBe(headers[2].element)
|
||||||
|
wrapper.unmount()
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
|
||||||
|
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
|
||||||
|
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
|
||||||
|
|
||||||
|
it('clips the panel (overflow-hidden) while collapsed', () => {
|
||||||
|
const wrapper = mountAccordion({}, ONE)
|
||||||
|
const inner = wrapper.find('[role="region"] > div')
|
||||||
|
expect(inner.classes()).toContain('overflow-hidden')
|
||||||
|
expect(inner.classes()).not.toContain('overflow-visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
|
||||||
|
const wrapper = mountAccordion({}, ONE_OPEN)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to overflow-visible after the open transition ends', async () => {
|
||||||
|
const wrapper = mountAccordion({}, ONE)
|
||||||
|
await wrapper.find('button[aria-expanded]').trigger('click')
|
||||||
|
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
|
||||||
|
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-clips (overflow-hidden) as soon as it closes', async () => {
|
||||||
|
const wrapper = mountAccordion({}, ONE_OPEN)
|
||||||
|
await nextTick()
|
||||||
|
await wrapper.find('button[aria-expanded]').trigger('click')
|
||||||
|
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div v-bind="$attrs" :class="rootClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, provide, ref, useId} from 'vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import {accordionContextKey, type AccordionItemRegistration} from './context'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioAccordion', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
mode?: 'single' | 'multiple'
|
||||||
|
modelValue?: string | string[]
|
||||||
|
id?: string
|
||||||
|
groupClass?: string
|
||||||
|
}>(), {
|
||||||
|
mode: 'multiple',
|
||||||
|
modelValue: undefined,
|
||||||
|
id: '',
|
||||||
|
groupClass: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const baseId = computed(() => props.id || `malio-accordion-${generatedId}`)
|
||||||
|
const mode = computed(() => props.mode)
|
||||||
|
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const localOpen = ref<string[]>([])
|
||||||
|
|
||||||
|
const items = ref<AccordionItemRegistration[]>([])
|
||||||
|
|
||||||
|
const openKeys = computed<string[]>(() => {
|
||||||
|
if (isControlled.value) {
|
||||||
|
const v = props.modelValue
|
||||||
|
if (props.mode === 'single') return v ? [v as string] : []
|
||||||
|
if (Array.isArray(v)) return v
|
||||||
|
return v ? [v as string] : []
|
||||||
|
}
|
||||||
|
return localOpen.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function isOpen(value: string) {
|
||||||
|
return openKeys.value.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(value: string) {
|
||||||
|
const current = openKeys.value
|
||||||
|
let next: string[]
|
||||||
|
if (props.mode === 'single') {
|
||||||
|
next = current.includes(value) ? [] : [value]
|
||||||
|
} else {
|
||||||
|
next = current.includes(value)
|
||||||
|
? current.filter(v => v !== value)
|
||||||
|
: [...current, value]
|
||||||
|
}
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localOpen.value = next
|
||||||
|
}
|
||||||
|
emit('update:modelValue', props.mode === 'single' ? (next[0] ?? '') : next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function register(item: AccordionItemRegistration, defaultOpen: boolean) {
|
||||||
|
items.value.push(item)
|
||||||
|
if (defaultOpen && !isControlled.value) {
|
||||||
|
if (props.mode === 'single') {
|
||||||
|
if (localOpen.value.length === 0) localOpen.value = [item.value]
|
||||||
|
} else if (!localOpen.value.includes(item.value)) {
|
||||||
|
localOpen.value.push(item.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregister(value: string) {
|
||||||
|
items.value = items.value.filter(i => i.value !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `items` est ordonné par ordre de montage (= ordre du DOM pour des sections
|
||||||
|
// statiques/ajoutées en fin). Si un consommateur réordonne dynamiquement les
|
||||||
|
// items, cet ordre peut diverger de l'ordre visuel ; trier par position DOM
|
||||||
|
// serait alors nécessaire (hors périmètre v1).
|
||||||
|
function focusSibling(value: string, offset: 1 | -1) {
|
||||||
|
const enabled = items.value.filter(i => !i.isDisabled())
|
||||||
|
const idx = enabled.findIndex(i => i.value === value)
|
||||||
|
if (idx === -1) return
|
||||||
|
const next = enabled[(idx + offset + enabled.length) % enabled.length]
|
||||||
|
next?.getHeaderEl()?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootClass = computed(() =>
|
||||||
|
twMerge('divide-y divide-black border-y border-black', props.groupClass),
|
||||||
|
)
|
||||||
|
|
||||||
|
provide(accordionContextKey, {
|
||||||
|
mode,
|
||||||
|
baseId,
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
register,
|
||||||
|
unregister,
|
||||||
|
focusSibling,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import Accordion from './Accordion.vue'
|
||||||
|
import AccordionItem from './AccordionItem.vue'
|
||||||
|
|
||||||
|
function mountInAccordion(slot: string, accordionProps: Record<string, unknown> = {}) {
|
||||||
|
return mount(Accordion, {
|
||||||
|
props: accordionProps,
|
||||||
|
slots: {default: slot},
|
||||||
|
global: {components: {MalioAccordionItem: AccordionItem}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioAccordionItem', () => {
|
||||||
|
it('throws when used outside MalioAccordion', () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
|
||||||
|
/à l'intérieur de MalioAccordion/,
|
||||||
|
)
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
|
||||||
|
const wrapper = mountInAccordion(
|
||||||
|
`<MalioAccordionItem title="Sans value"><p>X</p></MalioAccordionItem>`,
|
||||||
|
)
|
||||||
|
const header = wrapper.find('button[aria-expanded]')
|
||||||
|
expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
|
||||||
|
await header.trigger('click')
|
||||||
|
expect(header.attributes('aria-expanded')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies headerClass and panelClass overrides via twMerge', () => {
|
||||||
|
const wrapper = mountInAccordion(
|
||||||
|
`<MalioAccordionItem title="T" value="t" header-class="bg-red-500" panel-class="text-lg"><p>X</p></MalioAccordionItem>`,
|
||||||
|
)
|
||||||
|
const header = wrapper.find('button[aria-expanded]')
|
||||||
|
expect(header.classes()).toContain('bg-red-500')
|
||||||
|
expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a rotating chevron icon', () => {
|
||||||
|
const wrapper = mountInAccordion(
|
||||||
|
`<MalioAccordionItem title="T" value="t"><p>X</p></MalioAccordionItem>`,
|
||||||
|
)
|
||||||
|
expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="m-0">
|
||||||
|
<button
|
||||||
|
:id="headerId"
|
||||||
|
ref="headerRef"
|
||||||
|
type="button"
|
||||||
|
:class="headerClasses"
|
||||||
|
:aria-expanded="open"
|
||||||
|
:aria-controls="panelId"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-disabled="disabled || undefined"
|
||||||
|
@click="onToggle"
|
||||||
|
@keydown.down.prevent="ctx.focusSibling(value, 1)"
|
||||||
|
@keydown.up.prevent="ctx.focusSibling(value, -1)"
|
||||||
|
>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="mdi:chevron-down"
|
||||||
|
:width="24"
|
||||||
|
class="shrink-0 transition-transform duration-200"
|
||||||
|
:class="open ? 'rotate-180' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
:id="panelId"
|
||||||
|
role="region"
|
||||||
|
:aria-labelledby="headerId"
|
||||||
|
class="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||||
|
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||||
|
@transitionend="onPanelTransitionEnd"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="overflowVisible ? 'overflow-visible' : 'overflow-hidden'"
|
||||||
|
:inert="!open || undefined"
|
||||||
|
>
|
||||||
|
<div :class="panelInnerClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, inject, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
|
||||||
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import {accordionContextKey} from './context'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioAccordionItem', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
title: string
|
||||||
|
value?: string
|
||||||
|
defaultOpen?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
headerClass?: string
|
||||||
|
panelClass?: string
|
||||||
|
}>(), {
|
||||||
|
value: '',
|
||||||
|
defaultOpen: false,
|
||||||
|
disabled: false,
|
||||||
|
headerClass: '',
|
||||||
|
panelClass: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = inject(accordionContextKey)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('MalioAccordionItem doit être utilisé à l\'intérieur de MalioAccordion')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
const value = computed(() => props.value || `malio-accordion-item-${generatedId}`)
|
||||||
|
const headerRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
|
||||||
|
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
|
||||||
|
const open = computed(() => ctx.isOpen(value.value))
|
||||||
|
|
||||||
|
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
|
||||||
|
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
|
||||||
|
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
|
||||||
|
// pas rogné. On re-clippe dès le début de la fermeture.
|
||||||
|
const overflowVisible = ref(false)
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (!isOpen) overflowVisible.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPanelTransitionEnd(e: TransitionEvent) {
|
||||||
|
if (e.propertyName === 'grid-template-rows' && open.value) {
|
||||||
|
overflowVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle() {
|
||||||
|
if (props.disabled) return
|
||||||
|
ctx.toggle(value.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerClasses = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
|
||||||
|
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
|
||||||
|
props.headerClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
ctx.register(
|
||||||
|
{
|
||||||
|
value: value.value,
|
||||||
|
getHeaderEl: () => headerRef.value,
|
||||||
|
isDisabled: () => props.disabled,
|
||||||
|
},
|
||||||
|
props.defaultOpen,
|
||||||
|
)
|
||||||
|
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
|
||||||
|
if (open.value) overflowVisible.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => ctx.unregister(value.value))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type {ComputedRef, InjectionKey} from 'vue'
|
||||||
|
|
||||||
|
export interface AccordionItemRegistration {
|
||||||
|
value: string
|
||||||
|
getHeaderEl: () => HTMLElement | null
|
||||||
|
isDisabled: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccordionContext {
|
||||||
|
mode: ComputedRef<'single' | 'multiple'>
|
||||||
|
baseId: ComputedRef<string>
|
||||||
|
isOpen: (value: string) => boolean
|
||||||
|
toggle: (value: string) => void
|
||||||
|
register: (item: AccordionItemRegistration, defaultOpen: boolean) => void
|
||||||
|
unregister: (value: string) => void
|
||||||
|
focusSibling: (value: string, offset: 1 | -1) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('MalioAccordion')
|
||||||
@@ -162,8 +162,8 @@ describe('MalioButton', () => {
|
|||||||
it('applies correct dimensions', () => {
|
it('applies correct dimensions', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
expect(wrapper.get('button').classes()).toContain('w-[240px]')
|
expect(wrapper.get('button').classes()).toContain('w-[180px]')
|
||||||
expect(wrapper.get('button').classes()).toContain('h-[40px]')
|
expect(wrapper.get('button').classes()).toContain('h-[38px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies font styles', () => {
|
it('applies font styles', () => {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
|||||||
|
|
||||||
const mergedButtonClass = computed(() =>
|
const mergedButtonClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'inline-flex w-[240px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
|
||||||
variantClasses.value,
|
variantClasses.value,
|
||||||
props.buttonClass,
|
props.buttonClass,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const isFilled = computed(() => props.variant === 'filled')
|
|||||||
|
|
||||||
const mergedButtonClass = computed(() =>
|
const mergedButtonClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
|
||||||
isFilled.value
|
isFilled.value
|
||||||
? props.disabled
|
? props.disabled
|
||||||
? 'bg-m-disabled text-white cursor-not-allowed'
|
? 'bg-m-disabled text-white cursor-not-allowed'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type CheckboxProps = {
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
|
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
|
||||||
@@ -161,4 +162,33 @@ describe('MalioCheckbox', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountCheckbox({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,12 +25,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="mergedMessageClass"
|
:class="mergedMessageClass"
|
||||||
>
|
>
|
||||||
@@ -42,6 +42,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'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ const props = withDefaults(
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -75,6 +77,7 @@ const props = withDefaults(
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
const mergedMessageClass = computed(() =>
|
const mergedMessageClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
|
props.reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
@@ -176,6 +180,11 @@ const onChange = (event: Event) => {
|
|||||||
border-color: rgb(0, 0, 0);
|
border-color: rgb(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inp-cbx:focus-visible + .cbx span:first-child {
|
||||||
|
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.cbx span:first-child svg {
|
.cbx span:first-child svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.key"
|
:key="col.key"
|
||||||
scope="col"
|
scope="col"
|
||||||
class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
|
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]"
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
v-if="$slots[`header-${col.key}`]"
|
v-if="$slots[`header-${col.key}`]"
|
||||||
:name="`header-${col.key}`"
|
:name="`header-${col.key}`"
|
||||||
:column="col"
|
:column="col"
|
||||||
/>
|
/>
|
||||||
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
|
<span v-else class="font-semibold text-black">{{ col.label }}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<td
|
<td
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.key"
|
:key="col.key"
|
||||||
class="px-3 py-4 text-[18px] text-m-primary"
|
class="px-3 py-4 text-[14px] text-black"
|
||||||
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
@@ -57,30 +57,33 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="totalItems > 0"
|
v-if="totalItems > 0"
|
||||||
class="flex justify-between pt-2"
|
class="flex items-center justify-between pt-3"
|
||||||
data-test="pagination"
|
data-test="pagination"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
|
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
||||||
<MalioSelect
|
<div class="h-[30px]">
|
||||||
:model-value="perPage"
|
<MalioSelect
|
||||||
:options="perPageSelectOptions"
|
:model-value="perPage"
|
||||||
min-width="w-20 !mt-0"
|
:options="perPageSelectOptions"
|
||||||
rounded="rounded"
|
group-class="w-20 h-[30px]"
|
||||||
text-field="text-sm"
|
field-class="h-[30px]"
|
||||||
text-value="text-sm"
|
rounded="rounded"
|
||||||
text-label="text-xs"
|
text-field="text-sm"
|
||||||
data-test="per-page-select"
|
text-value="text-sm"
|
||||||
@update:model-value="onPerPageChange"
|
text-label="text-xs"
|
||||||
/>
|
data-test="per-page-select"
|
||||||
|
@update:model-value="onPerPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
|
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
label="Prev"
|
label="Préc."
|
||||||
:disabled="page <= 1"
|
:disabled="page <= 1"
|
||||||
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||||
aria-label="Page précédente"
|
aria-label="Page précédente"
|
||||||
data-test="prev-button"
|
data-test="prev-button"
|
||||||
@click="goToPage(page - 1)"
|
@click="goToPage(page - 1)"
|
||||||
@@ -95,7 +98,7 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
class="inline-flex h-[30px] min-w-[2.5rem] items-center justify-center rounded px-2 text-sm transition-colors"
|
||||||
:class="p === page
|
:class="p === page
|
||||||
? 'bg-m-btn-primary text-white font-semibold'
|
? 'bg-m-btn-primary text-white font-semibold'
|
||||||
: 'text-m-text hover:bg-m-bg'"
|
: 'text-m-text hover:bg-m-bg'"
|
||||||
@@ -109,9 +112,9 @@
|
|||||||
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
label="Next"
|
label="Suiv."
|
||||||
:disabled="page >= totalPages"
|
:disabled="page >= totalPages"
|
||||||
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||||
aria-label="Page suivante"
|
aria-label="Page suivante"
|
||||||
data-test="next-button"
|
data-test="next-button"
|
||||||
@click="goToPage(page + 1)"
|
@click="goToPage(page + 1)"
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ type DateProps = {
|
|||||||
min?: string
|
min?: string
|
||||||
max?: string
|
max?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
invalidMessage?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateForTest = Date_ as DefineComponent<DateProps>
|
const DateForTest = Date_ as DefineComponent<DateProps>
|
||||||
@@ -40,6 +43,16 @@ describe('MalioDate', () => {
|
|||||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountDate({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountDate({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('displays the formatted value in the field', () => {
|
it('displays the formatted value in the field', () => {
|
||||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||||
@@ -175,6 +188,37 @@ describe('MalioDate', () => {
|
|||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('readonly vide : bordure noire sans bleu', () => {
|
||||||
|
const wrapper = mountDate({readonly: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
expect(input.classes()).toContain('border-black')
|
||||||
|
expect(input.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label muted sans bleu', () => {
|
||||||
|
const wrapper = mountDate({readonly: true, label: 'Date'})
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
expect(label.classes()).toContain('text-m-muted')
|
||||||
|
expect(label.classes()).not.toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : icône calendrier en text-m-muted', () => {
|
||||||
|
const wrapper = mountDate({readonly: true, label: 'Date'})
|
||||||
|
expect(wrapper.get('[data-test="calendar-icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label et icône en noir, bordure noire', () => {
|
||||||
|
const wrapper = mountDate({readonly: true, label: 'Date', modelValue: '2026-05-19'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
const icon = wrapper.get('[data-test="calendar-icon"]')
|
||||||
|
expect(input.classes()).toContain('border-black')
|
||||||
|
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(label.classes()).toContain('text-black')
|
||||||
|
expect(icon.classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('accessibilité', () => {
|
describe('accessibilité', () => {
|
||||||
@@ -195,4 +239,236 @@ describe('MalioDate', () => {
|
|||||||
expect(input.value).toBe('25/12/2026')
|
expect(input.value).toBe('25/12/2026')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reserveMessageSpace', () => {
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountDate({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saisie manuelle (editable)', () => {
|
||||||
|
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||||
|
expect(wrapper.text()).not.toContain('Date invalide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
expect(input.attributes('readonly')).toBeDefined()
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('19/05/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||||
|
expect(input.attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||||
|
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('25/12/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet null sur saisie vidée au blur', async () => {
|
||||||
|
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||||
|
expect(wrapper.text()).not.toContain('Date invalide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valide et ferme le popover sur Entrée', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
|
await input.setValue('19/05/2026')
|
||||||
|
// Valeur DOM réelle de la touche Entrée ('Enter') ; `trigger('keydown.enter')`
|
||||||
|
// produirait `key: 'enter'`, qui ne matche pas le handler manuel `e.key === 'Enter'`.
|
||||||
|
await input.trigger('keydown', {key: 'Enter'})
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('utilise le message invalidMessage personnalisé', async () => {
|
||||||
|
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('99/99/9999')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.text()).toContain('Format incorrect')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gabarit de saisie (editable)', () => {
|
||||||
|
it('affiche le gabarit complet en gris quand editable + focus + vide', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||||
|
const ghost = wrapper.get('[data-test="format-ghost"]')
|
||||||
|
expect(ghost.text()).toBe('JJ/MM/AAAA')
|
||||||
|
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('19')
|
||||||
|
// eager : le séparateur se pose dès que le groupe est complet (« 19 » → « 19/ »)
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text()).toBe('19/MM/AAAA')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pose le séparateur automatiquement dès qu\'un groupe est complet (eager)', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('1905')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('19/05/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit quand editable mais vide et non focus', () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('état de validité (update:valid)', () => {
|
||||||
|
it('émet valid=true au montage avec une valeur valide', () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true au montage quand le champ est vide', () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true sur saisie clavier valide', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('19/05/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=false sur saisie hors min/max', async () => {
|
||||||
|
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('25/12/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
|
||||||
|
const wrapper = mountDate({editable: true, required: true, modelValue: '2026-05-19'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true sur clear', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
|
||||||
|
const wrapper = mountDate()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||||
|
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,14 +10,16 @@
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:hint="hint"
|
:hint="hint"
|
||||||
:error="error"
|
:error="mergedError"
|
||||||
:success="success"
|
:success="success"
|
||||||
:clearable="clearable"
|
:clearable="clearable"
|
||||||
|
:editable="editable"
|
||||||
:input-class="inputClass"
|
:input-class="inputClass"
|
||||||
:label-class="labelClass"
|
:label-class="labelClass"
|
||||||
:group-class="groupClass"
|
:group-class="groupClass"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@clear="emit('update:modelValue', null)"
|
@clear="onClear"
|
||||||
|
@commit="onCommit"
|
||||||
>
|
>
|
||||||
<template #default="{ currentMonth, currentYear, close }">
|
<template #default="{ currentMonth, currentYear, close }">
|
||||||
<MonthGrid
|
<MonthGrid
|
||||||
@@ -26,17 +28,17 @@
|
|||||||
:selected-date="modelValue ?? null"
|
:selected-date="modelValue ?? null"
|
||||||
:min="min"
|
:min="min"
|
||||||
:max="max"
|
:max="max"
|
||||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
@select="(iso) => onSelect(iso, close)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</CalendarField>
|
</CalendarField>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, watch} from 'vue'
|
import {computed, ref, watch} from 'vue'
|
||||||
import CalendarField from './internal/CalendarField.vue'
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
import MonthGrid from './internal/MonthGrid.vue'
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||||
|
|
||||||
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -56,6 +58,8 @@ const props = withDefaults(
|
|||||||
min?: string
|
min?: string
|
||||||
max?: string
|
max?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
invalidMessage?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -75,19 +79,64 @@ const props = withDefaults(
|
|||||||
min: undefined,
|
min: undefined,
|
||||||
max: undefined,
|
max: undefined,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
|
editable: false,
|
||||||
|
invalidMessage: 'Date invalide',
|
||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | null): void
|
||||||
|
(e: 'update:valid', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||||
|
|
||||||
|
const internalError = ref('')
|
||||||
|
const mergedError = computed(() => props.error || internalError.value)
|
||||||
|
|
||||||
|
// La validité ne reflète que la saisie : malformée/hors plage → false. Un champ
|
||||||
|
// vide est valide (l'obligation `required` reste à la charge du parent).
|
||||||
|
const setError = (message: string) => {
|
||||||
|
internalError.value = message
|
||||||
|
emit('update:valid', message === '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCommit = (text: string) => {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (trimmed === '') {
|
||||||
|
setError('')
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const iso = parseDisplayToIso(trimmed)
|
||||||
|
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||||
|
setError('')
|
||||||
|
emit('update:modelValue', iso)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(props.invalidMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
setError('')
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (iso: string, close: () => void) => {
|
||||||
|
setError('')
|
||||||
|
emit('update:modelValue', iso)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// immediate : émet aussi la validité au montage, pour que le parent connaisse
|
||||||
|
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
|
setError('')
|
||||||
if (val && !isValidIso(val) && import.meta.dev) {
|
if (val && !isValidIso(val) && import.meta.dev) {
|
||||||
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
||||||
}
|
}
|
||||||
})
|
}, {immediate: true})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|||||||
import {mount} from '@vue/test-utils'
|
import {mount} from '@vue/test-utils'
|
||||||
import type {DefineComponent} from 'vue'
|
import type {DefineComponent} from 'vue'
|
||||||
import DateTime_ from './DateTime.vue'
|
import DateTime_ from './DateTime.vue'
|
||||||
|
import MalioTimePicker from '../time/TimePicker.vue'
|
||||||
|
|
||||||
type DateTimeProps = {
|
type DateTimeProps = {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -18,6 +19,8 @@ type DateTimeProps = {
|
|||||||
min?: string
|
min?: string
|
||||||
max?: string
|
max?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
invalidMessage?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -30,7 +33,7 @@ const mountDateTime = (props: DateTimeProps = {}) =>
|
|||||||
describe('MalioDateTime', () => {
|
describe('MalioDateTime', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05
|
||||||
})
|
})
|
||||||
afterEach(() => vi.useRealTimers())
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
@@ -49,28 +52,30 @@ describe('MalioDateTime', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('popover', () => {
|
describe('popover', () => {
|
||||||
it('ouvre la grille et l\'input heure au clic', async () => {
|
it('ouvre la grille et le champ sélecteur d\'heure au clic', async () => {
|
||||||
const wrapper = mountDateTime()
|
const wrapper = mountDateTime()
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
|
||||||
expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true)
|
expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sélection', () => {
|
describe('sélection', () => {
|
||||||
it('émet le jour à 00:00 et garde le popover ouvert', async () => {
|
it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => {
|
||||||
const wrapper = mountDateTime()
|
const wrapper = mountDateTime()
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
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-19"]').trigger('click')
|
||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
|
// heure système figée à 09:05
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00'])
|
||||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applique l\'heure réglée avant le clic du jour', async () => {
|
it('applique l\'heure réglée avant le clic du jour', async () => {
|
||||||
const wrapper = mountDateTime()
|
const wrapper = mountDateTime()
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
await wrapper.get('[data-test="time-input"]').setValue('09:15')
|
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '09:15')
|
||||||
// pas d'émission tant qu'aucun jour n'est choisi
|
await wrapper.vm.$nextTick()
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').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-19T09:15:00'])
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
|
||||||
@@ -79,15 +84,15 @@ describe('MalioDateTime', () => {
|
|||||||
it('met à jour l\'heure quand une date est déjà choisie', async () => {
|
it('met à jour l\'heure quand une date est déjà choisie', async () => {
|
||||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
await wrapper.get('[data-test="time-input"]').setValue('08:45')
|
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '08:45')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initialise l\'input heure depuis la valeur', async () => {
|
it('initialise le champ heure depuis la valeur', async () => {
|
||||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement
|
expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30')
|
||||||
expect(time.value).toBe('14:30')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -117,4 +122,165 @@ describe('MalioDateTime', () => {
|
|||||||
expect(wrapper.text()).toContain('Date requise')
|
expect(wrapper.text()).toContain('Date requise')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('saisie manuelle (editable)', () => {
|
||||||
|
it('par défaut (editable=false) l\'input reste readonly', () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet le datetime ISO sur saisie clavier valide au blur', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('20/05/2026 14:30')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026 14:30')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('32/13/2026 14:30')
|
||||||
|
expect(input.attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe en erreur si le datetime saisi est hors min/max', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('25/12/2026 10:00')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet null sur saisie vidée au blur', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valide et ferme le popover sur Entrée', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
|
await input.setValue('20/05/2026 14:30')
|
||||||
|
await input.trigger('keydown', {key: 'Enter'})
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('utilise le message invalidMessage personnalisé', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('99/99/9999 10:00')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.text()).toContain('Format incorrect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026 14:30')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.text()).toContain('Date invalide')
|
||||||
|
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
expect(wrapper.text()).not.toContain('Date invalide')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gabarit de saisie (editable)', () => {
|
||||||
|
it('affiche le gabarit date+heure complet en gris quand editable + focus + vide', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('JJ/MM/AAAA HH:MM')
|
||||||
|
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('190520')
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('19/05/20AA HH:MM')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/05/20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('état de validité (update:valid)', () => {
|
||||||
|
it('émet valid=true au montage avec une valeur valide', () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true au montage quand le champ est vide', () => {
|
||||||
|
const wrapper = mountDateTime()
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true sur saisie clavier valide', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('20/05/2026 14:30')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026 14:30')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true, required: true, modelValue: '2026-05-20T14:30:00'})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true sur clear', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
|
||||||
|
const wrapper = mountDateTime()
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026 14:30')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
|
||||||
|
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,14 +10,17 @@
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:hint="hint"
|
:hint="hint"
|
||||||
:error="error"
|
:error="mergedError"
|
||||||
:success="success"
|
:success="success"
|
||||||
:clearable="clearable"
|
:clearable="clearable"
|
||||||
|
:editable="editable"
|
||||||
|
placeholder-template="JJ/MM/AAAA HH:MM"
|
||||||
:input-class="inputClass"
|
:input-class="inputClass"
|
||||||
:label-class="labelClass"
|
:label-class="labelClass"
|
||||||
:group-class="groupClass"
|
:group-class="groupClass"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@clear="onClear"
|
@clear="onClear"
|
||||||
|
@commit="onCommit"
|
||||||
>
|
>
|
||||||
<template #default="{ currentMonth, currentYear }">
|
<template #default="{ currentMonth, currentYear }">
|
||||||
<MonthGrid
|
<MonthGrid
|
||||||
@@ -28,26 +31,27 @@
|
|||||||
:max="max?.slice(0, 10)"
|
:max="max?.slice(0, 10)"
|
||||||
@select="onSelectDay"
|
@select="onSelectDay"
|
||||||
/>
|
/>
|
||||||
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
|
<div class="mt-4">
|
||||||
<div class="mt-[26px] flex-col items-center gap-2">
|
<MalioTimePicker
|
||||||
<input
|
:model-value="timeValue || null"
|
||||||
:id="timeInputId"
|
label="Heure"
|
||||||
data-test="time-input"
|
:clearable="false"
|
||||||
type="time"
|
static-popover
|
||||||
:value="timeValue"
|
@update:model-value="onTimeChange"
|
||||||
class="w-full border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
|
/>
|
||||||
@input="onTimeInput"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CalendarField>
|
</CalendarField>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, useId, watch} from 'vue'
|
import {computed, ref, watch} from 'vue'
|
||||||
import CalendarField from './internal/CalendarField.vue'
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
import MonthGrid from './internal/MonthGrid.vue'
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
import MalioTimePicker from '../time/TimePicker.vue'
|
||||||
|
import {formatTime} from '../time/composables/timeFormat'
|
||||||
|
import {isDateInRange} from './composables/dateFormat'
|
||||||
|
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, parseDisplayToIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
||||||
|
|
||||||
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -67,6 +71,8 @@ const props = withDefaults(
|
|||||||
min?: string
|
min?: string
|
||||||
max?: string
|
max?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
invalidMessage?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -86,16 +92,18 @@ const props = withDefaults(
|
|||||||
min: undefined,
|
min: undefined,
|
||||||
max: undefined,
|
max: undefined,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
|
editable: false,
|
||||||
|
invalidMessage: 'Date invalide',
|
||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | null): void
|
||||||
const generatedId = useId()
|
(e: 'update:valid', value: boolean): void
|
||||||
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).
|
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
|
||||||
const pendingTime = ref('')
|
const pendingTime = ref('')
|
||||||
@@ -105,15 +113,29 @@ const datePart = computed(() => parts.value.date)
|
|||||||
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
|
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
|
||||||
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
||||||
|
|
||||||
|
const internalError = ref('')
|
||||||
|
const mergedError = computed(() => props.error || internalError.value)
|
||||||
|
|
||||||
|
// La validité ne reflète que la saisie clavier : malformée/hors plage → false. Un
|
||||||
|
// champ vide est valide (l'obligation `required` reste à la charge du parent).
|
||||||
|
const setError = (message: string) => {
|
||||||
|
internalError.value = message
|
||||||
|
emit('update:valid', message === '')
|
||||||
|
}
|
||||||
|
|
||||||
function onSelectDay(iso: string) {
|
function onSelectDay(iso: string) {
|
||||||
const time = parts.value.time || pendingTime.value || '00:00'
|
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
|
||||||
|
// (heure courante au moment du clic)
|
||||||
|
const now = new Date()
|
||||||
|
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
|
||||||
|
setError('')
|
||||||
emit('update:modelValue', composeDateTime(iso, time))
|
emit('update:modelValue', composeDateTime(iso, time))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTimeInput(e: Event) {
|
function onTimeChange(value: string | null) {
|
||||||
const value = (e.target as HTMLInputElement).value
|
|
||||||
if (!value) return
|
if (!value) return
|
||||||
if (datePart.value) {
|
if (datePart.value) {
|
||||||
|
setError('')
|
||||||
emit('update:modelValue', composeDateTime(datePart.value, value))
|
emit('update:modelValue', composeDateTime(datePart.value, value))
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -121,14 +143,34 @@ function onTimeInput(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCommit(text: string) {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (trimmed === '') {
|
||||||
|
setError('')
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const iso = parseDisplayToIsoDateTime(trimmed)
|
||||||
|
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||||
|
setError('')
|
||||||
|
emit('update:modelValue', iso)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(props.invalidMessage)
|
||||||
|
}
|
||||||
|
|
||||||
function onClear() {
|
function onClear() {
|
||||||
|
setError('')
|
||||||
pendingTime.value = ''
|
pendingTime.value = ''
|
||||||
emit('update:modelValue', null)
|
emit('update:modelValue', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// immediate : émet aussi la validité au montage, pour que le parent connaisse
|
||||||
|
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
|
setError('')
|
||||||
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
|
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
|
||||||
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
|
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
|
||||||
}
|
}
|
||||||
})
|
}, {immediate: true})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
composeDateTime,
|
composeDateTime,
|
||||||
formatIsoDateTimeToDisplay,
|
formatIsoDateTimeToDisplay,
|
||||||
isValidIsoDateTime,
|
isValidIsoDateTime,
|
||||||
|
parseDisplayToIsoDateTime,
|
||||||
splitDateTime,
|
splitDateTime,
|
||||||
} from './datetimeFormat'
|
} from './datetimeFormat'
|
||||||
|
|
||||||
@@ -49,6 +50,34 @@ describe('datetimeFormat', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseDisplayToIsoDateTime', () => {
|
||||||
|
it('parse un JJ/MM/AAAA HH:MM valide en datetime ISO', () => {
|
||||||
|
expect(parseDisplayToIsoDateTime('20/05/2026 14:30')).toBe('2026-05-20T14:30:00')
|
||||||
|
expect(parseDisplayToIsoDateTime('01/01/2026 00:00')).toBe('2026-01-01T00:00:00')
|
||||||
|
expect(parseDisplayToIsoDateTime('31/12/2026 23:59')).toBe('2026-12-31T23:59:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolère les espaces autour', () => {
|
||||||
|
expect(parseDisplayToIsoDateTime(' 20/05/2026 14:30 ')).toBe('2026-05-20T14:30:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejette une date malformée', () => {
|
||||||
|
expect(parseDisplayToIsoDateTime('32/01/2026 10:00')).toBeNull()
|
||||||
|
expect(parseDisplayToIsoDateTime('10/13/2026 10:00')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejette une heure hors bornes', () => {
|
||||||
|
expect(parseDisplayToIsoDateTime('20/05/2026 24:00')).toBeNull()
|
||||||
|
expect(parseDisplayToIsoDateTime('20/05/2026 12:60')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejette un format incomplet ou sans heure', () => {
|
||||||
|
expect(parseDisplayToIsoDateTime('20/05/2026')).toBeNull()
|
||||||
|
expect(parseDisplayToIsoDateTime('20/05/2026 14')).toBeNull()
|
||||||
|
expect(parseDisplayToIsoDateTime('')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('composeDateTime', () => {
|
describe('composeDateTime', () => {
|
||||||
it('recompose un datetime ISO avec secondes à 00', () => {
|
it('recompose un datetime ISO avec secondes à 00', () => {
|
||||||
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
|
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {isValidIso} from './dateFormat'
|
import {isValidIso, parseDisplayToIso} from './dateFormat'
|
||||||
|
|
||||||
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
|
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
|
||||||
|
|
||||||
@@ -27,6 +27,16 @@ export function splitDateTime(s: string | null): {date: string | null; time: str
|
|||||||
return {date, time: time.slice(0, 5)}
|
return {date, time: time.slice(0, 5)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseDisplayToIsoDateTime(display: string): string | null {
|
||||||
|
const match = /^(\d{2}\/\d{2}\/\d{4}) (\d{2}):(\d{2})$/.exec(display.trim())
|
||||||
|
if (!match) return null
|
||||||
|
const [, datePart, hh, mm] = match
|
||||||
|
const iso = parseDisplayToIso(datePart)
|
||||||
|
if (!iso) return null
|
||||||
|
if (Number(hh) > 23 || Number(mm) > 59) return null
|
||||||
|
return `${iso}T${hh}:${mm}:00`
|
||||||
|
}
|
||||||
|
|
||||||
export function composeDateTime(date: string, time: string): string {
|
export function composeDateTime(date: string, time: string): string {
|
||||||
const t = time || '00:00'
|
const t = time || '00:00'
|
||||||
return `${date}T${t}:00`
|
return `${date}T${t}:00`
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
|
v-maska="maskaOptions"
|
||||||
:name="name"
|
:name="name"
|
||||||
data-test="date-input"
|
data-test="date-input"
|
||||||
readonly
|
:readonly="inputReadonly"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:class="mergedInputClass"
|
:class="mergedInputClass"
|
||||||
:required="required"
|
:required="required"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:value="displayValue"
|
:value="editable ? draft : displayValue"
|
||||||
:aria-invalid="!!error"
|
:aria-invalid="!!error"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
:aria-expanded="isOpen"
|
:aria-expanded="isOpen"
|
||||||
@@ -22,14 +23,31 @@
|
|||||||
placeholder="_"
|
placeholder="_"
|
||||||
type="text"
|
type="text"
|
||||||
@click="onFieldClick"
|
@click="onFieldClick"
|
||||||
|
@focus="onFocus(); onKbdFocus()"
|
||||||
|
@input="onInput"
|
||||||
|
@blur="onBlur(); onKbdBlur()"
|
||||||
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showGhost"
|
||||||
|
data-test="format-ghost"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute left-0 right-0 top-1/2 flex h-10 -translate-y-1/2 items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent pl-3 pr-10 text-lg"
|
||||||
|
><span
|
||||||
|
data-test="ghost-typed"
|
||||||
|
class="text-black"
|
||||||
|
>{{ ghostTyped }}</span><span
|
||||||
|
data-test="ghost-remaining"
|
||||||
|
class="text-m-muted"
|
||||||
|
>{{ ghostRemaining }}</span></div>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||||
@@ -37,9 +55,9 @@
|
|||||||
v-if="showClear"
|
v-if="showClear"
|
||||||
type="button"
|
type="button"
|
||||||
data-test="clear"
|
data-test="clear"
|
||||||
class="text-m-muted hover:text-m-primary"
|
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||||
aria-label="Effacer la date"
|
aria-label="Effacer la date"
|
||||||
@click.stop="emit('clear')"
|
@click.stop="onClearClick"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:close"
|
icon="mdi:close"
|
||||||
@@ -61,6 +79,7 @@
|
|||||||
data-test="popover"
|
data-test="popover"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
|
||||||
>
|
>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
@@ -85,11 +104,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -101,13 +121,19 @@
|
|||||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||||
import {Icon} from '@iconify/vue'
|
import {Icon} from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import {vMaska} from 'maska/vue'
|
||||||
|
import type {MaskInputOptions} from 'maska'
|
||||||
|
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||||
import CalendarHeader from './CalendarHeader.vue'
|
import CalendarHeader from './CalendarHeader.vue'
|
||||||
import MonthPicker from './MonthPicker.vue'
|
import MonthPicker from './MonthPicker.vue'
|
||||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||||
import {useCalendarView} from '../composables/useCalendarView'
|
import {useCalendarView} from '../composables/useCalendarView'
|
||||||
|
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
displayValue: string
|
displayValue: string
|
||||||
@@ -123,9 +149,12 @@ const props = withDefaults(
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
placeholderTemplate?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -139,25 +168,56 @@ const props = withDefaults(
|
|||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
clearable: true,
|
clearable: true,
|
||||||
|
editable: false,
|
||||||
|
placeholderTemplate: 'JJ/MM/AAAA',
|
||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'clear' | 'close'): void
|
||||||
|
(e: 'commit', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const draft = ref(props.displayValue)
|
||||||
|
// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés).
|
||||||
|
// eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet.
|
||||||
|
const maskaOptions = computed<MaskInputOptions>(() => ({
|
||||||
|
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined,
|
||||||
|
eager: props.editable,
|
||||||
|
}))
|
||||||
|
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||||
|
|
||||||
|
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
|
||||||
|
// par-dessus l'input (dont le texte est rendu transparent en mode editable).
|
||||||
|
// Espaces → insécables : un espace en bord de span (flex-item) serait sinon rogné,
|
||||||
|
// collant la suite du gabarit à la date (« 12/12/1999HH:MM »).
|
||||||
|
const nbsp = (s: string) => s.replace(/ /g, ' ')
|
||||||
|
const ghostTyped = computed(() => nbsp(draft.value))
|
||||||
|
const ghostRemaining = computed(() => nbsp(props.placeholderTemplate.slice(draft.value.length)))
|
||||||
|
|
||||||
|
watch(() => props.displayValue, (value) => {
|
||||||
|
draft.value = value
|
||||||
|
})
|
||||||
|
|
||||||
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
||||||
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
||||||
|
|
||||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||||
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 isFilled = computed(() => props.displayValue.length > 0)
|
const isFilled = computed(() =>
|
||||||
|
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||||
|
)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
|
||||||
const showClear = computed(() =>
|
const showClear = computed(() =>
|
||||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||||
)
|
)
|
||||||
@@ -171,6 +231,13 @@ watch(isOpen, (value) => {
|
|||||||
|
|
||||||
const onFieldClick = () => {
|
const onFieldClick = () => {
|
||||||
if (props.disabled || props.readonly) return
|
if (props.disabled || props.readonly) return
|
||||||
|
if (props.editable) {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
syncToIso(props.syncTo)
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
closePopover()
|
closePopover()
|
||||||
return
|
return
|
||||||
@@ -179,6 +246,63 @@ const onFieldClick = () => {
|
|||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
if (props.disabled || props.readonly || !props.editable) return
|
||||||
|
if (!isOpen.value) {
|
||||||
|
syncToIso(props.syncTo)
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
draft.value = (event.target as HTMLInputElement).value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset local immédiat : sur saisie invalide, modelValue est déjà null, donc le
|
||||||
|
// watch(displayValue) ne se redéclenche pas — il faut vider le draft soi-même.
|
||||||
|
const onClearClick = () => {
|
||||||
|
draft.value = ''
|
||||||
|
emit('clear')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
if (!props.editable) return
|
||||||
|
emit('commit', draft.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
if (!props.editable) return
|
||||||
|
emit('commit', draft.value)
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (isOpen.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.editable) {
|
||||||
|
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
onEnter()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onFieldClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.syncTo, (value) => {
|
watch(() => props.syncTo, (value) => {
|
||||||
if (isOpen.value) syncToIso(value)
|
if (isOpen.value) syncToIso(value)
|
||||||
})
|
})
|
||||||
@@ -195,14 +319,19 @@ const mergedGroupClass = computed(() =>
|
|||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
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',
|
'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',
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-danger'
|
? 'border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success'
|
? 'border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||||
|
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||||
|
// En mode editable, le texte réel est masqué : c'est le gabarit fantôme qui l'affiche.
|
||||||
|
props.editable ? 'text-transparent caret-black' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -210,14 +339,16 @@ const mergedInputClass = computed(() =>
|
|||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
|
'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' : '',
|
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: isOpen.value
|
: isReadonly.value
|
||||||
? 'text-m-primary'
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
: 'peer-placeholder-shown:text-m-muted text-black',
|
: isOpen.value
|
||||||
|
? 'text-m-primary'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted text-black',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -225,6 +356,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
const iconStateClass = computed(() => {
|
const iconStateClass = computed(() => {
|
||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isOpen.value) return 'text-m-primary'
|
if (isOpen.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return 'text-m-muted'
|
return 'text-m-muted'
|
||||||
|
|||||||
@@ -152,12 +152,13 @@ describe('MalioDrawer', () => {
|
|||||||
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the #footer slot inside the body (scrollable zone)', () => {
|
it('renders the #footer slot in a footer pinned below the body', () => {
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent(
|
||||||
{ modelValue: true },
|
{ modelValue: true },
|
||||||
{ footer: '<button data-test="save">Enregistrer</button>' },
|
{ footer: '<button data-test="save">Enregistrer</button>' },
|
||||||
)
|
)
|
||||||
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render the footer wrapper when no #footer slot', () => {
|
it('does not render the footer wrapper when no #footer slot', () => {
|
||||||
@@ -170,14 +171,12 @@ describe('MalioDrawer', () => {
|
|||||||
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies footerClass to the footer wrapper', () => {
|
it('applies footerClass to the footer', () => {
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent(
|
||||||
{ modelValue: true, footerClass: 'sticky bottom-0' },
|
{ modelValue: true, footerClass: 'justify-end' },
|
||||||
{ footer: '<span>pied</span>' },
|
{ footer: '<span>pied</span>' },
|
||||||
)
|
)
|
||||||
const footer = wrapper.find('[data-test="footer"]')
|
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
|
||||||
expect(footer.classes()).toContain('sticky')
|
|
||||||
expect(footer.classes()).toContain('bottom-0')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aligns to the right by default', () => {
|
it('aligns to the right by default', () => {
|
||||||
|
|||||||
@@ -64,13 +64,13 @@
|
|||||||
data-test="body"
|
data-test="body"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<div
|
</div>
|
||||||
v-if="$slots.footer"
|
<div
|
||||||
:class="footerClass"
|
v-if="$slots.footer"
|
||||||
data-test="footer"
|
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
|
||||||
>
|
data-test="footer"
|
||||||
<slot name="footer" />
|
>
|
||||||
</div>
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type InputProps = {
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputForTest = Input as DefineComponent<InputProps>
|
const InputForTest = Input as DefineComponent<InputProps>
|
||||||
@@ -53,6 +54,16 @@ describe('MalioInputText', () => {
|
|||||||
expect(wrapper.get('label').text()).toBe('labelTest')
|
expect(wrapper.get('label').text()).toBe('labelTest')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('applies the name attribute', () => {
|
it('applies the name attribute', () => {
|
||||||
const wrapper = mountInput({name: 'nameTest'})
|
const wrapper = mountInput({name: 'nameTest'})
|
||||||
|
|
||||||
@@ -126,6 +137,13 @@ describe('MalioInputText', () => {
|
|||||||
expect(wrapper.get('input').classes()).toContain('text-black/60')
|
expect(wrapper.get('input').classes()).toContain('text-black/60')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows muted label color when disabled (matches border color)', () => {
|
||||||
|
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
|
||||||
|
})
|
||||||
|
|
||||||
it('emits update:modelValue on input change', async () => {
|
it('emits update:modelValue on input change', async () => {
|
||||||
const wrapper = mountInput({modelValue: ''})
|
const wrapper = mountInput({modelValue: ''})
|
||||||
|
|
||||||
@@ -253,6 +271,34 @@ describe('MalioInputText', () => {
|
|||||||
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
|
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reserves space for the message even when no hint/error/success is set', () => {
|
||||||
|
const wrapper = mountInput({})
|
||||||
|
|
||||||
|
const p = wrapper.find('p')
|
||||||
|
expect(p.exists()).toBe(true)
|
||||||
|
expect(p.text()).toBe('')
|
||||||
|
expect(p.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not render label when label prop is missing', () => {
|
it('does not render label when label prop is missing', () => {
|
||||||
const wrapper = mountInput({labelClass: 'text-red-500'})
|
const wrapper = mountInput({labelClass: 'text-red-500'})
|
||||||
|
|
||||||
@@ -308,4 +354,25 @@ describe('MalioInputText', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ', readonly: true})
|
||||||
|
const field = wrapper.get('input')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(field.classes()).not.toContain('grow-height')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir et icône noire', () => {
|
||||||
|
const wrapper = mountInput({label: 'Champ', readonly: true, modelValue: 'hello', iconName: 'mdi:key-outline'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type InputAmountProps = {
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
|
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
|
||||||
@@ -96,7 +97,7 @@ describe('MalioInputAmount', () => {
|
|||||||
await wrapper.get('input').setValue('12.5')
|
await wrapper.get('input').setValue('12.5')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts commas but normalizes them to dots', async () => {
|
it('accepts commas but normalizes them to dots', async () => {
|
||||||
@@ -105,7 +106,7 @@ describe('MalioInputAmount', () => {
|
|||||||
await wrapper.get('input').setValue('0012,345abc')
|
await wrapper.get('input').setValue('0012,345abc')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
||||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes a leading decimal separator', async () => {
|
it('normalizes a leading decimal separator', async () => {
|
||||||
@@ -114,7 +115,7 @@ describe('MalioInputAmount', () => {
|
|||||||
await wrapper.get('input').setValue(',5')
|
await wrapper.get('input').setValue(',5')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the normalized decimal value on blur', async () => {
|
it('keeps the normalized decimal value on blur', async () => {
|
||||||
@@ -125,7 +126,7 @@ describe('MalioInputAmount', () => {
|
|||||||
await input.trigger('blur')
|
await input.trigger('blur')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
||||||
expect(input.element.value).toBe('12.5')
|
expect(input.element.value).toBe('12,5')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps integer values unchanged on blur', async () => {
|
it('keeps integer values unchanged on blur', async () => {
|
||||||
@@ -174,4 +175,107 @@ describe('MalioInputAmount', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||||
|
const field = wrapper.get('input')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(field.classes()).not.toContain('grow-height')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : icône en text-m-muted', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir et icône noire', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', readonly: true, modelValue: '12.50'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||||
|
const wrapper = mountInputAmount({modelValue: ''})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('1234567')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||||
|
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groupe un grand montant avec décimales', async () => {
|
||||||
|
const wrapper = mountInputAmount({modelValue: ''})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('1234567,89')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||||
|
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||||
|
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||||
|
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('12345')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect(wrapper.get('input').element.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maxLength autorise une valeur à la limite', async () => {
|
||||||
|
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||||
|
|
||||||
|
await wrapper.get('input').setValue('1234')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||||
|
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||||
|
const wrapper = mountInputAmount({maxLength: 4})
|
||||||
|
|
||||||
|
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
:class="mergedInputClass"
|
:class="mergedInputClass"
|
||||||
:required="required"
|
:required="required"
|
||||||
:maxlength="maxLength"
|
|
||||||
:minlength="minLength"
|
:minlength="minLength"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:value="currentValue"
|
:value="formattedValue"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:aria-invalid="!!error"
|
:aria-invalid="!!error"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
@@ -21,7 +20,7 @@
|
|||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
placeholder="_"
|
placeholder="_"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -44,7 +43,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -52,7 +51,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -64,9 +64,14 @@
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -89,6 +94,7 @@ const props = withDefaults(
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -109,8 +115,9 @@ const props = withDefaults(
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
iconSize: 24,
|
iconSize: 20,
|
||||||
iconColor: 'text-m-muted',
|
iconColor: 'text-m-muted',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,10 +129,16 @@ const isFocused = ref(false)
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentValue.value.length > 0,
|
||||||
|
)
|
||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
@@ -135,30 +148,40 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: 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
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
iconInputPaddingClass.value,
|
iconInputPaddingClass.value,
|
||||||
focusPaddingClass.value,
|
isReadonly.value ? '' : focusPaddingClass.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value
|
||||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||||
|
: '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: disabled.value
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -172,40 +195,37 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: string): void
|
(event: 'update:modelValue', value: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const normalizeAmount = (value: string) => {
|
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||||
const sanitizedValue = value
|
|
||||||
.replace(/\s+/g, '')
|
|
||||||
.replace(/,/g, '.')
|
|
||||||
.replace(/[^\d.]/g, '')
|
|
||||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
|
||||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
|
||||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
|
||||||
|
|
||||||
if (sanitizedValue.includes('.')) {
|
|
||||||
return `${integerPart || '0'}.${decimalPart}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return integerPart
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the DOM input value, local state, and v-model emission in sync.
|
|
||||||
const updateValue = (target: HTMLInputElement, value: string) => {
|
|
||||||
target.value = value
|
|
||||||
if (!isControlled.value) {
|
|
||||||
localValue.value = value
|
|
||||||
}
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize while typing so the field never keeps invalid amount characters.
|
|
||||||
const onInput = (event: Event) => {
|
const onInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
updateValue(target, normalizeAmount(target.value))
|
const rawText = target.value
|
||||||
|
const caret = target.selectionStart ?? rawText.length
|
||||||
|
const model = normalizeAmount(rawText)
|
||||||
|
|
||||||
|
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||||
|
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||||
|
target.value = formattedValue.value
|
||||||
|
const restored = Math.min(Math.max(0, caret - 1), formattedValue.value.length)
|
||||||
|
target.setSelectionRange(restored, restored)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = formatGroupedAmount(model)
|
||||||
|
const sig = countSignificant(rawText, caret)
|
||||||
|
target.value = display
|
||||||
|
const newCaret = caretFromSignificant(display, sig)
|
||||||
|
target.setSelectionRange(newCaret, newCaret)
|
||||||
|
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = model
|
||||||
|
}
|
||||||
|
emit('update:modelValue', model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the blur handler only for focus-driven UI state.
|
// Keep the blur handler only for focus-driven UI state.
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
isFocused.value = false
|
isFocused.value = false
|
||||||
|
onKbdBlur()
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconInputPaddingClass = computed(() => {
|
const iconInputPaddingClass = computed(() => {
|
||||||
@@ -234,6 +254,7 @@ const iconStateClass = computed(() => {
|
|||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (disabled.value) return props.iconColor
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return props.iconColor
|
return props.iconColor
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type InputAutocompleteProps = {
|
|||||||
debounce?: number
|
debounce?: number
|
||||||
minSearchLength?: number
|
minSearchLength?: number
|
||||||
allowCreate?: boolean
|
allowCreate?: boolean
|
||||||
|
localFilter?: boolean
|
||||||
iconName?: string
|
iconName?: string
|
||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
@@ -35,6 +36,7 @@ type InputAutocompleteProps = {
|
|||||||
noResultsText?: string
|
noResultsText?: string
|
||||||
loadingText?: string
|
loadingText?: string
|
||||||
minSearchText?: string
|
minSearchText?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
|
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
|
||||||
@@ -64,6 +66,16 @@ describe('MalioInputAutocomplete', () => {
|
|||||||
expect(wrapper.get('label').text()).toBe('Pays')
|
expect(wrapper.get('label').text()).toBe('Pays')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('renders with type combobox role', () => {
|
it('renders with type combobox role', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
@@ -427,4 +439,128 @@ describe('MalioInputAutocomplete', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('input').element.value).toBe('Custom')
|
expect(wrapper.get('input').element.value).toBe('Custom')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not filter options when localFilter is false (default)', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('fr')
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters options client-side when localFilter is true', async () => {
|
||||||
|
const wrapper = mountComponent({options, localFilter: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('fr')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="option"]')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
expect(items[0].text()).toBe('France')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('localFilter is case-insensitive and matches substrings', async () => {
|
||||||
|
const wrapper = mountComponent({options, localFilter: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('GIQ')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="option"]')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
expect(items[0].text()).toBe('Belgique')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('localFilter shows all options when input is empty', async () => {
|
||||||
|
const wrapper = mountComponent({options, localFilter: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('localFilter shows the no-results state when nothing matches', async () => {
|
||||||
|
const wrapper = mountComponent({options, localFilter: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
await wrapper.get('input').setValue('zzzzz')
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(0)
|
||||||
|
expect(wrapper.find('[data-test="no-results-text"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the floating label at the same position whether focused or not (no jump)', async () => {
|
||||||
|
const wrapper = mountComponent({options, label: 'Pays', modelValue: 'fr'})
|
||||||
|
|
||||||
|
// when a value is selected and the field is not focused, the label is already floated
|
||||||
|
const labelClasses = wrapper.get('label').classes()
|
||||||
|
expect(labelClasses).toContain('-translate-y-[1.25rem]')
|
||||||
|
// and there is no extra peer-focus translate that would make it jump on click
|
||||||
|
expect(labelClasses).not.toContain('peer-focus:-translate-y-[1.55rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not shift inner text horizontally on focus (no focus:pl change)', () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
const inputClasses = wrapper.get('input').classes()
|
||||||
|
expect(inputClasses).not.toContain('focus:pl-[11px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the bottom border allocation when open (transparent, not zero)', async () => {
|
||||||
|
const wrapper = mountComponent({options})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
const inputClasses = wrapper.get('input').classes()
|
||||||
|
// border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
|
||||||
|
// border-b-transparent keeps the 1px allocation but hides the line
|
||||||
|
expect(inputClasses).not.toContain('!border-b-0')
|
||||||
|
expect(inputClasses).toContain('!border-b-transparent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
const field = wrapper.get('input')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(field.classes()).not.toContain('grow-height')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : chevron en text-m-muted', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir et icône noire', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fr', options, iconName: 'mdi:magnify', iconPosition: 'left'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="icon-left"]').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', options})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
|
@blur="onKbdBlur"
|
||||||
@click="onInputClick"
|
@click="onInputClick"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -90,6 +91,7 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success select-scrollbar-success'
|
? 'border-m-success select-scrollbar-success'
|
||||||
: 'border-m-primary select-scrollbar-primary',
|
: 'border-m-primary select-scrollbar-primary',
|
||||||
|
keyboardFocused ? 'm-combo-ring-bottom' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
@@ -107,7 +109,7 @@
|
|||||||
{{ minSearchText }}
|
{{ minSearchText }}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-else-if="options.length === 0"
|
v-else-if="filteredOptions.length === 0"
|
||||||
class="px-3 py-2 text-m-muted"
|
class="px-3 py-2 text-m-muted"
|
||||||
data-test="no-results-text"
|
data-test="no-results-text"
|
||||||
>
|
>
|
||||||
@@ -115,7 +117,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<li
|
<li
|
||||||
v-for="(opt, index) in options"
|
v-for="(opt, index) in filteredOptions"
|
||||||
:id="optionId(index)"
|
:id="optionId(index)"
|
||||||
:key="String(opt.value)"
|
:key="String(opt.value)"
|
||||||
data-test="option"
|
data-test="option"
|
||||||
@@ -136,11 +138,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -149,12 +152,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
@@ -180,6 +187,7 @@ const props = withDefaults(
|
|||||||
debounce?: number
|
debounce?: number
|
||||||
minSearchLength?: number
|
minSearchLength?: number
|
||||||
allowCreate?: boolean
|
allowCreate?: boolean
|
||||||
|
localFilter?: boolean
|
||||||
iconName?: string
|
iconName?: string
|
||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
@@ -187,6 +195,7 @@ const props = withDefaults(
|
|||||||
noResultsText?: string
|
noResultsText?: string
|
||||||
loadingText?: string
|
loadingText?: string
|
||||||
minSearchText?: string
|
minSearchText?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -207,6 +216,7 @@ const props = withDefaults(
|
|||||||
debounce: 300,
|
debounce: 300,
|
||||||
minSearchLength: 0,
|
minSearchLength: 0,
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
|
localFilter: false,
|
||||||
iconName: '',
|
iconName: '',
|
||||||
iconPosition: 'left',
|
iconPosition: 'left',
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
@@ -214,6 +224,7 @@ const props = withDefaults(
|
|||||||
noResultsText: 'Aucun résultat',
|
noResultsText: 'Aucun résultat',
|
||||||
loadingText: 'Chargement…',
|
loadingText: 'Chargement…',
|
||||||
minSearchText: 'Tapez pour rechercher',
|
minSearchText: 'Tapez pour rechercher',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,15 +258,29 @@ const hasSelection = computed(() =>
|
|||||||
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 isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
|
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || inputValue.value.length > 0,
|
||||||
|
)
|
||||||
|
|
||||||
const showMinSearch = computed(() =>
|
const showMinSearch = computed(() =>
|
||||||
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
|
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
if (!props.localFilter) return props.options
|
||||||
|
const query = inputValue.value.trim().toLowerCase()
|
||||||
|
if (query === '') return props.options
|
||||||
|
return props.options.filter(opt =>
|
||||||
|
opt.label.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const optionId = (index: number) => `${inputId.value}-option-${index}`
|
const optionId = (index: number) => `${inputId.value}-option-${index}`
|
||||||
const activeOptionId = computed(() =>
|
const activeOptionId = computed(() =>
|
||||||
activeIndex.value >= 0 && props.options[activeIndex.value]
|
activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
|
||||||
? optionId(activeIndex.value)
|
? optionId(activeIndex.value)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
@@ -294,19 +319,18 @@ const iconInputPaddingClass = computed(() => {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusPaddingClass = computed(() => {
|
|
||||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
|
||||||
return 'focus:pl-[11px]'
|
|
||||||
})
|
|
||||||
|
|
||||||
const labelPositionClass = computed(() =>
|
const labelPositionClass = computed(() =>
|
||||||
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
|
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
|
||||||
)
|
)
|
||||||
|
|
||||||
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 text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||||
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
props.disabled
|
props.disabled
|
||||||
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
|
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
|
||||||
: 'cursor-text',
|
: 'cursor-text',
|
||||||
@@ -314,11 +338,11 @@ const mergedInputClass = computed(() =>
|
|||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
isOpen.value ? '!rounded-b-none !border-b-0' : '',
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
|
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
iconInputPaddingClass.value,
|
iconInputPaddingClass.value,
|
||||||
focusPaddingClass.value,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,13 +350,16 @@ const mergedLabelClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
|
||||||
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: props.disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -341,6 +368,7 @@ const iconStateClass = computed(() => {
|
|||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (props.disabled) return props.iconColor
|
if (props.disabled) return props.iconColor
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return props.iconColor
|
return props.iconColor
|
||||||
@@ -349,6 +377,7 @@ const iconStateClass = computed(() => {
|
|||||||
const chevronColorClass = computed(() => {
|
const chevronColorClass = computed(() => {
|
||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isOpen.value) return 'text-m-primary'
|
if (isOpen.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return 'text-m-muted'
|
return 'text-m-muted'
|
||||||
@@ -377,6 +406,7 @@ const onInput = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
|
onKbdFocus()
|
||||||
if (props.disabled || props.readonly) return
|
if (props.disabled || props.readonly) return
|
||||||
isFocused.value = true
|
isFocused.value = true
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
@@ -423,7 +453,20 @@ const closeAndRevert = () => {
|
|||||||
isFocused.value = false
|
isFocused.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
|
||||||
|
watch(activeIndex, async (index) => {
|
||||||
|
if (index < 0 || !isOpen.value) return
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||||
|
})
|
||||||
|
|
||||||
const onKeydown = (event: KeyboardEvent) => {
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
if (isOpen.value) closeAndCommit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
closeAndRevert()
|
closeAndRevert()
|
||||||
@@ -432,8 +475,8 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
|
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
|
||||||
onSelect(props.options[activeIndex.value])
|
onSelect(filteredOptions.value[activeIndex.value])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (props.allowCreate && inputValue.value !== '') {
|
if (props.allowCreate && inputValue.value !== '') {
|
||||||
@@ -450,13 +493,31 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
}
|
}
|
||||||
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
|
activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowUp') {
|
if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
// Liste fermée : ouvre et place sur la dernière option (APG)
|
||||||
|
if (!isOpen.value) {
|
||||||
|
isOpen.value = true
|
||||||
|
activeIndex.value = filteredOptions.value.length - 1
|
||||||
|
return
|
||||||
|
}
|
||||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home / End : première / dernière option quand la liste est ouverte
|
||||||
|
if (isOpen.value && event.key === 'Home') {
|
||||||
|
event.preventDefault()
|
||||||
|
activeIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isOpen.value && event.key === 'End') {
|
||||||
|
event.preventDefault()
|
||||||
|
activeIndex.value = filteredOptions.value.length - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,12 +542,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grow-height {
|
.grow-height {
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
|
||||||
|
|
||||||
.grow-height:focus {
|
|
||||||
padding-top: 0.625rem;
|
|
||||||
padding-bottom: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ type InputEmailProps = {
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
lowercase?: boolean
|
||||||
|
addable?: boolean
|
||||||
|
addIconName?: string
|
||||||
|
addButtonLabel?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
||||||
@@ -52,6 +57,16 @@ describe('MalioInputEmail', () => {
|
|||||||
expect(wrapper.get('label').text()).toBe('Adresse email')
|
expect(wrapper.get('label').text()).toBe('Adresse email')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('has type email', () => {
|
it('has type email', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
@@ -225,4 +240,156 @@ describe('MalioInputEmail', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
|
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supprime tous les espaces saisis', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||||
|
const emits = wrapper.emitted('update:modelValue')!
|
||||||
|
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
|
||||||
|
expect(wrapper.get('input').element.value).toBe('ab@c.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve la casse par défaut', async () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
await wrapper.get('input').setValue('User@Example.COM')
|
||||||
|
const emits = wrapper.emitted('update:modelValue')!
|
||||||
|
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met en minuscules quand lowercase est vrai', async () => {
|
||||||
|
const wrapper = mountComponent({lowercase: true})
|
||||||
|
await wrapper.get('input').setValue('User@Example.COM')
|
||||||
|
const emits = wrapper.emitted('update:modelValue')!
|
||||||
|
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet la valeur sanitisée en mode contrôlé', async () => {
|
||||||
|
const wrapper = mountComponent({modelValue: ''})
|
||||||
|
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||||
|
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
|
||||||
|
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
|
||||||
|
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
|
||||||
|
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
|
||||||
|
const wrapper = mountComponent({modelValue: 'ab@c.com'})
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
await input.setValue('ab@c.com ')
|
||||||
|
expect(input.element.value).toBe('ab@c.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
const field = wrapper.get('input')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(field.classes()).not.toContain('grow-height')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir et icône noire', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render add button by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders add button when addable is true', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits add event when add button is clicked', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit add when disabled', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true, disabled: true})
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('add')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit add when readonly', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true, readonly: true})
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('add')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables add button when disabled', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, readonly: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the email icon to the left automatically when addable', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
const icon = wrapper.get('[data-test="icon"]')
|
||||||
|
expect(icon.classes()).toContain('left-[10px]')
|
||||||
|
expect(icon.classes()).not.toContain('right-[10px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the email icon on the right when addable is false', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the default add button aria-label', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows overriding the add button aria-label', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
type="email"
|
type="email"
|
||||||
inputmode="email"
|
inputmode="email"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -40,9 +40,26 @@
|
|||||||
:class="[iconStateClass, iconPositionClass]"
|
:class="[iconStateClass, iconPositionClass]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="addable"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="addButtonLabel"
|
||||||
|
data-test="add-button"
|
||||||
|
:class="mergedAddButtonClass"
|
||||||
|
@click="onAdd"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
:icon="addIconName"
|
||||||
|
:width="24"
|
||||||
|
:height="24"
|
||||||
|
data-test="add-icon"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -50,7 +67,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -63,9 +81,13 @@
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -86,6 +108,11 @@ const props = withDefaults(
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
addable?: boolean
|
||||||
|
addIconName?: string
|
||||||
|
addButtonLabel?: string
|
||||||
|
lowercase?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -106,6 +133,11 @@ const props = withDefaults(
|
|||||||
success: '',
|
success: '',
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
iconColor: 'text-m-muted',
|
iconColor: 'text-m-muted',
|
||||||
|
addable: false,
|
||||||
|
addIconName: 'mdi:plus',
|
||||||
|
addButtonLabel: 'Ajouter une adresse email',
|
||||||
|
lowercase: false,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,10 +149,15 @@ const isFocused = ref(false)
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentValue.value.length > 0,
|
||||||
|
)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
@@ -129,34 +166,52 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: 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
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
iconInputPaddingClass.value,
|
iconInputPaddingClass.value,
|
||||||
focusPaddingClass.value,
|
isReadonly.value ? '' : focusPaddingClass.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value
|
||||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||||
|
: '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: disabled.value
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mergedAddButtonClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||||
|
iconStateClass.value,
|
||||||
|
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const describedBy = computed(() => {
|
const describedBy = computed(() => {
|
||||||
const ids: string[] = []
|
const ids: string[] = []
|
||||||
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||||
@@ -167,35 +222,74 @@ const describedBy = computed(() => {
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: string): void
|
(event: 'update:modelValue', value: string): void
|
||||||
|
(event: 'add'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const sanitizeEmail = (v: string) => {
|
||||||
|
let out = v.replace(/\s+/g, '')
|
||||||
|
if (props.lowercase) out = out.toLowerCase()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
const onInput = (event: Event) => {
|
const onInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
if (!isControlled.value) {
|
const raw = target.value
|
||||||
localValue.value = target.value
|
const sanitized = sanitizeEmail(raw)
|
||||||
|
|
||||||
|
if (sanitized !== raw) {
|
||||||
|
// `<input type="email">` ne supporte pas l'API de sélection :
|
||||||
|
// selectionStart vaut null et setSelectionRange lève en navigateur.
|
||||||
|
// (En jsdom selectionStart peut renvoyer un nombre, d'où le code gardé ci-dessous.)
|
||||||
|
const caret = target.selectionStart
|
||||||
|
target.value = sanitized
|
||||||
|
if (caret !== null) {
|
||||||
|
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
|
||||||
|
try {
|
||||||
|
target.setSelectionRange(newCaret, newCaret)
|
||||||
|
} catch {
|
||||||
|
/* type d'input sans support de sélection — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
emit('update:modelValue', target.value)
|
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = sanitized
|
||||||
|
}
|
||||||
|
emit('update:modelValue', sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
emit('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveIconPosition = computed(() =>
|
||||||
|
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||||
|
)
|
||||||
|
|
||||||
const iconInputPaddingClass = computed(() => {
|
const iconInputPaddingClass = computed(() => {
|
||||||
if (!props.iconName) return ''
|
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (leftIcon) parts.push('!pl-11')
|
||||||
|
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||||
|
return parts.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
const disabled = computed(() => props.disabled)
|
const disabled = computed(() => props.disabled)
|
||||||
|
|
||||||
const labelPositionClass = computed(() => {
|
const labelPositionClass = computed(() => {
|
||||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
|
||||||
return 'left-3'
|
return 'left-3'
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusPaddingClass = computed(() => {
|
const focusPaddingClass = computed(() => {
|
||||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
|
||||||
return 'focus:pl-[11px]'
|
return 'focus:pl-[11px]'
|
||||||
})
|
})
|
||||||
|
|
||||||
const iconPositionClass = computed(() => {
|
const iconPositionClass = computed(() => {
|
||||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -203,6 +297,7 @@ const iconStateClass = computed(() => {
|
|||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (disabled.value) return props.iconColor
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return props.iconColor
|
return props.iconColor
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import InputNumber from './InputNumber.vue'
|
|||||||
type InputNumberProps = {
|
type InputNumberProps = {
|
||||||
modelValue?: string | null
|
modelValue?: string | null
|
||||||
label?: string
|
label?: string
|
||||||
|
required?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
min?: number | string
|
min?: number | string
|
||||||
max?: number | string
|
max?: number | string
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
|
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
|
||||||
@@ -162,4 +166,33 @@ describe('MalioInputNumber', () => {
|
|||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
|
||||||
expect(input.element.value).toBe('5')
|
expect(input.element.value).toBe('5')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountInputNumber({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountInputNumber({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountInputNumber({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="m-focus-ring rounded-malio"
|
||||||
:disabled="isMinusDisabled"
|
:disabled="isMinusDisabled"
|
||||||
@click="decrement"
|
@click="decrement"
|
||||||
>
|
>
|
||||||
@@ -35,11 +36,12 @@
|
|||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
placeholder="_"
|
placeholder="_"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="m-focus-ring rounded-malio"
|
||||||
:disabled="isPlusDisabled"
|
:disabled="isPlusDisabled"
|
||||||
@click="increment"
|
@click="increment"
|
||||||
>
|
>
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -59,7 +61,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'text-xs ml-[2px] ',
|
'text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -71,9 +74,13 @@
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -91,6 +98,7 @@ const props = withDefaults(
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -108,6 +116,7 @@ const props = withDefaults(
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -180,6 +189,7 @@ const mergedGroupClass = computed(() =>
|
|||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||||
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type InputPasswordProps = {
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
displayIcon?: boolean
|
displayIcon?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
|
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
|
||||||
@@ -51,6 +52,16 @@ describe('MalioInputPassword', () => {
|
|||||||
expect(wrapper.get('label').text()).toBe('Mot de passe')
|
expect(wrapper.get('label').text()).toBe('Mot de passe')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('has type password by default', () => {
|
it('has type password by default', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
@@ -185,4 +196,55 @@ describe('MalioInputPassword', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
const field = wrapper.get('input')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(field.classes()).not.toContain('grow-height')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : icône en text-m-muted', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir et icône noire', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'secret'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : eye toggle reste cliquable', async () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
await wrapper.get('[data-test="icon"]').trigger('click')
|
||||||
|
expect(wrapper.get('input').attributes('type')).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
placeholder="_"
|
placeholder="_"
|
||||||
:type="isPasswordVisible ? 'text' : 'password'"
|
:type="isPasswordVisible ? 'text' : 'password'"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -55,7 +55,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -68,9 +69,13 @@
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -90,6 +95,7 @@ const props = withDefaults(
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
displayIcon?: boolean
|
displayIcon?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -109,6 +115,7 @@ const props = withDefaults(
|
|||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
displayIcon: true,
|
displayIcon: true,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,10 +132,15 @@ const toggleVisibility = () => {
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-password-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-password-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentValue.value.length > 0,
|
||||||
|
)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
@@ -137,16 +149,21 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: 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
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
props.displayIcon ? '!pr-10' : '',
|
props.displayIcon ? '!pr-10' : '',
|
||||||
'focus:pl-[11px]',
|
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -154,13 +171,18 @@ const mergedLabelClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
'left-3',
|
'left-3',
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value
|
||||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||||
|
: '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: disabled.value
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -191,6 +213,7 @@ const iconStateClass = computed(() => {
|
|||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (disabled.value) return 'text-m-muted'
|
if (disabled.value) return 'text-m-muted'
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return 'text-m-muted'
|
return 'text-m-muted'
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type InputPhoneProps = {
|
|||||||
addable?: boolean
|
addable?: boolean
|
||||||
addIconName?: string
|
addIconName?: string
|
||||||
addButtonLabel?: string
|
addButtonLabel?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
|
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
|
||||||
@@ -56,6 +57,16 @@ describe('MalioInputPhone', () => {
|
|||||||
expect(wrapper.get('label').text()).toBe('Téléphone')
|
expect(wrapper.get('label').text()).toBe('Téléphone')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('has type tel', () => {
|
it('has type tel', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
@@ -264,10 +275,43 @@ describe('MalioInputPhone', () => {
|
|||||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables add button when readonly', () => {
|
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||||
const wrapper = mountComponent({addable: true, readonly: true})
|
const wrapper = mountComponent({addable: true, readonly: true})
|
||||||
|
|
||||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : border-black appliqué sur l\'input', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Tel', readonly: true})
|
||||||
|
expect(wrapper.get('input').classes()).toContain('border-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : icône en text-m-muted quand vide', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Tel', readonly: true})
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : icône en text-black quand rempli', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : pas d\'apparence désactivée (pas opacity-40)', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Tel', addable: true, readonly: true})
|
||||||
|
// opacity-40 was only ever applied to the add button, not the input
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('opacity-40')
|
||||||
|
// and the input is not natively disabled in readonly:
|
||||||
|
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label en text-m-muted', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Tel', readonly: true})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label en text-black', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the default add icon (mdi:plus)', () => {
|
it('renders the default add icon (mdi:plus)', () => {
|
||||||
@@ -298,6 +342,41 @@ describe('MalioInputPhone', () => {
|
|||||||
expect(wrapper.get('input').classes()).toContain('!pr-10')
|
expect(wrapper.get('input').classes()).toContain('!pr-10')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows default add button color when empty and unfocused', () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-muted')
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows primary add button color on focus', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black add button color when filled and unfocused', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, modelValue: '+33612345678'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error overrides focus color on add button', async () => {
|
||||||
|
const wrapper = mountComponent({addable: true, error: 'Numéro invalide'})
|
||||||
|
|
||||||
|
await wrapper.get('input').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('success applies to add button', () => {
|
||||||
|
const wrapper = mountComponent({addable: true, success: 'Numéro valide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
it('applies mask via maska directive', async () => {
|
it('applies mask via maska directive', async () => {
|
||||||
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
||||||
|
|
||||||
@@ -305,4 +384,23 @@ describe('MalioInputPhone', () => {
|
|||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
type="tel"
|
type="tel"
|
||||||
inputmode="tel"
|
inputmode="tel"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="addable"
|
v-if="addable"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="disabled || readonly"
|
:disabled="disabled"
|
||||||
:aria-label="addButtonLabel"
|
:aria-label="addButtonLabel"
|
||||||
data-test="add-button"
|
data-test="add-button"
|
||||||
:class="mergedAddButtonClass"
|
:class="mergedAddButtonClass"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -68,7 +68,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -83,9 +84,13 @@ import {vMaska} from 'maska/vue'
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -110,6 +115,7 @@ const props = withDefaults(
|
|||||||
addable?: boolean
|
addable?: boolean
|
||||||
addIconName?: string
|
addIconName?: string
|
||||||
addButtonLabel?: string
|
addButtonLabel?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -134,6 +140,7 @@ const props = withDefaults(
|
|||||||
addable: false,
|
addable: false,
|
||||||
addIconName: 'mdi:plus',
|
addIconName: 'mdi:plus',
|
||||||
addButtonLabel: 'Ajouter un numéro',
|
addButtonLabel: 'Ajouter un numéro',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,10 +152,15 @@ const isFocused = ref(false)
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentValue.value.length > 0,
|
||||||
|
)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
@@ -157,38 +169,49 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: 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
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
iconInputPaddingClass.value,
|
iconInputPaddingClass.value,
|
||||||
focusPaddingClass.value,
|
isReadonly.value ? '' : focusPaddingClass.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value
|
||||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||||
|
: '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: disabled.value
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedAddButtonClass = computed(() =>
|
const mergedAddButtonClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70',
|
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||||
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
iconStateClass.value,
|
||||||
|
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,6 +271,7 @@ const iconStateClass = computed(() => {
|
|||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (disabled.value) return props.iconColor
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return props.iconColor
|
return props.iconColor
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type InputRichTextProps = {
|
|||||||
groupClass?: string
|
groupClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
editorClass?: string
|
editorClass?: string
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
|
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
|
||||||
@@ -155,6 +157,18 @@ describe('MalioInputRichText', () => {
|
|||||||
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
|
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('expose aria-required quand required est vrai', async () => {
|
||||||
|
const wrapper = await mountComponent({required: true})
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'expose pas aria-required par défaut', async () => {
|
||||||
|
const wrapper = await mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('renders initial markdown content visually', async () => {
|
it('renders initial markdown content visually', async () => {
|
||||||
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
|
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
|
||||||
|
|
||||||
@@ -162,4 +176,35 @@ describe('MalioInputRichText', () => {
|
|||||||
expect(html).toContain('Mon titre')
|
expect(html).toContain('Mon titre')
|
||||||
expect(html).toContain('Un paragraphe.')
|
expect(html).toContain('Un paragraphe.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Champ', required: true})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Champ'})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', async () => {
|
||||||
|
const wrapper = await mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
:for="editorId"
|
:for="editorId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Mode lecture seule (rendu uniquement) -->
|
<!-- Mode lecture seule (rendu uniquement) -->
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
v-else
|
v-else
|
||||||
:id="editorId"
|
:id="editorId"
|
||||||
:class="mergedEditorWrapperClass"
|
:class="mergedEditorWrapperClass"
|
||||||
|
:aria-required="required || undefined"
|
||||||
@click="focusEditor"
|
@click="focusEditor"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${editorId}-describedby`"
|
:id="`${editorId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -193,6 +194,7 @@
|
|||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px]',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -211,6 +213,7 @@ import Color from '@tiptap/extension-color'
|
|||||||
import Highlight from '@tiptap/extension-highlight'
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
import { Markdown } from 'tiptap-markdown'
|
import { Markdown } from 'tiptap-markdown'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
|
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
|
||||||
|
|
||||||
@@ -233,6 +236,8 @@ const props = withDefaults(
|
|||||||
groupClass?: string
|
groupClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
editorClass?: string
|
editorClass?: string
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -250,6 +255,8 @@ const props = withDefaults(
|
|||||||
groupClass: '',
|
groupClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
editorClass: '',
|
editorClass: '',
|
||||||
|
required: false,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -279,10 +286,11 @@ const mergedLabelClass = computed(() =>
|
|||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: isFocused.value
|
: props.disabled
|
||||||
? 'text-m-primary'
|
? 'text-m-muted'
|
||||||
: 'text-m-text',
|
: isFocused.value
|
||||||
props.disabled ? 'text-black/60' : '',
|
? 'text-m-primary'
|
||||||
|
: 'text-m-text',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -308,6 +316,7 @@ const mergedReadonlyClass = computed(() =>
|
|||||||
'prose-headings:font-semibold prose-a:text-m-primary',
|
'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-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',
|
'prose-pre:bg-m-text prose-pre:text-white',
|
||||||
|
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
|
||||||
props.editorClass,
|
props.editorClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -486,7 +495,7 @@ onMounted(() => {
|
|||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
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',
|
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 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
placeholder="_"
|
placeholder="_"
|
||||||
type="text"
|
type="text"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -52,7 +52,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -67,9 +68,13 @@ import {vMaska} from 'maska/vue'
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -94,6 +99,7 @@ const props = withDefaults(
|
|||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
mask?: string | MaskInputOptions
|
mask?: string | MaskInputOptions
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -117,6 +123,7 @@ const props = withDefaults(
|
|||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
iconColor: 'text-m-muted',
|
iconColor: 'text-m-muted',
|
||||||
mask: undefined,
|
mask: undefined,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,10 +135,15 @@ const isFocused = ref(false)
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentValue.value.length > 0,
|
||||||
|
)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
@@ -140,30 +152,40 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: 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
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
iconInputPaddingClass.value,
|
iconInputPaddingClass.value,
|
||||||
focusPaddingClass.value,
|
isReadonly.value ? '' : focusPaddingClass.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
labelPositionClass.value,
|
labelPositionClass.value,
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value
|
||||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||||
|
: '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: disabled.value
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -214,6 +236,7 @@ const iconStateClass = computed(() => {
|
|||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (disabled.value) return props.iconColor
|
if (disabled.value) return props.iconColor
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return props.iconColor
|
return props.iconColor
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type InputTextAreaProps = {
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
|
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
|
||||||
@@ -149,4 +150,87 @@ describe('MalioInputTextArea', () => {
|
|||||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders as a single root element (works as a single grid item)', () => {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {
|
||||||
|
attachTo: host,
|
||||||
|
})
|
||||||
|
|
||||||
|
// host > div[data-v-app] > component roots
|
||||||
|
const app = host.firstElementChild as HTMLElement
|
||||||
|
expect(app.children.length).toBe(1)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
host.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies primary scrollbar class on focus', async () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest)
|
||||||
|
|
||||||
|
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||||
|
|
||||||
|
await wrapper.get('textarea').trigger('focus')
|
||||||
|
|
||||||
|
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes primary scrollbar class on blur', async () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest)
|
||||||
|
|
||||||
|
await wrapper.get('textarea').trigger('focus')
|
||||||
|
await wrapper.get('textarea').trigger('blur')
|
||||||
|
|
||||||
|
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', required: true}})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de bleu', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
|
||||||
|
const field = wrapper.get('textarea')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu focus', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||||
|
// En readonly, pas de couleur primary sur le label
|
||||||
|
expect(wrapper.get('label').classes()).not.toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true, modelValue: 'du texte'}})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||||
|
const msg = wrapper.find('[data-test="message-line"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false}})
|
||||||
|
expect(wrapper.find('[data-test="message-line"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false, error: 'Erreur'}})
|
||||||
|
const msg = wrapper.find('[data-test="message-line"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,88 +1,101 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="mergedGroupClass">
|
<div :class="mergedGroupClass">
|
||||||
<textarea
|
<div class="relative w-full flex-1">
|
||||||
:id="inputId"
|
<textarea
|
||||||
:name="name"
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
|
||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
|
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
|
||||||
:class="[
|
:class="[
|
||||||
isFilled ? 'border-black' : 'border-m-muted',
|
isReadonly ? 'border-black' : (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' : (isReadonly ? 'cursor-default' : 'cursor-text'),
|
||||||
hasError
|
|
||||||
? 'border-m-danger focus:border-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'border-m-success focus:border-m-success'
|
|
||||||
: 'focus:border-m-primary',
|
|
||||||
textInput,
|
|
||||||
showCounterComputed ? 'pb-6' : '',
|
|
||||||
rounded,
|
|
||||||
]"
|
|
||||||
:required="required"
|
|
||||||
:maxlength="maxLength"
|
|
||||||
:rows="rowsCount"
|
|
||||||
:disabled="disabled"
|
|
||||||
:value="currentValue"
|
|
||||||
:readonly="readonly"
|
|
||||||
:aria-invalid="hasError"
|
|
||||||
:aria-describedby="describedBy"
|
|
||||||
:style="textareaStyle"
|
|
||||||
v-bind="attrs"
|
|
||||||
placeholder="_"
|
|
||||||
@input="onInput"
|
|
||||||
@focus="isFocused = true"
|
|
||||||
@blur="isFocused = false"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
v-if="label"
|
|
||||||
:for="inputId"
|
|
||||||
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
|
||||||
:class="[
|
|
||||||
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
|
||||||
disabled ? 'text-black/60' : '',
|
|
||||||
hasError
|
|
||||||
? 'text-m-danger'
|
|
||||||
: hasSuccess
|
|
||||||
? 'text-m-success'
|
|
||||||
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
|
|
||||||
textLabel,
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</label>
|
|
||||||
<span
|
|
||||||
v-if="showCounterComputed"
|
|
||||||
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
|
|
||||||
>
|
|
||||||
{{ currentLength }}/{{ maxLength }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="hasError || hasSuccess || hint"
|
|
||||||
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
:id="`${inputId}-describedby`"
|
|
||||||
:class="[
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'border-m-danger focus:border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'border-m-success focus:border-m-success'
|
||||||
: 'text-m-muted',
|
: isReadonly ? '' : 'focus:border-m-primary',
|
||||||
'ml-[2px]',
|
isReadonly ? '' : (isFocused ? 'textarea-scrollbar-primary' : ''),
|
||||||
|
textInput,
|
||||||
|
showCounterComputed ? 'pb-6' : '',
|
||||||
|
rounded,
|
||||||
|
keyboardFocused ? 'm-focus-ring-kbd' : '',
|
||||||
]"
|
]"
|
||||||
|
:required="required"
|
||||||
|
:maxlength="maxLength"
|
||||||
|
:rows="rowsCount"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentValue"
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-invalid="hasError"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
:style="textareaStyle"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="inputId"
|
||||||
|
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
||||||
|
:class="[
|
||||||
|
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly
|
||||||
|
? (isFilled ? 'text-black' : 'text-m-muted')
|
||||||
|
: (isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted'),
|
||||||
|
textLabel,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
v-if="showCounterComputed"
|
||||||
|
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
|
||||||
|
>
|
||||||
|
{{ currentLength }}/{{ maxLength }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
|
data-test="message-line"
|
||||||
|
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
||||||
|
:class="reserveMessageSpace ? 'min-h-[1rem]' : ''"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
<p
|
||||||
</p>
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'ml-[2px]',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ error || success || hint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -108,6 +121,7 @@ const props = withDefaults(
|
|||||||
success?: string
|
success?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -134,11 +148,14 @@ const props = withDefaults(
|
|||||||
minResizeHeight: 40,
|
minResizeHeight: 40,
|
||||||
maxResizeHeight: 320,
|
maxResizeHeight: 320,
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge('relative w-full', props.groupClass),
|
// pt-1 (4px) aligne le haut de la textarea avec les inputs floating-label,
|
||||||
|
// qui centrent un champ de 40px dans un groupe h-12 (≈ 4px de décalage en haut).
|
||||||
|
twMerge('flex flex-col w-full pt-1', props.groupClass),
|
||||||
)
|
)
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
@@ -149,9 +166,15 @@ const isFocused = ref(false)
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
|
||||||
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 isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentValue.value.length > 0,
|
||||||
|
)
|
||||||
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
|
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
|
||||||
const currentLength = computed(() => (currentValue.value ?? '').length)
|
const currentLength = computed(() => (currentValue.value ?? '').length)
|
||||||
const showCounterComputed = computed(() =>
|
const showCounterComputed = computed(() =>
|
||||||
@@ -165,7 +188,6 @@ const textareaStyle = computed(() => ({
|
|||||||
minHeight: toCssSize(props.minResizeHeight),
|
minHeight: toCssSize(props.minResizeHeight),
|
||||||
maxHeight: toCssSize(props.maxResizeHeight),
|
maxHeight: toCssSize(props.maxResizeHeight),
|
||||||
}))
|
}))
|
||||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
|
||||||
const describedBy = computed(() =>
|
const describedBy = computed(() =>
|
||||||
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
|
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
|
||||||
)
|
)
|
||||||
@@ -188,4 +210,8 @@ const onInput = (event: Event) => {
|
|||||||
background: white;
|
background: white;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-scrollbar-primary {
|
||||||
|
scrollbar-color: rgb(var(--m-primary)) transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, expect, it} from 'vitest'
|
import {describe, expect, it, vi} from 'vitest'
|
||||||
import {mount} from '@vue/test-utils'
|
import {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'
|
||||||
@@ -12,11 +12,14 @@ type InputUploadProps = {
|
|||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
displayIcon?: boolean
|
displayIcon?: boolean
|
||||||
accept?: string
|
accept?: string
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
|
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
|
||||||
@@ -167,6 +170,11 @@ describe('MalioInputUpload', () => {
|
|||||||
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
|
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('expose aria-required sur le champ visible quand required est vrai', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.get('input[type="text"]').attributes('aria-required')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
it('passes accept attribute to file input', () => {
|
it('passes accept attribute to file input', () => {
|
||||||
const wrapper = mountComponent({accept: '.pdf,.doc'})
|
const wrapper = mountComponent({accept: '.pdf,.doc'})
|
||||||
|
|
||||||
@@ -186,4 +194,70 @@ describe('MalioInputUpload', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', required: true})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
const field = wrapper.get('input[type="text"]')
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('grow-height')
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
expect(label.classes()).not.toContain('peer-focus:text-m-primary')
|
||||||
|
expect(label.classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : icône en text-m-muted', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label noir + icône noire', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fichier.pdf'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly empêche l\'ouverture du sélecteur de fichier', async () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||||
|
const fileInput = wrapper.get('input[type="file"]').element as HTMLInputElement
|
||||||
|
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||||
|
await wrapper.get('input[type="text"]').trigger('click')
|
||||||
|
expect(clickSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
:accept="accept"
|
:accept="accept"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -19,13 +20,16 @@
|
|||||||
:value="currentDisplayValue"
|
:value="currentDisplayValue"
|
||||||
:readonly="true"
|
:readonly="true"
|
||||||
:aria-invalid="!!error"
|
:aria-invalid="!!error"
|
||||||
|
:aria-required="required || undefined"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
placeholder="_"
|
placeholder="_"
|
||||||
type="text"
|
type="text"
|
||||||
@click="openFilePicker"
|
@click="openFilePicker"
|
||||||
@focus="isFocused = true"
|
@keydown.enter.prevent="openFilePicker"
|
||||||
@blur="isFocused = false"
|
@keydown.space.prevent="openFilePicker"
|
||||||
|
@focus="isFocused = true; onKbdFocus()"
|
||||||
|
@blur="isFocused = false; onKbdBlur()"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -33,24 +37,40 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<div
|
||||||
v-if="displayIcon"
|
v-if="displayIcon || showClear"
|
||||||
icon="mdi:cloud-arrow-up-outline"
|
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||||
:width="24"
|
>
|
||||||
:height="24"
|
<button
|
||||||
data-test="icon"
|
v-if="showClear"
|
||||||
:class="[
|
type="button"
|
||||||
iconStateClass,
|
data-test="clear"
|
||||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||||
]"
|
aria-label="Retirer le fichier"
|
||||||
/>
|
@click.stop="onClear"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="mdi:close"
|
||||||
|
:width="16"
|
||||||
|
:height="16"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="displayIcon"
|
||||||
|
icon="mdi:cloud-arrow-up-outline"
|
||||||
|
:width="24"
|
||||||
|
:height="24"
|
||||||
|
data-test="icon"
|
||||||
|
:class="[iconStateClass, 'pointer-events-none']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -58,7 +78,8 @@
|
|||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 text-xs ml-[2px] ',
|
'mt-1 text-xs ml-[2px]',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ hint || error || success }}
|
{{ hint || error || success }}
|
||||||
@@ -71,9 +92,13 @@
|
|||||||
import {computed, ref, useAttrs, useId} from 'vue'
|
import {computed, ref, useAttrs, useId} 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -83,11 +108,15 @@ const props = withDefaults(
|
|||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
displayIcon?: boolean
|
displayIcon?: boolean
|
||||||
accept?: string
|
accept?: string
|
||||||
|
required?: boolean
|
||||||
|
clearable?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -97,11 +126,15 @@ const props = withDefaults(
|
|||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
displayIcon: true,
|
displayIcon: true,
|
||||||
accept: '',
|
accept: '',
|
||||||
|
required: false,
|
||||||
|
clearable: false,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,10 +147,16 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
|||||||
const inputId = computed(() => props.id?.toString() || `malio-input-upload-${generatedId}`)
|
const inputId = computed(() => props.id?.toString() || `malio-input-upload-${generatedId}`)
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const currentDisplayValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentDisplayValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const shouldFloatLabel = computed(() => isFocused.value || currentDisplayValue.value.length > 0)
|
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
|
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
|
||||||
|
const disabled = computed(() => props.disabled)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const shouldFloatLabel = computed(() =>
|
||||||
|
isReadonly.value
|
||||||
|
? isFilled.value
|
||||||
|
: isFocused.value || currentDisplayValue.value.length > 0,
|
||||||
|
)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'relative flex h-12 w-full items-center',
|
'relative flex h-12 w-full items-center',
|
||||||
@@ -126,16 +165,24 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
|
isReadonly.value ? '' : 'grow-height',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
|
disabled.value ? 'text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
props.displayIcon ? '!pr-10' : '',
|
showClear.value
|
||||||
'focus:pl-[11px]',
|
? (props.displayIcon ? '!pr-16' : '!pr-10')
|
||||||
|
: (props.displayIcon ? '!pr-10' : ''),
|
||||||
|
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
|
disabled.value ? 'cursor-not-allowed' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -143,13 +190,18 @@ const mergedLabelClass = computed(() =>
|
|||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||||
'left-3',
|
'left-3',
|
||||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
shouldFloatLabel.value
|
||||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||||
|
: '',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
: disabled.value
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -165,10 +217,23 @@ const describedBy = computed(() => {
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: string): void
|
(event: 'update:modelValue', value: string): void
|
||||||
(event: 'file-selected', file: File): void
|
(event: 'file-selected', file: File): void
|
||||||
|
(event: 'clear'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showClear = computed(() =>
|
||||||
|
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
if (props.disabled || isReadonly.value) return
|
||||||
|
if (!isControlled.value) localValue.value = ''
|
||||||
|
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('clear')
|
||||||
|
}
|
||||||
|
|
||||||
const openFilePicker = () => {
|
const openFilePicker = () => {
|
||||||
if (props.disabled) return
|
if (props.disabled || props.readonly) return
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +250,11 @@ const onFileChange = (event: Event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = computed(() => props.disabled)
|
|
||||||
|
|
||||||
const iconStateClass = computed(() => {
|
const iconStateClass = computed(() => {
|
||||||
if (hasError.value) return 'text-m-danger'
|
if (hasError.value) return 'text-m-danger'
|
||||||
if (hasSuccess.value) return 'text-m-success'
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
if (disabled.value) return 'text-m-muted'
|
if (disabled.value) return 'text-m-muted'
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
if (isFocused.value) return 'text-m-primary'
|
if (isFocused.value) return 'text-m-primary'
|
||||||
if (isFilled.value) return 'text-black'
|
if (isFilled.value) return 'text-black'
|
||||||
return 'text-m-muted'
|
return 'text-m-muted'
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||||
|
|
||||||
|
describe('normalizeAmount', () => {
|
||||||
|
it('garde le point décimal', () => {
|
||||||
|
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||||
|
})
|
||||||
|
it('convertit la virgule en point et nettoie', () => {
|
||||||
|
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||||
|
})
|
||||||
|
it('normalise une décimale en tête', () => {
|
||||||
|
expect(normalizeAmount(',5')).toBe('0.5')
|
||||||
|
})
|
||||||
|
it('retire les espaces', () => {
|
||||||
|
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||||
|
})
|
||||||
|
it('limite à 2 décimales', () => {
|
||||||
|
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||||
|
})
|
||||||
|
it('garde une décimale en cours de saisie', () => {
|
||||||
|
expect(normalizeAmount('12.')).toBe('12.')
|
||||||
|
})
|
||||||
|
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||||
|
expect(normalizeAmount('abc')).toBe('')
|
||||||
|
})
|
||||||
|
it('garde un zéro seul', () => {
|
||||||
|
expect(normalizeAmount('0')).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatGroupedAmount', () => {
|
||||||
|
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||||
|
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||||
|
})
|
||||||
|
it('utilise la virgule comme séparateur décimal', () => {
|
||||||
|
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||||
|
})
|
||||||
|
it('affiche une virgule pour une décimale en cours', () => {
|
||||||
|
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||||
|
})
|
||||||
|
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||||
|
expect(formatGroupedAmount('12')).toBe('12')
|
||||||
|
})
|
||||||
|
it('groupe avec une décimale en tête', () => {
|
||||||
|
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||||
|
})
|
||||||
|
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||||
|
expect(formatGroupedAmount('')).toBe('')
|
||||||
|
})
|
||||||
|
it('garde un zéro seul', () => {
|
||||||
|
expect(formatGroupedAmount('0')).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('countSignificant', () => {
|
||||||
|
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||||
|
expect(countSignificant('1 234', 5)).toBe(4)
|
||||||
|
})
|
||||||
|
it('ignore un espace juste avant le curseur', () => {
|
||||||
|
expect(countSignificant('1 234', 2)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('caretFromSignificant', () => {
|
||||||
|
it('place le curseur après le n-ième caractère significatif', () => {
|
||||||
|
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||||
|
})
|
||||||
|
it('place le curseur en fin si on dépasse', () => {
|
||||||
|
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||||
|
})
|
||||||
|
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||||
|
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||||
|
export const normalizeAmount = (value: string): string => {
|
||||||
|
const sanitizedValue = value
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/,/g, '.')
|
||||||
|
.replace(/[^\d.]/g, '')
|
||||||
|
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||||
|
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||||
|
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||||
|
|
||||||
|
if (sanitizedValue.includes('.')) {
|
||||||
|
return `${integerPart || '0'}.${decimalPart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return integerPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||||
|
export const formatGroupedAmount = (model: string): string => {
|
||||||
|
if (model === '') return ''
|
||||||
|
const hasDot = model.includes('.')
|
||||||
|
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||||
|
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||||
|
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||||
|
export const countSignificant = (str: string, upTo: number): number =>
|
||||||
|
str.slice(0, upTo).replace(/ /g, '').length
|
||||||
|
|
||||||
|
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||||
|
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||||
|
if (sig <= 0) return 0
|
||||||
|
let seen = 0
|
||||||
|
for (let i = 0; i < display.length; i++) {
|
||||||
|
if (display[i] !== ' ') seen++
|
||||||
|
if (seen >= sig) return i + 1
|
||||||
|
}
|
||||||
|
return display.length
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import Modal from './Modal.vue'
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
id?: string
|
||||||
|
modelValue?: boolean
|
||||||
|
showClose?: boolean
|
||||||
|
dismissable?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
|
modalClass?: string
|
||||||
|
overlayClass?: string
|
||||||
|
headerClass?: string
|
||||||
|
bodyClass?: string
|
||||||
|
footerClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalForTest = Modal as DefineComponent<ModalProps>
|
||||||
|
|
||||||
|
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
|
||||||
|
return mount(ModalForTest, {
|
||||||
|
props,
|
||||||
|
slots,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioModal', () => {
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when modelValue is false', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: false })
|
||||||
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the panel when modelValue is true', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('centers the modal (items-center justify-center)', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const root = wrapper.find('.fixed')
|
||||||
|
expect(root.classes()).toContain('items-center')
|
||||||
|
expect(root.classes()).toContain('justify-center')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders default slot in the body', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ default: '<p data-test="content">Contenu</p>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works in uncontrolled mode (defaults closed)', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses custom id when provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
|
||||||
|
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when not provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="dialog" and aria-modal on the panel', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('role')).toBe('dialog')
|
||||||
|
expect(panel.attributes('aria-modal')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies modalClass to the panel', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
|
||||||
|
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the #header slot inside the header bar', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ header: '<h2 data-test="title">Titre</h2>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the header bar when showClose is true even without #header', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the header bar when no #header and showClose is false', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
||||||
|
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the close button by default', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the close button when showClose is false', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, showClose: false },
|
||||||
|
{ header: '<h2>Titre</h2>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close button renders mdi:cancel-bold icon', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const icon = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(icon.props('icon')).toBe('mdi:cancel-bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close button has aria-label "Fermer"', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false and close on close button click', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="close-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-labelledby to the header id when #header is provided', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, id: 'test-modal' },
|
||||||
|
{ header: '<h2>Titre</h2>' },
|
||||||
|
)
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
|
||||||
|
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-label from ariaLabel when no #header is provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies headerClass to the header bar', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
|
||||||
|
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the #footer slot in a footer pinned below the body', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ footer: '<button data-test="save">Enregistrer</button>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the footer when no #footer slot', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies bodyClass to the body', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
|
||||||
|
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies footerClass to the footer', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, footerClass: 'justify-end' },
|
||||||
|
{ footer: '<span>pied</span>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close on backdrop click when dismissable is false', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, dismissable: false })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies overlayClass to the backdrop', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
|
||||||
|
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape key when closeOnEscape is true', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close on Escape when closeOnEscape is false', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks body scroll when opened and restores it when closed', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
await wrapper.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus into the panel when opened', async () => {
|
||||||
|
const wrapper = mount(ModalForTest, {
|
||||||
|
props: { modelValue: false, showClose: false },
|
||||||
|
slots: { default: '<button data-test="first">OK</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const first = wrapper.find('[data-test="first"]').element
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores focus to the trigger when closed', async () => {
|
||||||
|
const trigger = document.createElement('button')
|
||||||
|
document.body.appendChild(trigger)
|
||||||
|
trigger.focus()
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
|
||||||
|
const wrapper = mount(ModalForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
slots: { default: '<button>OK</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ modelValue: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
await wrapper.setProps({ modelValue: false })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
trigger.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
|
||||||
|
const wrapper = mount(ModalForTest, {
|
||||||
|
props: { modelValue: true, showClose: false },
|
||||||
|
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
|
||||||
|
last.focus()
|
||||||
|
expect(document.activeElement).toBe(last)
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
|
||||||
|
const wrapper = mount(ModalForTest, {
|
||||||
|
props: { modelValue: true, showClose: false },
|
||||||
|
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
|
||||||
|
first.focus()
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
|
||||||
|
const wrapperA = mount(ModalForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
const wrapperB = mount(ModalForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapperA.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapperB.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapperB.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapperA.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
name="modal"
|
||||||
|
appear
|
||||||
|
@after-leave="isRendered = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isRendered && isOpen"
|
||||||
|
:id="componentId"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
v-bind="attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
||||||
|
data-test="backdrop"
|
||||||
|
@click="onBackdropClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="panelRef"
|
||||||
|
:class="twMerge(
|
||||||
|
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]',
|
||||||
|
modalClass,
|
||||||
|
)"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-labelledby="hasHeader ? headerId : undefined"
|
||||||
|
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
||||||
|
tabindex="-1"
|
||||||
|
data-test="panel"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasHeader || showClose"
|
||||||
|
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
||||||
|
data-test="header"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:id="headerId"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
data-test="header-content"
|
||||||
|
>
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="showClose"
|
||||||
|
type="button"
|
||||||
|
aria-label="Fermer"
|
||||||
|
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
||||||
|
data-test="close-button"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="mdi:cancel-bold"
|
||||||
|
:width="16"
|
||||||
|
:height="16"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
||||||
|
data-test="body"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
|
||||||
|
data-test="footer"
|
||||||
|
>
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
useId,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MalioModal', inheritAttrs: false })
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
modelValue?: boolean
|
||||||
|
showClose?: boolean
|
||||||
|
dismissable?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
|
modalClass?: string
|
||||||
|
overlayClass?: string
|
||||||
|
headerClass?: string
|
||||||
|
bodyClass?: string
|
||||||
|
footerClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
showClose: true,
|
||||||
|
dismissable: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
ariaLabel: '',
|
||||||
|
modalClass: '',
|
||||||
|
overlayClass: '',
|
||||||
|
headerClass: '',
|
||||||
|
bodyClass: '',
|
||||||
|
footerClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
|
||||||
|
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
const headerId = computed(() => `${componentId.value}-header`)
|
||||||
|
const hasHeader = computed(() => !!slots.header)
|
||||||
|
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const localValue = ref(false)
|
||||||
|
const isOpen = computed(() =>
|
||||||
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
|
)
|
||||||
|
const isRendered = ref(isOpen.value)
|
||||||
|
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let previouslyFocused: HTMLElement | null = null
|
||||||
|
// Per-instance flag: true while this modal holds a scroll-lock count slot.
|
||||||
|
let lockedByThisInstance = false
|
||||||
|
|
||||||
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
|
||||||
|
),
|
||||||
|
).filter((el) => el.tabIndex !== -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
||||||
|
if (!lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = true
|
||||||
|
openModalCount++
|
||||||
|
if (openModalCount === 1) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
const panel = panelRef.value
|
||||||
|
if (!panel) return
|
||||||
|
const focusable = getFocusable(panel)
|
||||||
|
;(focusable[0] ?? panel).focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = false
|
||||||
|
openModalCount = Math.max(0, openModalCount - 1)
|
||||||
|
if (openModalCount === 0) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previouslyFocused?.focus?.()
|
||||||
|
previouslyFocused = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (val) => {
|
||||||
|
if (val) {
|
||||||
|
isRendered.value = true
|
||||||
|
onOpen()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isOpen.value) onOpen()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// If this instance is still holding a scroll-lock slot, release it.
|
||||||
|
if (lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = false
|
||||||
|
openModalCount = Math.max(0, openModalCount - 1)
|
||||||
|
if (openModalCount === 0) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onBackdropClick() {
|
||||||
|
if (props.dismissable) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && props.closeOnEscape) {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
const panel = panelRef.value
|
||||||
|
if (!panel) return
|
||||||
|
const focusable = getFocusable(panel)
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
panel.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const first = focusable[0]!
|
||||||
|
const last = focusable[focusable.length - 1]!
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isControlled.value) localValue.value = false
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
|
||||||
|
let openModalCount = 0
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active > div:last-child,
|
||||||
|
.modal-leave-active > div:last-child {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from > div:last-child,
|
||||||
|
.modal-leave-to > div:last-child {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -173,6 +173,16 @@ describe('MalioRadioButton', () => {
|
|||||||
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountRadioButton({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountRadioButton({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,6 +46,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'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -178,6 +179,11 @@ const onChange = (event: Event) => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radio-control input[type='radio']:focus-visible {
|
||||||
|
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.radio-control.is-error input[type='radio'] {
|
.radio-control.is-error input[type='radio'] {
|
||||||
border-color: rgb(var(--m-danger) / 1);
|
border-color: rgb(var(--m-danger) / 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type SelectProps = {
|
|||||||
textLabel?: string
|
textLabel?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectForTest = Select as DefineComponent<SelectProps>
|
const SelectForTest = Select as DefineComponent<SelectProps>
|
||||||
@@ -207,4 +210,173 @@ describe('MalioSelect', () => {
|
|||||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
|
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows muted chevron color when empty and closed', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows primary chevron color when open', async () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black chevron color when an option is selected and closed', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: 'fr', options},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows muted chevron color when disabled', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: 'fr', options, disabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows danger chevron color on error even when open', async () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options, error: 'Selection error'},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success chevron color on success', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options, success: 'OK'},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, label: 'Champ', required: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, label: 'Champ'},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expose aria-required quand required est vrai', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options, required: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'expose pas aria-required par défaut', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, options},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
const buttonClasses = wrapper.get('button').classes()
|
||||||
|
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
|
||||||
|
// !border-b-transparent keeps the 1px allocation but hides the line
|
||||||
|
expect(buttonClasses).not.toContain('!border-b-0')
|
||||||
|
expect(buttonClasses).toContain('!border-b-transparent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
const trigger = wrapper.get('button')
|
||||||
|
expect(trigger.classes()).toContain('border-black')
|
||||||
|
expect(trigger.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(trigger.classes()).not.toContain('grow-height')
|
||||||
|
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
expect(label.classes()).not.toContain('text-m-primary')
|
||||||
|
expect(label.classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly sélectionné : label noir + chevron noir', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {label: 'Champ', readonly: true, modelValue: 'a', options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly empêche l’ouverture du dropdown', async () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {
|
||||||
|
props: {modelValue: null, label: 'Champ', readonly: true, options},
|
||||||
|
})
|
||||||
|
const trigger = wrapper.get('button')
|
||||||
|
expect(trigger.attributes('aria-readonly')).toBe('true')
|
||||||
|
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disabled + readonly : pas d’aria-readonly (disabled prime)', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
|
||||||
|
const trigger = wrapper.get('button')
|
||||||
|
expect(trigger.attributes('aria-readonly')).toBeUndefined()
|
||||||
|
expect(trigger.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options}})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false}})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,38 +8,53 @@
|
|||||||
:id="buttonId"
|
:id="buttonId"
|
||||||
ref="buttonRef"
|
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-m-primary"
|
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
|
||||||
:class="[
|
:class="[
|
||||||
|
isReadonly ? '' : 'grow-height',
|
||||||
|
isReadonly ? '' : 'focus-visible:border-m-primary',
|
||||||
hasError
|
hasError
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||||
: 'border-m-danger'
|
: 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-success !border-t-0'
|
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||||
: 'border-m-success'
|
: 'border-m-success'
|
||||||
: isOpen
|
: isReadonly
|
||||||
? openDirection === 'down'
|
? 'border-black'
|
||||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
: isOpen
|
||||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
? openDirection === 'down'
|
||||||
: isOptionSelected
|
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||||
? 'border-black'
|
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||||
: 'border-m-muted',
|
: isOptionSelected
|
||||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
? 'border-black'
|
||||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
: 'border-m-muted',
|
||||||
|
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||||
|
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||||
rounded,
|
rounded,
|
||||||
textField,
|
textField,
|
||||||
|
keyboardFocused
|
||||||
|
? (isOpen
|
||||||
|
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||||
|
: 'm-focus-ring-kbd')
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
:aria-expanded="isOpen"
|
:aria-expanded="isOpen"
|
||||||
:aria-controls="listboxId"
|
:aria-controls="listboxId"
|
||||||
|
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||||
:aria-invalid="hasError"
|
:aria-invalid="hasError"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
|
:aria-required="required || undefined"
|
||||||
|
:aria-readonly="isReadonly || undefined"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@focus="onKbdFocus"
|
||||||
|
@blur="onKbdBlur"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
@@ -50,16 +65,20 @@
|
|||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: isOpen
|
: isReadonly
|
||||||
? 'text-m-primary'
|
? isOptionSelected
|
||||||
: isOptionSelected
|
|
||||||
? 'text-black'
|
? 'text-black'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted'
|
||||||
|
: isOpen
|
||||||
|
? 'text-m-primary'
|
||||||
|
: isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted',
|
||||||
textLabel,
|
textLabel,
|
||||||
]"
|
]"
|
||||||
:style="labelTransformStyle"
|
:style="labelTransformStyle"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -73,13 +92,24 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
|
data-test="chevron"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-current'
|
: disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly
|
||||||
|
? isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
|
: isOpen
|
||||||
|
? 'text-m-primary'
|
||||||
|
: isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
@@ -113,7 +143,10 @@
|
|||||||
? 'border-m-danger'
|
? 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success'
|
? 'border-m-success'
|
||||||
: 'border-m-primary'
|
: 'border-m-primary',
|
||||||
|
keyboardFocused
|
||||||
|
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
@@ -145,7 +178,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${buttonId}-describedby`"
|
:id="`${buttonId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -154,6 +187,7 @@
|
|||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -162,12 +196,16 @@
|
|||||||
</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, 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'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number | null
|
value: string | number | null
|
||||||
@@ -183,10 +221,14 @@ const props = withDefaults(defineProps<{
|
|||||||
textField?: string
|
textField?: string
|
||||||
textValue?: string
|
textValue?: string
|
||||||
textLabel?: string
|
textLabel?: string
|
||||||
|
fieldClass?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
noOptionsText?: string
|
noOptionsText?: string
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
@@ -197,10 +239,14 @@ const props = withDefaults(defineProps<{
|
|||||||
textField: 'text-lg',
|
textField: 'text-lg',
|
||||||
textValue: 'text-lg',
|
textValue: 'text-lg',
|
||||||
textLabel: 'text-sm',
|
textLabel: 'text-sm',
|
||||||
|
fieldClass: '',
|
||||||
rounded: 'rounded-md',
|
rounded: 'rounded-md',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
noOptionsText: 'Aucune option disponible',
|
noOptionsText: 'Aucune option disponible',
|
||||||
|
required: false,
|
||||||
|
reserveMessageSpace: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -228,8 +274,9 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|||||||
const isOptionSelected = computed(() =>
|
const isOptionSelected = computed(() =>
|
||||||
props.options.some(o => o.value === props.modelValue)
|
props.options.some(o => o.value === props.modelValue)
|
||||||
)
|
)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
const shouldFloatLabel = computed(() =>
|
const shouldFloatLabel = computed(() =>
|
||||||
isOpen.value || isOptionSelected.value
|
isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value)
|
||||||
)
|
)
|
||||||
const selectedLabel = computed(() =>
|
const selectedLabel = computed(() =>
|
||||||
props.options.find(o => o.value === props.modelValue)?.label ?? ''
|
props.options.find(o => o.value === props.modelValue)?.label ?? ''
|
||||||
@@ -257,6 +304,7 @@ function updateOpenDirection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
updateOpenDirection()
|
updateOpenDirection()
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
|
|
||||||
@@ -300,7 +348,7 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
if (props.disabled) return
|
if (props.disabled || props.readonly) return
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
close()
|
close()
|
||||||
return
|
return
|
||||||
@@ -311,7 +359,68 @@ 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()
|
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
|
||||||
|
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
|
||||||
|
// les options ; au clavier le focus n'a jamais quitté le bouton.
|
||||||
|
buttonRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde l'option active visible quand on navigue au clavier
|
||||||
|
watch(activeIndex, async (index) => {
|
||||||
|
if (index < 0 || !isOpen.value) return
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||||
|
})
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
|
||||||
|
// Tab : laisse le focus partir mais ferme la liste
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
if (isOpen.value) close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste fermée : ouverture au clavier
|
||||||
|
if (!isOpen.value) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste ouverte
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
const opt = normalizedOptions.value[activeIndex.value]
|
||||||
|
if (opt) select(opt.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Home') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'End') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = normalizedOptions.value.length - 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
@@ -330,12 +439,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grow-height {
|
.grow-height {
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
|
||||||
|
|
||||||
.grow-height:focus {
|
|
||||||
padding-top: 0.625rem;
|
|
||||||
padding-bottom: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, expect, it} from 'vitest'
|
import {describe, expect, it} from 'vitest'
|
||||||
import {mount} from '@vue/test-utils'
|
import {mount, renderToString} from '@vue/test-utils'
|
||||||
import type {DefineComponent} from 'vue'
|
import type {DefineComponent} from 'vue'
|
||||||
import SelectCheckbox from './SelectCheckbox.vue'
|
import SelectCheckbox from './SelectCheckbox.vue'
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ type Option = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SelectCheckboxProps = {
|
type SelectCheckboxProps = {
|
||||||
modelValue: Array<string | number>
|
modelValue?: Array<string | number>
|
||||||
options?: Option[]
|
options?: Option[]
|
||||||
emptyOptionLabel?: string
|
emptyOptionLabel?: string
|
||||||
label?: string
|
label?: string
|
||||||
@@ -24,7 +24,10 @@ type SelectCheckboxProps = {
|
|||||||
displaySelectAll?: boolean
|
displaySelectAll?: boolean
|
||||||
selectAllLabel?: string
|
selectAllLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
||||||
@@ -36,6 +39,18 @@ const options: Option[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
describe('MalioSelectCheckbox', () => {
|
describe('MalioSelectCheckbox', () => {
|
||||||
|
it('rend sans planter quand modelValue n’est pas fourni (non contrôlé)', () => {
|
||||||
|
expect(() =>
|
||||||
|
mount(SelectCheckboxForTest, {props: {label: 'Catégories', options}}),
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rend en SSR sans planter quand modelValue est absent (cause du crash playground)', async () => {
|
||||||
|
await expect(
|
||||||
|
renderToString(SelectCheckboxForTest, {props: {label: 'Catégories', readonly: true, options}}),
|
||||||
|
).resolves.toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders checkbox inputs for options', async () => {
|
it('renders checkbox inputs for options', async () => {
|
||||||
const wrapper = mount(SelectCheckboxForTest, {
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
props: {modelValue: [], options},
|
props: {modelValue: [], options},
|
||||||
@@ -53,8 +68,9 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
const checkboxInputs = wrapper.findAll('input[type="checkbox"]')
|
// Le toggle se fait au clic sur la ligne d'option (la checkbox est en pointer-events-none).
|
||||||
await checkboxInputs[1].setValue(true)
|
const optionRows = wrapper.findAll('li[role="option"]')
|
||||||
|
await optionRows[1].trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
|
||||||
})
|
})
|
||||||
@@ -134,8 +150,9 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
// La ligne « tout sélectionner » est la première option de la liste.
|
||||||
await checkboxes[0].setValue(true)
|
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
|
||||||
|
await selectAllRow.trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
|
||||||
})
|
})
|
||||||
@@ -147,8 +164,9 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
|
|
||||||
await wrapper.get('button').trigger('click')
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
// La ligne « tout sélectionner » est la première option de la liste.
|
||||||
await checkboxes[0].setValue(false)
|
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
|
||||||
|
await selectAllRow.trigger('click')
|
||||||
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
|
||||||
})
|
})
|
||||||
@@ -182,4 +200,173 @@ describe('MalioSelectCheckbox', () => {
|
|||||||
const root = wrapper.find('button').element.parentElement
|
const root = wrapper.find('button').element.parentElement
|
||||||
expect(root?.className).toContain('mt-4')
|
expect(root?.className).toContain('mt-4')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows muted chevron color when nothing is selected and closed', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows primary chevron color when open', async () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows black chevron color when options are selected and closed', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: ['fr'], options},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows muted chevron color when disabled', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: ['fr'], options, disabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows danger chevron color on error even when open', async () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options, error: 'Selection error'},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success chevron color on success', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options, success: 'OK'},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], label: 'Champ', required: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], label: 'Champ'},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expose aria-required quand required est vrai', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options, required: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'expose pas aria-required par défaut', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {modelValue: [], options},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
const buttonClasses = wrapper.get('button').classes()
|
||||||
|
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
|
||||||
|
// !border-b-transparent keeps the 1px allocation but hides the line
|
||||||
|
expect(buttonClasses).not.toContain('!border-b-0')
|
||||||
|
expect(buttonClasses).toContain('!border-b-transparent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
const trigger = wrapper.get('button')
|
||||||
|
expect(trigger.classes()).toContain('border-black')
|
||||||
|
expect(trigger.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(trigger.classes()).not.toContain('grow-height')
|
||||||
|
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label gris, pas de bleu', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
expect(label.classes()).not.toContain('text-m-primary')
|
||||||
|
expect(label.classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly sélectionné : label noir + chevron noir', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {label: 'Champ', readonly: true, modelValue: ['a'], options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly empêche l’ouverture du dropdown', async () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||||
|
})
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {
|
||||||
|
props: {label: 'Champ', readonly: true, modelValue: [], options},
|
||||||
|
})
|
||||||
|
const trigger = wrapper.get('button')
|
||||||
|
expect(trigger.attributes('aria-readonly')).toBe('true')
|
||||||
|
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disabled + readonly : pas d’aria-readonly (disabled prime)', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {props: {modelValue: [], label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
|
||||||
|
const trigger = wrapper.get('button')
|
||||||
|
expect(trigger.attributes('aria-readonly')).toBeUndefined()
|
||||||
|
expect(trigger.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options}})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false}})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,38 +8,53 @@
|
|||||||
:id="buttonId"
|
:id="buttonId"
|
||||||
ref="buttonRef"
|
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-m-primary"
|
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
|
||||||
:class="[
|
:class="[
|
||||||
|
isReadonly ? '' : 'grow-height',
|
||||||
|
isReadonly ? '' : 'focus-visible:border-m-primary',
|
||||||
hasError
|
hasError
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||||
: 'border-m-danger'
|
: 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? isOpen
|
? isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-success !border-t-0'
|
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||||
: 'border-m-success'
|
: 'border-m-success'
|
||||||
: isOpen
|
: isReadonly
|
||||||
? openDirection === 'down'
|
? 'border-black'
|
||||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
: isOpen
|
||||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
? openDirection === 'down'
|
||||||
: isOptionSelected
|
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||||
? 'border-black'
|
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||||
: 'border-m-muted',
|
: isOptionSelected
|
||||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
? 'border-black'
|
||||||
|
: 'border-m-muted',
|
||||||
|
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||||
rounded,
|
rounded,
|
||||||
textField,
|
textField,
|
||||||
|
keyboardFocused
|
||||||
|
? (isOpen
|
||||||
|
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||||
|
: 'm-focus-ring-kbd')
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
:aria-expanded="isOpen"
|
:aria-expanded="isOpen"
|
||||||
:aria-controls="listboxId"
|
:aria-controls="listboxId"
|
||||||
|
:aria-activedescendant="!isOpen ? undefined : (activeIndex === -1 ? selectAllId : (activeIndex >= 0 ? optionId(activeIndex) : undefined))"
|
||||||
:aria-invalid="hasError"
|
:aria-invalid="hasError"
|
||||||
:aria-describedby="describedBy"
|
:aria-describedby="describedBy"
|
||||||
|
:aria-required="required || undefined"
|
||||||
|
:aria-readonly="isReadonly || undefined"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@focus="onKbdFocus"
|
||||||
|
@blur="onKbdBlur"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
@@ -50,16 +65,20 @@
|
|||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: isOpen
|
: isReadonly
|
||||||
? 'text-m-primary'
|
? isOptionSelected
|
||||||
: isOptionSelected
|
|
||||||
? 'text-black'
|
? 'text-black'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted'
|
||||||
|
: isOpen
|
||||||
|
? 'text-m-primary'
|
||||||
|
: isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted',
|
||||||
textLabel,
|
textLabel,
|
||||||
]"
|
]"
|
||||||
:style="labelTransformStyle"
|
:style="labelTransformStyle"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -101,13 +120,24 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
|
data-test="chevron"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-current'
|
: disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly
|
||||||
|
? isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
|
: isOpen
|
||||||
|
? 'text-m-primary'
|
||||||
|
: isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
@@ -141,7 +171,10 @@
|
|||||||
? 'border-m-danger'
|
? 'border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success'
|
? 'border-m-success'
|
||||||
: 'border-m-primary'
|
: 'border-m-primary',
|
||||||
|
keyboardFocused
|
||||||
|
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
@@ -153,17 +186,23 @@
|
|||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="displaySelectAll && normalizedOptions.length > 0"
|
v-if="displaySelectAll && normalizedOptions.length > 0"
|
||||||
class="border-b border-m-muted/30 px-3 py-2"
|
:id="selectAllId"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="allSelected"
|
||||||
|
class="cursor-pointer border-b border-m-muted/30 px-3 py-2"
|
||||||
|
:class="[activeIndex === -1 ? 'bg-m-muted/10' : '']"
|
||||||
|
@mouseenter="activeIndex = -1"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
|
@click="toggleAll"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="allSelected"
|
:model-value="allSelected"
|
||||||
:label="selectAllLabel"
|
:label="selectAllLabel"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
group-class="!mt-0"
|
group-class="!mt-0 pointer-events-none"
|
||||||
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
label-class="option-checkbox w-full font-semibold"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@update:model-value="toggleAll"
|
:reserve-message-space="false"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
@@ -172,7 +211,7 @@
|
|||||||
:key="String(opt.value)"
|
:key="String(opt.value)"
|
||||||
role="option"
|
role="option"
|
||||||
:aria-selected="isChecked(opt.value)"
|
:aria-selected="isChecked(opt.value)"
|
||||||
class="px-3 py-2"
|
class="cursor-pointer px-3 py-2"
|
||||||
:class="[
|
:class="[
|
||||||
index === activeIndex ? 'bg-m-muted/10' : '',
|
index === activeIndex ? 'bg-m-muted/10' : '',
|
||||||
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
||||||
@@ -180,21 +219,22 @@
|
|||||||
]"
|
]"
|
||||||
@mouseenter="activeIndex = index"
|
@mouseenter="activeIndex = index"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
|
@click="toggleOption(opt.value)"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="isChecked(opt.value)"
|
:model-value="isChecked(opt.value)"
|
||||||
:label="opt.label || '\u00A0'"
|
:label="opt.label || '\u00A0'"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
group-class="!mt-0"
|
group-class="!mt-0 pointer-events-none"
|
||||||
label-class="option-checkbox w-full cursor-pointer"
|
label-class="option-checkbox w-full"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@update:model-value="toggleOption(opt.value)"
|
:reserve-message-space="false"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${buttonId}-describedby`"
|
:id="`${buttonId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -203,6 +243,7 @@
|
|||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -211,19 +252,23 @@
|
|||||||
</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, 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'
|
||||||
import Checkbox from '../checkbox/Checkbox.vue'
|
import Checkbox from '../checkbox/Checkbox.vue'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||||
|
|
||||||
|
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number
|
value: string | number
|
||||||
}
|
}
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: Array<string | number>
|
modelValue?: Array<string | number>
|
||||||
options?: Option[]
|
options?: Option[]
|
||||||
emptyOptionLabel?: string
|
emptyOptionLabel?: string
|
||||||
label?: string
|
label?: string
|
||||||
@@ -238,9 +283,13 @@ const props = withDefaults(defineProps<{
|
|||||||
displaySelectAll?: boolean
|
displaySelectAll?: boolean
|
||||||
selectAllLabel?: string
|
selectAllLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
noOptionsText?: string
|
noOptionsText?: string
|
||||||
|
required?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
|
modelValue: () => [],
|
||||||
options: () => [],
|
options: () => [],
|
||||||
emptyOptionLabel: '',
|
emptyOptionLabel: '',
|
||||||
label: '',
|
label: '',
|
||||||
@@ -255,8 +304,11 @@ const props = withDefaults(defineProps<{
|
|||||||
displaySelectAll: false,
|
displaySelectAll: false,
|
||||||
selectAllLabel: 'Tout sélectionner',
|
selectAllLabel: 'Tout sélectionner',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
noOptionsText: 'Aucune option disponible',
|
noOptionsText: 'Aucune option disponible',
|
||||||
|
required: false,
|
||||||
|
reserveMessageSpace: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -270,6 +322,9 @@ const openDirection = ref<'down' | 'up'>('down')
|
|||||||
const uid = useId()
|
const uid = useId()
|
||||||
const buttonId = `custom-select-btn-${uid}`
|
const buttonId = `custom-select-btn-${uid}`
|
||||||
const listboxId = `custom-select-listbox-${uid}`
|
const listboxId = `custom-select-listbox-${uid}`
|
||||||
|
const selectAllId = `custom-select-all-${uid}`
|
||||||
|
// Index actif le plus bas : -1 cible la ligne « tout sélectionner » quand elle est affichée
|
||||||
|
const lowestIndex = computed(() => (props.displaySelectAll && normalizedOptions.value.length > 0 ? -1 : 0))
|
||||||
const listRef = ref<HTMLElement | null>(null)
|
const 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)
|
||||||
@@ -281,6 +336,7 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|||||||
const isOptionSelected = computed(() =>
|
const isOptionSelected = computed(() =>
|
||||||
props.modelValue.length > 0
|
props.modelValue.length > 0
|
||||||
)
|
)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
const selectedOptions = computed(() =>
|
const selectedOptions = computed(() =>
|
||||||
normalizedOptions.value.filter(option => props.modelValue.includes(option.value)),
|
normalizedOptions.value.filter(option => props.modelValue.includes(option.value)),
|
||||||
)
|
)
|
||||||
@@ -288,7 +344,7 @@ const displayTags = computed(() =>
|
|||||||
props.displayTag && selectedOptions.value.length > 0,
|
props.displayTag && selectedOptions.value.length > 0,
|
||||||
)
|
)
|
||||||
const shouldFloatLabel = computed(() =>
|
const shouldFloatLabel = computed(() =>
|
||||||
isOpen.value || displayTags.value
|
isReadonly.value ? isOptionSelected.value : (isOpen.value || displayTags.value)
|
||||||
)
|
)
|
||||||
const selectionSummary = computed(() =>
|
const selectionSummary = computed(() =>
|
||||||
`${props.modelValue.length}/${normalizedOptions.value.length}`
|
`${props.modelValue.length}/${normalizedOptions.value.length}`
|
||||||
@@ -320,6 +376,7 @@ function updateOpenDirection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
updateOpenDirection()
|
updateOpenDirection()
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
|
|
||||||
@@ -363,7 +420,7 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
if (props.disabled) return
|
if (props.disabled || props.readonly) return
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
close()
|
close()
|
||||||
return
|
return
|
||||||
@@ -393,6 +450,70 @@ function toggleAll() {
|
|||||||
nextTick(() => buttonRef.value?.focus())
|
nextTick(() => buttonRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Garde l'option active visible quand on navigue au clavier
|
||||||
|
watch(activeIndex, async (index) => {
|
||||||
|
if (!isOpen.value) return
|
||||||
|
await nextTick()
|
||||||
|
const id = index === -1 ? selectAllId : (index >= 0 ? optionId(index) : null)
|
||||||
|
if (id) document.getElementById(id)?.scrollIntoView({block: 'nearest'})
|
||||||
|
})
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
|
||||||
|
// Tab : laisse le focus partir mais ferme la liste
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
if (isOpen.value) close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste fermée : ouverture au clavier
|
||||||
|
if (!isOpen.value) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste ouverte (multi-select : Entrée/Espace togglent et la liste reste ouverte)
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
// -1 = ligne « tout sélectionner »
|
||||||
|
if (activeIndex.value === -1) {
|
||||||
|
toggleAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const opt = normalizedOptions.value[activeIndex.value]
|
||||||
|
if (opt) toggleOption(opt.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = Math.max(activeIndex.value - 1, lowestIndex.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Home') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = lowestIndex.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'End') {
|
||||||
|
e.preventDefault()
|
||||||
|
activeIndex.value = normalizedOptions.value.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
if (!root.value) return
|
if (!root.value) return
|
||||||
if (!root.value.contains(e.target as Node)) close()
|
if (!root.value.contains(e.target as Node)) close()
|
||||||
@@ -409,12 +530,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grow-height {
|
.grow-height {
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
|
||||||
|
|
||||||
.grow-height:focus {
|
|
||||||
padding-top: 0.625rem;
|
|
||||||
padding-bottom: 0.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import RequiredMark from './RequiredMark.vue'
|
||||||
|
|
||||||
|
describe('MalioRequiredMark', () => {
|
||||||
|
it('rend un astérisque', () => {
|
||||||
|
const wrapper = mount(RequiredMark)
|
||||||
|
expect(wrapper.text()).toBe('*')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('est masqué pour les technologies d\'assistance', () => {
|
||||||
|
const wrapper = mount(RequiredMark)
|
||||||
|
expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('utilise le token de couleur danger', () => {
|
||||||
|
const wrapper = mount(RequiredMark)
|
||||||
|
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rend l\'astérisque à 16px', () => {
|
||||||
|
const wrapper = mount(RequiredMark)
|
||||||
|
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-[16px]')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
data-test="required-mark"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="ml-0.5 select-none text-[16px] leading-none text-m-danger"
|
||||||
|
>*</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détection de la modalité de focus (clavier vs souris/tactile).
|
||||||
|
*
|
||||||
|
* Sur les champs texte, `:focus-visible` natif se déclenche AUSSI au clic souris
|
||||||
|
* (le navigateur suppose qu'on va taper). Pour n'afficher l'anneau de focus qu'à
|
||||||
|
* la navigation clavier (Tab), on suit la dernière interaction au niveau document
|
||||||
|
* et on n'arme l'anneau que si le focus a été précédé d'un évènement clavier.
|
||||||
|
*
|
||||||
|
* Le visuel « champ actif » existant (grossissement, label flottant, bordure bleue)
|
||||||
|
* reste piloté par `:focus` et n'est pas affecté : ce composable ne gère QUE l'anneau.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let hadKeyboardEvent = false
|
||||||
|
let listenersAttached = false
|
||||||
|
|
||||||
|
function ensureGlobalListeners() {
|
||||||
|
if (listenersAttached || typeof document === 'undefined') return
|
||||||
|
listenersAttached = true
|
||||||
|
|
||||||
|
// capture=true pour observer l'évènement avant qu'il n'atteigne sa cible
|
||||||
|
document.addEventListener('keydown', () => {
|
||||||
|
hadKeyboardEvent = true
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
const markPointer = () => {
|
||||||
|
hadKeyboardEvent = false
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', markPointer, true)
|
||||||
|
document.addEventListener('pointerdown', markPointer, true)
|
||||||
|
document.addEventListener('touchstart', markPointer, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKbdFocusRing() {
|
||||||
|
ensureGlobalListeners()
|
||||||
|
|
||||||
|
const keyboardFocused = ref(false)
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
keyboardFocused.value = hadKeyboardEvent
|
||||||
|
}
|
||||||
|
const onBlur = () => {
|
||||||
|
keyboardFocused.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {keyboardFocused, onFocus, onBlur}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ type TabListProps = {
|
|||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
maxVisibleTabs?: number
|
||||||
|
maxWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabListForTest = TabList as DefineComponent<TabListProps>
|
const TabListForTest = TabList as DefineComponent<TabListProps>
|
||||||
@@ -185,3 +187,154 @@ describe('MalioTabList', () => {
|
|||||||
expect(buttons[1].attributes('aria-selected')).toBe('false')
|
expect(buttons[1].attributes('aria-selected')).toBe('false')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('MalioTabList — fenêtrage maxVisibleTabs', () => {
|
||||||
|
const sevenTabs: Tab[] = [
|
||||||
|
{key: 't1', label: 'Tab 1'},
|
||||||
|
{key: 't2', label: 'Tab 2'},
|
||||||
|
{key: 't3', label: 'Tab 3'},
|
||||||
|
{key: 't4', label: 'Tab 4'},
|
||||||
|
{key: 't5', label: 'Tab 5'},
|
||||||
|
{key: 't6', label: 'Tab 6'},
|
||||||
|
{key: 't7', label: 'Tab 7'},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('applies the default maxWidth (1100px) on the tabs container when windowed', () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a custom maxWidth on the tabs container', () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200})
|
||||||
|
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders only maxVisibleTabs buttons and disables prev at start', () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons).toHaveLength(5)
|
||||||
|
expect(buttons[0].text()).toContain('Tab 1')
|
||||||
|
expect(buttons[4].text()).toContain('Tab 5')
|
||||||
|
|
||||||
|
const prev = wrapper.find('[data-test="tab-prev"]')
|
||||||
|
const next = wrapper.find('[data-test="tab-next"]')
|
||||||
|
expect(prev.exists()).toBe(true)
|
||||||
|
expect(next.exists()).toBe(true)
|
||||||
|
expect(prev.attributes('disabled')).toBeDefined()
|
||||||
|
expect(next.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shifts the window by 1 on next click', async () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
|
||||||
|
const labels = wrapper.findAll('[role="tab"]').map(b => b.text())
|
||||||
|
expect(labels.some(l => l.includes('Tab 1'))).toBe(false)
|
||||||
|
expect(labels.some(l => l.includes('Tab 6'))).toBe(true)
|
||||||
|
expect(labels).toHaveLength(5)
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="tab-prev"]').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables next at the end and shows the last window', async () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
|
||||||
|
// 7 - 5 = 2 clicks to reach the end
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
|
||||||
|
const next = wrapper.find('[data-test="tab-next"]')
|
||||||
|
expect(next.attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons).toHaveLength(5)
|
||||||
|
// last window starts at tabs[length-5] = tabs[2] = Tab 3
|
||||||
|
expect(buttons[0].text()).toContain('Tab 3')
|
||||||
|
expect(buttons[4].text()).toContain('Tab 7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking next past the end does not overshoot', async () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
const next = wrapper.find('[data-test="tab-next"]')
|
||||||
|
|
||||||
|
await next.trigger('click')
|
||||||
|
await next.trigger('click')
|
||||||
|
await next.trigger('click') // guarded, no-op
|
||||||
|
await next.trigger('click') // guarded, no-op
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(buttons).toHaveLength(5)
|
||||||
|
expect(buttons[0].text()).toContain('Tab 3')
|
||||||
|
expect(buttons[4].text()).toContain('Tab 7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no arrows and all tabs when maxVisibleTabs is undefined', () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs})
|
||||||
|
expect(wrapper.findAll('[role="tab"]')).toHaveLength(7)
|
||||||
|
expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no arrows when maxVisibleTabs >= tabs.length', () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 7})
|
||||||
|
expect(wrapper.findAll('[role="tab"]')).toHaveLength(7)
|
||||||
|
expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting a visible tab activates it without moving the window', async () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
const buttons = wrapper.findAll('[role="tab"]')
|
||||||
|
|
||||||
|
await buttons[2].trigger('click')
|
||||||
|
|
||||||
|
const after = wrapper.findAll('[role="tab"]')
|
||||||
|
expect(after[2].attributes('aria-selected')).toBe('true')
|
||||||
|
// window unchanged
|
||||||
|
expect(after[0].text()).toContain('Tab 1')
|
||||||
|
expect(after).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the active panel rendered even when its tab is outside the window', async () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{tabs: sevenTabs, maxVisibleTabs: 5, modelValue: 't1'},
|
||||||
|
{t1: '<p>Panel 1</p>'},
|
||||||
|
)
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
|
||||||
|
// Tab 1 is no longer in the window
|
||||||
|
const labels = wrapper.findAll('[role="tab"]').map(b => b.text())
|
||||||
|
expect(labels.some(l => l.includes('Tab 1'))).toBe(false)
|
||||||
|
|
||||||
|
// but its panel is still rendered and visible
|
||||||
|
const panels = wrapper.findAll('[role="tabpanel"]')
|
||||||
|
expect(panels).toHaveLength(7)
|
||||||
|
expect(wrapper.text()).toContain('Panel 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps exactly one rendered tab with tabindex=0 when the active tab scrolls out of the window', async () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
|
||||||
|
// active tab is the first one (t1) by default; scroll it out of the window
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||||
|
|
||||||
|
// t1 is no longer rendered
|
||||||
|
const labels = wrapper.findAll('[role="tab"]').map(b => b.text())
|
||||||
|
expect(labels.some(l => l.includes('Tab 1'))).toBe(false)
|
||||||
|
|
||||||
|
const focusable = wrapper.findAll('[role="tab"]').filter(b => b.attributes('tabindex') === '0')
|
||||||
|
expect(focusable).toHaveLength(1)
|
||||||
|
// falls back to the first visible tab (Tab 3)
|
||||||
|
expect(focusable[0].text()).toContain('Tab 3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrows expose aria-labels', () => {
|
||||||
|
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||||
|
expect(wrapper.find('[data-test="tab-prev"]').attributes('aria-label')).toBe('Onglets précédents')
|
||||||
|
expect(wrapper.find('[data-test="tab-next"]').attributes('aria-label')).toBe('Onglets suivants')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,11 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-bind="$attrs">
|
<div v-bind="$attrs">
|
||||||
|
<div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Onglets précédents"
|
||||||
|
data-test="tab-prev"
|
||||||
|
:disabled="!canPrev"
|
||||||
|
:class="[
|
||||||
|
'transition-colors',
|
||||||
|
canPrev
|
||||||
|
? 'cursor-pointer text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active'
|
||||||
|
: 'cursor-not-allowed text-m-disabled',
|
||||||
|
]"
|
||||||
|
@click="prev"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:chevron-left" :width="28" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
class="flex flex-1 justify-center gap-[60px]"
|
||||||
|
:style="{ maxWidth: `${maxWidth}px` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="tab in visibleTabs"
|
||||||
|
:id="`${componentId}-tab-${tab.key}`"
|
||||||
|
:key="tab.key"
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
:aria-selected="activeTab === tab.key"
|
||||||
|
:aria-controls="`${componentId}-panel-${tab.key}`"
|
||||||
|
:aria-disabled="!!tab.disabled"
|
||||||
|
:tabindex="focusedKey === tab.key ? 0 : -1"
|
||||||
|
:disabled="tab.disabled"
|
||||||
|
:class="[
|
||||||
|
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
|
||||||
|
: tab.disabled
|
||||||
|
? 'cursor-not-allowed text-m-primary/50'
|
||||||
|
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
|
||||||
|
]"
|
||||||
|
@click="selectTab(tab.key)"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="tab.icon"
|
||||||
|
:icon="tab.icon"
|
||||||
|
:width="tab.iconSize ?? 24"
|
||||||
|
/>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Onglets suivants"
|
||||||
|
data-test="tab-next"
|
||||||
|
:disabled="!canNext"
|
||||||
|
:class="[
|
||||||
|
'transition-colors',
|
||||||
|
canNext
|
||||||
|
? 'cursor-pointer text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active'
|
||||||
|
: 'cursor-not-allowed text-m-disabled',
|
||||||
|
]"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:chevron-right" :width="28" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-else
|
||||||
role="tablist"
|
role="tablist"
|
||||||
class="flex justify-center gap-[60px] border-b border-m-primary"
|
class="flex justify-center gap-[60px] border-b border-m-primary"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in visibleTabs"
|
||||||
:id="`${componentId}-tab-${tab.key}`"
|
:id="`${componentId}-tab-${tab.key}`"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -13,7 +83,7 @@
|
|||||||
: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"
|
:aria-disabled="!!tab.disabled"
|
||||||
:tabindex="activeTab === tab.key ? 0 : -1"
|
:tabindex="focusedKey === tab.key ? 0 : -1"
|
||||||
:disabled="tab.disabled"
|
:disabled="tab.disabled"
|
||||||
:class="[
|
:class="[
|
||||||
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||||
@@ -40,7 +110,8 @@
|
|||||||
:id="`${componentId}-panel-${tab.key}`"
|
:id="`${componentId}-panel-${tab.key}`"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
:aria-labelledby="`${componentId}-tab-${tab.key}`"
|
:aria-labelledby="isTabRendered(tab.key) ? `${componentId}-tab-${tab.key}` : undefined"
|
||||||
|
:aria-label="isTabRendered(tab.key) ? undefined : tab.label"
|
||||||
>
|
>
|
||||||
<slot :name="tab.key" />
|
<slot :name="tab.key" />
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +119,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, useId} from 'vue'
|
import {computed, ref, useId, watch} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioTabList', inheritAttrs: false})
|
defineOptions({name: 'MalioTabList', inheritAttrs: false})
|
||||||
@@ -65,9 +136,13 @@ const props = withDefaults(defineProps<{
|
|||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
maxVisibleTabs?: number
|
||||||
|
maxWidth?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
modelValue: undefined,
|
modelValue: undefined,
|
||||||
id: '',
|
id: '',
|
||||||
|
maxVisibleTabs: undefined,
|
||||||
|
maxWidth: 1100,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -84,6 +159,53 @@ const activeTab = computed(() =>
|
|||||||
isControlled.value ? props.modelValue! : localValue.value,
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isWindowed = computed(() =>
|
||||||
|
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs,
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxStartIndex = computed(() =>
|
||||||
|
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const startIndex = ref(0)
|
||||||
|
|
||||||
|
const visibleTabs = computed(() =>
|
||||||
|
isWindowed.value
|
||||||
|
? props.tabs.slice(startIndex.value, startIndex.value + props.maxVisibleTabs!)
|
||||||
|
: props.tabs,
|
||||||
|
)
|
||||||
|
|
||||||
|
const focusedKey = computed(() => {
|
||||||
|
if (!isWindowed.value) return activeTab.value
|
||||||
|
const inView = visibleTabs.value.some(t => t.key === activeTab.value)
|
||||||
|
return inView ? activeTab.value : (visibleTabs.value[0]?.key ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTabRendered = (key: string) => !isWindowed.value || visibleTabs.value.some(t => t.key === key)
|
||||||
|
|
||||||
|
const canPrev = computed(() => isWindowed.value && startIndex.value > 0)
|
||||||
|
const canNext = computed(() => isWindowed.value && startIndex.value < maxStartIndex.value)
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (!canPrev.value) return
|
||||||
|
startIndex.value -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (!canNext.value) return
|
||||||
|
startIndex.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp startIndex back in range if tabs or maxVisibleTabs change.
|
||||||
|
watch(maxStartIndex, (max) => {
|
||||||
|
if (startIndex.value > max) startIndex.value = max
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset the window to the start when the tab list is replaced.
|
||||||
|
watch(() => props.tabs, () => {
|
||||||
|
startIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
function selectTab(key: string) {
|
function selectTab(key: string) {
|
||||||
const tab = props.tabs.find(t => t.key === key)
|
const tab = props.tabs.find(t => t.key === key)
|
||||||
if (tab?.disabled) return
|
if (tab?.disabled) return
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type TimeProps = {
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimeForTest = Time as DefineComponent<TimeProps>
|
const TimeForTest = Time as DefineComponent<TimeProps>
|
||||||
@@ -76,4 +77,33 @@ describe('MalioTime', () => {
|
|||||||
expect(inputs[0].classes()).toContain('border-m-primary')
|
expect(inputs[0].classes()).toContain('border-m-primary')
|
||||||
expect(inputs[1].classes()).not.toContain('border-m-primary')
|
expect(inputs[1].classes()).not.toContain('border-m-primary')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountTime({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountTime({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountTime({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountTime({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountTime({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
:for="hoursInputId"
|
:for="hoursInputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
:class="[
|
:class="[
|
||||||
hasError
|
hasError
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: 'text-m-muted',
|
: 'text-m-muted',
|
||||||
'mt-1 ml-[2px] text-xs',
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue'
|
import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioTime', inheritAttrs: false})
|
defineOptions({name: 'MalioTime', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ const props = withDefaults(
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -110,6 +113,7 @@ const props = withDefaults(
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import TimePicker from './TimePicker.vue'
|
||||||
|
|
||||||
|
type TimePickerProps = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
clearable?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps>
|
||||||
|
const mountPicker = (props: TimePickerProps = {}) =>
|
||||||
|
mount(TimePickerForTest, {props, attachTo: document.body})
|
||||||
|
|
||||||
|
describe('MalioTimePicker', () => {
|
||||||
|
it('affiche le label et l\'icône horloge', () => {
|
||||||
|
const wrapper = mountPicker({label: 'Heure'})
|
||||||
|
expect(wrapper.get('label').text()).toBe('Heure')
|
||||||
|
expect(wrapper.find('[data-test="clock-icon"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche la valeur HH:MM dans le champ', () => {
|
||||||
|
const wrapper = mountPicker({modelValue: '14:30'})
|
||||||
|
const input = wrapper.get('[data-test="time-field"]').element as HTMLInputElement
|
||||||
|
expect(input.value).toBe('14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ouvre le popover à molettes au clic', async () => {
|
||||||
|
const wrapper = mountPicker()
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-test="time-wheels"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'ouvre pas le popover si disabled', async () => {
|
||||||
|
const wrapper = mountPicker({disabled: true})
|
||||||
|
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'ouvre pas le popover si readonly', async () => {
|
||||||
|
const wrapper = mountPicker({readonly: true})
|
||||||
|
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet la valeur réglée depuis les molettes', async () => {
|
||||||
|
const wrapper = mountPicker({modelValue: '09:30'})
|
||||||
|
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||||
|
wrapper.findComponent({name: 'MalioTimeWheels'}).vm.$emit('update:modelValue', '10:30')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['10:30'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet null au clic sur la croix', async () => {
|
||||||
|
const wrapper = mountPicker({modelValue: '14:30'})
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('positionne aria-invalid et describedby sur erreur', () => {
|
||||||
|
const wrapper = mountPicker({error: 'Heure requise'})
|
||||||
|
const input = wrapper.get('[data-test="time-field"]')
|
||||||
|
expect(input.attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||||
|
expect(wrapper.text()).toContain('Heure requise')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche l\'astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = mountPicker({label: 'Champ', required: true})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||||
|
const wrapper = mountPicker({label: 'Champ'})
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : bordure noire sans bleu', () => {
|
||||||
|
const wrapper = mountPicker({readonly: true})
|
||||||
|
const input = wrapper.get('[data-test="time-field"]')
|
||||||
|
expect(input.classes()).toContain('border-black')
|
||||||
|
expect(input.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : label muted sans bleu', () => {
|
||||||
|
const wrapper = mountPicker({readonly: true, label: 'Heure'})
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
expect(label.classes()).toContain('text-m-muted')
|
||||||
|
expect(label.classes()).not.toContain('text-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly vide : icône horloge en text-m-muted', () => {
|
||||||
|
const wrapper = mountPicker({readonly: true, label: 'Heure'})
|
||||||
|
expect(wrapper.get('[data-test="clock-icon"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly rempli : label et icône en noir, bordure noire', () => {
|
||||||
|
const wrapper = mountPicker({readonly: true, label: 'Heure', modelValue: '14:30'})
|
||||||
|
const input = wrapper.get('[data-test="time-field"]')
|
||||||
|
const label = wrapper.get('label')
|
||||||
|
const icon = wrapper.get('[data-test="clock-icon"]')
|
||||||
|
expect(input.classes()).toContain('border-black')
|
||||||
|
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
expect(label.classes()).toContain('text-black')
|
||||||
|
expect(icon.classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('réserve l’espace message par défaut même sans message', () => {
|
||||||
|
const wrapper = mountPicker({label: 'Champ'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||||
|
const wrapper = mountPicker({label: 'Champ', reserveMessageSpace: false})
|
||||||
|
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||||
|
const wrapper = mountPicker({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||||
|
const msg = wrapper.find('[id$="-describedby"]')
|
||||||
|
expect(msg.exists()).toBe(true)
|
||||||
|
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="root">
|
||||||
|
<div :class="mergedGroupClass">
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:name="name"
|
||||||
|
data-test="time-field"
|
||||||
|
readonly
|
||||||
|
autocomplete="off"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="displayValue"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
type="text"
|
||||||
|
@click="onFieldClick"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="inputId"
|
||||||
|
:class="mergedLabelClass"
|
||||||
|
>
|
||||||
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||||
|
<button
|
||||||
|
v-if="showClear"
|
||||||
|
type="button"
|
||||||
|
data-test="clear"
|
||||||
|
class="text-m-muted hover:text-m-primary"
|
||||||
|
aria-label="Effacer l'heure"
|
||||||
|
@click.stop="onClear"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:close" :width="16" :height="16" />
|
||||||
|
</button>
|
||||||
|
<Icon
|
||||||
|
data-test="clock-icon"
|
||||||
|
icon="mdi:clock-outline"
|
||||||
|
:width="24"
|
||||||
|
:height="24"
|
||||||
|
:class="iconStateClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode overlay (par défaut) : popover absolu au-dessus du contenu suivant. -->
|
||||||
|
<div
|
||||||
|
v-if="isOpen && !staticPopover"
|
||||||
|
data-test="popover"
|
||||||
|
role="dialog"
|
||||||
|
class="absolute left-0 right-0 top-full z-20 box-border w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<TimeWheels
|
||||||
|
:model-value="wheelsValue"
|
||||||
|
@update:model-value="onWheelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode statique : molette en flux (hors du groupe à hauteur fixe) → le
|
||||||
|
conteneur parent (ex. popover du DateTime) grandit pour l'englober. -->
|
||||||
|
<div
|
||||||
|
v-if="isOpen && staticPopover"
|
||||||
|
data-test="popover"
|
||||||
|
role="dialog"
|
||||||
|
class="relative mt-4 w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<TimeWheels
|
||||||
|
:model-value="wheelsValue"
|
||||||
|
@update:model-value="onWheelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||||
|
'mt-1 ml-[2px] text-xs',
|
||||||
|
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ error || success || hint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
|
||||||
|
import {Icon} from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
import TimeWheels from './internal/TimeWheels.vue'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
clearable?: boolean
|
||||||
|
staticPopover?: boolean
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
placeholder: 'HH:MM',
|
||||||
|
required: false,
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
clearable: true,
|
||||||
|
staticPopover: false,
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const localValue = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const currentValue = computed(() => (isControlled.value ? props.modelValue : localValue.value))
|
||||||
|
|
||||||
|
const inputId = computed(() => props.id?.toString() || `malio-time-picker-${generatedId}`)
|
||||||
|
const hasError = computed(() => !!props.error)
|
||||||
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
|
const displayValue = computed(() => currentValue.value ?? '')
|
||||||
|
const isFilled = computed(() => displayValue.value.length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const wheelsValue = computed(() => currentValue.value || '00:00')
|
||||||
|
const showClear = computed(() =>
|
||||||
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||||
|
)
|
||||||
|
const describedBy = computed(() =>
|
||||||
|
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const commit = (value: string | null) => {
|
||||||
|
if (!isControlled.value) localValue.value = value
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWheelChange = (value: string) => commit(value)
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
commit(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFieldClick = () => {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseDown = (event: MouseEvent) => {
|
||||||
|
if (!isOpen.value || !root.value) return
|
||||||
|
if (!root.value.contains(event.target as Node)) isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('mousedown', onMouseDown))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
|
||||||
|
|
||||||
|
const mergedGroupClass = computed(() =>
|
||||||
|
twMerge('relative flex h-12 w-full items-center', props.groupClass),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedInputClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
||||||
|
isReadonly.value
|
||||||
|
? 'border-black'
|
||||||
|
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||||
|
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
|
||||||
|
hasError.value
|
||||||
|
? 'border-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'border-m-success'
|
||||||
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
|
(!isReadonly.value && isOpen.value) ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
|
||||||
|
props.inputClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedLabelClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
|
||||||
|
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||||
|
hasError.value
|
||||||
|
? 'text-m-danger'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'text-m-success'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
: isOpen.value
|
||||||
|
? 'text-m-primary'
|
||||||
|
: 'text-black peer-placeholder-shown:text-m-muted',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconStateClass = computed(() => {
|
||||||
|
if (hasError.value) return 'text-m-danger'
|
||||||
|
if (hasSuccess.value) return 'text-m-success'
|
||||||
|
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
|
if (isOpen.value) return 'text-m-primary'
|
||||||
|
if (isFilled.value) return 'text-black'
|
||||||
|
return 'text-m-muted'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-label {
|
||||||
|
background: white;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {clampHours, clampMinutes, formatTime, padSegment, parseTime} from './timeFormat'
|
||||||
|
|
||||||
|
describe('timeFormat', () => {
|
||||||
|
it('parse une chaîne HH:MM valide', () => {
|
||||||
|
expect(parseTime('09:05')).toEqual({hours: 9, minutes: 5})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie null pour vide ou invalide', () => {
|
||||||
|
expect(parseTime('')).toBeNull()
|
||||||
|
expect(parseTime(null)).toBeNull()
|
||||||
|
expect(parseTime('abc')).toBeNull()
|
||||||
|
expect(parseTime('12')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamp les valeurs hors bornes au parsing', () => {
|
||||||
|
expect(parseTime('99:88')).toEqual({hours: 23, minutes: 59})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate avec zéro-padding', () => {
|
||||||
|
expect(formatTime(9, 5)).toBe('09:05')
|
||||||
|
expect(formatTime(0, 0)).toBe('00:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamp et pad les helpers', () => {
|
||||||
|
expect(clampHours(30)).toBe(23)
|
||||||
|
expect(clampHours(-2)).toBe(0)
|
||||||
|
expect(clampMinutes(75)).toBe(59)
|
||||||
|
expect(padSegment(7)).toBe('07')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface TimeParts {
|
||||||
|
hours: number
|
||||||
|
minutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampHours(value: number): number {
|
||||||
|
if (Number.isNaN(value)) return 0
|
||||||
|
return Math.min(23, Math.max(0, Math.trunc(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampMinutes(value: number): number {
|
||||||
|
if (Number.isNaN(value)) return 0
|
||||||
|
return Math.min(59, Math.max(0, Math.trunc(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function padSegment(value: number): string {
|
||||||
|
return value.toString().padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTime(value: string | null | undefined): TimeParts | null {
|
||||||
|
if (!value) return null
|
||||||
|
const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim())
|
||||||
|
if (!match) return null
|
||||||
|
return {
|
||||||
|
hours: clampHours(Number.parseInt(match[1], 10)),
|
||||||
|
minutes: clampMinutes(Number.parseInt(match[2], 10)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(hours: number, minutes: number): string {
|
||||||
|
return `${padSegment(clampHours(hours))}:${padSegment(clampMinutes(minutes))}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {describe, expect, it, vi} from 'vitest'
|
||||||
|
import {defineComponent, nextTick, ref} from 'vue'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import {
|
||||||
|
CENTER_OFFSET,
|
||||||
|
VISIBLE_ROWS,
|
||||||
|
loopCorrection,
|
||||||
|
scrollTopForValueIndex,
|
||||||
|
useInfiniteWheel,
|
||||||
|
valueIndexFromScroll,
|
||||||
|
} from './useInfiniteWheel'
|
||||||
|
|
||||||
|
const H = 40 // itemHeight
|
||||||
|
const LEN = 24 // ex. heures
|
||||||
|
|
||||||
|
describe('useInfiniteWheel — math pure', () => {
|
||||||
|
it('expose 5 lignes visibles et un offset central de 2', () => {
|
||||||
|
expect(VISIBLE_ROWS).toBe(5)
|
||||||
|
expect(CENTER_OFFSET).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scrollTopForValueIndex et valueIndexFromScroll font un aller-retour', () => {
|
||||||
|
for (const index of [0, 1, 9, 23]) {
|
||||||
|
const top = scrollTopForValueIndex(index, H, LEN)
|
||||||
|
expect(valueIndexFromScroll(top, H, LEN)).toBe(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valueIndexFromScroll boucle en modulo', () => {
|
||||||
|
const top = scrollTopForValueIndex(0, H, LEN)
|
||||||
|
expect(valueIndexFromScroll(top + LEN * H, H, LEN)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loopCorrection laisse le scroll de la copie du milieu inchangé', () => {
|
||||||
|
const top = scrollTopForValueIndex(12, H, LEN)
|
||||||
|
expect(loopCorrection(top, H, LEN)).toBe(top)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loopCorrection ramène vers le milieu quand on dérive vers le haut', () => {
|
||||||
|
const drifted = scrollTopForValueIndex(0, H, LEN) - LEN * H
|
||||||
|
expect(loopCorrection(drifted, H, LEN)).toBe(drifted + LEN * H)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loopCorrection ramène vers le milieu quand on dérive vers le bas', () => {
|
||||||
|
const drifted = scrollTopForValueIndex(0, H, LEN) + LEN * H
|
||||||
|
expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) {
|
||||||
|
let api!: ReturnType<typeof useInfiniteWheel>
|
||||||
|
const Harness = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
api = useInfiniteWheel(container, {
|
||||||
|
length: 24,
|
||||||
|
itemHeight: 40,
|
||||||
|
initialIndex: () => initialIndex,
|
||||||
|
onChange,
|
||||||
|
})
|
||||||
|
return {container}
|
||||||
|
},
|
||||||
|
template: '<div ref="container" style="height:200px;overflow:auto"><div style="height:2880px" /></div>',
|
||||||
|
})
|
||||||
|
const wrapper = mount(Harness, {attachTo: document.body})
|
||||||
|
return {wrapper, api: () => api}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useInfiniteWheel — composable', () => {
|
||||||
|
it('step(+1) émet l\'index suivant', async () => {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {api} = mountWheelHarness(9, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
api().step(1)
|
||||||
|
expect(changes.at(-1)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('step boucle de 23 à 0', async () => {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {api} = mountWheelHarness(23, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
api().step(1)
|
||||||
|
expect(changes.at(-1)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onKeydown ArrowUp décrémente (avec wrap)', async () => {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {api} = mountWheelHarness(0, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'}))
|
||||||
|
expect(changes.at(-1)).toBe(23)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Anti-boucle navigateur : un scroll programmatique déclenche une rafale d'évènements
|
||||||
|
// scroll (animation/snap). Ils ne doivent PAS être pris pour du scroll utilisateur,
|
||||||
|
// sinon settle() ré-émet en boucle et corrompt le patch DOM de Vue.
|
||||||
|
it('n\'émet pas en double quand un scroll programmatique déclenche une rafale de scroll', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {wrapper, api} = mountWheelHarness(9, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
const el = wrapper.element as HTMLElement
|
||||||
|
changes.length = 0
|
||||||
|
|
||||||
|
api().scrollToIndex(12)
|
||||||
|
|
||||||
|
el.dispatchEvent(new Event('scroll'))
|
||||||
|
el.dispatchEvent(new Event('scroll'))
|
||||||
|
el.dispatchEvent(new Event('scroll'))
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
|
||||||
|
expect(changes).toEqual([12])
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
|
||||||
|
|
||||||
|
export const VISIBLE_ROWS = 5
|
||||||
|
export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2
|
||||||
|
|
||||||
|
/** Index de valeur logique (0..length-1) centré pour un scrollTop donné. */
|
||||||
|
export function valueIndexFromScroll(scrollTop: number, itemHeight: number, length: number): number {
|
||||||
|
const flat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET
|
||||||
|
return ((flat % length) + length) % length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** scrollTop qui centre l'index donné dans la copie du milieu (buffer à 3 copies). */
|
||||||
|
export function scrollTopForValueIndex(valueIndex: number, itemHeight: number, length: number): number {
|
||||||
|
const flat = length + valueIndex - CENTER_OFFSET
|
||||||
|
return flat * itemHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recentre le scrollTop dans la copie du milieu [length, 2*length) si on a dérivé. */
|
||||||
|
export function loopCorrection(scrollTop: number, itemHeight: number, length: number): number {
|
||||||
|
const block = length * itemHeight
|
||||||
|
const centeredFlat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET
|
||||||
|
if (centeredFlat < length) return scrollTop + block
|
||||||
|
if (centeredFlat >= 2 * length) return scrollTop - block
|
||||||
|
return scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInfiniteWheelOptions {
|
||||||
|
length: number
|
||||||
|
itemHeight: number
|
||||||
|
initialIndex: () => number
|
||||||
|
onChange: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteWheel(
|
||||||
|
containerRef: Ref<HTMLElement | null>,
|
||||||
|
options: UseInfiniteWheelOptions,
|
||||||
|
) {
|
||||||
|
const centeredIndex = ref(options.initialIndex())
|
||||||
|
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// Fenêtre de suppression : ignore les évènements scroll provoqués par NOS
|
||||||
|
// repositionnements programmatiques (et les réajustements de scroll-snap), qui
|
||||||
|
// arrivent en rafale. Un booléen one-shot n'en absorberait qu'un seul : les
|
||||||
|
// suivants seraient pris pour du scroll utilisateur → settle() → onChange en
|
||||||
|
// boucle (re-render ré-entrant qui corrompt le patch DOM dans le navigateur).
|
||||||
|
let suppressed = false
|
||||||
|
let suppressTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// Scroll programmatique INSTANTANÉ : pas de 'smooth', dont l'animation multi-frames
|
||||||
|
// émettrait justement la rafale d'évènements scroll problématique.
|
||||||
|
function applyScroll(top: number) {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
suppressed = true
|
||||||
|
if (suppressTimer) clearTimeout(suppressTimer)
|
||||||
|
suppressTimer = setTimeout(() => { suppressed = false }, 100)
|
||||||
|
el.scrollTop = top
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCentered() {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
centeredIndex.value = valueIndexFromScroll(el.scrollTop, options.itemHeight, options.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle() {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
readCentered()
|
||||||
|
options.onChange(centeredIndex.value)
|
||||||
|
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
|
||||||
|
if (corrected !== el.scrollTop) applyScroll(corrected)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (suppressed) return
|
||||||
|
readCentered()
|
||||||
|
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||||
|
scrollEndTimer = setTimeout(settle, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToIndex(index: number) {
|
||||||
|
centeredIndex.value = index
|
||||||
|
applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length))
|
||||||
|
options.onChange(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(delta: number) {
|
||||||
|
const next = (((centeredIndex.value + delta) % options.length) + options.length) % options.length
|
||||||
|
scrollToIndex(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
step(-1)
|
||||||
|
}
|
||||||
|
else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
step(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('scroll', onScroll, {passive: true})
|
||||||
|
applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length))
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
containerRef.value?.removeEventListener('scroll', onScroll)
|
||||||
|
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||||
|
if (suppressTimer) clearTimeout(suppressTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {centeredIndex, scrollToIndex, step, onKeydown}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import TimeWheel from './TimeWheel.vue'
|
||||||
|
|
||||||
|
const HOURS = Array.from({length: 24}, (_, i) => i)
|
||||||
|
|
||||||
|
const mountWheel = (modelValue = 9) =>
|
||||||
|
mount(TimeWheel, {
|
||||||
|
props: {modelValue, values: HOURS, ariaLabel: 'Heures'},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioTimeWheel', () => {
|
||||||
|
it('expose le rôle spinbutton et les attributs aria', () => {
|
||||||
|
const wrapper = mountWheel(9)
|
||||||
|
const el = wrapper.get('[role="spinbutton"]')
|
||||||
|
expect(el.attributes('aria-label')).toBe('Heures')
|
||||||
|
expect(el.attributes('aria-valuenow')).toBe('9')
|
||||||
|
expect(el.attributes('aria-valuemin')).toBe('0')
|
||||||
|
expect(el.attributes('aria-valuemax')).toBe('23')
|
||||||
|
expect(el.attributes('aria-valuetext')).toBe('09')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rend 3 copies des valeurs (buffer infini)', () => {
|
||||||
|
const wrapper = mountWheel()
|
||||||
|
expect(wrapper.findAll('[data-test="wheel-item"]')).toHaveLength(24 * 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet la nouvelle valeur au clavier ArrowDown', async () => {
|
||||||
|
const wrapper = mountWheel(9)
|
||||||
|
await wrapper.get('[role="spinbutton"]').trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([10])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet la valeur cliquée', async () => {
|
||||||
|
const wrapper = mountWheel(9)
|
||||||
|
const item = wrapper.findAll('[data-test="wheel-item"]').find((w) => w.text() === '11')!
|
||||||
|
await item.trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([11])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="malio-wheel relative h-[160px] w-14 snap-y snap-mandatory overflow-y-scroll"
|
||||||
|
role="spinbutton"
|
||||||
|
:tabindex="0"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
:aria-valuenow="modelValue"
|
||||||
|
:aria-valuemin="values[0]"
|
||||||
|
:aria-valuemax="values[values.length - 1]"
|
||||||
|
:aria-valuetext="pad(modelValue)"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in buffer"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
data-test="wheel-item"
|
||||||
|
class="flex h-8 w-full snap-center items-center justify-center leading-none outline-none transition-all"
|
||||||
|
:class="itemClass(item.flat)"
|
||||||
|
tabindex="-1"
|
||||||
|
@click="onItemClick(item.value)"
|
||||||
|
>
|
||||||
|
{{ pad(item.value) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import {useInfiniteWheel} from '../composables/useInfiniteWheel'
|
||||||
|
import {padSegment} from '../composables/timeFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioTimeWheel', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: number
|
||||||
|
values: number[]
|
||||||
|
ariaLabel: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: number): void}>()
|
||||||
|
|
||||||
|
const ITEM_HEIGHT = 32
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const pad = (value: number) => padSegment(value)
|
||||||
|
const indexOfValue = (value: number) => Math.max(0, props.values.indexOf(value))
|
||||||
|
|
||||||
|
const {centeredIndex, scrollToIndex, onKeydown} = useInfiniteWheel(container, {
|
||||||
|
length: props.values.length,
|
||||||
|
itemHeight: ITEM_HEIGHT,
|
||||||
|
initialIndex: () => indexOfValue(props.modelValue),
|
||||||
|
onChange: (index) => emit('update:modelValue', props.values[index]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const buffer = computed(() =>
|
||||||
|
[0, 1, 2].flatMap((copy) =>
|
||||||
|
props.values.map((value, i) => {
|
||||||
|
const flat = copy * props.values.length + i
|
||||||
|
return {value, flat, key: flat}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Taille décroissante avec la distance au centre (effet molette iOS).
|
||||||
|
const itemClass = (flat: number) => {
|
||||||
|
const distance = Math.abs(flat - (props.values.length + centeredIndex.value))
|
||||||
|
if (distance === 0) return 'text-[16px] font-medium text-black'
|
||||||
|
if (distance === 1) return 'text-[14px] text-m-muted'
|
||||||
|
return 'text-[12px] text-m-muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemClick = (value: number) => scrollToIndex(indexOfValue(value))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.malio-wheel {
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Estompe les valeurs en haut et en bas (effet molette iOS) pour qu'elles ne
|
||||||
|
débordent pas visuellement du cadre. */
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||||
|
}
|
||||||
|
.malio-wheel::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import TimeWheels from './TimeWheels.vue'
|
||||||
|
import TimeWheel from './TimeWheel.vue'
|
||||||
|
|
||||||
|
const mountWheels = (modelValue = '09:30') =>
|
||||||
|
mount(TimeWheels, {props: {modelValue}, attachTo: document.body})
|
||||||
|
|
||||||
|
describe('MalioTimeWheels', () => {
|
||||||
|
it('rend deux molettes (heures + minutes) et un séparateur', () => {
|
||||||
|
const wrapper = mountWheels('09:30')
|
||||||
|
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||||
|
expect(wheels).toHaveLength(2)
|
||||||
|
expect(wheels[0].props('ariaLabel')).toBe('Heures')
|
||||||
|
expect(wheels[1].props('ariaLabel')).toBe('Minutes')
|
||||||
|
expect(wrapper.text()).toContain(':')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splitte modelValue vers les bonnes molettes', () => {
|
||||||
|
const wrapper = mountWheels('09:30')
|
||||||
|
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||||
|
expect(wheels[0].props('modelValue')).toBe(9)
|
||||||
|
expect(wheels[1].props('modelValue')).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recompose et émet HH:MM quand l\'heure change', async () => {
|
||||||
|
const wrapper = mountWheels('09:30')
|
||||||
|
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||||
|
wheels[0].vm.$emit('update:modelValue', 14)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['14:30'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recompose et émet HH:MM quand la minute change', async () => {
|
||||||
|
const wrapper = mountWheels('09:30')
|
||||||
|
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||||
|
wheels[1].vm.$emit('update:modelValue', 5)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['09:05'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('par défaut 00:00 quand modelValue est vide', () => {
|
||||||
|
const wrapper = mountWheels('')
|
||||||
|
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||||
|
expect(wheels[0].props('modelValue')).toBe(0)
|
||||||
|
expect(wheels[1].props('modelValue')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-test="time-wheels"
|
||||||
|
class="relative flex items-center justify-center gap-3 py-2"
|
||||||
|
>
|
||||||
|
<!-- bande centrale (overlay, traverse les 2 colonnes) -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-2 top-1/2 z-0 h-8 mx-3 -translate-y-1/2 rounded-lg bg-m-primary-light"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioTimeWheel
|
||||||
|
:model-value="hours"
|
||||||
|
:values="HOURS"
|
||||||
|
aria-label="Heures"
|
||||||
|
class="relative z-10"
|
||||||
|
@update:model-value="onHours"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="relative z-10 text-[14px] font-bold text-black">:</span>
|
||||||
|
|
||||||
|
<MalioTimeWheel
|
||||||
|
:model-value="minutes"
|
||||||
|
:values="MINUTES"
|
||||||
|
aria-label="Minutes"
|
||||||
|
class="relative z-10"
|
||||||
|
@update:model-value="onMinutes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue'
|
||||||
|
import MalioTimeWheel from './TimeWheel.vue'
|
||||||
|
import {formatTime, parseTime} from '../composables/timeFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioTimeWheels', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{modelValue?: string | null}>(),
|
||||||
|
{modelValue: ''},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: string): void}>()
|
||||||
|
|
||||||
|
const HOURS = Array.from({length: 24}, (_, i) => i)
|
||||||
|
const MINUTES = Array.from({length: 60}, (_, i) => i)
|
||||||
|
|
||||||
|
const parts = computed(() => parseTime(props.modelValue) ?? {hours: 0, minutes: 0})
|
||||||
|
const hours = computed(() => parts.value.hours)
|
||||||
|
const minutes = computed(() => parts.value.minutes)
|
||||||
|
|
||||||
|
const onHours = (value: number) => emit('update:modelValue', formatTime(value, minutes.value))
|
||||||
|
const onMinutes = (value: number) => emit('update:modelValue', formatTime(hours.value, value))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Disclosure/Accordion">
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) — défaut</h2>
|
||||||
|
<MalioAccordion v-model="multiple">
|
||||||
|
<MalioAccordionItem title="Prix" value="prix">
|
||||||
|
<p>Slider de prix ici…</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Catégorie" value="cat">
|
||||||
|
<p>Liste de checkboxes ici…</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Marque" value="marque">
|
||||||
|
<p>Recherche + liste ici…</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
|
||||||
|
<MalioAccordion v-model="single" mode="single">
|
||||||
|
<MalioAccordionItem title="Question 1" value="q1">
|
||||||
|
<p>Réponse 1</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Question 2" value="q2">
|
||||||
|
<p>Réponse 2</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
|
||||||
|
<MalioAccordion>
|
||||||
|
<MalioAccordionItem title="Active" value="ok">
|
||||||
|
<p>Contenu accessible</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
|
||||||
|
<p>Inaccessible</p>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioAccordion
|
||||||
|
|
||||||
|
Accordéon compositionnel : un parent `MalioAccordion` qui enveloppe des
|
||||||
|
`MalioAccordionItem`. Conçu pour des systèmes de filtres (plusieurs sections
|
||||||
|
dépliées simultanément) comme pour des FAQ (une seule section ouverte).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props — MalioAccordion
|
||||||
|
|
||||||
|
### mode
|
||||||
|
- Type: `'single' | 'multiple'`
|
||||||
|
- Défaut: `'multiple'`
|
||||||
|
- Description: `multiple` autorise plusieurs panneaux ouverts ; `single` ferme les autres à l'ouverture.
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
- Type: `string | string[]`
|
||||||
|
- Description: clés ouvertes. `string[]` en mode `multiple`, `string` en mode `single`. Sans v-model, état interne (non contrôlé).
|
||||||
|
|
||||||
|
### id
|
||||||
|
- Type: `string`
|
||||||
|
- Description: préfixe des IDs d'accessibilité. Auto-généré si absent.
|
||||||
|
|
||||||
|
### groupClass
|
||||||
|
- Type: `string`
|
||||||
|
- Description: classes du conteneur, fusionnées via `twMerge`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props — MalioAccordionItem
|
||||||
|
|
||||||
|
### title
|
||||||
|
- Type: `string` (requis) — texte de l'en-tête.
|
||||||
|
|
||||||
|
### value
|
||||||
|
- Type: `string` — clé unique de la section (recommandée pour piloter le v-model). Auto-générée si absente.
|
||||||
|
|
||||||
|
### defaultOpen
|
||||||
|
- Type: `boolean` — défaut `false`. Ouvre la section au montage (mode non contrôlé uniquement).
|
||||||
|
|
||||||
|
### disabled
|
||||||
|
- Type: `boolean` — défaut `false`. En-tête non cliquable.
|
||||||
|
|
||||||
|
### headerClass / panelClass
|
||||||
|
- Type: `string` — override des classes de l'en-tête / du panneau (`twMerge`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
Slot par défaut de `MalioAccordionItem` = contenu du panneau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- En-tête = `<button>` natif, `aria-expanded`, `aria-controls`.
|
||||||
|
- Panneau `role="region"` + `aria-labelledby`.
|
||||||
|
- Sections désactivées : `disabled` + `aria-disabled`.
|
||||||
|
- Navigation clavier ↑/↓ entre les en-têtes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
- Émis à chaque bascule. Retourne `string[]` (mode `multiple`) ou `string` (mode `single`, `''` si tout fermé).
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioAccordion from '../../components/malio/accordion/Accordion.vue'
|
||||||
|
import MalioAccordionItem from '../../components/malio/accordion/AccordionItem.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AccordionStory' })
|
||||||
|
|
||||||
|
const multiple = ref<string[]>(['prix'])
|
||||||
|
const single = ref('q1')
|
||||||
|
</script>
|
||||||
@@ -28,6 +28,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||||
|
<MalioDate
|
||||||
|
v-model="editableValue"
|
||||||
|
label="Date de naissance"
|
||||||
|
editable
|
||||||
|
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||||
<MalioDate
|
<MalioDate
|
||||||
@@ -91,4 +101,5 @@ const simpleValue = ref<string | null>(null)
|
|||||||
const initialValue = ref<string | null>(todayIso)
|
const initialValue = ref<string | null>(todayIso)
|
||||||
const boundedValue = ref<string | null>(null)
|
const boundedValue = ref<string | null>(null)
|
||||||
const errorValue = ref<string | null>(null)
|
const errorValue = ref<string | null>(null)
|
||||||
|
const editableValue = ref<string | null>(null)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const showNoDismiss = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="Avec footer collant">
|
<Variant title="Avec footer d'actions">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
@@ -62,9 +62,7 @@ const showNoDismiss = ref(false)
|
|||||||
<MalioInputText label="Prénom" />
|
<MalioInputText label="Prénom" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
||||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="bigValue"
|
||||||
|
label="Budget"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-m-muted">
|
||||||
|
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
@@ -251,6 +262,7 @@ import {ref} from 'vue'
|
|||||||
import MalioInputAmount from '../../components/malio/input/InputAmount.vue'
|
import MalioInputAmount from '../../components/malio/input/InputAmount.vue'
|
||||||
|
|
||||||
const simpleValue = ref('')
|
const simpleValue = ref('')
|
||||||
|
const bigValue = ref('1234567.89')
|
||||||
const hintValue = ref('')
|
const hintValue = ref('')
|
||||||
const disabledValue = ref('1500.00')
|
const disabledValue = ref('1500.00')
|
||||||
const readonlyValue = ref('2450.75')
|
const readonlyValue = ref('2450.75')
|
||||||
|
|||||||
@@ -18,6 +18,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="addableValue"
|
||||||
|
label="Adresse email"
|
||||||
|
addable
|
||||||
|
@add="onAdd"
|
||||||
|
/>
|
||||||
|
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||||
|
Bouton cliqué {{ addClicks }} fois
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4">
|
<div class="rounded-lg border p-4">
|
||||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
@@ -251,6 +264,9 @@ import {ref} from 'vue'
|
|||||||
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
|
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
|
||||||
|
|
||||||
const simpleValue = ref('')
|
const simpleValue = ref('')
|
||||||
|
const addableValue = ref('')
|
||||||
|
const addClicks = ref(0)
|
||||||
|
const onAdd = () => { addClicks.value += 1 }
|
||||||
const leftIconValue = ref('')
|
const leftIconValue = ref('')
|
||||||
const noIconValue = ref('')
|
const noIconValue = ref('')
|
||||||
const hintValue = ref('')
|
const hintValue = ref('')
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ModalStory' })
|
||||||
|
|
||||||
|
const showBase = ref(false)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const showNoDismiss = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story title="Overlay/Modal">
|
||||||
|
<Variant title="Simple">
|
||||||
|
<div class="p-4">
|
||||||
|
<button
|
||||||
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
|
@click="showBase = true"
|
||||||
|
>
|
||||||
|
Ouvrir
|
||||||
|
</button>
|
||||||
|
<MalioModal v-model="showBase">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">Détails</h2>
|
||||||
|
</template>
|
||||||
|
<p>Contenu simple de la modal.</p>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Avec footer d'actions">
|
||||||
|
<div class="p-4">
|
||||||
|
<button
|
||||||
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
|
@click="showForm = true"
|
||||||
|
>
|
||||||
|
Ouvrir le formulaire
|
||||||
|
</button>
|
||||||
|
<MalioModal v-model="showForm" modal-class="max-w-lg">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">Nouveau contact</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4 py-2">
|
||||||
|
<MalioInputText label="Nom" />
|
||||||
|
<MalioInputText label="Prénom" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Non dismissable">
|
||||||
|
<div class="p-4">
|
||||||
|
<button
|
||||||
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
|
@click="showNoDismiss = true"
|
||||||
|
>
|
||||||
|
Ouvrir
|
||||||
|
</button>
|
||||||
|
<MalioModal v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">Action requise</h2>
|
||||||
|
</template>
|
||||||
|
<p>Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Time/TimePicker">
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioTimePicker v-model="simpleValue" label="Heure" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||||
|
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioTimePicker from '../../components/malio/time/TimePicker.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const initialValue = ref('08:30')
|
||||||
|
const disabledValue = ref('14:15')
|
||||||
|
const errorValue = ref('25:90')
|
||||||
|
const successValue = ref('09:00')
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user