From 88dd76a0e4bf3676250d3b0b7e7fbf0eee975c65 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 8 Mar 2026 18:59:50 +0000 Subject: [PATCH] =?UTF-8?q?[#364=20]=20Cr=C3=A9ation=20d'un=20composant=20?= =?UTF-8?q?de=20type=20radio=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | #364 | Création d'un composant de type radio | ## 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é Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/6 Co-authored-by: kevin Co-committed-by: kevin --- .playground/pages/composant/radioButton.vue | 111 +++++++++++ CHANGELOG.md | 1 + app/components/malio/RadioButton.test.ts | 156 ++++++++++++++++ app/components/malio/RadioButton.vue | 197 ++++++++++++++++++++ app/story/RadioButton.story.vue | 187 +++++++++++++++++++ 5 files changed, 652 insertions(+) create mode 100644 .playground/pages/composant/radioButton.vue create mode 100644 app/components/malio/RadioButton.test.ts create mode 100644 app/components/malio/RadioButton.vue create mode 100644 app/story/RadioButton.story.vue diff --git a/.playground/pages/composant/radioButton.vue b/.playground/pages/composant/radioButton.vue new file mode 100644 index 0000000..7e32afe --- /dev/null +++ b/.playground/pages/composant/radioButton.vue @@ -0,0 +1,111 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b688f4..28cba51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ 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 ### Changed diff --git a/app/components/malio/RadioButton.test.ts b/app/components/malio/RadioButton.test.ts new file mode 100644 index 0000000..5a10947 --- /dev/null +++ b/app/components/malio/RadioButton.test.ts @@ -0,0 +1,156 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import RadioButton from './RadioButton.vue' + +type RadioButtonProps = { + id?: string + label?: string + name?: string + modelValue?: string | number | boolean | null | undefined + value?: string | number | boolean | null | undefined + inputClass?: string + labelClass?: string + groupClass?: string + required?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string +} + +const RadioButtonForTest = RadioButton as DefineComponent + +const mountRadioButton = (props: RadioButtonProps = {}) => + mount(RadioButtonForTest, { + props, + }) + +describe('MalioRadioButton', () => { + it('renders the label text', () => { + const wrapper = mountRadioButton({label: 'Option 1'}) + + expect(wrapper.get('.radio-text').text()).toBe('Option 1') + }) + + it('applies provided id to input and label', () => { + const wrapper = mountRadioButton({id: 'radio-id', label: 'Option 1'}) + + expect(wrapper.get('input').attributes('id')).toBe('radio-id') + expect(wrapper.get('.radio-text').attributes('for')).toBe('radio-id') + }) + + it('generates an id when missing and reuses it on label', () => { + const wrapper = mountRadioButton({label: 'Option 1'}) + + const inputId = wrapper.get('input').attributes('id') + + expect(inputId?.startsWith('malio-radio-')).toBe(true) + expect(wrapper.get('.radio-text').attributes('for')).toBe(inputId) + }) + + it('applies the name attribute', () => { + const wrapper = mountRadioButton({name: 'choice-group'}) + + expect(wrapper.get('input').attributes('name')).toBe('choice-group') + }) + + it('sets required when true', () => { + const wrapper = mountRadioButton({required: true}) + + expect(wrapper.get('input').attributes('required')).toBeDefined() + }) + + it('sets disabled styles when true', () => { + const wrapper = mountRadioButton({disabled: true, label: 'Option 1'}) + + expect(wrapper.get('input').attributes('disabled')).toBeDefined() + expect(wrapper.get('.radio-control').classes()).toContain('is-disabled') + expect(wrapper.get('.radio-text').classes()).toContain('cursor-not-allowed') + }) + + it('checks the input when modelValue matches value', () => { + const wrapper = mountRadioButton({modelValue: 'a', value: 'a'}) + + expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true) + }) + + it('emits update:modelValue on change', async () => { + const wrapper = mountRadioButton({modelValue: 'a', value: 'b'}) + + await wrapper.get('input').trigger('change') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['b']) + }) + + it('prevents updates when readonly', async () => { + const wrapper = mountRadioButton({modelValue: 'a', value: 'b', readonly: true}) + const input = wrapper.get('input') + + ;(input.element as HTMLInputElement).checked = true + await input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect((input.element as HTMLInputElement).checked).toBe(false) + }) + + it('shows hint message and wires aria-describedby', () => { + const wrapper = mountRadioButton({hint: 'Helpful hint'}) + const input = wrapper.get('input') + const message = wrapper.get('.radio-message') + + expect(message.text()).toBe('Helpful hint') + expect(input.attributes('aria-describedby')).toBe(message.attributes('id')) + expect(input.attributes('aria-invalid')).toBe('false') + }) + + it('shows error state on control, label and helper text', () => { + const wrapper = mountRadioButton({ + label: 'Option 1', + error: 'Selection required', + }) + + 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('input').attributes('aria-invalid')).toBe('true') + }) + + it('shows success state when no error is present', () => { + const wrapper = mountRadioButton({ + label: 'Option 1', + success: 'Selection saved', + }) + + expect(wrapper.get('.radio-control').classes()).toContain('is-success') + expect(wrapper.get('.radio-text').classes()).toContain('text-m-success') + expect(wrapper.get('.radio-message').classes()).toContain('text-m-success') + }) + + it('prioritizes error over success', () => { + const wrapper = mountRadioButton({ + error: 'Selection required', + success: 'Selection saved', + }) + + 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') + }) + + it('merges custom classes on group, input and label', () => { + const wrapper = mountRadioButton({ + label: 'Option 1', + groupClass: 'mt-0 custom-group', + inputClass: 'border-red-500', + labelClass: 'font-bold', + }) + + expect(wrapper.get('.radio-item').classes()).toContain('custom-group') + expect(wrapper.get('.radio-item').classes()).toContain('mt-0') + expect(wrapper.get('input').classes()).toContain('border-red-500') + expect(wrapper.get('.radio-text').classes()).toContain('font-bold') + }) +}) diff --git a/app/components/malio/RadioButton.vue b/app/components/malio/RadioButton.vue new file mode 100644 index 0000000..06fac78 --- /dev/null +++ b/app/components/malio/RadioButton.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/app/story/RadioButton.story.vue b/app/story/RadioButton.story.vue new file mode 100644 index 0000000..a577296 --- /dev/null +++ b/app/story/RadioButton.story.vue @@ -0,0 +1,187 @@ + + + +# MalioRadioButton + +Composant radio personnalisé compatible avec `v-model`, les groupes via `name`, +et les états visuels de validation. + +------------------------------------------------------------------------ + +## Props détaillées + +### modelValue + +- Type: `string | number | boolean | null | undefined` +- Description: Valeur actuellement sélectionnée dans le groupe. +- Comportement: +- Compatible avec `v-model`. +- Le radio est coché quand `modelValue === value`. + +### value + +- Type: `string | number | boolean | null | undefined` +- Description: Valeur portée par le radio courant. + +### label + +- Type: `string` +- Description: Texte affiché à droite du radio. + +### name + +- Type: `string` +- Description: Nom HTML partagé par les radios d’un même groupe. + +------------------------------------------------------------------------ + +## États + +### error + +- Type: `string` +- Description: Message et style d’erreur. + +### success + +- Type: `string` +- Description: Message et style de succès. +- Comportement: ignoré si `error` est présent. + +### disabled + +- Type: `boolean` +- Description: Désactive le radio. + +### readonly + +- Type: `boolean` +- Description: Empêche le changement de valeur tout en gardant le rendu affiché. + +### hint + +- Type: `string` +- Description: Message d’aide sous le groupe. + +------------------------------------------------------------------------ + +## Events + +### update:modelValue + +- Émis à la sélection d’un radio. +- Retourne la `value` du radio sélectionné. + + + +