feat : composant mot de passe

This commit is contained in:
2026-03-19 10:42:42 +01:00
parent ded414ba1a
commit deda3aa3f4
11 changed files with 935 additions and 7 deletions

View File

@@ -265,7 +265,7 @@ describe('MalioInputText', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('pointer-events-none')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('absolute')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-2')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('top-1/2')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2')
})
@@ -277,7 +277,7 @@ describe('MalioInputText', () => {
label: 'Password',
})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2')
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')
})

View File

@@ -53,7 +53,7 @@ describe('MalioInputAmount', () => {
expect(wrapper.get('[data-test="icon"]').exists()).toBe(true)
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-2')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('generates an amount-specific id', () => {
@@ -156,7 +156,7 @@ describe('MalioInputAmount', () => {
iconPosition: 'left',
})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2')
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')
})

View File

@@ -230,7 +230,7 @@ const focusPaddingClass = computed(() => {
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
</script>

View File

@@ -0,0 +1,174 @@
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 InputPassword from './InputPassword.vue'
type InputPasswordProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
}
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
const mountComponent = (props: InputPasswordProps = {}) =>
mount(InputPasswordForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputPassword', () => {
it('renders the initial input value', () => {
const wrapper = mountComponent({modelValue: 'secret123'})
expect(wrapper.get('input').element.value).toBe('secret123')
})
it('renders the label text', () => {
const wrapper = mountComponent({label: 'Mot de passe'})
expect(wrapper.get('label').text()).toBe('Mot de passe')
})
it('has type password by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('type')).toBe('password')
})
it('toggles to type text when icon is clicked', async () => {
const wrapper = mountComponent()
await wrapper.get('[data-test="icon"]').trigger('click')
expect(wrapper.get('input').attributes('type')).toBe('text')
})
it('toggles back to password on second click', async () => {
const wrapper = mountComponent()
await wrapper.get('[data-test="icon"]').trigger('click')
await wrapper.get('[data-test="icon"]').trigger('click')
expect(wrapper.get('input').attributes('type')).toBe('password')
})
it('does not render icon when displayIcon is false', () => {
const wrapper = mountComponent({displayIcon: false})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('renders icon by default', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="icon"]').exists()).toBe(true)
})
it('shows eye-off-outline icon when password is hidden', () => {
const wrapper = mountComponent()
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:eye-off-outline')
})
it('shows eye-outline icon when password is visible', async () => {
const wrapper = mountComponent()
await wrapper.get('[data-test="icon"]').trigger('click')
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:eye-outline')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue('new password')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new password'])
})
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: 'Mot de passe requis'})
expect(wrapper.get('p.text-m-error').text()).toBe('Mot de passe requis')
expect(wrapper.get('input').classes()).toContain('border-m-error')
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-error')
})
it('shows success message and styles', () => {
const wrapper = mountComponent({success: 'Mot de passe valide'})
expect(wrapper.get('p.text-m-success').text()).toBe('Mot de passe 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('links label to input via for/id', () => {
const wrapper = mountComponent({id: 'pwd', label: 'Password'})
expect(wrapper.get('input').attributes('id')).toBe('pwd')
expect(wrapper.get('label').attributes('for')).toBe('pwd')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountComponent({label: 'Password'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-password-')).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')
})
})

View File

@@ -0,0 +1,209 @@
<template>
<div
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
:type="isPasswordVisible ? 'text' : 'password'"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="displayIcon"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24"
:height="24"
data-test="icon"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success' : 'text-m-muted',
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
]"
@click="toggleVisibility"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
}>(),
{
id: '',
name: '',
autocomplete: 'off',
modelValue: undefined,
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
maxLength: undefined,
minLength: undefined,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
displayIcon: true,
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const isPasswordVisible = ref(false)
const toggleVisibility = () => {
isPasswordVisible.value = !isPasswordVisible.value
}
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 mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
: 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]',
props.inputClass,
),
)
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' : '',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (!isControlled.value) {
localValue.value = target.value
}
emit('update:modelValue', target.value)
}
const disabled = computed(() => props.disabled)
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
.grow-height { transition: none; }
}
</style>

View File

@@ -210,7 +210,7 @@ const focusPaddingClass = computed(() => {
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
</script>

View File

@@ -0,0 +1,252 @@
<template>
<Story title="Input/Password">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputPassword
v-model="simpleValue"
label="Mot de passe"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
<MalioInputPassword
v-model="noIconValue"
label="Mot de passe"
:display-icon="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputPassword
v-model="hintValue"
label="Mot de passe"
hint="8 caractères minimum"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputPassword
v-model="disabledValue"
label="Mot de passe"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputPassword
v-model="readonlyValue"
label="Mot de passe"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputPassword
v-model="errorValue"
label="Mot de passe"
error="Le mot de passe est trop court"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputPassword
v-model="successValue"
label="Mot de passe"
success="Mot de passe valide"
/>
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioInputPassword
Composant input mot de passe avec label flottant, toggle de visibilité
(icône oeil), états visuels (erreur / succès) et accessibilité.
------------------------------------------------------------------------
## Props détaillées
### id
- Type: string
- Description: Identifiant HTML de l'input.
- Comportement: Si non fourni, un id unique est généré
automatiquement.
### label
- Type: string
- Description: Texte affiché comme label flottant.
- Comportement: Si absent, aucun label n'est rendu.
### name
- Type: string
- Description: Attribut name de l'input (utile pour les formulaires).
### autocomplete
- Type: string
- Description: Active ou configure l'autocomplétion navigateur.
- Défaut: off
### modelValue
- Type: string | null | undefined
- Description: Valeur contrôlée du composant.
- Comportement:
- Si défini composant contrôlé (v-model).
- Sinon gestion interne de l'état.
------------------------------------------------------------------------
## Apparence & Style
### inputClass
- Type: string
- Description: Classes CSS appliquées à l'input.
### labelClass
- Type: string
- Description: Classes CSS appliquées au label.
### groupClass
- Type: string
- Description: Classes CSS appliquées au conteneur.
------------------------------------------------------------------------
## Validation & Contraintes
### required
- Type: boolean
- Description: Ajoute l'attribut HTML required.
### maxLength
- Type: number | string
- Description: Longueur maximale autorisée.
### minLength
- Type: number | string
- Description: Longueur minimale autorisée.
### disabled
- Type: boolean
- Description: Désactive complètement le champ.
### readonly
- Type: boolean
- Description: Rend le champ non modifiable mais focusable.
------------------------------------------------------------------------
## États & Messages
### hint
- Type: string
- Description: Message d'aide affiché sous le champ.
### error
- Type: string
- Description: Message d'erreur.
- Effet:
- Active l'état visuel erreur.
- aria-invalid=true
- Prioritaire sur success et hint.
### success
- Type: string
- Description: Message de succès.
- Effet:
- Actif uniquement si error est absent.
------------------------------------------------------------------------
## Icône de visibilité
### displayIcon
- Type: boolean
- Défaut: true
- Description: Affiche ou masque l'icône toggle de visibilité.
- Comportement:
- `true` : affiche une icône oeil cliquable à droite de l'input.
- `false` : pas d'icône, le type reste `password`.
### Icônes utilisées
- `mdi:eye-off-outline` : mot de passe masqué (état par défaut).
- `mdi:eye-outline` : mot de passe visible (après clic).
### Couleur de l'icône
- `text-m-muted` par défaut.
- `text-m-error` si la prop `error` est renseignée.
- `text-m-success` si la prop `success` est renseignée.
------------------------------------------------------------------------
## Comportement
- Au clic sur l'icône, le type de l'input alterne entre `password` et `text`.
- Aucune validation interne.
- Les états sont pilotés uniquement par les props.
## Priorité visuelle
1. error
2. success
3. neutre
------------------------------------------------------------------------
## Accessibilité
- aria-invalid est activé si error existe.
- aria-describedby référence dynamiquement le message affiché.
- Fonctionne avec ou sans v-model.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque modification de l'input.
- Permet l'utilisation avec v-model.
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioInputPassword from '../components/malio/InputPassword.vue'
const simpleValue = ref('')
const noIconValue = ref('')
const hintValue = ref('')
const disabledValue = ref('motdepasse123')
const readonlyValue = ref('lectureseule')
const errorValue = ref('abc')
const successValue = ref('Str0ngP@ss!')
</script>