Files
malio-layer-ui/.playground/pages/composant/form/client.vue
T
tristan dc33cf4135 feat(inputs): UX polish across input family + localFilter + focus scrollbar
Polish across the form input components, plus two new features and a few
standalone fixes.

Fixes
-----
* Reserve hint/error/success paragraph space (min-h-[1rem]) in 15
  components so a single error message no longer shifts neighboring grid
  cells: InputText, Email, Password, Phone, Amount, Number, Upload,
  Autocomplete, RichText, TextArea, Select, SelectCheckbox, Time,
  TimePicker, CalendarField, Checkbox.
* InputPhone: the '+' add button now follows the icon-state cascade
  (muted / primary on focus / black when filled / danger / success) like
  the other field icons instead of being permanently primary.
* Select and SelectCheckbox: chevron color follows the field state
  (muted by default, primary when open, black when an option is
  selected, danger / success on error / success) instead of always being
  text-current.
* InputTextArea: single-root component (was multi-root). The message
  wrapper used to occupy its own grid cell, breaking row-span layouts.
  Now flex flex-col, with the textarea area filling the available height
  via flex-1 and the message inside the same root.
* Disabled labels use text-m-muted (border-gray) instead of text-black/60
  (dark) across InputText, Email, Password, Amount, Phone, Upload,
  Autocomplete, TextArea, RichText. Also removes an unreachable
  peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60 rule that
  twMerge was silently overriding with text-black.
* InputAutocomplete: eliminates four sources of visual jitter when
  focusing / opening a field that already has a selected value.
  - Drop peer-focus:-translate-y-[1.55rem] extra label translate.
  - Drop the .grow-height:focus padding rule (no more height growth or
    downward text shift on focus).
  - Drop focus:pl-[11px] (no more 1px horizontal jump).
  - Replace !border-b-0 with !border-b-transparent so the bottom border
    still reserves its 1px while remaining invisible against the
    dropdown.
* Select / SelectCheckbox: same anti-jitter treatment.
  - Drop .grow-height:focus padding rule (~12px height growth gone).
  - Replace !border-b-0 / !border-t-0 with !border-b-transparent /
    !border-t-transparent across danger / success / primary branches.
* Button: default width 240px -> 200px to match the form button sizing
  used across the app. Test updated to match.

Features
--------
* InputTextArea: scrollbar turns primary blue on focus
  (scrollbar-color: rgb(var(--m-primary)) transparent), matching the
  Select listbox styling.
* InputAutocomplete: new localFilter prop (default false). When enabled,
  filters the options prop client-side based on the input value
  (case-insensitive label.includes(query)), so static lists no longer
  need a @search listener. Async/API usage keeps the existing behavior.
  Playground "Simple statique" and "Avec icône à gauche" examples use
  local-filter.

Playground
----------
* client.vue: tighter grid gap (gap-y-5) plus an example error on a
  SelectCheckbox to visually exercise the message-space fix.

Tests
-----
All component test files include regression coverage for the above.
720/720 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:43:53 +02:00

324 lines
10 KiB
Vue

<template>
<div class="flex justify-center">
<div class="w-[1348px]">
<div class="flex gap-3 mt-[46px]">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
aria-label="Précédent"
variant="ghost"
/>
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
</div>
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
label="Nom du client (Entreprise)"
/>
<MalioInputText
label="Nom du contact principal"
/>
<MalioInputText
label="Prénom du contact principal"
/>
<MalioSelectCheckbox
v-model="multiselectValue"
error="test"
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-5 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"/>
<MalioDate
v-model="dateCreation"
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-5 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 2', value: 'Catégorie 2'}
]"
/>
<MalioSelect
label="Pays"
v-model="pays"
:options="[
{label: 'France', value: 'France'},
{label: 'Espagne', value: 'Espagne'}
]"/>
<MalioInputText v-model="codePostal" label="Code postal" />
<MalioSelect
v-model="ville"
label="Ville"
:options="villeOptions"
:no-options-text="villeNoOptionsText"
/>
<MalioInputAutocomplete
v-model="adresse"
label="Adresse"
:options="adresseOptions"
:loading="adresseLoading"
:min-search-length="2"
:no-results-text="adresseNoResultsText"
:min-search-text="adresseMinSearchText"
@search="onSearchAdresse"
/>
<MalioInputText label="Adresse complémentaire"/>
<div class="flex justify-between">
<MalioCheckbox
v-for="dep in departements"
:key="dep"
v-model="departementsSelected[dep]"
:label="dep"
group-class="w-auto self-center"
/>
</div>
<MalioSelect label="Contact" :options="[]"/>
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
</div>
<div class="mt-12 flex justify-center gap-6">
<MalioButton label="Nouvelle Adresse" variant="secondary" icon-name="mdi:add-bold" icon-position="left"/>
<MalioButton label="Valider" variant="primary"/>
</div>
</template>
</MalioTabList>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import MalioDate from "../../../../app/components/malio/date/Date.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 distributeur = ref<string>('')
const phones = ref<string[]>([''])
const nomDistributeur = ref<string>('')
const nomCourtier = ref<string>('')
function addPhoneInput() {
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 dateCreation = ref<string | null>(null)
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>