diff --git a/.playground/pages/composant/inputText.vue b/.playground/pages/composant/inputText.vue new file mode 100644 index 0000000..e1d478f --- /dev/null +++ b/.playground/pages/composant/inputText.vue @@ -0,0 +1,173 @@ + + + diff --git a/.playground/pages/index.vue b/.playground/pages/index.vue index 257a509..ccf06c3 100644 --- a/.playground/pages/index.vue +++ b/.playground/pages/index.vue @@ -1,11 +1,107 @@ +type LoadedModule = { + default: unknown +} +type Item = { + name: string + label: string + demoComponent?: unknown +} + +const componentModules = import.meta.glob('../../app/components/malio/*.vue', { eager: true }) as Record +const demoModules = import.meta.glob('./composant/*.vue', { eager: true }) as Record + +const demoByName = Object.fromEntries( + Object.entries(demoModules).map(([file, mod]) => { + const name = file.split('/').pop()?.replace('.vue', '') ?? '' + return [name.toLowerCase(), mod.default] + }), +) + +const items = computed(() => + Object.entries(componentModules).map(([file]) => { + const name = file.split('/').pop()?.replace('.vue', '') ?? '' + + return { + name, + label: name, + demoComponent: demoByName[name.toLowerCase()], + } + }) as Item[], +) + +const selectedName = ref('') +const hasInitializedSelection = ref(false) + +watchEffect(() => { + if (!hasInitializedSelection.value && items.value.length > 0) { + selectedName.value = items.value[0].name + hasInitializedSelection.value = true + } +}) + +function selectOrToggle(name: string) { + selectedName.value = selectedName.value === name ? '' : name +} + +function clearSelection() { + selectedName.value = '' +} + +const selectedDemoComponent = computed(() => + items.value.find((item) => item.name === selectedName.value)?.demoComponent, +) + +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 index f230edc..4b688f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Liste des évolutions de la librairie Malio layer UI ### Parameters ### Added +* [#333] Création d'un composant text ### Changed 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 index aefc9fb..ccb7ba1 100644 --- a/app/components/malio/Input.test.ts +++ b/app/components/malio/Input.test.ts @@ -1,23 +1,319 @@ -import { describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' -import Input from './Input.vue' +import {describe, expect, it} from 'vitest' +import {config, 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 + textSize?: string + labelClass?: string + required?: boolean + maxLength?: number | string + minLength?: number | string + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + iconName?: string + iconSize?: string | number + iconColor?: string +} + +const InputForTest = Input as DefineComponent +const iconStub = { + template: '', +} +config.global.stubs = { + ...(config.global.stubs ?? {}), + Icon: iconStub, +} describe('MalioInput', () => { - it('affiche la valeur initiale', () => { - const wrapper = mount(Input, { - props: { modelValue: 'hello' }, + // Props de base: valeur, label, name, id, autocomplete + it('renders the initial input value', () => { + const wrapper = mount(InputForTest, { + props: {modelValue: 'initialValueTest'}, }) - - expect(wrapper.get('input').element.value).toBe('hello') + expect(wrapper.get('input').element.value).toBe('initialValueTest') }) - it('emet update:modelValue au changement', async () => { - const wrapper = mount(Input, { - props: { modelValue: '' }, + it('renders the label text', () => { + const wrapper = mount(InputForTest, { + props: {label: 'labelTest'}, }) + expect(wrapper.get('label').text()).toBe('labelTest') + }) + it('applies the name attribute', () => { + const wrapper = mount(InputForTest, { + props: {name: 'nameTest'}, + }) + expect(wrapper.get('input').attributes('name')).toBe('nameTest') + }) + + it('uses provided id on input and label', () => { + const wrapper = mount(InputForTest, { + props: {id: 'custom-id', label: 'Label'}, + }) + expect(wrapper.get('input').attributes('id')).toBe('custom-id') + expect(wrapper.get('label').attributes('for')).toBe('custom-id') + }) + + it('applies a different size of rounded', () => { + const wrapper = mount(InputForTest, { + props: {rounded: 'rounded-md'}, + }) + expect(wrapper.get('input').classes()).toContain('rounded-md') + }) + + it('generates an id when missing and reuses it on label', () => { + const wrapper = mount(InputForTest, { + props: {label: 'Label'}, + }) + const inputId = wrapper.get('input').attributes('id') + expect(inputId).toBeDefined() + expect(inputId?.startsWith('malio-input-text-')).toBe(true) + expect(wrapper.get('label').attributes('for')).toBe(inputId) + }) + + it('applies the autocomplete attribute', () => { + const wrapper = mount(InputForTest, { + props: {autocomplete: 'autocompleteTest'}, + }) + expect(wrapper.get('input').attributes('autocomplete')).toBe('autocompleteTest') + }) + + // États HTML: required, readonly, disabled + it('does not set required when false', () => { + const wrapper = mount(InputForTest, { + props: {required: false}, + }) + expect(wrapper.get('input').attributes('required')).toBeUndefined() + }) + + it('sets required when true', () => { + const wrapper = mount(InputForTest, { + props: {required: true}, + }) + expect(wrapper.get('input').attributes('required')).toBeDefined() + }) + + it('does not set readonly when false', () => { + const wrapper = mount(InputForTest, { + props: {readonly: false}, + }) + expect(wrapper.get('input').attributes('readonly')).toBeUndefined() + }) + + it('sets readonly when true', () => { + const wrapper = mount(InputForTest, { + props: {readonly: true}, + }) + expect(wrapper.get('input').attributes('readonly')).toBeDefined() + }) + + it('does not set disabled and keeps text cursor when false', () => { + const wrapper = mount(InputForTest, { + props: {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 = mount(InputForTest, { + props: {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') + }) + + // Émission d'événements + it('emits update:modelValue on input change', async () => { + const wrapper = mount(InputForTest, { + props: {modelValue: ''}, + }) await wrapper.get('input').setValue('new value') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value']) }) + + // Contraintes et classes de texte + it('applies maxLength to input', () => { + const wrapper = mount(InputForTest, { + props: {maxLength: 25}, + }) + expect(wrapper.get('input').attributes('maxlength')).toBe('25') + }) + + it('applies minLength to input', () => { + const wrapper = mount(InputForTest, { + props: {minLength: 25}, + }) + expect(wrapper.get('input').attributes('minlength')).toBe('25') + }) + + it('applies textSize class on label', () => { + const wrapper = mount(InputForTest, { + props: {label: 'Label', textLabel: 'text-sm'}, + }) + expect(wrapper.get('label').classes()).toContain('text-sm') + }) + + // États visuels: erreur et succès + it('shows error message without label and icon', () => { + const wrapper = mount(InputForTest, { + props: {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('p').classes()).toContain('text-m-error') + }) + + it('shows error message with label and without icon', () => { + const wrapper = mount(InputForTest, { + props: {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('label').classes()).toContain('text-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 = mount(InputForTest, { + props: {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('label').classes()).toContain('text-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 = mount(InputForTest, { + props: {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 = mount(InputForTest, { + props: {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 = mount(InputForTest, { + props: {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') + expect(wrapper.get('label').classes()).toContain('text-m-success') + expect(wrapper.get('label').classes()).toContain('text-m-success') + }) + + it('shows success message with label and icon', () => { + const wrapper = mount(InputForTest, { + props: {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('label').classes()).toContain('text-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 = mount(InputForTest, { + props: {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 = mount(InputForTest, { + props: { + 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') + }) + + // Aide et classes de label + it('shows hint message', () => { + const wrapper = mount(InputForTest, { + props: {hint: 'Hint message test'}, + }) + expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test') + }) + + it('applies labelClass on label', () => { + const wrapper = mount(InputForTest, { + props: {label: 'Label', labelClass: 'text-red-500'}, + }) + expect(wrapper.get('label').classes()).toContain('text-red-500') + }) + + it('does not render label when label prop is missing', () => { + const wrapper = mount(InputForTest, { + props: {labelClass: 'text-red-500'}, + }) + + expect(wrapper.find('label').exists()).toBe(false) + }) + + // Icône : rendu et options + it('renders icon with default positioning and muted color', () => { + const wrapper = mount(InputForTest, { + props: {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('passes icon size prop to icon component', () => { + const wrapper = mount(InputForTest, { + props: {iconName: 'mdi:key-outline', iconSize: '24'}, + }) + expect(wrapper.get('[data-test="icon"]').attributes('height')).toBe('24') + }) + + it('applies icon color class', () => { + const wrapper = mount(InputForTest, { + props: {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/InputText.vue b/app/components/malio/InputText.vue new file mode 100644 index 0000000..38c2091 --- /dev/null +++ b/app/components/malio/InputText.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index daa8552..a9302c3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,9 +1,34 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' -const currentDir = dirname(fileURLToPath(import.meta.url)) +const dir = dirname(fileURLToPath(import.meta.url)) export default defineNuxtConfig({ - modules: ['@nuxtjs/tailwindcss'], - css: [join(currentDir, './app/assets/css/tailwind.css')], + modules: ['@nuxtjs/tailwindcss','@nuxt/icon'], + css: [join(dir, 'app/assets/css/malio.css')], + + tailwindcss: { + config: { + theme: { + extend: { + borderRadius: { + malio: 'var(--m-radius)', + }, + colors: { + m: { + primary: 'rgb(var(--m-primary) / )', + secondary: 'rgb(var(--m-secondary) / )', + tertiary: 'rgb(var(--m-tertiary) / )', + border: 'rgb(var(--m-border) / )', + text: 'rgb(var(--m-text) / )', + muted: 'rgb(var(--m-muted) / )', + bg: 'rgb(var(--m-bg) / )', + error: 'rgb(var(--m-error) / )', + success: 'rgb(var(--m-success) / )', + } + } + } + } + } + } }) diff --git a/package.json b/package.json index b41f9ad..49cf44f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,11 @@ "type": "module", "version": "0.0.1", "main": "./nuxt.config.ts", - "files": ["app/**", "nuxt.config.ts", "README.md"], + "files": [ + "app/**", + "nuxt.config.ts", + "README.md" + ], "scripts": { "dev": "nuxi dev .playground", "dev:prepare": "nuxt prepare .playground", diff --git a/tailwind.config.ts b/tailwind.config.ts index 29d8599..bc5b42e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,15 +1,15 @@ -import type { Config } from 'tailwindcss' +import type {Config} from 'tailwindcss' export default { content: [ './app/**/*.{vue,js,ts}', - './**/*.story.{vue,js,ts}', + './app/**/*.story.{vue,js,ts}', './histoire.setup.ts', './histoire.config.ts', ], safelist: [ { - pattern: /(sm:|md:|lg:|xl:|2xl:)?(text|rounded|w)-.+/, + pattern: /(sm:|md:|lg:|xl:|2xl:)?(text|rounded|w|min-w|max-w)-.+/, }, ], theme: {