[#MUI-32] Création d'un composant saisie assistée (autocomplete) (#46)

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #46
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #46.
This commit is contained in:
2026-05-13 06:59:13 +00:00
committed by Autin
parent 9ed094ba86
commit b2e3a83bb9
12 changed files with 1949 additions and 75 deletions

View File

@@ -1,75 +1,182 @@
<template> <template>
<div> <div class="flex justify-center">
<div class="flex justify-center mt-16"> <div class="w-[1348px]">
<div> <div class="flex gap-3 mt-[46px]">
<div class="w-[1348px] grid grid-cols-3 gap-x-[80px] gap-y-8"> <MalioButtonIcon
<MalioInputText icon="mdi:arrow-left-bold"
label="Nom du client (Entreprise)" icon-size="24"
/> aria-label="Précédent"
<MalioInputText variant="ghost"
label="Nom du contact principal" />
/> <h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
<MalioInputText </div>
label="Prénom du contact principal" <div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8">
/> <MalioInputText
<MalioSelectCheckbox label="Nom du client (Entreprise)"
v-model="multiselectValue" />
label="Catégorie" <MalioInputText
:options="[ 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-12 flex justify-center">
<MalioButton label="Valider" variant="primary"/>
</div>
<div class="mt-[60px]">
<MalioTabList :tabs="tabs" v-model="tabsValue">
<template #information>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioInputText label="Date création"/>
<MalioInputText label="Nombre de salariés" />
<MalioInputAmount label="CA"/>
<MalioInputText label="Dirigeant" />
<MalioInputText label="Résultat" />
</div>
<div class="mt-12 flex justify-center">
<MalioButton label="Valider" variant="primary"/>
</div>
</template>
<template #adresses>
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer l'adresse"
variant="ghost"
button-class="absolute top-3 right-3"
@click="onDeleteAdresse"
/>
<MalioCheckbox label="Prospect" groupClass="self-center"/>
<MalioCheckbox label="Adresse de livraison" groupClass="self-center"/>
<MalioCheckbox label="Facturation" groupClass="self-center"/>
<MalioSelectCheckbox
v-model="multiselectValue"
label="Catégorie"
:options="[
{label: 'Catégorie 1', value: 'Catégorie 1'}, {label: 'Catégorie 1', value: 'Catégorie 1'},
{label: 'Catégorie 2', value: 'Catégorie 2'} {label: 'Catégorie 2', value: 'Catégorie 2'}
]" ]"
/> />
<MalioInputPhone <MalioSelect
v-for="(_, index) in phones" label="Pays"
:key="index" v-model="pays"
v-model="phones[index]" :options="[
label="Téléphone" {label: 'France', value: 'France'},
add-icon-name="mdi:plus" {label: 'Espagne', value: 'Espagne'}
:addable="phones.length === 1" ]"/>
@add="addPhoneInput" <MalioInputText v-model="codePostal" label="Code postal" />
/> <MalioSelect
<MalioInputEmail v-model="ville"
label="Email" label="Ville"
/> :options="villeOptions"
<MalioSelect :no-options-text="villeNoOptionsText"
v-model="distributeur" />
value="" <MalioInputAutocomplete
label="Distributeur / Courtier" v-model="adresse"
:options="[ label="Adresse"
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'}, :options="adresseOptions"
{label: 'Distributeur', value: 'Distributeur'}, :loading="adresseLoading"
{label: 'Courtier', value: 'Courtier'}, :min-search-length="2"
]" :no-results-text="adresseNoResultsText"
/> :min-search-text="adresseMinSearchText"
<MalioSelect @search="onSearchAdresse"
v-model="nomCourtier" />
value="" <MalioInputText label="Adresse complémentaire"/>
label="Nom du courtier" <div class="flex justify-between">
:options="[ <MalioCheckbox
{label: 'Nom 1', value: 'Nom 1'} v-for="dep in departements"
]" :key="dep"
/> v-model="departementsSelected[dep]"
<MalioSelect :label="dep"
v-model="nomDistributeur" group-class="w-auto self-center"
value="" />
label="Nom du distributeur" </div>
:options="[ <MalioSelect label="Contact" :options="[]"/>
{label: 'Nom 1', value: 'Nom 1'} <MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
]" </div>
/> <div class="mt-12 flex justify-center gap-6">
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/> <MalioButton label="Nouvelle Adresse" variant="secondary" icon-name="mdi:add-bold" icon-position="left"/>
</div> <MalioButton label="Valider" variant="primary"/>
<div class="mt-16 flex justify-center"> </div>
<MalioButton label="Valider" variant="primary"/> </template>
</div> </MalioTabList>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref} from 'vue' import {ref, computed, watch} from 'vue'
type Commune = {
nom: string
code: string
codesPostaux: string[]
}
type BanFeature = {
properties: {
label: string
id: string
name: string
housenumber?: string
street?: string
postcode: string
citycode: string
city: string
}
}
const multiselectValue = ref<Array<string | number>>([]) const multiselectValue = ref<Array<string | number>>([])
const distributeur = ref<string>('') const distributeur = ref<string>('')
@@ -80,4 +187,131 @@ const nomCourtier = ref<string>('')
function addPhoneInput() { function addPhoneInput() {
phones.value.push('') phones.value.push('')
} }
function onDeleteAdresse() {
console.log('Supprimer cette adresse')
}
const departements = ['86', '17', '82']
const departementsSelected = ref<Record<string, boolean>>({86: false, 17: false, 82: false})
const pays = ref<string>('France')
const codePostal = ref<string>('')
const ville = ref<string | number | null>(null)
const villeOptions = ref<Array<{label: string; value: string}>>([])
const villeLoading = ref(false)
const villeNoOptionsText = computed(() => {
if (villeLoading.value) return 'Chargement…'
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir un code postal (5 chiffres)'
return 'Aucune ville pour ce code postal'
})
let villeFetchId = 0
watch(codePostal, async (cp) => {
ville.value = null
villeOptions.value = []
adresse.value = null
adresseOptions.value = []
if (!/^\d{5}$/.test(cp)) {
villeLoading.value = false
return
}
const requestId = ++villeFetchId
villeLoading.value = true
try {
const response = await fetch(`https://geo.api.gouv.fr/communes?codePostal=${cp}`)
const data = await response.json() as Commune[]
if (requestId !== villeFetchId) return
villeOptions.value = data.map(c => ({label: c.nom, value: c.code}))
} catch (err) {
if (requestId !== villeFetchId) return
villeOptions.value = []
console.error('Erreur lors du chargement des villes', err)
} finally {
if (requestId === villeFetchId) villeLoading.value = false
}
})
const adresse = ref<string | number | null>(null)
const adresseOptions = ref<Array<{label: string; value: string}>>([])
const adresseLoading = ref(false)
const adresseMinSearchText = computed(() => {
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
return 'Tapez au moins 3 caractères'
})
const adresseNoResultsText = computed(() => {
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
return 'Aucune adresse trouvée'
})
let adresseFetchId = 0
const onSearchAdresse = async (query: string) => {
if (!/^\d{5}$/.test(codePostal.value) || query.length < 3) {
adresseOptions.value = []
adresseLoading.value = false
return
}
const requestId = ++adresseFetchId
adresseLoading.value = true
try {
const params = new URLSearchParams({
q: query,
postcode: codePostal.value,
type: 'housenumber',
})
const response = await fetch(`https://api-adresse.data.gouv.fr/search/?${params.toString()}`)
const data = await response.json() as {features: BanFeature[]}
if (requestId !== adresseFetchId) return
adresseOptions.value = data.features.map(f => ({
label: f.properties.name,
value: f.properties.name,
}))
} catch (err) {
if (requestId !== adresseFetchId) return
adresseOptions.value = []
console.error('Erreur lors du chargement des adresses', err)
} finally {
if (requestId === adresseFetchId) adresseLoading.value = false
}
}
const tabsValue = ref('information')
const concurrent = ref('')
const informationValid = computed(() => concurrent.value.trim().length > 0)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
const tabs = computed(() => [
{
key: 'information',
label: 'Information',
icon: 'mdi:account-outline',
},
{
key: 'contacts',
label: 'Contacts',
icon: 'mdi:account-box-plus-outline',
disabled: !informationValid.value,
},
{
key: 'adresses',
label: 'Adresses',
icon: 'mdi:map-marker-outline',
disabled: !informationValid.value,
},
{
key: 'transport',
label: 'Transport',
icon: 'mdi:truck-delivery-outline',
disabled: !informationValid.value || !adressesValid.value,
},
{
key: 'comptabilité',
label: 'Comptabilité',
icon: 'mdi:bank-circle-outline',
disabled: !informationValid.value || !adressesValid.value,
},
])
</script> </script>

View File

@@ -0,0 +1,180 @@
<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 (statique)</h2>
<MalioInputAutocomplete
v-model="simpleValue"
label="Pays"
:options="staticOptions"
/>
<p class="mt-2 text-sm text-m-muted">
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
<MalioInputAutocomplete
v-model="leftIconValue"
label="Recherche"
icon-name="mdi:magnify"
icon-position="left"
:options="staticOptions"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Branché sur une API (simulé)</h2>
<p class="mb-3 text-sm text-m-muted">
Le parent écoute l'event <code>search</code> et alimente <code>options</code> + <code>loading</code>.
Tapez au moins 2 caractères.
</p>
<MalioInputAutocomplete
v-model="apiValue"
label="Client"
:options="apiOptions"
:loading="apiLoading"
:min-search-length="2"
icon-name="mdi:magnify"
icon-position="left"
@search="onSearchApi"
@select="onSelectApi"
/>
<p v-if="apiSelected" class="mt-2 text-sm text-m-muted">
Sélection : <code>{{ apiSelected.label }} (id={{ apiSelected.value }})</code>
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
<MalioInputAutocomplete
v-model="createValue"
label="Catégorie"
:options="staticOptions"
allow-create
hint="Taper Entrée pour créer une nouvelle valeur"
@create="onCreate"
/>
<p v-if="createdItems.length > 0" class="mt-2 text-sm text-m-muted">
Créés : {{ createdItems.join(', ') }}
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputAutocomplete
v-model="hintValue"
label="Pays"
:options="staticOptions"
hint="Sélectionne un pays dans la liste"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
error="Sélection invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
success="Sélection valide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Liste vide</h2>
<MalioInputAutocomplete
v-model="emptyValue"
label="Recherche"
:options="[]"
no-results-text="Aucun élément disponible"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const staticOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
{label: 'Espagne', value: 'es'},
{label: 'Italie', value: 'it'},
]
const simpleValue = ref<string | number | null>(null)
const leftIconValue = ref<string | number | null>(null)
const createValue = ref<string | number | null>(null)
const hintValue = ref<string | number | null>(null)
const emptyValue = ref<string | number | null>(null)
const createdItems = ref<string[]>([])
const onCreate = (value: string) => {
createdItems.value.push(value)
}
const apiValue = ref<string | number | null>(null)
const apiOptions = ref<Option[]>([])
const apiLoading = ref(false)
const apiSelected = ref<Option | null>(null)
const fakeClients: Option[] = [
{label: 'Yuno Malio', value: 1},
{label: 'Yuna Corp', value: 2},
{label: 'Yum Foods', value: 3},
{label: 'Yumi Studio', value: 4},
{label: 'Acme Inc.', value: 5},
{label: 'Globex Corp', value: 6},
{label: 'Initech', value: 7},
{label: 'Soylent Corp', value: 8},
]
const onSearchApi = async (query: string) => {
apiLoading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
apiOptions.value = fakeClients.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase()),
)
apiLoading.value = false
}
const onSelectApi = (option: Option | null) => {
apiSelected.value = option
}
</script>

View File

@@ -29,6 +29,7 @@ Liste des évolutions de la librairie Malio layer UI
* Création d'un composant rich text (TipTap) avec sortie markdown / HTML * Création d'un composant rich text (TipTap) avec sortie markdown / HTML
* [#MUI-30] Création d'un composant email * [#MUI-30] Création d'un composant email
* [#MUI-31] Création d'un composant téléphone * [#MUI-31] Création d'un composant téléphone
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
### Changed ### Changed

View File

@@ -144,6 +144,82 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
--- ---
## MalioInputAutocomplete
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| number \| null` | `undefined` | Valeur sélectionnée (v-model) |
| `name` | `string` | `''` | Attribut name |
| `options` | `{label: string; value: string\|number}[]` | `[]` | Liste affichée dans le dropdown |
| `loading` | `boolean` | `false` | Affiche un spinner + un message de chargement |
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
| `iconName` | `string` | `''` | Icône Iconify décorative |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur de l'icône |
| `noResultsText` | `string` | `'Aucun résultat'` | Texte affiché quand `options` est vide |
| `loadingText` | `string` | `'Chargement…'` | Texte affiché pendant le chargement |
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
| `required` | `boolean` | `false` | Champ requis |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
| `success` | `string` | `''` | Message de succès |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
**Events :**
- `update:modelValue(value: string \| number \| null)` — valeur sélectionnée (v-model)
- `search(query: string)` — émis (après debounce + minSearchLength) avec le texte tapé ; le parent l'écoute pour lancer son fetch API
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
```vue
<!-- Usage statique -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
<!-- Usage API (parent gère le fetch) -->
<MalioInputAutocomplete
v-model="clientId"
label="Client"
:options="clientOptions"
:loading="isFetching"
:min-search-length="2"
@search="onSearchClients"
@select="onSelectClient"
/>
<!-- Avec création libre -->
<MalioInputAutocomplete
v-model="category"
label="Catégorie"
:options="categories"
allow-create
@create="onCreateCategory"
/>
```
```ts
async function onSearchClients(query: string) {
isFetching.value = true
const res = await $fetch('/api/clients', {params: {q: query}})
clientOptions.value = res.map(c => ({label: c.name, value: c.id}))
isFetching.value = false
}
```
---
## MalioInputAmount ## MalioInputAmount
Champ montant avec icône devise (euro par défaut). Champ montant avec icône devise (euro par défaut).
@@ -200,6 +276,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `showCounter` | `boolean` | `false` | Afficher le compteur | | `showCounter` | `boolean` | `false` | Afficher le compteur |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
**Events :** `update:modelValue(value: string)` **Events :** `update:modelValue(value: string)`
@@ -285,6 +362,7 @@ Liste déroulante.
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton | | `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur | | `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label | | `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: string | number | null)` **Events :** `update:modelValue(value: string | number | null)`
**Slots :** `icon` (icône dropdown custom) **Slots :** `icon` (icône dropdown custom)
@@ -310,6 +388,7 @@ Liste déroulante multi-sélection avec checkboxes.
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global | | `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: (string | number)[])` **Events :** `update:modelValue(value: (string | number)[])`
@@ -440,18 +519,42 @@ Navigation par onglets avec contenu dynamique.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) | | `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
| `tabs` | `{ key: string, label: string, icon?: string }[]` | **requis** | Liste des onglets | | `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
**Events :** `update:modelValue(value: string)` Type `Tab` :
| Propriété | Type | Défaut | Description |
|-----------|------|--------|-------------|
| `key` | `string` | — | Identifiant unique (utilisé pour le slot et le v-model) |
| `label` | `string` | — | Texte de l'onglet |
| `icon` | `string` | — | Nom Iconify (optionnel) |
| `iconSize` | `string` | `24` | Taille de l'icône |
| `disabled` | `boolean` | `false` | Onglet désactivé : grisé et non cliquable. Le parent calcule cet état selon sa logique de validation |
**Events :** `update:modelValue(value: string)` — émis uniquement quand l'onglet cible n'est pas `disabled`
**Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet **Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet
```vue ```vue
<MalioTabList v-model="activeTab" :tabs="[{ key: 'infos', label: 'Informations' }, { key: 'docs', label: 'Documents', icon: 'mdi:file' }]"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #infos>Contenu infos</template> <template #infos>Contenu infos</template>
<template #docs>Contenu docs</template> <template #docs>Contenu docs</template>
</MalioTabList> </MalioTabList>
``` ```
**Pattern de gating progressif** (déverrouille les onglets quand les précédents sont valides) :
```ts
const informationValid = computed(() => name.value && email.value)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
const tabs = computed(() => [
{ key: 'information', label: 'Information' },
{ key: 'contacts', label: 'Contacts', disabled: !informationValid.value },
{ key: 'adresses', label: 'Adresses', disabled: !informationValid.value },
{ key: 'transport', label: 'Transport', disabled: !informationValid.value || !adressesValid.value },
])
```
--- ---
## MalioSidebar ## MalioSidebar

View File

@@ -0,0 +1,430 @@
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import InputAutocomplete from './InputAutocomplete.vue'
type Option = {
label: string
value: string | number
}
type InputAutocompleteProps = {
id?: string
label?: string
name?: string
modelValue?: string | number | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
options?: Option[]
loading?: boolean
debounce?: number
minSearchLength?: number
allowCreate?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
noResultsText?: string
loadingText?: string
minSearchText?: string
}
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
const options: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
]
const mountComponent = (props: InputAutocompleteProps = {}) =>
mount(InputAutocompleteForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputAutocomplete', () => {
it('renders the label text', () => {
const wrapper = mountComponent({label: 'Pays'})
expect(wrapper.get('label').text()).toBe('Pays')
})
it('renders with type combobox role', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('role')).toBe('combobox')
})
it('renders input with provided modelValue label when option matches', () => {
const wrapper = mountComponent({modelValue: 'fr', options})
expect(wrapper.get('input').element.value).toBe('France')
})
it('opens dropdown on focus', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(true)
expect(wrapper.get('input').attributes('aria-expanded')).toBe('true')
})
it('does not open dropdown on focus when disabled', async () => {
const wrapper = mountComponent({options, disabled: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('does not open dropdown on focus when readonly', async () => {
const wrapper = mountComponent({options, readonly: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('renders all options in dropdown', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(3)
expect(items[0].text()).toBe('France')
expect(items[1].text()).toBe('Belgique')
expect(items[2].text()).toBe('Canada')
})
it('emits update:modelValue with option value when option is selected', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
})
it('emits select with full option object', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([options[0]])
})
it('closes dropdown after selecting an option', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('fills input with selected option label after selection', async () => {
const wrapper = mountComponent({options, modelValue: null})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[1].trigger('click')
await wrapper.setProps({modelValue: 'be'})
expect(wrapper.get('input').element.value).toBe('Belgique')
})
it('emits search after debounce when user types', async () => {
vi.useFakeTimers()
const wrapper = mountComponent({options, debounce: 300})
await wrapper.get('input').setValue('fra')
expect(wrapper.emitted('search')).toBeUndefined()
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')?.[0]).toEqual(['fra'])
vi.useRealTimers()
})
it('does not emit search until minSearchLength is reached', async () => {
vi.useFakeTimers()
const wrapper = mountComponent({minSearchLength: 3, debounce: 300})
await wrapper.get('input').setValue('fr')
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')).toBeUndefined()
await wrapper.get('input').setValue('fra')
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')?.[0]).toEqual(['fra'])
vi.useRealTimers()
})
it('shows minSearch text in dropdown when minSearchLength not reached', async () => {
const wrapper = mountComponent({minSearchLength: 3, minSearchText: 'Tapez 3 caractères'})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="min-search-text"]').text()).toBe('Tapez 3 caractères')
})
it('shows loading text in dropdown when loading', async () => {
const wrapper = mountComponent({loading: true, loadingText: 'En cours…'})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="loading-text"]').text()).toBe('En cours…')
})
it('shows loading icon when loading', async () => {
const wrapper = mountComponent({loading: true})
expect(wrapper.find('[data-test="loading-icon"]').exists()).toBe(true)
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('shows no results text when options is empty', async () => {
const wrapper = mountComponent({options: [], noResultsText: 'Rien trouvé'})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="no-results-text"]').text()).toBe('Rien trouvé')
})
it('clears selection when typing different value', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('belg')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null])
expect(wrapper.emitted('select')?.[0]).toEqual([null])
})
it('emits create event with typed value when allowCreate and Enter pressed', async () => {
const wrapper = mountComponent({options, allowCreate: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('Custom')
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('create')?.[0]).toEqual(['Custom'])
expect(wrapper.emitted('update:modelValue')?.some(e => e[0] === 'Custom')).toBe(true)
})
it('does not emit create when allowCreate is false', async () => {
const wrapper = mountComponent({options, allowCreate: false})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('Custom')
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('create')).toBeUndefined()
})
it('selects option on Enter with active index', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['fr'])
})
it('navigates options with ArrowDown', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
expect(wrapper.get('input').attributes('aria-activedescendant')).toContain('-option-1')
})
it('closes dropdown on Escape', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(true)
await wrapper.get('input').trigger('keydown', {key: 'Escape'})
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('reverts input value on Escape', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('xyz')
await wrapper.get('input').trigger('keydown', {key: 'Escape'})
expect(wrapper.get('input').element.value).toBe('France')
})
it('shows error message and styles', () => {
const wrapper = mountComponent({error: 'Champ invalide'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Champ invalide')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
})
it('shows success message and styles', () => {
const wrapper = mountComponent({success: 'Champ valide'})
expect(wrapper.get('p.text-m-success').text()).toBe('Champ valide')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows hint message', () => {
const wrapper = mountComponent({hint: 'Tapez pour rechercher'})
expect(wrapper.get('p.text-m-muted').text()).toBe('Tapez pour rechercher')
})
it('renders left icon when iconName provided with left position', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(true)
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
})
it('renders right icon when iconName provided with right position', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'right'})
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(true)
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
})
it('does not render icon when iconName is empty', () => {
const wrapper = mountComponent({iconName: ''})
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
})
it('uses left padding when icon is left', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.get('input').classes()).toContain('!pl-11')
})
it('uses extra right padding when icon is right', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'right'})
expect(wrapper.get('input').classes()).toContain('!pr-16')
})
it('renders the chevron with default icon', () => {
const wrapper = mountComponent()
const icons = wrapper.findAllComponents(IconifyIcon)
const chevron = icons[icons.length - 1]
expect(chevron.props('icon')).toBe('mdi:chevron-down')
})
it('rotates the chevron when dropdown is open', async () => {
const wrapper = mountComponent({options})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('rotate-0')
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('rotate-180')
})
it('sets disabled attribute', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('sets readonly attribute', () => {
const wrapper = mountComponent({readonly: true})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('links label to input via for/id', () => {
const wrapper = mountComponent({id: 'country', label: 'Pays'})
expect(wrapper.get('input').attributes('id')).toBe('country')
expect(wrapper.get('label').attributes('for')).toBe('country')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountComponent({label: 'Pays'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-autocomplete-')).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('marks the option matching modelValue as aria-selected', async () => {
const wrapper = mountComponent({options, modelValue: 'be'})
await wrapper.get('input').trigger('focus')
const items = wrapper.findAll('[data-test="option"]')
expect(items[0].attributes('aria-selected')).toBe('false')
expect(items[1].attributes('aria-selected')).toBe('true')
expect(items[2].attributes('aria-selected')).toBe('false')
})
it('updates inputValue when modelValue changes externally', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
expect(wrapper.get('input').element.value).toBe('France')
await wrapper.setProps({modelValue: 'ca'})
expect(wrapper.get('input').element.value).toBe('Canada')
})
it('clears inputValue when modelValue is cleared externally', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
expect(wrapper.get('input').element.value).toBe('France')
await wrapper.setProps({modelValue: null})
expect(wrapper.get('input').element.value).toBe('')
})
it('uses allowCreate modelValue as inputValue when no match in options', async () => {
const wrapper = mountComponent({options, allowCreate: true, modelValue: 'Custom'})
expect(wrapper.get('input').element.value).toBe('Custom')
})
})

View File

@@ -0,0 +1,513 @@
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:readonly="readonly"
:value="inputValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeOptionId"
role="combobox"
v-bind="attrs"
placeholder="_"
type="text"
@input="onInput"
@focus="onFocus"
@click="onInputClick"
@keydown="onKeydown"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName && iconPosition === 'left'"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon-left"
:class="[iconStateClass, 'pointer-events-none absolute left-[10px] top-1/2 -translate-y-1/2']"
/>
<div class="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<IconifyIcon
v-if="iconName && iconPosition === 'right'"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon-right"
:class="[iconStateClass]"
/>
<IconifyIcon
v-if="loading"
icon="mdi:loading"
:width="20"
:height="20"
data-test="loading-icon"
class="animate-spin text-m-primary"
/>
<IconifyIcon
v-else
icon="mdi:chevron-down"
:width="20"
:height="20"
data-test="chevron"
class="transition-transform duration-300"
:class="[
isOpen ? 'rotate-180' : 'rotate-0',
chevronColorClass,
]"
/>
</div>
<ul
v-if="isOpen"
:id="listboxId"
ref="listRef"
data-test="dropdown"
role="listbox"
:aria-labelledby="inputId"
class="absolute left-0 right-0 top-[calc(100%-4px)] z-20 max-h-60 w-full overflow-auto rounded-b-md border border-t-0 bg-white"
:class="[
hasError
? 'border-m-danger select-scrollbar-error'
: hasSuccess
? 'border-m-success select-scrollbar-success'
: 'border-m-primary select-scrollbar-primary',
]"
>
<li
v-if="loading"
class="px-3 py-2 text-m-muted"
data-test="loading-text"
>
{{ loadingText }}
</li>
<li
v-else-if="showMinSearch"
class="px-3 py-2 text-m-muted"
data-test="min-search-text"
>
{{ minSearchText }}
</li>
<li
v-else-if="options.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-results-text"
>
{{ noResultsText }}
</li>
<template v-else>
<li
v-for="(opt, index) in options"
:id="optionId(index)"
:key="String(opt.value)"
data-test="option"
role="option"
:aria-selected="opt.value === modelValue"
class="cursor-pointer px-3 py-2 text-black"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
@click="onSelect(opt)"
>
{{ opt.label || '\u00A0' }}
</li>
</template>
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ hint || error || success }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
type Option = {
label: string
value: string | number
}
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: string | number | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
options?: Option[]
loading?: boolean
debounce?: number
minSearchLength?: number
allowCreate?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
noResultsText?: string
loadingText?: string
minSearchText?: string
}>(),
{
id: '',
name: '',
modelValue: undefined,
inputClass: '',
labelClass: '',
groupClass: '',
label: '',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
options: () => [],
loading: false,
debounce: 300,
minSearchLength: 0,
allowCreate: false,
iconName: '',
iconPosition: 'left',
iconSize: 24,
iconColor: 'text-m-muted',
noResultsText: 'Aucun résultat',
loadingText: 'Chargement…',
minSearchText: 'Tapez pour rechercher',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void
(e: 'search' | 'create', value: string): void
(e: 'select', option: Option | null): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
const inputValue = ref<string>('')
const isFocused = ref(false)
const isOpen = ref(false)
const activeIndex = ref(-1)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const inputId = computed(() => props.id?.toString() || `malio-input-autocomplete-${generatedId}`)
const listboxId = computed(() => `${inputId.value}-listbox`)
const selectedOption = computed(() =>
props.options.find(o => o.value === props.modelValue) ?? null,
)
const hasSelection = computed(() =>
props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '',
)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)
const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
)
const optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() =>
activeIndex.value >= 0 && props.options[activeIndex.value]
? optionId(activeIndex.value)
: undefined,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(
[() => props.modelValue, () => props.options],
() => {
if (isFocused.value) return
if (selectedOption.value) {
inputValue.value = selectedOption.value.label
} else if (props.allowCreate && typeof props.modelValue === 'string' && props.modelValue !== '') {
inputValue.value = props.modelValue
} else if (!hasSelection.value) {
inputValue.value = ''
}
},
{immediate: true},
)
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const iconInputPaddingClass = computed(() => {
const parts: string[] = []
if (props.iconName && props.iconPosition === 'left') parts.push('!pl-11')
const hasCustomRight = !!props.iconName && props.iconPosition === 'right'
if (hasCustomRight) parts.push('!pr-16')
else parts.push('!pr-10')
return parts.join(' ')
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const labelPositionClass = computed(() =>
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
)
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',
props.disabled
? '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',
isOpen.value ? '!rounded-b-none !border-b-0' : '',
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' : '',
props.disabled ? '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 iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return props.iconColor
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
})
const chevronColorClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
const scheduleSearch = () => {
if (debounceTimer) clearTimeout(debounceTimer)
if (showMinSearch.value) return
debounceTimer = setTimeout(() => {
emit('search', inputValue.value)
}, props.debounce)
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
inputValue.value = target.value
if (!isOpen.value) isOpen.value = true
activeIndex.value = -1
if (hasSelection.value && target.value !== selectedOption.value?.label) {
emit('update:modelValue', null)
emit('select', null)
}
scheduleSearch()
}
const onFocus = () => {
if (props.disabled || props.readonly) return
isFocused.value = true
isOpen.value = true
}
const onInputClick = () => {
if (props.disabled || props.readonly) return
isOpen.value = true
}
const onSelect = (option: Option) => {
inputValue.value = option.label
activeIndex.value = -1
emit('update:modelValue', option.value)
emit('select', option)
isOpen.value = false
isFocused.value = false
}
const closeAndCommit = () => {
if (
props.allowCreate
&& inputValue.value !== ''
&& inputValue.value !== selectedOption.value?.label
) {
emit('update:modelValue', inputValue.value)
emit('create', inputValue.value)
} else if (selectedOption.value) {
inputValue.value = selectedOption.value.label
} else if (!props.allowCreate) {
inputValue.value = ''
}
isOpen.value = false
isFocused.value = false
}
const closeAndRevert = () => {
if (selectedOption.value) {
inputValue.value = selectedOption.value.label
} else {
inputValue.value = ''
}
isOpen.value = false
isFocused.value = false
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
closeAndRevert()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
onSelect(props.options[activeIndex.value])
return
}
if (props.allowCreate && inputValue.value !== '') {
emit('update:modelValue', inputValue.value)
emit('create', inputValue.value)
isOpen.value = false
isFocused.value = false
}
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!isOpen.value) {
isOpen.value = true
}
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
}
}
const onClickOutside = (event: MouseEvent) => {
if (!root.value) return
if (!root.value.contains(event.target as Node)) {
closeAndCommit()
}
}
onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside)
if (debounceTimer) clearTimeout(debounceTimer)
})
</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;
}
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
}
: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,7 +1,5 @@
<template> <template>
<div <div :class="mergedGroupClass">
class="relative w-full"
>
<textarea <textarea
:id="inputId" :id="inputId"
:name="name" :name="name"
@@ -81,6 +79,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false}) defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
@@ -108,6 +107,7 @@ const props = withDefaults(
error?: string error?: string
success?: string success?: string
rounded?: string rounded?: string
groupClass?: string
}>(), }>(),
{ {
@@ -133,9 +133,14 @@ const props = withDefaults(
maxResizeWidth: 640, maxResizeWidth: 640,
minResizeHeight: 40, minResizeHeight: 40,
maxResizeHeight: 320, maxResizeHeight: 320,
groupClass: '',
}, },
) )
const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.groupClass),
)
const attrs = useAttrs() const attrs = useAttrs()
const generatedId = useId() const generatedId = useId()
const localValue = ref('') const localValue = ref('')

View File

@@ -6,6 +6,7 @@
> >
<button <button
:id="buttonId" :id="buttonId"
ref="buttonRef"
type="button" 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-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="[ :class="[
@@ -115,8 +116,16 @@
: 'border-m-primary' : 'border-m-primary'
]" ]"
> >
<li
v-if="normalizedOptions.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-options-text"
>
{{ noOptionsText }}
</li>
<li <li
v-for="(opt, index) in normalizedOptions" v-for="(opt, index) in normalizedOptions"
v-else
:id="optionId(index)" :id="optionId(index)"
:key="String(opt.value)" :key="String(opt.value)"
role="option" role="option"
@@ -177,6 +186,7 @@ const props = withDefaults(defineProps<{
rounded?: string rounded?: string
disabled?: boolean disabled?: boolean
groupClass?: string groupClass?: string
noOptionsText?: string
}>(), { }>(), {
options: () => [], options: () => [],
emptyOptionLabel: '', emptyOptionLabel: '',
@@ -190,12 +200,14 @@ const props = withDefaults(defineProps<{
rounded: 'rounded-md', rounded: 'rounded-md',
disabled: false, disabled: false,
groupClass: '', groupClass: '',
noOptionsText: 'Aucune option disponible',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: string | number | null): void (e: 'update:modelValue', v: string | number | null): void
}>() }>()
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null)
const isOpen = ref(false) const isOpen = ref(false)
const activeIndex = ref(-1) const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down') const openDirection = ref<'down' | 'up'>('down')
@@ -299,6 +311,7 @@ function toggle() {
function select(value: string | number | null) { function select(value: string | number | null) {
emit('update:modelValue', value) emit('update:modelValue', value)
close() close()
buttonRef.value?.blur()
} }
function onClickOutside(e: MouseEvent) { function onClickOutside(e: MouseEvent) {
@@ -316,6 +329,21 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
padding: 0 0.25rem; 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;
}
}
:deep(ul[role="listbox"]) { :deep(ul[role="listbox"]) {
scrollbar-width: auto; scrollbar-width: auto;
} }

View File

@@ -6,6 +6,7 @@
> >
<button <button
:id="buttonId" :id="buttonId"
ref="buttonRef"
type="button" 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-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="[ :class="[
@@ -144,7 +145,14 @@
]" ]"
> >
<li <li
v-if="displaySelectAll" v-if="normalizedOptions.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-options-text"
>
{{ noOptionsText }}
</li>
<li
v-if="displaySelectAll && normalizedOptions.length > 0"
class="border-b border-m-muted/30 px-3 py-2" class="border-b border-m-muted/30 px-3 py-2"
@mousedown.prevent @mousedown.prevent
> >
@@ -231,6 +239,7 @@ const props = withDefaults(defineProps<{
selectAllLabel?: string selectAllLabel?: string
disabled?: boolean disabled?: boolean
groupClass?: string groupClass?: string
noOptionsText?: string
}>(), { }>(), {
options: () => [], options: () => [],
emptyOptionLabel: '', emptyOptionLabel: '',
@@ -247,12 +256,14 @@ const props = withDefaults(defineProps<{
selectAllLabel: 'Tout sélectionner', selectAllLabel: 'Tout sélectionner',
disabled: false, disabled: false,
groupClass: '', groupClass: '',
noOptionsText: 'Aucune option disponible',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: Array<string | number>): void (e: 'update:modelValue', v: Array<string | number>): void
}>() }>()
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null)
const isOpen = ref(false) const isOpen = ref(false)
const activeIndex = ref(-1) const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down') const openDirection = ref<'down' | 'up'>('down')
@@ -367,9 +378,10 @@ function isChecked(value: string | number) {
function toggleOption(value: string | number) { function toggleOption(value: string | number) {
if (isChecked(value)) { if (isChecked(value)) {
emit('update:modelValue', props.modelValue.filter(item => item !== value)) emit('update:modelValue', props.modelValue.filter(item => item !== value))
return } else {
emit('update:modelValue', [...props.modelValue, value])
} }
emit('update:modelValue', [...props.modelValue, value]) nextTick(() => buttonRef.value?.focus())
} }
function toggleAll() { function toggleAll() {
@@ -378,6 +390,7 @@ function toggleAll() {
} else { } else {
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value)) emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
} }
nextTick(() => buttonRef.value?.focus())
} }
function onClickOutside(e: MouseEvent) { function onClickOutside(e: MouseEvent) {
@@ -395,6 +408,21 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
padding: 0 0.25rem; 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;
}
}
:deep(ul[role="listbox"]) { :deep(ul[role="listbox"]) {
scrollbar-width: auto; scrollbar-width: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;

View File

@@ -8,6 +8,7 @@ type Tab = {
key: string key: string
label: string label: string
icon?: string icon?: string
disabled?: boolean
} }
type TabListProps = { type TabListProps = {
@@ -134,4 +135,53 @@ describe('MalioTabList', () => {
expect(icons[0].props('icon')).toBe('mdi:home') expect(icons[0].props('icon')).toBe('mdi:home')
expect(icons[1].props('icon')).toBe('mdi:account') expect(icons[1].props('icon')).toBe('mdi:account')
}) })
it('sets disabled attribute and aria-disabled on disabled tabs', () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].attributes('disabled')).toBeDefined()
expect(buttons[1].attributes('aria-disabled')).toBe('true')
})
it('applies cursor-not-allowed on disabled tabs', () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].classes()).toContain('cursor-not-allowed')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
})
it('does not emit update:modelValue when clicking a disabled tab', async () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs, modelValue: 'a'})
const buttons = wrapper.findAll('[role="tab"]')
await buttons[1].trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('does not change active tab in uncontrolled mode when clicking disabled tab', async () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
await buttons[1].trigger('click')
expect(buttons[0].attributes('aria-selected')).toBe('true')
expect(buttons[1].attributes('aria-selected')).toBe('false')
})
}) })

View File

@@ -12,19 +12,23 @@
type="button" type="button"
:aria-selected="activeTab === tab.key" :aria-selected="activeTab === tab.key"
:aria-controls="`${componentId}-panel-${tab.key}`" :aria-controls="`${componentId}-panel-${tab.key}`"
:aria-disabled="!!tab.disabled"
:tabindex="activeTab === tab.key ? 0 : -1" :tabindex="activeTab === tab.key ? 0 : -1"
:disabled="tab.disabled"
:class="[ :class="[
'flex items-center gap-[18px] text-[24px] font-medium transition-colors cursor-pointer', 'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
activeTab === tab.key activeTab === tab.key
? 'border-b-2 border-m-primary text-m-primary font-bold outline-b' ? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
: 'border-transparent text-m-primary/50 hover:text-m-primary/70', : tab.disabled
? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
]" ]"
@click="selectTab(tab.key)" @click="selectTab(tab.key)"
> >
<IconifyIcon <IconifyIcon
v-if="tab.icon" v-if="tab.icon"
:icon="tab.icon" :icon="tab.icon"
:width="20" :width="tab.iconSize ?? 24"
/> />
{{ tab.label }} {{ tab.label }}
</button> </button>
@@ -53,6 +57,8 @@ type Tab = {
key: string key: string
label: string label: string
icon?: string icon?: string
iconSize?: string
disabled?: boolean
} }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -79,6 +85,8 @@ const activeTab = computed(() =>
) )
function selectTab(key: string) { function selectTab(key: string) {
const tab = props.tabs.find(t => t.key === key)
if (tab?.disabled) return
if (!isControlled.value) { if (!isControlled.value) {
localValue.value = key localValue.value = key
} }

View File

@@ -0,0 +1,294 @@
<template>
<Story title="Input/Autocomplete">
<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 (statique)</h2>
<MalioInputAutocomplete
v-model="simpleValue"
label="Pays"
:options="staticOptions"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
<MalioInputAutocomplete
v-model="leftIconValue"
label="Recherche"
icon-name="mdi:magnify"
icon-position="left"
:options="staticOptions"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Branché sur une API (simulé)</h2>
<p class="mb-3 text-sm text-m-muted">
Tapez au moins 2 caractères. Le parent écoute <code>@search</code> et alimente <code>options</code> + <code>loading</code>.
</p>
<MalioInputAutocomplete
v-model="apiValue"
label="Client"
:options="apiOptions"
:loading="apiLoading"
:min-search-length="2"
icon-name="mdi:magnify"
icon-position="left"
@search="onSearchApi"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Création libre (allowCreate)</h2>
<MalioInputAutocomplete
v-model="createValue"
label="Catégorie"
:options="staticOptions"
allow-create
hint="Taper Entrée pour créer une nouvelle valeur"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAutocomplete
v-model="disabledValue"
label="Pays"
:options="staticOptions"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputAutocomplete
v-model="readonlyValue"
label="Pays"
:options="staticOptions"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputAutocomplete
v-model="hintValue"
label="Pays"
:options="staticOptions"
hint="Sélectionne un pays dans la liste"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputAutocomplete
v-model="errorValue"
label="Pays"
:options="staticOptions"
error="Sélection invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputAutocomplete
v-model="successValue"
label="Pays"
:options="staticOptions"
success="Sélection valide"
/>
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioInputAutocomplete
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Combine le pattern floating-label des autres inputs avec un dropdown inspiré de `MalioSelect`. Conçu pour les cas ERP la liste vient d'un appel API (auth, transformation, cache gérés par le parent).
------------------------------------------------------------------------
## Props détaillées
### id
- Type: string
- Description: Identifiant HTML de l'input. Auto-généré si non fourni (préfixe `malio-input-autocomplete-`).
### label
- Type: string
- Description: Texte affiché comme label flottant.
### name
- Type: string
- Description: Attribut name de l'input (formulaires).
### modelValue
- Type: `string | number | null | undefined`
- Description: Valeur sélectionnée. Doit correspondre à un `option.value` (ou être un texte libre si `allowCreate`).
### options
- Type: `{ label: string; value: string | number }[]`
- Défaut: `[]`
- Description: Liste affichée dans le dropdown. Le parent alimente cette liste (statique ou via API en réponse à l'event `search`).
### loading
- Type: boolean
- Défaut: `false`
- Description: Affiche un spinner à la place du chevron et un message dans le dropdown.
### debounce
- Type: number
- Défaut: `300`
- Description: Délai (ms) avant émission de l'event `search` après une frappe. Évite de spammer l'API.
### minSearchLength
- Type: number
- Défaut: `0`
- Description: Nombre minimum de caractères avant d'émettre `search`. Pratique pour API : ne pas appeler avec query vide.
### allowCreate
- Type: boolean
- Défaut: `false`
- Description: Si vrai, l'utilisateur peut valider (Entrée ou clic ailleurs) une valeur libre non présente dans `options` ; émet l'event `create`.
------------------------------------------------------------------------
## Icône
### iconName
- Type: string
- Défaut: `''` (aucune)
- Description: Nom Iconify de l'icône décorative.
### iconPosition
- Type: `'left' | 'right'`
- Défaut: `left`
- Description: Côté d'affichage de l'icône. À droite, l'icône s'aligne avec le chevron.
### iconSize / iconColor
- Type: number / string
- Défaut: `24` / `text-m-muted`
------------------------------------------------------------------------
## Textes du dropdown
### noResultsText
- Type: string
- Défaut: `Aucun résultat`
- Description: Affiché quand `options` est vide.
### loadingText
- Type: string
- Défaut: `Chargement…`
- Description: Affiché pendant que `loading=true`.
### minSearchText
- Type: string
- Défaut: `Tapez pour rechercher`
- Description: Affiché quand l'utilisateur n'a pas atteint `minSearchLength`.
------------------------------------------------------------------------
## Apparence & Style
### inputClass / labelClass / groupClass
- Type: string
- Description: Classes CSS appliquées respectivement à l'input, au label et au conteneur (fusionnées via `twMerge`).
------------------------------------------------------------------------
## Validation & Contraintes
### required / disabled / readonly
- Type: boolean
- Description: Attributs HTML standards. `disabled` et `readonly` empêchent l'ouverture du dropdown.
------------------------------------------------------------------------
## États & Messages
### hint / error / success
- Type: string
- Description: Messages affichés sous le champ. `error` est prioritaire et active `aria-invalid`.
------------------------------------------------------------------------
## Clavier
- `` / `` : naviguer dans les options
- `Entrée` : sélectionner l'option active (ou créer si `allowCreate`)
- `Échap` : fermer le dropdown et revenir à la dernière sélection
------------------------------------------------------------------------
## Accessibilité
- `role="combobox"` sur l'input avec `aria-expanded`, `aria-controls`, `aria-activedescendant`.
- `role="listbox"` sur le dropdown, `role="option"` sur chaque entrée, `aria-selected` reflète `modelValue`.
- `aria-invalid` activé si `error` existe.
- `aria-describedby` référence dynamiquement le message affiché.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis quand l'utilisateur sélectionne une option. Permet l'utilisation avec v-model.
### search
- Émis (après debounce + minSearchLength) avec la query texte tapée. C'est ce que le parent écoute pour lancer l'appel API.
### select
- Émis avec l'objet `Option` complet (ou `null` à la réinitialisation). Utile pour récupérer le `label` côté parent en plus du `value`.
### create
- Émis avec la chaîne saisie quand `allowCreate` est vrai et que l'utilisateur valide une valeur libre.
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioInputAutocomplete from '../../components/malio/input/InputAutocomplete.vue'
type Option = {label: string; value: string | number}
const staticOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const simpleValue = ref<string | number | null>('fr')
const leftIconValue = ref<string | number | null>(null)
const createValue = ref<string | number | null>(null)
const disabledValue = ref<string | number | null>('fr')
const readonlyValue = ref<string | number | null>('be')
const hintValue = ref<string | number | null>(null)
const errorValue = ref<string | number | null>('fr')
const successValue = ref<string | number | null>('fr')
const apiValue = ref<string | number | null>(null)
const apiOptions = ref<Option[]>([])
const apiLoading = ref(false)
const fakeClients: Option[] = [
{label: 'Yuno Malio', value: 1},
{label: 'Yuna Corp', value: 2},
{label: 'Yum Foods', value: 3},
{label: 'Acme Inc.', value: 4},
{label: 'Globex Corp', value: 5},
]
const onSearchApi = async (query: string) => {
apiLoading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
apiOptions.value = fakeClients.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase()),
)
apiLoading.value = false
}
</script>