Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f772a84ed | |||
| 1131420960 | |||
| 2a818a0c77 | |||
| 59230bbc7e | |||
| 49a5dc5252 | |||
| 9ff3e83c03 | |||
| b55050b2ad | |||
| 1d66e5dd31 |
@@ -13,6 +13,15 @@
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -50,6 +59,25 @@
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +90,9 @@ const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const readonlyFilledDate = ref<string | null>('2026-06-15')
|
||||
const value = ref<string | null>(null)
|
||||
const erpValue = ref<string | null>(null)
|
||||
const bounded = ref<string | null>(null)
|
||||
const editableValue = ref<string | null>(null)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">Champs en lecture seule (readonly)</h1>
|
||||
<p class="text-sm text-m-muted">
|
||||
Tous les champs de formulaire dans leur état <code>readonly</code>, vides puis remplis.
|
||||
Règles : bordure noire même vide, label et icône gris quand vide → noir quand rempli,
|
||||
pas de focus bleu ni de grossissement.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputText
|
||||
label="Référence (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputText
|
||||
model-value="Commande #A-2048"
|
||||
label="Référence (rempli)"
|
||||
icon-name="mdi:lock-outline"
|
||||
icon-size="20"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputEmail</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputEmail
|
||||
label="Adresse email (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
model-value="contact@malio.fr"
|
||||
label="Adresse email (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputAmount
|
||||
label="Montant (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
model-value="1250.00"
|
||||
label="Montant (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputAutocomplete
|
||||
label="Pays (vide)"
|
||||
:options="countryOptions"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputAutocomplete
|
||||
model-value="de"
|
||||
label="Pays (rempli)"
|
||||
icon-name="mdi:lock-outline"
|
||||
icon-position="left"
|
||||
:options="countryOptions"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputPassword</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputPassword
|
||||
label="Mot de passe (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputPassword
|
||||
model-value="motdepasse123"
|
||||
label="Mot de passe (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputTextArea
|
||||
label="Description (vide)"
|
||||
:size="3"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
model-value="Ce texte est en lecture seule et ne peut pas être modifié."
|
||||
label="Description (rempli)"
|
||||
:size="3"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputPhone</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputPhone
|
||||
label="Téléphone (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
label="Téléphone (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioInputUpload</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioInputUpload
|
||||
label="Fichier (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioInputUpload
|
||||
model-value="document.pdf"
|
||||
label="Fichier (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioSelect</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioSelect
|
||||
label="Catégorie (readonly vide)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="'a'"
|
||||
label="Catégorie (readonly rempli)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune selection"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioSelectCheckbox
|
||||
label="Catégories (readonly vide)"
|
||||
:options="categoryOptions"
|
||||
:display-tag="true"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="['a']"
|
||||
label="Catégories (readonly rempli)"
|
||||
:options="categoryOptions"
|
||||
empty-option-label="Aucune selection"
|
||||
:display-tag="true"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDate</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDate
|
||||
label="Date de naissance (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDate
|
||||
model-value="2026-06-15"
|
||||
label="Date de naissance (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateTime
|
||||
label="Date et heure (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDateTime
|
||||
model-value="2026-12-25T09:30:00"
|
||||
label="Date et heure (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateRange
|
||||
label="Période (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDateRange
|
||||
:model-value="rangeValue"
|
||||
label="Période (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioDateWeek
|
||||
label="Semaine (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioDateWeek
|
||||
model-value="2026-W52"
|
||||
label="Semaine (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">MalioTimePicker</h2>
|
||||
<div class="space-y-4">
|
||||
<MalioTimePicker
|
||||
label="Heure (vide)"
|
||||
:readonly="true"
|
||||
/>
|
||||
<MalioTimePicker
|
||||
model-value="14:30"
|
||||
label="Heure (rempli)"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
type Option = {label: string; value: string | number}
|
||||
|
||||
const countryOptions: Option[] = [
|
||||
{label: 'France', value: 'fr'},
|
||||
{label: 'Belgique', value: 'be'},
|
||||
{label: 'Canada', value: 'ca'},
|
||||
{label: 'Suisse', value: 'ch'},
|
||||
{label: 'Luxembourg', value: 'lu'},
|
||||
{label: 'Allemagne', value: 'de'},
|
||||
]
|
||||
|
||||
const categoryOptions: Option[] = [
|
||||
{label: 'Catégorie A', value: 'a'},
|
||||
{label: 'Catégorie B', value: 'b'},
|
||||
]
|
||||
|
||||
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
|
||||
</script>
|
||||
@@ -55,7 +55,7 @@
|
||||
<MalioButton
|
||||
label="Réinitialiser"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFiltres"
|
||||
/>
|
||||
<MalioButton
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
|
||||
</div>
|
||||
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8">
|
||||
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||
<MalioInputText
|
||||
label="Nom du client (Entreprise)"
|
||||
/>
|
||||
@@ -22,6 +22,7 @@
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-model="multiselectValue"
|
||||
error="test"
|
||||
label="Catégorie"
|
||||
:options="[
|
||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||
@@ -75,7 +76,7 @@
|
||||
<div class="mt-[60px]">
|
||||
<MalioTabList :tabs="tabs" v-model="tabsValue">
|
||||
<template #information>
|
||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||
<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"/>
|
||||
<MalioInputText v-model="concurrent" label="Concurrent"/>
|
||||
<MalioDate
|
||||
@@ -92,7 +93,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #adresses>
|
||||
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
|
||||
<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
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer l'adresse"
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<div class="mt-2 rounded border p-3 text-sm">
|
||||
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputAmount
|
||||
@@ -36,6 +47,23 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
|
||||
<div class="mt-4">
|
||||
@@ -57,4 +85,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const readonlyFilledAmount = ref('1250.00')
|
||||
const bigValue = ref('1234567.89')
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
v-model="simpleValue"
|
||||
label="Pays"
|
||||
:options="staticOptions"
|
||||
local-filter
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
|
||||
@@ -20,6 +21,7 @@
|
||||
icon-name="mdi:magnify"
|
||||
icon-position="left"
|
||||
:options="staticOptions"
|
||||
local-filter
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +82,25 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputAutocomplete
|
||||
@@ -138,6 +159,7 @@ const staticOptions: Option[] = [
|
||||
{label: 'Italie', value: 'it'},
|
||||
]
|
||||
|
||||
const readonlyFilledAutocomplete = ref<string | number | null>('de')
|
||||
const simpleValue = ref<string | number | null>(null)
|
||||
const leftIconValue = ref<string | number | null>(null)
|
||||
const createValue = ref<string | number | null>(null)
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
|
||||
<div class="space-y-3">
|
||||
<MalioInputEmail
|
||||
v-for="(email, index) in emails"
|
||||
:key="index"
|
||||
v-model="emails[index]"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="emails.push('')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
||||
<MalioInputEmail
|
||||
@@ -48,6 +62,23 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputEmail
|
||||
@@ -84,14 +115,36 @@
|
||||
:success="dynamicSuccess"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledEmail = ref('contact@malio.fr')
|
||||
const emailValue = ref('')
|
||||
const emails = ref<string[]>([''])
|
||||
const dynamicEmail = ref('')
|
||||
const requiredEmail = ref('')
|
||||
const lowercaseEmail = ref('')
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
|
||||
|
||||
@@ -41,6 +41,23 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputPassword
|
||||
@@ -83,6 +100,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledPassword = ref('motdepasse123')
|
||||
const passwordValue = ref('')
|
||||
const dynamicPassword = ref('')
|
||||
|
||||
|
||||
@@ -73,6 +73,23 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputPhone
|
||||
@@ -121,6 +138,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const readonlyFilledPhone = ref('+33 6 12 34 56 78')
|
||||
const phoneValue = ref('')
|
||||
const phoneAddable = ref('')
|
||||
const phoneFrench = ref('')
|
||||
|
||||
@@ -108,6 +108,33 @@
|
||||
icon-size="20"
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
|
||||
<MalioInputText
|
||||
@@ -154,6 +181,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
const readonlyFilledValue = ref('Commande #A-2048')
|
||||
const nameValue = ref('')
|
||||
const searchValue = ref('')
|
||||
const codeValue = ref('')
|
||||
|
||||
@@ -61,6 +61,25 @@
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
|
||||
<MalioInputTextArea
|
||||
@@ -94,6 +113,7 @@
|
||||
import {ref} from '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 iconValue = ref('')
|
||||
const errorValue = ref('abc')
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Clearable (croix pour vider)</h2>
|
||||
<MalioInputUpload
|
||||
v-model="clearableUpload"
|
||||
label="Téléverser un document"
|
||||
clearable
|
||||
@clear="onClearUpload"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ clearableUpload || '(aucun)' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||
<MalioInputUpload
|
||||
@@ -31,6 +42,23 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputUpload
|
||||
@@ -74,8 +102,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledUpload = ref('document.pdf')
|
||||
const uploadValue = ref('')
|
||||
const dynamicUpload = ref('')
|
||||
const clearableUpload = ref('rapport-2026.pdf')
|
||||
|
||||
const onClearUpload = () => {
|
||||
clearableUpload.value = ''
|
||||
}
|
||||
|
||||
const dynamicError = computed(() => {
|
||||
if (!dynamicUpload.value) return ''
|
||||
|
||||
@@ -82,6 +82,17 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
|
||||
<MalioSelect
|
||||
@@ -92,6 +103,28 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||
<MalioSelect
|
||||
@@ -151,6 +184,7 @@ const longOptions = [
|
||||
{label: 'Republique tcheque', value: 'cz'},
|
||||
]
|
||||
|
||||
const requiredValue = ref<string | number | null>(null)
|
||||
const basicValue = ref<string | number | null>(null)
|
||||
const labelValue = ref<string | number | null>(null)
|
||||
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 longListValue = 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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue"
|
||||
:options="options"
|
||||
displayTag="true"
|
||||
:display-tag="true"
|
||||
empty-option-label=" "
|
||||
/>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue1"
|
||||
:options="options"
|
||||
displayTag="true"
|
||||
:display-tag="true"
|
||||
label="Pays"
|
||||
empty-option-label=" "
|
||||
/>
|
||||
@@ -123,6 +123,28 @@
|
||||
/>
|
||||
</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">
|
||||
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
|
||||
<MalioSelectCheckbox
|
||||
@@ -145,6 +167,7 @@
|
||||
empty-option-label="Aucune selection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -190,4 +213,6 @@ const selectAllValue = ref<Array<string | number>>([])
|
||||
const selectAllCustomValue = ref<Array<string | number>>([])
|
||||
const longListValue = ref<Array<string | number>>([])
|
||||
const bottomValue = ref<Array<string | number>>([])
|
||||
const readonlyEmptyValue = ref<Array<string | number>>([])
|
||||
const readonlyFilledValue = ref<Array<string | number>>(['fr'])
|
||||
</script>
|
||||
|
||||
@@ -36,6 +36,36 @@
|
||||
<template #details><p class="p-4">Détails avancés</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-1 text-xl font-bold">Beaucoup d'onglets (fenêtré)</h2>
|
||||
<p class="mb-4 text-sm text-m-muted">
|
||||
7 onglets avec <code>:max-visible-tabs="5"</code> — flèches gauche/droite pour faire défiler
|
||||
(1 par 1). L'onglet actif reste sélectionné même hors fenêtre.
|
||||
</p>
|
||||
<MalioTabList v-model="manyValue" :tabs="manyTabs" :max-visible-tabs="5">
|
||||
<template #infos><p class="p-4">Contenu Informations</p></template>
|
||||
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||
<template #compta><p class="p-4">Contenu Comptabilité</p></template>
|
||||
<template #documents><p class="p-4">Contenu Documents</p></template>
|
||||
<template #historique><p class="p-4">Contenu Historique</p></template>
|
||||
<template #parametres><p class="p-4">Contenu Paramètres</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-1 text-xl font-bold">Peu d'onglets avec maxVisibleTabs</h2>
|
||||
<p class="mb-4 text-sm text-m-muted">
|
||||
3 onglets avec <code>:max-visible-tabs="5"</code> — le fenêtrage ne s'active pas
|
||||
(onglets ≤ max), donc pas de flèches, affichage normal centré.
|
||||
</p>
|
||||
<MalioTabList v-model="fewValue" :tabs="fewTabs" :max-visible-tabs="5">
|
||||
<template #general><p class="p-4">Contenu Général</p></template>
|
||||
<template #adresses><p class="p-4">Contenu Adresses</p></template>
|
||||
<template #contacts><p class="p-4">Contenu Contacts</p></template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -60,7 +90,25 @@ const tabsTwo = [
|
||||
{ key: 'details', label: 'Détails', icon: 'mdi:cog-outline' },
|
||||
]
|
||||
|
||||
const manyTabs = [
|
||||
{ key: 'infos', label: 'Informations', icon: 'mdi:information-outline' },
|
||||
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||
{ key: 'compta', label: 'Comptabilité', icon: 'mdi:web' },
|
||||
{ key: 'documents', label: 'Documents', icon: 'mdi:file-document-outline' },
|
||||
{ key: 'historique', label: 'Historique', icon: 'mdi:history' },
|
||||
{ key: 'parametres', label: 'Paramètres', icon: 'mdi:cog-outline' },
|
||||
]
|
||||
|
||||
const simpleValue = ref('qualimat')
|
||||
const noIconValue = ref('tab1')
|
||||
const twoTabValue = ref('general')
|
||||
const manyValue = ref('infos')
|
||||
|
||||
const fewTabs = [
|
||||
{ key: 'general', label: 'Général', icon: 'mdi:information-outline' },
|
||||
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
|
||||
]
|
||||
const fewValue = ref('general')
|
||||
</script>
|
||||
|
||||
@@ -30,12 +30,23 @@
|
||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
|
||||
<MalioTimePicker label="Heure (readonly vide)" :readonly="true" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
|
||||
<MalioTimePicker v-model="readonlyFilledTime" label="Heure (readonly rempli)" :readonly="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const readonlyFilledTime = ref('14:30')
|
||||
const simpleValue = ref('')
|
||||
const initialValue = ref('08:30')
|
||||
const disabledValue = ref('14:15')
|
||||
|
||||
@@ -69,6 +69,7 @@ export const navSections: SidebarSection[] = [
|
||||
label: 'DIVERS',
|
||||
icon: 'mdi:dots-horizontal',
|
||||
items: [
|
||||
{label: 'Champs readonly', to: '/composant/divers/readonly'},
|
||||
{label: 'Heure', to: '/composant/time/time'},
|
||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||
|
||||
@@ -36,11 +36,42 @@ 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-37] Création d'un composant accordéon
|
||||
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
|
||||
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
|
||||
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
|
||||
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
|
||||
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
|
||||
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
|
||||
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||
* [#MUI-42] Anneau de focus clavier standardisé (`outline` 2px `m-primary`, offset 2px) affiché **uniquement** à la navigation clavier (jamais au clic souris), sur l'ensemble des champs et contrôles : inputs (Text, Email, Password, Phone, Amount, Number + boutons ±, Upload, TextArea, Autocomplete), Select, SelectCheckbox, famille Date (Date, DateRange, DateTime, DateWeek), Button, ButtonIcon. Mécanique : composable `useKbdFocusRing` (détection de modalité clavier/souris) + utilitaires CSS `.m-focus-ring` (éléments à `:focus-visible` natif) et `.m-focus-ring-kbd` (champs texte, où `:focus-visible` se déclenche aussi à la souris)
|
||||
* [#MUI-42] Anneau « combo » : quand un dropdown / calendrier est ouvert (Autocomplete, Select, SelectCheckbox, Date), l'anneau entoure le champ **et** la liste / le calendrier d'un seul tenant, adapté au sens d'ouverture (utilitaires `.m-combo-ring-top` / `.m-combo-ring-bottom`)
|
||||
* [#MUI-42] Navigation clavier WAI-ARIA APG sur les listes déroulantes : Select et SelectCheckbox gagnent la navigation (flèches, Home/End, Entrée/Espace, Échap, Tab — absente jusque-là), avec scroll automatique de l'option active et `aria-activedescendant` ; InputAutocomplete complété (scroll auto, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme)
|
||||
* [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche
|
||||
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
|
||||
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
|
||||
|
||||
### Changed
|
||||
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
|
||||
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
|
||||
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
|
||||
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
|
||||
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
|
||||
|
||||
### Fixed
|
||||
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
|
||||
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
|
||||
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
||||
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
|
||||
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
|
||||
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
|
||||
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
|
||||
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
|
||||
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
|
||||
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
|
||||
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
|
||||
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
|
||||
* [#MUI-42] RadioButton : ajout d'un focus visible au clavier (`outline` 2px `m-primary`, offset 2px) — l'input en `appearance-none` n'avait aucun indicateur de focus, seul l'`outline: auto 1px` du navigateur restait, quasi invisible. La navigation native (Tab entre groupes, flèches dans le groupe) reste inchangée
|
||||
* [#MUI-42] Checkbox : ajout d'un focus visible au clavier sur la case (`outline` 2px `m-primary`, offset 2px) — l'input réel est masqué (`clip-path`), aucun indicateur n'apparaissait à la tabulation
|
||||
* [#MUI-42] Select : le focus reste sur le bouton après sélection (un `blur()` renvoyait le focus au `body`, cassant la tabulation clavier — un Tab repartait du haut de page)
|
||||
|
||||
+94
-16
@@ -2,6 +2,10 @@
|
||||
|
||||
Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-model` pour le binding bidirectionnel sur les composants de formulaire.
|
||||
|
||||
> **Champ obligatoire :** sur les composants de formulaire, la prop `required` ajoute un astérisque rouge dans le label. C'est un repère visuel ; la sémantique « obligatoire » est portée par l'attribut natif `required` ou `aria-required`.
|
||||
|
||||
> **Focus clavier :** tous les champs et contrôles affichent un anneau de focus (`outline` 2px `m-primary`, offset 2px) **uniquement** à la navigation clavier (Tab), jamais au clic souris. Sur les composants à dropdown/calendrier ouverts, l'anneau entoure le champ et la liste d'un seul tenant. Voir la note « Clavier » de chaque composant pour la navigation détaillée.
|
||||
|
||||
---
|
||||
|
||||
## MalioInputText
|
||||
@@ -15,10 +19,11 @@ Champ texte avec label, icône optionnelle et support de masque de saisie.
|
||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||
| `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 |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `iconName` | `string` | `''` | Icône Iconify (ex: `mdi:magnify`) |
|
||||
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||
@@ -53,9 +58,11 @@ Champ mot de passe avec toggle visibilité.
|
||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
@@ -79,25 +86,35 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||
| `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 |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
||||
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
|
||||
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
|
||||
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
|
||||
|
||||
**Events :**
|
||||
- `update:modelValue(value: string)`
|
||||
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||
|
||||
```vue
|
||||
<MalioInputEmail v-model="email" label="Adresse email" />
|
||||
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
|
||||
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
|
||||
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
|
||||
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
|
||||
```
|
||||
|
||||
---
|
||||
@@ -115,10 +132,11 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
|
||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
|
||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||
@@ -146,7 +164,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
|
||||
|
||||
## 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 |
|
||||
|------|------|--------|-------------|
|
||||
@@ -159,6 +177,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
||||
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
|
||||
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
|
||||
| `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 |
|
||||
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
|
||||
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
|
||||
@@ -168,10 +187,11 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
||||
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||
@@ -182,11 +202,11 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
||||
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
|
||||
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
|
||||
|
||||
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
||||
**Clavier (WAI-ARIA APG) :** `↓` ouvre / option suivante, `↑` précédente (ou ouvre sur la dernière option si fermé), `Début`/`Fin`, scroll automatique de l'option active, `Entrée` sélection (ou création), `Échap` annule, `Tab` ferme. Anneau de focus clavier (combo champ + liste à l'ouverture).
|
||||
|
||||
```vue
|
||||
<!-- Usage statique -->
|
||||
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
|
||||
<!-- Usage statique (filtrage côté client via local-filter) -->
|
||||
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
|
||||
|
||||
<!-- Usage API (parent gère le fetch) -->
|
||||
<MalioInputAutocomplete
|
||||
@@ -224,19 +244,25 @@ async function onSearchClients(query: string) {
|
||||
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
|
||||
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
```vue
|
||||
<MalioInputAmount v-model="montant" label="Montant TTC" />
|
||||
<MalioInputAmount v-model="prix" label="Prix" error="Montant invalide" />
|
||||
<MalioInputAmount v-model="gros" label="Budget" />
|
||||
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
|
||||
```
|
||||
|
||||
---
|
||||
@@ -252,7 +278,9 @@ Champ numérique avec boutons +/-.
|
||||
| `min` | `number \| string` | — | Valeur minimum |
|
||||
| `max` | `number \| string` | — | Valeur maximum |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
@@ -275,7 +303,9 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
||||
| `maxLength` | `number` | `800` | Longueur max |
|
||||
| `showCounter` | `boolean` | `false` | Afficher le compteur |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
@@ -303,9 +333,11 @@ Zone de texte multiligne avec compteur et redimensionnement.
|
||||
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
|
||||
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
|
||||
| `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 |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `outputFormat` | `'markdown' \| 'html'` | `'html'` | Format émis dans `update:modelValue` |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||
| `labelClass` | `string` | `''` | Classes CSS label (twMerge) |
|
||||
@@ -332,13 +364,20 @@ Champ d'upload de fichier.
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `accept` | `string` | `''` | Types de fichiers acceptés |
|
||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
|
||||
| `clearable` | `boolean` | `false` | Affiche une croix (`mdi:close`) focusable qui vide le champ quand un fichier est sélectionné |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
|
||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`, `clear()`
|
||||
|
||||
**Clavier :** `Entrée` / `Espace` ouvrent le sélecteur de fichier. La croix `clearable` est focusable (anneau clavier, `Entrée`/`Espace`).
|
||||
|
||||
```vue
|
||||
<MalioInputUpload v-model="fileName" label="Document" accept=".pdf,.doc" @file-selected="onFile" />
|
||||
<MalioInputUpload v-model="fileName" label="Document" clearable @clear="onClear" />
|
||||
```
|
||||
|
||||
---
|
||||
@@ -356,17 +395,23 @@ Liste déroulante.
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
|
||||
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
|
||||
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
||||
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
|
||||
| `fieldClass` | `string` | `''` | Classes supplémentaires sur le field (override hauteur, ex. `h-[30px]`) |
|
||||
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
||||
|
||||
**Events :** `update:modelValue(value: string | number | null)`
|
||||
**Slots :** `icon` (icône dropdown custom)
|
||||
|
||||
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto de l'option active), `Début`/`Fin`, `Entrée`/`Espace` sélectionnent, `Échap`/`Tab` ferment. Le focus reste sur le bouton après sélection. Anneau de focus clavier (combo bouton + liste à l'ouverture, adapté au sens haut/bas).
|
||||
|
||||
```vue
|
||||
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
|
||||
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
|
||||
@@ -381,17 +426,22 @@ Liste déroulante multi-sélection avec checkboxes.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `(string \| number)[]` | **requis** | Valeurs sélectionnées (v-model) |
|
||||
| `modelValue` | `(string \| number)[]` | `[]` | Valeurs sélectionnées (v-model) |
|
||||
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options |
|
||||
| `displayTag` | `boolean` | `false` | Afficher les tags sélectionnés |
|
||||
| `displaySelectAll` | `boolean` | `false` | Afficher "Tout sélectionner" |
|
||||
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
|
||||
|
||||
**Events :** `update:modelValue(value: (string | number)[])`
|
||||
|
||||
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto), `Début`/`Fin`, `Entrée`/`Espace` cochent/décochent l'option active (la liste **reste ouverte**), `Échap`/`Tab` ferment. La ligne « Tout sélectionner » est navigable au clavier. Le clic sur toute la ligne (pas que le label) coche/décoche. Anneau de focus clavier (combo bouton + liste à l'ouverture).
|
||||
|
||||
```vue
|
||||
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
|
||||
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
|
||||
@@ -409,10 +459,14 @@ Case à cocher.
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: boolean)`
|
||||
|
||||
**Clavier :** `Espace` coche/décoche. Focus clavier visible sur la case (`outline` 2px `m-primary`).
|
||||
|
||||
```vue
|
||||
<MalioCheckbox v-model="accepte" label="J'accepte les conditions" />
|
||||
<MalioCheckbox v-model="newsletter" label="Newsletter" disabled />
|
||||
@@ -432,9 +486,12 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
||||
| `name` | `string` | `''` | Nom du groupe radio |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
|
||||
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
||||
|
||||
**Clavier :** comportement natif d'un groupe radio (options partageant le même `name`) — `Tab` / `Maj+Tab` entre/sort du groupe (1 seul arrêt par groupe), `↑↓←→` déplacent la sélection entre les options d'un même groupe. Focus clavier visible (`outline` 2px `m-primary`).
|
||||
|
||||
```vue
|
||||
<MalioRadioButton v-model="civilite" name="civilite" value="M" label="Monsieur" />
|
||||
<MalioRadioButton v-model="civilite" name="civilite" value="Mme" label="Madame" />
|
||||
@@ -448,6 +505,8 @@ Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
||||
|
||||
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||
|
||||
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
|
||||
@@ -455,7 +514,7 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `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é |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -464,14 +523,20 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
|
||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
|
||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
|
||||
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
|
||||
**Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_
|
||||
|
||||
```vue
|
||||
<MalioDate v-model="date" label="Date de naissance" />
|
||||
<!-- date === "2026-05-20" -->
|
||||
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
|
||||
<MalioDate v-model="date" label="Date de naissance" editable />
|
||||
```
|
||||
|
||||
---
|
||||
@@ -489,7 +554,7 @@ La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `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é |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -498,6 +563,7 @@ La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"
|
||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
|
||||
@@ -522,7 +588,7 @@ La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `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é |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -531,6 +597,7 @@ La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2
|
||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
@@ -552,7 +619,9 @@ Sélecteur d'heure.
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
@@ -574,13 +643,14 @@ Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes in
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
|
||||
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
@@ -607,7 +677,7 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `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é |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
@@ -616,6 +686,7 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
|
||||
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) |
|
||||
| `max` | `string` | `undefined` | Borne max (idem) |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
@@ -652,8 +723,11 @@ Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
||||
<MalioButton label="Voir plus" variant="tertiary" />
|
||||
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
|
||||
<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
|
||||
@@ -686,6 +760,10 @@ Navigation par onglets avec contenu dynamique.
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
|
||||
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
|
||||
| `maxVisibleTabs` | `number` | `undefined` | Nombre max d'onglets affichés à la fois. Au-delà, un carrousel avec flèches gauche/droite apparaît (décalage 1 par 1). Non défini = tous les onglets. |
|
||||
| `maxWidth` | `number` | `1100` | Largeur max (px) du bloc d'onglets en mode fenêtré. |
|
||||
|
||||
Quand `maxVisibleTabs` est défini et que le nombre d'onglets le dépasse, la barre passe en mode fenêtré : seuls `maxVisibleTabs` onglets sont visibles à la fois, encadrés par des flèches gauche/droite qui font défiler la fenêtre un onglet à la fois (largeur du bloc bornée par `maxWidth`).
|
||||
|
||||
Type `Tab` :
|
||||
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
|
||||
Deux déclencheurs, même rendu :
|
||||
- .m-focus-ring → s'appuie sur :focus-visible natif. Pour les éléments
|
||||
où :focus-visible se limite déjà au clavier (boutons,
|
||||
onglets, tuiles, checkbox/radio…).
|
||||
- .m-focus-ring-kbd → classe ajoutée en JS (via useKbdFocusRing) uniquement
|
||||
quand le focus vient du clavier. Pour les champs texte,
|
||||
où :focus-visible natif se déclenche aussi à la souris.
|
||||
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
|
||||
`outline-none` des inputs. */
|
||||
.m-focus-ring:focus-visible,
|
||||
.m-focus-ring-kbd:focus {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
|
||||
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
|
||||
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
|
||||
reste sans contour pour un raccord sans couture. */
|
||||
.m-combo-ring-top {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 -2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
.m-combo-ring-bottom {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Globales ── */
|
||||
@@ -31,6 +66,9 @@
|
||||
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
|
||||
--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) ── */
|
||||
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
|
||||
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
|
||||
|
||||
@@ -162,8 +162,8 @@ describe('MalioButton', () => {
|
||||
it('applies correct dimensions', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('w-[240px]')
|
||||
expect(wrapper.get('button').classes()).toContain('h-[40px]')
|
||||
expect(wrapper.get('button').classes()).toContain('w-[180px]')
|
||||
expect(wrapper.get('button').classes()).toContain('h-[38px]')
|
||||
})
|
||||
|
||||
it('applies font styles', () => {
|
||||
|
||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex w-[240px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
|
||||
variantClasses.value,
|
||||
props.buttonClass,
|
||||
),
|
||||
|
||||
@@ -52,7 +52,7 @@ const isFilled = computed(() => props.variant === 'filled')
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
|
||||
isFilled.value
|
||||
? props.disabled
|
||||
? 'bg-m-disabled text-white cursor-not-allowed'
|
||||
|
||||
@@ -17,6 +17,7 @@ type CheckboxProps = {
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
|
||||
@@ -161,4 +162,33 @@ describe('MalioCheckbox', () => {
|
||||
|
||||
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>
|
||||
</span>
|
||||
<span>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="mergedMessageClass"
|
||||
>
|
||||
@@ -42,6 +42,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
||||
|
||||
@@ -60,6 +61,7 @@ const props = withDefaults(
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -75,6 +77,7 @@ const props = withDefaults(
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -122,6 +125,7 @@ const mergedLabelClass = computed(() =>
|
||||
const mergedMessageClass = computed(() =>
|
||||
twMerge(
|
||||
'text-xs',
|
||||
props.reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
@@ -176,6 +180,11 @@ const onChange = (event: Event) => {
|
||||
border-color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.inp-cbx:focus-visible + .cbx span:first-child {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cbx span:first-child svg {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
|
||||
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]"
|
||||
>
|
||||
<slot
|
||||
v-if="$slots[`header-${col.key}`]"
|
||||
:name="`header-${col.key}`"
|
||||
:column="col"
|
||||
/>
|
||||
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
|
||||
<span v-else class="font-semibold text-black">{{ col.label }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -32,7 +32,7 @@
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-3 py-4 text-[18px] text-m-primary"
|
||||
class="px-3 py-4 text-[14px] text-black"
|
||||
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
||||
>
|
||||
<slot
|
||||
@@ -57,30 +57,33 @@
|
||||
|
||||
<div
|
||||
v-if="totalItems > 0"
|
||||
class="flex justify-between pt-2"
|
||||
class="flex items-center justify-between pt-3"
|
||||
data-test="pagination"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
|
||||
<MalioSelect
|
||||
:model-value="perPage"
|
||||
:options="perPageSelectOptions"
|
||||
min-width="w-20 !mt-0"
|
||||
rounded="rounded"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
text-label="text-xs"
|
||||
data-test="per-page-select"
|
||||
@update:model-value="onPerPageChange"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
||||
<div class="h-[30px]">
|
||||
<MalioSelect
|
||||
:model-value="perPage"
|
||||
:options="perPageSelectOptions"
|
||||
group-class="w-20 h-[30px]"
|
||||
field-class="h-[30px]"
|
||||
rounded="rounded"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
text-label="text-xs"
|
||||
data-test="per-page-select"
|
||||
@update:model-value="onPerPageChange"
|
||||
/>
|
||||
</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
|
||||
variant="tertiary"
|
||||
label="Prev"
|
||||
label="Préc."
|
||||
:disabled="page <= 1"
|
||||
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page précédente"
|
||||
data-test="prev-button"
|
||||
@click="goToPage(page - 1)"
|
||||
@@ -95,7 +98,7 @@
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||
class="inline-flex h-[30px] min-w-[2.5rem] items-center justify-center rounded px-2 text-sm transition-colors"
|
||||
:class="p === page
|
||||
? 'bg-m-btn-primary text-white font-semibold'
|
||||
: 'text-m-text hover:bg-m-bg'"
|
||||
@@ -109,9 +112,9 @@
|
||||
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Next"
|
||||
label="Suiv."
|
||||
:disabled="page >= totalPages"
|
||||
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page suivante"
|
||||
data-test="next-button"
|
||||
@click="goToPage(page + 1)"
|
||||
|
||||
@@ -18,9 +18,12 @@ type DateProps = {
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const DateForTest = Date_ as DefineComponent<DateProps>
|
||||
@@ -40,6 +43,16 @@ describe('MalioDate', () => {
|
||||
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', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
@@ -175,6 +188,37 @@ describe('MalioDate', () => {
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
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é', () => {
|
||||
@@ -195,4 +239,116 @@ describe('MalioDate', () => {
|
||||
expect(input.value).toBe('25/12/2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserveMessageSpace', () => {
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountDate({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,14 +10,16 @@
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:error="mergedError"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:editable="editable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="emit('update:modelValue', null)"
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
@@ -26,17 +28,17 @@
|
||||
:selected-date="modelValue ?? null"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, watch} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||
|
||||
@@ -56,6 +58,8 @@ const props = withDefaults(
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -75,6 +79,8 @@ const props = withDefaults(
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
@@ -85,7 +91,38 @@ const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>
|
||||
|
||||
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
internalError.value = props.invalidMessage
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
internalError.value = ''
|
||||
if (val && !isValidIso(val) && import.meta.dev) {
|
||||
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
readonly
|
||||
:readonly="inputReadonly"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="displayValue"
|
||||
:value="editable ? draft : displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
@@ -22,6 +23,10 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus(); onKbdFocus()"
|
||||
@input="onInput"
|
||||
@blur="onBlur(); onKbdBlur()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -29,7 +34,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
@@ -37,7 +42,7 @@
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="text-m-muted hover:text-m-primary"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Effacer la date"
|
||||
@click.stop="emit('clear')"
|
||||
>
|
||||
@@ -61,6 +66,7 @@
|
||||
data-test="popover"
|
||||
role="dialog"
|
||||
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
|
||||
>
|
||||
<CalendarHeader
|
||||
:view-mode="viewMode"
|
||||
@@ -85,11 +91,12 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -101,13 +108,19 @@
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
import {useCalendarView} from '../composables/useCalendarView'
|
||||
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
displayValue: string
|
||||
@@ -123,9 +136,11 @@ const props = withDefaults(
|
||||
error?: string
|
||||
success?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -139,25 +154,41 @@ const props = withDefaults(
|
||||
error: '',
|
||||
success: '',
|
||||
clearable: true,
|
||||
editable: false,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
|
||||
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
const isFilled = computed(() =>
|
||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||
)
|
||||
@@ -171,6 +202,13 @@ watch(isOpen, (value) => {
|
||||
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
@@ -179,6 +217,56 @@ const onFieldClick = () => {
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (isOpen.value) {
|
||||
e.preventDefault()
|
||||
closePopover()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.editable) {
|
||||
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onFieldClick()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.syncTo, (value) => {
|
||||
if (isOpen.value) syncToIso(value)
|
||||
})
|
||||
@@ -195,14 +283,17 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
||||
hasError.value
|
||||
? 'border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
@@ -210,14 +301,16 @@ const mergedInputClass = computed(() =>
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
|
||||
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: isOpen.value
|
||||
? 'text-m-primary'
|
||||
: 'peer-placeholder-shown:text-m-muted text-black',
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: isOpen.value
|
||||
? 'text-m-primary'
|
||||
: 'peer-placeholder-shown:text-m-muted text-black',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -225,6 +318,7 @@ const mergedLabelClass = computed(() =>
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isOpen.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
|
||||
@@ -24,6 +24,7 @@ type InputProps = {
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputForTest = Input as DefineComponent<InputProps>
|
||||
@@ -53,6 +54,16 @@ describe('MalioInputText', () => {
|
||||
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', () => {
|
||||
const wrapper = mountInput({name: 'nameTest'})
|
||||
|
||||
@@ -126,6 +137,13 @@ describe('MalioInputText', () => {
|
||||
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 () => {
|
||||
const wrapper = mountInput({modelValue: ''})
|
||||
|
||||
@@ -253,6 +271,34 @@ describe('MalioInputText', () => {
|
||||
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', () => {
|
||||
const wrapper = mountInput({labelClass: 'text-red-500'})
|
||||
|
||||
@@ -308,4 +354,25 @@ describe('MalioInputText', () => {
|
||||
|
||||
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'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
|
||||
@@ -96,7 +97,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue('12.5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
})
|
||||
|
||||
it('accepts commas but normalizes them to dots', async () => {
|
||||
@@ -105,7 +106,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue('0012,345abc')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
})
|
||||
|
||||
it('normalizes a leading decimal separator', async () => {
|
||||
@@ -114,7 +115,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue(',5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
})
|
||||
|
||||
it('keeps the normalized decimal value on blur', async () => {
|
||||
@@ -125,7 +126,7 @@ describe('MalioInputAmount', () => {
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
||||
expect(input.element.value).toBe('12.5')
|
||||
expect(input.element.value).toBe('12,5')
|
||||
})
|
||||
|
||||
it('keeps integer values unchanged on blur', async () => {
|
||||
@@ -174,4 +175,107 @@ describe('MalioInputAmount', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly vide : icône en text-m-muted', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', readonly: true, modelValue: '12.50'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:minlength="minLength"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:value="formattedValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
@@ -21,7 +20,7 @@
|
||||
inputmode="decimal"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="onBlur"
|
||||
>
|
||||
|
||||
@@ -30,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -44,7 +43,7 @@
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -52,7 +51,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -64,9 +64,14 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||
|
||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -89,6 +94,7 @@ const props = withDefaults(
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -109,8 +115,9 @@ const props = withDefaults(
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconSize: 20,
|
||||
iconColor: 'text-m-muted',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -122,10 +129,16 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
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(() =>
|
||||
twMerge(
|
||||
@@ -135,30 +148,40 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'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',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
isReadonly.value ? '' : focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -172,40 +195,37 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const normalizeAmount = (value: string) => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Keep the DOM input value, local state, and v-model emission in sync.
|
||||
const updateValue = (target: HTMLInputElement, value: string) => {
|
||||
target.value = value
|
||||
if (!isControlled.value) {
|
||||
localValue.value = value
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Normalize while typing so the field never keeps invalid amount characters.
|
||||
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
updateValue(target, normalizeAmount(target.value))
|
||||
const rawText = target.value
|
||||
const caret = target.selectionStart ?? rawText.length
|
||||
const model = normalizeAmount(rawText)
|
||||
|
||||
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||
target.value = formattedValue.value
|
||||
const restored = Math.min(Math.max(0, caret - 1), formattedValue.value.length)
|
||||
target.setSelectionRange(restored, restored)
|
||||
return
|
||||
}
|
||||
|
||||
const display = formatGroupedAmount(model)
|
||||
const sig = countSignificant(rawText, caret)
|
||||
target.value = display
|
||||
const newCaret = caretFromSignificant(display, sig)
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = model
|
||||
}
|
||||
emit('update:modelValue', model)
|
||||
}
|
||||
|
||||
// Keep the blur handler only for focus-driven UI state.
|
||||
const onBlur = () => {
|
||||
isFocused.value = false
|
||||
onKbdBlur()
|
||||
}
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
@@ -234,6 +254,7 @@ const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
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 (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
|
||||
@@ -28,6 +28,7 @@ type InputAutocompleteProps = {
|
||||
debounce?: number
|
||||
minSearchLength?: number
|
||||
allowCreate?: boolean
|
||||
localFilter?: boolean
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
@@ -35,6 +36,7 @@ type InputAutocompleteProps = {
|
||||
noResultsText?: string
|
||||
loadingText?: string
|
||||
minSearchText?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
|
||||
@@ -64,6 +66,16 @@ describe('MalioInputAutocomplete', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -427,4 +439,128 @@ describe('MalioInputAutocomplete', () => {
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('Custom')
|
||||
})
|
||||
|
||||
it('does not filter options when localFilter is false (default)', async () => {
|
||||
const wrapper = mountComponent({options})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
await wrapper.get('input').setValue('fr')
|
||||
|
||||
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters options client-side when localFilter is true', async () => {
|
||||
const wrapper = mountComponent({options, localFilter: true})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
await wrapper.get('input').setValue('fr')
|
||||
|
||||
const items = wrapper.findAll('[data-test="option"]')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toBe('France')
|
||||
})
|
||||
|
||||
it('localFilter is case-insensitive and matches substrings', async () => {
|
||||
const wrapper = mountComponent({options, localFilter: true})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
await wrapper.get('input').setValue('GIQ')
|
||||
|
||||
const items = wrapper.findAll('[data-test="option"]')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toBe('Belgique')
|
||||
})
|
||||
|
||||
it('localFilter shows all options when input is empty', async () => {
|
||||
const wrapper = mountComponent({options, localFilter: true})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('localFilter shows the no-results state when nothing matches', async () => {
|
||||
const wrapper = mountComponent({options, localFilter: true})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
await wrapper.get('input').setValue('zzzzz')
|
||||
|
||||
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(0)
|
||||
expect(wrapper.find('[data-test="no-results-text"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the floating label at the same position whether focused or not (no jump)', async () => {
|
||||
const wrapper = mountComponent({options, label: 'Pays', modelValue: 'fr'})
|
||||
|
||||
// when a value is selected and the field is not focused, the label is already floated
|
||||
const labelClasses = wrapper.get('label').classes()
|
||||
expect(labelClasses).toContain('-translate-y-[1.25rem]')
|
||||
// and there is no extra peer-focus translate that would make it jump on click
|
||||
expect(labelClasses).not.toContain('peer-focus:-translate-y-[1.55rem]')
|
||||
})
|
||||
|
||||
it('does not shift inner text horizontally on focus (no focus:pl change)', () => {
|
||||
const wrapper = mountComponent({options})
|
||||
|
||||
const inputClasses = wrapper.get('input').classes()
|
||||
expect(inputClasses).not.toContain('focus:pl-[11px]')
|
||||
})
|
||||
|
||||
it('keeps the bottom border allocation when open (transparent, not zero)', async () => {
|
||||
const wrapper = mountComponent({options})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
const inputClasses = wrapper.get('input').classes()
|
||||
// border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
|
||||
// border-b-transparent keeps the 1px allocation but hides the line
|
||||
expect(inputClasses).not.toContain('!border-b-0')
|
||||
expect(inputClasses).toContain('!border-b-transparent')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly vide : chevron en text-m-muted', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fr', options, iconName: 'mdi:magnify', iconPosition: 'left'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon-left"]').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', options})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onKbdBlur"
|
||||
@click="onInputClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
@@ -33,7 +34,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -90,6 +91,7 @@
|
||||
: hasSuccess
|
||||
? 'border-m-success select-scrollbar-success'
|
||||
: 'border-m-primary select-scrollbar-primary',
|
||||
keyboardFocused ? 'm-combo-ring-bottom' : '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -107,7 +109,7 @@
|
||||
{{ minSearchText }}
|
||||
</li>
|
||||
<li
|
||||
v-else-if="options.length === 0"
|
||||
v-else-if="filteredOptions.length === 0"
|
||||
class="px-3 py-2 text-m-muted"
|
||||
data-test="no-results-text"
|
||||
>
|
||||
@@ -115,7 +117,7 @@
|
||||
</li>
|
||||
<template v-else>
|
||||
<li
|
||||
v-for="(opt, index) in options"
|
||||
v-for="(opt, index) in filteredOptions"
|
||||
:id="optionId(index)"
|
||||
:key="String(opt.value)"
|
||||
data-test="option"
|
||||
@@ -136,11 +138,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -149,12 +152,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: string | number
|
||||
@@ -180,6 +187,7 @@ const props = withDefaults(
|
||||
debounce?: number
|
||||
minSearchLength?: number
|
||||
allowCreate?: boolean
|
||||
localFilter?: boolean
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
@@ -187,6 +195,7 @@ const props = withDefaults(
|
||||
noResultsText?: string
|
||||
loadingText?: string
|
||||
minSearchText?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -207,6 +216,7 @@ const props = withDefaults(
|
||||
debounce: 300,
|
||||
minSearchLength: 0,
|
||||
allowCreate: false,
|
||||
localFilter: false,
|
||||
iconName: '',
|
||||
iconPosition: 'left',
|
||||
iconSize: 24,
|
||||
@@ -214,6 +224,7 @@ const props = withDefaults(
|
||||
noResultsText: 'Aucun résultat',
|
||||
loadingText: 'Chargement…',
|
||||
minSearchText: 'Tapez pour rechercher',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -247,15 +258,29 @@ const hasSelection = computed(() =>
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
|
||||
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value
|
||||
? isFilled.value
|
||||
: isFocused.value || inputValue.value.length > 0,
|
||||
)
|
||||
|
||||
const showMinSearch = computed(() =>
|
||||
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 activeOptionId = computed(() =>
|
||||
activeIndex.value >= 0 && props.options[activeIndex.value]
|
||||
activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
|
||||
? optionId(activeIndex.value)
|
||||
: undefined,
|
||||
)
|
||||
@@ -294,19 +319,18 @@ const iconInputPaddingClass = computed(() => {
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const labelPositionClass = computed(() =>
|
||||
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
|
||||
)
|
||||
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'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',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
props.disabled
|
||||
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
|
||||
: 'cursor-text',
|
||||
@@ -314,11 +338,11 @@ const mergedInputClass = computed(() =>
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
isOpen.value ? '!rounded-b-none !border-b-0' : '',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -326,13 +350,16 @@ const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: props.disabled
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -341,6 +368,7 @@ const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
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 (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
@@ -349,6 +377,7 @@ const iconStateClass = computed(() => {
|
||||
const chevronColorClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isOpen.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
@@ -377,6 +406,7 @@ const onInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
onKbdFocus()
|
||||
if (props.disabled || props.readonly) return
|
||||
isFocused.value = true
|
||||
isOpen.value = true
|
||||
@@ -423,7 +453,20 @@ const closeAndRevert = () => {
|
||||
isFocused.value = false
|
||||
}
|
||||
|
||||
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
|
||||
if (event.key === 'Tab') {
|
||||
if (isOpen.value) closeAndCommit()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeAndRevert()
|
||||
@@ -432,8 +475,8 @@ const onKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
|
||||
onSelect(props.options[activeIndex.value])
|
||||
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
|
||||
onSelect(filteredOptions.value[activeIndex.value])
|
||||
return
|
||||
}
|
||||
if (props.allowCreate && inputValue.value !== '') {
|
||||
@@ -450,13 +493,31 @@ const onKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
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
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Liste fermée : ouvre et place sur la dernière option (APG)
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
return
|
||||
}
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// Home / End : première / dernière option quand la liste est ouverte
|
||||
if (isOpen.value && event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (isOpen.value && event.key === 'End') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,12 +542,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.grow-height {
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||
}
|
||||
|
||||
.grow-height:focus {
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -23,6 +23,11 @@ type InputEmailProps = {
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
lowercase?: boolean
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
||||
@@ -52,6 +57,16 @@ describe('MalioInputEmail', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -225,4 +240,156 @@ describe('MalioInputEmail', () => {
|
||||
|
||||
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
|
||||
})
|
||||
|
||||
it('supprime tous les espaces saisis', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
|
||||
expect(wrapper.get('input').element.value).toBe('ab@c.com')
|
||||
})
|
||||
|
||||
it('conserve la casse par défaut', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await wrapper.get('input').setValue('User@Example.COM')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
|
||||
})
|
||||
|
||||
it('met en minuscules quand lowercase est vrai', async () => {
|
||||
const wrapper = mountComponent({lowercase: true})
|
||||
await wrapper.get('input').setValue('User@Example.COM')
|
||||
const emits = wrapper.emitted('update:modelValue')!
|
||||
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
|
||||
})
|
||||
|
||||
it('émet la valeur sanitisée en mode contrôlé', async () => {
|
||||
const wrapper = mountComponent({modelValue: ''})
|
||||
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
|
||||
})
|
||||
|
||||
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
|
||||
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
|
||||
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
|
||||
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
|
||||
const wrapper = mountComponent({modelValue: 'ab@c.com'})
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('ab@c.com ')
|
||||
expect(input.element.value).toBe('ab@c.com')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('does not render add button by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders add button when addable is true', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits add event when add button is clicked', async () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not emit add when readonly', async () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when disabled', () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the email icon to the left automatically when addable', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
const icon = wrapper.get('[data-test="icon"]')
|
||||
expect(icon.classes()).toContain('left-[10px]')
|
||||
expect(icon.classes()).not.toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('keeps the email icon on the right when addable is false', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('uses the default add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
|
||||
})
|
||||
|
||||
it('allows overriding the add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
type="email"
|
||||
inputmode="email"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -28,7 +28,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -40,9 +40,26 @@
|
||||
:class="[iconStateClass, iconPositionClass]"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -50,7 +67,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -63,9 +81,13 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -86,6 +108,11 @@ const props = withDefaults(
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
lowercase?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -106,6 +133,11 @@ const props = withDefaults(
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter une adresse email',
|
||||
lowercase: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -117,10 +149,15 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value
|
||||
? isFilled.value
|
||||
: isFocused.value || currentValue.value.length > 0,
|
||||
)
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
@@ -129,34 +166,52 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'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',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
isReadonly.value ? '' : focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
|
||||
const describedBy = computed(() => {
|
||||
const ids: string[] = []
|
||||
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||
@@ -167,35 +222,74 @@ const describedBy = computed(() => {
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
|
||||
const sanitizeEmail = (v: string) => {
|
||||
let out = v.replace(/\s+/g, '')
|
||||
if (props.lowercase) out = out.toLowerCase()
|
||||
return out
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!isControlled.value) {
|
||||
localValue.value = target.value
|
||||
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 et setSelectionRange lève en navigateur.
|
||||
// (En jsdom selectionStart peut renvoyer un nombre, d'où le code gardé ci-dessous.)
|
||||
const caret = target.selectionStart
|
||||
target.value = sanitized
|
||||
if (caret !== null) {
|
||||
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
|
||||
try {
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
} catch {
|
||||
/* type d'input sans support de sélection — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
emit('update:modelValue', target.value)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = sanitized
|
||||
}
|
||||
emit('update:modelValue', sanitized)
|
||||
}
|
||||
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
if (!props.iconName) return ''
|
||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
|
||||
@@ -203,6 +297,7 @@ const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
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 (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
|
||||
@@ -6,9 +6,13 @@ import InputNumber from './InputNumber.vue'
|
||||
type InputNumberProps = {
|
||||
modelValue?: string | null
|
||||
label?: string
|
||||
required?: boolean
|
||||
readonly?: boolean
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
error?: string
|
||||
hint?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
|
||||
@@ -162,4 +166,33 @@ describe('MalioInputNumber', () => {
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
|
||||
expect(input.element.value).toBe('5')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isMinusDisabled"
|
||||
@click="decrement"
|
||||
>
|
||||
@@ -35,11 +36,12 @@
|
||||
inputmode="numeric"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isPlusDisabled"
|
||||
@click="increment"
|
||||
>
|
||||
@@ -51,7 +53,7 @@
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -59,7 +61,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'text-xs ml-[2px] ',
|
||||
'text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -71,9 +74,13 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -91,6 +98,7 @@ const props = withDefaults(
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -108,6 +116,7 @@ const props = withDefaults(
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -180,6 +189,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
|
||||
@@ -22,6 +22,7 @@ type InputPasswordProps = {
|
||||
error?: string
|
||||
success?: string
|
||||
displayIcon?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
|
||||
@@ -51,6 +52,16 @@ describe('MalioInputPassword', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -185,4 +196,55 @@ describe('MalioInputPassword', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly vide : icône en text-m-muted', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'secret'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('readonly : eye toggle reste cliquable', async () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
await wrapper.get('[data-test="icon"]').trigger('click')
|
||||
expect(wrapper.get('input').attributes('type')).toBe('text')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
placeholder="_"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -55,7 +55,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -68,9 +69,13 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -90,6 +95,7 @@ const props = withDefaults(
|
||||
error?: string
|
||||
success?: string
|
||||
displayIcon?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -109,6 +115,7 @@ const props = withDefaults(
|
||||
error: '',
|
||||
success: '',
|
||||
displayIcon: true,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -125,10 +132,15 @@ const toggleVisibility = () => {
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-password-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value
|
||||
? isFilled.value
|
||||
: isFocused.value || currentValue.value.length > 0,
|
||||
)
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
@@ -137,16 +149,21 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'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',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
props.displayIcon ? '!pr-10' : '',
|
||||
'focus:pl-[11px]',
|
||||
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
@@ -154,13 +171,18 @@ const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
'left-3',
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -191,6 +213,7 @@ const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (disabled.value) return 'text-m-muted'
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isFocused.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
|
||||
@@ -27,6 +27,7 @@ type InputPhoneProps = {
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
|
||||
@@ -56,6 +57,16 @@ describe('MalioInputPhone', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -264,10 +275,43 @@ describe('MalioInputPhone', () => {
|
||||
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})
|
||||
|
||||
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)', () => {
|
||||
@@ -298,6 +342,41 @@ describe('MalioInputPhone', () => {
|
||||
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 () => {
|
||||
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
||||
|
||||
@@ -305,4 +384,23 @@ describe('MalioInputPhone', () => {
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountComponent({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -44,7 +44,7 @@
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled || readonly"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -68,7 +68,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -83,9 +84,13 @@ import {vMaska} from 'maska/vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -110,6 +115,7 @@ const props = withDefaults(
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -134,6 +140,7 @@ const props = withDefaults(
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter un numéro',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -145,10 +152,15 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value
|
||||
? isFilled.value
|
||||
: isFocused.value || currentValue.value.length > 0,
|
||||
)
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
@@ -157,38 +169,49 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'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',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
isReadonly.value ? '' : focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70',
|
||||
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -248,6 +271,7 @@ const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
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 (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
|
||||
@@ -19,6 +19,8 @@ type InputRichTextProps = {
|
||||
groupClass?: string
|
||||
labelClass?: string
|
||||
editorClass?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
|
||||
@@ -155,6 +157,18 @@ describe('MalioInputRichText', () => {
|
||||
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 () => {
|
||||
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
|
||||
|
||||
@@ -162,4 +176,35 @@ describe('MalioInputRichText', () => {
|
||||
expect(html).toContain('Mon titre')
|
||||
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"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<!-- Mode lecture seule (rendu uniquement) -->
|
||||
@@ -22,6 +22,7 @@
|
||||
v-else
|
||||
:id="editorId"
|
||||
:class="mergedEditorWrapperClass"
|
||||
:aria-required="required || undefined"
|
||||
@click="focusEditor"
|
||||
>
|
||||
<div
|
||||
@@ -184,7 +185,7 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${editorId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -193,6 +194,7 @@
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -211,6 +213,7 @@ import Color from '@tiptap/extension-color'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
|
||||
|
||||
@@ -233,6 +236,8 @@ const props = withDefaults(
|
||||
groupClass?: string
|
||||
labelClass?: string
|
||||
editorClass?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -250,6 +255,8 @@ const props = withDefaults(
|
||||
groupClass: '',
|
||||
labelClass: '',
|
||||
editorClass: '',
|
||||
required: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -279,10 +286,11 @@ const mergedLabelClass = computed(() =>
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: isFocused.value
|
||||
? 'text-m-primary'
|
||||
: 'text-m-text',
|
||||
props.disabled ? 'text-black/60' : '',
|
||||
: props.disabled
|
||||
? 'text-m-muted'
|
||||
: isFocused.value
|
||||
? 'text-m-primary'
|
||||
: 'text-m-text',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -308,6 +316,7 @@ const mergedReadonlyClass = computed(() =>
|
||||
'prose-headings:font-semibold prose-a:text-m-primary',
|
||||
'prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none',
|
||||
'prose-pre:bg-m-text prose-pre:text-white',
|
||||
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
|
||||
props.editorClass,
|
||||
),
|
||||
)
|
||||
@@ -486,7 +495,7 @@ onMounted(() => {
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white',
|
||||
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
|
||||
},
|
||||
},
|
||||
onUpdate: () => {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -30,7 +30,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -52,7 +52,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -67,9 +68,13 @@ import {vMaska} from 'maska/vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -94,6 +99,7 @@ const props = withDefaults(
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
mask?: string | MaskInputOptions
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -117,6 +123,7 @@ const props = withDefaults(
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
mask: undefined,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -128,10 +135,15 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isReadonly.value
|
||||
? isFilled.value
|
||||
: isFocused.value || currentValue.value.length > 0,
|
||||
)
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
@@ -140,30 +152,40 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
'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',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
props.inputClass,
|
||||
iconInputPaddingClass.value,
|
||||
focusPaddingClass.value,
|
||||
isReadonly.value ? '' : focusPaddingClass.value,
|
||||
),
|
||||
)
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
labelPositionClass.value,
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -214,6 +236,7 @@ const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
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 (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
|
||||
@@ -21,6 +21,7 @@ type InputTextAreaProps = {
|
||||
error?: string
|
||||
success?: string
|
||||
rounded?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
|
||||
@@ -149,4 +150,87 @@ describe('MalioInputTextArea', () => {
|
||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
|
||||
})
|
||||
|
||||
it('renders as a single root element (works as a single grid item)', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const wrapper = mount(InputTextAreaForTest, {
|
||||
attachTo: host,
|
||||
})
|
||||
|
||||
// host > div[data-v-app] > component roots
|
||||
const app = host.firstElementChild as HTMLElement
|
||||
expect(app.children.length).toBe(1)
|
||||
|
||||
wrapper.unmount()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('applies primary scrollbar class on focus', async () => {
|
||||
const wrapper = mount(InputTextAreaForTest)
|
||||
|
||||
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||
|
||||
await wrapper.get('textarea').trigger('focus')
|
||||
|
||||
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
|
||||
})
|
||||
|
||||
it('removes primary scrollbar class on blur', async () => {
|
||||
const wrapper = mount(InputTextAreaForTest)
|
||||
|
||||
await wrapper.get('textarea').trigger('focus')
|
||||
await wrapper.get('textarea').trigger('blur')
|
||||
|
||||
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', required: true}})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de bleu', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
|
||||
const field = wrapper.get('textarea')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu focus', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
// En readonly, pas de couleur primary sur le label
|
||||
expect(wrapper.get('label').classes()).not.toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true, modelValue: 'du texte'}})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
|
||||
const msg = wrapper.find('[data-test="message-line"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false}})
|
||||
expect(wrapper.find('[data-test="message-line"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false, error: 'Erreur'}})
|
||||
const msg = wrapper.find('[data-test="message-line"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,88 +1,101 @@
|
||||
<template>
|
||||
<div :class="mergedGroupClass">
|
||||
<textarea
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
<div class="relative w-full flex-1">
|
||||
<textarea
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
|
||||
: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="[
|
||||
isFilled ? 'border-black' : 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||
hasError
|
||||
? 'border-m-danger focus:border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success focus:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
textInput,
|
||||
showCounterComputed ? 'pb-6' : '',
|
||||
rounded,
|
||||
]"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:rows="rowsCount"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:style="textareaStyle"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
||||
:class="[
|
||||
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
||||
disabled ? 'text-black/60' : '',
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
|
||||
textLabel,
|
||||
]"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<span
|
||||
v-if="showCounterComputed"
|
||||
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
|
||||
>
|
||||
{{ currentLength }}/{{ maxLength }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasError || hasSuccess || hint"
|
||||
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<p
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
: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="[
|
||||
isReadonly ? 'border-black' : (isFilled ? 'border-black' : 'border-m-muted'),
|
||||
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : (isReadonly ? 'cursor-default' : 'cursor-text'),
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
? 'border-m-danger focus:border-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'ml-[2px]',
|
||||
? 'border-m-success focus:border-m-success'
|
||||
: isReadonly ? '' : 'focus:border-m-primary',
|
||||
isReadonly ? '' : (isFocused ? 'textarea-scrollbar-primary' : ''),
|
||||
textInput,
|
||||
showCounterComputed ? 'pb-6' : '',
|
||||
rounded,
|
||||
keyboardFocused ? 'm-focus-ring-kbd' : '',
|
||||
]"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:rows="rowsCount"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:style="textareaStyle"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
/>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
|
||||
:class="[
|
||||
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: disabled
|
||||
? 'text-m-muted'
|
||||
: isReadonly
|
||||
? (isFilled ? 'text-black' : 'text-m-muted')
|
||||
: (isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted'),
|
||||
textLabel,
|
||||
]"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
<span
|
||||
v-if="showCounterComputed"
|
||||
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
|
||||
>
|
||||
{{ currentLength }}/{{ maxLength }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
data-test="message-line"
|
||||
class="mt-1 flex items-center justify-between gap-2 text-xs"
|
||||
:class="reserveMessageSpace ? 'min-h-[1rem]' : ''"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
<p
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'ml-[2px]',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -108,6 +121,7 @@ const props = withDefaults(
|
||||
success?: string
|
||||
rounded?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
|
||||
}>(),
|
||||
{
|
||||
@@ -134,11 +148,14 @@ const props = withDefaults(
|
||||
minResizeHeight: 40,
|
||||
maxResizeHeight: 320,
|
||||
groupClass: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -149,9 +166,15 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !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 currentLength = computed(() => (currentValue.value ?? '').length)
|
||||
const showCounterComputed = computed(() =>
|
||||
@@ -165,7 +188,6 @@ const textareaStyle = computed(() => ({
|
||||
minHeight: toCssSize(props.minResizeHeight),
|
||||
maxHeight: toCssSize(props.maxResizeHeight),
|
||||
}))
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
const describedBy = computed(() =>
|
||||
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
|
||||
)
|
||||
@@ -188,4 +210,8 @@ const onInput = (event: Event) => {
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.textarea-scrollbar-primary {
|
||||
scrollbar-color: rgb(var(--m-primary)) transparent;
|
||||
}
|
||||
</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 type {DefineComponent} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
@@ -12,11 +12,14 @@ type InputUploadProps = {
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
displayIcon?: boolean
|
||||
accept?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
|
||||
@@ -167,6 +170,11 @@ describe('MalioInputUpload', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent({accept: '.pdf,.doc'})
|
||||
|
||||
@@ -186,4 +194,70 @@ describe('MalioInputUpload', () => {
|
||||
|
||||
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"
|
||||
class="hidden"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
@@ -19,13 +20,16 @@
|
||||
:value="currentDisplayValue"
|
||||
:readonly="true"
|
||||
:aria-invalid="!!error"
|
||||
:aria-required="required || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="openFilePicker"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.enter.prevent="openFilePicker"
|
||||
@keydown.space.prevent="openFilePicker"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -33,24 +37,40 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
v-if="displayIcon"
|
||||
icon="mdi:cloud-arrow-up-outline"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="icon"
|
||||
:class="[
|
||||
iconStateClass,
|
||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="displayIcon || showClear"
|
||||
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||
>
|
||||
<button
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Retirer le fichier"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:close"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<IconifyIcon
|
||||
v-if="displayIcon"
|
||||
icon="mdi:cloud-arrow-up-outline"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="icon"
|
||||
:class="[iconStateClass, 'pointer-events-none']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -58,7 +78,8 @@
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
'mt-1 text-xs ml-[2px]',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
@@ -71,9 +92,13 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -83,11 +108,15 @@ const props = withDefaults(
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
displayIcon?: boolean
|
||||
accept?: string
|
||||
required?: boolean
|
||||
clearable?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -97,11 +126,15 @@ const props = withDefaults(
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
displayIcon: true,
|
||||
accept: '',
|
||||
required: false,
|
||||
clearable: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -114,10 +147,16 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-upload-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentDisplayValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const shouldFloatLabel = computed(() => isFocused.value || currentDisplayValue.value.length > 0)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
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(() =>
|
||||
twMerge(
|
||||
'relative flex h-12 w-full items-center',
|
||||
@@ -126,16 +165,24 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
|
||||
'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',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
disabled.value ? 'text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : '',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
props.displayIcon ? '!pr-10' : '',
|
||||
'focus:pl-[11px]',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
showClear.value
|
||||
? (props.displayIcon ? '!pr-16' : '!pr-10')
|
||||
: (props.displayIcon ? '!pr-10' : ''),
|
||||
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
disabled.value ? 'cursor-not-allowed' : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
@@ -143,13 +190,18 @@ const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
|
||||
'left-3',
|
||||
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
|
||||
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||
shouldFloatLabel.value
|
||||
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
|
||||
: '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
: disabled.value
|
||||
? 'text-m-muted'
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -165,10 +217,23 @@ const describedBy = computed(() => {
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'file-selected', file: File): void
|
||||
(event: 'clear'): void
|
||||
}>()
|
||||
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
|
||||
)
|
||||
|
||||
const onClear = () => {
|
||||
if (props.disabled || isReadonly.value) return
|
||||
if (!isControlled.value) localValue.value = ''
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (props.disabled) return
|
||||
if (props.disabled || props.readonly) return
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -185,12 +250,11 @@ const onFileChange = (event: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (disabled.value) return 'text-m-muted'
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isFocused.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||
|
||||
describe('normalizeAmount', () => {
|
||||
it('garde le point décimal', () => {
|
||||
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||
})
|
||||
it('convertit la virgule en point et nettoie', () => {
|
||||
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||
})
|
||||
it('normalise une décimale en tête', () => {
|
||||
expect(normalizeAmount(',5')).toBe('0.5')
|
||||
})
|
||||
it('retire les espaces', () => {
|
||||
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||
})
|
||||
it('limite à 2 décimales', () => {
|
||||
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||
})
|
||||
it('garde une décimale en cours de saisie', () => {
|
||||
expect(normalizeAmount('12.')).toBe('12.')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||
expect(normalizeAmount('abc')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(normalizeAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGroupedAmount', () => {
|
||||
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||
})
|
||||
it('utilise la virgule comme séparateur décimal', () => {
|
||||
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||
})
|
||||
it('affiche une virgule pour une décimale en cours', () => {
|
||||
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||
})
|
||||
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||
expect(formatGroupedAmount('12')).toBe('12')
|
||||
})
|
||||
it('groupe avec une décimale en tête', () => {
|
||||
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||
expect(formatGroupedAmount('')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(formatGroupedAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSignificant', () => {
|
||||
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||
expect(countSignificant('1 234', 5)).toBe(4)
|
||||
})
|
||||
it('ignore un espace juste avant le curseur', () => {
|
||||
expect(countSignificant('1 234', 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caretFromSignificant', () => {
|
||||
it('place le curseur après le n-ième caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||
})
|
||||
it('place le curseur en fin si on dépasse', () => {
|
||||
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||
})
|
||||
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||
export const normalizeAmount = (value: string): string => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||
export const formatGroupedAmount = (model: string): string => {
|
||||
if (model === '') return ''
|
||||
const hasDot = model.includes('.')
|
||||
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||
}
|
||||
|
||||
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
if (sig <= 0) return 0
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
@@ -173,6 +173,16 @@ describe('MalioRadioButton', () => {
|
||||
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 () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||
|
||||
@@ -178,6 +179,11 @@ const onChange = (event: Event) => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.radio-control input[type='radio']:focus-visible {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.radio-control.is-error input[type='radio'] {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ type SelectProps = {
|
||||
textLabel?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const SelectForTest = Select as DefineComponent<SelectProps>
|
||||
@@ -207,4 +210,173 @@ describe('MalioSelect', () => {
|
||||
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
|
||||
})
|
||||
|
||||
it('shows muted chevron color when empty and closed', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('shows primary chevron color when open', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('shows black chevron color when an option is selected and closed', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: 'fr', options},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('shows muted chevron color when disabled', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: 'fr', options, disabled: true},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('shows danger chevron color on error even when open', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options, error: 'Selection error'},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('shows success chevron color on success', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options, success: 'OK'},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ', required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ'},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('expose aria-required quand required est vrai', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options, required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'expose pas aria-required par défaut', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, options},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
const buttonClasses = wrapper.get('button').classes()
|
||||
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
|
||||
// !border-b-transparent keeps the 1px allocation but hides the line
|
||||
expect(buttonClasses).not.toContain('!border-b-0')
|
||||
expect(buttonClasses).toContain('!border-b-transparent')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.classes()).toContain('border-black')
|
||||
expect(trigger.classes()).not.toContain('border-m-muted')
|
||||
expect(trigger.classes()).not.toContain('grow-height')
|
||||
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
const label = wrapper.get('label')
|
||||
expect(label.classes()).not.toContain('text-m-primary')
|
||||
expect(label.classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly sélectionné : label noir + chevron noir', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: 'a', options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('readonly empêche l’ouverture du dropdown', async () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
|
||||
const wrapper = mount(SelectForTest, {
|
||||
props: {modelValue: null, label: 'Champ', readonly: true, options},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.attributes('aria-readonly')).toBe('true')
|
||||
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disabled + readonly : pas d’aria-readonly (disabled prime)', () => {
|
||||
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.attributes('aria-readonly')).toBeUndefined()
|
||||
expect(trigger.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options}})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false}})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,38 +8,53 @@
|
||||
:id="buttonId"
|
||||
ref="buttonRef"
|
||||
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="[
|
||||
isReadonly ? '' : 'grow-height',
|
||||
isReadonly ? '' : 'focus-visible:border-m-primary',
|
||||
hasError
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||
: 'border-m-danger'
|
||||
: hasSuccess
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-success !border-t-0'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||
: 'border-m-success'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
: isReadonly
|
||||
? 'border-black'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -50,16 +65,20 @@
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
: isReadonly
|
||||
? isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
: 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
textLabel,
|
||||
]"
|
||||
:style="labelTransformStyle"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<span
|
||||
@@ -73,13 +92,24 @@
|
||||
</span>
|
||||
|
||||
<span
|
||||
data-test="chevron"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? '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">
|
||||
@@ -113,7 +143,10 @@
|
||||
? 'border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -145,7 +178,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${buttonId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -154,6 +187,7 @@
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -162,12 +196,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number | null
|
||||
@@ -183,10 +221,14 @@ const props = withDefaults(defineProps<{
|
||||
textField?: string
|
||||
textValue?: string
|
||||
textLabel?: string
|
||||
fieldClass?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
groupClass?: string
|
||||
noOptionsText?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(), {
|
||||
options: () => [],
|
||||
emptyOptionLabel: '',
|
||||
@@ -197,10 +239,14 @@ const props = withDefaults(defineProps<{
|
||||
textField: 'text-lg',
|
||||
textValue: 'text-lg',
|
||||
textLabel: 'text-sm',
|
||||
fieldClass: '',
|
||||
rounded: 'rounded-md',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
groupClass: '',
|
||||
noOptionsText: 'Aucune option disponible',
|
||||
required: false,
|
||||
reserveMessageSpace: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -228,8 +274,9 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isOptionSelected = computed(() =>
|
||||
props.options.some(o => o.value === props.modelValue)
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isOpen.value || isOptionSelected.value
|
||||
isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value)
|
||||
)
|
||||
const selectedLabel = computed(() =>
|
||||
props.options.find(o => o.value === props.modelValue)?.label ?? ''
|
||||
@@ -257,6 +304,7 @@ function updateOpenDirection() {
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (props.disabled || props.readonly) return
|
||||
updateOpenDirection()
|
||||
isOpen.value = true
|
||||
|
||||
@@ -300,7 +348,7 @@ function close() {
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
return
|
||||
@@ -311,7 +359,68 @@ function toggle() {
|
||||
function select(value: string | number | null) {
|
||||
emit('update:modelValue', value)
|
||||
close()
|
||||
buttonRef.value?.blur()
|
||||
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
|
||||
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
|
||||
// les options ; au clavier le focus n'a jamais quitté le bouton.
|
||||
buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) select(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
@@ -330,12 +439,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
}
|
||||
|
||||
.grow-height {
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||
}
|
||||
|
||||
.grow-height:focus {
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 SelectCheckbox from './SelectCheckbox.vue'
|
||||
|
||||
@@ -9,7 +9,7 @@ type Option = {
|
||||
}
|
||||
|
||||
type SelectCheckboxProps = {
|
||||
modelValue: Array<string | number>
|
||||
modelValue?: Array<string | number>
|
||||
options?: Option[]
|
||||
emptyOptionLabel?: string
|
||||
label?: string
|
||||
@@ -24,7 +24,10 @@ type SelectCheckboxProps = {
|
||||
displaySelectAll?: boolean
|
||||
selectAllLabel?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
||||
@@ -36,6 +39,18 @@ const options: Option[] = [
|
||||
]
|
||||
|
||||
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 () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
@@ -182,4 +197,173 @@ describe('MalioSelectCheckbox', () => {
|
||||
const root = wrapper.find('button').element.parentElement
|
||||
expect(root?.className).toContain('mt-4')
|
||||
})
|
||||
|
||||
it('shows muted chevron color when nothing is selected and closed', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('shows primary chevron color when open', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('shows black chevron color when options are selected and closed', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: ['fr'], options},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('shows muted chevron color when disabled', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: ['fr'], options, disabled: true},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('shows danger chevron color on error even when open', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options, error: 'Selection error'},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('shows success chevron color on success', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options, success: 'OK'},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], label: 'Champ', required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], label: 'Champ'},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('expose aria-required quand required est vrai', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options, required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'expose pas aria-required par défaut', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
const buttonClasses = wrapper.get('button').classes()
|
||||
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
|
||||
// !border-b-transparent keeps the 1px allocation but hides the line
|
||||
expect(buttonClasses).not.toContain('!border-b-0')
|
||||
expect(buttonClasses).toContain('!border-b-transparent')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.classes()).toContain('border-black')
|
||||
expect(trigger.classes()).not.toContain('border-m-muted')
|
||||
expect(trigger.classes()).not.toContain('grow-height')
|
||||
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
const label = wrapper.get('label')
|
||||
expect(label.classes()).not.toContain('text-m-primary')
|
||||
expect(label.classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly sélectionné : label noir + chevron noir', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: ['a'], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('readonly empêche l’ouverture du dropdown', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.attributes('aria-readonly')).toBe('true')
|
||||
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disabled + readonly : pas d’aria-readonly (disabled prime)', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {modelValue: [], label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.attributes('aria-readonly')).toBeUndefined()
|
||||
expect(trigger.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options}})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false}})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,38 +8,53 @@
|
||||
:id="buttonId"
|
||||
ref="buttonRef"
|
||||
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="[
|
||||
isReadonly ? '' : 'grow-height',
|
||||
isReadonly ? '' : 'focus-visible:border-m-primary',
|
||||
hasError
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
|
||||
: 'border-m-danger'
|
||||
: hasSuccess
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-success !border-t-0'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-success !border-t-transparent'
|
||||
: 'border-m-success'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
||||
: isReadonly
|
||||
? 'border-black'
|
||||
: isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="!isOpen ? undefined : (activeIndex === -1 ? selectAllId : (activeIndex >= 0 ? optionId(activeIndex) : undefined))"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -50,16 +65,20 @@
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
: isReadonly
|
||||
? isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
: 'text-m-muted'
|
||||
: isOpen
|
||||
? 'text-m-primary'
|
||||
: isOptionSelected
|
||||
? 'text-black'
|
||||
: 'text-m-muted',
|
||||
textLabel,
|
||||
]"
|
||||
:style="labelTransformStyle"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
@@ -101,13 +120,24 @@
|
||||
</span>
|
||||
|
||||
<span
|
||||
data-test="chevron"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? '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">
|
||||
@@ -141,7 +171,10 @@
|
||||
? 'border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -153,17 +186,23 @@
|
||||
</li>
|
||||
<li
|
||||
v-if="displaySelectAll && normalizedOptions.length > 0"
|
||||
class="border-b border-m-muted/30 px-3 py-2"
|
||||
:id="selectAllId"
|
||||
role="option"
|
||||
:aria-selected="allSelected"
|
||||
class="cursor-pointer border-b border-m-muted/30 px-3 py-2"
|
||||
:class="[activeIndex === -1 ? 'bg-m-muted/10' : '']"
|
||||
@mouseenter="activeIndex = -1"
|
||||
@mousedown.prevent
|
||||
@click="toggleAll"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:label="selectAllLabel"
|
||||
:disabled="disabled"
|
||||
group-class="!mt-0"
|
||||
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
||||
group-class="!mt-0 pointer-events-none"
|
||||
label-class="option-checkbox w-full font-semibold"
|
||||
tabindex="-1"
|
||||
@update:model-value="toggleAll"
|
||||
:reserve-message-space="false"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
@@ -172,7 +211,7 @@
|
||||
:key="String(opt.value)"
|
||||
role="option"
|
||||
:aria-selected="isChecked(opt.value)"
|
||||
class="px-3 py-2"
|
||||
class="cursor-pointer px-3 py-2"
|
||||
:class="[
|
||||
index === activeIndex ? 'bg-m-muted/10' : '',
|
||||
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
||||
@@ -180,21 +219,22 @@
|
||||
]"
|
||||
@mouseenter="activeIndex = index"
|
||||
@mousedown.prevent
|
||||
@click="toggleOption(opt.value)"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isChecked(opt.value)"
|
||||
:label="opt.label || '\u00A0'"
|
||||
:disabled="disabled"
|
||||
group-class="!mt-0"
|
||||
label-class="option-checkbox w-full cursor-pointer"
|
||||
group-class="!mt-0 pointer-events-none"
|
||||
label-class="option-checkbox w-full"
|
||||
tabindex="-1"
|
||||
@update:model-value="toggleOption(opt.value)"
|
||||
:reserve-message-space="false"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${buttonId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -203,6 +243,7 @@
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -211,19 +252,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import Checkbox from '../checkbox/Checkbox.vue'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number
|
||||
}
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Array<string | number>
|
||||
modelValue?: Array<string | number>
|
||||
options?: Option[]
|
||||
emptyOptionLabel?: string
|
||||
label?: string
|
||||
@@ -238,9 +283,13 @@ const props = withDefaults(defineProps<{
|
||||
displaySelectAll?: boolean
|
||||
selectAllLabel?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
groupClass?: string
|
||||
noOptionsText?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
options: () => [],
|
||||
emptyOptionLabel: '',
|
||||
label: '',
|
||||
@@ -255,8 +304,11 @@ const props = withDefaults(defineProps<{
|
||||
displaySelectAll: false,
|
||||
selectAllLabel: 'Tout sélectionner',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
groupClass: '',
|
||||
noOptionsText: 'Aucune option disponible',
|
||||
required: false,
|
||||
reserveMessageSpace: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -270,6 +322,9 @@ const openDirection = ref<'down' | 'up'>('down')
|
||||
const uid = useId()
|
||||
const buttonId = `custom-select-btn-${uid}`
|
||||
const listboxId = `custom-select-listbox-${uid}`
|
||||
const selectAllId = `custom-select-all-${uid}`
|
||||
// Index actif le plus bas : -1 cible la ligne « tout sélectionner » quand elle est affichée
|
||||
const lowestIndex = computed(() => (props.displaySelectAll && normalizedOptions.value.length > 0 ? -1 : 0))
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const listHeight = ref(0)
|
||||
const normalizedOptions = computed<Option[]>(() => props.options)
|
||||
@@ -281,6 +336,7 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isOptionSelected = computed(() =>
|
||||
props.modelValue.length > 0
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const selectedOptions = computed(() =>
|
||||
normalizedOptions.value.filter(option => props.modelValue.includes(option.value)),
|
||||
)
|
||||
@@ -288,7 +344,7 @@ const displayTags = computed(() =>
|
||||
props.displayTag && selectedOptions.value.length > 0,
|
||||
)
|
||||
const shouldFloatLabel = computed(() =>
|
||||
isOpen.value || displayTags.value
|
||||
isReadonly.value ? isOptionSelected.value : (isOpen.value || displayTags.value)
|
||||
)
|
||||
const selectionSummary = computed(() =>
|
||||
`${props.modelValue.length}/${normalizedOptions.value.length}`
|
||||
@@ -320,6 +376,7 @@ function updateOpenDirection() {
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (props.disabled || props.readonly) return
|
||||
updateOpenDirection()
|
||||
isOpen.value = true
|
||||
|
||||
@@ -363,7 +420,7 @@ function close() {
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
return
|
||||
@@ -393,6 +450,70 @@ function toggleAll() {
|
||||
nextTick(() => buttonRef.value?.focus())
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (!isOpen.value) return
|
||||
await nextTick()
|
||||
const id = index === -1 ? selectAllId : (index >= 0 ? optionId(index) : null)
|
||||
if (id) document.getElementById(id)?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte (multi-select : Entrée/Espace togglent et la liste reste ouverte)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
// -1 = ligne « tout sélectionner »
|
||||
if (activeIndex.value === -1) {
|
||||
toggleAll()
|
||||
return
|
||||
}
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) toggleOption(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, lowestIndex.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = lowestIndex.value
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!root.value) return
|
||||
if (!root.value.contains(e.target as Node)) close()
|
||||
@@ -409,12 +530,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
}
|
||||
|
||||
.grow-height {
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||
}
|
||||
|
||||
.grow-height:focus {
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import RequiredMark from './RequiredMark.vue'
|
||||
|
||||
describe('MalioRequiredMark', () => {
|
||||
it('rend un astérisque', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.text()).toBe('*')
|
||||
})
|
||||
|
||||
it('est masqué pour les technologies d\'assistance', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('utilise le token de couleur danger', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('rend l\'astérisque à 16px', () => {
|
||||
const wrapper = mount(RequiredMark)
|
||||
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-[16px]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<span
|
||||
data-test="required-mark"
|
||||
aria-hidden="true"
|
||||
class="ml-0.5 select-none text-[16px] leading-none text-m-danger"
|
||||
>*</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
import {ref} from 'vue'
|
||||
|
||||
/**
|
||||
* Détection de la modalité de focus (clavier vs souris/tactile).
|
||||
*
|
||||
* Sur les champs texte, `:focus-visible` natif se déclenche AUSSI au clic souris
|
||||
* (le navigateur suppose qu'on va taper). Pour n'afficher l'anneau de focus qu'à
|
||||
* la navigation clavier (Tab), on suit la dernière interaction au niveau document
|
||||
* et on n'arme l'anneau que si le focus a été précédé d'un évènement clavier.
|
||||
*
|
||||
* Le visuel « champ actif » existant (grossissement, label flottant, bordure bleue)
|
||||
* reste piloté par `:focus` et n'est pas affecté : ce composable ne gère QUE l'anneau.
|
||||
*/
|
||||
|
||||
let hadKeyboardEvent = false
|
||||
let listenersAttached = false
|
||||
|
||||
function ensureGlobalListeners() {
|
||||
if (listenersAttached || typeof document === 'undefined') return
|
||||
listenersAttached = true
|
||||
|
||||
// capture=true pour observer l'évènement avant qu'il n'atteigne sa cible
|
||||
document.addEventListener('keydown', () => {
|
||||
hadKeyboardEvent = true
|
||||
}, true)
|
||||
|
||||
const markPointer = () => {
|
||||
hadKeyboardEvent = false
|
||||
}
|
||||
document.addEventListener('mousedown', markPointer, true)
|
||||
document.addEventListener('pointerdown', markPointer, true)
|
||||
document.addEventListener('touchstart', markPointer, true)
|
||||
}
|
||||
|
||||
export function useKbdFocusRing() {
|
||||
ensureGlobalListeners()
|
||||
|
||||
const keyboardFocused = ref(false)
|
||||
|
||||
const onFocus = () => {
|
||||
keyboardFocused.value = hadKeyboardEvent
|
||||
}
|
||||
const onBlur = () => {
|
||||
keyboardFocused.value = false
|
||||
}
|
||||
|
||||
return {keyboardFocused, onFocus, onBlur}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ type TabListProps = {
|
||||
tabs: Tab[]
|
||||
modelValue?: string
|
||||
id?: string
|
||||
maxVisibleTabs?: number
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
const TabListForTest = TabList as DefineComponent<TabListProps>
|
||||
@@ -185,3 +187,154 @@ describe('MalioTabList', () => {
|
||||
expect(buttons[1].attributes('aria-selected')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MalioTabList — fenêtrage maxVisibleTabs', () => {
|
||||
const sevenTabs: Tab[] = [
|
||||
{key: 't1', label: 'Tab 1'},
|
||||
{key: 't2', label: 'Tab 2'},
|
||||
{key: 't3', label: 'Tab 3'},
|
||||
{key: 't4', label: 'Tab 4'},
|
||||
{key: 't5', label: 'Tab 5'},
|
||||
{key: 't6', label: 'Tab 6'},
|
||||
{key: 't7', label: 'Tab 7'},
|
||||
]
|
||||
|
||||
it('applies the default maxWidth (1100px) on the tabs container when windowed', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px')
|
||||
})
|
||||
|
||||
it('applies a custom maxWidth on the tabs container', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200})
|
||||
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px')
|
||||
})
|
||||
|
||||
it('renders only maxVisibleTabs buttons and disables prev at start', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
const buttons = wrapper.findAll('[role="tab"]')
|
||||
expect(buttons).toHaveLength(5)
|
||||
expect(buttons[0].text()).toContain('Tab 1')
|
||||
expect(buttons[4].text()).toContain('Tab 5')
|
||||
|
||||
const prev = wrapper.find('[data-test="tab-prev"]')
|
||||
const next = wrapper.find('[data-test="tab-next"]')
|
||||
expect(prev.exists()).toBe(true)
|
||||
expect(next.exists()).toBe(true)
|
||||
expect(prev.attributes('disabled')).toBeDefined()
|
||||
expect(next.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shifts the window by 1 on next click', async () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
|
||||
const labels = wrapper.findAll('[role="tab"]').map(b => b.text())
|
||||
expect(labels.some(l => l.includes('Tab 1'))).toBe(false)
|
||||
expect(labels.some(l => l.includes('Tab 6'))).toBe(true)
|
||||
expect(labels).toHaveLength(5)
|
||||
|
||||
expect(wrapper.find('[data-test="tab-prev"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables next at the end and shows the last window', async () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
|
||||
// 7 - 5 = 2 clicks to reach the end
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
|
||||
const next = wrapper.find('[data-test="tab-next"]')
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
|
||||
const buttons = wrapper.findAll('[role="tab"]')
|
||||
expect(buttons).toHaveLength(5)
|
||||
// last window starts at tabs[length-5] = tabs[2] = Tab 3
|
||||
expect(buttons[0].text()).toContain('Tab 3')
|
||||
expect(buttons[4].text()).toContain('Tab 7')
|
||||
})
|
||||
|
||||
it('clicking next past the end does not overshoot', async () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
const next = wrapper.find('[data-test="tab-next"]')
|
||||
|
||||
await next.trigger('click')
|
||||
await next.trigger('click')
|
||||
await next.trigger('click') // guarded, no-op
|
||||
await next.trigger('click') // guarded, no-op
|
||||
|
||||
const buttons = wrapper.findAll('[role="tab"]')
|
||||
expect(buttons).toHaveLength(5)
|
||||
expect(buttons[0].text()).toContain('Tab 3')
|
||||
expect(buttons[4].text()).toContain('Tab 7')
|
||||
})
|
||||
|
||||
it('renders no arrows and all tabs when maxVisibleTabs is undefined', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs})
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(7)
|
||||
expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders no arrows when maxVisibleTabs >= tabs.length', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 7})
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(7)
|
||||
expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('selecting a visible tab activates it without moving the window', async () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
const buttons = wrapper.findAll('[role="tab"]')
|
||||
|
||||
await buttons[2].trigger('click')
|
||||
|
||||
const after = wrapper.findAll('[role="tab"]')
|
||||
expect(after[2].attributes('aria-selected')).toBe('true')
|
||||
// window unchanged
|
||||
expect(after[0].text()).toContain('Tab 1')
|
||||
expect(after).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('keeps the active panel rendered even when its tab is outside the window', async () => {
|
||||
const wrapper = mountComponent(
|
||||
{tabs: sevenTabs, maxVisibleTabs: 5, modelValue: 't1'},
|
||||
{t1: '<p>Panel 1</p>'},
|
||||
)
|
||||
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
|
||||
// Tab 1 is no longer in the window
|
||||
const labels = wrapper.findAll('[role="tab"]').map(b => b.text())
|
||||
expect(labels.some(l => l.includes('Tab 1'))).toBe(false)
|
||||
|
||||
// but its panel is still rendered and visible
|
||||
const panels = wrapper.findAll('[role="tabpanel"]')
|
||||
expect(panels).toHaveLength(7)
|
||||
expect(wrapper.text()).toContain('Panel 1')
|
||||
})
|
||||
|
||||
it('keeps exactly one rendered tab with tabindex=0 when the active tab scrolls out of the window', async () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
|
||||
// active tab is the first one (t1) by default; scroll it out of the window
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
await wrapper.find('[data-test="tab-next"]').trigger('click')
|
||||
|
||||
// t1 is no longer rendered
|
||||
const labels = wrapper.findAll('[role="tab"]').map(b => b.text())
|
||||
expect(labels.some(l => l.includes('Tab 1'))).toBe(false)
|
||||
|
||||
const focusable = wrapper.findAll('[role="tab"]').filter(b => b.attributes('tabindex') === '0')
|
||||
expect(focusable).toHaveLength(1)
|
||||
// falls back to the first visible tab (Tab 3)
|
||||
expect(focusable[0].text()).toContain('Tab 3')
|
||||
})
|
||||
|
||||
it('arrows expose aria-labels', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
expect(wrapper.find('[data-test="tab-prev"]').attributes('aria-label')).toBe('Onglets précédents')
|
||||
expect(wrapper.find('[data-test="tab-next"]').attributes('aria-label')).toBe('Onglets suivants')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,81 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Onglets précédents"
|
||||
data-test="tab-prev"
|
||||
:disabled="!canPrev"
|
||||
:class="[
|
||||
'transition-colors',
|
||||
canPrev
|
||||
? 'cursor-pointer text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active'
|
||||
: 'cursor-not-allowed text-m-disabled',
|
||||
]"
|
||||
@click="prev"
|
||||
>
|
||||
<IconifyIcon icon="mdi:chevron-left" :width="28" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex flex-1 justify-center gap-[60px]"
|
||||
:style="{ maxWidth: `${maxWidth}px` }"
|
||||
>
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
:id="`${componentId}-tab-${tab.key}`"
|
||||
:key="tab.key"
|
||||
role="tab"
|
||||
type="button"
|
||||
:aria-selected="activeTab === tab.key"
|
||||
:aria-controls="`${componentId}-panel-${tab.key}`"
|
||||
:aria-disabled="!!tab.disabled"
|
||||
:tabindex="focusedKey === tab.key ? 0 : -1"
|
||||
:disabled="tab.disabled"
|
||||
:class="[
|
||||
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
|
||||
: tab.disabled
|
||||
? 'cursor-not-allowed text-m-primary/50'
|
||||
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
|
||||
]"
|
||||
@click="selectTab(tab.key)"
|
||||
>
|
||||
<IconifyIcon
|
||||
v-if="tab.icon"
|
||||
:icon="tab.icon"
|
||||
:width="tab.iconSize ?? 24"
|
||||
/>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Onglets suivants"
|
||||
data-test="tab-next"
|
||||
:disabled="!canNext"
|
||||
:class="[
|
||||
'transition-colors',
|
||||
canNext
|
||||
? 'cursor-pointer text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active'
|
||||
: 'cursor-not-allowed text-m-disabled',
|
||||
]"
|
||||
@click="next"
|
||||
>
|
||||
<IconifyIcon icon="mdi:chevron-right" :width="28" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
role="tablist"
|
||||
class="flex justify-center gap-[60px] border-b border-m-primary"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
v-for="tab in visibleTabs"
|
||||
:id="`${componentId}-tab-${tab.key}`"
|
||||
:key="tab.key"
|
||||
role="tab"
|
||||
@@ -13,7 +83,7 @@
|
||||
:aria-selected="activeTab === tab.key"
|
||||
:aria-controls="`${componentId}-panel-${tab.key}`"
|
||||
:aria-disabled="!!tab.disabled"
|
||||
:tabindex="activeTab === tab.key ? 0 : -1"
|
||||
:tabindex="focusedKey === tab.key ? 0 : -1"
|
||||
:disabled="tab.disabled"
|
||||
:class="[
|
||||
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||
@@ -40,7 +110,8 @@
|
||||
:id="`${componentId}-panel-${tab.key}`"
|
||||
:key="tab.key"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="`${componentId}-tab-${tab.key}`"
|
||||
:aria-labelledby="isTabRendered(tab.key) ? `${componentId}-tab-${tab.key}` : undefined"
|
||||
:aria-label="isTabRendered(tab.key) ? undefined : tab.label"
|
||||
>
|
||||
<slot :name="tab.key" />
|
||||
</div>
|
||||
@@ -48,7 +119,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useId} from 'vue'
|
||||
import {computed, ref, useId, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
|
||||
defineOptions({name: 'MalioTabList', inheritAttrs: false})
|
||||
@@ -65,9 +136,13 @@ const props = withDefaults(defineProps<{
|
||||
tabs: Tab[]
|
||||
modelValue?: string
|
||||
id?: string
|
||||
maxVisibleTabs?: number
|
||||
maxWidth?: number
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
id: '',
|
||||
maxVisibleTabs: undefined,
|
||||
maxWidth: 1100,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -84,6 +159,53 @@ const activeTab = computed(() =>
|
||||
isControlled.value ? props.modelValue! : localValue.value,
|
||||
)
|
||||
|
||||
const isWindowed = computed(() =>
|
||||
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs,
|
||||
)
|
||||
|
||||
const maxStartIndex = computed(() =>
|
||||
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0,
|
||||
)
|
||||
|
||||
const startIndex = ref(0)
|
||||
|
||||
const visibleTabs = computed(() =>
|
||||
isWindowed.value
|
||||
? props.tabs.slice(startIndex.value, startIndex.value + props.maxVisibleTabs!)
|
||||
: props.tabs,
|
||||
)
|
||||
|
||||
const focusedKey = computed(() => {
|
||||
if (!isWindowed.value) return activeTab.value
|
||||
const inView = visibleTabs.value.some(t => t.key === activeTab.value)
|
||||
return inView ? activeTab.value : (visibleTabs.value[0]?.key ?? '')
|
||||
})
|
||||
|
||||
const isTabRendered = (key: string) => !isWindowed.value || visibleTabs.value.some(t => t.key === key)
|
||||
|
||||
const canPrev = computed(() => isWindowed.value && startIndex.value > 0)
|
||||
const canNext = computed(() => isWindowed.value && startIndex.value < maxStartIndex.value)
|
||||
|
||||
function prev() {
|
||||
if (!canPrev.value) return
|
||||
startIndex.value -= 1
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (!canNext.value) return
|
||||
startIndex.value += 1
|
||||
}
|
||||
|
||||
// Clamp startIndex back in range if tabs or maxVisibleTabs change.
|
||||
watch(maxStartIndex, (max) => {
|
||||
if (startIndex.value > max) startIndex.value = max
|
||||
})
|
||||
|
||||
// Reset the window to the start when the tab list is replaced.
|
||||
watch(() => props.tabs, () => {
|
||||
startIndex.value = 0
|
||||
})
|
||||
|
||||
function selectTab(key: string) {
|
||||
const tab = props.tabs.find(t => t.key === key)
|
||||
if (tab?.disabled) return
|
||||
|
||||
@@ -17,6 +17,7 @@ type TimeProps = {
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const TimeForTest = Time as DefineComponent<TimeProps>
|
||||
@@ -76,4 +77,33 @@ describe('MalioTime', () => {
|
||||
expect(inputs[0].classes()).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"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
@@ -67,6 +67,7 @@
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -77,6 +78,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
|
||||
defineOptions({name: 'MalioTime', inheritAttrs: false})
|
||||
|
||||
@@ -95,6 +97,7 @@ const props = withDefaults(
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -110,6 +113,7 @@ const props = withDefaults(
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type TimePickerProps = {
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps>
|
||||
@@ -52,6 +53,12 @@ describe('MalioTimePicker', () => {
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('n\'ouvre pas le popover si readonly', async () => {
|
||||
const wrapper = mountPicker({readonly: true})
|
||||
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('émet la valeur réglée depuis les molettes', async () => {
|
||||
const wrapper = mountPicker({modelValue: '09:30'})
|
||||
await wrapper.get('[data-test="time-field"]').trigger('click')
|
||||
@@ -73,4 +80,64 @@ describe('MalioTimePicker', () => {
|
||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('Heure requise')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mountPicker({label: 'Champ', required: true})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'affiche pas l\'astérisque par défaut', () => {
|
||||
const wrapper = mountPicker({label: 'Champ'})
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly vide : bordure noire sans bleu', () => {
|
||||
const wrapper = mountPicker({readonly: true})
|
||||
const input = wrapper.get('[data-test="time-field"]')
|
||||
expect(input.classes()).toContain('border-black')
|
||||
expect(input.classes()).not.toContain('border-m-muted')
|
||||
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label muted sans bleu', () => {
|
||||
const wrapper = mountPicker({readonly: true, label: 'Heure'})
|
||||
const label = wrapper.get('label')
|
||||
expect(label.classes()).toContain('text-m-muted')
|
||||
expect(label.classes()).not.toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : icône horloge en text-m-muted', () => {
|
||||
const wrapper = mountPicker({readonly: true, label: 'Heure'})
|
||||
expect(wrapper.get('[data-test="clock-icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label et icône en noir, bordure noire', () => {
|
||||
const wrapper = mountPicker({readonly: true, label: 'Heure', modelValue: '14:30'})
|
||||
const input = wrapper.get('[data-test="time-field"]')
|
||||
const label = wrapper.get('label')
|
||||
const icon = wrapper.get('[data-test="clock-icon"]')
|
||||
expect(input.classes()).toContain('border-black')
|
||||
expect(input.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(label.classes()).toContain('text-black')
|
||||
expect(icon.classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mountPicker({label: 'Champ'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
|
||||
const wrapper = mountPicker({label: 'Champ', reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mountPicker({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
|
||||
const msg = wrapper.find('[id$="-describedby"]')
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
@@ -78,11 +78,12 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
@@ -94,6 +95,7 @@
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import TimeWheels from './internal/TimeWheels.vue'
|
||||
|
||||
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
|
||||
@@ -116,6 +118,7 @@ const props = withDefaults(
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -134,6 +137,7 @@ const props = withDefaults(
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -153,6 +157,7 @@ const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const displayValue = computed(() => currentValue.value ?? '')
|
||||
const isFilled = computed(() => displayValue.value.length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const wheelsValue = computed(() => currentValue.value || '00:00')
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||
@@ -192,14 +197,16 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
|
||||
hasError.value
|
||||
? 'border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
isOpen.value ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
@@ -207,14 +214,16 @@ const mergedInputClass = computed(() =>
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
|
||||
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: isOpen.value
|
||||
? 'text-m-primary'
|
||||
: 'text-black peer-placeholder-shown:text-m-muted',
|
||||
: isReadonly.value
|
||||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
: isOpen.value
|
||||
? 'text-m-primary'
|
||||
: 'text-black peer-placeholder-shown:text-m-muted',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -222,6 +231,7 @@ const mergedLabelClass = computed(() =>
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||||
if (isOpen.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date de naissance"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||
<MalioDate
|
||||
@@ -91,4 +101,5 @@ const simpleValue = ref<string | null>(null)
|
||||
const initialValue = ref<string | null>(todayIso)
|
||||
const boundedValue = ref<string | null>(null)
|
||||
const errorValue = ref<string | null>(null)
|
||||
const editableValue = ref<string | null>(null)
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputAmount
|
||||
@@ -251,6 +262,7 @@ import {ref} from 'vue'
|
||||
import MalioInputAmount from '../../components/malio/input/InputAmount.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const bigValue = ref('1234567.89')
|
||||
const hintValue = ref('')
|
||||
const disabledValue = ref('1500.00')
|
||||
const readonlyValue = ref('2450.75')
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputEmail
|
||||
v-model="addableValue"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputEmail
|
||||
@@ -251,6 +264,9 @@ import {ref} from 'vue'
|
||||
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const addableValue = ref('')
|
||||
const addClicks = ref(0)
|
||||
const onAdd = () => { addClicks.value += 1 }
|
||||
const leftIconValue = ref('')
|
||||
const noIconValue = ref('')
|
||||
const hintValue = ref('')
|
||||
|
||||
@@ -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,532 @@
|
||||
# MalioInputAmount — séparateurs de milliers — 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:** Afficher les montants groupés à la française (`1 234 567,89`) en temps réel dans `MalioInputAmount`, tout en émettant un `modelValue` propre inchangé (`1234567.89`).
|
||||
|
||||
**Architecture:** Extraction des fonctions pures (`normalizeAmount` déplacé + `formatGroupedAmount` + helpers curseur) dans `composables/amountFormat.ts`. Le composant binde l'affichage groupé (`formatGroupedAmount(currentValue)`) et, à la frappe, parse vers le modèle propre (émis), reformate, et repositionne le curseur en comptant les caractères significatifs. `maxLength` borne la longueur du modèle (plus de `maxlength` natif).
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Vitest + @vue/test-utils (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `app/components/malio/input/composables/amountFormat.ts` — fonctions pures : `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant`.
|
||||
- **Create** `app/components/malio/input/composables/amountFormat.test.ts` — tests unitaires des fonctions pures.
|
||||
- **Modify** `app/components/malio/input/InputAmount.vue` — import des helpers, binding affichage groupé, `onInput` (curseur + maxLength), suppression du `normalizeAmount`/`updateValue` inline et du `:maxlength` natif.
|
||||
- **Modify** `app/components/malio/input/InputAmount.test.ts` — assertions d'affichage (brut → groupé), nouveaux tests.
|
||||
- **Modify** `COMPONENTS.md` — note affichage groupé + contrat modèle + `maxLength`.
|
||||
- **Modify** `CHANGELOG.md` — entrée.
|
||||
- **Modify** `app/story/input/inputAmount.story.vue` + `.playground/pages/composant/input/inputAmount.vue` — exemple grand montant.
|
||||
|
||||
**Note hooks pré-commit :** `make pre-commit` lance lint + suite complète (~900 tests), KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Stager des fichiers explicites — **jamais** `git add -A` (`nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
|
||||
|
||||
**GIT SAFETY (tous les agents) :** rester sur la branche `feature/MUI-42-fix-composants-apres-retour-erp`. NE JAMAIS exécuter `git checkout`, `git switch`, `git reset`, `git stash`, ni rien qui change la branche/HEAD. Uniquement `git add <fichiers>` et `git commit`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `amountFormat.ts` — fonctions pures (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/input/composables/amountFormat.ts`
|
||||
- Create: `app/components/malio/input/composables/amountFormat.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent car le module n'existe pas)**
|
||||
|
||||
Créer `app/components/malio/input/composables/amountFormat.test.ts` :
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||
|
||||
describe('normalizeAmount', () => {
|
||||
it('garde le point décimal', () => {
|
||||
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||
})
|
||||
it('convertit la virgule en point et nettoie', () => {
|
||||
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||
})
|
||||
it('normalise une décimale en tête', () => {
|
||||
expect(normalizeAmount(',5')).toBe('0.5')
|
||||
})
|
||||
it('retire les espaces', () => {
|
||||
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||
})
|
||||
it('limite à 2 décimales', () => {
|
||||
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||
})
|
||||
it('garde une décimale en cours de saisie', () => {
|
||||
expect(normalizeAmount('12.')).toBe('12.')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||
expect(normalizeAmount('abc')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(normalizeAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGroupedAmount', () => {
|
||||
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||
})
|
||||
it('utilise la virgule comme séparateur décimal', () => {
|
||||
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||
})
|
||||
it('affiche une virgule pour une décimale en cours', () => {
|
||||
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||
})
|
||||
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||
expect(formatGroupedAmount('12')).toBe('12')
|
||||
})
|
||||
it('groupe avec une décimale en tête', () => {
|
||||
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||
expect(formatGroupedAmount('')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(formatGroupedAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSignificant', () => {
|
||||
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||
// "1 234|" → curseur en position 5, 4 caractères significatifs (1,2,3,4)
|
||||
expect(countSignificant('1 234', 5)).toBe(4)
|
||||
})
|
||||
it('ignore un espace juste avant le curseur', () => {
|
||||
// "1 |234" → curseur en position 2, 1 caractère significatif
|
||||
expect(countSignificant('1 234', 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caretFromSignificant', () => {
|
||||
it('place le curseur après le n-ième caractère significatif', () => {
|
||||
// 4 caractères significatifs dans "1 234 567" → après le "4" (index 5)
|
||||
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||
})
|
||||
it('place le curseur en fin si on dépasse', () => {
|
||||
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||
})
|
||||
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : FAIL (le module `./amountFormat` n'existe pas).
|
||||
|
||||
- [ ] **Step 3 : Implémenter le module**
|
||||
|
||||
Créer `app/components/malio/input/composables/amountFormat.ts` :
|
||||
```ts
|
||||
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||
export const normalizeAmount = (value: string): string => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||
export const formatGroupedAmount = (model: string): string => {
|
||||
if (model === '') return ''
|
||||
const hasDot = model.includes('.')
|
||||
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||
}
|
||||
|
||||
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
if (sig <= 0) return 0
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : PASS (tous).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/composables/amountFormat.ts app/components/malio/input/composables/amountFormat.test.ts
|
||||
git commit -m "feat(amount) : helpers amountFormat (normalize, group, curseur)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Brancher `InputAmount.vue` sur les helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputAmount.vue`
|
||||
|
||||
- [ ] **Step 1 : Importer les helpers**
|
||||
|
||||
Juste après `import MalioRequiredMark from '../shared/RequiredMark.vue'`, ajouter :
|
||||
```ts
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la computed `formattedValue`**
|
||||
|
||||
Juste après la ligne `const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))`, ajouter :
|
||||
```ts
|
||||
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Binder l'affichage groupé et retirer le `maxlength` natif**
|
||||
|
||||
Dans le `<template>`, sur l'`<input>` :
|
||||
- Remplacer `:value="currentValue"` par `:value="formattedValue"`.
|
||||
- **Supprimer** la ligne `:maxlength="maxLength"` (le plafond est géré en JS). Garder `:minlength="minLength"`.
|
||||
|
||||
- [ ] **Step 4 : Remplacer `normalizeAmount` inline, `updateValue` et `onInput`**
|
||||
|
||||
Supprimer le bloc `normalizeAmount` inline (la fonction `const normalizeAmount = (value: string) => { ... }`) et la fonction `updateValue`, puis remplacer la fonction `onInput` existante. Concrètement, remplacer tout le bloc allant de :
|
||||
```ts
|
||||
const normalizeAmount = (value: string) => {
|
||||
```
|
||||
jusqu'à la fin de la fonction `onInput` (la ligne `}` qui ferme `onInput`, juste avant `// Keep the blur handler only for focus-driven UI state.`), par :
|
||||
```ts
|
||||
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const rawText = target.value
|
||||
const caret = target.selectionStart ?? rawText.length
|
||||
const model = normalizeAmount(rawText)
|
||||
|
||||
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||
target.value = formattedValue.value
|
||||
const restored = Math.max(0, caret - 1)
|
||||
target.setSelectionRange(restored, restored)
|
||||
return
|
||||
}
|
||||
|
||||
const display = formatGroupedAmount(model)
|
||||
const sig = countSignificant(rawText, caret)
|
||||
target.value = display
|
||||
const newCaret = caretFromSignificant(display, sig)
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = model
|
||||
}
|
||||
emit('update:modelValue', model)
|
||||
}
|
||||
```
|
||||
|
||||
(La fonction `onBlur` qui suit reste inchangée.)
|
||||
|
||||
- [ ] **Step 5 : Vérifier la compilation et lancer les tests existants (ils vont en partie échouer — c'est attendu, ils sont mis à jour en Task 3)**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : PASS (le module est intact).
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur `InputAmount.vue` (pas de variable inutilisée, imports utilisés).
|
||||
|
||||
Note : `npm run test -- InputAmount.test.ts` affichera des échecs sur les assertions d'affichage (`'12.5'` → `'12,5'`) — c'est normal, la Task 3 met à jour ces tests. Ne pas « corriger » le composant pour les faire passer.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputAmount.vue
|
||||
git commit -m "feat(amount) : affichage groupé temps réel (séparateurs de milliers)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Mettre à jour `InputAmount.test.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputAmount.test.ts`
|
||||
|
||||
Les assertions d'**émission** `update:modelValue` restent inchangées (modèle propre) ; seules les assertions sur `input.element.value` passent à l'affichage groupé.
|
||||
|
||||
- [ ] **Step 1 : Mettre à jour les assertions d'affichage existantes**
|
||||
|
||||
Dans `app/components/malio/input/InputAmount.test.ts`, appliquer ces remplacements exacts :
|
||||
|
||||
Test « keeps dots as the decimal separator on input » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
```
|
||||
|
||||
Test « accepts commas but normalizes them to dots » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
```
|
||||
|
||||
Test « normalizes a leading decimal separator » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
```
|
||||
|
||||
Test « keeps the normalized decimal value on blur » :
|
||||
```ts
|
||||
expect(input.element.value).toBe('12.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(input.element.value).toBe('12,5')
|
||||
```
|
||||
|
||||
(Les tests « keeps integer values unchanged on blur » → `'12'` et « keeps an empty value empty on blur » → `''` restent corrects tels quels, car `formatGroupedAmount('12') === '12'` et `formatGroupedAmount('') === ''`.)
|
||||
|
||||
- [ ] **Step 2 : Ajouter les tests de groupement et de maxLength**
|
||||
|
||||
Juste avant la `})` finale qui ferme le `describe('MalioInputAmount', ...)`, ajouter :
|
||||
```ts
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer la suite**
|
||||
|
||||
Run : `npm run test -- InputAmount.test.ts`
|
||||
Expected : PASS (tous : existants mis à jour + 6 nouveaux).
|
||||
|
||||
Si un test de `setValue` échoue parce que jsdom ne déclenche pas `setSelectionRange` comme attendu, vérifier la valeur réelle via `console.log(wrapper.get('input').element.value)` — l'affichage attendu suit `formatGroupedAmount`. Ne pas affaiblir une assertion sans comprendre.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputAmount.test.ts
|
||||
git commit -m "test(amount) : affichage groupé + maxLength sur le modèle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Note dans `COMPONENTS.md`**
|
||||
|
||||
Dans la section `## MalioInputAmount`, remplacer la ligne de description :
|
||||
```markdown
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
```
|
||||
par :
|
||||
```markdown
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
|
||||
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Mettre à jour l'exemple de la section**
|
||||
|
||||
Dans le bloc ```vue de la section `## MalioInputAmount`, ajouter avant la fence fermante :
|
||||
```vue
|
||||
<MalioInputAmount v-model="gros" label="Budget" />
|
||||
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (après la dernière puce existante du bloc Added) :
|
||||
```markdown
|
||||
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(amount) : documente l'affichage groupé des milliers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Story + playground
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/story/input/inputAmount.story.vue`
|
||||
- Modify: `.playground/pages/composant/input/inputAmount.vue`
|
||||
|
||||
- [ ] **Step 1 : Carte « grand montant » dans la story**
|
||||
|
||||
Dans `app/story/input/inputAmount.story.vue`, juste après la carte « Simple » (le `<div class="rounded-lg border p-4">` contenant `v-model="simpleValue"`, qui se termine par `</div>` avant la carte « Avec hint »), insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer la ref dans la story**
|
||||
|
||||
Dans le `<script setup>` de `app/story/input/inputAmount.story.vue`, après `const simpleValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const bigValue = ref('1234567.89')
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Exemple « grand montant » dans le playground**
|
||||
|
||||
Dans `.playground/pages/composant/input/inputAmount.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` contenant `name="amount"`, qui se termine par `</div>` avant la carte « Désactivé »), insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<div class="mt-2 rounded border p-3 text-sm">
|
||||
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le playground**
|
||||
|
||||
Dans le `<script setup>` de `.playground/pages/composant/input/inputAmount.vue`, ajouter (créer le bloc s'il n'existe pas déjà — vérifier la présence d'un `<script setup lang="ts">` ; sinon l'ajouter en bas du fichier) :
|
||||
```ts
|
||||
const bigValue = ref('1234567.89')
|
||||
```
|
||||
Si le `<script setup>` n'importe pas encore `ref`, ajouter `import {ref} from 'vue'` en tête du script.
|
||||
|
||||
- [ ] **Step 5 : Vérifier le lint**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur les deux fichiers modifiés (warnings pré-existants ailleurs tolérés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/story/input/inputAmount.story.vue .playground/pages/composant/input/inputAmount.vue
|
||||
git commit -m "docs(amount) : exemple grand montant groupé (story + playground)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Suites amount**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts InputAmount.test.ts`
|
||||
Expected : PASS (helpers + composant).
|
||||
|
||||
- [ ] **Step 2 : Lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle (playground)**
|
||||
|
||||
Run : `npm run dev`, ouvrir `composant/input/inputAmount`, carte « Grand montant ».
|
||||
Vérifier :
|
||||
- Taper `1234567` → affiche `1 234 567` au fil de la frappe ; `modelValue` affiché = `1234567`.
|
||||
- Taper une virgule + décimales → `1 234 567,89` ; `modelValue` = `1234567.89`.
|
||||
- Le curseur reste cohérent quand un séparateur s'insère (taper au milieu d'un nombre).
|
||||
- La valeur initiale `1234567.89` s'affiche groupée au montage.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Modèle propre, séparateurs visuels → Task 2 (binding `formattedValue`, emit `model`).
|
||||
- Temps réel + curseur → Task 2 Step 4 (`onInput` avec `countSignificant`/`caretFromSignificant`).
|
||||
- Format FR (espace + virgule) → Task 1 (`formatGroupedAmount`).
|
||||
- Par défaut sur tous (pas de prop) → aucune prop ajoutée.
|
||||
- `maxLength` sur le modèle + suppression `maxlength` natif → Task 2 Steps 3-4, tests Task 3.
|
||||
- Extraction `amountFormat.ts` → Task 1.
|
||||
- Table de vérité → Task 1 tests + Task 3 tests.
|
||||
- Docs + story + playground → Tasks 4, 5.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; code fourni intégralement.
|
||||
|
||||
**Type consistency :** `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant` (signatures identiques entre Task 1, leur usage Task 2, et les imports). `formattedValue` (computed) utilisée dans le binding et le chemin de rejet maxLength. `model` = sortie de `normalizeAmount`, émis tel quel.
|
||||
@@ -0,0 +1,458 @@
|
||||
# MalioInputEmail — bouton « + » d'ajout — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ajouter à `MalioInputEmail` un bouton « + » optionnel (prop `addable`) qui émet un event `add`, calqué sur `MalioInputPhone`, sans toucher à la logique de sanitisation email existante.
|
||||
|
||||
**Architecture:** Recopie du pattern `addable` de `InputPhone.vue` dans `InputEmail.vue` (props `addable`/`addIconName`/`addButtonLabel`, event `add`, bouton `data-test="add-button"`). L'icône email étant à droite par défaut, une nouvelle computed `effectiveIconPosition` la force à gauche quand `addable` est actif, libérant la droite pour le bouton. Aucune modification de `onInput`/`sanitizeEmail`/`lowercase`.
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `@iconify/vue` (Icon), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputemail-bouton-ajout-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/components/malio/input/InputEmail.vue` — props `addable`/`addIconName`/`addButtonLabel`, event `add`, `effectiveIconPosition`, 4 computeds repointées, bouton + handler `onAdd` + `mergedAddButtonClass`.
|
||||
- **Modify** `app/components/malio/input/InputEmail.test.ts` — tests du bouton + repositionnement icône.
|
||||
- **Modify** `COMPONENTS.md` — props + event + exemple.
|
||||
- **Modify** `CHANGELOG.md` — entrée de version.
|
||||
- **Modify** `app/story/input/inputEmail.story.vue` — carte « addable ».
|
||||
- **Modify** `.playground/pages/composant/input/inputEmail.vue` — exemple d'ajout dynamique.
|
||||
|
||||
**Note hooks pré-commit :** le repo a un hook `make pre-commit` (lint + suite complète ~888 tests) KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout de test sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Toujours stager des fichiers explicites — **jamais** `git add -A` (le `nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
|
||||
|
||||
Le composant de référence est `app/components/malio/input/InputPhone.vue` (le pattern `addable` y est déjà implémenté à l'identique).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `InputEmail.vue` — bouton addable + icône effective
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputEmail.vue`
|
||||
|
||||
Comportement attendu : `addable=false` (défaut) ⇒ rendu strictement inchangé ; `addable=true` ⇒ bouton « + » à droite, icône email à gauche, event `add` émis au clic (sauf `disabled`/`readonly`).
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props `addable`/`addIconName`/`addButtonLabel`**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter ces trois lignes juste après `iconColor?: string` :
|
||||
```ts
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
```
|
||||
Dans `withDefaults(..., { ... })`, ajouter juste après `iconColor: 'text-m-muted',` :
|
||||
```ts
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter une adresse email',
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter l'event `add`**
|
||||
|
||||
Remplacer :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter le handler `onAdd`**
|
||||
|
||||
Juste après la fonction `onInput` (après son `}` de fermeture, avant `const iconInputPaddingClass`), ajouter :
|
||||
```ts
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter `effectiveIconPosition` et réécrire `iconInputPaddingClass`**
|
||||
|
||||
Remplacer le bloc actuel :
|
||||
```ts
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
if (!props.iconName) return ''
|
||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
||||
})
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Repointer `labelPositionClass`, `focusPaddingClass`, `iconPositionClass` sur `effectiveIconPosition`**
|
||||
|
||||
Remplacer :
|
||||
```ts
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Ajouter la computed `mergedAddButtonClass`**
|
||||
|
||||
Juste après la computed `mergedLabelClass` (après son `)` de fermeture, avant `const describedBy`), ajouter :
|
||||
```ts
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Ajouter le bouton dans le template**
|
||||
|
||||
Dans le template, juste après le bloc `<IconifyIcon v-if="iconName" ... />` (sa balise fermante `/>`) et avant la `</div>` qui ferme le conteneur du champ, insérer :
|
||||
```html
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 8 : Vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- InputEmail.test.ts`
|
||||
Expected : PASS — tous les tests existants passent toujours (le cas `addable=false` est strictement inchangé : icône à droite, paddings identiques).
|
||||
|
||||
- [ ] **Step 9 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputEmail.vue
|
||||
git commit -m "feat(email) : bouton + d'ajout (event add) sur MalioInputEmail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Tests du bouton addable
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputEmail.test.ts`
|
||||
|
||||
Le fichier utilise déjà un helper `mountComponent(props)` qui stub `IconifyIcon` en `<span data-test="icon" v-bind="$attrs" />`. L'icône email rend `data-test="icon"` ; le `<button>` rend `data-test="add-button"` et son icône interne `data-test="add-icon"` — donc `[data-test="icon"]` ne matche que l'icône email.
|
||||
|
||||
- [ ] **Step 1 : Étendre le type `InputEmailProps`**
|
||||
|
||||
Dans le type `InputEmailProps` (en tête de fichier), ajouter après `lowercase?: boolean` :
|
||||
```ts
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter les tests addable**
|
||||
|
||||
À l'intérieur du `describe('MalioInputEmail', () => { ... })`, juste avant la `})` finale qui ferme ce describe, ajouter :
|
||||
```ts
|
||||
it('does not render add button by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders add button when addable is true', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits add event when add button is clicked', async () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not emit add when readonly', async () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when disabled', () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the email icon to the left automatically when addable', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
const icon = wrapper.get('[data-test="icon"]')
|
||||
expect(icon.classes()).toContain('left-[10px]')
|
||||
expect(icon.classes()).not.toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('keeps the email icon on the right when addable is false', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('uses the default add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
|
||||
})
|
||||
|
||||
it('allows overriding the add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer les tests**
|
||||
|
||||
Run : `npm run test -- InputEmail.test.ts`
|
||||
Expected : PASS — tests existants + 11 nouveaux.
|
||||
|
||||
Si le test `moves the email icon to the left` échoue parce que `get('[data-test="icon"]')` trouve plusieurs éléments, c'est que le stub du bouton-icône a rendu `data-test="icon"` au lieu de `add-icon` ; debug en loggant `wrapper.findAll('[data-test="icon"]').length`. Ne PAS affaiblir l'assertion sans comprendre : `data-test="add-icon"` doit primer via `v-bind="$attrs"`.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputEmail.test.ts
|
||||
git commit -m "test(email) : couvre le bouton + d'ajout de MalioInputEmail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props au tableau `MalioInputEmail`**
|
||||
|
||||
Dans `COMPONENTS.md`, section `## MalioInputEmail`, dans le tableau des props, insérer ces lignes juste après la ligne `| \`iconColor\` | \`string\` | \`'text-m-muted'\` | Classe couleur icône |` :
|
||||
```markdown
|
||||
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
|
||||
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Documenter l'event `add` et ajouter un exemple**
|
||||
|
||||
Dans la même section, remplacer la ligne :
|
||||
```markdown
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
```
|
||||
par :
|
||||
```markdown
|
||||
**Events :**
|
||||
- `update:modelValue(value: string)`
|
||||
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||
```
|
||||
Puis, dans le bloc d'exemple ```vue de cette section, ajouter cette ligne juste avant la fence fermante ``` :
|
||||
```vue
|
||||
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (juste après `* [#MUI-41] InputEmail : sanitisation à la saisie ...`) :
|
||||
```markdown
|
||||
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(email) : documente le bouton + d'ajout de MalioInputEmail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Story + playground
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/story/input/inputEmail.story.vue`
|
||||
- Modify: `.playground/pages/composant/input/inputEmail.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter une carte « addable » dans la story**
|
||||
|
||||
Dans `app/story/input/inputEmail.story.vue`, juste après la carte « Icône à gauche » (le `<div class="rounded-lg border p-4">` qui se termine ligne 19, contenant `icon-position="left"`) et avant la carte « Sans icône », insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputEmail
|
||||
v-model="addableValue"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer les refs/handler dans le `<script setup>` de la story**
|
||||
|
||||
Dans le `<script setup>` de `app/story/input/inputEmail.story.vue`, après la ligne `const simpleValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const addableValue = ref('')
|
||||
const addClicks = ref(0)
|
||||
const onAdd = () => { addClicks.value += 1 }
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter un exemple d'ajout dynamique dans le playground**
|
||||
|
||||
Dans `.playground/pages/composant/input/inputEmail.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` qui se termine ligne 15) et avant la carte « Icône à gauche », insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
|
||||
<div class="space-y-3">
|
||||
<MalioInputEmail
|
||||
v-for="(email, index) in emails"
|
||||
:key="index"
|
||||
v-model="emails[index]"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="emails.push('')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` du playground**
|
||||
|
||||
Dans le `<script setup>` de `.playground/pages/composant/input/inputEmail.vue`, après la ligne `const emailValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const emails = ref<string[]>([''])
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier le lint**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur les deux fichiers modifiés (des warnings pré-existants sur d'AUTRES fichiers sont tolérés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/story/input/inputEmail.story.vue .playground/pages/composant/input/inputEmail.vue
|
||||
git commit -m "docs(email) : exemples bouton + d'ajout (story + playground)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Suite InputEmail**
|
||||
|
||||
Run : `npm run test -- InputEmail.test.ts`
|
||||
Expected : PASS (existants + 11 nouveaux).
|
||||
|
||||
- [ ] **Step 2 : Lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle (recommandée)**
|
||||
|
||||
Run : `npm run dev`, ouvrir `composant/input/inputEmail`.
|
||||
Vérifier :
|
||||
- Carte « Ajout dynamique » : cliquer « + » ajoute un nouveau champ email en dessous.
|
||||
- Avec `addable`, l'icône email est à gauche et le « + » à droite, sans chevauchement.
|
||||
- Le bouton « + » est grisé/inactif en `disabled`.
|
||||
- Les autres cartes email (sans `addable`) sont inchangées (icône à droite).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Props `addable`/`addIconName`/`addButtonLabel` (défauts `false`/`'mdi:plus'`/`'Ajouter une adresse email'`) → Task 1 Step 1.
|
||||
- Event `add` → Task 1 Step 2.
|
||||
- `effectiveIconPosition` (icône à gauche si addable) + 4 computeds repointées → Task 1 Steps 4-5.
|
||||
- `iconInputPaddingClass` aligné Phone (pr-10 si addable) → Task 1 Step 4.
|
||||
- Bouton template + `mergedAddButtonClass` + `onAdd` (garde disabled/readonly) → Task 1 Steps 3, 6, 7.
|
||||
- Logique email existante intacte (`onInput`/`sanitizeEmail`/`lowercase` non touchés) → aucune tâche ne les modifie.
|
||||
- Tests (présence, émission, gardes disabled/readonly, repositionnement icône, libellé) → Task 2.
|
||||
- Docs COMPONENTS.md + CHANGELOG.md → Task 3 ; story + playground → Task 4.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
|
||||
|
||||
**Type consistency :** `addable`/`addIconName`/`addButtonLabel` (props), `add` (event), `onAdd`/`effectiveIconPosition`/`mergedAddButtonClass`/`iconStateClass` (composant) — noms cohérents entre tâches. Les `data-test` (`add-button`, `add-icon`, `icon`) concordent entre composant (Task 1) et tests (Task 2). `iconStateClass` et `twMerge` existent déjà dans `InputEmail.vue`.
|
||||
@@ -0,0 +1,635 @@
|
||||
# MalioDate — saisie manuelle au clavier — 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:** Permettre la saisie clavier `JJ/MM/AAAA` dans `MalioDate` (opt-in via prop `editable`), en plus de la sélection au calendrier, avec validation au blur et état d'erreur visuel.
|
||||
|
||||
**Architecture:** `CalendarField` (interne, partagé) gagne un mode `editable` : input non `readonly`, masque `maska`, buffer local `draft` synchronisé sur `displayValue`, émission d'un event `commit(text)` au blur / à Entrée. `MalioDate` conserve toute la logique date : parse (`parseDisplayToIso`), validation bornes (`isDateInRange`), état d'erreur interne fusionné avec la prop `error` du consommateur. `CalendarField` reste agnostique au format.
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `maska` (directive `v-maska`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-maliodate-saisie-manuelle-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/components/malio/date/internal/CalendarField.vue` — mode `editable` : prop, masque, buffer `draft`, handlers focus/input/blur/enter, event `commit`.
|
||||
- **Modify** `app/components/malio/date/Date.vue` — props `editable` / `invalidMessage`, état `internalError`, handler `onCommit`, fusion `mergedError`, nettoyage erreur à la sélection/clear.
|
||||
- **Modify** `app/components/malio/date/Date.test.ts` — tests de saisie manuelle + non-régression.
|
||||
- **Modify** `COMPONENTS.md` — documentation des props.
|
||||
- **Modify** `CHANGELOG.md` — entrée de version.
|
||||
- **Modify** `.playground/pages/composant/date/date.vue` — exemple éditable.
|
||||
- **Modify** `app/story/date/datePicker.story.vue` — exemple éditable.
|
||||
|
||||
**Note hooks pré-commit :** le projet a un hook `make pre-commit` (lint + 888 tests) parfois lent/flaky. Si un commit échoue sur un timeout de test sans rapport, relancer ; en dernier recours `--no-verify`. Toujours stager des fichiers explicites, **jamais** `git add -A` (le `nuxt.config.ts` modifié localement ne doit pas être committé).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `CalendarField` — prop `editable`, masque et buffer
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/internal/CalendarField.vue`
|
||||
|
||||
Cette tâche ajoute l'infrastructure du mode éditable. On la valide via les tests de la Task 3 (le comportement observable passe par `MalioDate`). Ici on vérifie surtout la non-régression : `editable=false` ⇒ input `readonly`, valeur affichée intacte.
|
||||
|
||||
- [ ] **Step 1 : Ajouter les imports `maska`**
|
||||
|
||||
Dans le bloc `<script setup>`, juste après la ligne `import {twMerge} from 'tailwind-merge'` (ligne 104), ajouter :
|
||||
|
||||
```ts
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la prop `editable` à l'interface et aux défauts**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter la ligne après `clearable?: boolean` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
```
|
||||
|
||||
Dans le bloc `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
|
||||
|
||||
```ts
|
||||
editable: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Déclarer l'event `commit`**
|
||||
|
||||
Remplacer la ligne (≈152) :
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter le buffer `draft`, le masque et l'état `readonly` calculé**
|
||||
|
||||
Juste après la ligne `const root = ref<HTMLElement | null>(null)` (≈156), ajouter :
|
||||
|
||||
```ts
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
```
|
||||
|
||||
(Note : `mask: undefined` désactive le masquage de `maska` — la valeur passe intacte. Ne **pas** utiliser `''`, qui viderait la valeur.)
|
||||
|
||||
- [ ] **Step 5 : Mettre à jour le computed `isFilled` pour tenir compte du buffer**
|
||||
|
||||
Remplacer (≈164) :
|
||||
|
||||
```ts
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const isFilled = computed(() =>
|
||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Remplacer `onFieldClick` et ajouter les handlers éditables**
|
||||
|
||||
Remplacer le bloc `onFieldClick` (≈177-185) :
|
||||
|
||||
```ts
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Mettre à jour l'`<input>` dans le template**
|
||||
|
||||
Remplacer le bloc `<input>` (≈7-25) :
|
||||
|
||||
```html
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
>
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
:readonly="inputReadonly"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="editable ? draft : displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 8 : Lancer la suite Date pour vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (tous les tests existants de `MalioDate` passent toujours ; l'input par défaut reste `readonly` et affiche la valeur formatée).
|
||||
|
||||
- [ ] **Step 9 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/CalendarField.vue
|
||||
git commit -m "feat(date) : mode editable dans CalendarField (saisie clavier)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : `MalioDate` — parsing, validation et état d'erreur
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.vue`
|
||||
|
||||
- [ ] **Step 1 : Étendre les imports de `dateFormat`**
|
||||
|
||||
Remplacer (≈39) :
|
||||
|
||||
```ts
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
```
|
||||
|
||||
Et compléter l'import Vue (≈36) pour disposer de `ref` :
|
||||
|
||||
```ts
|
||||
import {computed, ref, watch} from 'vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter les props `editable` et `invalidMessage`**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter après `clearable?: boolean` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
```
|
||||
|
||||
Dans `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
|
||||
|
||||
```ts
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'état d'erreur interne, la fusion, et les handlers**
|
||||
|
||||
Juste après la ligne `const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))` (≈86), ajouter :
|
||||
|
||||
```ts
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
internalError.value = props.invalidMessage
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Brancher les props et events sur `CalendarField` dans le template**
|
||||
|
||||
Dans `<CalendarField ...>`, remplacer `:error="error"` (≈13) par :
|
||||
|
||||
```html
|
||||
:error="mergedError"
|
||||
```
|
||||
|
||||
Ajouter, juste après `:clearable="clearable"` (≈15) :
|
||||
|
||||
```html
|
||||
:editable="editable"
|
||||
```
|
||||
|
||||
Remplacer `@clear="emit('update:modelValue', null)"` (≈20) par :
|
||||
|
||||
```html
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Brancher la sélection calendrier sur `onSelect`**
|
||||
|
||||
Remplacer (≈29) :
|
||||
|
||||
```html
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Lancer la suite Date pour vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (les tests existants passent ; `mergedError` se comporte comme `error` tant qu'aucune saisie invalide n'est faite).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.vue
|
||||
git commit -m "feat(date) : saisie manuelle MalioDate (parse, validation, erreur)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Tests de la saisie manuelle
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Étendre le type de props de test**
|
||||
|
||||
Dans le type `DateProps` (≈6-25), ajouter après `groupClass?: string` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le bloc de tests `saisie manuelle (editable)`**
|
||||
|
||||
Ajouter, juste avant la fermeture du `describe('MalioDate', ...)` (avant la dernière `})` du fichier, après le bloc `describe('reserveMessageSpace', ...)`), le bloc suivant :
|
||||
|
||||
```ts
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer les nouveaux tests**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (tous, anciens + nouveaux).
|
||||
|
||||
Si un test de saisie échoue parce que `maska` a reformaté la valeur en jsdom autrement qu'attendu, inspecter la valeur réelle via un `console.log((input.element as HTMLInputElement).value)` et ajuster l'assertion (le masque `##/##/####` laisse les chiffres tels quels ; une entrée déjà bien formée n'est pas modifiée).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.test.ts
|
||||
git commit -m "test(date) : couvre la saisie manuelle de MalioDate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props au tableau `MalioDate` de `COMPONENTS.md`**
|
||||
|
||||
Dans la section `## MalioDate`, dans le tableau des props, insérer juste après la ligne `| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |` :
|
||||
|
||||
```markdown
|
||||
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
|
||||
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Compléter la description et l'exemple `MalioDate`**
|
||||
|
||||
Dans la section `## MalioDate`, juste après la ligne de description `La valeur est une chaîne ISO ...`, ajouter le paragraphe :
|
||||
|
||||
```markdown
|
||||
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
|
||||
```
|
||||
|
||||
Dans le bloc d'exemple ```vue de cette section, ajouter une ligne avant la fermeture ``` :
|
||||
|
||||
```vue
|
||||
<MalioDate v-model="date" label="Date de naissance" editable />
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter à la fin de la liste (après la dernière puce `* [#MUI-41] InputEmail : ...`) :
|
||||
|
||||
```markdown
|
||||
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(date) : documente la saisie manuelle de MalioDate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Exemples playground + story
|
||||
|
||||
**Files:**
|
||||
- Modify: `.playground/pages/composant/date/date.vue`
|
||||
- Modify: `app/story/date/datePicker.story.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter un bloc éditable dans la page playground**
|
||||
|
||||
Dans `.playground/pages/composant/date/date.vue`, dans la première colonne `Large (480px)`, juste après le `<div class="rounded border p-3 text-sm">...</div>` qui affiche la valeur ISO (≈13-15), ajouter :
|
||||
|
||||
```html
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer la ref dans le `<script setup>` de la page playground**
|
||||
|
||||
Dans le `<script setup>` du même fichier, après `const bounded = ref<string | null>(null)`, ajouter :
|
||||
|
||||
```ts
|
||||
const editableValue = ref<string | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter un exemple éditable dans la story**
|
||||
|
||||
Dans `app/story/date/datePicker.story.vue`, ajouter une nouvelle carte juste après le bloc `<!-- Avec min/max -->` (le `<div class="rounded-lg border p-4">` qui contient « Avec min/max »), avant le bloc « Non effaçable » :
|
||||
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date de naissance"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` de la story**
|
||||
|
||||
Dans le `<script setup>` du même fichier, après `const errorValue = ref<string | null>(null)`, ajouter :
|
||||
|
||||
```ts
|
||||
const editableValue = ref<string | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier que rien ne casse (lint + build des types)**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : PASS (aucune erreur sur les fichiers modifiés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add .playground/pages/composant/date/date.vue app/story/date/datePicker.story.vue
|
||||
git commit -m "docs(date) : exemples saisie manuelle (playground + story)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Lancer toute la suite de tests**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS — l'ensemble du fichier (anciens + 9 nouveaux tests).
|
||||
|
||||
- [ ] **Step 2 : Lancer le lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : PASS.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle dans le playground (optionnel mais recommandé)**
|
||||
|
||||
Run : `npm run dev` puis ouvrir la page `composant/date`.
|
||||
Vérifier :
|
||||
- Taper `19/05/2026` puis cliquer ailleurs → la valeur ISO affichée devient `2026-05-19`.
|
||||
- Taper `32/13/2026` puis blur → le texte reste, le champ passe en rouge avec « Date invalide ».
|
||||
- Avec une saisie invalide, ouvrir le calendrier et choisir un jour → l'erreur disparaît, la valeur se met à jour.
|
||||
- Le focus dans le champ ouvre bien le calendrier, et taper reste possible.
|
||||
- Sur le champ `editable=false` existant : aucun changement (lecture seule).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Prop `editable` opt-in (défaut false) → Task 1 Step 2, Task 2 Step 2.
|
||||
- Masque `##/##/####` + focus ouvre le popover → Task 1 Steps 4/6/7.
|
||||
- Validation au blur, pas à la frappe → Task 1 (onInput ne valide pas) + Task 2 (onCommit).
|
||||
- Saisie invalide : garde le texte + erreur visuelle → Task 2 Step 3 + Task 3 test dédié.
|
||||
- Message par défaut « Date invalide », surchargeable → Task 2 Step 2.
|
||||
- Touche Entrée commit + ferme popover → Task 1 Step 6 (`onEnter`) + Task 3 test.
|
||||
- Hors min/max = invalide → Task 2 (`isDateInRange`) + Task 3 test.
|
||||
- Sélection calendrier efface l'erreur → Task 2 Step 5 (`onSelect`) + Task 3 test.
|
||||
- `disabled`/`readonly` priment → Task 1 (`inputReadonly`, gardes dans handlers).
|
||||
- Non-régression `editable=false` → Task 1 Step 8 + Task 3 test readonly.
|
||||
- Docs COMPONENTS.md + CHANGELOG.md + playground/story → Tasks 4 et 5.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
|
||||
|
||||
**Type consistency :** `editable`/`invalidMessage` (props), `commit` (event CalendarField), `onCommit`/`onClear`/`onSelect`/`internalError`/`mergedError` (MalioDate), `draft`/`maskaOptions`/`inputReadonly`/`onFocus`/`onInput`/`onBlur`/`onEnter` (CalendarField) — noms cohérents entre tâches. `onCommit(text: string)` correspond à l'event `commit(value: string)`. `onSelect(iso: string, close: () => void)` correspond à la signature du slot (`close` exposé par `CalendarField`).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,117 @@
|
||||
# MalioInputAmount — séparateurs de milliers (affichage groupé FR)
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé, prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioInputAmount` uniquement.
|
||||
|
||||
## Objectif
|
||||
|
||||
Afficher les montants avec des **séparateurs de milliers** (espaces) et une **virgule décimale**, à la française : `1 234 567,89`. Demande du client ERP. Le formatage est **purement visuel** ; la valeur émise reste une chaîne numérique propre.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Valeur émise (`modelValue`) | Reste **propre** : point décimal, sans espaces (`'1234567.89'`). Contrat consommateur inchangé. Les séparateurs ne sont qu'à l'affichage. |
|
||||
| Moment du formatage | **Temps réel** (à la frappe), avec gestion du curseur. |
|
||||
| Format affiché | Français : **espace** pour les milliers, **virgule** pour les décimales (`1 234 567,89`). |
|
||||
| Activation | **Par défaut sur tous** les `MalioInputAmount` (pas de prop opt-in). |
|
||||
| `maxLength` | S'applique à la **longueur du `modelValue`** (chaîne propre), **pas** à l'affichage. Le `maxlength` natif (qui compterait les espaces) est retiré ; le plafond est appliqué en JS. |
|
||||
| Extraction | Les fonctions pures vont dans `app/components/malio/input/composables/amountFormat.ts` (testables en isolation). |
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. Contrat de valeur & flux de données
|
||||
|
||||
- **Modèle (émis)** : sortie inchangée de `normalizeAmount` — point décimal, max 2 décimales, zéros de tête retirés, `''` si vide. Ex. `1234567.89`.
|
||||
- **Affichage** : `formatGroupedAmount(model)` groupe la partie entière par 3 avec des espaces et remplace le point par une virgule. Ex. `1 234 567,89`. Si le modèle finit par `.` (décimale en cours, ex. `12.`), l'affichage finit par `,` (`12,`).
|
||||
- **Binding** : l'input affiche `formatGroupedAmount(currentValue)` au lieu de `currentValue`.
|
||||
- **Parse** : à la frappe, le texte saisi (avec espaces/virgule) repasse par `normalizeAmount` → modèle propre → émis.
|
||||
|
||||
### 2. Temps réel & gestion du curseur
|
||||
|
||||
À chaque `@input` :
|
||||
1. Lire `rawText = target.value` et `caret = target.selectionStart`.
|
||||
2. `model = normalizeAmount(rawText)`.
|
||||
3. **Plafond `maxLength`** (cf. §3) : si dépassement, ignorer le keystroke (restaurer l'affichage précédent, ne pas émettre).
|
||||
4. Sinon : `display = formatGroupedAmount(model)` ; écrire `target.value = display` ; **émettre** `update:modelValue(model)`.
|
||||
5. **Repositionner le curseur** : compter les caractères significatifs (tout sauf les espaces de groupement) à gauche du curseur dans `rawText`, puis placer le curseur après ce même nombre de caractères significatifs dans `display`.
|
||||
|
||||
Helpers curseur (purs) :
|
||||
```ts
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
```
|
||||
|
||||
`<input type="text">` supporte l'API de sélection, donc `setSelectionRange` fonctionne directement (pas de try/catch nécessaire).
|
||||
|
||||
### 3. `maxLength` sur le modèle
|
||||
|
||||
- On **retire** le binding `:maxlength="maxLength"` natif de l'`<input>` (il compterait les espaces de l'affichage).
|
||||
- Dans `onInput`, après `model = normalizeAmount(rawText)` : si `props.maxLength != null` **et** `model.length > Number(props.maxLength)`, on **ignore** le keystroke :
|
||||
- on restaure `target.value = formatGroupedAmount(currentValue)` (modèle précédent),
|
||||
- on replace le curseur à `max(0, caret - 1)` (le caractère refusé n'est pas inséré),
|
||||
- on **n'émet pas**.
|
||||
- `maxLength` borne donc la **longueur de la chaîne `modelValue`** (point décimal inclus). Ce point est documenté explicitement.
|
||||
- `minLength` : laissé tel quel (attribut natif de validation). Connu : il s'évalue sur le texte affiché ; hors périmètre de cette évolution.
|
||||
|
||||
### 4. Helpers extraits — `composables/amountFormat.ts`
|
||||
|
||||
- `normalizeAmount(value: string): string` — **déplacé tel quel** depuis le composant (parse).
|
||||
- `formatGroupedAmount(model: string): string` — nouveau (format groupé FR). Algorithme :
|
||||
- Si `model === ''` → `''`.
|
||||
- Séparer sur `.` → `integerPart`, `decimalPart` (présent ssi le modèle contient `.`).
|
||||
- Grouper `integerPart` par paquets de 3 depuis la droite avec une espace `' '`.
|
||||
- Si le modèle contient `.` → `groupedInteger + ',' + decimalPart` (decimalPart éventuellement vide).
|
||||
- Sinon → `groupedInteger`.
|
||||
- `countSignificant`, `caretFromSignificant` — helpers curseur (purs).
|
||||
|
||||
Le composant importe ces helpers ; la logique DOM (lecture `target.value`, `setSelectionRange`) reste dans `InputAmount.vue`.
|
||||
|
||||
### 5. Table de vérité (format/parse)
|
||||
|
||||
| Saisie utilisateur | `modelValue` émis | Affichage (`formatGroupedAmount`) |
|
||||
|---|---|---|
|
||||
| `1234567` | `1234567` | `1 234 567` |
|
||||
| `1234,56` ou `1234.56` | `1234.56` | `1 234,56` |
|
||||
| `12.` (décimale en cours) | `12.` | `12,` |
|
||||
| `,5` | `0.5` | `0,5` |
|
||||
| `0012345abc` | `12345` | `12 345` |
|
||||
| `1234.567` (3 décimales) | `1234.56` | `1 234,56` |
|
||||
| `` (vide) | `` | `` |
|
||||
| `0` | `0` | `0` |
|
||||
|
||||
## Tests
|
||||
|
||||
**`composables/amountFormat.test.ts`** (nouveau) :
|
||||
- `normalizeAmount` : reprise des cas existants (espaces, virgule→point, zéros de tête, 2 décimales max, vide, décimale en tête).
|
||||
- `formatGroupedAmount` : table §5 (groupement par 3, virgule décimale, `12.`→`12,`, vide→vide, nombres < 1000 inchangés).
|
||||
- `countSignificant` / `caretFromSignificant` : positions de curseur clés (avant/après un espace inséré, en fin de chaîne).
|
||||
|
||||
**`InputAmount.test.ts`** (mis à jour) :
|
||||
- Les assertions `input.element.value` passent de la valeur brute (`1234.56`) à la valeur groupée (`1 234,56`).
|
||||
- Les assertions d'émission `update:modelValue` restent **inchangées** (modèle propre : `'1234.56'`, `'0.5'`, `''`…).
|
||||
- Nouveaux tests : groupement à la frappe d'un grand montant (`1234567` → affichage `1 234 567`, émis `1234567`) ; `maxLength` plafonne le modèle (un keystroke au-delà est ignoré, pas d'émission supplémentaire) ; position du curseur après insertion d'un séparateur.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- `COMPONENTS.md` : note dans la section `## MalioInputAmount` — affichage groupé FR (`1 234 567,89`), `modelValue` reste propre (`'1234567.89'`), `maxLength` borne la longueur du modèle.
|
||||
- `CHANGELOG.md` : entrée sous `### Added` / `### Changed`.
|
||||
- Story `app/story/input/inputAmount.story.vue` : exemple grand montant montrant les séparateurs.
|
||||
- Playground `.playground/pages/composant/input/inputAmount.vue` : exemple grand montant + affichage de la valeur ISO/propre émise.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Internationalisation configurable (autres locales / séparateurs paramétrables) — on fige le format FR.
|
||||
- `minLength` sur le modèle (reste natif sur l'affichage).
|
||||
- Passage à `maska` en mode number (approche écartée au profit du `normalizeAmount` existant).
|
||||
- Devises / symboles dynamiques (l'icône € existante est conservée telle quelle).
|
||||
@@ -0,0 +1,157 @@
|
||||
# MalioInputEmail — bouton « + » d'ajout (event `add`)
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé, prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioInputEmail` uniquement.
|
||||
|
||||
## Objectif
|
||||
|
||||
Ajouter à `MalioInputEmail` le même bouton « + » que `MalioInputPhone` : un bouton optionnel à droite du champ qui émet un event `add`, permettant au consommateur d'ajouter dynamiquement un autre champ email (ou toute autre action).
|
||||
|
||||
La logique email existante (ajoutée en MUI-41) est **conservée intégralement** : `sanitizeEmail` (suppression des espaces), prop `lowercase`, gestion du caret dans `onInput`, `type="email"` / `inputmode="email"`. Ce travail n'y touche pas.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| API | Calquée sur `MalioInputPhone` : props `addable` / `addIconName` / `addButtonLabel`, event `add`, `data-test="add-button"`. |
|
||||
| Collision icône/bouton | L'icône email étant à droite par défaut, quand `addable` est actif l'icône passe **automatiquement à gauche** et le « + » occupe la droite (disposition éprouvée de Phone). |
|
||||
| Libellé par défaut | `addButtonLabel` défaut `'Ajouter une adresse email'`. |
|
||||
| Garde désactivé | Le bouton n'émet pas `add` si `disabled` ou `readonly` (comme Phone). |
|
||||
| Approche | Recopier le pattern de Phone dans Email (pas d'extraction d'un composant partagé — noté comme cleanup futur possible, hors scope). |
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. Props ajoutées (interface + défauts)
|
||||
|
||||
```ts
|
||||
addable?: boolean // défaut: false
|
||||
addIconName?: string // défaut: 'mdi:plus'
|
||||
addButtonLabel?: string // défaut: 'Ajouter une adresse email'
|
||||
```
|
||||
|
||||
### 2. Event ajouté
|
||||
|
||||
L'`defineEmits` passe de :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
```
|
||||
à :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
```
|
||||
|
||||
### 3. Position d'icône « effective »
|
||||
|
||||
Nouvelle règle, unique source du repositionnement :
|
||||
```ts
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
```
|
||||
|
||||
Les quatre computeds qui dépendent aujourd'hui de `props.iconPosition` utilisent désormais `effectiveIconPosition.value` :
|
||||
- `iconInputPaddingClass`
|
||||
- `iconPositionClass`
|
||||
- `labelPositionClass`
|
||||
- `focusPaddingClass`
|
||||
|
||||
Conséquence :
|
||||
- `addable=false` → `effectiveIconPosition === props.iconPosition` → comportement **strictement identique** à aujourd'hui.
|
||||
- `addable=true` (avec une icône) → icône à gauche + espace réservé à droite pour le bouton.
|
||||
|
||||
### 4. `iconInputPaddingClass` aligné sur Phone
|
||||
|
||||
Remplacement de l'implémentation actuelle d'Email par la forme éprouvée de Phone (rendu identique dans le cas non-addable car la classe de base `pl-3 pr-3` est commune aux deux composants) :
|
||||
```ts
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Bouton dans le template
|
||||
|
||||
Inséré après le bloc `<IconifyIcon v-if="iconName">`, à l'identique de Phone :
|
||||
```html
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 6. `mergedAddButtonClass` (copie de Phone)
|
||||
|
||||
```ts
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
```
|
||||
(`iconStateClass` existe déjà dans Email.)
|
||||
|
||||
### 7. Handler `onAdd` (copie de Phone)
|
||||
|
||||
```ts
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
```
|
||||
|
||||
## Quatre computeds à modifier — détail
|
||||
|
||||
Aujourd'hui dans `InputEmail.vue` ils référencent `props.iconPosition` ; ils doivent référencer `effectiveIconPosition.value`. Les corps restent identiques par ailleurs :
|
||||
- `iconPositionClass` : `effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'`
|
||||
- `labelPositionClass` : `props.iconName && effectiveIconPosition.value === 'left' ? 'left-11' : 'left-3'`
|
||||
- `focusPaddingClass` : `props.iconName && effectiveIconPosition.value === 'left' ? 'focus:!pl-11' : 'focus:pl-[11px]'`
|
||||
- `iconInputPaddingClass` : remplacé par la version §4.
|
||||
|
||||
## Tests (`InputEmail.test.ts`)
|
||||
|
||||
Ajouts (mirroring `InputPhone.test.ts`), sans toucher aux tests existants de sanitisation/lowercase :
|
||||
- `addable=false` (défaut) → pas de `[data-test="add-button"]`.
|
||||
- `addable=true` → `[data-test="add-button"]` présent.
|
||||
- Clic sur le bouton → un event `add` émis (longueur 1).
|
||||
- `addable + disabled` → clic n'émet pas `add` ; le bouton a l'attribut `disabled`.
|
||||
- `addable + readonly` → clic n'émet pas `add` ; le bouton n'a PAS l'attribut natif `disabled` (la garde `onAdd` bloque).
|
||||
- `addable=true` avec icône → l'icône email est positionnée à gauche (`left-[10px]` présent / `right-[10px]` absent sur l'icône `[data-test="icon"]`).
|
||||
- Non-régression : avec `addable=false`, l'icône reste à droite (`right-[10px]`).
|
||||
- `addButtonLabel` personnalisé → `aria-label` respecté ; défaut → `'Ajouter une adresse email'`.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- `COMPONENTS.md` : ajouter les lignes `addable` / `addIconName` / `addButtonLabel` et l'event `add()` dans la section `## MalioInputEmail`, plus un exemple `<MalioInputEmail ... addable @add="..." />`.
|
||||
- `CHANGELOG.md` : entrée sous `### Added`.
|
||||
- Story `app/story/input/inputEmail.story.vue` : une carte « Addable » avec `@add` (calquée sur `inputPhone.story.vue`).
|
||||
- Playground `.playground/pages/composant/input/inputEmail.vue` : un exemple `addable` avec un handler qui illustre l'ajout d'un champ.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Extraction d'un composant/bouton partagé entre Phone et Email (refactor dédié futur).
|
||||
- Gestion réelle de la liste de champs email côté composant (c'est au consommateur de réagir à l'event `add`, comme pour Phone).
|
||||
- Toute modification de la logique de sanitisation / `lowercase` / caret existante.
|
||||
@@ -0,0 +1,118 @@
|
||||
# MalioDate — saisie manuelle au clavier
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé, prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioDate` uniquement (la famille `DateTime`/`DateRange`/`DateWeek` n'est pas concernée pour l'instant).
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à l'utilisateur de saisir une date au clavier (`JJ/MM/AAAA`) dans `MalioDate`, en plus de la sélection via le calendrier. Aujourd'hui l'`<input>` de `CalendarField` est codé en dur en `readonly` : seule la sélection au calendrier est possible.
|
||||
|
||||
## Décisions d'UX (validées)
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Ouverture du popover | Le **focus** (ou le clic) ouvre le calendrier, tout en laissant taper en même temps. |
|
||||
| Masque / validation | Masque `maska` `##/##/####` pendant la frappe ; **validation au blur** (pas à chaque touche). |
|
||||
| Activation | **Opt-in** via une prop `editable` (défaut `false`). Aucune régression pour les consommateurs existants. |
|
||||
| Saisie invalide au blur | On **garde le texte tapé** et on affiche un **état d'erreur visuel** (bordure rouge + message). |
|
||||
| Message d'erreur par défaut | « **Date invalide** » (couvre aussi le hors-bornes min/max), surchargeable via prop. |
|
||||
| Touche Entrée | Déclenche le `commit` (parse immédiat) + ferme le popover. |
|
||||
|
||||
## Approche retenue
|
||||
|
||||
**Mode `editable` dans `CalendarField`, parsing dans `MalioDate`.**
|
||||
|
||||
`CalendarField` reste agnostique au format : il expose un mode éditable (input non `readonly`, masque, buffer local) et émet du texte brut. `MalioDate` conserve toute la logique propre à la date (parse, validation `min`/`max`, état d'erreur). Cela évite de coupler `CalendarField` à un format date spécifique et garde le terrain prêt pour une éventuelle extension future à la famille Date.
|
||||
|
||||
Approches écartées :
|
||||
- **`CalendarField` générique avec fonction `parse` injectée** : trop générique pour le périmètre actuel (YAGNI).
|
||||
- **`MalioDate` gère son propre `<input>`** : duplication du rendu / label flottant / styles de `CalendarField`.
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. `MalioDate` — props ajoutées
|
||||
|
||||
- `editable?: boolean` — défaut `false`. Active la saisie clavier.
|
||||
- `invalidMessage?: string` — défaut `'Date invalide'`. Message affiché en cas de saisie invalide/hors-bornes.
|
||||
|
||||
Quand `editable === false`, le comportement est **strictement identique** à aujourd'hui (lecture seule, sélection calendrier uniquement).
|
||||
|
||||
### 2. `CalendarField` — mode éditable
|
||||
|
||||
Ajout d'une prop `editable?: boolean` (défaut `false`). Quand `true` :
|
||||
|
||||
- L'`<input>` perd l'attribut `readonly` et reçoit `v-maska="'##/##/####'"`.
|
||||
- Un buffer local `draft` (ref) alimente l'input : `:value="editable ? draft : displayValue"`.
|
||||
- `draft` est **resynchronisé** sur `displayValue` via un `watch` → couvre la sélection au calendrier, le clear, et tout changement externe de `modelValue`. Cette resynchro **efface aussi l'état d'erreur** côté `MalioDate` (via le nouveau `displayValue` émis).
|
||||
- À la frappe (`@input`) : met à jour `draft` et émet `input(text)`. **Pas de validation** à ce stade.
|
||||
- Au blur (`@blur`) : émet `commit(text)`.
|
||||
- À la touche Entrée (`@keydown.enter`) : émet `commit(text)` + ferme le popover.
|
||||
- `@focus` ouvre le popover, tout en laissant taper (input non `readonly`).
|
||||
|
||||
Quand `editable === false`, aucun de ces comportements ne s'applique : le chemin de code actuel reste inchangé.
|
||||
|
||||
`disabled` et `readonly` priment toujours sur `editable` (champ non éditable).
|
||||
|
||||
### 3. `MalioDate` — parsing, validation, état d'erreur
|
||||
|
||||
Une ref locale `internalError` est fusionnée avec la prop `error` du consommateur et transmise à `CalendarField` :
|
||||
`:error="error || internalError"` (l'erreur métier du consommateur reste prioritaire).
|
||||
|
||||
Sur réception de `commit(text)` :
|
||||
|
||||
- **Texte vide** → `emit('update:modelValue', null)` ; `internalError = ''`.
|
||||
- **Valide** (`parseDisplayToIso(text)` non `null` **et** `isDateInRange(iso, min, max)`) → `emit('update:modelValue', iso)` ; `internalError = ''`.
|
||||
- **Invalide ou hors-bornes** → on **n'émet pas** de nouveau `modelValue` ; `internalError = props.invalidMessage`. Le texte tapé reste affiché.
|
||||
|
||||
L'état d'erreur s'efface dès qu'une saisie valide ou une sélection calendrier ultérieure produit un nouveau `displayValue`.
|
||||
|
||||
## Flux de données
|
||||
|
||||
```
|
||||
Frappe clavier
|
||||
└─ CalendarField: maj draft + émet input(text) (pas de validation)
|
||||
Blur / Entrée
|
||||
└─ CalendarField: émet commit(text)
|
||||
└─ MalioDate: parseDisplayToIso + isDateInRange
|
||||
├─ valide → emit update:modelValue(iso) ; internalError=''
|
||||
├─ vide → emit update:modelValue(null) ; internalError=''
|
||||
└─ invalide→ internalError = invalidMessage ; (texte conservé)
|
||||
|
||||
Sélection calendrier
|
||||
└─ emit update:modelValue(iso)
|
||||
└─ displayValue change → CalendarField resync draft → erreur effacée
|
||||
```
|
||||
|
||||
## Réutilisation de l'existant
|
||||
|
||||
Les helpers nécessaires existent déjà dans `app/components/malio/date/composables/dateFormat.ts` :
|
||||
- `parseDisplayToIso(display)` → `string | null`
|
||||
- `isValidIso(iso)` → `boolean`
|
||||
- `isDateInRange(iso, min?, max?)` → `boolean`
|
||||
- `formatIsoToDisplay(iso)` → `string`
|
||||
|
||||
`maska` est déjà une dépendance du projet (utilisée par `InputText`/`InputPhone` via `v-maska` + `vMaska` de `maska/vue`).
|
||||
|
||||
## Tests (`Date.test.ts`)
|
||||
|
||||
- Frappe valide + blur → émet l'ISO attendu.
|
||||
- Saisie invalide (`32/13/2026`) au blur → texte conservé, message « Date invalide », `aria-invalid`.
|
||||
- Date valide hors `min`/`max` au blur → état d'erreur.
|
||||
- Saisie vide au blur → émet `null`.
|
||||
- Sélection au calendrier après une saisie invalide → erreur effacée, valeur mise à jour.
|
||||
- Touche Entrée → commit + fermeture popover.
|
||||
- `editable=false` (défaut) → input reste `readonly`, aucun nouveau comportement (non-régression).
|
||||
- `invalidMessage` personnalisé → message affiché respecté.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- Mise à jour de `COMPONENTS.md` (props `editable`, `invalidMessage`).
|
||||
- Entrée dans `CHANGELOG.md`.
|
||||
- Mise à jour de la story Histoire + page playground de `MalioDate` pour exposer la prop `editable`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Extension de la saisie manuelle à `DateTime`, `DateRange`, `DateWeek`.
|
||||
- Saisie partielle « intelligente » (auto-complétion d'année, etc.).
|
||||
- Validation à la frappe (on reste sur validation au blur).
|
||||
@@ -17,6 +17,9 @@ export default {
|
||||
borderRadius: {
|
||||
malio: 'var(--m-radius)',
|
||||
},
|
||||
width: {
|
||||
'm-btn-action': 'var(--m-btn-action-width)',
|
||||
},
|
||||
colors: {
|
||||
m: {
|
||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||
|
||||
Reference in New Issue
Block a user