dc33cf4135
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>
324 lines
10 KiB
Vue
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>
|