Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a3df7481 | |||
| bf4d62a23f | |||
| 1b5a4c9920 | |||
| cda0f994ca | |||
| 5f1dc834cd | |||
| de504d8ba0 | |||
| 59004c9635 | |||
| 801925c443 | |||
| cec528eac6 | |||
| 358ba246d7 | |||
| 621077f555 | |||
| f5163f10f1 | |||
| 734c7aba2f | |||
| bdca9490ee | |||
| 5b9b771174 | |||
| 993364062d | |||
| 12a165c1c1 | |||
| f8c0bf13d5 | |||
| 26c0a8b533 | |||
| e622380916 | |||
| 289ff036d2 | |||
| fd3e3a7922 | |||
| c934019260 | |||
| cc03559dcf | |||
| 6b1e11bd6f | |||
| 4f5eaaacb9 | |||
| 2d8639a913 | |||
| 3e09f4278e | |||
| 4e2303c471 | |||
| 6081f0c90c | |||
| 120020b210 | |||
| 61cb90a9c6 | |||
| 167cc43870 | |||
| 03fe458248 | |||
| df289aa829 | |||
| 05949b727e | |||
| aedfaa865d | |||
| 39eb6e6068 | |||
| 1d66e5dd31 | |||
| ce9b4853e6 | |||
| dc33cf4135 | |||
| 526dcd1a84 | |||
| 280b650e49 | |||
| 951acd448e | |||
| 90b81975e3 | |||
| e6a46a9d60 | |||
| 6efb830ffe | |||
| 7b838c60ca | |||
| 9551816bf8 | |||
| 7ac097e7f0 | |||
| bc813190c6 | |||
| f3e298e03b | |||
| e2dabb0a26 | |||
| ac06ed9ae6 | |||
| b2e3a83bb9 | |||
| 9ed094ba86 | |||
| 1ffe63827d | |||
| eb21827686 | |||
| 6938e730b6 | |||
| 174f1f9a64 | |||
| 30efd482d8 | |||
| 7dec45b374 | |||
| ea92acff3a | |||
| a3421c02e9 | |||
| 5563d89743 | |||
| 640ff90187 | |||
| 2eb7a5247a | |||
| 3336ff0c69 | |||
| da3a4cb349 | |||
| 0ddae4dd70 | |||
| 23210e6868 | |||
| 1c0fcd24e3 | |||
| d74f3acc97 | |||
| 014a057196 | |||
| 73483b0573 | |||
| 4855923008 | |||
| fc844078a6 | |||
| 02495245a5 | |||
| 330fb2130b | |||
| 5acefc1d59 | |||
| e77bf49146 | |||
| f59f866354 | |||
| 660c3787fd | |||
| e9741ff38d | |||
| 32608c8f71 | |||
| e1965db04e | |||
| 0ad344bab9 | |||
| 96719be78d | |||
| b90baec571 | |||
| 384f86a3b3 | |||
| e8ddf4e083 | |||
| 7ee64289a8 | |||
| f09f8a91ac | |||
| bcadd46ce2 | |||
| e76337502a | |||
| 968b7087b5 | |||
| 3deba3f369 | |||
| cf46ab0c85 | |||
| 09cc3edf6f | |||
| c95a3657c0 | |||
| 9843f4d032 | |||
| 9d9b9c9dc4 | |||
| 187ef52865 | |||
| 9925f1ced4 | |||
| ded414ba1a | |||
| 11d60e687b | |||
| d3038994c3 | |||
| 0d350e12c6 | |||
| c6acaace27 | |||
| 927c7c3c70 | |||
| bf0aa92497 | |||
| 88dd76a0e4 | |||
| cc04114f89 | |||
| f456ea4ddf | |||
| 77364daa67 | |||
| 1ab7b2427a | |||
| 82ecc9cfe2 | |||
| 65d9060e26 | |||
| ec4c157226 |
@@ -50,6 +50,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,6 +81,7 @@ 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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
label="Réinitialiser"
|
label="Réinitialiser"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
button-class="w-[150px]"
|
button-class="w-m-btn-action"
|
||||||
@click="resetFiltres"
|
@click="resetFiltres"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -36,6 +36,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 +74,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledAmount = ref('1250.00')
|
||||||
</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)
|
||||||
|
|||||||
@@ -48,6 +48,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 +101,35 @@
|
|||||||
: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 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')
|
||||||
|
|||||||
@@ -31,6 +31,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,6 +91,7 @@
|
|||||||
<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('')
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -30,12 +30,23 @@
|
|||||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||||
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
|
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref} from 'vue'
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const readonlyFilledTime = ref('14:30')
|
||||||
const simpleValue = ref('')
|
const simpleValue = ref('')
|
||||||
const initialValue = ref('08:30')
|
const initialValue = ref('08:30')
|
||||||
const disabledValue = ref('14:15')
|
const disabledValue = ref('14:15')
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ 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'},
|
||||||
|
|||||||
@@ -36,11 +36,25 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
|
* [#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-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)
|
* [#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`)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
|
||||||
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
|
* 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
|
||||||
|
|||||||
+32
-12
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MalioInputText
|
## MalioInputText
|
||||||
@@ -15,7 +17,7 @@ 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 |
|
||||||
@@ -53,6 +55,7 @@ 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 |
|
||||||
@@ -79,7 +82,8 @@ 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 |
|
||||||
@@ -91,6 +95,8 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
|||||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||||
|
|
||||||
|
> **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)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
@@ -115,7 +121,7 @@ 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 |
|
||||||
@@ -146,7 +152,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 +165,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,7 +175,7 @@ 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 |
|
||||||
@@ -185,8 +192,8 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
|||||||
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- Usage statique -->
|
<!-- Usage statique (filtrage côté client via local-filter) -->
|
||||||
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
|
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
|
||||||
|
|
||||||
<!-- Usage API (parent gère le fetch) -->
|
<!-- Usage API (parent gère le fetch) -->
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
@@ -230,6 +237,7 @@ Champ montant avec icône devise (euro par défaut).
|
|||||||
| `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 |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
@@ -252,6 +260,7 @@ 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 |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
@@ -275,6 +284,7 @@ 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 |
|
||||||
| `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.) |
|
||||||
|
|
||||||
@@ -303,6 +313,7 @@ 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 |
|
||||||
@@ -333,6 +344,7 @@ Champ d'upload de fichier.
|
|||||||
| `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 |
|
||||||
| `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 |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
|
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
|
||||||
@@ -357,6 +369,7 @@ Liste déroulante.
|
|||||||
| `error` | `string` | `''` | Message d'erreur |
|
| `error` | `string` | `''` | Message d'erreur |
|
||||||
| `success` | `string` | `''` | Message de succès |
|
| `success` | `string` | `''` | Message de succès |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `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 |
|
||||||
@@ -388,6 +401,7 @@ Liste déroulante multi-sélection avec checkboxes.
|
|||||||
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
|
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
|
||||||
| `label` | `string` | `''` | Label |
|
| `label` | `string` | `''` | Label |
|
||||||
| `disabled` | `boolean` | `false` | Désactivé |
|
| `disabled` | `boolean` | `false` | Désactivé |
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `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)[])`
|
||||||
@@ -409,6 +423,7 @@ 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 |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`
|
**Events :** `update:modelValue(value: boolean)`
|
||||||
@@ -432,6 +447,7 @@ 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)`
|
||||||
|
|
||||||
@@ -455,7 +471,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 |
|
||||||
@@ -489,7 +505,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 |
|
||||||
@@ -522,7 +538,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 |
|
||||||
@@ -552,6 +568,7 @@ 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 |
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: string)`
|
**Events :** `update:modelValue(value: string)`
|
||||||
@@ -574,7 +591,7 @@ Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes in
|
|||||||
| `label` | `string` | `''` | Label flottant |
|
| `label` | `string` | `''` | Label flottant |
|
||||||
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
|
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
|
||||||
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
|
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
|
||||||
| `required` | `boolean` | `false` | Champ requis |
|
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||||
@@ -607,7 +624,7 @@ 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 |
|
||||||
@@ -652,8 +669,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
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
||||||
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
--m-btn-danger-active: 255 83 86; /* #FF5356 */
|
||||||
|
|
||||||
|
/* ── Largeurs Boutons ── */
|
||||||
|
--m-btn-action-width: 150px; /* Boutons d'action (liste, ligne tableau, footer dense…) */
|
||||||
|
|
||||||
/* ── Couleurs de site (usage ponctuel) ── */
|
/* ── Couleurs de site (usage ponctuel) ── */
|
||||||
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
||||||
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe('MalioButton', () => {
|
|||||||
it('applies correct dimensions', () => {
|
it('applies correct dimensions', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
expect(wrapper.get('button').classes()).toContain('w-[240px]')
|
expect(wrapper.get('button').classes()).toContain('w-[200px]')
|
||||||
expect(wrapper.get('button').classes()).toContain('h-[40px]')
|
expect(wrapper.get('button').classes()).toContain('h-[40px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
|||||||
|
|
||||||
const mergedButtonClass = computed(() =>
|
const mergedButtonClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'inline-flex w-[240px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
'inline-flex w-[200px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||||
variantClasses.value,
|
variantClasses.value,
|
||||||
props.buttonClass,
|
props.buttonClass,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -57,15 +57,16 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="totalItems > 0"
|
v-if="totalItems > 0"
|
||||||
class="flex justify-between pt-2"
|
class="flex items-center justify-between pt-2"
|
||||||
data-test="pagination"
|
data-test="pagination"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
|
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
||||||
|
<div class="h-12">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="perPage"
|
:model-value="perPage"
|
||||||
:options="perPageSelectOptions"
|
:options="perPageSelectOptions"
|
||||||
min-width="w-20 !mt-0"
|
group-class="w-20"
|
||||||
rounded="rounded"
|
rounded="rounded"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
@@ -74,8 +75,9 @@
|
|||||||
@update:model-value="onPerPageChange"
|
@update:model-value="onPerPageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
|
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
label="Prev"
|
label="Prev"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type DateProps = {
|
|||||||
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 +41,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 +186,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 +237,25 @@ 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]')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
: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">
|
||||||
@@ -85,11 +85,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,6 +102,7 @@
|
|||||||
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 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'
|
||||||
@@ -126,6 +128,7 @@ const props = withDefaults(
|
|||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -142,6 +145,7 @@ const props = withDefaults(
|
|||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,6 +162,7 @@ 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.displayValue.length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
const showClear = computed(() =>
|
const showClear = computed(() =>
|
||||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||||
)
|
)
|
||||||
@@ -195,14 +200,16 @@ 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' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -210,11 +217,13 @@ 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'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
: isOpen.value
|
: isOpen.value
|
||||||
? 'text-m-primary'
|
? 'text-m-primary'
|
||||||
: 'peer-placeholder-shown:text-m-muted text-black',
|
: 'peer-placeholder-shown:text-m-muted text-black',
|
||||||
@@ -225,6 +234,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'
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -174,4 +175,59 @@ 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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -89,6 +91,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 +112,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 +126,15 @@ 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 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,29 +144,38 @@ 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',
|
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'
|
||||||
|
: 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',
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
@@ -234,6 +252,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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
{{ minSearchText }}
|
{{ minSearchText }}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-else-if="options.length === 0"
|
v-else-if="filteredOptions.length === 0"
|
||||||
class="px-3 py-2 text-m-muted"
|
class="px-3 py-2 text-m-muted"
|
||||||
data-test="no-results-text"
|
data-test="no-results-text"
|
||||||
>
|
>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<li
|
<li
|
||||||
v-for="(opt, index) in options"
|
v-for="(opt, index) in filteredOptions"
|
||||||
:id="optionId(index)"
|
:id="optionId(index)"
|
||||||
:key="String(opt.value)"
|
:key="String(opt.value)"
|
||||||
data-test="option"
|
data-test="option"
|
||||||
@@ -136,11 +136,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 }}
|
||||||
@@ -152,6 +153,7 @@
|
|||||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
import {computed, 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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -180,6 +182,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 +190,7 @@ const props = withDefaults(
|
|||||||
noResultsText?: string
|
noResultsText?: string
|
||||||
loadingText?: string
|
loadingText?: string
|
||||||
minSearchText?: string
|
minSearchText?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -207,6 +211,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 +219,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 +253,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 +314,17 @@ 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',
|
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 +332,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,12 +344,15 @@ 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'
|
||||||
|
: 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',
|
: '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 +362,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 +371,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'
|
||||||
@@ -432,8 +455,8 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
|
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
|
||||||
onSelect(props.options[activeIndex.value])
|
onSelect(filteredOptions.value[activeIndex.value])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (props.allowCreate && inputValue.value !== '') {
|
if (props.allowCreate && inputValue.value !== '') {
|
||||||
@@ -450,7 +473,7 @@ const onKeydown = (event: KeyboardEvent) => {
|
|||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
}
|
}
|
||||||
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
|
activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,12 +504,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,8 @@ type InputEmailProps = {
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
lowercase?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
||||||
@@ -52,6 +54,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 +237,82 @@ 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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -42,7 +42,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
|
||||||
@@ -50,7 +50,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,6 +64,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ const props = withDefaults(
|
|||||||
iconPosition?: 'left' | 'right'
|
iconPosition?: 'left' | 'right'
|
||||||
iconSize?: string | number
|
iconSize?: string | number
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
|
lowercase?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -106,6 +110,8 @@ const props = withDefaults(
|
|||||||
success: '',
|
success: '',
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
iconColor: 'text-m-muted',
|
iconColor: 'text-m-muted',
|
||||||
|
lowercase: false,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,10 +123,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,29 +140,38 @@ 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',
|
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'
|
||||||
|
: 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',
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
@@ -169,12 +189,37 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: string): void
|
(event: 'update:modelValue', value: string): 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 iconInputPaddingClass = computed(() => {
|
const iconInputPaddingClass = computed(() => {
|
||||||
@@ -203,6 +248,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,7 +6,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -51,7 +51,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 +59,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,6 +72,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ const props = withDefaults(
|
|||||||
hint?: string
|
hint?: string
|
||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -108,6 +111,7 @@ const props = withDefaults(
|
|||||||
hint: '',
|
hint: '',
|
||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,6 +69,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -90,6 +92,7 @@ const props = withDefaults(
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
displayIcon?: boolean
|
displayIcon?: boolean
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -109,6 +112,7 @@ const props = withDefaults(
|
|||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
displayIcon: true,
|
displayIcon: true,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,10 +129,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 +146,20 @@ 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',
|
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,12 +167,17 @@ 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'
|
||||||
|
: 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',
|
: '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 +209,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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,6 +84,7 @@ 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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ const props = withDefaults(
|
|||||||
addable?: boolean
|
addable?: boolean
|
||||||
addIconName?: string
|
addIconName?: string
|
||||||
addButtonLabel?: string
|
addButtonLabel?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -134,6 +137,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 +149,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,29 +166,38 @@ 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',
|
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'
|
||||||
|
: 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',
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
@@ -187,8 +205,9 @@ const mergedLabelClass = computed(() =>
|
|||||||
|
|
||||||
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 +267,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'
|
||||||
|
: props.disabled
|
||||||
|
? 'text-m-muted'
|
||||||
: isFocused.value
|
: isFocused.value
|
||||||
? 'text-m-primary'
|
? 'text-m-primary'
|
||||||
: 'text-m-text',
|
: 'text-m-text',
|
||||||
props.disabled ? 'text-black/60' : '',
|
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,6 +68,7 @@ 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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -94,6 +96,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 +120,7 @@ const props = withDefaults(
|
|||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
iconColor: 'text-m-muted',
|
iconColor: 'text-m-muted',
|
||||||
mask: undefined,
|
mask: undefined,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,10 +132,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,29 +149,38 @@ 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',
|
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'
|
||||||
|
: 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',
|
: '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 +232,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,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="mergedGroupClass">
|
<div :class="mergedGroupClass">
|
||||||
|
<div class="relative w-full flex-1">
|
||||||
<textarea
|
<textarea
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:name="name"
|
:name="name"
|
||||||
@@ -7,13 +8,14 @@
|
|||||||
: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
|
hasError
|
||||||
? 'border-m-danger focus:border-m-danger'
|
? 'border-m-danger focus:border-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'border-m-success focus:border-m-success'
|
? 'border-m-success focus:border-m-success'
|
||||||
: 'focus:border-m-primary',
|
: isReadonly ? '' : 'focus:border-m-primary',
|
||||||
|
isReadonly ? '' : (isFocused ? 'textarea-scrollbar-primary' : ''),
|
||||||
textInput,
|
textInput,
|
||||||
showCounterComputed ? 'pb-6' : '',
|
showCounterComputed ? 'pb-6' : '',
|
||||||
rounded,
|
rounded,
|
||||||
@@ -39,16 +41,19 @@
|
|||||||
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
||||||
:class="[
|
:class="[
|
||||||
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
||||||
disabled ? 'text-black/60' : '',
|
|
||||||
hasError
|
hasError
|
||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
|
: disabled
|
||||||
|
? 'text-m-muted'
|
||||||
|
: isReadonly
|
||||||
|
? (isFilled ? 'text-black' : 'text-m-muted')
|
||||||
|
: (isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted'),
|
||||||
textLabel,
|
textLabel,
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
<span
|
<span
|
||||||
v-if="showCounterComputed"
|
v-if="showCounterComputed"
|
||||||
@@ -58,8 +63,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="hasError || hasSuccess || hint"
|
v-if="reserveMessageSpace || hint || error || success"
|
||||||
|
data-test="message-line"
|
||||||
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
||||||
|
:class="reserveMessageSpace ? 'min-h-[1rem]' : ''"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
@@ -75,11 +82,13 @@
|
|||||||
{{ error || success || hint }}
|
{{ error || success || hint }}
|
||||||
</p>
|
</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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -108,6 +117,7 @@ const props = withDefaults(
|
|||||||
success?: string
|
success?: string
|
||||||
rounded?: string
|
rounded?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -134,11 +144,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 +162,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 +184,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 +206,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,6 +20,7 @@
|
|||||||
: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="_"
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
:for="inputId"
|
:for="inputId"
|
||||||
:class="mergedLabelClass"
|
:class="mergedLabelClass"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
@@ -50,7 +52,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
|
||||||
@@ -58,7 +60,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,6 +74,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -83,11 +87,14 @@ 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
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -97,11 +104,14 @@ 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,
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,10 +124,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 +142,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 cursor-pointer',
|
||||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
isReadonly.value ? '' : 'grow-height',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
|
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' : '',
|
props.displayIcon ? '!pr-10' : '',
|
||||||
'focus:pl-[11px]',
|
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||||
|
isReadonly.value ? 'cursor-default' : '',
|
||||||
|
disabled.value ? 'cursor-not-allowed' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -143,12 +164,17 @@ 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'
|
||||||
|
: 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',
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
@@ -168,7 +194,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const openFilePicker = () => {
|
const openFilePicker = () => {
|
||||||
if (props.disabled) return
|
if (props.disabled || props.readonly) return
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +211,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'
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 +8,32 @@
|
|||||||
: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'
|
||||||
|
: isReadonly
|
||||||
|
? 'border-black'
|
||||||
: isOpen
|
: isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-m-muted',
|
: 'border-m-muted',
|
||||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
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,
|
||||||
@@ -38,6 +42,8 @@
|
|||||||
:aria-controls="listboxId"
|
:aria-controls="listboxId"
|
||||||
: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"
|
||||||
>
|
>
|
||||||
@@ -50,6 +56,10 @@
|
|||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
|
: isReadonly
|
||||||
|
? isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
: isOpen
|
: isOpen
|
||||||
? 'text-m-primary'
|
? 'text-m-primary'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
@@ -59,7 +69,7 @@
|
|||||||
]"
|
]"
|
||||||
:style="labelTransformStyle"
|
:style="labelTransformStyle"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -73,13 +83,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">
|
||||||
@@ -145,7 +166,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 +175,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 }}
|
||||||
@@ -165,6 +187,7 @@
|
|||||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -185,8 +208,11 @@ const props = withDefaults(defineProps<{
|
|||||||
textLabel?: string
|
textLabel?: 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: '',
|
||||||
@@ -199,8 +225,11 @@ const props = withDefaults(defineProps<{
|
|||||||
textLabel: 'text-sm',
|
textLabel: 'text-sm',
|
||||||
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 +257,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 +287,7 @@ function updateOpenDirection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
updateOpenDirection()
|
updateOpenDirection()
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
|
|
||||||
@@ -300,7 +331,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
|
||||||
@@ -330,12 +361,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},
|
||||||
@@ -182,4 +197,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,28 +8,32 @@
|
|||||||
: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'
|
||||||
|
: isReadonly
|
||||||
|
? 'border-black'
|
||||||
: isOpen
|
: isOpen
|
||||||
? openDirection === 'down'
|
? openDirection === 'down'
|
||||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-m-muted',
|
: 'border-m-muted',
|
||||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
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,
|
||||||
@@ -38,6 +42,8 @@
|
|||||||
:aria-controls="listboxId"
|
:aria-controls="listboxId"
|
||||||
: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"
|
||||||
>
|
>
|
||||||
@@ -50,6 +56,10 @@
|
|||||||
? 'text-m-danger'
|
? 'text-m-danger'
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
? 'text-m-success'
|
? 'text-m-success'
|
||||||
|
: isReadonly
|
||||||
|
? isOptionSelected
|
||||||
|
? 'text-black'
|
||||||
|
: 'text-m-muted'
|
||||||
: isOpen
|
: isOpen
|
||||||
? 'text-m-primary'
|
? 'text-m-primary'
|
||||||
: isOptionSelected
|
: isOptionSelected
|
||||||
@@ -59,7 +69,7 @@
|
|||||||
]"
|
]"
|
||||||
:style="labelTransformStyle"
|
:style="labelTransformStyle"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -101,13 +111,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">
|
||||||
@@ -163,6 +184,7 @@
|
|||||||
group-class="!mt-0"
|
group-class="!mt-0"
|
||||||
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
:reserve-message-space="false"
|
||||||
@update:model-value="toggleAll"
|
@update:model-value="toggleAll"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -188,13 +210,14 @@
|
|||||||
group-class="!mt-0"
|
group-class="!mt-0"
|
||||||
label-class="option-checkbox w-full cursor-pointer"
|
label-class="option-checkbox w-full cursor-pointer"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
:reserve-message-space="false"
|
||||||
@update:model-value="toggleOption(opt.value)"
|
@update:model-value="toggleOption(opt.value)"
|
||||||
/>
|
/>
|
||||||
</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 +226,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 }}
|
||||||
@@ -215,6 +239,7 @@ import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
|||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
import Checkbox from '../checkbox/Checkbox.vue'
|
import Checkbox from '../checkbox/Checkbox.vue'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||||
|
|
||||||
@@ -223,7 +248,7 @@ type Option = {
|
|||||||
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 +263,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 +284,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<{
|
||||||
@@ -281,6 +313,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 +321,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 +353,7 @@ function updateOpenDirection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
|
if (props.disabled || props.readonly) return
|
||||||
updateOpenDirection()
|
updateOpenDirection()
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
|
|
||||||
@@ -363,7 +397,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
|
||||||
@@ -409,12 +443,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>
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type TimePickerProps = {
|
|||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps>
|
const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps>
|
||||||
@@ -52,6 +53,12 @@ describe('MalioTimePicker', () => {
|
|||||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
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 () => {
|
it('émet la valeur réglée depuis les molettes', async () => {
|
||||||
const wrapper = mountPicker({modelValue: '09:30'})
|
const wrapper = mountPicker({modelValue: '09:30'})
|
||||||
await wrapper.get('[data-test="time-field"]').trigger('click')
|
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||||
@@ -73,4 +80,64 @@ describe('MalioTimePicker', () => {
|
|||||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||||
expect(wrapper.text()).toContain('Heure requise')
|
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]')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
: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">
|
||||||
@@ -78,11 +78,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 }}
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
|
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
|
||||||
import {Icon} from '@iconify/vue'
|
import {Icon} from '@iconify/vue'
|
||||||
import {twMerge} from 'tailwind-merge'
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
import TimeWheels from './internal/TimeWheels.vue'
|
import TimeWheels from './internal/TimeWheels.vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
|
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
|
||||||
@@ -116,6 +118,7 @@ const props = withDefaults(
|
|||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
|
reserveMessageSpace?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
@@ -134,6 +137,7 @@ const props = withDefaults(
|
|||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
reserveMessageSpace: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,6 +157,7 @@ const hasError = computed(() => !!props.error)
|
|||||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||||
const displayValue = computed(() => currentValue.value ?? '')
|
const displayValue = computed(() => currentValue.value ?? '')
|
||||||
const isFilled = computed(() => displayValue.value.length > 0)
|
const isFilled = computed(() => displayValue.value.length > 0)
|
||||||
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
const wheelsValue = computed(() => currentValue.value || '00:00')
|
const wheelsValue = computed(() => currentValue.value || '00:00')
|
||||||
const showClear = computed(() =>
|
const showClear = computed(() =>
|
||||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||||
@@ -192,14 +197,16 @@ 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 border-m-muted text-black/60' : '',
|
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
|
||||||
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 !rounded-b-none !py-[9px]' : '',
|
(!isReadonly.value && isOpen.value) ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
|
||||||
props.inputClass,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,11 +214,13 @@ const mergedInputClass = computed(() =>
|
|||||||
const mergedLabelClass = computed(() =>
|
const mergedLabelClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
|
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
|
||||||
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
|
(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'
|
||||||
|
: isReadonly.value
|
||||||
|
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||||
: isOpen.value
|
: isOpen.value
|
||||||
? 'text-m-primary'
|
? 'text-m-primary'
|
||||||
: 'text-black peer-placeholder-shown:text-m-muted',
|
: 'text-black peer-placeholder-shown:text-m-muted',
|
||||||
@@ -222,6 +231,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'
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# État visuel `readonly` cohérent — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Donner aux champs `readonly` un état visuel distinct et cohérent : bordure noire même vide, aucun grossissement/bleu au focus, label gris→noir selon rempli, icône gris→noir selon rempli.
|
||||||
|
|
||||||
|
**Architecture:** Pas de composant partagé (les styles sont dupliqués par composant, on suit ce pattern). Dans chaque composant on rend conditionnelles 4 zones de classes selon `readonly`. Le champ reste focusable (sélection/copie du texte) mais sans visuel de focus.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 `<script setup>`, Tailwind `m-*`, `twMerge`, Vitest + @vue/test-utils.
|
||||||
|
|
||||||
|
**Branche:** `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (on continue dessus).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## La recette commune (appliquée quand `readonly === true`)
|
||||||
|
|
||||||
|
Priorité inchangée : `error` puis `success` puis `disabled` passent TOUJOURS avant `readonly`. La recette readonly ne s'applique que dans la branche « état normal ».
|
||||||
|
|
||||||
|
1. **Bordure** : forcer `border-black` (même vide). Ne PAS inclure `border-m-muted` ni `focus:border-m-primary` quand readonly.
|
||||||
|
2. **Grow + bleu** : ne PAS inclure la classe `grow-height` (donc pas de grossissement au focus) ni les classes `focus:*` (border, padding `focus:pl-*`/`focus:!pl-*`). Pour `InputTextArea` (pas de `grow-height`) : retirer `focus:border-m-primary` et le surlignage de focus `textarea-scrollbar-primary`.
|
||||||
|
3. **Label** : utiliser `isFilled ? 'text-black' : 'text-m-muted'` ; ne PAS inclure `peer-focus:text-m-primary` ni les combos `peer-placeholder-shown`/`peer-[&:not(:placeholder-shown):not(:focus)]`. De plus, en readonly, `shouldFloatLabel` (ou équivalent qui pilote le float) doit ignorer `isFocused` → float basé sur `isFilled` seul (un champ readonly vide garde son label gris au repos).
|
||||||
|
4. **Icône** : `isFilled ? 'text-black' : 'text-m-muted'` ; sauter la branche `isFocused → text-m-primary`. (`error`/`success`/`disabled` toujours prioritaires.)
|
||||||
|
5. **Interaction** : `readonly` bloque l'ouverture (Upload : `openFilePicker` no-op ; pickers : déjà bloqué). Le champ reste sélectionnable (ne pas retirer la focusabilité).
|
||||||
|
|
||||||
|
Implémentation conseillée : un petit computed `isReadonly = computed(() => props.readonly && !props.disabled)` (disabled prime), puis dans chaque `twMerge(...)` remplacer les fragments concernés par des expressions ternaires sur `props.readonly`. Garder le code lisible et homogène avec l'existant du fichier.
|
||||||
|
|
||||||
|
### Patron de test (adapter le sélecteur input/textarea et le helper de montage du fichier)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||||
|
const wrapper = mountX({label: 'Champ', readonly: true}) // pas de modelValue → vide
|
||||||
|
const field = wrapper.get('input') // ou 'textarea'
|
||||||
|
expect(field.classes()).toContain('border-black')
|
||||||
|
expect(field.classes()).not.toContain('border-m-muted')
|
||||||
|
expect(field.classes()).not.toContain('grow-height') // sauf InputTextArea (pas de grow-height) : asserter l'absence de 'focus:border-m-primary'
|
||||||
|
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readonly : label gris si vide, pas de bleu', () => {
|
||||||
|
const wrapper = mountX({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 rempli : label noir + icône noire', () => {
|
||||||
|
const wrapper = mountX({label: 'Champ', readonly: true, modelValue: '...valeur remplie...'})
|
||||||
|
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||||
|
// si le composant a une icône d'état :
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les composants à icône d'état, ajouter aussi : readonly + vide → icône `text-m-muted`. Adapter `modelValue` au type (montant, date ISO, etc.). Pour les pickers, la « valeur remplie » se passe via la prop d'affichage habituelle (voir tests voisins).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : `InputUpload` (ajout de la prop `readonly`)
|
||||||
|
|
||||||
|
`InputUpload` n'a PAS de prop `readonly` aujourd'hui (son `<input type="text">` est `:readonly="true"` en dur pour empêcher la saisie). On AJOUTE une vraie prop `readonly`.
|
||||||
|
|
||||||
|
**Files:** Modify `app/components/malio/input/InputUpload.vue` ; Test `app/components/malio/input/InputUpload.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 — tests d'abord** : ajouter le patron de test ci-dessus. Champ = `wrapper.get('input[type="text"]')`. Icône = `[data-test="icon"]` (le nuage). « rempli » = `modelValue: 'fichier.pdf'`.
|
||||||
|
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/input/InputUpload.test.ts`
|
||||||
|
- [ ] **Step 3 — ajouter la prop** : `readonly?: boolean` dans `defineProps` + `readonly: false` dans `withDefaults`.
|
||||||
|
- [ ] **Step 4 — appliquer la recette** dans `mergedInputClass`, `mergedLabelClass`, `iconStateClass` et `shouldFloatLabel` (float = `isFilled` quand readonly). Forcer `cursor-default` (au lieu de `cursor-pointer`) quand readonly.
|
||||||
|
- [ ] **Step 5 — bloquer l'ouverture** : dans `openFilePicker`, `if (props.disabled || props.readonly) return`.
|
||||||
|
- [ ] **Step 6 — run, PASS** : même commande. (Suite flaky connue : relancer le fichier si timeout non lié ; `--no-verify` si un timeout flaky bloque un commit déjà vérifié.)
|
||||||
|
- [ ] **Step 7 — commit**
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/input/InputUpload.vue app/components/malio/input/InputUpload.test.ts
|
||||||
|
git commit -m "feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)"
|
||||||
|
```
|
||||||
|
(corps + ligne vide + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Inputs floating-label standard (lot de 6)
|
||||||
|
|
||||||
|
`InputText`, `InputEmail`, `InputAmount`, `InputAutocomplete`, `InputPassword`, `InputTextArea` ont déjà une prop `readonly`. Appliquer la recette à chacun.
|
||||||
|
|
||||||
|
**Files:** Modify les 6 `.vue` (`app/components/malio/input/InputText.vue`, `InputEmail.vue`, `InputAmount.vue`, `InputAutocomplete.vue`, `InputPassword.vue`, `InputTextArea.vue`) ; Test les 6 `*.test.ts` correspondants (`Input.test.ts` pour InputText, puis `InputEmail/InputAmount/InputAutocomplete/InputPassword/InputTextArea.test.ts`).
|
||||||
|
|
||||||
|
Spécificités par fichier :
|
||||||
|
- **InputText / InputEmail / InputAmount** : structure identique (`mergedInputClass` avec `grow-height` + `focus:border-m-primary` + `focus:pl-[11px]` ; `mergedLabelClass` avec `peer-focus:text-m-primary` ; `iconStateClass` avec branche `isFocused`). Appliquer la recette 1-4.
|
||||||
|
- **InputAutocomplete** : idem ; il a deux usages de `iconStateClass` (icône gauche + chevron) — appliquer la recette à `iconStateClass`. `isFilled` y inclut `hasSelection`.
|
||||||
|
- **InputPassword** : recette 1-4. L'icône est le **toggle œil** (cliquable) : garder le `@click` de bascule ; seule la couleur suit la recette (pas de bleu). NE PAS rendre l'œil non-cliquable en readonly.
|
||||||
|
- **InputTextArea** : classes **inline** dans le template (pas de `grow-height`). Recette : `isFilled ? border-black : border-m-muted` → `readonly ? border-black : (isFilled ? border-black : border-m-muted)` ; retirer `focus:border-m-primary` et le `isFocused ? 'textarea-scrollbar-primary'` quand readonly ; label idem. Pas d'icône (recette 4 N/A).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — tests d'abord** : ajouter le patron à chacun des 6 fichiers test (champ = `input`, sauf TextArea = `textarea` ; pour TextArea ne pas asserter `grow-height`). Pour les composants à icône, asserter aussi l'icône (`[data-test="icon"]`). Adapter `modelValue` rempli au type (Amount : un montant valide).
|
||||||
|
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/input/Input.test.ts app/components/malio/input/InputEmail.test.ts app/components/malio/input/InputAmount.test.ts app/components/malio/input/InputAutocomplete.test.ts app/components/malio/input/InputPassword.test.ts app/components/malio/input/InputTextArea.test.ts`
|
||||||
|
- [ ] **Step 3 — appliquer la recette** aux 6 `.vue`.
|
||||||
|
- [ ] **Step 4 — run, PASS** : même commande (relancer un fichier si flaky).
|
||||||
|
- [ ] **Step 5 — commit**
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/input/InputText.vue app/components/malio/input/InputEmail.vue app/components/malio/input/InputAmount.vue app/components/malio/input/InputAutocomplete.vue app/components/malio/input/InputPassword.vue app/components/malio/input/InputTextArea.vue app/components/malio/input/Input.test.ts app/components/malio/input/InputEmail.test.ts app/components/malio/input/InputAmount.test.ts app/components/malio/input/InputAutocomplete.test.ts app/components/malio/input/InputPassword.test.ts app/components/malio/input/InputTextArea.test.ts
|
||||||
|
git commit -m "feat(ui) : état readonly visuel sur les inputs floating-label"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : `InputPhone` (découpler readonly de disabled)
|
||||||
|
|
||||||
|
Aujourd'hui `InputPhone` traite `disabled || readonly` ensemble (bouton « add » + `opacity-40`, look désactivé). On découple : readonly applique la recette readonly (bordure noire, pas de look disabled), tout en restant non-éditable. L'action « add » reste bloquée en readonly mais le **champ** ne doit plus avoir l'apparence désactivée.
|
||||||
|
|
||||||
|
**Files:** Modify `app/components/malio/input/InputPhone.vue` ; Test `app/components/malio/input/InputPhone.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 — tests d'abord** : patron readonly (champ = `input`, icône = `[data-test="icon"]`). Ajouter aussi une assertion que le champ readonly n'a PAS `opacity-40` (plus de look disabled). `modelValue` rempli = un numéro.
|
||||||
|
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/input/InputPhone.test.ts`
|
||||||
|
- [ ] **Step 3 — appliquer la recette** à `mergedInputClass`/`mergedLabelClass`/`iconStateClass` (recette 1-4). Pour le bouton « add » (`mergedAddButtonClass`) : garder l'action bloquée en readonly (`onAdd` retourne déjà), mais retirer l'apparence `opacity-40 cursor-not-allowed` spécifique au readonly — la garder uniquement pour `disabled`. (En readonly, le bouton add suit la couleur d'icône readonly.)
|
||||||
|
- [ ] **Step 4 — run, PASS** (relancer si flaky).
|
||||||
|
- [ ] **Step 5 — commit**
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/input/InputPhone.vue app/components/malio/input/InputPhone.test.ts
|
||||||
|
git commit -m "feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Pickers `CalendarField` (date family) + `TimePicker`
|
||||||
|
|
||||||
|
`CalendarField` (rend Date/DateTime/DateRange/DateWeek) et `TimePicker` ont déjà une prop `readonly` qui bloque l'ouverture du popover. Appliquer la recette visuelle. Leur input interne est déjà `readonly` natif ; le float du label suit `isFilled || isOpen` — en readonly, `isOpen` reste faux (ouverture bloquée), donc float = `isFilled`. Forcer bordure noire, label gris→noir, icône gris→noir sans branche focus/open.
|
||||||
|
|
||||||
|
**Files:** Modify `app/components/malio/date/internal/CalendarField.vue`, `app/components/malio/time/TimePicker.vue` ; Test `app/components/malio/date/Date.test.ts` (couvre CalendarField) et `app/components/malio/time/TimePicker.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 — tests d'abord** : patron readonly. Pour `Date.test.ts`, monter `mountDate({label, readonly: true})` et une variante remplie (passer une valeur de date ISO comme les tests voisins). Champ = l'input du composant (voir sélecteur utilisé par les tests voisins). Pour `TimePicker.test.ts`, utiliser le helper du fichier.
|
||||||
|
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/date/Date.test.ts app/components/malio/time/TimePicker.test.ts`
|
||||||
|
- [ ] **Step 3 — appliquer la recette** à `CalendarField.vue` et `TimePicker.vue` (`mergedInputClass`/`mergedLabelClass`/`iconStateClass` ; float = `isFilled` en readonly). Vérifier que la croix « clear » reste masquée en readonly (déjà le cas — ne pas régresser).
|
||||||
|
- [ ] **Step 4 — run, PASS** (relancer si flaky).
|
||||||
|
- [ ] **Step 5 — commit**
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/date/internal/CalendarField.vue app/components/malio/time/TimePicker.vue app/components/malio/date/Date.test.ts app/components/malio/time/TimePicker.test.ts
|
||||||
|
git commit -m "feat(ui) : état readonly visuel sur pickers date/heure"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Playground + vérification finale
|
||||||
|
|
||||||
|
**Files:** Modify les pages playground concernées sous `.playground/pages/composant/...`
|
||||||
|
|
||||||
|
- [ ] **Step 1 — exemples readonly** : ajouter sur chaque page concernée (inputText, inputEmail, inputAmount, inputAutocomplete, inputPassword, inputTextArea, inputPhone, **inputUpload** [manquant], date, timePicker) un exemple readonly : une instance vide (`:readonly="true"`) ET une instance remplie readonly, pour visualiser bordure noire vide + label/icône noir rempli. Suivre le pattern de chaque page ; si une page rend l'ajout coûteux, le signaler et passer (mais inputUpload est demandé explicitement, le faire).
|
||||||
|
- [ ] **Step 2 — lint** : `npm run lint` → 0 erreur (baseline 24 warnings préexistants).
|
||||||
|
- [ ] **Step 3 — suite complète** : `npm run test` → tout vert (relancer un fichier en cas de timeout flaky).
|
||||||
|
- [ ] **Step 4 — commit**
|
||||||
|
```bash
|
||||||
|
git add .playground
|
||||||
|
git commit -m "docs(playground) : exemples readonly"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Récapitulatif commits attendus
|
||||||
|
1. `feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)`
|
||||||
|
2. `feat(ui) : état readonly visuel sur les inputs floating-label`
|
||||||
|
3. `feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)`
|
||||||
|
4. `feat(ui) : état readonly visuel sur pickers date/heure`
|
||||||
|
5. `docs(playground) : exemples readonly`
|
||||||
|
|
||||||
|
Note convention : le hook commit-msg malio impose un espace avant `:`.
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
# État « obligatoire » cohérent + normalisation email — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Exposer une prop `required` cohérente avec astérisque rouge dans le label sur toute la famille formulaire, et ajouter une sanitisation à la saisie (suppression des espaces + option `lowercase`) à `MalioInputEmail`.
|
||||||
|
|
||||||
|
**Architecture :** Un composant présentational partagé `MalioRequiredMark` (astérisque `aria-hidden`, token `text-m-danger`) est importé explicitement et rendu dans le `<label>` de chaque composant quand `required` est vrai. Les 4 composants sans la prop la reçoivent (+ câblage `aria-required` là où il n'y a pas de `required` natif). `MalioInputEmail.onInput` sanitise la valeur avant émission.
|
||||||
|
|
||||||
|
**Tech Stack :** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Tailwind (palette `m-*`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||||
|
|
||||||
|
**Spec :** `docs/superpowers/specs/2026-06-03-required-asterisk-email-sanitization-design.md`
|
||||||
|
|
||||||
|
**Conventions de test (rappel) :** chaque fichier `*.test.ts` définit son propre helper de montage (nom variable : `mountInput`, `mountDate`, `mountCheckbox`, `mountTime`, `mountComponent`…) ou monte en inline. Le tableau de chaque tâche indique le helper exact à réutiliser.
|
||||||
|
|
||||||
|
**⚠️ Suite flaky :** des timeouts intermittents existent sur diverses suites. Si un test échoue par timeout sans rapport avec le changement, relancer le fichier ciblé ; ne pas conclure à un échec sans relance. Le hook pre-commit lance les tests — si un timeout flaky bloque un commit déjà vérifié manuellement, utiliser `git commit --no-verify`.
|
||||||
|
|
||||||
|
**Branche :** `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (rester dessus, ne pas créer de branche).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Composant partagé `MalioRequiredMark`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/malio/shared/RequiredMark.vue`
|
||||||
|
- Test: `app/components/malio/shared/RequiredMark.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
Create `app/components/malio/shared/RequiredMark.test.ts` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts`
|
||||||
|
Expected: FAIL — `Failed to resolve import './RequiredMark.vue'` (le composant n'existe pas encore).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Créer le composant**
|
||||||
|
|
||||||
|
Create `app/components/malio/shared/RequiredMark.vue` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
data-test="required-mark"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="ml-0.5 select-none text-m-danger"
|
||||||
|
>*</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer le test, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/shared/RequiredMark.vue app/components/malio/shared/RequiredMark.test.ts
|
||||||
|
git commit -m "feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Prop `required` + a11y + astérisque sur les 4 composants sans la prop
|
||||||
|
|
||||||
|
Composants : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`. Chacun reçoit la prop `required`, le câblage a11y adapté, l'import + le rendu de l'astérisque, et un test.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/select/Select.vue`, `app/components/malio/select/SelectCheckbox.vue`, `app/components/malio/input/InputUpload.vue`, `app/components/malio/input/InputRichText.vue`
|
||||||
|
- Test: `app/components/malio/select/Select.test.ts`, `app/components/malio/select/SelectCheckbox.test.ts`, `app/components/malio/input/InputUpload.test.ts`, `app/components/malio/input/InputRichText.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests qui échouent (un par composant)**
|
||||||
|
|
||||||
|
Patron d'assertion (à adapter au helper de chaque fichier) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('affiche l’astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = /* monter avec { label: 'Champ', required: true, ...props requises } */
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n’affiche pas l’astérisque par défaut', () => {
|
||||||
|
const wrapper = /* monter avec { label: 'Champ', ...props requises } */
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Montage par fichier :
|
||||||
|
|
||||||
|
| Fichier test | Montage |
|
||||||
|
|---|---|
|
||||||
|
| `select/Select.test.ts` | inline : `mount(SelectForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` (et sans `required` pour le 2ᵉ test) |
|
||||||
|
| `select/SelectCheckbox.test.ts` | inline : `mount(SelectCheckboxForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` |
|
||||||
|
| `input/InputUpload.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` |
|
||||||
|
| `input/InputRichText.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` |
|
||||||
|
|
||||||
|
> Note : pour `Select`/`SelectCheckbox`, reprendre la forme exacte des `options` et les `global.stubs` déjà utilisés dans les autres `it()` du fichier (copier un montage voisin).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts`
|
||||||
|
Expected: FAIL sur les nouveaux tests « affiche l’astérisque » (la prop/le rendu n'existent pas encore).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter la prop `required` (type + défaut) dans les 4 composants**
|
||||||
|
|
||||||
|
Dans chaque `defineProps<{…}>()`, ajouter la ligne :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
required?: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans chaque `withDefaults(…, { … })`, ajouter :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
required: false,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Câbler l'accessibilité (un élément interactif par composant)**
|
||||||
|
|
||||||
|
`Select.vue` — sur le `<button>` déclencheur (là où sont déjà `:aria-expanded`, `:aria-controls`), ajouter :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:aria-required="required || undefined"
|
||||||
|
```
|
||||||
|
|
||||||
|
`SelectCheckbox.vue` — idem, sur son `<button>` déclencheur :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:aria-required="required || undefined"
|
||||||
|
```
|
||||||
|
|
||||||
|
`InputUpload.vue` — sur l'`<input type="file">`, ajouter l'attribut natif :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:required="required"
|
||||||
|
```
|
||||||
|
|
||||||
|
`InputRichText.vue` — sur le wrapper éditeur identifié par `:id="editorId"` (le conteneur de `<EditorContent>` en mode éditable), ajouter :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:aria-required="required || undefined"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Importer et rendre l'astérisque dans les 4 composants**
|
||||||
|
|
||||||
|
Dans le `<script setup>` de chacun, ajouter l'import (chemin relatif depuis `family/Component.vue`) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans le `<template>`, remplacer le rendu du libellé `{{ label }}` (celui à l'intérieur du `<label>` du champ — **pas** un `{{ opt.label }}`) par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
|
```
|
||||||
|
|
||||||
|
> Respecter l'indentation existante de chaque fichier. Pour `Select`/`SelectCheckbox`, viser le `{{ label }}` du `<label>` flottant, pas le `{{ opt.label }}` des options.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Lancer les tests, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts`
|
||||||
|
Expected: PASS (anciens + nouveaux tests). En cas de timeout flaky non lié, relancer le fichier concerné.
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/select/Select.vue app/components/malio/select/SelectCheckbox.vue app/components/malio/input/InputUpload.vue app/components/malio/input/InputRichText.vue app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts
|
||||||
|
git commit -m "feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Astérisque sur les composants ayant déjà `required`
|
||||||
|
|
||||||
|
Ces composants ont déjà la prop `required` (câblée nativement). On ajoute uniquement l'import + le rendu de l'astérisque + un test.
|
||||||
|
|
||||||
|
**Files (16 composants → 13 via CalendarField mutualisé) :**
|
||||||
|
|
||||||
|
| Composant `.vue` | Import à ajouter | Fichier test | Helper de montage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `input/InputText.vue` | `'../shared/RequiredMark.vue'` | `input/Input.test.ts` | `mountInput({label:'Champ', required:true})` |
|
||||||
|
| `input/InputEmail.vue` | `'../shared/RequiredMark.vue'` | `input/InputEmail.test.ts` | `mountComponent({label:'Champ', required:true})` |
|
||||||
|
| `input/InputPhone.vue` | `'../shared/RequiredMark.vue'` | `input/InputPhone.test.ts` | `mountComponent({label:'Champ', required:true})` |
|
||||||
|
| `input/InputPassword.vue` | `'../shared/RequiredMark.vue'` | `input/InputPassword.test.ts` | `mountComponent({label:'Champ', required:true})` |
|
||||||
|
| `input/InputTextArea.vue` | `'../shared/RequiredMark.vue'` | `input/InputTextArea.test.ts` | helper du fichier (`mount<…>` ; copier un montage voisin) |
|
||||||
|
| `input/InputAmount.vue` | `'../shared/RequiredMark.vue'` | `input/InputAmount.test.ts` | helper du fichier |
|
||||||
|
| `input/InputNumber.vue` | `'../shared/RequiredMark.vue'` | `input/InputNumber.test.ts` | helper du fichier |
|
||||||
|
| `input/InputAutocomplete.vue` | `'../shared/RequiredMark.vue'` | `input/InputAutocomplete.test.ts` | `mountComponent({label:'Champ', required:true, …props requises})` |
|
||||||
|
| `checkbox/Checkbox.vue` | `'../shared/RequiredMark.vue'` | `checkbox/Checkbox.test.ts` | `mountCheckbox({label:'Champ', required:true})` |
|
||||||
|
| `radio/RadioButton.vue` | `'../shared/RequiredMark.vue'` | `radio/RadioButton.test.ts` | helper du fichier |
|
||||||
|
| `time/Time.vue` | `'../shared/RequiredMark.vue'` | `time/Time.test.ts` | `mountTime({label:'Champ', required:true})` |
|
||||||
|
| `time/TimePicker.vue` | `'../shared/RequiredMark.vue'` | `time/TimePicker.test.ts` | helper du fichier |
|
||||||
|
| `date/internal/CalendarField.vue` | `'../../shared/RequiredMark.vue'` | `date/Date.test.ts` | `mountDate({label:'Champ', required:true})` |
|
||||||
|
|
||||||
|
> `CalendarField` rend le label de tout le date family (`Date`, `DateTime`, `DateRange`, `DateWeek`). Une seule modif + un seul test (via `Date.test.ts`) couvrent les quatre.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests qui échouent (un couple par fichier test du tableau)**
|
||||||
|
|
||||||
|
Pour chaque fichier test listé, ajouter :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('affiche l’astérisque quand required est vrai', () => {
|
||||||
|
const wrapper = /* helper du tableau, avec required: true */
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n’affiche pas l’astérisque par défaut', () => {
|
||||||
|
const wrapper = /* helper du tableau, sans required */
|
||||||
|
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts`
|
||||||
|
Expected: FAIL sur les nouveaux tests « affiche l’astérisque ».
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter l'import + le rendu de l'astérisque dans les 13 `.vue`**
|
||||||
|
|
||||||
|
Dans chaque `<script setup>`, ajouter l'import indiqué dans la colonne « Import à ajouter ».
|
||||||
|
|
||||||
|
Dans chaque `<template>`, transformer le libellé du champ :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
|
```
|
||||||
|
|
||||||
|
(Le `{{ label }}` est à l'intérieur du `<label v-if="label">` du champ. Respecter l'indentation propre à chaque fichier.)
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts`
|
||||||
|
Expected: PASS. (Vérifier notamment que `input/InputEmail.test.ts` « renders the label text » → `'Adresse email'` passe toujours : pas de `required` dans ce test, donc pas d'astérisque.) Relancer en cas de timeout flaky.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/internal/CalendarField.vue app/components/malio/date/Date.test.ts
|
||||||
|
git commit -m "feat(ui): astérisque required dans le label de la famille formulaire"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Sanitisation de `MalioInputEmail`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/malio/input/InputEmail.vue`
|
||||||
|
- Test: `app/components/malio/input/InputEmail.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||||
|
|
||||||
|
Ajouter à `input/InputEmail.test.ts` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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'])
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ajouter `lowercase?: boolean` au type `InputEmailProps` en tête du fichier de test (sinon TS refuse la prop dans le 3ᵉ test).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/input/InputEmail.test.ts`
|
||||||
|
Expected: FAIL — les espaces ne sont pas supprimés / `lowercase` inconnu.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter la prop `lowercase`**
|
||||||
|
|
||||||
|
Dans `defineProps<{…}>()` de `InputEmail.vue`, ajouter :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
lowercase?: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `withDefaults(…, { … })`, ajouter :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
lowercase: false,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Ajouter la fonction de sanitisation et réécrire `onInput`**
|
||||||
|
|
||||||
|
Ajouter la fonction pure (au-dessus de `onInput`) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sanitizeEmail = (v: string) => {
|
||||||
|
let out = v.replace(/\s+/g, '')
|
||||||
|
if (props.lowercase) out = out.toLowerCase()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacer le `onInput` existant par :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const raw = target.value
|
||||||
|
const sanitized = sanitizeEmail(raw)
|
||||||
|
|
||||||
|
if (sanitized !== raw) {
|
||||||
|
// `<input type="email">` ne supporte pas l'API de sélection :
|
||||||
|
// selectionStart vaut null, setSelectionRange lève. On garde defensivement.
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = sanitized
|
||||||
|
}
|
||||||
|
emit('update:modelValue', sanitized)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer les tests, vérifier le succès**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/input/InputEmail.test.ts`
|
||||||
|
Expected: PASS (anciens tests inclus, dont « emits update:modelValue on input change » avec `'new@example.com'` qui n'a pas d'espace → inchangé).
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/malio/input/InputEmail.vue app/components/malio/input/InputEmail.test.ts
|
||||||
|
git commit -m "feat(inputs): sanitisation email (suppression des espaces + option lowercase)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Documentation (`COMPONENTS.md` + `CHANGELOG.md`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `COMPONENTS.md`, `CHANGELOG.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : `COMPONENTS.md` — lignes `required` manquantes**
|
||||||
|
|
||||||
|
Pour les sections `MalioSelect`, `MalioSelectCheckbox`, `MalioInputUpload`, `MalioInputRichText`, ajouter dans le tableau des props la ligne (au même format que les autres composants) :
|
||||||
|
|
||||||
|
```
|
||||||
|
| `required` | `boolean` | `false` | Champ requis (affiche un astérisque rouge dans le label) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : `COMPONENTS.md` — note astérisque + prop `lowercase`**
|
||||||
|
|
||||||
|
- Dans l'introduction de la famille formulaire (ou la section des props communes), ajouter une phrase : « Lorsque `required` est vrai, un astérisque rouge est ajouté dans le label (visuel ; la sémantique est portée par l'attribut `required`/`aria-required`). »
|
||||||
|
- Dans la section `MalioInputEmail`, ajouter la ligne de prop :
|
||||||
|
|
||||||
|
```
|
||||||
|
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
|
||||||
|
```
|
||||||
|
|
||||||
|
et préciser que les espaces sont supprimés automatiquement à la saisie (pas de masque ; la validation de format reste à la couche `error`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : `CHANGELOG.md` — entrées**
|
||||||
|
|
||||||
|
Sous le `### Added` de la version en cours (format `* [#…] …`), ajouter :
|
||||||
|
|
||||||
|
```
|
||||||
|
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire
|
||||||
|
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add COMPONENTS.md CHANGELOG.md
|
||||||
|
git commit -m "docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Exemples playground + vérification finale
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: page(s) playground des composants concernés (selon `.playground/` ; cf. mémoire « Architecture playground »)
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Ajouter des exemples légers**
|
||||||
|
|
||||||
|
Sur la page playground d'un composant représentatif (ex. `InputText`/`Select`), ajouter une instance `:required="true"`. Sur la page `InputEmail`, ajouter une instance `:lowercase="true"`. Si le coût d'intégration dépasse quelques minutes (routage/nav à câbler), le **noter** et passer — c'est hors scope strict du ticket.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 0 erreur. Corriger le cas échéant.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Suite de tests complète des fichiers touchés**
|
||||||
|
|
||||||
|
Run: `npm run test -- app/components/malio/shared app/components/malio/input app/components/malio/select app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date`
|
||||||
|
Expected: PASS. En cas de timeout flaky, relancer le(s) fichier(s) concerné(s) individuellement.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit (si exemples playground ajoutés)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .playground
|
||||||
|
git commit -m "docs(playground): exemples required + email lowercase"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Récapitulatif des commits attendus
|
||||||
|
|
||||||
|
1. `feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)`
|
||||||
|
2. `feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText`
|
||||||
|
3. `feat(ui): astérisque required dans le label de la famille formulaire`
|
||||||
|
4. `feat(inputs): sanitisation email (suppression des espaces + option lowercase)`
|
||||||
|
5. `docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)`
|
||||||
|
6. `docs(playground): exemples required + email lowercase` (optionnel)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Design — État « obligatoire » cohérent + normalisation email
|
||||||
|
|
||||||
|
- **Date** : 2026-06-03
|
||||||
|
- **Ticket Malio UI** : MUI-41 (branche `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co`)
|
||||||
|
- **Ticket Starseed lié** : ERP-101 (MAJ Malio UI + branchement `required` + stratégie de validation), découvert pendant ERP-63 (écran « Ajouter un client »)
|
||||||
|
|
||||||
|
## Contexte & problème
|
||||||
|
|
||||||
|
Pendant ERP-63, deux manques ont bloqué la mise en place de champs obligatoires :
|
||||||
|
|
||||||
|
1. Certains composants de formulaire n'exposent pas de prop `required` (`MalioSelect`, `MalioSelectCheckbox`), et **aucun composant n'affiche d'indicateur visuel** de champ obligatoire. Résultat : le bouton « Valider » se bloque sans feedback à l'utilisateur — anti-pattern UX.
|
||||||
|
2. Tentation erronée de « masquer » l'email à la maska. Un email n'a **pas** de structure fixe : pas de masque. Le bon comportement est une **sanitisation** légère à la saisie + validation déléguée à la couche `error`.
|
||||||
|
|
||||||
|
État réel constaté (inventaire) : la **majorité** des composants ont déjà la prop `required` (câblée sur l'attribut HTML natif uniquement, sans astérisque). Seuls **5** ne l'ont pas : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`, `SiteSelector`. Aucun composant n'affiche d'astérisque. Il n'existe pas de composant de label partagé : chaque composant rend `{{ label }}` dans son propre `<label>` au style spécifique (floating labels).
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
- Prop `required: boolean` cohérente sur **toute la famille formulaire**.
|
||||||
|
- Quand `required` est vrai → **astérisque rouge dans le label**.
|
||||||
|
- `MalioInputEmail` : sanitisation à la saisie (suppression de tous les espaces, option `lowercase`), **sans** masque ni validation de format.
|
||||||
|
- Mettre à jour `COMPONENTS.md` et `CHANGELOG.md`.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Validation de format email (reste à la charge de la couche validation via la prop `error`, alimentée serveur ou check client).
|
||||||
|
- Toute logique de masque sur l'email.
|
||||||
|
- Refonte des suites de tests existantes.
|
||||||
|
|
||||||
|
## Décisions de cadrage (validées avec l'utilisateur)
|
||||||
|
|
||||||
|
| Décision | Choix retenu |
|
||||||
|
|---|---|
|
||||||
|
| Périmètre `required` + astérisque | **Toute la famille formulaire**, y compris `InputUpload`, `InputRichText`, `SiteSelector` |
|
||||||
|
| Prop `lowercase` (email) | **Opt-in, défaut `false`** |
|
||||||
|
| Espaces email | **Supprimer tous les espaces** (début, milieu, fin) ; préservation du curseur *best-effort* (voir caveat ci-dessous) |
|
||||||
|
| Accessibilité astérisque | `aria-hidden="true"` — la sémantique est portée par l'attribut HTML natif `required` |
|
||||||
|
|
||||||
|
## Section 1 — Indicateur « obligatoire »
|
||||||
|
|
||||||
|
### Composant partagé `MalioRequiredMark`
|
||||||
|
|
||||||
|
Nouveau composant `app/components/malio/shared/RequiredMark.vue` (auto-importé `<MalioRequiredMark>`). Source unique de vérité pour couleur/espacement.
|
||||||
|
|
||||||
|
Rendu :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<span aria-hidden="true" class="ml-0.5 select-none text-m-danger">*</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `aria-hidden="true"` : évite la double annonce, la sémantique est déjà sur l'attribut natif `required`.
|
||||||
|
- Couleur via token existant `text-m-danger` (`--m-danger`, rouge `#F2696B`).
|
||||||
|
- `defineOptions({ name: 'MalioRequiredMark', inheritAttrs: false })`.
|
||||||
|
|
||||||
|
### Intégration
|
||||||
|
|
||||||
|
Dans chaque composant de la famille, remplacer `{{ label }}` par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
{{ label }}<MalioRequiredMark v-if="required" />
|
||||||
|
```
|
||||||
|
|
||||||
|
L'astérisque vit **à l'intérieur du `<label>`** → il flotte avec le floating-label et reste dans la pastille blanche.
|
||||||
|
|
||||||
|
### Props à ajouter
|
||||||
|
|
||||||
|
`required?: boolean` (défaut `false`) sur les **4** composants qui ne l'ont pas et qui possèdent un label de champ : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`.
|
||||||
|
|
||||||
|
### Câblage accessibilité (a11y)
|
||||||
|
|
||||||
|
L'astérisque est `aria-hidden` : la sémantique « obligatoire » doit donc être portée par le DOM.
|
||||||
|
|
||||||
|
- **Élément natif `required` déjà câblé** (asterisque suffit) : `InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `Checkbox`, `RadioButton`, `Time`, `TimePicker`, et `CalendarField` (date family).
|
||||||
|
- **Pas de `required` natif** → ajouter `:aria-required="required || undefined"` sur l'élément interactif :
|
||||||
|
- `Select` / `SelectCheckbox` : le `<button>` déclencheur (combobox).
|
||||||
|
- `InputRichText` : le wrapper éditeur (`#editorId`, contenteditable via TipTap).
|
||||||
|
- `InputUpload` : possède un `<input type="file">` natif → on câble `:required="required"` dessus (natif).
|
||||||
|
|
||||||
|
### Composants concernés par le rendu de l'astérisque
|
||||||
|
|
||||||
|
`InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `InputUpload`, `InputRichText`, `Select`, `SelectCheckbox`, `Checkbox`, `RadioButton`, `Time`, `TimePicker`, et `CalendarField` (rendu mutualisé pour `Date`, `DateTime`, `DateRange`, `DateWeek`).
|
||||||
|
|
||||||
|
### Exclusion : `SiteSelector`
|
||||||
|
|
||||||
|
`MalioSiteSelector` est un **radiogroup de tuiles** (segmented control) : il n'a **pas de label de champ** (son `labelClass` style le nom de chaque tuile). Y placer un astérisque n'a pas de sens. Il est **exclu** du périmètre `required`/astérisque. À rouvrir si un besoin de « groupe obligatoire » émerge (ce serait alors un libellé de groupe distinct, hors de ce ticket).
|
||||||
|
|
||||||
|
### Alternative écartée
|
||||||
|
|
||||||
|
Inliner un `<span>` dans chaque composant : duplication, couleur/espacement à changer à ~20 endroits. Le composant partagé est préféré.
|
||||||
|
|
||||||
|
## Section 2 — Sanitisation `MalioInputEmail`
|
||||||
|
|
||||||
|
### Nouvelle prop
|
||||||
|
|
||||||
|
`lowercase?: boolean` (défaut `false`).
|
||||||
|
|
||||||
|
### Fonction de sanitisation (pure, testable)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sanitizeEmail = (v: string) => {
|
||||||
|
let out = v.replace(/\s+/g, '') // supprime TOUT espace
|
||||||
|
if (props.lowercase) out = out.toLowerCase()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `onInput` réécrit
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const raw = target.value
|
||||||
|
const sanitized = sanitizeEmail(raw)
|
||||||
|
|
||||||
|
if (sanitized !== raw) {
|
||||||
|
// `<input type="email">` ne supporte PAS l'API de sélection :
|
||||||
|
// selectionStart vaut null, setSelectionRange lève une exception.
|
||||||
|
// On garde donc la repositionnement défensif (no-op sur type=email).
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isControlled.value) localValue.value = sanitized
|
||||||
|
emit('update:modelValue', sanitized)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Points clés :
|
||||||
|
|
||||||
|
- **Resynchro DOM** : `target.value = sanitized` même en mode contrôlé, pour que l'affichage colle toujours à la valeur émise.
|
||||||
|
- **Caveat curseur** : la spec HTML interdit l'API de sélection sur `type="email"` (`selectionStart` = `null`, `setSelectionRange` lève). La repositionnement est donc **best-effort** et inactif sur l'email : sur le cas rare d'une suppression d'espace en milieu de chaîne, le curseur peut aller en fin. Les cas courants (espace en fin, collage) gardent naturellement le curseur en fin. Le code est gardé (`caret !== null` + `try/catch`) pour ne jamais lever.
|
||||||
|
- **Collage** couvert (paste déclenche `input`).
|
||||||
|
- **Inchangé** : `type="email"`, `inputmode="email"`, icône, et **aucune validation de format**.
|
||||||
|
|
||||||
|
## Section 3 — Tests, docs & livraison
|
||||||
|
|
||||||
|
### Tests (colocalisés `*.test.ts`)
|
||||||
|
|
||||||
|
- `RequiredMark.test.ts` — rend `*`, `aria-hidden="true"`, classe `text-m-danger`.
|
||||||
|
- 1 test ciblé par composant équipé : `required: true` → astérisque présent dans le label ; défaut → absent. S'appuie sur le helper `mountComponent` existant de chaque fichier.
|
||||||
|
- `InputEmail.test.ts` — espaces (début/milieu/fin) supprimés ; `lowercase=false` préserve la casse ; `lowercase=true` minuscule ; valeur émise sanitisée ; valeur DOM resynchronisée. Le curseur n'est pas testé (peu fiable en jsdom) → on teste la valeur.
|
||||||
|
|
||||||
|
⚠️ Suite de tests **flaky** connue (timeouts intermittents). Lancer les tests des fichiers touchés ; en cas de timeout non lié aux changements, relancer / documenter plutôt que conclure à un échec.
|
||||||
|
|
||||||
|
### Documentation (manuelle, requise par convention)
|
||||||
|
|
||||||
|
- `COMPONENTS.md` : ajouter la ligne `required` aux 5 composants manquants ; ajouter `lowercase` à `MalioInputEmail` ; mentionner en intro famille formulaire que `required` affiche un astérisque rouge.
|
||||||
|
- `CHANGELOG.md` : entrée(s) `MUI-41` sous `### Added`, format existant (`* [#MUI-41] ...`).
|
||||||
|
|
||||||
|
### Playground / Histoire
|
||||||
|
|
||||||
|
Ajouter un exemple `required` + un exemple email `lowercase` sur les pages playground concernées si coût faible ; sinon signaler (hors scope strict).
|
||||||
|
|
||||||
|
### Découpage de livraison (1 PR, commits Conventional)
|
||||||
|
|
||||||
|
1. `feat(ui): MalioRequiredMark + prop required sur Select/SelectCheckbox/Upload/RichText/SiteSelector`
|
||||||
|
2. `feat(ui): astérisque required dans le label de la famille formulaire`
|
||||||
|
3. `feat(inputs): sanitisation email (suppression espaces + option lowercase)`
|
||||||
|
4. `docs: COMPONENTS.md + CHANGELOG`
|
||||||
|
|
||||||
|
Branche : `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (inchangée).
|
||||||
@@ -17,6 +17,9 @@ export default {
|
|||||||
borderRadius: {
|
borderRadius: {
|
||||||
malio: 'var(--m-radius)',
|
malio: 'var(--m-radius)',
|
||||||
},
|
},
|
||||||
|
width: {
|
||||||
|
'm-btn-action': 'var(--m-btn-action-width)',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
m: {
|
m: {
|
||||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||||
|
|||||||
Reference in New Issue
Block a user