From f3a18ace1d527e417806df9ded431566f33f61c9 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 13 May 2026 07:01:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20composant=20saisie=20assist=C3=A9e,=20c?= =?UTF-8?q?omposant=20t=C3=A9l=C3=A9phone=20et=20composant=20mail=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Co-authored-by: matthieu Co-authored-by: THOLOT DECHENE Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/47 Co-authored-by: tristan Co-committed-by: tristan --- .playground/pages/composant/form/client.vue | 332 +++++++++--- .../composant/input/inputAutocomplete.vue | 180 ++++++ .../pages/composant/input/inputEmail.vue | 106 ++++ .../pages/composant/input/inputPhone.vue | 141 +++++ CHANGELOG.md | 3 + COMPONENTS.md | 187 ++++++- .../malio/checkbox/Checkbox.test.ts | 26 +- app/components/malio/checkbox/Checkbox.vue | 35 +- app/components/malio/input/Input.test.ts | 16 +- .../malio/input/InputAmount.test.ts | 16 +- app/components/malio/input/InputAmount.vue | 21 +- .../malio/input/InputAutocomplete.test.ts | 430 +++++++++++++++ .../malio/input/InputAutocomplete.vue | 513 ++++++++++++++++++ app/components/malio/input/InputEmail.test.ts | 228 ++++++++ app/components/malio/input/InputEmail.vue | 229 ++++++++ .../malio/input/InputPassword.test.ts | 14 + app/components/malio/input/InputPassword.vue | 16 +- app/components/malio/input/InputPhone.test.ts | 308 +++++++++++ app/components/malio/input/InputPhone.vue | 274 ++++++++++ app/components/malio/input/InputText.vue | 21 +- app/components/malio/input/InputTextArea.vue | 13 +- .../malio/input/InputUpload.test.ts | 14 + app/components/malio/input/InputUpload.vue | 16 +- .../malio/radio/RadioButton.test.ts | 29 + app/components/malio/radio/RadioButton.vue | 17 +- app/components/malio/select/Select.vue | 44 +- .../malio/select/SelectCheckbox.vue | 50 +- app/components/malio/tab/TabList.test.ts | 50 ++ app/components/malio/tab/TabList.vue | 16 +- app/components/malio/time/Time.vue | 6 +- app/story/input/inputAutocomplete.story.vue | 294 ++++++++++ app/story/input/inputEmail.story.vue | 261 +++++++++ app/story/input/inputPhone.story.vue | 285 ++++++++++ 33 files changed, 4040 insertions(+), 151 deletions(-) create mode 100644 .playground/pages/composant/input/inputAutocomplete.vue create mode 100644 .playground/pages/composant/input/inputEmail.vue create mode 100644 .playground/pages/composant/input/inputPhone.vue create mode 100644 app/components/malio/input/InputAutocomplete.test.ts create mode 100644 app/components/malio/input/InputAutocomplete.vue create mode 100644 app/components/malio/input/InputEmail.test.ts create mode 100644 app/components/malio/input/InputEmail.vue create mode 100644 app/components/malio/input/InputPhone.test.ts create mode 100644 app/components/malio/input/InputPhone.vue create mode 100644 app/story/input/inputAutocomplete.story.vue create mode 100644 app/story/input/inputEmail.story.vue create mode 100644 app/story/input/inputPhone.story.vue diff --git a/.playground/pages/composant/form/client.vue b/.playground/pages/composant/form/client.vue index ad29579..ac6131c 100644 --- a/.playground/pages/composant/form/client.vue +++ b/.playground/pages/composant/form/client.vue @@ -1,8 +1,16 @@ diff --git a/.playground/pages/composant/input/inputAutocomplete.vue b/.playground/pages/composant/input/inputAutocomplete.vue new file mode 100644 index 0000000..73a70b5 --- /dev/null +++ b/.playground/pages/composant/input/inputAutocomplete.vue @@ -0,0 +1,180 @@ + + + diff --git a/.playground/pages/composant/input/inputEmail.vue b/.playground/pages/composant/input/inputEmail.vue new file mode 100644 index 0000000..38c6791 --- /dev/null +++ b/.playground/pages/composant/input/inputEmail.vue @@ -0,0 +1,106 @@ + + + diff --git a/.playground/pages/composant/input/inputPhone.vue b/.playground/pages/composant/input/inputPhone.vue new file mode 100644 index 0000000..5775e28 --- /dev/null +++ b/.playground/pages/composant/input/inputPhone.vue @@ -0,0 +1,141 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d28167..a07c7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-22] Création d'un composant datatable * [#MUI-27] Création d'un composant sélection de site * Création d'un composant rich text (TipTap) avec sortie markdown / HTML +* [#MUI-30] Création d'un composant email +* [#MUI-31] Création d'un composant téléphone +* [#MUI-32] Création d'un composant saisie assistée (autocomplete) ### Changed diff --git a/COMPONENTS.md b/COMPONENTS.md index 8dae0ea..f45b21d 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -66,6 +66,160 @@ Champ mot de passe avec toggle visibilité. --- +## MalioInputEmail + +Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outline` à droite par défaut. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto | Identifiant HTML | +| `label` | `string` | `''` | Label du champ | +| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) | +| `name` | `string` | `''` | Attribut name | +| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) | +| `disabled` | `boolean` | `false` | Désactive le champ | +| `readonly` | `boolean` | `false` | Lecture seule | +| `required` | `boolean` | `false` | Champ requis | +| `hint` | `string` | `''` | Message d'aide | +| `error` | `string` | `''` | Message d'erreur | +| `success` | `string` | `''` | Message de succès | +| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) | +| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône | +| `iconSize` | `string \| number` | `24` | Taille icône | +| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône | +| `inputClass` | `string` | `''` | Classes CSS input | +| `labelClass` | `string` | `''` | Classes CSS label | +| `groupClass` | `string` | `''` | Classes CSS conteneur | + +**Events :** `update:modelValue(value: string)` + +```vue + + + + +``` + +--- + +## MalioInputPhone + +Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outline` à gauche par défaut et bouton `+` optionnel à droite pour gérer une liste de numéros côté parent. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto | Identifiant HTML | +| `label` | `string` | `''` | Label du champ | +| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) | +| `name` | `string` | `''` | Attribut name | +| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) | +| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + | +| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) | +| `required` | `boolean` | `false` | Champ requis | +| `hint` | `string` | `''` | Message d'aide | +| `error` | `string` | `''` | Message d'erreur | +| `success` | `string` | `''` | Message de succès | +| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) | +| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône | +| `iconSize` | `string \| number` | `24` | Taille icône | +| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône | +| `mask` | `string \| MaskInputOptions` | `undefined` | Masque maska (aucun par défaut, utile pour mono-pays) | +| `addable` | `boolean` | `false` | Affiche un bouton à droite qui émet l'event `add` | +| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout | +| `addButtonLabel` | `string` | `'Ajouter un numéro'` | aria-label du bouton d'ajout | +| `inputClass` | `string` | `''` | Classes CSS input | +| `labelClass` | `string` | `''` | Classes CSS label | +| `groupClass` | `string` | `''` | Classes CSS conteneur | + +**Events :** +- `update:modelValue(value: string)` +- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`) + +```vue + + + + +``` + +--- + +## MalioInputAutocomplete + +Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto | Identifiant HTML | +| `label` | `string` | `''` | Label flottant | +| `modelValue` | `string \| number \| null` | `undefined` | Valeur sélectionnée (v-model) | +| `name` | `string` | `''` | Attribut name | +| `options` | `{label: string; value: string\|number}[]` | `[]` | Liste affichée dans le dropdown | +| `loading` | `boolean` | `false` | Affiche un spinner + un message de chargement | +| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` | +| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` | +| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) | +| `iconName` | `string` | `''` | Icône Iconify décorative | +| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative | +| `iconSize` | `string \| number` | `24` | Taille de l'icône | +| `iconColor` | `string` | `'text-m-muted'` | Classe couleur de l'icône | +| `noResultsText` | `string` | `'Aucun résultat'` | Texte affiché quand `options` est vide | +| `loadingText` | `string` | `'Chargement…'` | Texte affiché pendant le chargement | +| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint | +| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture | +| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) | +| `required` | `boolean` | `false` | Champ requis | +| `hint` | `string` | `''` | Message d'aide | +| `error` | `string` | `''` | Message d'erreur (prioritaire) | +| `success` | `string` | `''` | Message de succès | +| `inputClass` | `string` | `''` | Classes CSS input | +| `labelClass` | `string` | `''` | Classes CSS label | +| `groupClass` | `string` | `''` | Classes CSS conteneur | + +**Events :** +- `update:modelValue(value: string \| number \| null)` — valeur sélectionnée (v-model) +- `search(query: string)` — émis (après debounce + minSearchLength) avec le texte tapé ; le parent l'écoute pour lancer son fetch API +- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`) +- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre + +**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown. + +```vue + + + + + + + + +``` + +```ts +async function onSearchClients(query: string) { + isFetching.value = true + const res = await $fetch('/api/clients', {params: {q: query}}) + clientOptions.value = res.map(c => ({label: c.name, value: c.id})) + isFetching.value = false +} +``` + +--- + ## MalioInputAmount Champ montant avec icône devise (euro par défaut). @@ -122,6 +276,7 @@ Zone de texte multiligne avec compteur et redimensionnement. | `showCounter` | `boolean` | `false` | Afficher le compteur | | `disabled` | `boolean` | `false` | Désactivé | | `error` | `string` | `''` | Message d'erreur | +| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) | **Events :** `update:modelValue(value: string)` @@ -207,6 +362,7 @@ Liste déroulante. | `textField` | `string` | `'text-lg'` | Classe taille texte bouton | | `textValue` | `string` | `'text-lg'` | Classe taille texte valeur | | `textLabel` | `string` | `'text-sm'` | Classe taille texte label | +| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide | **Events :** `update:modelValue(value: string | number | null)` **Slots :** `icon` (icône dropdown custom) @@ -232,6 +388,7 @@ Liste déroulante multi-sélection avec checkboxes. | `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global | | `label` | `string` | `''` | Label | | `disabled` | `boolean` | `false` | Désactivé | +| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide | **Events :** `update:modelValue(value: (string | number)[])` @@ -362,18 +519,42 @@ Navigation par onglets avec contenu dynamique. | Prop | Type | Défaut | Description | |------|------|--------|-------------| | `modelValue` | `string` | `undefined` | Onglet actif (v-model) | -| `tabs` | `{ key: string, label: string, icon?: string }[]` | **requis** | Liste des onglets | +| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) | -**Events :** `update:modelValue(value: string)` +Type `Tab` : + +| Propriété | Type | Défaut | Description | +|-----------|------|--------|-------------| +| `key` | `string` | — | Identifiant unique (utilisé pour le slot et le v-model) | +| `label` | `string` | — | Texte de l'onglet | +| `icon` | `string` | — | Nom Iconify (optionnel) | +| `iconSize` | `string` | `24` | Taille de l'icône | +| `disabled` | `boolean` | `false` | Onglet désactivé : grisé et non cliquable. Le parent calcule cet état selon sa logique de validation | + +**Events :** `update:modelValue(value: string)` — émis uniquement quand l'onglet cible n'est pas `disabled` **Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet ```vue - + ``` +**Pattern de gating progressif** (déverrouille les onglets quand les précédents sont valides) : + +```ts +const informationValid = computed(() => name.value && email.value) +const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value)) + +const tabs = computed(() => [ + { key: 'information', label: 'Information' }, + { key: 'contacts', label: 'Contacts', disabled: !informationValid.value }, + { key: 'adresses', label: 'Adresses', disabled: !informationValid.value }, + { key: 'transport', label: 'Transport', disabled: !informationValid.value || !adressesValid.value }, +]) +``` + --- ## MalioSidebar diff --git a/app/components/malio/checkbox/Checkbox.test.ts b/app/components/malio/checkbox/Checkbox.test.ts index 6fccf55..0f27fa1 100644 --- a/app/components/malio/checkbox/Checkbox.test.ts +++ b/app/components/malio/checkbox/Checkbox.test.ts @@ -114,7 +114,7 @@ describe('MalioCheckbox', () => { }) expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') - expect(wrapper.get('label').classes()).toContain('text-m-error') + expect(wrapper.get('label').classes()).toContain('text-m-danger') expect(wrapper.get('p').text()).toBe('You must accept') }) @@ -125,7 +125,7 @@ describe('MalioCheckbox', () => { }) expect(wrapper.get('p').text()).toBe('Invalid') - expect(wrapper.get('p').classes()).toContain('text-m-error') + expect(wrapper.get('p').classes()).toContain('text-m-danger') }) it('shows success styles and message when there is no error', () => { @@ -139,4 +139,26 @@ describe('MalioCheckbox', () => { expect(wrapper.get('p').text()).toBe('Valid') expect(wrapper.get('p').classes()).toContain('text-m-success') }) + + it('uses muted label color when unchecked', () => { + const wrapper = mountCheckbox({label: 'Accept terms', modelValue: false}) + + expect(wrapper.get('label').classes()).toContain('text-m-muted') + }) + + it('uses black label color when checked', () => { + const wrapper = mountCheckbox({label: 'Accept terms', modelValue: true}) + + expect(wrapper.get('label').classes()).toContain('text-black') + }) + + it('updates label color when toggled without v-model (uncontrolled)', async () => { + const wrapper = mountCheckbox({label: 'Accept terms'}) + + expect(wrapper.get('label').classes()).toContain('text-m-muted') + + await wrapper.get('input').setValue(true) + + expect(wrapper.get('label').classes()).toContain('text-black') + }) }) diff --git a/app/components/malio/checkbox/Checkbox.vue b/app/components/malio/checkbox/Checkbox.vue index 4b9a698..a968a15 100644 --- a/app/components/malio/checkbox/Checkbox.vue +++ b/app/components/malio/checkbox/Checkbox.vue @@ -40,7 +40,7 @@ @@ -161,10 +168,14 @@ const onChange = (event: Event) => { height: 18px; flex: 0 0 18px; transform: scale(1); - border: 2px solid rgb(0, 0, 0); + border: 2px solid rgb(var(--m-muted) / 1); transition: all 0.1s ease; } +.inp-cbx:checked + .cbx span:first-child { + border-color: rgb(0, 0, 0); +} + .cbx span:first-child svg { position: absolute; top: 2px; @@ -200,14 +211,14 @@ const onChange = (event: Event) => { stroke-dashoffset: 0; } -.inp-cbx + .cbx.text-m-error span:first-child { - border-color: rgb(var(--m-error) / 1); +.inp-cbx + .cbx.text-m-danger span:first-child { + border-color: rgb(var(--m-danger) / 1); } -.cbx.text-m-error span:first-child svg { - stroke: rgb(var(--m-error) / 1); +.cbx.text-m-danger span:first-child svg { + stroke: rgb(var(--m-danger) / 1); } -.inp-cbx:checked + .cbx.text-m-error span:first-child { - border-color: rgb(var(--m-error) / 1); +.inp-cbx:checked + .cbx.text-m-danger span:first-child { + border-color: rgb(var(--m-danger) / 1); } .inp-cbx + .cbx.text-m-success span:first-child { diff --git a/app/components/malio/input/Input.test.ts b/app/components/malio/input/Input.test.ts index 8accb92..167bb35 100644 --- a/app/components/malio/input/Input.test.ts +++ b/app/components/malio/input/Input.test.ts @@ -279,7 +279,7 @@ describe('MalioInputText', () => { expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]') expect(wrapper.get('input').classes()).toContain('!pl-11') - expect(wrapper.get('label').classes()).toContain('left-8') + expect(wrapper.get('label').classes()).toContain('left-11') }) it('passes icon size props to icon component', () => { @@ -294,4 +294,18 @@ describe('MalioInputText', () => { expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') }) + + it('shows primary icon color on focus', async () => { + const wrapper = mountInput({iconName: 'mdi:key-outline'}) + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) + + it('shows black icon color when filled and unfocused', () => { + const wrapper = mountInput({iconName: 'mdi:key-outline', modelValue: 'hello'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black') + }) }) diff --git a/app/components/malio/input/InputAmount.test.ts b/app/components/malio/input/InputAmount.test.ts index 6a57f54..a8c4e2f 100644 --- a/app/components/malio/input/InputAmount.test.ts +++ b/app/components/malio/input/InputAmount.test.ts @@ -158,6 +158,20 @@ describe('MalioInputAmount', () => { expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]') expect(wrapper.get('input').classes()).toContain('!pl-11') - expect(wrapper.get('label').classes()).toContain('left-8') + expect(wrapper.get('label').classes()).toContain('left-11') + }) + + it('shows primary icon color on focus', async () => { + const wrapper = mountInputAmount() + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) + + it('shows black icon color when filled and unfocused', () => { + const wrapper = mountInputAmount({modelValue: '12,50'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black') }) }) diff --git a/app/components/malio/input/InputAmount.vue b/app/components/malio/input/InputAmount.vue index ef58742..210cea9 100644 --- a/app/components/malio/input/InputAmount.vue +++ b/app/components/malio/input/InputAmount.vue @@ -39,13 +39,7 @@ :width="iconSize" :height="iconSize" data-test="icon" - :class="[ - hasError - ? 'text-m-danger' - : hasSuccess - ? 'text-m-success' : iconColor, - iconPositionClass, - ]" + :class="[iconStateClass, iconPositionClass]" /> @@ -141,7 +135,7 @@ 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 focus:border-2 text-lg rounded-md', + 'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md', isFilled.value ? 'border-black' : 'border-m-muted', disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text', hasError.value @@ -222,7 +216,7 @@ const iconInputPaddingClass = computed(() => { const disabled = computed(() => props.disabled) const labelPositionClass = computed(() => { - if (props.iconName && props.iconPosition === 'left') return 'left-8' + if (props.iconName && props.iconPosition === 'left') return 'left-11' return 'left-3' }) @@ -235,6 +229,15 @@ const iconPositionClass = computed(() => { const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]' return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2` }) + +const iconStateClass = computed(() => { + if (hasError.value) return 'text-m-danger' + if (hasSuccess.value) return 'text-m-success' + if (disabled.value) return props.iconColor + if (isFocused.value) return 'text-m-primary' + if (isFilled.value) return 'text-black' + return props.iconColor +}) diff --git a/app/components/malio/input/InputEmail.test.ts b/app/components/malio/input/InputEmail.test.ts new file mode 100644 index 0000000..0e0259c --- /dev/null +++ b/app/components/malio/input/InputEmail.test.ts @@ -0,0 +1,228 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import { Icon as IconifyIcon } from '@iconify/vue' +import InputEmail from './InputEmail.vue' + +type InputEmailProps = { + id?: string + label?: string + name?: string + autocomplete?: string + modelValue?: string | null + inputClass?: string + labelClass?: string + groupClass?: string + required?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + iconName?: string + iconPosition?: 'left' | 'right' + iconSize?: string | number + iconColor?: string +} + +const InputEmailForTest = InputEmail as DefineComponent + +const mountComponent = (props: InputEmailProps = {}) => + mount(InputEmailForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputEmail', () => { + it('renders the initial input value', () => { + const wrapper = mountComponent({modelValue: 'user@example.com'}) + + expect(wrapper.get('input').element.value).toBe('user@example.com') + }) + + it('renders the label text', () => { + const wrapper = mountComponent({label: 'Adresse email'}) + + expect(wrapper.get('label').text()).toBe('Adresse email') + }) + + it('has type email', () => { + const wrapper = mountComponent() + + expect(wrapper.get('input').attributes('type')).toBe('email') + }) + + it('has inputmode email', () => { + const wrapper = mountComponent() + + expect(wrapper.get('input').attributes('inputmode')).toBe('email') + }) + + it('renders the default email icon', () => { + const wrapper = mountComponent() + + const iconComponent = wrapper.findComponent(IconifyIcon) + expect(iconComponent.props('icon')).toBe('mdi:email-outline') + }) + + it('allows overriding the icon', () => { + const wrapper = mountComponent({iconName: 'mdi:at'}) + + const iconComponent = wrapper.findComponent(IconifyIcon) + expect(iconComponent.props('icon')).toBe('mdi:at') + }) + + it('does not render icon when iconName is empty', () => { + const wrapper = mountComponent({iconName: ''}) + + expect(wrapper.find('[data-test="icon"]').exists()).toBe(false) + }) + + it('places icon on the right by default', () => { + const wrapper = mountComponent() + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]') + }) + + it('places icon on the left when iconPosition is left', () => { + const wrapper = mountComponent({iconPosition: 'left'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]') + }) + + it('emits update:modelValue on input change', async () => { + const wrapper = mountComponent({modelValue: ''}) + + await wrapper.get('input').setValue('new@example.com') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new@example.com']) + }) + + it('sets disabled styles when true', () => { + const wrapper = mountComponent({disabled: true}) + + expect(wrapper.get('input').attributes('disabled')).toBeDefined() + expect(wrapper.get('input').classes()).toContain('cursor-not-allowed') + }) + + it('sets readonly when true', () => { + const wrapper = mountComponent({readonly: true}) + + expect(wrapper.get('input').attributes('readonly')).toBeDefined() + }) + + it('shows error message and styles', () => { + const wrapper = mountComponent({error: 'Email invalide'}) + + expect(wrapper.get('p.text-m-danger').text()).toBe('Email invalide') + expect(wrapper.get('input').classes()).toContain('border-m-danger') + expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') + }) + + it('shows error style on icon', () => { + const wrapper = mountComponent({error: 'Error'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger') + }) + + it('shows success message and styles', () => { + const wrapper = mountComponent({success: 'Email valide'}) + + expect(wrapper.get('p.text-m-success').text()).toBe('Email valide') + expect(wrapper.get('input').classes()).toContain('border-m-success') + }) + + it('shows success style on icon', () => { + const wrapper = mountComponent({success: 'Success'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success') + }) + + it('shows default icon color when empty and unfocused', () => { + const wrapper = mountComponent() + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted') + }) + + it('shows primary icon color on focus', async () => { + const wrapper = mountComponent() + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) + + it('shows black icon color when filled and unfocused', () => { + const wrapper = mountComponent({modelValue: 'user@example.com'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black') + }) + + it('keeps primary icon color when filled and focused', async () => { + const wrapper = mountComponent({modelValue: 'user@example.com'}) + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) + + it('keeps default icon color when disabled, even if filled', () => { + const wrapper = mountComponent({modelValue: 'user@example.com', disabled: true}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted') + }) + + it('error overrides focus color on icon', async () => { + const wrapper = mountComponent({error: 'Email invalide'}) + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger') + }) + + it('shows hint message', () => { + const wrapper = mountComponent({hint: 'ex: prenom.nom@malio.fr'}) + + expect(wrapper.get('p.text-m-muted').text()).toBe('ex: prenom.nom@malio.fr') + }) + + it('links label to input via for/id', () => { + const wrapper = mountComponent({id: 'email-field', label: 'Email'}) + + expect(wrapper.get('input').attributes('id')).toBe('email-field') + expect(wrapper.get('label').attributes('for')).toBe('email-field') + }) + + it('generates an id when missing and reuses it on label', () => { + const wrapper = mountComponent({label: 'Email'}) + + const inputId = wrapper.get('input').attributes('id') + + expect(inputId?.startsWith('malio-input-email-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(inputId) + }) + + it('aria-invalid is false when no error', () => { + const wrapper = mountComponent() + + expect(wrapper.get('input').attributes('aria-invalid')).toBe('false') + }) + + it('uses autocomplete off by default', () => { + const wrapper = mountComponent() + + expect(wrapper.get('input').attributes('autocomplete')).toBe('off') + }) + + it('allows overriding autocomplete', () => { + const wrapper = mountComponent({autocomplete: 'email'}) + + expect(wrapper.get('input').attributes('autocomplete')).toBe('email') + }) +}) diff --git a/app/components/malio/input/InputEmail.vue b/app/components/malio/input/InputEmail.vue new file mode 100644 index 0000000..c0498dd --- /dev/null +++ b/app/components/malio/input/InputEmail.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/app/components/malio/input/InputPassword.test.ts b/app/components/malio/input/InputPassword.test.ts index 48eed58..a201bdb 100644 --- a/app/components/malio/input/InputPassword.test.ts +++ b/app/components/malio/input/InputPassword.test.ts @@ -171,4 +171,18 @@ describe('MalioInputPassword', () => { expect(wrapper.get('input').attributes('aria-invalid')).toBe('false') }) + + it('shows primary icon color on focus', async () => { + const wrapper = mountComponent() + + await wrapper.get('input').trigger('focus') + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) + + it('shows black icon color when filled and unfocused', () => { + const wrapper = mountComponent({modelValue: 'secret'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black') + }) }) diff --git a/app/components/malio/input/InputPassword.vue b/app/components/malio/input/InputPassword.vue index a63cdd3..c28d73e 100644 --- a/app/components/malio/input/InputPassword.vue +++ b/app/components/malio/input/InputPassword.vue @@ -39,10 +39,7 @@ :height="24" data-test="icon" :class="[ - hasError - ? 'text-m-danger' - : hasSuccess - ? 'text-m-success' : 'text-m-muted', + iconStateClass, 'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2', ]" @click="toggleVisibility" @@ -140,7 +137,7 @@ 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 focus:border-2 text-lg rounded-md', + 'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md', isFilled.value ? 'border-black' : 'border-m-muted', disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text', hasError.value @@ -189,6 +186,15 @@ const onInput = (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 (isFocused.value) return 'text-m-primary' + if (isFilled.value) return 'text-black' + return 'text-m-muted' +}) diff --git a/app/components/malio/input/InputText.vue b/app/components/malio/input/InputText.vue index d936b2e..80a9ccf 100644 --- a/app/components/malio/input/InputText.vue +++ b/app/components/malio/input/InputText.vue @@ -39,13 +39,7 @@ :width="iconSize" :height="iconSize" data-test="icon" - :class="[ - hasError - ? 'text-m-danger' - : hasSuccess - ? 'text-m-success' : iconColor, - iconPositionClass, - ]" + :class="[iconStateClass, iconPositionClass]" /> @@ -146,7 +140,7 @@ 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 focus:border-2 text-lg rounded-md', + 'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md', isFilled.value ? 'border-black' : 'border-m-muted', disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text', hasError.value @@ -202,7 +196,7 @@ const iconInputPaddingClass = computed(() => { const disabled = computed(() => props.disabled) const labelPositionClass = computed(() => { - if (props.iconName && props.iconPosition === 'left') return 'left-8' + if (props.iconName && props.iconPosition === 'left') return 'left-11' return 'left-3' }) @@ -215,6 +209,15 @@ const iconPositionClass = computed(() => { const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]' return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2` }) + +const iconStateClass = computed(() => { + if (hasError.value) return 'text-m-danger' + if (hasSuccess.value) return 'text-m-success' + if (disabled.value) return props.iconColor + if (isFocused.value) return 'text-m-primary' + if (isFilled.value) return 'text-black' + return props.iconColor +})