From 82f93215067774a469b84e990525c37af4d7bf8b Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 25 Jun 2026 08:42:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(input)=20:=20InputAutocomplete=20garde=20la?= =?UTF-8?q?=20valeur=20coll=C3=A9e=20apr=C3=A8s=20s=C3=A9lection=20(MUI-48?= =?UTF-8?q?)=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte — MUI-48 « Le champ adresse disparaît après un copier-coller » (Malio UI, bug frontend). ## Repro exacte 1. Taper une adresse, **sélectionner une suggestion** dans la liste. 2. Sans re-cliquer dans le champ : `Ctrl+A` puis `Ctrl+V` pour remplacer. 3. → Le champ se **vide** (label qui redescend, dropdown « Tapez pour rechercher »), la valeur collée est perdue. À la souris (re-clic dans le champ) le bug ne se produit pas. ## Cause racine `onSelect` repassait `isFocused` à `false` alors que l'input **garde le focus DOM** (l'option est cliquée en `mousedown.prevent`). Au collage, `onInput` émet `update:modelValue(null)` ; le `watch` de synchronisation, protégé par le seul `isFocused`, remettait alors `inputValue` à `''`. ## Correctif `onInput` resynchronise `isFocused = true` : recevoir un évènement `input` prouve que le champ est en cours d'édition. Le `watch` se protège correctement et ne stompe plus la valeur collée. Correctif d'une ligne, au point exact de la cause. ## Tests / vérifs - Test de non-régression colocalisé (séquence sélection → `Ctrl+A`/`Ctrl+V`) — échoue sans le fix, passe avec. - Suite complète : **1057/1057** tests OK, lint sans erreur. - Page playground : nouvelle section **allowCreate + BAN** dédiée au test manuel. - `CHANGELOG.md` : entrée `Fixed` MUI-48. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/87 Co-authored-by: tristan Co-committed-by: tristan --- .../composant/input/inputAutocomplete.vue | 61 +++++++++++++++++++ CHANGELOG.md | 1 + .../malio/input/InputAutocomplete.test.ts | 38 +++++++++++- .../malio/input/InputAutocomplete.vue | 5 ++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/.playground/pages/composant/input/inputAutocomplete.vue b/.playground/pages/composant/input/inputAutocomplete.vue index dfac49b..89a2119 100644 --- a/.playground/pages/composant/input/inputAutocomplete.vue +++ b/.playground/pages/composant/input/inputAutocomplete.vue @@ -47,6 +47,31 @@

+
+

allowCreate + BAN (test MUI-48)

+

+ Tapez au moins 3 caractères → suggestions de la Base Adresse Nationale. + Repro : sélectionnez une adresse dans la liste, puis + Ctrl+A et Ctrl+V pour coller une autre valeur + par-dessus. La valeur collée doit rester (le champ ne doit ni se vider, ni faire redescendre le label). +

+ +

+ v-model : {{ banValue ?? 'null' }} +

+
+

Avec création (allowCreate)

{ const onSelectApi = (option: Option | null) => { apiSelected.value = option } + +// allowCreate + BAN (test MUI-48) : recherche nationale sur la Base Adresse Nationale. +const banValue = ref(null) +const banOptions = ref([]) +const banLoading = ref(false) +let banFetchId = 0 + +const onSearchBan = async (query: string) => { + if (query.length < 3) { + banOptions.value = [] + banLoading.value = false + return + } + const requestId = ++banFetchId + banLoading.value = true + try { + const params = new URLSearchParams({q: query, limit: '8'}) + const response = await fetch(`https://api-adresse.data.gouv.fr/search/?${params.toString()}`) + const data = await response.json() as {features: {properties: {label: string}}[]} + if (requestId !== banFetchId) return + banOptions.value = data.features.map(f => ({ + label: f.properties.label, + value: f.properties.label, + })) + } catch (err) { + if (requestId !== banFetchId) return + banOptions.value = [] + console.error('Erreur lors du chargement des adresses BAN', err) + } finally { + if (requestId === banFetchId) banLoading.value = false + } +} + +const onCreateBan = (value: string) => { + console.log('BAN — valeur libre créée :', value) +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 107ab34..5d277ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants. ### Fixed +* [#MUI-48] InputAutocomplete : après avoir **sélectionné une suggestion** dans la liste, un **collage qui remplace tout** (Ctrl+A puis Ctrl+V, sans re-cliquer dans le champ) **vidait le champ** au lieu de prendre la valeur collée (label qui redescend, dropdown « Tapez pour rechercher »). Cause : `onSelect` repassait `isFocused` à `false` alors que l'input gardait le focus DOM (option cliquée en `mousedown.prevent`) ; le `watch` de synchronisation, non protégé, remettait `inputValue` à `''`. `onInput` resynchronise désormais `isFocused`. La sélection à la souris n'était pas affectée (re-clic réalignant l'état). * [#MUI-47] Sidebar : la **bande de ~4px en haut/bas d'un lien** (padding du `
  • ` qui porte le fond de hover) était survolée mais **non cliquable**. Le padding vertical passe du `
  • ` à l'`` (`py-1`), si bien que toute la zone survolée devient cliquable — sans changement visuel. Les côtés n'étaient pas affectés (`` en `block`, pas de padding horizontal sur le `
  • `). * Sidebar : le **lien actif** reste actif sur les **sous-routes** (match par préfixe via `useRoute().path` au lieu de l'`active-class` de NuxtLink qui dépendait de l'imbrication des routes) — ex. `/supplier` reste surligné sur `/supplier/1/edit`. Nouvelle option `exact: true` par item pour forcer le match strict. * Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir). diff --git a/app/components/malio/input/InputAutocomplete.test.ts b/app/components/malio/input/InputAutocomplete.test.ts index ebc59fe..96cf735 100644 --- a/app/components/malio/input/InputAutocomplete.test.ts +++ b/app/components/malio/input/InputAutocomplete.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from 'vitest' import {mount} from '@vue/test-utils' -import type {DefineComponent} from 'vue' +import {defineComponent, nextTick, ref, type DefineComponent} from 'vue' import {Icon as IconifyIcon} from '@iconify/vue' import InputAutocomplete from './InputAutocomplete.vue' @@ -569,4 +569,40 @@ describe('MalioInputAutocomplete', () => { expect(msg.exists()).toBe(true) expect(msg.classes()).not.toContain('min-h-[1rem]') }) + + // MUI-48 : après avoir sélectionné une option dans la liste, le champ garde le focus DOM + // mais isFocused interne passe à false (clic option en mousedown.prevent). Un collage qui + // remplace tout (Ctrl+A puis Ctrl+V) déclenche update:modelValue(null) ; le watch ne doit + // PAS vider la valeur collée. Régression : le champ se vidait au lieu de prendre le texte collé. + it('MUI-48 : un collage après sélection dans la liste remplace la valeur (ne la vide pas)', async () => { + const Harness = defineComponent({ + components: {InputAutocomplete}, + setup() { + const val = ref(null) + const opts = ref([{label: '10 Rue de la Paix', value: '10 Rue de la Paix'}]) + return {val, opts} + }, + template: '', + }) + + const wrapper = mount(Harness, { + attachTo: document.body, + global: {stubs: {IconifyIcon: {template: ''}}}, + }) + const input = wrapper.get('input') + + // saisie puis sélection d'une suggestion (commit, focus DOM conservé) + await input.trigger('focus') + await input.setValue('10') + await wrapper.findAll('[data-test="option"]')[0].trigger('click') + await nextTick() + expect(input.element.value).toBe('10 Rue de la Paix') + + // Ctrl+A puis Ctrl+V : input toujours focalisé DOM, aucun nouvel évènement focus + await input.setValue('25 Avenue Victor Hugo') + await nextTick() + + expect(input.element.value).toBe('25 Avenue Victor Hugo') + wrapper.unmount() + }) }) diff --git a/app/components/malio/input/InputAutocomplete.vue b/app/components/malio/input/InputAutocomplete.vue index 1d907c2..780ed73 100644 --- a/app/components/malio/input/InputAutocomplete.vue +++ b/app/components/malio/input/InputAutocomplete.vue @@ -393,6 +393,11 @@ const scheduleSearch = () => { const onInput = (event: Event) => { const target = event.target as HTMLInputElement + // Un évènement input prouve que le champ est en cours d'édition : on resynchronise + // isFocused, qu'une sélection précédente (onSelect) a pu passer à false tout en gardant + // le focus DOM (clic option en mousedown.prevent). Sans ça, le watch ci-dessous remettrait + // inputValue à '' au collage et la valeur collée serait perdue (MUI-48). + isFocused.value = true inputValue.value = target.value if (!isOpen.value) isOpen.value = true activeIndex.value = -1