From e76337502a3aa48cac88cf466b6cd6319d11aa9f Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 24 Mar 2026 10:12:28 +0000 Subject: [PATCH] =?UTF-8?q?[#MUI-10]=20Cr=C3=A9ation=20d'un=20composant=20?= =?UTF-8?q?bouton=20(#19)?= 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/19 Co-authored-by: tristan Co-committed-by: tristan --- .playground/pages/composant/button/button.vue | 112 +++++++++ CHANGELOG.md | 1 + app/components/malio/button/Button.test.ts | 218 ++++++++++++++++++ app/components/malio/button/Button.vue | 102 ++++++++ app/components/malio/checkbox/Checkbox.vue | 16 +- app/components/malio/input/InputAmount.vue | 8 +- app/components/malio/input/InputNumber.vue | 10 +- app/components/malio/input/InputPassword.vue | 8 +- app/components/malio/input/InputTextArea.vue | 6 +- app/components/malio/input/InputUpload.vue | 8 +- app/components/malio/radio/RadioButton.vue | 8 +- app/components/malio/select/Select.vue | 14 +- .../malio/select/SelectCheckbox.vue | 14 +- app/components/malio/time/Time.vue | 6 +- app/story/button/button.story.vue | 148 ++++++++++++ app/story/input/inputUpload.story.vue | 2 +- 16 files changed, 631 insertions(+), 50 deletions(-) create mode 100644 .playground/pages/composant/button/button.vue create mode 100644 app/components/malio/button/Button.test.ts create mode 100644 app/components/malio/button/Button.vue create mode 100644 app/story/button/button.story.vue 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/CHANGELOG.md b/CHANGELOG.md index 6b6191a..309707e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-11] Création d'un composant navigation par onglets * [#MUI-20] Création d'un composant sidebar * [#MUI-23] Revoir la config couleur tailwind +* [#MUI-10] Création d'un composant bouton ### Changed diff --git a/app/components/malio/button/Button.test.ts b/app/components/malio/button/Button.test.ts new file mode 100644 index 0000000..2534828 --- /dev/null +++ b/app/components/malio/button/Button.test.ts @@ -0,0 +1,218 @@ +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 Button from './Button.vue' + +type ButtonProps = { + id?: string + label?: string + disabled?: boolean + buttonClass?: string + variant?: 'primary' | 'secondary' | 'tertiary' | 'danger' + iconName?: string + iconPosition?: 'left' | 'right' + iconSize?: string | number +} + +const ButtonForTest = Button as DefineComponent + +const mountComponent = (props: ButtonProps = {}, slots?: Record) => + mount(ButtonForTest, { + props, + slots, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioButton', () => { + it('renders a button with label', () => { + const wrapper = mountComponent({ label: 'Valider' }) + + expect(wrapper.find('button').exists()).toBe(true) + expect(wrapper.text()).toContain('Valider') + }) + + it('renders slot content over label prop', () => { + const wrapper = mountComponent({ label: 'Prop' }, { default: 'Slot content' }) + + expect(wrapper.text()).toContain('Slot content') + expect(wrapper.text()).not.toContain('Prop') + }) + + it('uses provided id on button', () => { + const wrapper = mountComponent({ id: 'custom-id' }) + + 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-')).toBe(true) + }) + + it('sets type="button" on the button', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').attributes('type')).toBe('button') + }) + + 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({ disabled: true }) + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('click')).toBeUndefined() + }) + + it('sets disabled attribute when disabled', () => { + const wrapper = mountComponent({ disabled: true }) + + expect(wrapper.get('button').attributes('disabled')).toBeDefined() + }) + + // --- Variant: Primary (default) --- + + it('applies primary variant by default', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').classes()).toContain('bg-m-btn-primary') + expect(wrapper.get('button').classes()).toContain('text-white') + expect(wrapper.get('button').classes()).toContain('cursor-pointer') + }) + + it('applies primary disabled styles', () => { + const wrapper = mountComponent({ disabled: true }) + + expect(wrapper.get('button').classes()).toContain('bg-m-disabled') + expect(wrapper.get('button').classes()).toContain('text-white') + expect(wrapper.get('button').classes()).toContain('cursor-not-allowed') + }) + + // --- Variant: Secondary --- + + it('applies secondary variant', () => { + const wrapper = mountComponent({ variant: 'secondary' }) + + expect(wrapper.get('button').classes()).toContain('bg-m-btn-secondary') + expect(wrapper.get('button').classes()).toContain('text-white') + }) + + it('applies secondary disabled styles', () => { + const wrapper = mountComponent({ variant: 'secondary', disabled: true }) + + expect(wrapper.get('button').classes()).toContain('bg-m-disabled') + expect(wrapper.get('button').classes()).toContain('cursor-not-allowed') + }) + + // --- Variant: Tertiary --- + + it('applies tertiary variant with border and no background', () => { + const wrapper = mountComponent({ variant: 'tertiary' }) + + expect(wrapper.get('button').classes()).toContain('border') + expect(wrapper.get('button').classes()).toContain('border-m-btn-primary') + expect(wrapper.get('button').classes()).toContain('text-m-btn-primary') + expect(wrapper.get('button').classes()).toContain('bg-transparent') + expect(wrapper.get('button').classes()).not.toContain('text-white') + }) + + it('applies tertiary disabled styles with border', () => { + const wrapper = mountComponent({ variant: 'tertiary', disabled: true }) + + expect(wrapper.get('button').classes()).toContain('border') + expect(wrapper.get('button').classes()).toContain('border-m-disabled') + expect(wrapper.get('button').classes()).toContain('text-m-disabled') + expect(wrapper.get('button').classes()).toContain('bg-transparent') + }) + + // --- Variant: Danger --- + + it('applies danger variant', () => { + const wrapper = mountComponent({ variant: 'danger' }) + + expect(wrapper.get('button').classes()).toContain('bg-m-btn-danger') + expect(wrapper.get('button').classes()).toContain('text-white') + }) + + it('applies danger disabled styles', () => { + const wrapper = mountComponent({ variant: 'danger', disabled: true }) + + expect(wrapper.get('button').classes()).toContain('bg-m-disabled') + expect(wrapper.get('button').classes()).toContain('cursor-not-allowed') + }) + + // --- Sizing --- + + it('applies correct dimensions', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').classes()).toContain('w-[240px]') + expect(wrapper.get('button').classes()).toContain('h-[40px]') + }) + + it('applies font styles', () => { + const wrapper = mountComponent() + + expect(wrapper.get('button').classes()).toContain('text-base') + expect(wrapper.get('button').classes()).toContain('font-bold') + }) + + // --- buttonClass override --- + + it('applies buttonClass', () => { + const wrapper = mountComponent({ buttonClass: 'w-full rounded-full' }) + + expect(wrapper.get('button').classes()).toContain('w-full') + expect(wrapper.get('button').classes()).toContain('rounded-full') + }) + + // --- Icon --- + + it('renders icon on the right by default', () => { + const wrapper = mountComponent({ iconName: 'mdi:arrow-right' }) + + expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(true) + expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false) + }) + + it('renders icon on the left when specified', () => { + const wrapper = mountComponent({ iconName: 'mdi:arrow-left', iconPosition: 'left' }) + + expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(true) + expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false) + }) + + it('does not render icon when iconName is empty', () => { + const wrapper = mountComponent() + + expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false) + expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false) + }) + + it('passes icon name and size to icon component', () => { + const wrapper = mount(ButtonForTest, { + props: { iconName: 'mdi:check', iconSize: 18 }, + }) + + const iconComponent = wrapper.findComponent(IconifyIcon) + expect(iconComponent.props('icon')).toBe('mdi:check') + expect(iconComponent.props('width')).toBe(18) + expect(iconComponent.props('height')).toBe(18) + }) +}) diff --git a/app/components/malio/button/Button.vue b/app/components/malio/button/Button.vue new file mode 100644 index 0000000..c6e3205 --- /dev/null +++ b/app/components/malio/button/Button.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/components/malio/checkbox/Checkbox.vue b/app/components/malio/checkbox/Checkbox.vue index dac4e76..f4a3a43 100644 --- a/app/components/malio/checkbox/Checkbox.vue +++ b/app/components/malio/checkbox/Checkbox.vue @@ -110,7 +110,7 @@ const mergedLabelClass = computed(() => twMerge( 'cbx text-black', disabled.value ? 'cursor-not-allowed text-black/60' : '', - hasError.value ? 'text-m-error' : '', + hasError.value ? 'text-m-danger' : '', hasSuccess.value ? 'text-m-success' : '', props.labelClass, ), @@ -120,7 +120,7 @@ const mergedMessageClass = computed(() => twMerge( 'text-xs', hasError.value - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' : 'text-m-muted', @@ -200,14 +200,14 @@ const onChange = (event: Event) => { stroke-dashoffset: 0; } -.inp-cbx + .cbx.text-m-error span:first-child { - border-color: rgb(var(--m-error) / 1); +.inp-cbx + .cbx.text-m-danger span:first-child { + border-color: rgb(var(--m-danger) / 1); } -.cbx.text-m-error span:first-child svg { - stroke: rgb(var(--m-error) / 1); +.cbx.text-m-danger span:first-child svg { + stroke: rgb(var(--m-danger) / 1); } -.inp-cbx:checked + .cbx.text-m-error span:first-child { - border-color: rgb(var(--m-error) / 1); +.inp-cbx:checked + .cbx.text-m-danger span:first-child { + border-color: rgb(var(--m-danger) / 1); } .inp-cbx + .cbx.text-m-success span:first-child { diff --git a/app/components/malio/input/InputAmount.vue b/app/components/malio/input/InputAmount.vue index 2dc23a7..4012ea0 100644 --- a/app/components/malio/input/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', diff --git a/app/components/malio/input/InputNumber.vue b/app/components/malio/input/InputNumber.vue index a7cd8cb..0146d72 100644 --- a/app/components/malio/input/InputNumber.vue +++ b/app/components/malio/input/InputNumber.vue @@ -54,7 +54,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', @@ -180,7 +180,7 @@ const mergedInputClass = computed(() => ' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black', props.disabled ? 'cursor-not-allowed text-black/60' : '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' : '', @@ -191,7 +191,7 @@ const mergedInputClass = computed(() => const mergedLabelClass = computed(() => twMerge( 'cursor-pointer text-black mr-4 text-[18px]', - hasError.value ? 'text-m-error' : '', + hasError.value ? 'text-m-danger' : '', hasSuccess.value ? 'text-m-success' : '', props.disabled ? 'cursor-not-allowed text-black/60' : '', props.labelClass, @@ -203,7 +203,7 @@ const mergedButtonMinusClass = computed(() => 'h-[22px] w-[40px] border border-black rounded-s-[3px]', isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer', hasError.value - ? 'border-m-error' + ? 'border-m-danger' : hasSuccess.value ? 'border-m-success' : '', @@ -215,7 +215,7 @@ const mergedButtonPlusClass = computed(() => 'h-[22px] w-[40px] border border-black rounded-e-[3px]', isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer', hasError.value - ? 'border-m-error' + ? 'border-m-danger' : hasSuccess.value ? 'border-m-success' : '', diff --git a/app/components/malio/input/InputPassword.vue b/app/components/malio/input/InputPassword.vue index 4c12513..1f90287 100644 --- a/app/components/malio/input/InputPassword.vue +++ b/app/components/malio/input/InputPassword.vue @@ -39,7 +39,7 @@ data-test="icon" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', 'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2', @@ -53,7 +53,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', @@ -142,7 +142,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', @@ -158,7 +158,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', diff --git a/app/components/malio/input/InputTextArea.vue b/app/components/malio/input/InputTextArea.vue index c3294f5..d8900b6 100644 --- a/app/components/malio/input/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.vue b/app/components/malio/input/InputUpload.vue index e9b988d..d46b25e 100644 --- a/app/components/malio/input/InputUpload.vue +++ b/app/components/malio/input/InputUpload.vue @@ -43,7 +43,7 @@ data-test="icon" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', 'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2', @@ -56,7 +56,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', @@ -131,7 +131,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-pointer', 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', @@ -147,7 +147,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', diff --git a/app/components/malio/radio/RadioButton.vue b/app/components/malio/radio/RadioButton.vue index 06fac78..d2c457d 100644 --- a/app/components/malio/radio/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/Select.vue b/app/components/malio/select/Select.vue index 67a5215..a4244b9 100644 --- a/app/components/malio/select/Select.vue +++ b/app/components/malio/select/Select.vue @@ -12,9 +12,9 @@ hasError ? isOpen ? openDirection === 'down' - ? 'rounded-b-none !border-2 !border-m-error !border-b-0' - : 'rounded-t-none !border-2 !border-m-error !border-t-0' - : 'border-m-error' + ? 'rounded-b-none !border-2 !border-m-danger !border-b-0' + : 'rounded-t-none !border-2 !border-m-danger !border-t-0' + : 'border-m-danger' : hasSuccess ? isOpen ? openDirection === 'down' @@ -46,7 +46,7 @@ :class="[ isOpen ? 'top-2 z-30' : 'top-2', hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : isOpen @@ -75,7 +75,7 @@ class="absolute right-3 top-1/2 -translate-y-1/2" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-current' @@ -109,7 +109,7 @@ ? 'select-scrollbar-success' : 'select-scrollbar-primary', hasError - ? 'border-m-error' + ? 'border-m-danger' : hasSuccess ? 'border-m-success' : 'border-m-primary' @@ -140,7 +140,7 @@ :id="`${buttonId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', diff --git a/app/components/malio/select/SelectCheckbox.vue b/app/components/malio/select/SelectCheckbox.vue index 4364e21..2f8547d 100644 --- a/app/components/malio/select/SelectCheckbox.vue +++ b/app/components/malio/select/SelectCheckbox.vue @@ -12,9 +12,9 @@ hasError ? isOpen ? openDirection === 'down' - ? 'rounded-b-none !border-2 !border-m-error !border-b-0' - : 'rounded-t-none !border-2 !border-m-error !border-t-0' - : 'border-m-error' + ? 'rounded-b-none !border-2 !border-m-danger !border-b-0' + : 'rounded-t-none !border-2 !border-m-danger !border-t-0' + : 'border-m-danger' : hasSuccess ? isOpen ? openDirection === 'down' @@ -46,7 +46,7 @@ :class="[ shouldFloatLabel ? 'top-2 z-30' : 'top-1/2 -translate-y-1/2', hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : isOpen @@ -103,7 +103,7 @@ class="absolute right-3 top-1/2 -translate-y-1/2" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-current' @@ -137,7 +137,7 @@ ? 'select-scrollbar-success' : 'select-scrollbar-primary', hasError - ? 'border-m-error' + ? 'border-m-danger' : hasSuccess ? 'border-m-success' : 'border-m-primary' @@ -190,7 +190,7 @@ :id="`${buttonId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', diff --git a/app/components/malio/time/Time.vue b/app/components/malio/time/Time.vue index 7ef04a8..61060e9 100644 --- a/app/components/malio/time/Time.vue +++ b/app/components/malio/time/Time.vue @@ -62,7 +62,7 @@ :id="`${inputId}-describedby`" :class="[ hasError - ? 'text-m-error' + ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', @@ -185,7 +185,7 @@ const mergedGroupClass = computed(() => const mergedLabelClass = computed(() => twMerge( 'mt-px mr-4 cursor-pointer text-black text-[18px]', - hasError.value ? 'text-m-error' : '', + hasError.value ? 'text-m-danger' : '', hasSuccess.value ? 'text-m-success' : '', props.disabled ? 'cursor-not-allowed text-black/60' : '', props.labelClass @@ -197,7 +197,7 @@ const mergedInputClass = (field: 'hours' | 'minutes') => 'h-[30px] w-10 border bg-white text-center text-[18px] outline-none rounded-md placeholder:text-m-muted', props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text', hasError.value - ? 'focus:border-2 border-m-error focus:border-m-error' + ? 'focus:border-2 border-m-danger focus:border-m-danger' : hasSuccess.value ? 'focus:border-2 border-m-success focus:border-m-success' : activeField.value === field diff --git a/app/story/button/button.story.vue b/app/story/button/button.story.vue new file mode 100644 index 0000000..a1ecf04 --- /dev/null +++ b/app/story/button/button.story.vue @@ -0,0 +1,148 @@ + + + +# MalioButton + +Bouton d'action avec 4 variantes visuelles et support d'icône optionnelle. + +## Props + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto-généré | Identifiant HTML du bouton | +| `label` | `string` | `''` | Texte du bouton (peut aussi être fourni via le slot par défaut) | +| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'danger'` | `'primary'` | Variante visuelle | +| `disabled` | `boolean` | `false` | Désactive le bouton | +| `buttonClass` | `string` | `''` | Classes CSS additionnelles (fusionnées via `twMerge`) | +| `iconName` | `string` | `''` | Nom de l'icône Iconify (ex: `mdi:check`) | +| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône par rapport au texte | +| `iconSize` | `string \| number` | `20` | Taille de l'icône en pixels | + +## Variantes + +- **Primary** : Fond `m-btn-primary`, texte blanc — action principale +- **Secondary** : Fond `m-btn-secondary`, texte blanc — action secondaire +- **Tertiary** : Bordure et texte `m-btn-primary`, fond transparent — action tertiaire +- **Danger** : Fond `m-btn-danger`, texte blanc — action destructrice + +## États + +Chaque variante a 4 états visuels : Default, Hover, Active, Disabled. + +## Dimensions par défaut + +- Largeur : 240px (`w-[240px]`), personnalisable via `buttonClass` +- Hauteur : 40px (`h-[40px]`) +- Police : 16px bold, line-height 150% + +## Accessibilité + +- `type="button"` évite la soumission de formulaire involontaire +- Support `disabled` natif +- Focus visible avec `focus-visible:ring-2` + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `click` | `MouseEvent` | Émis au clic (pas émis si `disabled`) | + + + diff --git a/app/story/input/inputUpload.story.vue b/app/story/input/inputUpload.story.vue index bc674fd..87439a5 100644 --- a/app/story/input/inputUpload.story.vue +++ b/app/story/input/inputUpload.story.vue @@ -180,7 +180,7 @@ et accessibilité. ### Couleur de l'icône - `text-m-muted` par défaut. -- `text-m-error` si la prop `error` est renseignée. +- `text-m-danger` si la prop `error` est renseignée. - `text-m-success` si la prop `success` est renseignée. ------------------------------------------------------------------------