14 Commits

17 changed files with 2564 additions and 186 deletions

View File

@@ -0,0 +1,173 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputText v-model="simpleValue"/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioInputText
v-model="nameValue"
label="Nom d'utilisateur"
name="username"
autocomplete="username"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône</h2>
<MalioInputText
v-model="searchValue"
label="Recherche"
icon-name="mdi:magnify"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputText
model-value="Valeur verrouillée"
disabled
label="Champ désactivé"
/>
<MalioInputText
disabled
label="Champ désactivé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputText
model-value="Lecture seule"
readonly
label="Champ readonly"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint + icône</h2>
<MalioInputText
v-model="cityValue"
label="Ville"
icon-name="mdi:map-marker-outline"
icon-size="20"
hint="Commencez à taper le nom de la ville"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur + icône</h2>
<MalioInputText
model-value="ab"
label="Code promo"
icon-name="mdi:alert-circle-outline"
icon-size="20"
error="Le code est invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès + icône</h2>
<MalioInputText
label="Code"
success="Code valide"
icon-name="mdi:alert-circle-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly + icône</h2>
<MalioInputText
model-value="Commande #A-2048"
label="Référence"
readonly
icon-name="mdi:lock-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé + icône</h2>
<MalioInputText
model-value="Compte indisponible"
label="Compte"
disabled
icon-name="mdi:account-off-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
<MalioInputText
label="Plaque d'immatriculation"
:mask="maskOptions"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Code dynamique</h2>
<MalioInputText
v-model="dynamicCodeValue"
label="Code d'accès"
hint="Format attendu: 6 à 10 caractères alphanumériques"
:error="dynamicCodeError"
:success="dynamicCodeSuccess"
icon-name="mdi:key-outline"
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint,erreur et succès</h2>
<MalioInputText
v-model="codeValue"
label="Code"
hint="Format attendu: 6 à 10 caractères alphanumériques"
/>
<div class="mt-4">
<MalioInputText
model-value="invalide"
label="Code"
error="Le code doit contenir au moins 6 caractères"
/>
</div>
<div class="mt-4">
<MalioInputText
model-value="valide"
label="Code"
success="Le code est valide"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const simpleValue = ref('')
const nameValue = ref('')
const searchValue = ref('')
const codeValue = ref('')
const cityValue = ref('')
const dynamicCodeValue = ref('')
const codeRegex = /^[A-Z0-9]{6,10}$/
const normalizedDynamicCode = computed(() => dynamicCodeValue.value.toUpperCase())
const isDynamicCodeValid = computed(() => codeRegex.test(normalizedDynamicCode.value))
const dynamicCodeError = computed(() => {
if (!dynamicCodeValue.value) return ''
return isDynamicCodeValid.value ? '' : 'Code invalide (6 à 10 caractères alphanumériques)'
})
const dynamicCodeSuccess = computed(() => {
if (!dynamicCodeValue.value) return ''
return isDynamicCodeValid.value ? 'Code valide' : ''
})
const maskOptions = {
mask: '@@-###-@@',
tokens: {
'@': {
pattern: /[A-Za-z]/,
transform: (char: string) => char.toUpperCase()
}
}
}
</script>

View File

@@ -1,11 +1,107 @@
<template>
<div class="p-6 space-y-4">
<MalioInput v-model="v" label="Email" placeholder="you@example.com" />
<pre class="text-xs">{{ v }}</pre>
<div class="flex min-h-screen">
<aside class="w-72 bg-m-bg p-6 text-white">
<button
type="button"
class="text-xl text-black font-semibold"
@click="clearSelection"
>
Liste des composants
</button>
<nav class="mt-6 flex flex-col gap-2">
<button
v-for="item in items"
:key="item.name"
type="button"
class="rounded px-3 py-2 text-left text-black font-bold hover:bg-m-primary hover:text-white"
:class="selectedName === item.name ? 'bg-m-secondary text-white ' : ''"
@click="selectOrToggle(item.name)"
>
{{ item.label }}
</button>
</nav>
</aside>
<main class="flex-1 p-6">
<component
:is="selectedDemoComponent"
v-if="selectedDemoComponent"
/>
<p
v-else-if="selectedName"
class="text-gray-700"
>
Page de demo introuvable: <code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
</p>
<div v-else>
<h1 class="text-2xl font-semibold text-gray-900">Playground composants</h1>
<p class="mt-2 text-gray-600">
Selectionne un composant dans la liste pour afficher sa page de demo.
</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const v = ref('')
</script>
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<string, LoadedModule>
const demoModules = import.meta.glob('./composant/*.vue', { eager: true }) as Record<string, LoadedModule>
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)
})
</script>

View File

@@ -6,6 +6,7 @@ Liste des évolutions de la librairie Malio layer UI
### Parameters
### Added
* [#333] Création d'un composant text
### Changed

19
app/assets/css/malio.css Normal file
View File

@@ -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 */
}
}

View File

@@ -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<InputProps>
const iconStub = {
template: '<span data-test="icon" v-bind="$attrs" />',
}
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', textLabel: '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')
})
})

View File

@@ -1,38 +0,0 @@
<template>
<div class="space-y-1">
<label v-if="label" :for="id" class="text-sm font-medium text-gray-700">{{ label }}</label>
<input
:id="id"
:value="props.modelValue"
:type="props.type"
:placeholder="props.placeholder"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm outline-none transition focus:border-gray-500"
@input="onInput"
>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue?: string
type?: string
label?: string
placeholder?: string
id?: string
}>(), {
modelValue: '',
type: 'text',
label: '',
placeholder: '',
id: undefined,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function onInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div
class="relative mt-4 flex h-12 w-full items-center"
:class="[minWidth, maxWidth]"
>
<input
:id="inputId"
v-maska="mask"
:name="name"
:autocomplete="autocomplete"
class="floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none focus:border-2"
:class="[
disabled ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError
? 'border-m-error focus:border-m-error focus:pl-[11px] [&:not(:placeholder-shown)]:border-m-error'
: hasSuccess
? 'border-m-success focus:border-m-success focus:pl-[11px] [&:not(:placeholder-shown)]:border-m-success'
: 'border-m-muted focus:border-m-primary focus:pl-[11px]',
textInput,
iconInputPaddingClass,
rounded,
]"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="modelValue ?? ''"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder=" "
type="text"
@input="onInput"
>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 origin-left transition-transform duration-150 font-medium"
:class="[
disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
textLabel,
]"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError ? 'text-m-error' : hasSuccess ? 'text-m-success' : '',
'pointer-events-none absolute right-2 top-1/2 -translate-y-1/2',
iconColor,
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</template>
<script setup lang="ts">
import type {MaskInputOptions} from 'maska'
import {vMaska} from 'maska/vue'
import {computed, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
minWidth?: string
maxWidth?: string
textInput?: string
textLabel?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
rounded?: string
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
}>(),
{
id: '',
name: '',
autocomplete: '',
modelValue: undefined,
iconName: '',
label: '',
minWidth: 'w-96',
maxWidth: '',
textInput: 'text-lg',
required: false,
maxLength: undefined,
minLength: undefined,
readonly: false,
textLabel: 'text-sm',
disabled: false,
rounded: 'rounded-md',
hint: '',
error: '',
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
},
)
const attrs = useAttrs()
const generatedId = useId()
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const iconInputPaddingClass = computed(() => {
return props.iconName ? 'pr-10' : ''
})
</script>
<style scoped>
.floating-input:focus + label,
.floating-input:not(:placeholder-shown) + label {
transform: translateY(-1.15rem) scale(0.9);
}
.floating-label {
background: white;
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
.grow-height { transition: none; }
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<Story title="Input/Text">
<MalioInputText/>
</Story>
</template>
<docs lang="md">
# Input Text
## Liste des props
Le composant Input Text permet de saisir du texte. Il peut afficher des messages d'erreur, de succès ou d'information.
On peut lui passer plusieurs props pour personnaliser son comportement et son apparence.
- id: Identifiant HTML du champ. Si non fourni, un id est généré automatiquement.
- label: Texte du label affiché au-dessus du champ (floating label).
- name: Attribut name de linput.
- autocomplete: Attribut autocomplete de linput.
- modelValue: Valeur du champ (utilisée avec v-model) ou si valeur brut mettre des " ".
- minWidth: Classe utilitaire pour la largeur minimale du conteneur. Classe Tailwind de largeur (ex: w-64, w-full).
- maxWidth: Classe utilitaire pour la largeur maximale du conteneur. Classe Tailwind de largeur maximale.
- textInput: Classe(s) de style du texte de linput. Classe(s) Tailwind de couleur ou de typographie (ex : text-gray-700, text-sm).
- textLabel: Classe(s) de style du texte du label. Classe(s) Tailwind de couleur ou de typographie (ex : text-gray-700, text-sm).
- required: Rend le champ obligatoire (required).
- maxLength: Nombre de caractère maximal autorisé.
- minLength: Nombre de caractère minimal autorisé.
- disabled: Désactive le champ et applique le style désactivé.
- readonly: Met le champ en lecture seule.
- hint: Message informatif affiché sous le champ.
- error: Message derreur affiché sous le champ. Active le style erreur.
- success: Message de succès affiché sous le champ. Active le style succès.
- iconName: Nom de licône affichée à droite dans le champ.
- rounded: Classe utilitaire pour le rayon des coins ( rounded- ).
- iconSize: Taille de licône. (ex : 24, 26, 85 ,99, ... ).
- iconColor: Classe(s) personnalisée(s) de couleur pour licône ( text- ).
- mask: Masque de saisie pour formater la valeur :
- \# : chiffre
- A : lettre majuscule
- a : lettre minuscule
- \* : chiffre ou lettre
Événement émis :
- update:modelValue: Émis à chaque saisie pour mettre à jour v-model.
Règles daffichage des messages :
Priorité daffichage :
1) error
2) success
3) hint
</docs>
<script setup lang="ts">
import MalioInputText from '../components/malio/InputText.vue'
</script>

24
histoire.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'histoire'
import { HstVue } from '@histoire/plugin-vue'
import vue from '@vitejs/plugin-vue'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
import path from 'path'
export default defineConfig({
setupFile: './histoire.setup.ts',
vite: {
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
css: {
postcss: {
plugins: [tailwindcss(), autoprefixer()],
},
},
},
plugins: [HstVue()],
})

4
histoire.setup.ts Normal file
View File

@@ -0,0 +1,4 @@
import './app/assets/css/malio.css'
export function setupVue3() {}
export function setupVanilla() {}

View File

@@ -8,6 +8,9 @@ install:
dev:
npm run dev
dev-histoire:
npm run story:dev
dev-prepare:
npm run dev:prepare

View File

@@ -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) / <alpha-value>)',
secondary: 'rgb(var(--m-secondary) / <alpha-value>)',
tertiary: 'rgb(var(--m-tertiary) / <alpha-value>)',
border: 'rgb(var(--m-border) / <alpha-value>)',
text: 'rgb(var(--m-text) / <alpha-value>)',
muted: 'rgb(var(--m-muted) / <alpha-value>)',
bg: 'rgb(var(--m-bg) / <alpha-value>)',
error: 'rgb(var(--m-error) / <alpha-value>)',
success: 'rgb(var(--m-success) / <alpha-value>)',
}
}
}
}
}
}
})

1677
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -11,17 +15,22 @@
"generate": "nuxt generate .playground",
"preview": "nuxt preview .playground",
"lint": "eslint .",
"test": "vitest run"
"test": "vitest run",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
},
"peerDependencies": {
"nuxt": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"@histoire/plugin-vue": "^1.0.0-beta.1",
"@nuxt/eslint": "latest",
"@types/node": "^24.10.13",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^10.0.0",
"histoire": "^1.0.0-beta.1",
"jsdom": "^27.0.1",
"nuxt": "^4.3.1",
"typescript": "^5.9.3",
@@ -29,6 +38,8 @@
"vue": "latest"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"maska": "^3.2.0"
}
}

View File

@@ -3,23 +3,14 @@ set -e
echo "######### Pre-commit hook start #############"
if ! command -v npm >/dev/null 2>&1; then
if [ -f ".nvmrc" ]; then
NVM_VERSION="$(tr -d '\r\n' < .nvmrc)"
NVM_VERSION="${NVM_VERSION#v}"
NPM_BIN="$HOME/.nvm/versions/node/v$NVM_VERSION/bin"
if [ -x "$NPM_BIN/npm" ]; then
PATH="$NPM_BIN:$PATH"
export PATH
fi
fi
fi
if ! command -v npm >/dev/null 2>&1; then
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$HOME/.nvm/nvm.sh"
nvm use >/dev/null 2>&1 || true
# Prefer the exact Node version from .nvmrc for hooks (IDE + CLI consistency).
if [ -f ".nvmrc" ]; then
NVM_VERSION="$(tr -d '\r\n' < .nvmrc)"
NVM_VERSION="${NVM_VERSION#v}"
NVM_BIN="$HOME/.nvm/versions/node/v$NVM_VERSION/bin"
if [ -x "$NVM_BIN/node" ] && [ -x "$NVM_BIN/npm" ]; then
PATH="$NVM_BIN:$PATH"
export PATH
fi
fi
@@ -28,6 +19,7 @@ if ! command -v npm >/dev/null 2>&1; then
exit 1
fi
echo "Node $(node -v) / npm $(npm -v)"
echo "--- make pre-commit start ---"
make pre-commit
echo "--- make pre-commit finished ---"

38
tailwind.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import type {Config} from 'tailwindcss'
export default {
content: [
'./app/**/*.{vue,js,ts}',
'./app/**/*.story.{vue,js,ts}',
'./histoire.setup.ts',
'./histoire.config.ts',
],
safelist: [
{
pattern: /(sm:|md:|lg:|xl:|2xl:)?(text|rounded|w|min-w|max-w)-.+/,
},
],
theme: {
extend: {
borderRadius: {
malio: 'var(--m-radius)',
},
colors: {
m: {
primary: 'rgb(var(--m-primary) / <alpha-value>)',
secondary: 'rgb(var(--m-secondary) / <alpha-value>)',
tertiary: 'rgb(var(--m-tertiary) / <alpha-value>)',
border: 'rgb(var(--m-border) / <alpha-value>)',
text: 'rgb(var(--m-text) / <alpha-value>)',
muted: 'rgb(var(--m-muted) / <alpha-value>)',
bg: 'rgb(var(--m-bg) / <alpha-value>)',
error: 'rgb(var(--m-error) / <alpha-value>)',
success: 'rgb(var(--m-success) / <alpha-value>)',
},
},
fontFamily: {
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'],
},
},
},
} satisfies Partial<Config>

View File

@@ -1,3 +1,21 @@
{
"extends": "./.playground/.nuxt/tsconfig.json"
"extends": "./.playground/.nuxt/tsconfig.json",
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"lib": [
"esnext"
],
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"jsx": "preserve"
},
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
]
}