From 45b9d9e09080cb4891fb74368fc313d490287120 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 20 Mar 2026 11:59:24 +0100 Subject: [PATCH] feat : ajout du composant bouton icon --- .playground/pages/composant/buttonIcon.vue | 155 +++++++++++++ CHANGELOG.md | 1 + app/assets/css/malio.css | 5 + app/components/malio/ButtonIcon.test.ts | 151 +++++++++++++ app/components/malio/ButtonIcon.vue | 76 +++++++ app/story/buttonIcon.story.vue | 242 +++++++++++++++++++++ tailwind.config.ts | 4 + 7 files changed, 634 insertions(+) create mode 100644 .playground/pages/composant/buttonIcon.vue create mode 100644 app/components/malio/ButtonIcon.test.ts create mode 100644 app/components/malio/ButtonIcon.vue create mode 100644 app/story/buttonIcon.story.vue diff --git a/.playground/pages/composant/buttonIcon.vue b/.playground/pages/composant/buttonIcon.vue new file mode 100644 index 0000000..a32423a --- /dev/null +++ b/.playground/pages/composant/buttonIcon.vue @@ -0,0 +1,155 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e93d2..f89f059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Liste des évolutions de la librairie Malio layer UI * Création d'un composant textarea * [#MUI-8] Création d'un composant mot de passe * [#MUI-9] Création d'un composant upload +* [#MUI-14] Création d'un composant bouton icône ### Changed diff --git a/app/assets/css/malio.css b/app/assets/css/malio.css index 0fa1352..5843a6d 100644 --- a/app/assets/css/malio.css +++ b/app/assets/css/malio.css @@ -15,5 +15,10 @@ --m-error: 155 17 30; /* rouge pour les erreurs */ --m-success: 15 149 70; /* vert pour les succès */ + + --m-btn-default: 34 39 131; /* #222783 - bouton par défaut */ + --m-btn-hover: 18 28 219; /* #121CDB - bouton hover */ + --m-btn-active: 33 37 103; /* #212567 - bouton active */ + --m-btn-disabled: 204 204 223; /* #CCCCDF - bouton désactivé */ } } diff --git a/app/components/malio/ButtonIcon.test.ts b/app/components/malio/ButtonIcon.test.ts new file mode 100644 index 0000000..0778727 --- /dev/null +++ b/app/components/malio/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-btn-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-default') + }) + + 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-default') + expect(wrapper.get('button').classes()).not.toContain('bg-m-btn-default') + 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-btn-disabled') + expect(wrapper.get('button').classes()).toContain('cursor-not-allowed') + expect(wrapper.get('button').classes()).not.toContain('bg-m-btn-disabled') + }) +}) diff --git a/app/components/malio/ButtonIcon.vue b/app/components/malio/ButtonIcon.vue new file mode 100644 index 0000000..8b121d6 --- /dev/null +++ b/app/components/malio/ButtonIcon.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/story/buttonIcon.story.vue b/app/story/buttonIcon.story.vue new file mode 100644 index 0000000..7e948a7 --- /dev/null +++ b/app/story/buttonIcon.story.vue @@ -0,0 +1,242 @@ + + + +# MalioButtonIcon + +Bouton contenant uniquement une icône, sans texte. Utilisé pour des actions +rapides et compactes (retour, modifier, supprimer, etc.). + +------------------------------------------------------------------------ + +## Props détaillées + +### id + +- Type: string +- Description: Identifiant HTML du bouton. +- Comportement: Si non fourni, un id unique est généré automatiquement. + +### icon + +- Type: string +- **Requis** +- Description: Nom de l'icône Iconify (ex: `mdi:arrow-left`). + +### ariaLabel + +- Type: string +- **Requis** +- Description: Label d'accessibilité du bouton. Obligatoire car le bouton + n'a pas de texte visible. + +### iconSize + +- Type: string | number +- Défaut: 24 +- Description: Taille de l'icône en pixels. + +### buttonClass + +- Type: string +- Description: Classes CSS additionnelles appliquées au bouton. + +### disabled + +- Type: boolean +- Description: Désactive le bouton. + +### variant + +- Type: `'filled' | 'ghost'` +- Défaut: `filled` +- Description: Variante visuelle du bouton. +- `filled` : fond coloré, icône blanche. +- `ghost` : sans fond, icône colorée. + +------------------------------------------------------------------------ + +## Comportement visuel + +### Variante `filled` (défaut) + +- **Default** : fond `#222783`, icône blanche +- **Hover** : fond `#121CDB` +- **Active** : fond `#212567` +- **Disabled** : fond `#CCCCDF` + +### Variante `ghost` + +- **Default** : icône `#222783`, sans fond +- **Hover** : icône `#121CDB` +- **Active** : icône `#212567` +- **Disabled** : icône `#CCCCDF` + +------------------------------------------------------------------------ + +## Accessibilité + +- `aria-label` est requis pour décrire l'action du bouton. +- `type="button"` pour éviter les soumissions de formulaire accidentelles. + +------------------------------------------------------------------------ + +## Events + +### click + +- Émis au clic sur le bouton. +- Non émis si le bouton est `disabled`. +- Retourne l'événement `MouseEvent` natif. + + + + diff --git a/tailwind.config.ts b/tailwind.config.ts index b8521e4..4d289d1 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -23,6 +23,10 @@ export default { bg: 'rgb(var(--m-bg) / )', error: 'rgb(var(--m-error) / )', success: 'rgb(var(--m-success) / )', + 'btn-default': 'rgb(var(--m-btn-default) / )', + 'btn-hover': 'rgb(var(--m-btn-hover) / )', + 'btn-active': 'rgb(var(--m-btn-active) / )', + 'btn-disabled': 'rgb(var(--m-btn-disabled) / )', }, }, fontFamily: { -- 2.39.5