17 Commits

Author SHA1 Message Date
0a3cf50576 Merge branch 'feat/333-creation-text-input' into feat/335-ajout-package-histoire 2026-02-25 11:34:19 +01:00
e73733de95 Merge branch 'feat/333-creation-text-input' into feat/335-ajout-package-histoire 2026-02-25 11:33:17 +01:00
1bce3304c9 Merge remote-tracking branch 'origin/feat/333-creation-text-input' into feat/333-creation-text-input 2026-02-25 10:37:52 +01:00
67436a0025 fix : correction du composant 2026-02-25 10:36:50 +01:00
a8e5f2e05a feat : ajout d'histoire et d'une story 2026-02-25 10:26:39 +01:00
a5ced1456e Actualiser app/assets/css/malio.css 2026-02-24 10:24:07 +00:00
480defcd82 feat : ajout des tests et retour sur le composant 2026-02-24 10:48:46 +01:00
33179ade9d Merge branch 'develop' into feat/333-creation-text-input 2026-02-23 11:29:42 +01:00
82ecc9cfe2 feat : ajout config vitest/make/pre-commit/commit-msg + un exemple de test vitest 2026-02-23 11:29:16 +01:00
a9627a6875 feat CHANGELOG.md 2026-02-23 11:14:24 +01:00
71797f0804 Merge branch 'develop' into feat/333-creation-text-input 2026-02-23 11:12:05 +01:00
65d9060e26 feat : ajout du template de MR + CHANGELOG.md 2026-02-23 11:11:31 +01:00
1013c83018 feat : ajout du masque et de l'etat succes sur le texte 2026-02-23 11:01:37 +01:00
9710fd63d7 fix : lint 2026-02-23 09:01:16 +01:00
75a3912727 feat : ajout du masque et de l'etat succes sur le texte 2026-02-23 08:56:27 +01:00
2340256ee1 feat : 333 creation du composant inputtext 2026-02-20 12:06:31 +01:00
ec4c157226 fix: readme.md 2026-02-19 11:18:36 +01:00
29 changed files with 260 additions and 3819 deletions

View File

@@ -1,101 +0,0 @@
<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>
<MalioCheckbox
v-model="simpleValue"
label="Accepter les conditions"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Coche par default</h2>
<MalioCheckbox
v-model="checkedValue"
label="Recevoir la newsletter"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint</h2>
<MalioCheckbox
v-model="hintValue"
label="J'accepte le traitement des donnees"
hint="Vous pouvez retirer votre consentement a tout moment."
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioCheckbox
:model-value="false"
label="Accepter les CGU"
error="Ce champ est obligatoire."
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioCheckbox
:model-value="true"
label="Adresse vérifiée"
success="Choix valide."
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Disabled et Readonly</h2>
<div class="space-y-4">
<MalioCheckbox
:model-value="true"
label="Option désactivée"
disabled
/>
<MalioCheckbox
:model-value="true"
label="Option readonly"
readonly
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Plusieurs checkbox</h2>
<div class="space-y-4">
<MalioCheckbox
label="Option 1"
/>
<MalioCheckbox
label="Option 2"
/>
<MalioCheckbox
label="Option 3"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Plusieurs checkbox avec v-for</h2>
<div class="space-y-4">
<MalioCheckbox
v-for="option in options"
:key="option"
:label="option"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioCheckbox from '../../../app/components/malio/Checkbox.vue'
const simpleValue = ref(false)
const checkedValue = ref(true)
const hintValue = ref(false)
const options = [
'Option A',
'Option B',
'Option C',
'Option D',
]
</script>

View File

@@ -1,60 +0,0 @@
<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>
<MalioInputAmount />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioInputAmount
label="Montant"
name="amount"
autocomplete="off"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAmount
model-value="125.00"
disabled
label="Montant désactivé"
/>
<MalioInputAmount
disabled
label="Montant désactivé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputAmount
model-value="42.50"
readonly
label="Montant readonly"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
<div class="mt-4">
<MalioInputAmount
model-value="12.3"
label="Montant"
error="Le montant est incorrect"
/>
</div>
<div class="mt-4">
<MalioInputAmount
model-value="89.90"
label="Montant"
success="Montant valide"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -2,8 +2,7 @@
<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
/>
<MalioInputText v-model="simpleValue"/>
</div>
<div class="rounded-lg border p-4">
@@ -17,7 +16,7 @@
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône à droite</h2>
<h2 class="mb-4 text-xl font-bold">Avec icône</h2>
<MalioInputText
v-model="searchValue"
label="Recherche"
@@ -26,16 +25,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
<MalioInputText
label="Recherche"
icon-name="mdi:magnify"
icon-size="20"
icon-position="left"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputText
@@ -153,7 +142,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const simpleValue = ref('')
const nameValue = ref('')
const searchValue = ref('')
const codeValue = ref('')

View File

@@ -1,104 +0,0 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 p-4 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputTextArea/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label + hint</h2>
<MalioInputTextArea
v-model="hintValue"
label="Description"
hint="Ajoutez un contexte clair"
:size="4"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône</h2>
<MalioInputTextArea
v-model="iconValue"
label="Commentaire"
icon-name="mdi:comment-text-outline"
:size="3"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur / Succès</h2>
<div class="space-y-4">
<MalioInputTextArea
v-model="errorValue"
label="Message"
error="Le message est trop court"
:size="3"
/>
<MalioInputTextArea
v-model="successValue"
label="Message"
success="Message valide"
:size="3"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly / Disabled</h2>
<div class="space-y-4">
<MalioInputTextArea
model-value="Contenu en lecture seule"
label="Readonly"
readonly
:size="3"
/>
<MalioInputTextArea
model-value="Champ indisponible"
label="Disabled"
disabled
:size="3"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
<MalioInputTextArea
v-model="resizeValue"
label="Notes"
resize="both"
:size="4"
:min-resize-width="300"
:max-resize-width="700"
:min-resize-height="120"
:max-resize-height="280"
hint="Resize limite en largeur et hauteur"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Compteur (interne au composant)</h2>
<MalioInputTextArea
v-model="counterValue"
label="Message"
:size="5"
:max-length="120"
:show-counter="true"
hint="Le compteur est en bas a gauche"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioInputTextArea from '../../../app/components/malio/InputTextArea.vue'
const hintValue = ref('')
const iconValue = ref('')
const errorValue = ref('abc')
const successValue = ref('Contenu ok')
const resizeValue = ref('Vous pouvez redimensionner ce champ.')
const counterValue = ref('')
</script>

View File

@@ -1,111 +0,0 @@
<template>
<div class="grid grid-cols-2 gap-6 md:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`simple-${option.value}`"
v-model="primaryChoice"
:label="option.label"
:value="option.value"
name="primary-choice"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Preselected</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`preselected-${option.value}`"
v-model="preselectedChoice"
:label="option.label"
:value="option.value"
name="preselected-choice"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Error</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`error-${option.value}`"
v-model="errorChoice"
:label="option.label"
:value="option.value"
name="error-choice"
error="Selection required"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Success</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`success-${option.value}`"
v-model="successChoice"
:label="option.label"
:value="option.value"
name="success-choice"
success="Selection saved"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Disabled</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`disabled-${option.value}`"
v-model="disabledChoice"
:label="option.label"
:value="option.value"
name="disabled-choice"
disabled
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`readonly-${option.value}`"
v-model="readonlyChoice"
:label="option.label"
:value="option.value"
name="readonly-choice"
readonly
hint="Readonly group"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioRadioButton from '../../../app/components/malio/RadioButton.vue'
const options = [
{label: 'Option 1', value: 'option1'},
{label: 'Option 2', value: 'option2'},
{label: 'Option 3', value: 'option3'},
{label: 'Option 4', value: 'option4'},
]
const primaryChoice = ref<string | null>(null)
const preselectedChoice = ref<string | null>('option2')
const errorChoice = ref<string | null>(null)
const successChoice = ref<string | null>('option3')
const disabledChoice = ref<string | null>('option2')
const readonlyChoice = ref<string | null>('option4')
</script>

View File

@@ -1,149 +0,0 @@
<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>
<MalioSelect
v-model="basicValue"
:options="options"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioSelect
v-model="labelValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur preselectionnee</h2>
<MalioSelect
v-model="selectedValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Hint</h2>
<MalioSelect
v-model="hintValue"
:options="options"
label="Pays"
hint="Choisissez votre pays"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioSelect
v-model="errorValue"
:options="options"
label="Pays"
error="Ce champ est obligatoire"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succes</h2>
<MalioSelect
v-model="successValue"
:options="options"
label="Pays"
success="Selection validee"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Desactive</h2>
<MalioSelect
v-model="disabledValue"
:options="options"
label="Pays"
disabled
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sans options</h2>
<MalioSelect
v-model="emptyValue"
label="Pays"
empty-option-label="Aucun pays disponible"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
<MalioSelect
v-model="longListValue"
:options="longOptions"
label="Pays"
hint="Permet de verifier la scrollbar"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Ouverture en bas de page</h2>
<div class="h-64" />
<MalioSelect
v-model="bottomValue"
:options="longOptions"
label="Ouverture adaptative"
hint="A ouvrir pres du bas de la page"
empty-option-label="Aucune selection"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const options = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Suisse', value: 'ch'},
{label: 'Canada', value: 'ca'},
{label: 'Allemagne', value: 'de'},
{label: 'Espagne', value: 'es'},
{label: 'Italie', value: 'it'},
{label: 'Portugal', value: 'pt'},
]
const longOptions = [
...options,
{label: 'Pays-Bas', value: 'nl'},
{label: 'Suede', value: 'se'},
{label: 'Norvege', value: 'no'},
{label: 'Danemark', value: 'dk'},
{label: 'Finlande', value: 'fi'},
{label: 'Autriche', value: 'at'},
{label: 'Irlande', value: 'ie'},
{label: 'Grece', value: 'gr'},
{label: 'Pologne', value: 'pl'},
{label: 'Hongrie', value: 'hu'},
{label: 'Republique tcheque', value: 'cz'},
]
const basicValue = ref<string | number | null>(null)
const labelValue = ref<string | number | null>(null)
const selectedValue = ref<string | number | null>('fr')
const hintValue = ref<string | number | null>(null)
const errorValue = ref<string | number | null>(null)
const successValue = ref<string | number | null>('be')
const disabledValue = ref<string | number | null>('ca')
const emptyValue = ref<string | number | null>(null)
const longListValue = ref<string | number | null>(null)
const bottomValue = ref<string | number | null>(null)
</script>

View File

@@ -15,7 +15,7 @@
: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' : ''"
:class="selectedName === item.name ? 'bg-m-secondary text-white ' : ''"
@click="selectOrToggle(item.name)"
>
{{ item.label }}
@@ -32,13 +32,10 @@
v-else-if="selectedName"
class="text-gray-700"
>
Page de demo introuvable:
<code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
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>
<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>
@@ -48,8 +45,6 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, shallowRef } from 'vue'
type LoadedModule = {
default: unknown
}
@@ -57,42 +52,40 @@ type LoadedModule = {
type Item = {
name: string
label: string
demoComponent?: unknown
}
const componentModules = import.meta.glob('../../app/components/malio/*.vue')
const demoModules = import.meta.glob('./composant/*.vue')
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: Record<string, () => Promise<LoadedModule>> =
Object.fromEntries(
Object.entries(demoModules).map(([file, loader]) => {
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
return [name.toLowerCase(), loader as () => Promise<LoadedModule>]
}),
)
const items = computed<Item[]>(() =>
Object.keys(componentModules).map((file) => {
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)
watch(
items,
(val) => {
if (!hasInitializedSelection.value && val.length > 0) {
selectedName.value = val[0].name
hasInitializedSelection.value = true
}
},
{ immediate: true },
)
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
@@ -102,23 +95,9 @@ function clearSelection() {
selectedName.value = ''
}
const selectedDemoComponent = shallowRef<unknown>(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 selectedDemoComponent = computed(() =>
items.value.find((item) => item.name === selectedName.value)?.demoComponent,
)
const selectedDemoFileName = computed(() => {
const name = selectedName.value

View File

@@ -7,10 +7,6 @@ Liste des évolutions de la librairie Malio layer UI
### Added
* [#333] Création d'un composant text
* [#364] Création d'un composant button radio
* [#337] Création d'un composant select
* [#363] Création d'un composant amount
* [#363] Création d'un composant checkbox
### Changed

View File

@@ -1,142 +0,0 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Checkbox from './Checkbox.vue'
type CheckboxProps = {
id?: string
label?: string
name?: string
modelValue?: boolean | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
const mountCheckbox = (props: CheckboxProps = {}) =>
mount(CheckboxForTest, {props})
describe('MalioCheckbox', () => {
it('renders a checkbox input', () => {
const wrapper = mountCheckbox()
expect(wrapper.get('input').attributes('type')).toBe('checkbox')
})
it('renders the label text', () => {
const wrapper = mountCheckbox({label: 'Accept terms'})
expect(wrapper.get('label').text()).toContain('Accept terms')
})
it('uses a provided id on input and label', () => {
const wrapper = mountCheckbox({
id: 'checkbox-id',
label: 'Accept terms',
})
expect(wrapper.get('input').attributes('id')).toBe('checkbox-id')
expect(wrapper.get('label').attributes('for')).toBe('checkbox-id')
})
it('generates an id when none is provided', () => {
const wrapper = mountCheckbox({label: 'Accept terms'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-checkbox-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('applies the name attribute', () => {
const wrapper = mountCheckbox({name: 'terms'})
expect(wrapper.get('input').attributes('name')).toBe('terms')
})
it('reflects the checked state from modelValue', () => {
const wrapper = mountCheckbox({modelValue: true})
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
})
it('emits update:modelValue when toggled', async () => {
const wrapper = mountCheckbox({modelValue: false})
const input = wrapper.get('input')
await input.setValue(true)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
})
it('does not emit when readonly', async () => {
const wrapper = mountCheckbox({
modelValue: true,
readonly: true,
})
const input = wrapper.get('input')
await input.setValue(false)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).checked).toBe(true)
})
it('sets disabled and required attributes', () => {
const wrapper = mountCheckbox({
disabled: true,
required: true,
})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
it('shows a hint message and links it with aria-describedby', () => {
const wrapper = mountCheckbox({hint: 'Required field'})
const inputId = wrapper.get('input').attributes('id')
expect(wrapper.get('p').text()).toBe('Required field')
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
})
it('shows an error state and message', () => {
const wrapper = mountCheckbox({
label: 'Accept terms',
error: 'You must accept',
})
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('label').classes()).toContain('text-m-error')
expect(wrapper.get('p').text()).toBe('You must accept')
})
it('shows success only when there is no error', () => {
const wrapper = mountCheckbox({
success: 'Valid',
error: 'Invalid',
})
expect(wrapper.get('p').text()).toBe('Invalid')
expect(wrapper.get('p').classes()).toContain('text-m-error')
})
it('shows success styles and message when there is no error', () => {
const wrapper = mountCheckbox({
label: 'Accept terms',
success: 'Valid',
modelValue: true,
})
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('p').text()).toBe('Valid')
expect(wrapper.get('p').classes()).toContain('text-m-success')
})
})

View File

@@ -1,227 +0,0 @@
<template>
<div :class="mergedGroupClass">
<input
:id="inputId"
:name="name"
:checked="isChecked"
:required="required"
:disabled="disabled"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:class="mergedInputClass"
v-bind="attrs"
type="checkbox"
@change="onChange"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
<span>
<svg width="12" height="10" viewBox="0 0 12 10" aria-hidden="true">
<polyline points="1.5 6 4.5 9 10.5 1"/>
</svg>
</span>
<span>
{{ label }}
</span>
</label>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: boolean | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}>(),
{
id: '',
label: '',
name: '',
modelValue: undefined,
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
},
)
const attrs = useAttrs()
const generatedId = useId()
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
const isChecked = computed(() => !!props.modelValue)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const disabled = computed(() => props.disabled)
const describedBy = computed(() => {
if (!props.hint && !hasError.value && !hasSuccess.value) return undefined
return `${inputId.value}-describedby`
})
const mergedGroupClass = computed(() =>
twMerge(
'checkbox-wrapper-4 mt-4 w-full',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'inp-cbx peer',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'cbx text-black',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
hasError.value ? 'text-m-error' : '',
hasSuccess.value ? 'text-m-success' : '',
props.labelClass,
),
)
const mergedMessageClass = computed(() =>
twMerge(
'text-xs',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'text-m-muted',
),
)
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const onChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (props.readonly) {
target.checked = isChecked.value
return
}
emit('update:modelValue', target.checked)
}
</script>
<style scoped>
.cbx {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.cbx span {
display: inline-flex;
align-items: center;
}
.cbx span:first-child {
position: relative;
width: 18px;
height: 18px;
flex: 0 0 18px;
transform: scale(1);
border: 2px solid rgb(0, 0, 0);
transition: all 0.1s ease;
}
.cbx span:first-child svg {
position: absolute;
top: 2px;
left: 1px;
fill: none;
stroke: #000000;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16px;
stroke-dashoffset: 16px;
transition: all 0.125s ease;
}
.cbx span:last-child {
padding-left: 12px;
line-height: 18px;
}
.inp-cbx {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.inp-cbx:checked + .cbx span:first-child svg {
stroke-dashoffset: 0;
}
.inp-cbx + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
}
.cbx.text-m-error span:first-child svg {
stroke: rgb(var(--m-error) / 1);
}
.inp-cbx:checked + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
}
.inp-cbx + .cbx.text-m-success span:first-child {
border-color: rgb(var(--m-success) / 1);
}
.cbx.text-m-success span:first-child svg {
stroke: rgb(var(--m-success) / 1);
}
.inp-cbx:checked + .cbx.text-m-success span:first-child {
border-color: rgb(var(--m-success) / 1);
}
.inp-cbx:disabled + .cbx {
cursor: not-allowed;
opacity: 0.6;
}
</style>

View File

@@ -1,17 +1,17 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
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
inputClass?: string
textSize?: string
labelClass?: string
groupClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
@@ -21,225 +21,245 @@ type InputProps = {
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
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,
}
const mountInput = (props: InputProps = {}) =>
mount(InputForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputText', () => {
describe('MalioInput', () => {
// Props de base: valeur, label, name, id, autocomplete
it('renders the initial input value', () => {
const wrapper = mountInput({modelValue: 'initialValueTest'})
const wrapper = mount(InputForTest, {
props: {modelValue: 'initialValueTest'},
})
expect(wrapper.get('input').element.value).toBe('initialValueTest')
})
it('renders the label text', () => {
const wrapper = mountInput({label: 'labelTest'})
const wrapper = mount(InputForTest, {
props: {label: 'labelTest'},
})
expect(wrapper.get('label').text()).toBe('labelTest')
})
it('applies the name attribute', () => {
const wrapper = mountInput({name: 'nameTest'})
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 = mountInput({id: 'custom-id', label: '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('keeps the default rounded class on input', () => {
const wrapper = mountInput()
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 = mountInput({label: '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 = mountInput({autocomplete: 'autocompleteTest'})
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 = mountInput({required: false})
const wrapper = mount(InputForTest, {
props: {required: false},
})
expect(wrapper.get('input').attributes('required')).toBeUndefined()
})
it('sets required when true', () => {
const wrapper = mountInput({required: true})
const wrapper = mount(InputForTest, {
props: {required: true},
})
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
it('does not set readonly when false', () => {
const wrapper = mountInput({readonly: false})
const wrapper = mount(InputForTest, {
props: {readonly: false},
})
expect(wrapper.get('input').attributes('readonly')).toBeUndefined()
})
it('sets readonly when true', () => {
const wrapper = mountInput({readonly: 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 = mountInput({disabled: 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 = mountInput({disabled: 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 = mountInput({modelValue: ''})
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 = mountInput({maxLength: 25})
const wrapper = mount(InputForTest, {
props: {maxLength: 25},
})
expect(wrapper.get('input').attributes('maxlength')).toBe('25')
})
it('applies minLength to input', () => {
const wrapper = mountInput({minLength: 25})
const wrapper = mount(InputForTest, {
props: {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('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 = mountInput({error: 'Error message test'})
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('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'})
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 = mountInput({
error: 'Error message test',
label: 'Error message',
iconName: 'mdi:key-outline',
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 = mountInput({error: 'Error message test', iconName: 'mdi:key-outline'})
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 = mountInput({success: 'Success message test'})
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 = mountInput({success: 'Success message test', label: 'Success message'})
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 = mountInput({
success: 'Success message test',
label: 'Success message',
iconName: 'mdi:key-outline',
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 = mountInput({success: 'Success message test', iconName: 'mdi:key-outline'})
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 = mountInput({
error: 'Error message test',
success: 'Success message test',
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)
@@ -247,21 +267,34 @@ describe('MalioInputText', () => {
expect(wrapper.get('input').classes()).not.toContain('border-m-success')
})
// Aide et classes de label
it('shows hint message', () => {
const wrapper = mountInput({hint: 'Hint message test'})
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 = mountInput({labelClass: 'text-red-500'})
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 = mountInput({iconName: 'mdi:key-outline'})
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')
@@ -270,28 +303,17 @@ describe('MalioInputText', () => {
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',
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"]').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'})
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,163 +0,0 @@
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<InputAmountProps>
const mountInputAmount = (props: InputAmountProps = {}) =>
mount(InputAmountForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
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')
})
})

View File

@@ -1,255 +0,0 @@
<template>
<div
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="decimal"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="onBlur"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
/>
</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 {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
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
}>(),
{
id: '',
name: '',
autocomplete: 'off',
modelValue: undefined,
iconName: 'mdi:currency-eur',
iconPosition: 'right',
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
maxLength: undefined,
minLength: undefined,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const describedBy = computed(() => {
if (!props.hint && !hasError.value && !hasSuccess.value) return undefined
return `${inputId.value}-describedby`
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const normalizeAmount = (value: string) => {
const sanitizedValue = value
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
const decimalPart = decimalParts.join('').slice(0, 2)
if (sanitizedValue.includes('.')) {
return `${integerPart || '0'}.${decimalPart}`
}
return integerPart
}
// Keep the DOM input value, local state, and v-model emission in sync.
const updateValue = (target: HTMLInputElement, value: string) => {
target.value = value
if (!isControlled.value) {
localValue.value = value
}
emit('update:modelValue', value)
}
// Normalize while typing so the field never keeps invalid amount characters.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
updateValue(target, normalizeAmount(target.value))
}
// Keep the blur handler only for focus-driven UI state.
const onBlur = () => {
isFocused.value = false
}
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : ''
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-8'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
</script>
<style scoped>
.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

@@ -1,33 +1,52 @@
<template>
<div
:class="mergedGroupClass"
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="mergedInputClass"
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="currentValue"
:value="modelValue ?? ''"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
placeholder=" "
type="text"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
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>
@@ -39,11 +58,9 @@
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
hasError ? 'text-m-error' : hasSuccess ? 'text-m-success' : '',
'pointer-events-none absolute right-2 top-1/2 -translate-y-1/2',
iconColor,
]"
/>
@@ -68,9 +85,8 @@
import type {MaskInputOptions} from 'maska'
import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import {computed, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
@@ -81,9 +97,10 @@ const props = withDefaults(
name?: string
autocomplete?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
minWidth?: string
maxWidth?: string
textInput?: string
textLabel?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
@@ -93,8 +110,7 @@ const props = withDefaults(
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
rounded?: string
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
@@ -102,19 +118,20 @@ const props = withDefaults(
{
id: '',
name: '',
autocomplete: 'off',
autocomplete: '',
modelValue: undefined,
iconName: '',
iconPosition: 'right',
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
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: '',
@@ -126,51 +143,10 @@ const props = withDefaults(
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const describedBy = computed(() => {
const ids: string[] = []
@@ -186,36 +162,20 @@ const emit = defineEmits<{
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (!isControlled.value) {
localValue.value = target.value
}
emit('update:modelValue', target.value)
}
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3'
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-8'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
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;

View File

@@ -1,152 +0,0 @@
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<InputTextAreaProps>
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')
})
})

View File

@@ -1,186 +0,0 @@
<template>
<div
class="relative mt-4 w-full"
>
<textarea
:id="inputId"
:name="name"
:autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 overflow-auto"
:class="[
isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
hasError
? 'border-m-error focus:border-m-error focus:pl-[11px]'
: hasSuccess
? 'border-m-success focus:border-m-success focus:pl-[11px]'
: 'focus:border-m-primary focus:pl-[11px]',
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
disabled ? 'text-black/60' : '',
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
textLabel,
]"
>
{{ label }}
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
v-if="hasError || hasSuccess || hint"
class="mt-1 flex items-center justify-between gap-2 text-xs"
>
<p
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
size?: number | string
textInput?: string
textLabel?: string
resize?: 'none' | 'both' | 'horizontal' | 'vertical'
minResizeWidth?: number
maxResizeWidth?: number
minResizeHeight?: number
maxResizeHeight?: number
required?: boolean
maxLength?: number
showCounter?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
rounded?: string
}>(),
{
id: '',
name: '',
autocomplete: 'off',
modelValue: undefined,
label: '',
size: 2,
textInput: 'text-lg',
required: false,
maxLength: 800,
showCounter: false,
readonly: false,
textLabel: 'text-sm',
disabled: false,
rounded: 'rounded-md',
hint: '',
error: '',
success: '',
resize: 'both',
minResizeWidth: 280,
maxResizeWidth: 640,
minResizeHeight: 40,
maxResizeHeight: 320,
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
const currentLength = computed(() => (currentValue.value ?? '').length)
const showCounterComputed = computed(() =>
props.showCounter && Number(props.maxLength) > 0
)
const toCssSize = (value: number | string) => (typeof value === 'number' ? `${value}px` : value)
const textareaStyle = computed(() => ({
resize: props.resize,
minWidth: toCssSize(props.minResizeWidth),
maxWidth: toCssSize(props.maxResizeWidth),
minHeight: toCssSize(props.minResizeHeight),
maxHeight: toCssSize(props.maxResizeHeight),
}))
const isFilled = computed(() => currentValue.value.trim().length > 0)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
if (!isControlled.value) {
localValue.value = target.value
}
emit('update:modelValue', target.value)
}
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>

View File

@@ -1,156 +0,0 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import RadioButton from './RadioButton.vue'
type RadioButtonProps = {
id?: string
label?: string
name?: string
modelValue?: string | number | boolean | null | undefined
value?: string | number | boolean | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}
const RadioButtonForTest = RadioButton as DefineComponent<RadioButtonProps>
const mountRadioButton = (props: RadioButtonProps = {}) =>
mount(RadioButtonForTest, {
props,
})
describe('MalioRadioButton', () => {
it('renders the label text', () => {
const wrapper = mountRadioButton({label: 'Option 1'})
expect(wrapper.get('.radio-text').text()).toBe('Option 1')
})
it('applies provided id to input and label', () => {
const wrapper = mountRadioButton({id: 'radio-id', label: 'Option 1'})
expect(wrapper.get('input').attributes('id')).toBe('radio-id')
expect(wrapper.get('.radio-text').attributes('for')).toBe('radio-id')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountRadioButton({label: 'Option 1'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-radio-')).toBe(true)
expect(wrapper.get('.radio-text').attributes('for')).toBe(inputId)
})
it('applies the name attribute', () => {
const wrapper = mountRadioButton({name: 'choice-group'})
expect(wrapper.get('input').attributes('name')).toBe('choice-group')
})
it('sets required when true', () => {
const wrapper = mountRadioButton({required: true})
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
it('sets disabled styles when true', () => {
const wrapper = mountRadioButton({disabled: true, label: 'Option 1'})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('.radio-control').classes()).toContain('is-disabled')
expect(wrapper.get('.radio-text').classes()).toContain('cursor-not-allowed')
})
it('checks the input when modelValue matches value', () => {
const wrapper = mountRadioButton({modelValue: 'a', value: 'a'})
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
})
it('emits update:modelValue on change', async () => {
const wrapper = mountRadioButton({modelValue: 'a', value: 'b'})
await wrapper.get('input').trigger('change')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['b'])
})
it('prevents updates when readonly', async () => {
const wrapper = mountRadioButton({modelValue: 'a', value: 'b', readonly: true})
const input = wrapper.get('input')
;(input.element as HTMLInputElement).checked = true
await input.trigger('change')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).checked).toBe(false)
})
it('shows hint message and wires aria-describedby', () => {
const wrapper = mountRadioButton({hint: 'Helpful hint'})
const input = wrapper.get('input')
const message = wrapper.get('.radio-message')
expect(message.text()).toBe('Helpful hint')
expect(input.attributes('aria-describedby')).toBe(message.attributes('id'))
expect(input.attributes('aria-invalid')).toBe('false')
})
it('shows error state on control, label and helper text', () => {
const wrapper = mountRadioButton({
label: 'Option 1',
error: 'Selection required',
})
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
expect(wrapper.get('.radio-text').classes()).toContain('text-m-error')
expect(wrapper.get('.radio-message').classes()).toContain('text-m-error')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
})
it('shows success state when no error is present', () => {
const wrapper = mountRadioButton({
label: 'Option 1',
success: 'Selection saved',
})
expect(wrapper.get('.radio-control').classes()).toContain('is-success')
expect(wrapper.get('.radio-text').classes()).toContain('text-m-success')
expect(wrapper.get('.radio-message').classes()).toContain('text-m-success')
})
it('prioritizes error over success', () => {
const wrapper = mountRadioButton({
error: 'Selection required',
success: 'Selection saved',
})
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
expect(wrapper.get('.radio-control').classes()).not.toContain('is-success')
expect(wrapper.get('.radio-message').text()).toBe('Selection required')
expect(wrapper.get('.radio-message').classes()).toContain('text-m-error')
})
it('merges custom classes on group, input and label', () => {
const wrapper = mountRadioButton({
label: 'Option 1',
groupClass: 'mt-0 custom-group',
inputClass: 'border-red-500',
labelClass: 'font-bold',
})
expect(wrapper.get('.radio-item').classes()).toContain('custom-group')
expect(wrapper.get('.radio-item').classes()).toContain('mt-0')
expect(wrapper.get('input').classes()).toContain('border-red-500')
expect(wrapper.get('.radio-text').classes()).toContain('font-bold')
})
})

View File

@@ -1,197 +0,0 @@
<template>
<div :class="mergedGroupClass">
<div :class="mergedControlClass">
<label :for="inputId" class="radio-indicator relative flex cursor-pointer items-center p-3">
<input
:id="inputId"
:name="name"
:value="value"
:checked="isChecked"
:required="required"
:disabled="disabled"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:class="mergedInputClass"
v-bind="attrs"
type="radio"
@click="onClick"
@change="onChange"
>
<span class="radio-dot pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-black opacity-0">
<svg viewBox="0 0 16 16" fill="currentColor" class="h-[10px]">
<circle cx="8" cy="8" r="8" />
</svg>
</span>
</label>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
</div>
<p
v-if="shouldShowMessage"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: string | number | boolean | null | undefined
value?: string | number | boolean | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}>(),
{
id: '',
label: '',
name: '',
modelValue: undefined,
value: undefined,
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
},
)
const attrs = useAttrs()
const generatedId = useId()
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
const isChecked = computed(() => props.modelValue === props.value)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const disabled = computed(() => props.disabled)
const shouldShowMessage = computed(() => !!(props.hint || hasError.value || hasSuccess.value))
const describedBy = computed(() => {
if (!shouldShowMessage.value) return undefined
return `${inputId.value}-describedby`
})
const mergedGroupClass = computed(() =>
twMerge(
'radio-item mt-4 w-full',
props.groupClass,
),
)
const mergedControlClass = computed(() =>
twMerge(
'radio-control flex items-center',
hasError.value ? 'is-error' : '',
hasSuccess.value ? 'is-success' : '',
disabled.value ? 'is-disabled' : '',
),
)
const mergedInputClass = computed(() =>
twMerge(
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-black',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'radio-text mt-px cursor-pointer text-black',
hasError.value ? 'text-m-error' : '',
hasSuccess.value ? 'text-m-success' : '',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
props.labelClass,
),
)
const mergedMessageClass = computed(() =>
twMerge(
'radio-message ml-3 -mt-1 text-xs',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'text-m-muted',
),
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string | number | boolean | null | undefined): void
}>()
const onClick = (event: MouseEvent) => {
if (!props.readonly) return
event.preventDefault()
}
const onChange = (event: Event) => {
if (props.readonly) {
const target = event.target as HTMLInputElement
target.checked = isChecked.value
return
}
emit('update:modelValue', props.value)
}
</script>
<style scoped>
.radio-control input[type='radio']:checked + .radio-dot {
opacity: 1;
}
.radio-control.is-error input[type='radio'] {
border-color: rgb(var(--m-error) / 1);
}
.radio-control.is-error .radio-dot {
color: rgb(var(--m-error) / 1);
}
.radio-control.is-success input[type='radio'] {
border-color: rgb(var(--m-success) / 1);
}
.radio-control.is-success .radio-dot {
color: rgb(var(--m-success) / 1);
}
.radio-control.is-disabled .radio-indicator,
.radio-control.is-disabled .radio-text {
cursor: not-allowed;
opacity: 0.6;
}
.radio-item:has(+ .radio-item) .radio-message {
display: none;
}
</style>

View File

@@ -1,177 +0,0 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Select from './Select.vue'
type Option = {
label: string
value: string | number | null
}
type SelectProps = {
modelValue?: string | number | null
options?: Option[]
emptyOptionLabel?: string
label?: string
hint?: string
error?: string
success?: string
minWidth?: string
maxWidth?: string
textField?: string
textValue?: string
textLabel?: string
rounded?: string
disabled?: boolean
}
const SelectForTest = Select as DefineComponent<SelectProps>
const options: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
]
describe('MalioSelect', () => {
it('renders the label text', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Country'},
})
expect(wrapper.get('label').text()).toBe('Country')
})
it('generates button and listbox ids and links them together', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
const button = wrapper.get('button')
expect(button.attributes('id')?.startsWith('custom-select-btn-')).toBe(true)
expect(button.attributes('aria-controls')?.startsWith('custom-select-listbox-')).toBe(true)
await button.trigger('click')
expect(wrapper.get('ul').attributes('id')).toBe(button.attributes('aria-controls'))
})
it('uses disabled styles and prevents opening when disabled', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, disabled: true, options},
})
const button = wrapper.get('button')
expect(button.attributes('disabled')).toBeDefined()
expect(button.classes()).toContain('cursor-not-allowed')
await button.trigger('click')
expect(wrapper.find('ul').exists()).toBe(false)
})
it('opens the list and rotates the icon on click', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('ul').exists()).toBe(true)
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('svg').classes()).toContain('rotate-180')
})
it('emits update:modelValue when selecting an option', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
await wrapper.findAll('li')[2].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
})
it('renders the empty option with muted text style', async () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
emptyOptionLabel: 'Aucune selection',
},
})
await wrapper.get('button').trigger('click')
const firstOption = wrapper.findAll('li')[0]
expect(firstOption.text()).toBe('Aucune selection')
expect(firstOption.classes()).toContain('text-black/40')
})
it('shows the selected value text when an option is selected', () => {
const wrapper = mount(SelectForTest, {
props: {
options,
modelValue: 'fr',
},
})
expect(wrapper.text()).toContain('France')
expect(wrapper.get('button').classes()).toContain('border-black')
})
it('shows hint message in muted color', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, hint: 'Select a country'},
})
expect(wrapper.get('p.text-m-muted').text()).toBe('Select a country')
})
it('shows error state on button, label and helper text', () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
label: 'Country',
error: 'Selection error',
},
})
expect(wrapper.get('button').classes()).toContain('border-m-error')
expect(wrapper.get('label').classes()).toContain('text-m-error')
expect(wrapper.get('p.text-m-error').text()).toBe('Selection error')
expect(wrapper.get('button').attributes('aria-invalid')).toBe('true')
})
it('shows success state on button, label and helper text', () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
label: 'Country',
success: 'Selection success',
},
})
expect(wrapper.get('button').classes()).toContain('border-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('p.text-m-success').text()).toBe('Selection success')
})
it('prioritizes error over success', () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options,
error: 'Selection error',
success: 'Selection success',
},
})
expect(wrapper.get('button').classes()).toContain('border-m-error')
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-error').text()).toBe('Selection error')
})
})

View File

@@ -1,333 +0,0 @@
<template>
<div
ref="root"
class="relative mt-4 w-full"
:class="[minWidth, maxWidth]"
>
<button
:id="buttonId"
type="button"
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
:class="[
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-error !border-b-0'
: 'rounded-t-none !border-2 !border-m-error !border-t-0'
: 'border-m-error'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
: 'border-m-success'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:disabled="disabled"
@click="toggle"
>
<label
v-if="label"
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
isOpen ? 'top-2 z-30' : 'top-2',
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
>
{{ label }}
</label>
<span
class="block truncate"
:class="[
textValue,
isOptionSelected ? 'text-black' : 'select-none text-transparent'
]"
>
{{ selectedLabel || '\u00A0' }}
</span>
<span
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-current'
]"
>
<slot name="icon">
<IconifyIcon
icon="mdi:chevron-down"
width="20"
class="transition-transform duration-300"
:class="isOpen ? 'rotate-180' : 'rotate-0'"
/>
</slot>
</span>
</button>
<ul
v-if="isOpen"
:id="listboxId"
ref="listRef"
role="listbox"
:aria-labelledby="buttonId"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
:class="[
openDirection === 'down'
? 'top-[calc(100%-2px)] rounded-b-md border-t-0'
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
hasError
? 'select-scrollbar-error'
: hasSuccess
? 'select-scrollbar-success'
: 'select-scrollbar-primary',
hasError
? 'border-m-error'
: hasSuccess
? 'border-m-success'
: 'border-m-primary'
]"
>
<li
v-for="(opt, index) in normalizedOptions"
:id="optionId(index)"
:key="String(opt.value)"
role="option"
:aria-selected="opt.value === modelValue"
class="cursor-pointer px-3 py-2"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
opt.value === null ? 'text-black/40' : 'text-black'
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
@click="select(opt.value)"
>
{{ opt.label || '\u00A0' }}
</li>
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
defineOptions({name: 'MalioSelect', inheritAttrs: false})
type Option = {
label: string;
value: string | number | null
}
const props = withDefaults(defineProps<{
modelValue: string | number | null
options?: Option[]
emptyOptionLabel?: string
label?: string
hint?: string
error?: string
success?: string
minWidth?: string
maxWidth?: string
textField?: string
textValue?: string
textLabel?: string
rounded?: string
disabled?: boolean
}>(), {
options: () => [],
emptyOptionLabel: '',
label: '',
hint: '',
error: '',
success: '',
minWidth: 'w-96',
maxWidth: '',
textField: 'text-lg',
textValue: 'text-lg',
textLabel: 'text-sm',
rounded: 'rounded-md',
disabled: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string | number | null): void
}>()
const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down')
const uid = useId()
const buttonId = `custom-select-btn-${uid}`
const listboxId = `custom-select-listbox-${uid}`
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => [
{label: props.emptyOptionLabel, value: null},
...props.options,
])
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
props.options.some(o => o.value === props.modelValue)
)
const shouldFloatLabel = computed(() =>
isOpen.value || isOptionSelected.value
)
const selectedLabel = computed(() =>
props.options.find(o => o.value === props.modelValue)?.label ?? ''
)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
)
function optionId(index: number) {
return `custom-select-opt-${uid}-${index}`
}
function updateOpenDirection() {
if (!root.value) return
const rect = root.value.getBoundingClientRect()
const estimatedListHeight = Math.min(normalizedOptions.value.length * 40, 240)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openDirection.value =
spaceBelow >= estimatedListHeight || spaceBelow >= spaceAbove
? 'down'
: 'up'
}
function open() {
updateOpenDirection()
isOpen.value = true
const selectedIndex = normalizedOptions.value.findIndex(o => o.value === props.modelValue)
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0
nextTick(() => {
if (openDirection.value === 'up' && listRef.value) {
listHeight.value = listRef.value.offsetHeight
} else {
listHeight.value = 0
}
})
}
const labelTransformStyle = computed(() => {
// label non flottant
if (!shouldFloatLabel.value) {
return {}
}
// fermé ou ouverture vers le bas : comportement classique
if (!isOpen.value || openDirection.value === 'down') {
return {
transform: 'translateY(-1.15rem) scale(0.9)',
}
}
// ouverture vers le haut : on remonte en fonction de la hauteur de la liste
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
const total = 4 +listHeight.value + extraOffset
// 18 ≈ 1.15rem pour garder la même base que votre flottant actuel
return {
transform: `translateY(-${total}px) scale(0.9)`,
}
})
function close() {
isOpen.value = false
}
function toggle() {
if (props.disabled) return
if (isOpen.value) {
close()
return
}
open()
}
function select(value: string | number | null) {
emit('update:modelValue', value)
close()
}
function onClickOutside(e: MouseEvent) {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
scrollbar-gutter: stable;
}
:deep(.select-scrollbar-primary) {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
:deep(.select-scrollbar-error) {
scrollbar-color: #000000 transparent;
}
:deep(.select-scrollbar-success) {
scrollbar-color: #000000 transparent;
}
</style>

View File

@@ -1,148 +0,0 @@
<template>
<Story
title="Select"
>
<MalioSelect
v-model="value"
:options="options"
/>
</Story>
</template>
<docs lang="md">
# MalioSelect
Composant select personnalisé avec label flottant, option vide,
états visuels selon la sélection et ouverture/fermeture contrôlée.
------------------------------------------------------------------------
## Props détaillées
### modelValue
- Type: string | number | null
- Description: Valeur actuellement sélectionnée.
- Comportement:
- Compatible avec `v-model`.
- Peut valoir `null` pour représenter loption vide.
### options
- Type: Array<{ label: string; value: string | number | null }>
- Description: Liste des options affichées dans le menu.
- Défaut: tableau vide.
### emptyOptionLabel
- Type: string
- Description: Texte de loption vide injectée automatiquement.
- Comportement:
- Cette option porte toujours la valeur `null`.
- Si la prop est vide, loption reste visuellement discrète.
### label
- Type: string
- Description: Texte affiché comme label flottant au-dessus du champ.
- Comportement: Si absent, aucun label nest rendu.
------------------------------------------------------------------------
## Apparence & Style
### textField
- Type: string
- Description: Classes CSS appliquées au bouton du select.
### textValue
- Type: string
- Description: Classes CSS appliquées à la valeur affichée.
### textLabel
- Type: string
- Description: Classes CSS appliquées au label flottant.
### rounded
- Type: string
- Description: Classe Tailwind pour le border-radius.
- Défaut: rounded-md
### minWidth / maxWidth
- Type: string
- Description: Classes utilitaires Tailwind pour contraindre la
largeur.
------------------------------------------------------------------------
## Interaction
### disabled
- Type: boolean
- Description: Désactive complètement le composant.
- Effet:
- Empêche louverture du menu.
- Applique un style visuel désactivé.
------------------------------------------------------------------------
## Comportement visuel
- Au repos sans sélection: bordure grise.
- Ouvert: bordure et label en couleur primaire.
- Fermé avec valeur sélectionnée: bordure et texte en noir.
- Loption vide dans la liste peut être stylée différemment pour être
distinguée des autres.
------------------------------------------------------------------------
## Accessibilité
- Le bouton porte `aria-expanded` et `aria-controls`.
- La liste utilise `role="listbox"`.
- Chaque option utilise `role="option"` et `aria-selected`.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque sélection dans la liste.
- Permet lutilisation avec v-model.
- Peut émettre `null` si loption vide est sélectionnée.
</docs>
<script setup lang="ts">
import { ref } from 'vue'
import MalioSelect from '../components/malio/Select.vue'
const value = ref<string | number | null>(null)
const options = [
{ label: 'France', value: 'fr' },
{ label: 'Belgique', value: 'be' },
{ label: 'Suisse', value: 'ch' },
{ label: 'Canada', value: 'ca' },
{ label: 'Allemagne', value: 'de' },
{ label: 'Espagne', value: 'es' },
{ label: 'Italie', value: 'it' },
{ label: 'Portugal', value: 'pt' },
{ label: 'Pays-Bas', value: 'nl' },
{ label: 'Suède', value: 'se' },
{ label: 'Norvège', value: 'no' },
{ label: 'Danemark', value: 'dk' },
{ label: 'Finlande', value: 'fi' },
{ label: 'Autriche', value: 'at' },
{ label: 'Irlande', value: 'ie' },
{ label: 'Grèce', value: 'gr' },
{ label: 'Pologne', value: 'pl' },
{ label: 'Hongrie', value: 'hu' },
{ label: 'République tchèque', value: 'cz' },
]
</script>

View File

@@ -1,187 +0,0 @@
<template>
<Story title="Input/Radio">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`simple-${option.value}`"
v-model="primaryChoice"
:label="option.label"
:value="option.value"
name="primary-choice"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Preselected</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`preselected-${option.value}`"
v-model="preselectedChoice"
:label="option.label"
:value="option.value"
name="preselected-choice"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Error</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`error-${option.value}`"
v-model="errorChoice"
:label="option.label"
:value="option.value"
name="error-choice"
error="Selection required"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Success</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`success-${option.value}`"
v-model="successChoice"
:label="option.label"
:value="option.value"
name="success-choice"
success="Selection saved"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Disabled</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`disabled-${option.value}`"
v-model="disabledChoice"
:label="option.label"
:value="option.value"
name="disabled-choice"
disabled
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<div class="space-y-1">
<MalioRadioButton
v-for="option in options"
:key="`readonly-${option.value}`"
v-model="readonlyChoice"
:label="option.label"
:value="option.value"
name="readonly-choice"
readonly
hint="Readonly group"
/>
</div>
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioRadioButton
Composant radio personnalisé compatible avec `v-model`, les groupes via `name`,
et les états visuels de validation.
------------------------------------------------------------------------
## Props détaillées
### modelValue
- Type: `string | number | boolean | null | undefined`
- Description: Valeur actuellement sélectionnée dans le groupe.
- Comportement:
- Compatible avec `v-model`.
- Le radio est coché quand `modelValue === value`.
### value
- Type: `string | number | boolean | null | undefined`
- Description: Valeur portée par le radio courant.
### label
- Type: `string`
- Description: Texte affiché à droite du radio.
### name
- Type: `string`
- Description: Nom HTML partagé par les radios dun même groupe.
------------------------------------------------------------------------
## États
### error
- Type: `string`
- Description: Message et style derreur.
### success
- Type: `string`
- Description: Message et style de succès.
- Comportement: ignoré si `error` est présent.
### disabled
- Type: `boolean`
- Description: Désactive le radio.
### readonly
- Type: `boolean`
- Description: Empêche le changement de valeur tout en gardant le rendu affiché.
### hint
- Type: `string`
- Description: Message daide sous le groupe.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à la sélection dun radio.
- Retourne la `value` du radio sélectionné.
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioRadioButton from '../components/malio/RadioButton.vue'
const options = [
{label: 'Option 1', value: 'option1'},
{label: 'Option 2', value: 'option2'},
{label: 'Option 3', value: 'option3'},
{label: 'Option 4', value: 'option4'},
]
const primaryChoice = ref<string | null>(null)
const preselectedChoice = ref<string | null>('option2')
const errorChoice = ref<string | null>(null)
const successChoice = ref<string | null>('option3')
const disabledChoice = ref<string | null>('option2')
const readonlyChoice = ref<string | null>('option4')
</script>

View File

@@ -1,200 +0,0 @@
<template>
<Story
title="Input/Amount"
>
<MalioInputAmount/>
</Story>
</template>
<docs lang="md">
# MalioInputAmount
Composant input dédié à la saisie dun montant décimal avec label flottant,
états visuels (erreur / succès) et icône configurable.
------------------------------------------------------------------------
## Props détaillées
### id
- Type: string
- Description: Identifiant HTML de linput.
- Comportement: Si non fourni, un id unique est généré automatiquement.
### label
- Type: string
- Description: Texte affiché comme label flottant.
- Comportement: Si absent, aucun label nest rendu.
### name
- Type: string
- Description: Attribut `name` de linput, utile dans les formulaires.
### autocomplete
- Type: string
- Description: Valeur de lattribut `autocomplete`.
- Défaut: `off`
### modelValue
- Type: string | null | undefined
- Description: Valeur contrôlée du composant.
- Comportement:
- Si défini, le composant fonctionne via `v-model`.
- Sinon, il conserve une valeur locale interne.
------------------------------------------------------------------------
## Apparence & Style
### inputClass
- Type: string
- Description: Classes CSS additionnelles appliquées à linput.
### labelClass
- Type: string
- Description: Classes CSS additionnelles appliquées au label.
### groupClass
- Type: string
- Description: Classes CSS additionnelles appliquées au conteneur.
------------------------------------------------------------------------
## Validation & Contraintes
### required
- Type: boolean
- Description: Ajoute lattribut HTML `required`.
### maxLength
- Type: number | string
- Description: Longueur maximale autorisée.
### minLength
- Type: number | string
- Description: Longueur minimale autorisée.
### disabled
- Type: boolean
- Description: Désactive complètement le champ.
### readonly
- Type: boolean
- Description: Rend le champ non modifiable mais focusable.
------------------------------------------------------------------------
## États & Messages
### hint
- Type: string
- Description: Message daide affiché sous le champ.
### error
- Type: string
- Description: Message derreur.
- Effet:
- Active létat visuel erreur.
- Positionne `aria-invalid="true"`.
- Est prioritaire sur `success` et `hint`.
### success
- Type: string
- Description: Message de succès.
- Effet:
- Actif uniquement si `error` est absent.
------------------------------------------------------------------------
## Icône
### iconName
- Type: string
- Description: Nom de licône affichée dans le champ.
- Défaut: `mdi:currency-eur`
### iconPosition
- Type: `'left' | 'right'`
- Description: Position de licône dans le champ.
- Défaut: `right`
### iconSize
- Type: string | number
- Description: Taille de licône.
### iconColor
- Type: string
- Description: Classe de couleur de licône en état neutre.
------------------------------------------------------------------------
## Comportement montant
- Le champ est rendu en `type="text"` avec `inputmode="decimal"`.
- Le séparateur décimal affiché par défaut est `.`.
- Les virgules saisies par lutilisateur sont converties en points.
- Tous les caractères non numériques, sauf le séparateur décimal, sont supprimés.
- La partie décimale est limitée à 2 chiffres.
### Exemples de normalisation
- `12,5` devient `12.5`
- `0012,345abc` devient `12.34`
- `,5` devient `0.5`
### Formatage au blur
- `12` devient `12.00`
- `12.5` devient `12.50`
------------------------------------------------------------------------
## Priorité visuelle
1. `error`
2. `success`
3. état neutre
------------------------------------------------------------------------
## Accessibilité
- `aria-invalid` est activé si `error` existe.
- `aria-describedby` référence le message affiché sous le champ.
- Le composant fonctionne avec ou sans `v-model`.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque modification de linput.
- Émis aussi au `blur` si la valeur est reformatée.
- Permet lutilisation avec `v-model`.
</docs>
<script setup lang="ts">
import MalioInputAmount from '../components/malio/InputAmount.vue'
</script>

View File

@@ -1,114 +0,0 @@
<template>
<Story title="Input/Checkbox">
<MalioCheckbox
v-model="simpleValue"
label="Accepter les conditions"
/>
</Story>
</template>
<docs lang="md">
# MalioCheckbox
Composant checkbox custom avec `v-model`, message d'aide, et états visuels
`error` / `success`.
------------------------------------------------------------------------
## Props
### id
- Type: `string`
- Description: Identifiant HTML du checkbox.
- Comportement: si absent, un id unique est généré automatiquement.
### label
- Type: `string`
- Description: Texte affiche a cote de la case.
### name
- Type: `string`
- Description: Attribut `name` du champ.
### modelValue
- Type: `boolean | null | undefined`
- Description: État coche du composant.
### inputClass
- Type: `string`
- Description: Classes supplémentaires appliquées a l'input natif.
### labelClass
- Type: `string`
- Description: Classes supplémentaires appliquées au label.
### groupClass
- Type: `string`
- Description: Classes supplémentaires appliquées au conteneur.
### required
- Type: `boolean`
- Description: Ajoute l'attribut HTML `required`.
### disabled
- Type: `boolean`
- Description: Désactive le composant.
### readonly
- Type: `boolean`
- Description: Empêche la mise a jour du `v-model` tout en gardant
l'affichage courant.
### hint
- Type: `string`
- Description: Message d'aide affiche sous le checkbox.
### error
- Type: `string`
- Description: Message d'erreur.
- Effet: prioritaire sur `success`, applique `aria-invalid` et la couleur
d'erreur au texte et a la case.
### success
- Type: `string`
- Description: Message de succès.
- Effet: applique la couleur de succès au texte et a la case si `error`
est absent.
------------------------------------------------------------------------
## Accessibilité
- `aria-invalid` est active si `error` existe.
- `aria-describedby` pointe vers le message affiche.
- L'input natif reste present pour conserver le comportement formulaire.
------------------------------------------------------------------------
## Event
### update:modelValue
- Émis a chaque changement de l'état coche.
- Retourne un booléen `true` ou `false`.
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioCheckbox from '../components/malio/Checkbox.vue'
const simpleValue = ref(false)
</script>

View File

@@ -1,200 +1,56 @@
<template>
<Story
title="Input/Text"
>
<Story title="Input/Text">
<MalioInputText/>
</Story>
</template>
<docs lang="md">
# MalioInputText
# 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
Composant input texte avec masque optionnel (maska), label flottant,
états visuels (erreur / succès) et icône configurable.
------------------------------------------------------------------------
## Props détaillées
### id
- Type: string
- Description: Identifiant HTML de linput.
- Comportement: Si non fourni, un id unique est généré
automatiquement.
### label
- Type: string
- Description: Texte affiché comme label flottant.
- Comportement: Si absent, aucun label nest rendu.
### name
- Type: string
- Description: Attribut name de linput (utile pour les formulaires).
### autocomplete
- Type: string
- Description: Active ou configure lautocomplétion navigateur.
- Défaut: vide
### modelValue
- Type: string | null | undefined
- Description: Valeur contrôlée du composant.
- Comportement:
- Si défini composant contrôlé (v-model).
- Sinon gestion interne de létat.
### mask
- Type: string | undefined
- Description: Masque appliqué via la directive maska.
- Comportement: Formate la saisie selon les tokens définis.
------------------------------------------------------------------------
## Apparence & Style
### textInput
- Type: string
- Description: Classes CSS appliquées à linput (taille de texte,
etc.).
### textLabel
- Type: string
- Description: Classes CSS appliquées au label.
### rounded
- Type: string
- Description: Classe Tailwind pour le border-radius.
- Défaut: rounded-md
### minWidth / maxWidth
- Type: string
- Description: Classes utilitaires Tailwind pour contraindre la
largeur.
------------------------------------------------------------------------
## Validation & Contraintes
### required
- Type: boolean
- Description: Ajoute lattribut HTML required.
### maxLength
- Type: number | string
- Description: Longueur maximale autorisée.
### minLength
- Type: number | string
- Description: Longueur minimale autorisée.
### disabled
- Type: boolean
- Description: Désactive complètement le champ.
### readonly
- Type: boolean
- Description: Rend le champ non modifiable mais focusable.
------------------------------------------------------------------------
## États & Messages
### hint
- Type: string
- Description: Message daide affiché sous le champ.
### error
- Type: string
- Description: Message derreur.
- Effet:
- Active létat visuel erreur.
- aria-invalid=true
- Prioritaire sur success et hint.
### success
- Type: string
- Description: Message de succès.
- Effet:
- Actif uniquement si error est absent.
------------------------------------------------------------------------
## Icône
### iconName
- Type: string
- Description: Nom de licône (ex: mdi:magnify).
### iconSize
- Type: string | number
- Description: Taille de licône.
### iconColor
- Type: string
- Description: Couleur de licône.
------------------------------------------------------------------------
## Comportement de validation
- Aucune validation interne.
- Les états sont pilotés uniquement par les props.
## Priorité visuelle
1. error
2. success
3. neutre
------------------------------------------------------------------------
## Tokens de masque
- \# : chiffre
- A : lettre majuscule
- a : lettre minuscule
- \* : chiffre ou lettre
------------------------------------------------------------------------
## Accessibilité
- aria-invalid est activé si error existe.
- aria-describedby référence dynamiquement le message affiché.
- Fonctionne avec ou sans v-model.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque modification de linput.
- Permet lutilisation avec v-model.
</docs>
<script setup lang="ts">
import MalioInputText from '../components/malio/InputText.vue'
</script>

View File

@@ -1,192 +0,0 @@
<template>
<Story
title="Input/TextArea"
>
<MalioInputTextArea/>
</Story>
</template>
<docs lang="md">
# MalioInputTextArea
Composant textarea avec label flottant, états visuels (erreur / succès),
gestion du redimensionnement et compteur optionnel.
------------------------------------------------------------------------
## Props détaillées
### id
- Type: string
- Description: Identifiant HTML du textarea.
- Comportement: Si non fourni, un id unique est généré
automatiquement.
### label
- Type: string
- Description: Texte affiché comme label flottant.
- Comportement: Si absent, aucun label nest rendu.
### name
- Type: string
- Description: Attribut name du textarea (utile pour les formulaires).
### autocomplete
- Type: string
- Description: Active ou configure lautocomplétion navigateur.
### modelValue
- Type: string | null | undefined
- Description: Valeur contrôlée du composant.
- Comportement:
- Si défini composant contrôlé (v-model).
- Sinon gestion interne de létat.
------------------------------------------------------------------------
## Apparence & Style
### textInput
- Type: string
- Description: Classes CSS appliquées au textarea (taille de texte,
etc.).
- Défaut: text-lg
### textLabel
- Type: string
- Description: Classes CSS appliquées au label.
- Défaut: text-sm
### rounded
- Type: string
- Description: Classe Tailwind pour le border-radius.
- Défaut: rounded-md
------------------------------------------------------------------------
## Dimensions
### size
- Type: number | string
- Description: Nombre de lignes initiales (rows).
- Défaut: 2
### minResizeWidth / maxResizeWidth
- Type: number | string
- Description: Largeur minimale / maximale autorisée lors du
redimensionnement.
### minResizeHeight / maxResizeHeight
- Type: number | string
- Description: Hauteur minimale / maximale autorisée lors du
redimensionnement.
### resize
- Type: 'none' | 'both' | 'horizontal' | 'vertical'
- Description: Définit le comportement de redimensionnement CSS.
- Défaut: both
------------------------------------------------------------------------
## Validation & États
### required
- Type: boolean
- Description: Ajoute lattribut HTML required.
### maxLength
- Type: number | string
- Description: Longueur maximale autorisée.
- Effet: Limite la saisie et alimente le compteur.
### disabled
- Type: boolean
- Description: Désactive complètement le champ.
### readonly
- Type: boolean
- Description: Rend le champ non modifiable mais focusable.
### hint
- Type: string
- Description: Message daide affiché sous le champ.
### error
- Type: string
- Description: Message derreur.
- Effet:
- Active létat visuel erreur.
- aria-invalid=true
- Prioritaire sur success et hint.
### success
- Type: string
- Description: Message de succès.
- Effet:
- Actif uniquement si error est absent.
------------------------------------------------------------------------
## Compteur
### showCounter
- Type: boolean
- Description: Affiche le compteur x / maxLength.
- Condition:
- showCounter = true
- maxLength défini et > 0
- Position: Bas gauche du textarea.
- Mise à jour: Dynamique à chaque saisie.
------------------------------------------------------------------------
## Priorité daffichage
### Messages
1. error
2. success
3. hint
------------------------------------------------------------------------
## Accessibilité
- aria-invalid est activé si error existe.
- aria-describedby référence le message affiché.
- Fonctionne avec ou sans v-model.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque modification du textarea.
- Permet lutilisation avec v-model.
</docs>
<script setup lang="ts">
import MalioInputTextArea from '../components/malio/InputTextArea.vue'
</script>

13
package-lock.json generated
View File

@@ -10,8 +10,7 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"maska": "^3.2.0",
"tailwind-merge": "^3.3.1"
"maska": "^3.2.0"
},
"devDependencies": {
"@histoire/plugin-vue": "^1.0.0-beta.1",
@@ -15123,16 +15122,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",

View File

@@ -40,7 +40,6 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"maska": "^3.2.0",
"tailwind-merge": "^3.3.1"
"maska": "^3.2.0"
}
}

View File

@@ -7,6 +7,11 @@ export default {
'./histoire.setup.ts',
'./histoire.config.ts',
],
safelist: [
{
pattern: /(sm:|md:|lg:|xl:|2xl:)?(text|rounded|w|min-w|max-w)-.+/,
},
],
theme: {
extend: {
borderRadius: {