From 898bc0f761147e933b3908b567a2376928a0f775 Mon Sep 17 00:00:00 2001 From: tristan Date: Sun, 8 Mar 2026 19:11:07 +0000 Subject: [PATCH 1/9] feat: Ajout de composant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Co-authored-by: kevin Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/8 Co-authored-by: tristan Co-committed-by: tristan --- .playground/pages/composant/checkbox.vue | 101 ++++++++++ CHANGELOG.md | 3 + app/components/malio/Checkbox.test.ts | 142 ++++++++++++++ app/components/malio/Checkbox.vue | 227 +++++++++++++++++++++++ app/story/inputCheckbox.story.vue | 114 ++++++++++++ 5 files changed, 587 insertions(+) create mode 100644 .playground/pages/composant/checkbox.vue create mode 100644 app/components/malio/Checkbox.test.ts create mode 100644 app/components/malio/Checkbox.vue create mode 100644 app/story/inputCheckbox.story.vue diff --git a/.playground/pages/composant/checkbox.vue b/.playground/pages/composant/checkbox.vue new file mode 100644 index 0000000..211328c --- /dev/null +++ b/.playground/pages/composant/checkbox.vue @@ -0,0 +1,101 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 28cba51..546484e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Liste des évolutions de la librairie Malio layer UI ### Added * [#333] Création d'un composant text * [#364] Création d'un composant button radio +* [#337] Création d'un composant select +* [#363] Création d'un composant amount +* [#363] Création d'un composant checkbox ### Changed diff --git a/app/components/malio/Checkbox.test.ts b/app/components/malio/Checkbox.test.ts new file mode 100644 index 0000000..6fccf55 --- /dev/null +++ b/app/components/malio/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-error') + 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-error') + }) + + 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.vue b/app/components/malio/Checkbox.vue new file mode 100644 index 0000000..dac4e76 --- /dev/null +++ b/app/components/malio/Checkbox.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/app/story/inputCheckbox.story.vue b/app/story/inputCheckbox.story.vue new file mode 100644 index 0000000..10d3667 --- /dev/null +++ b/app/story/inputCheckbox.story.vue @@ -0,0 +1,114 @@ + + + +# MalioCheckbox + +Composant checkbox custom avec `v-model`, message d'aide, et états visuels +`error` / `success`. + +------------------------------------------------------------------------ + +## Props + +### id + +- Type: `string` +- Description: Identifiant HTML du checkbox. +- Comportement: si absent, un id unique est généré automatiquement. + +### label + +- Type: `string` +- Description: Texte affiche a cote de la case. + +### name + +- Type: `string` +- Description: Attribut `name` du champ. + +### modelValue + +- Type: `boolean | null | undefined` +- Description: État coche du composant. + +### inputClass + +- Type: `string` +- Description: Classes supplémentaires appliquées a l'input natif. + +### labelClass + +- Type: `string` +- Description: Classes supplémentaires appliquées au label. + +### groupClass + +- Type: `string` +- Description: Classes supplémentaires appliquées au conteneur. + +### required + +- Type: `boolean` +- Description: Ajoute l'attribut HTML `required`. + +### disabled + +- Type: `boolean` +- Description: Désactive le composant. + +### readonly + +- Type: `boolean` +- Description: Empêche la mise a jour du `v-model` tout en gardant + l'affichage courant. + +### hint + +- Type: `string` +- Description: Message d'aide affiche sous le checkbox. + +### error + +- Type: `string` +- Description: Message d'erreur. +- Effet: prioritaire sur `success`, applique `aria-invalid` et la couleur + d'erreur au texte et a la case. + +### success + +- Type: `string` +- Description: Message de succès. +- Effet: applique la couleur de succès au texte et a la case si `error` + est absent. + +------------------------------------------------------------------------ + +## Accessibilité + +- `aria-invalid` est active si `error` existe. +- `aria-describedby` pointe vers le message affiche. +- L'input natif reste present pour conserver le comportement formulaire. + +------------------------------------------------------------------------ + +## Event + +### update:modelValue + +- Émis a chaque changement de l'état coche. +- Retourne un booléen `true` ou `false`. + + + From 7ee64289a8f3f8be59a636f5f667af98577f48a0 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 25 Mar 2026 08:38:36 +0100 Subject: [PATCH 2/9] fix : drawer animation --- app/components/malio/drawer/Drawer.vue | 60 ++++++++++++++------------ 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/app/components/malio/drawer/Drawer.vue b/app/components/malio/drawer/Drawer.vue index 3ad8eb7..41eec7d 100644 --- a/app/components/malio/drawer/Drawer.vue +++ b/app/components/malio/drawer/Drawer.vue @@ -1,25 +1,25 @@ From e8ddf4e083d32bea26bf4b62f235905f948a7a2e Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 26 Mar 2026 07:33:20 +0000 Subject: [PATCH 3/9] [#MUI-24] Fix composant Select (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/22 Co-authored-by: tristan Co-committed-by: tristan --- app/components/malio/select/Select.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/malio/select/Select.vue b/app/components/malio/select/Select.vue index a4244b9..6e604d9 100644 --- a/app/components/malio/select/Select.vue +++ b/app/components/malio/select/Select.vue @@ -1,8 +1,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 @@