diff --git a/.playground/pages/composant/inputUpload.vue b/.playground/pages/composant/inputUpload.vue new file mode 100644 index 0000000..5aa3afe --- /dev/null +++ b/.playground/pages/composant/inputUpload.vue @@ -0,0 +1,88 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9bc89..67e93d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Liste des évolutions de la librairie Malio layer UI * [#407] Création d'un composant time * Création d'un composant textarea * [#MUI-8] Création d'un composant mot de passe +* [#MUI-9] Création d'un composant upload ### Changed diff --git a/app/components/malio/InputUpload.test.ts b/app/components/malio/InputUpload.test.ts new file mode 100644 index 0000000..b253531 --- /dev/null +++ b/app/components/malio/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-error').text()).toBe('Fichier requis') + expect(wrapper.get('input[type="text"]').classes()).toContain('border-m-error') + 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-error') + }) + + 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/InputUpload.vue b/app/components/malio/InputUpload.vue new file mode 100644 index 0000000..e9b988d --- /dev/null +++ b/app/components/malio/InputUpload.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/app/story/inputUpload.story.vue b/app/story/inputUpload.story.vue new file mode 100644 index 0000000..f2190f4 --- /dev/null +++ b/app/story/inputUpload.story.vue @@ -0,0 +1,236 @@ + + + +# MalioInputUpload + +Composant input d'upload de fichier avec label flottant, icône cloud, +affichage du nom du fichier sélectionné, états visuels (erreur / succès) +et accessibilité. + +------------------------------------------------------------------------ + +## Props détaillées + +### id + +- Type: string +- Description: Identifiant HTML de l'input. +- Comportement: Si non fourni, un id unique est généré automatiquement. + +### label + +- Type: string +- Description: Texte affiché comme label flottant. +- Comportement: Si absent, aucun label n'est rendu. + +### modelValue + +- Type: string | null | undefined +- Description: Nom du fichier sélectionné (valeur contrôlée). +- Comportement: +- Si défini → composant contrôlé (v-model). +- Sinon → gestion interne de l'état. + +------------------------------------------------------------------------ + +## Apparence & Style + +### inputClass + +- Type: string +- Description: Classes CSS appliquées à l'input texte. + +### labelClass + +- Type: string +- Description: Classes CSS appliquées au label. + +### groupClass + +- Type: string +- Description: Classes CSS appliquées au conteneur. + +### displayIcon + +- Type: boolean +- Défaut: true +- Description: Affiche ou masque l'icône d'upload. + +------------------------------------------------------------------------ + +## Validation & Contraintes + +### disabled + +- Type: boolean +- Description: Désactive complètement le champ. + +### accept + +- Type: string +- Description: Types de fichiers acceptés (ex: `.pdf,.doc`). + +------------------------------------------------------------------------ + +## États & Messages + +### hint + +- Type: string +- Description: Message d'aide affiché sous le champ. + +### error + +- Type: string +- Description: Message d'erreur. +- Effet: +- Active l'état visuel erreur. +- aria-invalid=true +- Prioritaire sur success et hint. + +### success + +- Type: string +- Description: Message de succès. +- Effet: +- Actif uniquement si error est absent. + +------------------------------------------------------------------------ + +## Icône + +- `mdi:cloud-arrow-up-outline` : icône d'upload affichée à droite. + +### Couleur de l'icône + +- `text-m-muted` par défaut. +- `text-m-error` si la prop `error` est renseignée. +- `text-m-success` si la prop `success` est renseignée. + +------------------------------------------------------------------------ + +## Comportement + +- Au clic sur l'input texte, le sélecteur de fichier natif s'ouvre. +- Le nom du fichier sélectionné est affiché dans l'input. +- L'input texte est en readonly — la saisie manuelle n'est pas autorisée. + +## Priorité visuelle + +1. error +2. success +3. neutre + +------------------------------------------------------------------------ + +## Accessibilité + +- aria-invalid est activé si error existe. +- aria-describedby référence dynamiquement le message affiché. +- Fonctionne avec ou sans v-model. + +------------------------------------------------------------------------ + +## Events + +### update:modelValue + +- Émis quand un fichier est sélectionné (valeur = nom du fichier). +- Permet l'utilisation avec v-model. + +### file-selected + +- Émis quand un fichier est sélectionné (valeur = objet File). +- Permet d'accéder au fichier pour l'upload. + + + +