feat : composant mot de passe
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run:*)"
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npx vitest:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
190
.claude/skills/creating-malio-component/SKILL.md
Normal file
190
.claude/skills/creating-malio-component/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
---
|
||||||
|
name: creating-malio-component
|
||||||
|
description: Use when creating a new UI component in the @malio/layer-ui Nuxt layer — covers component, tests, playground page, and Histoire story
|
||||||
|
---
|
||||||
|
|
||||||
|
# Creating a Malio Component
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Step-by-step process for creating a component in `@malio/layer-ui`. Each component requires 5 deliverables : le `.vue`, les tests, la page playground, la story Histoire, et la mise à jour du CHANGELOG.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Création d'un nouveau composant dans `app/components/malio/`
|
||||||
|
- Ajout d'une variante d'un composant existant (ex: InputPassword basé sur InputText)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph create_component {
|
||||||
|
rankdir=TB;
|
||||||
|
"1. Lire les fichiers de référence" -> "2. Créer le composant .vue";
|
||||||
|
"2. Créer le composant .vue" -> "3. Créer les tests .test.ts";
|
||||||
|
"3. Créer les tests .test.ts" -> "4. npm run test + npm run lint";
|
||||||
|
"4. npm run test + npm run lint" -> "Tests OK?" [shape=diamond];
|
||||||
|
"Tests OK?" -> "5. Créer la page playground" [label="oui"];
|
||||||
|
"Tests OK?" -> "3. Créer les tests .test.ts" [label="non, corriger"];
|
||||||
|
"5. Créer la page playground" -> "6. Créer la story Histoire";
|
||||||
|
"6. Créer la story Histoire" -> "7. Mettre à jour CHANGELOG.md";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
### 1. Lire les fichiers de référence
|
||||||
|
|
||||||
|
Identifier le composant le plus proche comme base (ex: `InputText.vue` pour `InputPassword.vue`). Lire :
|
||||||
|
- Le composant de référence : `app/components/malio/<Ref>.vue`
|
||||||
|
- Ses tests : `app/components/malio/<Ref>.test.ts`
|
||||||
|
|
||||||
|
### 2. Créer le composant `.vue`
|
||||||
|
|
||||||
|
**Fichier :** `app/components/malio/<NomComposant>.vue`
|
||||||
|
|
||||||
|
**Checklist obligatoire :**
|
||||||
|
|
||||||
|
| Élément | Pattern |
|
||||||
|
|---------|---------|
|
||||||
|
| `defineOptions` | `{ name: 'Malio<Nom>', inheritAttrs: false }` |
|
||||||
|
| Props | `defineProps<T>()` + `withDefaults()` — props communes : `id`, `label`, `modelValue`, `inputClass`, `labelClass`, `groupClass`, `disabled`, `readonly`, `hint`, `error`, `success` |
|
||||||
|
| Contrôlé / non-contrôlé | `isControlled = computed(() => props.modelValue !== undefined)` + `localValue` en fallback |
|
||||||
|
| Classes CSS | Fusionnées via `twMerge()` pour permettre l'override consommateur |
|
||||||
|
| Accessibilité | `aria-invalid`, `aria-describedby`, `label[for]` lié à `input[id]` |
|
||||||
|
| Icônes | `Icon as IconifyIcon` depuis `@iconify/vue` (pas `@nuxt/icon`) |
|
||||||
|
| ID généré | `useId()` + prefix unique (ex: `malio-input-password-${generatedId}`) |
|
||||||
|
|
||||||
|
### 3. Créer les tests `.test.ts`
|
||||||
|
|
||||||
|
**Fichier :** `app/components/malio/<NomComposant>.test.ts` (colocalisé)
|
||||||
|
|
||||||
|
**Pattern de montage :**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
import MonComposant from './MonComposant.vue'
|
||||||
|
|
||||||
|
const ComposantForTest = MonComposant as DefineComponent<MonComposantProps>
|
||||||
|
|
||||||
|
const mountComponent = (props: MonComposantProps = {}) =>
|
||||||
|
mount(ComposantForTest, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
IconifyIcon: {
|
||||||
|
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tests minimum à couvrir :**
|
||||||
|
- Rendu initial avec valeur
|
||||||
|
- Rendu du label
|
||||||
|
- Emit `update:modelValue`
|
||||||
|
- Props `disabled`, `readonly`
|
||||||
|
- États `error`, `success`, `hint` (messages + classes CSS)
|
||||||
|
- Accessibilité (`aria-invalid`, `label[for]` / `input[id]`)
|
||||||
|
- Comportements spécifiques au composant
|
||||||
|
|
||||||
|
**Attention stub IconifyIcon :** Le stub basé sur le nom `IconifyIcon` ne remplace pas toujours le vrai composant `@iconify/vue`. Pour tester les props du composant Icon (ex: `icon`), utiliser `findComponent` avec l'import réel :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
// ...
|
||||||
|
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(iconComponent.props('icon')).toBe('mdi:eye-outline')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Vérification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # Tous les tests passent
|
||||||
|
npm run lint # Pas d'erreurs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Créer la page playground
|
||||||
|
|
||||||
|
**Fichier :** `.playground/pages/composant/<nomComposant>.vue` (camelCase)
|
||||||
|
|
||||||
|
La page est auto-détectée par `index.vue` via `import.meta.glob`. Inclure des variantes représentatives dans une grille :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Titre variante</h2>
|
||||||
|
<MalioMonComposant ... />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variantes typiques :** simple, avec label, désactivé, readonly, hint, erreur, succès, validation dynamique.
|
||||||
|
|
||||||
|
### 6. Créer la story Histoire
|
||||||
|
|
||||||
|
**Fichier :** `app/story/<nomComposant>.story.vue` (camelCase)
|
||||||
|
|
||||||
|
**Structure :**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Story title="Category/Name">
|
||||||
|
<!-- Variantes avec v-model et valeurs initiales -->
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioNomComposant
|
||||||
|
Description courte.
|
||||||
|
## Props détaillées
|
||||||
|
<!-- Documenter chaque prop : type, description, défaut, comportement -->
|
||||||
|
## Comportement
|
||||||
|
## Accessibilité
|
||||||
|
## Events
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import MalioMonComposant from '../components/malio/MonComposant.vue'
|
||||||
|
// refs pour chaque variante avec valeurs initiales
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important : initial state avec variantes.** La story doit contenir des exemples visuels directement visibles (pas un composant vide). Chaque variante a un `v-model` avec une `ref` initialisée. Variantes typiques à inclure :
|
||||||
|
- Simple (avec label)
|
||||||
|
- Sans icône (`display-icon="false"`) si applicable
|
||||||
|
- Avec hint
|
||||||
|
- Désactivé (avec valeur pré-remplie)
|
||||||
|
- Readonly (avec valeur pré-remplie)
|
||||||
|
- Erreur (avec valeur + message d'erreur)
|
||||||
|
- Succès (avec valeur + message de succès)
|
||||||
|
|
||||||
|
### 7. Mettre à jour le CHANGELOG
|
||||||
|
|
||||||
|
**Fichier :** `CHANGELOG.md` à la racine du projet.
|
||||||
|
|
||||||
|
Ajouter une ligne dans la section `### Added` de la version courante. Le numéro de ticket se trouve dans le nom de la branche Git (ex: branche `feat/MUI-8-composant-password` → ticket `MUI-8`).
|
||||||
|
|
||||||
|
**Format :**
|
||||||
|
- Avec numéro de ticket : `* [#MUI-8] Création d'un composant mot de passe`
|
||||||
|
- Sans numéro de ticket : `* Création d'un composant textarea`
|
||||||
|
|
||||||
|
Pour extraire le numéro de ticket depuis la branche courante :
|
||||||
|
```bash
|
||||||
|
git branch --show-current | grep -oP '(MUI-\d+|\d{3,})' | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
Cette section est alimentée au fur et à mesure des retours utilisateur et des problèmes rencontrés. **Si un retour ou un bug est identifié lors de la création d'un composant, ajouter une ligne dans ce tableau.**
|
||||||
|
|
||||||
|
| Erreur | Solution |
|
||||||
|
|--------|----------|
|
||||||
|
| Stub IconifyIcon ne fonctionne pas dans les tests | Utiliser `findComponent(IconifyIcon)` avec l'import réel pour tester les props |
|
||||||
|
| Oubli de `inheritAttrs: false` | Toujours dans `defineOptions` — sinon les attrs se dupliquent |
|
||||||
|
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
|
||||||
|
| Padding input pas ajusté avec icône | Ajouter `!pr-10` (ou équivalent) quand une icône est présente à droite |
|
||||||
|
| Story sans initial state | Toujours initialiser les `ref` avec des valeurs pour que les variantes soient visibles dès le chargement |
|
||||||
|
| CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit |
|
||||||
99
.playground/pages/composant/inputPassword.vue
Normal file
99
.playground/pages/composant/inputPassword.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputPassword />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
v-model="passwordValue"
|
||||||
|
label="Mot de passe"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
label="Mot de passe"
|
||||||
|
:display-icon="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
model-value="motdepasse123"
|
||||||
|
disabled
|
||||||
|
label="Mot de passe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
model-value="lectureseule"
|
||||||
|
readonly
|
||||||
|
label="Mot de passe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
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">Erreur</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
model-value="abc"
|
||||||
|
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
|
||||||
|
model-value="motdepasse123"
|
||||||
|
label="Mot de passe"
|
||||||
|
success="Mot de passe valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
|
||||||
|
<MalioInputPassword
|
||||||
|
v-model="dynamicPassword"
|
||||||
|
label="Mot de passe"
|
||||||
|
hint="8 caractères minimum, 1 majuscule, 1 chiffre"
|
||||||
|
:error="dynamicError"
|
||||||
|
:success="dynamicSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const passwordValue = ref('')
|
||||||
|
const dynamicPassword = ref('')
|
||||||
|
|
||||||
|
const passwordRegex = /^(?=.*[A-Z])(?=.*\d).{8,}$/
|
||||||
|
const isDynamicValid = computed(() => passwordRegex.test(dynamicPassword.value))
|
||||||
|
const dynamicError = computed(() => {
|
||||||
|
if (!dynamicPassword.value) return ''
|
||||||
|
return isDynamicValid.value ? '' : 'Mot de passe invalide'
|
||||||
|
})
|
||||||
|
const dynamicSuccess = computed(() => {
|
||||||
|
if (!dynamicPassword.value) return ''
|
||||||
|
return isDynamicValid.value ? 'Mot de passe valide' : ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -14,6 +14,9 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#365] Création d'un composant number
|
* [#365] Création d'un composant number
|
||||||
* [#366] Création d'un composant select checkbox
|
* [#366] Création d'un composant select checkbox
|
||||||
* [#407] Création d'un composant time
|
* [#407] Création d'un composant time
|
||||||
|
* Création d'un composant textarea
|
||||||
|
* [#MUI-8] Création d'un composant mot de passe
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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('text-m-muted')
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('pointer-events-none')
|
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('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('top-1/2')
|
||||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2')
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2')
|
||||||
})
|
})
|
||||||
@@ -277,7 +277,7 @@ describe('MalioInputText', () => {
|
|||||||
label: 'Password',
|
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('input').classes()).toContain('!pl-11')
|
||||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
expect(wrapper.get('label').classes()).toContain('left-8')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('MalioInputAmount', () => {
|
|||||||
|
|
||||||
expect(wrapper.get('[data-test="icon"]').exists()).toBe(true)
|
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('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', () => {
|
it('generates an amount-specific id', () => {
|
||||||
@@ -156,7 +156,7 @@ describe('MalioInputAmount', () => {
|
|||||||
iconPosition: 'left',
|
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('input').classes()).toContain('!pl-11')
|
||||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
expect(wrapper.get('label').classes()).toContain('left-8')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ const focusPaddingClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const iconPositionClass = 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`
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
174
app/components/malio/InputPassword.test.ts
Normal file
174
app/components/malio/InputPassword.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
209
app/components/malio/InputPassword.vue
Normal file
209
app/components/malio/InputPassword.vue
Normal 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>
|
||||||
@@ -210,7 +210,7 @@ const focusPaddingClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const iconPositionClass = 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`
|
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
252
app/story/inputPassword.story.vue
Normal file
252
app/story/inputPassword.story.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user