diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 47b3190..ef7d2e6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(npx vitest:*)" ] } } diff --git a/.claude/skills/creating-malio-component/SKILL.md b/.claude/skills/creating-malio-component/SKILL.md new file mode 100644 index 0000000..ea139f1 --- /dev/null +++ b/.claude/skills/creating-malio-component/SKILL.md @@ -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/.vue` +- Ses tests : `app/components/malio/.test.ts` + +### 2. Créer le composant `.vue` + +**Fichier :** `app/components/malio/.vue` + +**Checklist obligatoire :** + +| Élément | Pattern | +|---------|---------| +| `defineOptions` | `{ name: 'Malio', inheritAttrs: false }` | +| Props | `defineProps()` + `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/.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 + +const mountComponent = (props: MonComposantProps = {}) => + mount(ComposantForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) +``` + +**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/.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 +
+
+

Titre variante

+ +
+
+``` + +**Variantes typiques :** simple, avec label, désactivé, readonly, hint, erreur, succès, validation dynamique. + +### 6. Créer la story Histoire + +**Fichier :** `app/story/.story.vue` (camelCase) + +**Structure :** + +```vue + + + +# MalioNomComposant +Description courte. +## Props détaillées + +## Comportement +## Accessibilité +## Events + + + +``` + +**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 | diff --git a/.playground/pages/composant/inputPassword.vue b/.playground/pages/composant/inputPassword.vue new file mode 100644 index 0000000..54a8dc0 --- /dev/null +++ b/.playground/pages/composant/inputPassword.vue @@ -0,0 +1,99 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 142fb02..dd9bc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Liste des évolutions de la librairie Malio layer UI * [#365] Création d'un composant number * [#366] Création d'un composant select checkbox * [#407] Création d'un composant time +* Création d'un composant textarea +* [#MUI-8] Création d'un composant mot de passe + ### Changed ### Fixed diff --git a/app/components/malio/Input.test.ts b/app/components/malio/Input.test.ts index 317dcf5..aea71f9 100644 --- a/app/components/malio/Input.test.ts +++ b/app/components/malio/Input.test.ts @@ -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') }) diff --git a/app/components/malio/InputAmount.test.ts b/app/components/malio/InputAmount.test.ts index 222ce8a..afaa4ee 100644 --- a/app/components/malio/InputAmount.test.ts +++ b/app/components/malio/InputAmount.test.ts @@ -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') }) diff --git a/app/components/malio/InputAmount.vue b/app/components/malio/InputAmount.vue index ed3a4d7..2dc23a7 100644 --- a/app/components/malio/InputAmount.vue +++ b/app/components/malio/InputAmount.vue @@ -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` }) diff --git a/app/components/malio/InputPassword.test.ts b/app/components/malio/InputPassword.test.ts new file mode 100644 index 0000000..e8f98fa --- /dev/null +++ b/app/components/malio/InputPassword.test.ts @@ -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 + +const mountComponent = (props: InputPasswordProps = {}) => + mount(InputPasswordForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +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') + }) +}) diff --git a/app/components/malio/InputPassword.vue b/app/components/malio/InputPassword.vue new file mode 100644 index 0000000..4c12513 --- /dev/null +++ b/app/components/malio/InputPassword.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/app/components/malio/InputText.vue b/app/components/malio/InputText.vue index d1dc77d..132aa82 100644 --- a/app/components/malio/InputText.vue +++ b/app/components/malio/InputText.vue @@ -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` }) diff --git a/app/story/inputPassword.story.vue b/app/story/inputPassword.story.vue new file mode 100644 index 0000000..7e53a6f --- /dev/null +++ b/app/story/inputPassword.story.vue @@ -0,0 +1,252 @@ + + + +# 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. + + + +