-
-
{{ v }}
+
+
+
+
+
+
+ Page de demo introuvable: .playground/pages/composant/{{ selectedDemoFileName }}.vue
+
+
+
Playground composants
+
+ Selectionne un composant dans la liste pour afficher sa page de demo.
+
+
+
+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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ hint || error || success }}
+
+
+
+
+
+
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: {