[#MUI-31] Création d'un composant téléphone #45
@@ -1,122 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-center mt-8">
|
||||
<div>
|
||||
<div class="w-[1348px] grid grid-cols-3 gap-x-[160px] gap-y-8">
|
||||
<MalioInputText
|
||||
label="Nom du client (Entreprise)"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Nom du contact principal"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Prénom du contact principal"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
label="Catégorie"
|
||||
v-model="multiselectValue"
|
||||
:options="[
|
||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||
]"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Téléphone"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
label="Email"
|
||||
/>
|
||||
<MalioSelect
|
||||
value=""
|
||||
label="Distributeur / Courtier"
|
||||
:options="[
|
||||
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
|
||||
{label: 'Distributeur', value: 'Distributeur'},
|
||||
{label: 'Courtier', value: 'Courtier'},
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
value=""
|
||||
label="Nom du courtier"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
value=""
|
||||
label="Distributeur / Courtier"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioCheckbox label="Prestation de triage"/>
|
||||
</div>
|
||||
<div class="mt-16 flex justify-center">
|
||||
<MalioButton label="Valider" variant="primary"/>
|
||||
<div class="flex justify-center mt-16">
|
||||
<div>
|
||||
<div class="w-[1348px] grid grid-cols-3 gap-x-[80px] gap-y-8">
|
||||
<MalioInputText
|
||||
label="Nom du client (Entreprise)"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Nom du contact principal"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Prénom du contact principal"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-model="multiselectValue"
|
||||
label="Catégorie"
|
||||
:options="[
|
||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||
]"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-for="(_, index) in phones"
|
||||
:key="index"
|
||||
v-model="phones[index]"
|
||||
label="Téléphone"
|
||||
add-icon-name="mdi:plus"
|
||||
:addable="phones.length === 1"
|
||||
@add="addPhoneInput"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
label="Email"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="distributeur"
|
||||
value=""
|
||||
label="Distributeur / Courtier"
|
||||
:options="[
|
||||
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
|
||||
{label: 'Distributeur', value: 'Distributeur'},
|
||||
{label: 'Courtier', value: 'Courtier'},
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="nomCourtier"
|
||||
value=""
|
||||
label="Nom du courtier"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="nomDistributeur"
|
||||
value=""
|
||||
label="Nom du distributeur"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||
</div>
|
||||
<div class="mt-16 flex justify-center">
|
||||
<MalioButton label="Valider" variant="primary"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-16">
|
||||
<div>
|
||||
<div class="w-[1348px] grid grid-cols-3 gap-x-[80px] gap-y-8">
|
||||
<MalioInputText
|
||||
label="Nom du client (Entreprise)"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Nom du contact principal"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Prénom du contact principal"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
label="Catégorie"
|
||||
v-model="multiselectValue"
|
||||
:options="[
|
||||
{label: 'Catégorie 1', value: 'Catégorie 1'},
|
||||
{label: 'Catégorie 2', value: 'Catégorie 2'}
|
||||
]"
|
||||
/>
|
||||
<MalioInputText
|
||||
label="Téléphone"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
label="Email"
|
||||
/>
|
||||
<MalioSelect
|
||||
value=""
|
||||
label="Distributeur / Courtier"
|
||||
:options="[
|
||||
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
|
||||
{label: 'Distributeur', value: 'Distributeur'},
|
||||
{label: 'Courtier', value: 'Courtier'},
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
value=""
|
||||
label="Nom du courtier"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioSelect
|
||||
value=""
|
||||
label="Distributeur / Courtier"
|
||||
:options="[
|
||||
{label: 'Nom 1', value: 'Nom 1'}
|
||||
]"
|
||||
/>
|
||||
<MalioCheckbox label="Prestation de triage"/>
|
||||
</div>
|
||||
<div class="mt-16 flex justify-center">
|
||||
<MalioButton label="Valider" variant="primary"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
import {ref} from 'vue'
|
||||
|
||||
const multiselectValue = ref<Array<string | number>>([])
|
||||
const distributeur = ref<string>('')
|
||||
const phones = ref<string[]>([''])
|
||||
const nomDistributeur = ref<string>('')
|
||||
const nomCourtier = ref<string>('')
|
||||
|
||||
function addPhoneInput() {
|
||||
phones.value.push('')
|
||||
}
|
||||
</script>
|
||||
|
||||
141
.playground/pages/composant/input/inputPhone.vue
Normal file
141
.playground/pages/composant/input/inputPhone.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<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>
|
||||
<MalioInputPhone />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
|
||||
<MalioInputPhone
|
||||
v-model="phoneValue"
|
||||
label="Téléphone"
|
||||
name="phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputPhone
|
||||
v-model="phoneAddable"
|
||||
label="Téléphone"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icône à droite (sans bouton +)</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone"
|
||||
icon-position="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone"
|
||||
:icon-name="''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec masque français</h2>
|
||||
<MalioInputPhone
|
||||
v-model="phoneFrench"
|
||||
label="Téléphone (FR)"
|
||||
mask="+33 # ## ## ## ##"
|
||||
hint="Saisir uniquement les chiffres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé (avec addable)</h2>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
addable
|
||||
disabled
|
||||
label="Téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly (avec addable)</h2>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
addable
|
||||
readonly
|
||||
label="Téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputPhone
|
||||
label="Téléphone"
|
||||
hint="Format international recommandé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputPhone
|
||||
model-value="abc"
|
||||
label="Téléphone"
|
||||
error="Numéro de téléphone invalide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputPhone
|
||||
model-value="+33 6 12 34 56 78"
|
||||
label="Téléphone"
|
||||
success="Numéro valide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4 md:col-span-2">
|
||||
<h2 class="mb-4 text-xl font-bold">Cas ERP — liste de téléphones (max 2)</h2>
|
||||
<p class="mb-3 text-sm text-m-muted">
|
||||
Le bouton + s'affiche sur le dernier champ tant que la liste contient moins de {{ MAX_PHONES }} numéros.
|
||||
</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputPhone
|
||||
v-for="(phone, index) in phones"
|
||||
:key="index"
|
||||
v-model="phones[index]"
|
||||
:label="`Téléphone ${index + 1}`"
|
||||
:addable="index === phones.length - 1 && phones.length < MAX_PHONES"
|
||||
@add="addPhone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const phoneValue = ref('')
|
||||
const phoneAddable = ref('')
|
||||
const phoneFrench = ref('')
|
||||
const addClicks = ref(0)
|
||||
|
||||
const onAdd = () => {
|
||||
addClicks.value++
|
||||
}
|
||||
|
||||
const MAX_PHONES = 2
|
||||
const phones = ref<string[]>([''])
|
||||
|
||||
const addPhone = () => {
|
||||
if (phones.value.length < MAX_PHONES) {
|
||||
phones.value.push('')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -28,6 +28,7 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* [#MUI-27] Création d'un composant sélection de site
|
||||
* Création d'un composant rich text (TipTap) avec sortie markdown / HTML
|
||||
* [#MUI-30] Création d'un composant email
|
||||
* [#MUI-31] Création d'un composant téléphone
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -102,6 +102,48 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
||||
|
||||
---
|
||||
|
||||
## MalioInputPhone
|
||||
|
||||
Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outline` à gauche par défaut et bouton `+` optionnel à droite pour gérer une liste de numéros côté parent.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `id` | `string` | auto | Identifiant HTML |
|
||||
| `label` | `string` | `''` | Label du champ |
|
||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
|
||||
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
|
||||
| `required` | `boolean` | `false` | Champ requis |
|
||||
| `hint` | `string` | `''` | Message d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
|
||||
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
|
||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
||||
| `mask` | `string \| MaskInputOptions` | `undefined` | Masque maska (aucun par défaut, utile pour mono-pays) |
|
||||
| `addable` | `boolean` | `false` | Affiche un bouton à droite qui émet l'event `add` |
|
||||
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||
| `addButtonLabel` | `string` | `'Ajouter un numéro'` | aria-label du bouton d'ajout |
|
||||
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||
|
||||
**Events :**
|
||||
- `update:modelValue(value: string)`
|
||||
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||
|
||||
```vue
|
||||
<MalioInputPhone v-model="phone" label="Téléphone" />
|
||||
<MalioInputPhone v-model="phone" label="Téléphone (FR)" mask="+33 # ## ## ## ##" />
|
||||
<MalioInputPhone v-model="phone" label="Téléphone" addable @add="addPhoneField" />
|
||||
<MalioInputPhone v-model="phone" label="Téléphone" error="Numéro invalide" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MalioInputAmount
|
||||
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('MalioCheckbox', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-danger')
|
||||
expect(wrapper.get('p').text()).toBe('You must accept')
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('MalioCheckbox', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('p').text()).toBe('Invalid')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-error')
|
||||
expect(wrapper.get('p').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('shows success styles and message when there is no error', () => {
|
||||
@@ -151,4 +151,14 @@ describe('MalioCheckbox', () => {
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||
const wrapper = mountCheckbox({label: 'Accept terms'})
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
|
||||
await wrapper.get('input').setValue(true)
|
||||
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useAttrs, useId} from 'vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
|
||||
@@ -80,9 +80,11 @@ const props = withDefaults(
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localChecked = ref(false)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
|
||||
const isChecked = computed(() => !!props.modelValue)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const isChecked = computed(() => (isControlled.value ? !!props.modelValue : localChecked.value))
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const disabled = computed(() => props.disabled)
|
||||
@@ -111,7 +113,7 @@ const mergedLabelClass = computed(() =>
|
||||
'cbx text-lg',
|
||||
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||
hasError.value ? 'text-m-error' : '',
|
||||
hasError.value ? 'text-m-danger' : '',
|
||||
hasSuccess.value ? 'text-m-success' : '',
|
||||
props.labelClass,
|
||||
),
|
||||
@@ -121,7 +123,7 @@ const mergedMessageClass = computed(() =>
|
||||
twMerge(
|
||||
'text-xs',
|
||||
hasError.value
|
||||
? 'text-m-error'
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
@@ -140,6 +142,10 @@ const onChange = (event: Event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled.value) {
|
||||
localChecked.value = target.checked
|
||||
}
|
||||
|
||||
emit('update:modelValue', target.checked)
|
||||
}
|
||||
</script>
|
||||
@@ -205,14 +211,14 @@ const onChange = (event: Event) => {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.inp-cbx + .cbx.text-m-error span:first-child {
|
||||
border-color: rgb(var(--m-error) / 1);
|
||||
.inp-cbx + .cbx.text-m-danger span:first-child {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
}
|
||||
.cbx.text-m-error span:first-child svg {
|
||||
stroke: rgb(var(--m-error) / 1);
|
||||
.cbx.text-m-danger span:first-child svg {
|
||||
stroke: rgb(var(--m-danger) / 1);
|
||||
}
|
||||
.inp-cbx:checked + .cbx.text-m-error span:first-child {
|
||||
border-color: rgb(var(--m-error) / 1);
|
||||
.inp-cbx:checked + .cbx.text-m-danger span:first-child {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
}
|
||||
|
||||
.inp-cbx + .cbx.text-m-success span:first-child {
|
||||
|
||||
@@ -279,7 +279,7 @@ describe('MalioInputText', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
||||
expect(wrapper.get('label').classes()).toContain('left-11')
|
||||
})
|
||||
|
||||
it('passes icon size props to icon component', () => {
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('MalioInputAmount', () => {
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||
expect(wrapper.get('input').classes()).toContain('!pl-11')
|
||||
expect(wrapper.get('label').classes()).toContain('left-8')
|
||||
expect(wrapper.get('label').classes()).toContain('left-11')
|
||||
})
|
||||
|
||||
it('shows primary icon color on focus', async () => {
|
||||
|
||||
@@ -135,7 +135,7 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
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',
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent 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
|
||||
@@ -216,7 +216,7 @@ const iconInputPaddingClass = computed(() => {
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
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',
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent 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
|
||||
@@ -185,7 +185,7 @@ const iconInputPaddingClass = computed(() => {
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
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',
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent 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
|
||||
|
||||
308
app/components/malio/input/InputPhone.test.ts
Normal file
308
app/components/malio/input/InputPhone.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import InputPhone from './InputPhone.vue'
|
||||
|
||||
type InputPhoneProps = {
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
modelValue?: string | null
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
mask?: string
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
}
|
||||
|
||||
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
|
||||
|
||||
const mountComponent = (props: InputPhoneProps = {}) =>
|
||||
mount(InputPhoneForTest, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('MalioInputPhone', () => {
|
||||
it('renders the initial input value', () => {
|
||||
const wrapper = mountComponent({modelValue: '+33 6 12 34 56 78'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('+33 6 12 34 56 78')
|
||||
})
|
||||
|
||||
it('renders the label text', () => {
|
||||
const wrapper = mountComponent({label: 'Téléphone'})
|
||||
|
||||
expect(wrapper.get('label').text()).toBe('Téléphone')
|
||||
})
|
||||
|
||||
it('has type tel', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('input').attributes('type')).toBe('tel')
|
||||
})
|
||||
|
||||
it('has inputmode tel', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('input').attributes('inputmode')).toBe('tel')
|
||||
})
|
||||
|
||||
it('renders the default phone icon', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||
expect(iconComponent.props('icon')).toBe('mdi:phone-outline')
|
||||
})
|
||||
|
||||
it('allows overriding the icon', () => {
|
||||
const wrapper = mountComponent({iconName: 'mdi:cellphone'})
|
||||
|
||||
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||
expect(iconComponent.props('icon')).toBe('mdi:cellphone')
|
||||
})
|
||||
|
||||
it('does not render icon when iconName is empty', () => {
|
||||
const wrapper = mountComponent({iconName: ''})
|
||||
|
||||
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('places icon on the left by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
|
||||
})
|
||||
|
||||
it('places icon on the right when iconPosition is right', () => {
|
||||
const wrapper = mountComponent({iconPosition: 'right'})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on input change', async () => {
|
||||
const wrapper = mountComponent({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('+33612345678')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['+33612345678'])
|
||||
})
|
||||
|
||||
it('sets disabled styles when true', () => {
|
||||
const wrapper = mountComponent({disabled: true})
|
||||
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('sets readonly when true', () => {
|
||||
const wrapper = mountComponent({readonly: true})
|
||||
|
||||
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error message and styles', () => {
|
||||
const wrapper = mountComponent({error: 'Numéro invalide'})
|
||||
|
||||
expect(wrapper.get('p.text-m-danger').text()).toBe('Numéro invalide')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-danger')
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows error style on icon', () => {
|
||||
const wrapper = mountComponent({error: 'Error'})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('shows success message and styles', () => {
|
||||
const wrapper = mountComponent({success: 'Numéro valide'})
|
||||
|
||||
expect(wrapper.get('p.text-m-success').text()).toBe('Numéro valide')
|
||||
expect(wrapper.get('input').classes()).toContain('border-m-success')
|
||||
})
|
||||
|
||||
it('shows success style on icon', () => {
|
||||
const wrapper = mountComponent({success: 'Success'})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('shows default icon color when empty and unfocused', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('shows primary icon color on focus', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
|
||||
})
|
||||
|
||||
it('shows black icon color when filled and unfocused', () => {
|
||||
const wrapper = mountComponent({modelValue: '+33612345678'})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('keeps default icon color when disabled, even if filled', () => {
|
||||
const wrapper = mountComponent({modelValue: '+33612345678', disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('error overrides focus color on icon', async () => {
|
||||
const wrapper = mountComponent({error: 'Numéro invalide'})
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
|
||||
})
|
||||
|
||||
it('shows hint message', () => {
|
||||
const wrapper = mountComponent({hint: 'Format international recommandé'})
|
||||
|
||||
expect(wrapper.get('p.text-m-muted').text()).toBe('Format international recommandé')
|
||||
})
|
||||
|
||||
it('links label to input via for/id', () => {
|
||||
const wrapper = mountComponent({id: 'phone-field', label: 'Téléphone'})
|
||||
|
||||
expect(wrapper.get('input').attributes('id')).toBe('phone-field')
|
||||
expect(wrapper.get('label').attributes('for')).toBe('phone-field')
|
||||
})
|
||||
|
||||
it('generates an id when missing and reuses it on label', () => {
|
||||
const wrapper = mountComponent({label: 'Téléphone'})
|
||||
|
||||
const inputId = wrapper.get('input').attributes('id')
|
||||
|
||||
expect(inputId?.startsWith('malio-input-phone-')).toBe(true)
|
||||
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||
})
|
||||
|
||||
it('aria-invalid is false when no error', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
|
||||
})
|
||||
|
||||
it('uses autocomplete off by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('input').attributes('autocomplete')).toBe('off')
|
||||
})
|
||||
|
||||
it('allows overriding autocomplete', () => {
|
||||
const wrapper = mountComponent({autocomplete: 'tel'})
|
||||
|
||||
expect(wrapper.get('input').attributes('autocomplete')).toBe('tel')
|
||||
})
|
||||
|
||||
it('does not render add button by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders add button when addable is true', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits add event when add button is clicked', async () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not emit add when readonly', async () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when disabled', () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables add button when readonly', () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders the default add icon (mdi:plus)', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
const icons = wrapper.findAllComponents(IconifyIcon)
|
||||
const addIcon = icons[icons.length - 1]
|
||||
expect(addIcon.props('icon')).toBe('mdi:plus')
|
||||
})
|
||||
|
||||
it('allows overriding the add icon', () => {
|
||||
const wrapper = mountComponent({addable: true, addIconName: 'mdi:phone-plus'})
|
||||
|
||||
const icons = wrapper.findAllComponents(IconifyIcon)
|
||||
const addIcon = icons[icons.length - 1]
|
||||
expect(addIcon.props('icon')).toBe('mdi:phone-plus')
|
||||
})
|
||||
|
||||
it('exposes aria-label on add button', () => {
|
||||
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un autre numéro'})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un autre numéro')
|
||||
})
|
||||
|
||||
it('adds right padding to input when addable', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.get('input').classes()).toContain('!pr-10')
|
||||
})
|
||||
|
||||
it('applies mask via maska directive', async () => {
|
||||
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
|
||||
|
||||
await wrapper.get('input').setValue('33612345678')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||
})
|
||||
})
|
||||
274
app/components/malio/input/InputPhone.vue
Normal file
274
app/components/malio/input/InputPhone.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="mask"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
v-if="iconName"
|
||||
:icon="iconName"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
data-test="icon"
|
||||
:class="[iconStateClass, iconPositionClass]"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled || readonly"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 text-xs ml-[2px] ',
|
||||
]"
|
||||
>
|
||||
{{ hint || error || success }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
import {vMaska} from 'maska/vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioInputPhone', 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
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
iconName?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
mask?: string | MaskInputOptions
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
autocomplete: 'off',
|
||||
modelValue: undefined,
|
||||
iconName: 'mdi:phone-outline',
|
||||
iconPosition: 'left',
|
||||
label: '',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
required: false,
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
mask: undefined,
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter un numéro',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localValue = ref('')
|
||||
const isFocused = ref(false)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${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 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 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-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
: 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-danger'
|
||||
: 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 mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70',
|
||||
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
|
||||
const describedBy = computed(() => {
|
||||
const ids: string[] = []
|
||||
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||
if (hasError.value) ids.push(`${inputId.value}-error`)
|
||||
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
|
||||
return ids.length ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!isControlled.value) {
|
||||
localValue.value = target.value
|
||||
}
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
const leftIcon = props.iconName && props.iconPosition === 'left'
|
||||
const rightIcon = props.iconName && props.iconPosition === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
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-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (disabled.value) return props.iconColor
|
||||
if (isFocused.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return props.iconColor
|
||||
})
|
||||
</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>
|
||||
@@ -140,7 +140,7 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
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',
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent 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
|
||||
@@ -196,7 +196,7 @@ const iconInputPaddingClass = computed(() => {
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-8'
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:name="name"
|
||||
|
||||
:autocomplete="autocomplete"
|
||||
class="floating-input peer w-full border-2 bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
|
||||
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
|
||||
:class="[
|
||||
isFilled ? 'border-black' : 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||
|
||||
@@ -126,7 +126,7 @@ const mergedGroupClass = computed(() =>
|
||||
)
|
||||
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',
|
||||
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent 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-pointer',
|
||||
hasError.value
|
||||
|
||||
@@ -172,4 +172,14 @@ describe('MalioRadioButton', () => {
|
||||
|
||||
expect(wrapper.get('input').classes()).toContain('checked:border-black')
|
||||
})
|
||||
|
||||
it('updates label color when toggled without v-model (uncontrolled)', async () => {
|
||||
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
|
||||
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
|
||||
|
||||
await wrapper.get('input').trigger('change')
|
||||
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useAttrs, useId} from 'vue'
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||
@@ -86,9 +86,13 @@ const props = withDefaults(
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localValue = ref<string | number | boolean | null | undefined>(undefined)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
||||
const isChecked = computed(() => props.modelValue === props.value)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const isChecked = computed(() =>
|
||||
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
|
||||
)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const disabled = computed(() => props.disabled)
|
||||
@@ -161,6 +165,10 @@ const onChange = (event: Event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = props.value
|
||||
}
|
||||
|
||||
emit('update:modelValue', props.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,24 +7,24 @@
|
||||
<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="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary"
|
||||
:class="[
|
||||
hasError
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
|
||||
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
||||
: 'border-m-danger'
|
||||
: 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'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
||||
: 'rounded-t-none !border !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'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
@@ -98,7 +98,7 @@
|
||||
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="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
|
||||
:class="[
|
||||
openDirection === 'down'
|
||||
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||
|
||||
@@ -7,24 +7,24 @@
|
||||
<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="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary"
|
||||
:class="[
|
||||
hasError
|
||||
? isOpen
|
||||
? openDirection === 'down'
|
||||
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
|
||||
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
|
||||
? 'rounded-b-none !border !border-m-danger !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-danger !border-t-0'
|
||||
: 'border-m-danger'
|
||||
: 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'
|
||||
? 'rounded-b-none !border !border-m-success !border-b-0'
|
||||
: 'rounded-t-none !border !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'
|
||||
? 'rounded-b-none !border !border-m-primary !border-b-0'
|
||||
: 'rounded-t-none !border !border-m-primary !border-t-0'
|
||||
: isOptionSelected
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
@@ -126,7 +126,7 @@
|
||||
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="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
|
||||
:class="[
|
||||
openDirection === 'down'
|
||||
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
|
||||
|
||||
@@ -197,11 +197,11 @@ const mergedInputClass = (field: 'hours' | 'minutes') =>
|
||||
'h-[30px] w-10 border bg-white text-center text-[18px] outline-none rounded-md placeholder:text-m-muted',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'focus:border-2 border-m-danger focus:border-m-danger'
|
||||
? 'border-m-danger focus:border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'focus:border-2 border-m-success focus:border-m-success'
|
||||
? 'border-m-success focus:border-m-success'
|
||||
: activeField.value === field
|
||||
? 'border-2 border-m-primary text-m-primary'
|
||||
? 'border-m-primary text-m-primary'
|
||||
: 'border-black text-black',
|
||||
props.inputClass,
|
||||
)
|
||||
|
||||
285
app/story/input/inputPhone.story.vue
Normal file
285
app/story/input/inputPhone.story.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<Story title="Input/Phone">
|
||||
<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>
|
||||
<MalioInputPhone
|
||||
v-model="simpleValue"
|
||||
label="Téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputPhone
|
||||
v-model="addableValue"
|
||||
label="Téléphone"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icône à droite</h2>
|
||||
<MalioInputPhone
|
||||
v-model="rightIconValue"
|
||||
label="Téléphone"
|
||||
icon-position="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputPhone
|
||||
v-model="noIconValue"
|
||||
label="Téléphone"
|
||||
:icon-name="''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec masque français</h2>
|
||||
<MalioInputPhone
|
||||
v-model="maskedValue"
|
||||
label="Téléphone (FR)"
|
||||
mask="+33 # ## ## ## ##"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputPhone
|
||||
v-model="hintValue"
|
||||
label="Téléphone"
|
||||
hint="Format international recommandé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé (avec addable)</h2>
|
||||
<MalioInputPhone
|
||||
v-model="disabledValue"
|
||||
label="Téléphone"
|
||||
addable
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
|
||||
<MalioInputPhone
|
||||
v-model="readonlyValue"
|
||||
label="Téléphone"
|
||||
addable
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioInputPhone
|
||||
v-model="errorValue"
|
||||
label="Téléphone"
|
||||
error="Numéro de téléphone invalide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioInputPhone
|
||||
v-model="successValue"
|
||||
label="Téléphone"
|
||||
success="Numéro valide"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<docs lang="md">
|
||||
# MalioInputPhone
|
||||
|
||||
Champ téléphone avec label flottant, icône phone par défaut (à gauche),
|
||||
états visuels (erreur / succès), accessibilité et bouton « + » optionnel
|
||||
pour gérer une liste de numéros côté parent (`type="tel"` +
|
||||
`inputmode="tel"`).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Props détaillées
|
||||
|
||||
### id
|
||||
|
||||
- Type: string
|
||||
- Description: Identifiant HTML de l'input.
|
||||
- Comportement: Si non fourni, un id unique est généré automatiquement
|
||||
(préfixe `malio-input-phone-`).
|
||||
|
||||
### label
|
||||
|
||||
- Type: string
|
||||
- Description: Texte affiché comme label flottant.
|
||||
|
||||
### name
|
||||
|
||||
- Type: string
|
||||
- Description: Attribut name de l'input (utile pour les formulaires).
|
||||
|
||||
### autocomplete
|
||||
|
||||
- Type: string
|
||||
- Défaut: `off`
|
||||
- Description: Active ou configure l'autocomplétion navigateur. La
|
||||
valeur par défaut est `off` pour les formulaires de création d'ERP.
|
||||
Passer `tel` pour permettre au navigateur de suggérer un numéro
|
||||
enregistré.
|
||||
|
||||
### modelValue
|
||||
|
||||
- Type: string | null | undefined
|
||||
- Description: Valeur contrôlée du composant.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Apparence & Style
|
||||
|
||||
### inputClass / labelClass / groupClass
|
||||
|
||||
- Type: string
|
||||
- Description: Classes CSS appliquées respectivement à l'input, au
|
||||
label et au conteneur.
|
||||
|
||||
### mask
|
||||
|
||||
- Type: string | MaskInputOptions
|
||||
- Description: Masque maska à appliquer. Aucun masque par défaut —
|
||||
les formats téléphoniques varient trop selon les pays. À activer
|
||||
pour un usage mono-pays.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Validation & Contraintes
|
||||
|
||||
### required / disabled / readonly
|
||||
|
||||
- Type: boolean
|
||||
- Description: Attributs HTML standards.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## États & Messages
|
||||
|
||||
### hint / error / success
|
||||
|
||||
- Type: string
|
||||
- Description: Messages affichés sous le champ.
|
||||
- `error` est prioritaire sur `success` et active `aria-invalid`.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Icône
|
||||
|
||||
### iconName
|
||||
|
||||
- Type: string
|
||||
- Défaut: `mdi:phone-outline`
|
||||
- Description: Nom Iconify de l'icône affichée. Passer une chaîne
|
||||
vide pour ne pas afficher d'icône.
|
||||
|
||||
### iconPosition
|
||||
|
||||
- Type: `'left' | 'right'`
|
||||
- Défaut: `left` (laisse la droite libre pour le bouton +).
|
||||
|
||||
### iconSize / iconColor
|
||||
|
||||
- Type: number / string
|
||||
- Défaut: `24` / `text-m-muted`
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Bouton « ajouter »
|
||||
|
||||
### addable
|
||||
|
||||
- Type: boolean
|
||||
- Défaut: `false`
|
||||
- Description: Affiche un bouton à droite du champ. Au clic, le
|
||||
composant émet l'event `add` — c'est au parent de gérer l'ajout
|
||||
d'un nouveau champ téléphone.
|
||||
|
||||
### addIconName
|
||||
|
||||
- Type: string
|
||||
- Défaut: `mdi:plus`
|
||||
- Description: Nom Iconify de l'icône du bouton d'ajout.
|
||||
|
||||
### addButtonLabel
|
||||
|
||||
- Type: string
|
||||
- Défaut: `Ajouter un numéro`
|
||||
- Description: Attribut `aria-label` du bouton (accessibilité).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Comportement
|
||||
|
||||
- Aucune validation interne — le composant ne vérifie pas le format
|
||||
du numéro. Piloter `error` / `success` depuis le parent.
|
||||
- `inputmode="tel"` adapte le clavier mobile.
|
||||
- Le bouton `+` est désactivé quand `disabled` ou `readonly` est
|
||||
actif et n'émet pas l'event dans ce cas.
|
||||
|
||||
## Priorité visuelle
|
||||
|
||||
1. error
|
||||
2. success
|
||||
3. neutre
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Accessibilité
|
||||
|
||||
- `aria-invalid` activé si `error` existe.
|
||||
- `aria-describedby` référence dynamiquement le message affiché.
|
||||
- Le bouton `+` expose un `aria-label` configurable.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Events
|
||||
|
||||
### update:modelValue
|
||||
|
||||
- Émis à chaque modification de l'input. Permet l'utilisation avec
|
||||
v-model.
|
||||
|
||||
### add
|
||||
|
||||
- Émis au clic du bouton `+` (uniquement si `addable` est vrai et
|
||||
que le champ n'est ni `disabled` ni `readonly`).
|
||||
|
||||
</docs>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioInputPhone from '../../components/malio/input/InputPhone.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const addableValue = ref('')
|
||||
const rightIconValue = ref('')
|
||||
const noIconValue = ref('')
|
||||
const maskedValue = ref('')
|
||||
const hintValue = ref('')
|
||||
const disabledValue = ref('+33 6 12 34 56 78')
|
||||
const readonlyValue = ref('+33 6 12 34 56 78')
|
||||
const errorValue = ref('abc')
|
||||
const successValue = ref('+33 6 12 34 56 78')
|
||||
|
||||
const addClicks = ref(0)
|
||||
const onAdd = () => {
|
||||
addClicks.value++
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user