From 82c4cfaa90d35b4c131d5c658acda03f4924e659 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 26 Mar 2026 07:40:04 +0000 Subject: [PATCH] feat: Ajout de composant (#23) 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 - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Co-authored-by: kevin Co-authored-by: Kevin Boudet Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/23 Co-authored-by: tristan Co-committed-by: tristan --- .claude/settings.local.json | 18 + .../skills/creating-malio-component/SKILL.md | 223 ++++++++ .playground/pages/composant/button/button.vue | 112 ++++ .../pages/composant/button/buttonIcon.vue | 155 ++++++ .../pages/composant/checkbox/checkbox.vue | 101 ++++ .playground/pages/composant/drawer/drawer.vue | 49 ++ .../composant/{ => input}/inputAmount.vue | 0 .../pages/composant/input/inputNumber.vue | 80 +++ .../pages/composant/input/inputPassword.vue | 99 ++++ .../pages/composant/{ => input}/inputText.vue | 0 .../composant/{ => input}/inputTextArea.vue | 2 +- .../pages/composant/input/inputUpload.vue | 88 +++ .../composant/{ => radio}/radioButton.vue | 2 +- .../pages/composant/{ => select}/select.vue | 0 .../pages/composant/select/selectCheckbox.vue | 193 +++++++ .../pages/composant/sidebar/sidebar.vue | 131 +++++ .playground/pages/composant/tab/tabList.vue | 66 +++ .playground/pages/composant/time/time.vue | 87 +++ .playground/pages/index.vue | 105 +++- CHANGELOG.md | 17 +- CLAUDE.md | 64 +++ COMPONENTS.md | 386 +++++++++++++ app/assets/css/malio.css | 41 +- app/components/malio/button/Button.test.ts | 218 ++++++++ app/components/malio/button/Button.vue | 102 ++++ .../malio/button/ButtonIcon.test.ts | 151 ++++++ app/components/malio/button/ButtonIcon.vue | 76 +++ .../malio/checkbox/Checkbox.test.ts | 142 +++++ app/components/malio/checkbox/Checkbox.vue | 227 ++++++++ app/components/malio/drawer/Drawer.test.ts | 122 +++++ app/components/malio/drawer/Drawer.vue | 139 +++++ .../malio/{ => input}/Input.test.ts | 40 +- .../malio/{ => input}/InputAmount.test.ts | 6 +- .../malio/{ => input}/InputAmount.vue | 10 +- .../malio/input/InputNumber.test.ts | 165 ++++++ app/components/malio/input/InputNumber.vue | 303 +++++++++++ .../malio/input/InputPassword.test.ts | 174 ++++++ app/components/malio/input/InputPassword.vue | 209 +++++++ .../malio/{ => input}/InputText.vue | 10 +- .../malio/{ => input}/InputTextArea.test.ts | 10 +- .../malio/{ => input}/InputTextArea.vue | 6 +- .../malio/input/InputUpload.test.ts | 175 ++++++ app/components/malio/input/InputUpload.vue | 209 +++++++ .../malio/{ => radio}/RadioButton.test.ts | 6 +- .../malio/{ => radio}/RadioButton.vue | 8 +- .../malio/{ => select}/Select.test.ts | 10 +- app/components/malio/{ => select}/Select.vue | 24 +- .../malio/select/SelectCheckbox.test.ts | 178 ++++++ .../malio/select/SelectCheckbox.vue | 410 ++++++++++++++ app/components/malio/sidebar/Sidebar.test.ts | 205 +++++++ app/components/malio/sidebar/Sidebar.vue | 139 +++++ app/components/malio/tab/TabList.test.ts | 137 +++++ app/components/malio/tab/TabList.vue | 87 +++ app/components/malio/time/Time.test.ts | 79 +++ app/components/malio/time/Time.vue | 264 +++++++++ app/story/button/button.story.vue | 148 +++++ app/story/button/buttonIcon.story.vue | 242 +++++++++ app/story/checkbox/inputCheckbox.story.vue | 178 ++++++ app/story/drawer/drawer.story.vue | 123 +++++ app/story/{ => input}/inputAmount.story.vue | 69 ++- app/story/input/inputNumber.story.vue | 83 +++ app/story/input/inputPassword.story.vue | 252 +++++++++ app/story/{ => input}/inputText.story.vue | 89 ++- app/story/{ => input}/inputTextArea.story.vue | 80 ++- app/story/input/inputUpload.story.vue | 236 ++++++++ app/story/{ => radio}/RadioButton.story.vue | 2 +- app/story/{ => select}/InputSelect.story.vue | 82 ++- app/story/select/selectCheckbox.story.vue | 213 ++++++++ app/story/sidebar/sidebarMenu.story.vue | 227 ++++++++ app/story/tab/tabList.story.vue | 109 ++++ app/story/time/inputTime.story.vue | 89 +++ docs/superpowers/plans/2026-03-20-tab-list.md | 511 ++++++++++++++++++ memory/MEMORY.md | 3 + memory/user_profile.md | 7 + nuxt.config.ts | 31 +- public/LOGO_MALIO.png | Bin 0 -> 5824 bytes public/LOGO_MALIO_COLLAPSED.png | Bin 0 -> 2237 bytes tailwind.config.ts | 21 +- 78 files changed, 8700 insertions(+), 155 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .claude/skills/creating-malio-component/SKILL.md create mode 100644 .playground/pages/composant/button/button.vue create mode 100644 .playground/pages/composant/button/buttonIcon.vue create mode 100644 .playground/pages/composant/checkbox/checkbox.vue create mode 100644 .playground/pages/composant/drawer/drawer.vue rename .playground/pages/composant/{ => input}/inputAmount.vue (100%) create mode 100644 .playground/pages/composant/input/inputNumber.vue create mode 100644 .playground/pages/composant/input/inputPassword.vue rename .playground/pages/composant/{ => input}/inputText.vue (100%) rename .playground/pages/composant/{ => input}/inputTextArea.vue (96%) create mode 100644 .playground/pages/composant/input/inputUpload.vue rename .playground/pages/composant/{ => radio}/radioButton.vue (97%) rename .playground/pages/composant/{ => select}/select.vue (100%) create mode 100644 .playground/pages/composant/select/selectCheckbox.vue create mode 100644 .playground/pages/composant/sidebar/sidebar.vue create mode 100644 .playground/pages/composant/tab/tabList.vue create mode 100644 .playground/pages/composant/time/time.vue create mode 100644 CLAUDE.md create mode 100644 COMPONENTS.md create mode 100644 app/components/malio/button/Button.test.ts create mode 100644 app/components/malio/button/Button.vue create mode 100644 app/components/malio/button/ButtonIcon.test.ts create mode 100644 app/components/malio/button/ButtonIcon.vue create mode 100644 app/components/malio/checkbox/Checkbox.test.ts create mode 100644 app/components/malio/checkbox/Checkbox.vue create mode 100644 app/components/malio/drawer/Drawer.test.ts create mode 100644 app/components/malio/drawer/Drawer.vue rename app/components/malio/{ => input}/Input.test.ts (88%) rename app/components/malio/{ => input}/InputAmount.test.ts (97%) rename app/components/malio/{ => input}/InputAmount.vue (96%) create mode 100644 app/components/malio/input/InputNumber.test.ts create mode 100644 app/components/malio/input/InputNumber.vue create mode 100644 app/components/malio/input/InputPassword.test.ts create mode 100644 app/components/malio/input/InputPassword.vue rename app/components/malio/{ => input}/InputText.vue (96%) rename app/components/malio/{ => input}/InputTextArea.test.ts (95%) rename app/components/malio/{ => input}/InputTextArea.vue (97%) create mode 100644 app/components/malio/input/InputUpload.test.ts create mode 100644 app/components/malio/input/InputUpload.vue rename app/components/malio/{ => radio}/RadioButton.test.ts (99%) rename app/components/malio/{ => radio}/RadioButton.vue (96%) rename app/components/malio/{ => select}/Select.test.ts (95%) rename app/components/malio/{ => select}/Select.vue (94%) create mode 100644 app/components/malio/select/SelectCheckbox.test.ts create mode 100644 app/components/malio/select/SelectCheckbox.vue create mode 100644 app/components/malio/sidebar/Sidebar.test.ts create mode 100644 app/components/malio/sidebar/Sidebar.vue create mode 100644 app/components/malio/tab/TabList.test.ts create mode 100644 app/components/malio/tab/TabList.vue create mode 100644 app/components/malio/time/Time.test.ts create mode 100644 app/components/malio/time/Time.vue create mode 100644 app/story/button/button.story.vue create mode 100644 app/story/button/buttonIcon.story.vue create mode 100644 app/story/checkbox/inputCheckbox.story.vue create mode 100644 app/story/drawer/drawer.story.vue rename app/story/{ => input}/inputAmount.story.vue (70%) create mode 100644 app/story/input/inputNumber.story.vue create mode 100644 app/story/input/inputPassword.story.vue rename app/story/{ => input}/inputText.story.vue (62%) rename app/story/{ => input}/inputTextArea.story.vue (63%) create mode 100644 app/story/input/inputUpload.story.vue rename app/story/{ => radio}/RadioButton.story.vue (98%) rename app/story/{ => select}/InputSelect.story.vue (61%) create mode 100644 app/story/select/selectCheckbox.story.vue create mode 100644 app/story/sidebar/sidebarMenu.story.vue create mode 100644 app/story/tab/tabList.story.vue create mode 100644 app/story/time/inputTime.story.vue create mode 100644 docs/superpowers/plans/2026-03-20-tab-list.md create mode 100644 memory/MEMORY.md create mode 100644 memory/user_profile.md create mode 100644 public/LOGO_MALIO.png create mode 100644 public/LOGO_MALIO_COLLAPSED.png diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8aaa994 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run:*)", + "Bash(npx vitest:*)", + "Bash(sed -i \"s|from ''../../../app/components/malio/Checkbox.vue''|from ''../../../app/components/malio/checkbox/Checkbox.vue''|\" .playground/pages/composant/checkbox.vue)", + "Bash(sed -i \"s|from ''../../../app/components/malio/RadioButton.vue''|from ''../../../app/components/malio/radio/RadioButton.vue''|\" .playground/pages/composant/radioButton.vue)", + "Bash(sed -i \"s|from ''../../../app/components/malio/Time.vue''|from ''../../../app/components/malio/time/Time.vue''|\" .playground/pages/composant/time.vue)", + "Bash(sed -i \"s|from ''../../../app/components/malio/InputTextArea.vue''|from ''../../../app/components/malio/input/InputTextArea.vue''|\" .playground/pages/composant/inputTextArea.vue)", + "Bash(npx nuxi:*)", + "Bash(mkdir -p button input select checkbox radio time)", + "Bash(mv buttonIcon.story.vue button/)", + "Bash(mv inputText.story.vue inputAmount.story.vue inputNumber.story.vue inputPassword.story.vue inputTextArea.story.vue inputUpload.story.vue input/)", + "Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)", + "Bash(mv inputCheckbox.story.vue checkbox/)" + ] + } +} diff --git a/.claude/skills/creating-malio-component/SKILL.md b/.claude/skills/creating-malio-component/SKILL.md new file mode 100644 index 0000000..1e6695d --- /dev/null +++ b/.claude/skills/creating-malio-component/SKILL.md @@ -0,0 +1,223 @@ +--- +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 6 deliverables : le `.vue`, les tests, la page playground, la story Histoire, la mise à jour du CHANGELOG, et la mise à jour du `COMPONENTS.md`. + +## 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"; + "7. Mettre à jour CHANGELOG.md" -> "8. Mettre à jour COMPONENTS.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 +``` + +### 8. Mettre à jour COMPONENTS.md + +**Fichier :** `COMPONENTS.md` à la racine du projet. + +Ce fichier sert de documentation de référence pour les projets qui consomment `@malio/layer-ui`. Il est lu par Claude dans les projets consommateurs pour connaître les composants disponibles et leurs props. + +**Ajouter une section pour le nouveau composant** en suivant le format existant : + +```markdown +## MalioNomComposant + +Description courte du composant. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| ... | ... | ... | ... | + +**Events :** `update:modelValue(value: string)` + +\`\`\`vue + +\`\`\` +``` + +**Checklist :** +- Toutes les props documentées avec type, défaut et description +- Events listés +- Slots listés si applicable +- 2-5 exemples d'utilisation couvrant les cas courants (simple, avec options, disabled, erreur) +- Section placée par ordre logique (inputs ensemble, boutons ensemble, etc.) + +## 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 | +| COMPONENTS.md pas mis à jour | Ajouter la doc du composant dans `COMPONENTS.md` — c'est la référence pour les projets consommateurs | diff --git a/.playground/pages/composant/button/button.vue b/.playground/pages/composant/button/button.vue new file mode 100644 index 0000000..76c4edc --- /dev/null +++ b/.playground/pages/composant/button/button.vue @@ -0,0 +1,112 @@ + + diff --git a/.playground/pages/composant/button/buttonIcon.vue b/.playground/pages/composant/button/buttonIcon.vue new file mode 100644 index 0000000..a32423a --- /dev/null +++ b/.playground/pages/composant/button/buttonIcon.vue @@ -0,0 +1,155 @@ + + + diff --git a/.playground/pages/composant/checkbox/checkbox.vue b/.playground/pages/composant/checkbox/checkbox.vue new file mode 100644 index 0000000..1c9f39f --- /dev/null +++ b/.playground/pages/composant/checkbox/checkbox.vue @@ -0,0 +1,101 @@ + + + diff --git a/.playground/pages/composant/drawer/drawer.vue b/.playground/pages/composant/drawer/drawer.vue new file mode 100644 index 0000000..0c87902 --- /dev/null +++ b/.playground/pages/composant/drawer/drawer.vue @@ -0,0 +1,49 @@ + + + diff --git a/.playground/pages/composant/inputAmount.vue b/.playground/pages/composant/input/inputAmount.vue similarity index 100% rename from .playground/pages/composant/inputAmount.vue rename to .playground/pages/composant/input/inputAmount.vue diff --git a/.playground/pages/composant/input/inputNumber.vue b/.playground/pages/composant/input/inputNumber.vue new file mode 100644 index 0000000..7313ebf --- /dev/null +++ b/.playground/pages/composant/input/inputNumber.vue @@ -0,0 +1,80 @@ + + + diff --git a/.playground/pages/composant/input/inputPassword.vue b/.playground/pages/composant/input/inputPassword.vue new file mode 100644 index 0000000..54a8dc0 --- /dev/null +++ b/.playground/pages/composant/input/inputPassword.vue @@ -0,0 +1,99 @@ + + + diff --git a/.playground/pages/composant/inputText.vue b/.playground/pages/composant/input/inputText.vue similarity index 100% rename from .playground/pages/composant/inputText.vue rename to .playground/pages/composant/input/inputText.vue diff --git a/.playground/pages/composant/inputTextArea.vue b/.playground/pages/composant/input/inputTextArea.vue similarity index 96% rename from .playground/pages/composant/inputTextArea.vue rename to .playground/pages/composant/input/inputTextArea.vue index e093158..677937a 100644 --- a/.playground/pages/composant/inputTextArea.vue +++ b/.playground/pages/composant/input/inputTextArea.vue @@ -92,7 +92,7 @@ diff --git a/.playground/pages/composant/radioButton.vue b/.playground/pages/composant/radio/radioButton.vue similarity index 97% rename from .playground/pages/composant/radioButton.vue rename to .playground/pages/composant/radio/radioButton.vue index 7e32afe..f231e82 100644 --- a/.playground/pages/composant/radioButton.vue +++ b/.playground/pages/composant/radio/radioButton.vue @@ -93,7 +93,7 @@ diff --git a/.playground/pages/composant/sidebar/sidebar.vue b/.playground/pages/composant/sidebar/sidebar.vue new file mode 100644 index 0000000..533306e --- /dev/null +++ b/.playground/pages/composant/sidebar/sidebar.vue @@ -0,0 +1,131 @@ + + + diff --git a/.playground/pages/composant/tab/tabList.vue b/.playground/pages/composant/tab/tabList.vue new file mode 100644 index 0000000..f48d61f --- /dev/null +++ b/.playground/pages/composant/tab/tabList.vue @@ -0,0 +1,66 @@ + + + diff --git a/.playground/pages/composant/time/time.vue b/.playground/pages/composant/time/time.vue new file mode 100644 index 0000000..cbc9b0b --- /dev/null +++ b/.playground/pages/composant/time/time.vue @@ -0,0 +1,87 @@ + + + diff --git a/.playground/pages/index.vue b/.playground/pages/index.vue index 17e7186..6b07306 100644 --- a/.playground/pages/index.vue +++ b/.playground/pages/index.vue @@ -9,17 +9,41 @@ Liste des composants - @@ -48,7 +72,7 @@ diff --git a/app/components/malio/button/ButtonIcon.test.ts b/app/components/malio/button/ButtonIcon.test.ts new file mode 100644 index 0000000..ec5b85b --- /dev/null +++ b/app/components/malio/button/ButtonIcon.test.ts @@ -0,0 +1,151 @@ +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 ButtonIcon from './ButtonIcon.vue' + +type ButtonIconProps = { + id?: string + icon: string + ariaLabel: string + disabled?: boolean + buttonClass?: string + iconSize?: string | number + variant?: 'filled' | 'ghost' +} + +const ButtonIconForTest = ButtonIcon as DefineComponent + +const mountComponent = (props: ButtonIconProps = {icon: 'mdi:arrow-left', ariaLabel: 'Retour'}) => + mount(ButtonIconForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioButtonIcon', () => { + it('renders a button with the icon', () => { + const wrapper = mountComponent() + + expect(wrapper.find('button').exists()).toBe(true) + expect(wrapper.find('[data-test="icon"]').exists()).toBe(true) + }) + + it('uses provided id on button', () => { + const wrapper = mountComponent({id: 'custom-id', icon: 'mdi:arrow-left', ariaLabel: 'Retour'}) + + expect(wrapper.get('button').attributes('id')).toBe('custom-id') + }) + + it('generates an id when missing', () => { + const wrapper = mountComponent() + + const buttonId = wrapper.get('button').attributes('id') + expect(buttonId?.startsWith('malio-button-icon-')).toBe(true) + }) + + it('sets aria-label on button', () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour'}) + + expect(wrapper.get('button').attributes('aria-label')).toBe('Retour') + }) + + it('sets type="button" on the button', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').attributes('type')).toBe('button') + }) + + it('passes icon name to icon component', () => { + const wrapper = mount(ButtonIconForTest, { + props: {icon: 'mdi:pencil-outline', ariaLabel: 'Modifier'}, + }) + + const iconComponent = wrapper.findComponent(IconifyIcon) + expect(iconComponent.props('icon')).toBe('mdi:pencil-outline') + }) + + it('passes icon size to icon component', () => { + const wrapper = mount(ButtonIconForTest, { + props: {icon: 'mdi:arrow-left', ariaLabel: 'Retour', iconSize: 32}, + }) + + const iconComponent = wrapper.findComponent(IconifyIcon) + expect(iconComponent.props('width')).toBe(32) + expect(iconComponent.props('height')).toBe(32) + }) + + it('emits click event when clicked', async () => { + const wrapper = mountComponent() + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('click')).toHaveLength(1) + }) + + it('does not emit click when disabled', async () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', disabled: true}) + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('click')).toBeUndefined() + }) + + it('sets disabled attribute when disabled', () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', disabled: true}) + + expect(wrapper.get('button').attributes('disabled')).toBeDefined() + }) + + it('applies disabled styles when disabled', () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', disabled: true}) + + expect(wrapper.get('button').classes()).toContain('cursor-not-allowed') + expect(wrapper.get('button').classes()).toContain('bg-m-disabled') + }) + + it('applies cursor-pointer when not disabled', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').classes()).toContain('cursor-pointer') + }) + + it('applies white text color for icon visibility', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').classes()).toContain('text-white') + }) + + it('applies default background color', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').classes()).toContain('bg-m-btn-primary') + }) + + it('applies buttonClass', () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', buttonClass: 'rounded-full'}) + + expect(wrapper.get('button').classes()).toContain('rounded-full') + }) + + it('applies ghost variant with no background and colored icon', () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', variant: 'ghost'}) + + expect(wrapper.get('button').classes()).toContain('text-m-btn-primary') + expect(wrapper.get('button').classes()).not.toContain('bg-m-btn-primary') + expect(wrapper.get('button').classes()).not.toContain('text-white') + }) + + it('applies ghost disabled styles with no background', () => { + const wrapper = mountComponent({icon: 'mdi:arrow-left', ariaLabel: 'Retour', variant: 'ghost', disabled: true}) + + expect(wrapper.get('button').classes()).toContain('text-m-disabled') + expect(wrapper.get('button').classes()).toContain('cursor-not-allowed') + expect(wrapper.get('button').classes()).not.toContain('bg-m-disabled') + }) +}) diff --git a/app/components/malio/button/ButtonIcon.vue b/app/components/malio/button/ButtonIcon.vue new file mode 100644 index 0000000..9534324 --- /dev/null +++ b/app/components/malio/button/ButtonIcon.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/components/malio/checkbox/Checkbox.test.ts b/app/components/malio/checkbox/Checkbox.test.ts new file mode 100644 index 0000000..9915c01 --- /dev/null +++ b/app/components/malio/checkbox/Checkbox.test.ts @@ -0,0 +1,142 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import Checkbox from './Checkbox.vue' + +type CheckboxProps = { + id?: string + label?: string + name?: string + modelValue?: boolean | null + inputClass?: string + labelClass?: string + groupClass?: string + required?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string +} + +const CheckboxForTest = Checkbox as DefineComponent + +const mountCheckbox = (props: CheckboxProps = {}) => + mount(CheckboxForTest, {props}) + +describe('MalioCheckbox', () => { + it('renders a checkbox input', () => { + const wrapper = mountCheckbox() + + expect(wrapper.get('input').attributes('type')).toBe('checkbox') + }) + + it('renders the label text', () => { + const wrapper = mountCheckbox({label: 'Accept terms'}) + + expect(wrapper.get('label').text()).toContain('Accept terms') + }) + + it('uses a provided id on input and label', () => { + const wrapper = mountCheckbox({ + id: 'checkbox-id', + label: 'Accept terms', + }) + + expect(wrapper.get('input').attributes('id')).toBe('checkbox-id') + expect(wrapper.get('label').attributes('for')).toBe('checkbox-id') + }) + + it('generates an id when none is provided', () => { + const wrapper = mountCheckbox({label: 'Accept terms'}) + const inputId = wrapper.get('input').attributes('id') + + expect(inputId?.startsWith('malio-checkbox-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(inputId) + }) + + it('applies the name attribute', () => { + const wrapper = mountCheckbox({name: 'terms'}) + + expect(wrapper.get('input').attributes('name')).toBe('terms') + }) + + it('reflects the checked state from modelValue', () => { + const wrapper = mountCheckbox({modelValue: true}) + + expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true) + }) + + it('emits update:modelValue when toggled', async () => { + const wrapper = mountCheckbox({modelValue: false}) + const input = wrapper.get('input') + + await input.setValue(true) + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true]) + }) + + it('does not emit when readonly', async () => { + const wrapper = mountCheckbox({ + modelValue: true, + readonly: true, + }) + const input = wrapper.get('input') + + await input.setValue(false) + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect((input.element as HTMLInputElement).checked).toBe(true) + }) + + it('sets disabled and required attributes', () => { + const wrapper = mountCheckbox({ + disabled: true, + required: true, + }) + + expect(wrapper.get('input').attributes('disabled')).toBeDefined() + expect(wrapper.get('input').attributes('required')).toBeDefined() + }) + + it('shows a hint message and links it with aria-describedby', () => { + const wrapper = mountCheckbox({hint: 'Required field'}) + const inputId = wrapper.get('input').attributes('id') + + expect(wrapper.get('p').text()).toBe('Required field') + expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`) + }) + + it('shows an error state and message', () => { + const wrapper = mountCheckbox({ + label: 'Accept terms', + error: 'You must accept', + }) + + expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('label').classes()).toContain('text-m-danger') + expect(wrapper.get('p').text()).toBe('You must accept') + }) + + it('shows success only when there is no error', () => { + const wrapper = mountCheckbox({ + success: 'Valid', + error: 'Invalid', + }) + + expect(wrapper.get('p').text()).toBe('Invalid') + expect(wrapper.get('p').classes()).toContain('text-m-danger') + }) + + it('shows success styles and message when there is no error', () => { + const wrapper = mountCheckbox({ + label: 'Accept terms', + success: 'Valid', + modelValue: true, + }) + + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('p').text()).toBe('Valid') + expect(wrapper.get('p').classes()).toContain('text-m-success') + }) +}) diff --git a/app/components/malio/checkbox/Checkbox.vue b/app/components/malio/checkbox/Checkbox.vue new file mode 100644 index 0000000..f4a3a43 --- /dev/null +++ b/app/components/malio/checkbox/Checkbox.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/app/components/malio/drawer/Drawer.test.ts b/app/components/malio/drawer/Drawer.test.ts new file mode 100644 index 0000000..8fd6ff3 --- /dev/null +++ b/app/components/malio/drawer/Drawer.test.ts @@ -0,0 +1,122 @@ +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 Drawer from './Drawer.vue' + +type DrawerProps = { + modelValue?: boolean + title?: string + showClose?: boolean + id?: string + drawerClass?: string +} + +const DrawerForTest = Drawer as DefineComponent + +function mountComponent(props: DrawerProps = {}, slots?: Record) { + return mount(DrawerForTest, { + props, + slots, + global: { + stubs: { + Teleport: true, + }, + }, + }) +} + +describe('MalioDrawer', () => { + it('does not render when modelValue is false', () => { + const wrapper = mountComponent({ modelValue: false }) + expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) + }) + + it('renders when modelValue is true', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="panel"]').exists()).toBe(true) + }) + + it('renders the title', () => { + const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' }) + expect(wrapper.find('h2').text()).toBe('Mon tiroir') + }) + + it('renders slot content', () => { + const wrapper = mountComponent( + { modelValue: true }, + { default: '

Contenu du drawer

' }, + ) + expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer') + }) + + it('emits update:modelValue false on backdrop click', async () => { + const wrapper = mountComponent({ modelValue: true }) + await wrapper.find('[data-test="backdrop"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) + }) + + it('emits update:modelValue false on close button click', async () => { + const wrapper = mountComponent({ modelValue: true }) + await wrapper.find('[data-test="close-button"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) + }) + + it('shows close button by default', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true) + }) + + it('hides close button when showClose is false', () => { + const wrapper = mountComponent({ modelValue: true, showClose: false }) + expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false) + }) + + it('close button renders mdi:close icon', () => { + const wrapper = mountComponent({ modelValue: true }) + const icon = wrapper.findComponent(IconifyIcon) + expect(icon.props('icon')).toBe('mdi:close') + }) + + it('uses custom id when provided', () => { + const wrapper = mountComponent({ modelValue: true, id: 'my-drawer' }) + expect(wrapper.find('.fixed').attributes('id')).toBe('my-drawer') + }) + + it('generates an id when not provided', () => { + const wrapper = mountComponent({ modelValue: true }) + const id = wrapper.find('.fixed').attributes('id') + expect(id).toMatch(/^malio-drawer-/) + }) + + it('has role="dialog" and aria-modal on panel', () => { + const wrapper = mountComponent({ modelValue: true }) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.attributes('role')).toBe('dialog') + expect(panel.attributes('aria-modal')).toBe('true') + }) + + it('aria-labelledby links to title id', () => { + const wrapper = mountComponent({ modelValue: true, id: 'test-drawer' }) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.attributes('aria-labelledby')).toBe('test-drawer-title') + expect(wrapper.find('h2').attributes('id')).toBe('test-drawer-title') + }) + + it('applies drawerClass to the panel', () => { + const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' }) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.classes()).toContain('max-w-lg') + }) + + it('works in uncontrolled mode', () => { + const wrapper = mountComponent() + // Without modelValue, defaults to closed + expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) + }) + + it('close button has aria-label "Fermer"', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer') + }) +}) diff --git a/app/components/malio/drawer/Drawer.vue b/app/components/malio/drawer/Drawer.vue new file mode 100644 index 0000000..41eec7d --- /dev/null +++ b/app/components/malio/drawer/Drawer.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/app/components/malio/Input.test.ts b/app/components/malio/input/Input.test.ts similarity index 88% rename from app/components/malio/Input.test.ts rename to app/components/malio/input/Input.test.ts index 317dcf5..8accb92 100644 --- a/app/components/malio/Input.test.ts +++ b/app/components/malio/input/Input.test.ts @@ -161,19 +161,19 @@ describe('MalioInputText', () => { it('shows error message without label and icon', () => { const wrapper = mountInput({error: 'Error message test'}) - expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') - expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-danger') expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') - expect(wrapper.get('p').classes()).toContain('text-m-error') + expect(wrapper.get('p').classes()).toContain('text-m-danger') }) it('shows error message with label and without icon', () => { const wrapper = mountInput({error: 'Error message test', label: 'Error message'}) - expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') - expect(wrapper.get('input').classes()).toContain('border-m-error') - expect(wrapper.get('label').classes()).toContain('text-m-error') - expect(wrapper.get('p').classes()).toContain('text-m-error') + expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-danger') + expect(wrapper.get('label').classes()).toContain('text-m-danger') + expect(wrapper.get('p').classes()).toContain('text-m-danger') }) it('shows error message with label and icon', () => { @@ -183,19 +183,19 @@ describe('MalioInputText', () => { iconName: 'mdi:key-outline', }) - expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') - expect(wrapper.get('input').classes()).toContain('border-m-error') - expect(wrapper.get('label').classes()).toContain('text-m-error') - expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error') - expect(wrapper.get('p').classes()).toContain('text-m-error') + expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-danger') + expect(wrapper.get('label').classes()).toContain('text-m-danger') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger') + expect(wrapper.get('p').classes()).toContain('text-m-danger') }) it('shows error message with icon and without label', () => { const wrapper = mountInput({error: 'Error message test', iconName: 'mdi:key-outline'}) - expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') - expect(wrapper.get('input').classes()).toContain('border-m-error') - expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error') + expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test') + expect(wrapper.get('input').classes()).toContain('border-m-danger') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger') }) it('shows success message without label and icon', () => { @@ -240,10 +240,10 @@ describe('MalioInputText', () => { success: 'Success message test', }) - expect(wrapper.find('p.text-m-error').exists()).toBe(true) - expect(wrapper.get('p.text-m-error').text()).toBe('Error message test') + expect(wrapper.find('p.text-m-danger').exists()).toBe(true) + expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test') expect(wrapper.find('p.text-m-success').exists()).toBe(false) - expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('input').classes()).toContain('border-m-danger') expect(wrapper.get('input').classes()).not.toContain('border-m-success') }) @@ -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/input/InputAmount.test.ts similarity index 97% rename from app/components/malio/InputAmount.test.ts rename to app/components/malio/input/InputAmount.test.ts index 222ce8a..6a57f54 100644 --- a/app/components/malio/InputAmount.test.ts +++ b/app/components/malio/input/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', () => { @@ -87,7 +87,7 @@ describe('MalioInputAmount', () => { expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`) - expect(wrapper.get('p.text-m-error').text()).toBe('Montant invalide') + expect(wrapper.get('p.text-m-danger').text()).toBe('Montant invalide') }) it('keeps dots as the decimal separator on input', async () => { @@ -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/input/InputAmount.vue similarity index 96% rename from app/components/malio/InputAmount.vue rename to app/components/malio/input/InputAmount.vue index ed3a4d7..4012ea0 100644 --- a/app/components/malio/InputAmount.vue +++ b/app/components/malio/input/InputAmount.vue @@ -40,7 +40,7 @@ data-test="icon" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : iconColor, iconPositionClass, @@ -53,7 +53,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', @@ -143,7 +143,7 @@ const mergedInputClass = computed(() => 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' + ? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger' : hasSuccess.value ? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success' : 'focus:border-m-primary', @@ -159,7 +159,7 @@ const mergedLabelClass = computed(() => 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' + ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', @@ -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/input/InputNumber.test.ts b/app/components/malio/input/InputNumber.test.ts new file mode 100644 index 0000000..7b0ccac --- /dev/null +++ b/app/components/malio/input/InputNumber.test.ts @@ -0,0 +1,165 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import InputNumber from './InputNumber.vue' + +type InputNumberProps = { + modelValue?: string | null + label?: string + readonly?: boolean + min?: number | string + max?: number | string +} + +const InputNumberForTest = InputNumber as DefineComponent + +const mountInputNumber = (props: InputNumberProps = {}) => + mount(InputNumberForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputNumber', () => { + it('renders the input with a fixed 22px height', () => { + const wrapper = mountInputNumber() + const input = wrapper.get('input') + + expect(input.classes()).toContain('h-[22px]') + }) + + it('renders the increment and decrement buttons with a fixed 20px height', () => { + const wrapper = mountInputNumber() + const buttons = wrapper.findAll('button') + + expect(buttons).toHaveLength(2) + }) + + it('still emits update:modelValue on input', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('99') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['99']) + }) + + it('filters letters from the input value', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('a1b2c3') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['123']) + expect(input.element.value).toBe('123') + }) + + it('formats large numbers with spaces in the input display', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('1000000') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['1000000']) + expect(input.element.value).toBe('1 000 000') + }) + + it('accepts decimal values with commas', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('12,5') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5']) + expect(input.element.value).toBe('12.5') + }) + + it('keeps a trailing decimal separator while typing', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('12,') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.']) + expect(input.element.value).toBe('12.') + }) + + it('accepts a decimal starting with a comma', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue(',5') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5']) + expect(input.element.value).toBe('0.5') + }) + + it('increments the current value when clicking plus', async () => { + const wrapper = mountInputNumber({modelValue: '2'}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['3']) + }) + + it('increments decimal values with a step of 1', async () => { + const wrapper = mountInputNumber({modelValue: '1.5'}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2.5']) + }) + + it('decrements the current value when clicking minus', async () => { + const wrapper = mountInputNumber({modelValue: '2'}) + + await wrapper.findAll('button')[0].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['1']) + }) + + it('does not change the value from buttons when readonly', async () => { + const wrapper = mountInputNumber({modelValue: '2', readonly: true}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('disables minus and prevents decrement at min', async () => { + const wrapper = mountInputNumber({modelValue: '2', min: 2}) + const minusButton = wrapper.findAll('button')[0] + + expect(minusButton.attributes('disabled')).toBeDefined() + + await minusButton.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('disables plus and prevents increment at max', async () => { + const wrapper = mountInputNumber({modelValue: '2', max: 2}) + const plusButton = wrapper.findAll('button')[1] + + expect(plusButton.attributes('disabled')).toBeDefined() + + await plusButton.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('clamps manual input to max', async () => { + const wrapper = mountInputNumber({modelValue: '', max: 5}) + const input = wrapper.get('input') + + await input.setValue('12') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5']) + expect(input.element.value).toBe('5') + }) +}) diff --git a/app/components/malio/input/InputNumber.vue b/app/components/malio/input/InputNumber.vue new file mode 100644 index 0000000..0146d72 --- /dev/null +++ b/app/components/malio/input/InputNumber.vue @@ -0,0 +1,303 @@ + + + diff --git a/app/components/malio/input/InputPassword.test.ts b/app/components/malio/input/InputPassword.test.ts new file mode 100644 index 0000000..48eed58 --- /dev/null +++ b/app/components/malio/input/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-danger').text()).toBe('Mot de passe requis') + 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: '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/input/InputPassword.vue b/app/components/malio/input/InputPassword.vue new file mode 100644 index 0000000..1f90287 --- /dev/null +++ b/app/components/malio/input/InputPassword.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/app/components/malio/InputText.vue b/app/components/malio/input/InputText.vue similarity index 96% rename from app/components/malio/InputText.vue rename to app/components/malio/input/InputText.vue index d1dc77d..b514638 100644 --- a/app/components/malio/InputText.vue +++ b/app/components/malio/input/InputText.vue @@ -40,7 +40,7 @@ data-test="icon" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : iconColor, iconPositionClass, @@ -53,7 +53,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', @@ -148,7 +148,7 @@ const mergedInputClass = computed(() => 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' + ? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger' : hasSuccess.value ? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success' : 'focus:border-m-primary', @@ -164,7 +164,7 @@ const mergedLabelClass = computed(() => 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' + ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', @@ -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/components/malio/InputTextArea.test.ts b/app/components/malio/input/InputTextArea.test.ts similarity index 95% rename from app/components/malio/InputTextArea.test.ts rename to app/components/malio/input/InputTextArea.test.ts index 5398e6c..e5819fd 100644 --- a/app/components/malio/InputTextArea.test.ts +++ b/app/components/malio/input/InputTextArea.test.ts @@ -118,9 +118,9 @@ describe('MalioInputTextArea', () => { }, }) - expect(wrapper.get('textarea').classes()).toContain('border-m-error') - expect(wrapper.get('label').classes()).toContain('text-m-error') - expect(wrapper.get('p.text-m-error').text()).toBe('Textarea error') + expect(wrapper.get('textarea').classes()).toContain('border-m-danger') + expect(wrapper.get('label').classes()).toContain('text-m-danger') + expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error') expect(wrapper.get('textarea').attributes('aria-invalid')).toBe('true') }) @@ -145,8 +145,8 @@ describe('MalioInputTextArea', () => { }, }) - expect(wrapper.get('textarea').classes()).toContain('border-m-error') + expect(wrapper.get('textarea').classes()).toContain('border-m-danger') expect(wrapper.find('p.text-m-success').exists()).toBe(false) - expect(wrapper.get('p.text-m-error').text()).toBe('Textarea error') + expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error') }) }) diff --git a/app/components/malio/InputTextArea.vue b/app/components/malio/input/InputTextArea.vue similarity index 97% rename from app/components/malio/InputTextArea.vue rename to app/components/malio/input/InputTextArea.vue index c3294f5..d8900b6 100644 --- a/app/components/malio/InputTextArea.vue +++ b/app/components/malio/input/InputTextArea.vue @@ -12,7 +12,7 @@ isFilled ? 'border-black' : 'border-m-muted', disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text', hasError - ? 'border-m-error focus:border-m-error focus:pl-[11px]' + ? 'border-m-danger focus:border-m-danger focus:pl-[11px]' : hasSuccess ? 'border-m-success focus:border-m-success focus:pl-[11px]' : 'focus:border-m-primary focus:pl-[11px]', @@ -43,7 +43,7 @@ shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '', disabled ? 'text-black/60' : '', hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted', @@ -67,7 +67,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', diff --git a/app/components/malio/input/InputUpload.test.ts b/app/components/malio/input/InputUpload.test.ts new file mode 100644 index 0000000..fbd7c27 --- /dev/null +++ b/app/components/malio/input/InputUpload.test.ts @@ -0,0 +1,175 @@ +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 InputUpload from './InputUpload.vue' + +type InputUploadProps = { + id?: string + label?: string + modelValue?: string | null + inputClass?: string + labelClass?: string + groupClass?: string + disabled?: boolean + hint?: string + error?: string + success?: string + displayIcon?: boolean + accept?: string +} + +const InputUploadForTest = InputUpload as DefineComponent + +const mountComponent = (props: InputUploadProps = {}) => + mount(InputUploadForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputUpload', () => { + it('renders the initial display value', () => { + const wrapper = mountComponent({modelValue: 'document.pdf'}) + + expect(wrapper.get('input[type="text"]').element.value).toBe('document.pdf') + }) + + it('renders the label text', () => { + const wrapper = mountComponent({label: 'Téléverser un fichier'}) + + expect(wrapper.get('label').text()).toBe('Téléverser un fichier') + }) + + it('has a hidden file input', () => { + const wrapper = mountComponent() + + expect(wrapper.find('input[type="file"]').exists()).toBe(true) + expect(wrapper.find('input[type="file"]').classes()).toContain('hidden') + }) + + it('text input is readonly', () => { + const wrapper = mountComponent() + + expect(wrapper.get('input[type="text"]').attributes('readonly')).toBeDefined() + }) + + it('renders icon by default', () => { + const wrapper = mountComponent() + + expect(wrapper.find('[data-test="icon"]').exists()).toBe(true) + }) + + it('does not render icon when displayIcon is false', () => { + const wrapper = mountComponent({displayIcon: false}) + + expect(wrapper.find('[data-test="icon"]').exists()).toBe(false) + }) + + it('shows the correct upload icon', () => { + const wrapper = mountComponent() + + const iconComponent = wrapper.findComponent(IconifyIcon) + expect(iconComponent.props('icon')).toBe('mdi:cloud-arrow-up-outline') + }) + + it('emits update:modelValue when a file is selected', async () => { + const wrapper = mountComponent({modelValue: ''}) + const fileInput = wrapper.find('input[type="file"]') + const file = new File(['content'], 'test.pdf', {type: 'application/pdf'}) + + Object.defineProperty(fileInput.element, 'files', { + value: [file], + }) + await fileInput.trigger('change') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test.pdf']) + }) + + it('emits file-selected with the File object when a file is selected', async () => { + const wrapper = mountComponent({modelValue: ''}) + const fileInput = wrapper.find('input[type="file"]') + const file = new File(['content'], 'test.pdf', {type: 'application/pdf'}) + + Object.defineProperty(fileInput.element, 'files', { + value: [file], + }) + await fileInput.trigger('change') + + expect(wrapper.emitted('file-selected')?.[0]).toEqual([file]) + }) + + it('sets disabled on both inputs when disabled is true', () => { + const wrapper = mountComponent({disabled: true}) + + expect(wrapper.get('input[type="text"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('input[type="text"]').classes()).toContain('cursor-not-allowed') + }) + + it('shows error message and styles', () => { + const wrapper = mountComponent({error: 'Fichier requis'}) + + expect(wrapper.get('p.text-m-danger').text()).toBe('Fichier requis') + expect(wrapper.get('input[type="text"]').classes()).toContain('border-m-danger') + expect(wrapper.get('input[type="text"]').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: 'Fichier valide'}) + + expect(wrapper.get('p.text-m-success').text()).toBe('Fichier valide') + expect(wrapper.get('input[type="text"]').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 hint message', () => { + const wrapper = mountComponent({hint: 'PDF uniquement'}) + + expect(wrapper.get('p.text-m-muted').text()).toBe('PDF uniquement') + }) + + it('links label to input via for/id', () => { + const wrapper = mountComponent({id: 'upload', label: 'Fichier'}) + + expect(wrapper.get('input[type="text"]').attributes('id')).toBe('upload') + expect(wrapper.get('label').attributes('for')).toBe('upload') + }) + + it('generates an id when missing and reuses it on label', () => { + const wrapper = mountComponent({label: 'Fichier'}) + + const inputId = wrapper.get('input[type="text"]').attributes('id') + + expect(inputId?.startsWith('malio-input-upload-')).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[type="text"]').attributes('aria-invalid')).toBe('false') + }) + + it('passes accept attribute to file input', () => { + const wrapper = mountComponent({accept: '.pdf,.doc'}) + + expect(wrapper.get('input[type="file"]').attributes('accept')).toBe('.pdf,.doc') + }) +}) diff --git a/app/components/malio/input/InputUpload.vue b/app/components/malio/input/InputUpload.vue new file mode 100644 index 0000000..d46b25e --- /dev/null +++ b/app/components/malio/input/InputUpload.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/app/components/malio/RadioButton.test.ts b/app/components/malio/radio/RadioButton.test.ts similarity index 99% rename from app/components/malio/RadioButton.test.ts rename to app/components/malio/radio/RadioButton.test.ts index 5a10947..acb93d1 100644 --- a/app/components/malio/RadioButton.test.ts +++ b/app/components/malio/radio/RadioButton.test.ts @@ -112,8 +112,8 @@ describe('MalioRadioButton', () => { }) expect(wrapper.get('.radio-control').classes()).toContain('is-error') - expect(wrapper.get('.radio-text').classes()).toContain('text-m-error') - expect(wrapper.get('.radio-message').classes()).toContain('text-m-error') + expect(wrapper.get('.radio-text').classes()).toContain('text-m-danger') + expect(wrapper.get('.radio-message').classes()).toContain('text-m-danger') expect(wrapper.get('input').attributes('aria-invalid')).toBe('true') }) @@ -137,7 +137,7 @@ describe('MalioRadioButton', () => { expect(wrapper.get('.radio-control').classes()).toContain('is-error') expect(wrapper.get('.radio-control').classes()).not.toContain('is-success') expect(wrapper.get('.radio-message').text()).toBe('Selection required') - expect(wrapper.get('.radio-message').classes()).toContain('text-m-error') + expect(wrapper.get('.radio-message').classes()).toContain('text-m-danger') }) it('merges custom classes on group, input and label', () => { diff --git a/app/components/malio/RadioButton.vue b/app/components/malio/radio/RadioButton.vue similarity index 96% rename from app/components/malio/RadioButton.vue rename to app/components/malio/radio/RadioButton.vue index 06fac78..d2c457d 100644 --- a/app/components/malio/RadioButton.vue +++ b/app/components/malio/radio/RadioButton.vue @@ -125,7 +125,7 @@ const mergedInputClass = computed(() => const mergedLabelClass = computed(() => twMerge( 'radio-text mt-px cursor-pointer text-black', - hasError.value ? 'text-m-error' : '', + hasError.value ? 'text-m-danger' : '', hasSuccess.value ? 'text-m-success' : '', disabled.value ? 'cursor-not-allowed text-black/60' : '', props.labelClass, @@ -136,7 +136,7 @@ const mergedMessageClass = computed(() => twMerge( 'radio-message ml-3 -mt-1 text-xs', hasError.value - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' : 'text-m-muted', @@ -170,11 +170,11 @@ const onChange = (event: Event) => { } .radio-control.is-error input[type='radio'] { - border-color: rgb(var(--m-error) / 1); + border-color: rgb(var(--m-danger) / 1); } .radio-control.is-error .radio-dot { - color: rgb(var(--m-error) / 1); + color: rgb(var(--m-danger) / 1); } .radio-control.is-success input[type='radio'] { diff --git a/app/components/malio/Select.test.ts b/app/components/malio/select/Select.test.ts similarity index 95% rename from app/components/malio/Select.test.ts rename to app/components/malio/select/Select.test.ts index b629c15..67deae8 100644 --- a/app/components/malio/Select.test.ts +++ b/app/components/malio/select/Select.test.ts @@ -139,9 +139,9 @@ describe('MalioSelect', () => { }, }) - expect(wrapper.get('button').classes()).toContain('border-m-error') - expect(wrapper.get('label').classes()).toContain('text-m-error') - expect(wrapper.get('p.text-m-error').text()).toBe('Selection error') + expect(wrapper.get('button').classes()).toContain('border-m-danger') + expect(wrapper.get('label').classes()).toContain('text-m-danger') + expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error') expect(wrapper.get('button').attributes('aria-invalid')).toBe('true') }) @@ -170,8 +170,8 @@ describe('MalioSelect', () => { }, }) - expect(wrapper.get('button').classes()).toContain('border-m-error') + expect(wrapper.get('button').classes()).toContain('border-m-danger') expect(wrapper.find('p.text-m-success').exists()).toBe(false) - expect(wrapper.get('p.text-m-error').text()).toBe('Selection error') + expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error') }) }) diff --git a/app/components/malio/Select.vue b/app/components/malio/select/Select.vue similarity index 94% rename from app/components/malio/Select.vue rename to app/components/malio/select/Select.vue index 67a5215..6e604d9 100644 --- a/app/components/malio/Select.vue +++ b/app/components/malio/select/Select.vue @@ -1,8 +1,7 @@