From 2a66739040445846fc03674e56c6e859f18f454c Mon Sep 17 00:00:00 2001 From: tristan Date: Sun, 8 Mar 2026 19:00:24 +0000 Subject: [PATCH] Ajout de composant (#7) 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/7 Co-authored-by: tristan Co-committed-by: tristan --- .gitea/PULL_REQUEST_TEMPLATE.md | 23 + .nvmrc | 1 + .playground/pages/composant/inputAmount.vue | 60 + .playground/pages/composant/inputText.vue | 184 ++ .playground/pages/composant/inputTextArea.vue | 104 + .playground/pages/composant/radioButton.vue | 111 + .playground/pages/composant/select.vue | 149 + .playground/pages/index.vue | 127 +- CHANGELOG.md | 14 + README.md | 55 +- app/assets/css/malio.css | 19 + app/components/malio/Input.test.ts | 297 ++ app/components/malio/Input.vue | 38 - app/components/malio/InputAmount.test.ts | 163 + app/components/malio/InputAmount.vue | 255 ++ app/components/malio/InputText.vue | 235 ++ app/components/malio/InputTextArea.test.ts | 152 + app/components/malio/InputTextArea.vue | 186 ++ app/components/malio/RadioButton.test.ts | 156 + app/components/malio/RadioButton.vue | 197 ++ app/components/malio/Select.test.ts | 177 + app/components/malio/Select.vue | 333 ++ app/story/InputSelect.story.vue | 148 + app/story/RadioButton.story.vue | 187 ++ app/story/inputAmount.story.vue | 200 ++ app/story/inputText.story.vue | 200 ++ app/story/inputTextArea.story.vue | 192 ++ commit-msg | 31 + histoire.config.ts | 24 + histoire.setup.ts | 4 + makefile | 33 + nuxt.config.ts | 31 +- package-lock.json | 2850 ++++++++++++++++- package.json | 23 +- pre-commit | 28 + tailwind.config.ts | 33 + tsconfig.json | 20 +- vitest.config.ts | 10 + 38 files changed, 6879 insertions(+), 171 deletions(-) create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .nvmrc create mode 100644 .playground/pages/composant/inputAmount.vue create mode 100644 .playground/pages/composant/inputText.vue create mode 100644 .playground/pages/composant/inputTextArea.vue create mode 100644 .playground/pages/composant/radioButton.vue create mode 100644 .playground/pages/composant/select.vue create mode 100644 CHANGELOG.md create mode 100644 app/assets/css/malio.css create mode 100644 app/components/malio/Input.test.ts delete mode 100644 app/components/malio/Input.vue create mode 100644 app/components/malio/InputAmount.test.ts create mode 100644 app/components/malio/InputAmount.vue create mode 100644 app/components/malio/InputText.vue create mode 100644 app/components/malio/InputTextArea.test.ts create mode 100644 app/components/malio/InputTextArea.vue create mode 100644 app/components/malio/RadioButton.test.ts create mode 100644 app/components/malio/RadioButton.vue create mode 100644 app/components/malio/Select.test.ts create mode 100644 app/components/malio/Select.vue create mode 100644 app/story/InputSelect.story.vue create mode 100644 app/story/RadioButton.story.vue create mode 100644 app/story/inputAmount.story.vue create mode 100644 app/story/inputText.story.vue create mode 100644 app/story/inputTextArea.story.vue create mode 100644 commit-msg create mode 100644 histoire.config.ts create mode 100644 histoire.setup.ts create mode 100644 makefile create mode 100644 pre-commit create mode 100644 tailwind.config.ts create mode 100644 vitest.config.ts diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..aed2bc5 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +--- + +name: "Merge Request" +about: "Template de MR" +title: "[#NUMERO_TICKET] TITRE TICKET" +ref: "main" + +--- + +| 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é diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..32f8c50 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.13.1 diff --git a/.playground/pages/composant/inputAmount.vue b/.playground/pages/composant/inputAmount.vue new file mode 100644 index 0000000..1006ee6 --- /dev/null +++ b/.playground/pages/composant/inputAmount.vue @@ -0,0 +1,60 @@ + + + diff --git a/.playground/pages/composant/inputText.vue b/.playground/pages/composant/inputText.vue new file mode 100644 index 0000000..e329107 --- /dev/null +++ b/.playground/pages/composant/inputText.vue @@ -0,0 +1,184 @@ + + + diff --git a/.playground/pages/composant/inputTextArea.vue b/.playground/pages/composant/inputTextArea.vue new file mode 100644 index 0000000..e093158 --- /dev/null +++ b/.playground/pages/composant/inputTextArea.vue @@ -0,0 +1,104 @@ + + + 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/.playground/pages/composant/select.vue b/.playground/pages/composant/select.vue new file mode 100644 index 0000000..8b4cc0c --- /dev/null +++ b/.playground/pages/composant/select.vue @@ -0,0 +1,149 @@ + + + diff --git a/.playground/pages/index.vue b/.playground/pages/index.vue index 257a509..17e7186 100644 --- a/.playground/pages/index.vue +++ b/.playground/pages/index.vue @@ -1,11 +1,128 @@ +import { computed, ref, watch, shallowRef } from 'vue' +type LoadedModule = { + default: unknown +} + +type Item = { + name: string + label: string +} + +const componentModules = import.meta.glob('../../app/components/malio/*.vue') +const demoModules = import.meta.glob('./composant/*.vue') + +const demoByName: Record Promise> = + Object.fromEntries( + Object.entries(demoModules).map(([file, loader]) => { + const name = file.split('/').pop()?.replace('.vue', '') ?? '' + return [name.toLowerCase(), loader as () => Promise] + }), + ) + +const items = computed(() => + Object.keys(componentModules).map((file) => { + const name = file.split('/').pop()?.replace('.vue', '') ?? '' + return { + name, + label: name, + } + }), +) + +const selectedName = ref('') +const hasInitializedSelection = ref(false) + +watch( + items, + (val) => { + if (!hasInitializedSelection.value && val.length > 0) { + selectedName.value = val[0].name + hasInitializedSelection.value = true + } + }, + { immediate: true }, +) + +function selectOrToggle(name: string) { + selectedName.value = selectedName.value === name ? '' : name +} + +function clearSelection() { + selectedName.value = '' +} + +const selectedDemoComponent = shallowRef(null) + +watch(selectedName, async (name) => { + if (!name) { + selectedDemoComponent.value = null + return + } + + const loader = demoByName[name.toLowerCase()] + if (!loader) { + selectedDemoComponent.value = null + return + } + + const mod = await loader() + selectedDemoComponent.value = mod.default +}) + +const selectedDemoFileName = computed(() => { + const name = selectedName.value + if (!name) return '' + return name.charAt(0).toLowerCase() + name.slice(1) +}) + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..28cba51 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +Liste des évolutions de la librairie Malio layer UI + +## [0.0.0] +### Parameters + +### Added +* [#333] Création d'un composant text +* [#364] Création d'un composant button radio + +### Changed + +### Fixed diff --git a/README.md b/README.md index cc1d779..656bfc1 100644 --- a/README.md +++ b/README.md @@ -55,26 +55,34 @@ Prévisualiser le build : npm run preview ``` -### Livraison / publication du layer +### Livraison / publication du layer (CI) -Vérifier le contenu qui sera publié : +La publication est automatique via `.gitea/workflows/release.yml` sur push `main` / `master`. + +Le job CI : + +1. Installe les dépendances +2. Lance `npm run dev:prepare` +3. Lance `npm run lint` +4. Lance `semantic-release` (version automatique + publish sur Gitea Packages) + +Les versions sont calculées via Conventional Commits : + +- `fix: ...` -> patch (`1.0.0` -> `1.0.1`) +- `feat: ...` -> minor (`1.0.0` -> `1.1.0`) +- `feat!: ...` ou `BREAKING CHANGE:` -> major (`1.0.0` -> `2.0.0`) + +Secrets requis dans le repo Gitea : + +- `NPM_TOKEN` : token avec droits publish package +- `RELEASE_TOKEN` : token avec droits write repo (tags/releases) + +Commande locale utile avant push : ```bash npm pack --dry-run ``` -Publier sur le registry NPM configuré : - -```bash -npm publish -``` - -Publier explicitement sur un registry Gitea : - -```bash -npm publish --registry https:///api/packages//npm/ -``` - ## Tester un composant dans le playground Le playground étend déjà le layer via `.playground/nuxt.config.ts`. @@ -117,13 +125,28 @@ npm run dev ## Utiliser ce layer dans un autre projet Nuxt -Installer le package : +### 1) Configurer le `.npmrc` du projet consommateur + +Option simple : + +```ini +@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/ +``` +Puis : + +```bash +export NPM_TOKEN=TON_TOKEN_GITEA +``` + +### 2) Installer le package ```bash npm install @malio/layer-ui ``` -Étendre le layer dans `nuxt.config.ts` du projet consommateur : +### 3) Étendre le layer + +Dans `nuxt.config.ts` du projet consommateur : ```ts export default defineNuxtConfig({ diff --git a/app/assets/css/malio.css b/app/assets/css/malio.css new file mode 100644 index 0000000..0fa1352 --- /dev/null +++ b/app/assets/css/malio.css @@ -0,0 +1,19 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Couleurs en RGB “space separated” pour Tailwind */ + --m-primary: 34 39 131; /* Couleur principal*/ + --m-secondary: 48 73 152; /* Couleur secondaire */ + --m-tertiary: 243 244 248; /* Couleur tertiaire (background) */ + --m-border: 203 213 225; /* Couleur des bordures */ + --m-text: 15 23 42; /* Couleur du texte */ + --m-muted: 100 116 139; /* Couleur pour les éléments désactivés ou secondaires */ + --m-bg: 243 244 248; /* Couleur de fond générale */ + + --m-error: 155 17 30; /* rouge pour les erreurs */ + --m-success: 15 149 70; /* vert pour les succès */ + } +} diff --git a/app/components/malio/Input.test.ts b/app/components/malio/Input.test.ts new file mode 100644 index 0000000..317dcf5 --- /dev/null +++ b/app/components/malio/Input.test.ts @@ -0,0 +1,297 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import Input from './InputText.vue' + +type InputProps = { + 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 + iconName?: string + iconPosition?: 'left' | 'right' + iconSize?: string | number + iconColor?: string +} + +const InputForTest = Input as DefineComponent + +const mountInput = (props: InputProps = {}) => + mount(InputForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputText', () => { + it('renders the initial input value', () => { + const wrapper = mountInput({modelValue: 'initialValueTest'}) + + expect(wrapper.get('input').element.value).toBe('initialValueTest') + }) + + it('renders the label text', () => { + const wrapper = mountInput({label: 'labelTest'}) + + expect(wrapper.get('label').text()).toBe('labelTest') + }) + + it('applies the name attribute', () => { + const wrapper = mountInput({name: 'nameTest'}) + + expect(wrapper.get('input').attributes('name')).toBe('nameTest') + }) + + it('uses provided id on input and label', () => { + const wrapper = mountInput({id: 'custom-id', label: 'Label'}) + + expect(wrapper.get('input').attributes('id')).toBe('custom-id') + expect(wrapper.get('label').attributes('for')).toBe('custom-id') + }) + + it('keeps the default rounded class on input', () => { + const wrapper = mountInput() + + expect(wrapper.get('input').classes()).toContain('rounded-md') + }) + + it('generates an id when missing and reuses it on label', () => { + const wrapper = mountInput({label: 'Label'}) + + const inputId = wrapper.get('input').attributes('id') + + expect(inputId?.startsWith('malio-input-text-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(inputId) + }) + + it('applies the autocomplete attribute', () => { + const wrapper = mountInput({autocomplete: 'autocompleteTest'}) + + expect(wrapper.get('input').attributes('autocomplete')).toBe('autocompleteTest') + }) + + it('does not set required when false', () => { + const wrapper = mountInput({required: false}) + + expect(wrapper.get('input').attributes('required')).toBeUndefined() + }) + + it('sets required when true', () => { + const wrapper = mountInput({required: true}) + + expect(wrapper.get('input').attributes('required')).toBeDefined() + }) + + it('does not set readonly when false', () => { + const wrapper = mountInput({readonly: false}) + + expect(wrapper.get('input').attributes('readonly')).toBeUndefined() + }) + + it('sets readonly when true', () => { + const wrapper = mountInput({readonly: true}) + + expect(wrapper.get('input').attributes('readonly')).toBeDefined() + }) + + it('does not set disabled and keeps text cursor when false', () => { + const wrapper = mountInput({disabled: false}) + + expect(wrapper.get('input').attributes('disabled')).toBeUndefined() + expect(wrapper.get('input').classes()).toContain('cursor-text') + }) + + it('sets disabled styles when true', () => { + const wrapper = mountInput({disabled: true}) + + expect(wrapper.get('input').attributes('disabled')).toBeDefined() + expect(wrapper.get('input').classes()).toContain('cursor-not-allowed') + expect(wrapper.get('input').classes()).toContain('text-black/60') + }) + + it('emits update:modelValue on input change', async () => { + const wrapper = mountInput({modelValue: ''}) + + await wrapper.get('input').setValue('new value') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value']) + }) + + it('applies maxLength to input', () => { + const wrapper = mountInput({maxLength: 25}) + + expect(wrapper.get('input').attributes('maxlength')).toBe('25') + }) + + it('applies minLength to input', () => { + const wrapper = mountInput({minLength: 25}) + + expect(wrapper.get('input').attributes('minlength')).toBe('25') + }) + + it('applies labelClass on label', () => { + const wrapper = mountInput({label: 'Label', labelClass: 'text-red-500'}) + + expect(wrapper.get('label').classes()).toContain('text-red-500') + }) + + it('applies inputClass on input', () => { + const wrapper = mountInput({inputClass: 'text-sm'}) + + expect(wrapper.get('input').classes()).toContain('text-sm') + }) + + 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('input').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('p').classes()).toContain('text-m-error') + }) + + 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') + }) + + it('shows error message with label and icon', () => { + const wrapper = mountInput({ + error: 'Error message test', + label: 'Error message', + 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') + }) + + 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') + }) + + it('shows success message without label and icon', () => { + const wrapper = mountInput({success: 'Success message test'}) + + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + }) + + it('shows success message with label and without icon', () => { + const wrapper = mountInput({success: 'Success message test', label: 'Success message'}) + + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + }) + + it('shows success message with label and icon', () => { + const wrapper = mountInput({ + success: 'Success message test', + label: 'Success message', + iconName: 'mdi:key-outline', + }) + + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success') + }) + + it('shows success message with icon and without label', () => { + const wrapper = mountInput({success: 'Success message test', iconName: 'mdi:key-outline'}) + + expect(wrapper.get('p.text-m-success').text()).toBe('Success message test') + expect(wrapper.get('input').classes()).toContain('border-m-success') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success') + }) + + it('prioritizes error over success when both are provided', () => { + const wrapper = mountInput({ + error: 'Error message test', + 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-success').exists()).toBe(false) + expect(wrapper.get('input').classes()).toContain('border-m-error') + expect(wrapper.get('input').classes()).not.toContain('border-m-success') + }) + + it('shows hint message', () => { + const wrapper = mountInput({hint: 'Hint message test'}) + + expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test') + }) + + it('does not render label when label prop is missing', () => { + const wrapper = mountInput({labelClass: 'text-red-500'}) + + expect(wrapper.find('label').exists()).toBe(false) + }) + + it('renders icon with default positioning and muted color', () => { + const wrapper = mountInput({iconName: 'mdi:key-outline'}) + + 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('top-1/2') + expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2') + }) + + it('renders icon on the left when requested', () => { + const wrapper = mountInput({ + iconName: 'mdi:key-outline', + iconPosition: 'left', + label: 'Password', + }) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2') + expect(wrapper.get('input').classes()).toContain('!pl-11') + expect(wrapper.get('label').classes()).toContain('left-8') + }) + + it('passes icon size props to icon component', () => { + const wrapper = mountInput({iconName: 'mdi:key-outline', iconSize: '24'}) + + expect(wrapper.get('[data-test="icon"]').attributes('width')).toBe('24') + expect(wrapper.get('[data-test="icon"]').attributes('height')).toBe('24') + }) + + it('applies icon color class', () => { + const wrapper = mountInput({iconName: 'mdi:key-outline', iconColor: 'text-m-primary'}) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary') + }) +}) diff --git a/app/components/malio/Input.vue b/app/components/malio/Input.vue deleted file mode 100644 index 17119af..0000000 --- a/app/components/malio/Input.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/app/components/malio/InputAmount.test.ts b/app/components/malio/InputAmount.test.ts new file mode 100644 index 0000000..222ce8a --- /dev/null +++ b/app/components/malio/InputAmount.test.ts @@ -0,0 +1,163 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import InputAmount from './InputAmount.vue' + +type InputAmountProps = { + 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 + iconName?: string + iconPosition?: 'left' | 'right' + iconSize?: string | number + iconColor?: string +} + +const InputAmountForTest = InputAmount as DefineComponent + +const mountInputAmount = (props: InputAmountProps = {}) => + mount(InputAmountForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputAmount', () => { + it('renders as a text input with decimal input mode', () => { + const wrapper = mountInputAmount() + + expect(wrapper.get('input').attributes('type')).toBe('text') + expect(wrapper.get('input').attributes('inputmode')).toBe('decimal') + }) + + it('renders the default icon with muted styling', () => { + const wrapper = mountInputAmount() + + 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') + }) + + it('generates an amount-specific id', () => { + const wrapper = mountInputAmount({label: 'Montant'}) + + const inputId = wrapper.get('input').attributes('id') + + expect(inputId?.startsWith('malio-input-amount-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(inputId) + }) + + it('applies the provided input classes', () => { + const wrapper = mountInputAmount({inputClass: 'text-right'}) + + expect(wrapper.get('input').classes()).toContain('text-right') + }) + + it('links hint text through aria-describedby', () => { + const wrapper = mountInputAmount({hint: 'Saisissez un montant'}) + + const inputId = wrapper.get('input').attributes('id') + + expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`) + expect(wrapper.get('p').attributes('id')).toBe(`${inputId}-describedby`) + }) + + it('sets aria-invalid and describedby when showing an error', () => { + const wrapper = mountInputAmount({error: 'Montant invalide'}) + + const inputId = wrapper.get('input').attributes('id') + + 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') + }) + + it('keeps dots as the decimal separator on input', async () => { + const wrapper = mountInputAmount({modelValue: ''}) + + await wrapper.get('input').setValue('12.5') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5']) + expect(wrapper.get('input').element.value).toBe('12.5') + }) + + it('accepts commas but normalizes them to dots', async () => { + const wrapper = mountInputAmount({modelValue: ''}) + + await wrapper.get('input').setValue('0012,345abc') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34']) + expect(wrapper.get('input').element.value).toBe('12.34') + }) + + it('normalizes a leading decimal separator', async () => { + const wrapper = mountInputAmount({modelValue: ''}) + + await wrapper.get('input').setValue(',5') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5']) + expect(wrapper.get('input').element.value).toBe('0.5') + }) + + it('keeps the normalized decimal value on blur', async () => { + const wrapper = mountInputAmount() + const input = wrapper.get('input') + + await input.setValue('12.5') + await input.trigger('blur') + + expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']]) + expect(input.element.value).toBe('12.5') + }) + + it('keeps integer values unchanged on blur', async () => { + const wrapper = mountInputAmount() + const input = wrapper.get('input') + + await input.setValue('12') + await input.trigger('blur') + + expect(wrapper.emitted('update:modelValue')).toEqual([['12']]) + expect(input.element.value).toBe('12') + }) + + it('keeps an empty value empty on blur', async () => { + const wrapper = mountInputAmount() + const input = wrapper.get('input') + + await input.setValue('') + await input.trigger('blur') + + expect(wrapper.emitted('update:modelValue')).toEqual([['']]) + expect(input.element.value).toBe('') + }) + + it('supports icon positioning on the left', () => { + const wrapper = mountInputAmount({ + label: 'Montant', + iconPosition: 'left', + }) + + expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2') + 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/InputAmount.vue new file mode 100644 index 0000000..ed3a4d7 --- /dev/null +++ b/app/components/malio/InputAmount.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/app/components/malio/InputText.vue b/app/components/malio/InputText.vue new file mode 100644 index 0000000..d1dc77d --- /dev/null +++ b/app/components/malio/InputText.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/app/components/malio/InputTextArea.test.ts b/app/components/malio/InputTextArea.test.ts new file mode 100644 index 0000000..5398e6c --- /dev/null +++ b/app/components/malio/InputTextArea.test.ts @@ -0,0 +1,152 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import InputTextArea from './InputTextArea.vue' + +type InputTextAreaProps = { + id?: string + label?: string + name?: string + autocomplete?: string + modelValue?: string | null + size?: number | string + textInput?: string + textLabel?: string + required?: boolean + maxLength?: number + showCounter?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + rounded?: string +} + +const InputTextAreaForTest = InputTextArea as DefineComponent + +describe('MalioInputTextArea', () => { + it('renders the initial textarea value', () => { + const wrapper = mount(InputTextAreaForTest, { + props: {modelValue: 'initial textarea value'}, + }) + + expect(wrapper.get('textarea').element.value).toBe('initial textarea value') + }) + + it('renders the label text and reuses a provided id', () => { + const wrapper = mount(InputTextAreaForTest, { + props: {id: 'custom-textarea-id', label: 'Description'}, + }) + + expect(wrapper.get('textarea').attributes('id')).toBe('custom-textarea-id') + expect(wrapper.get('label').attributes('for')).toBe('custom-textarea-id') + expect(wrapper.get('label').text()).toBe('Description') + }) + + it('generates an id when missing', () => { + const wrapper = mount(InputTextAreaForTest, { + props: {label: 'Description'}, + }) + + const textareaId = wrapper.get('textarea').attributes('id') + expect(textareaId?.startsWith('malio-input-textarea-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(textareaId) + }) + + it('applies name, autocomplete and rows attributes', () => { + const wrapper = mount(InputTextAreaForTest, { + props: {name: 'bio', autocomplete: 'on', size: 4}, + }) + + expect(wrapper.get('textarea').attributes('name')).toBe('bio') + expect(wrapper.get('textarea').attributes('autocomplete')).toBe('on') + expect(wrapper.get('textarea').attributes('rows')).toBe('4') + }) + + it('sets required, readonly and disabled attributes', () => { + const wrapper = mount(InputTextAreaForTest, { + props: { + required: true, + readonly: true, + disabled: true, + }, + }) + + expect(wrapper.get('textarea').attributes('required')).toBeDefined() + expect(wrapper.get('textarea').attributes('readonly')).toBeDefined() + expect(wrapper.get('textarea').attributes('disabled')).toBeDefined() + expect(wrapper.get('textarea').classes()).toContain('cursor-not-allowed') + }) + + it('emits update:modelValue on input change', async () => { + const wrapper = mount(InputTextAreaForTest, { + props: {modelValue: ''}, + }) + + await wrapper.get('textarea').setValue('new textarea value') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new textarea value']) + }) + + it('shows the character counter when enabled', () => { + const wrapper = mount(InputTextAreaForTest, { + props: { + modelValue: 'hello', + showCounter: true, + maxLength: 20, + }, + }) + + expect(wrapper.get('span.text-xs').text()).toBe('5/20') + expect(wrapper.get('textarea').classes()).toContain('pb-6') + }) + + it('shows hint message in muted color', () => { + const wrapper = mount(InputTextAreaForTest, { + props: {hint: 'Helpful hint'}, + }) + + expect(wrapper.get('p.text-m-muted').text()).toBe('Helpful hint') + }) + + it('shows error state on textarea and label', () => { + const wrapper = mount(InputTextAreaForTest, { + props: { + label: 'Description', + error: 'Textarea error', + }, + }) + + 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').attributes('aria-invalid')).toBe('true') + }) + + it('shows success state on textarea and label', () => { + const wrapper = mount(InputTextAreaForTest, { + props: { + label: 'Description', + success: 'Textarea success', + }, + }) + + expect(wrapper.get('textarea').classes()).toContain('border-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('p.text-m-success').text()).toBe('Textarea success') + }) + + it('prioritizes error over success', () => { + const wrapper = mount(InputTextAreaForTest, { + props: { + error: 'Textarea error', + success: 'Textarea success', + }, + }) + + expect(wrapper.get('textarea').classes()).toContain('border-m-error') + expect(wrapper.find('p.text-m-success').exists()).toBe(false) + expect(wrapper.get('p.text-m-error').text()).toBe('Textarea error') + }) +}) diff --git a/app/components/malio/InputTextArea.vue b/app/components/malio/InputTextArea.vue new file mode 100644 index 0000000..c3294f5 --- /dev/null +++ b/app/components/malio/InputTextArea.vue @@ -0,0 +1,186 @@ +