diff --git a/.playground/pages/composant/form/client.vue b/.playground/pages/composant/form/client.vue index 165818d..3569070 100644 --- a/.playground/pages/composant/form/client.vue +++ b/.playground/pages/composant/form/client.vue @@ -1,75 +1,182 @@ diff --git a/.playground/pages/composant/input/inputAutocomplete.vue b/.playground/pages/composant/input/inputAutocomplete.vue new file mode 100644 index 0000000..73a70b5 --- /dev/null +++ b/.playground/pages/composant/input/inputAutocomplete.vue @@ -0,0 +1,180 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c5309..a07c7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * [#MUI-30] Création d'un composant email * [#MUI-31] Création d'un composant téléphone +* [#MUI-32] Création d'un composant saisie assistée (autocomplete) ### Changed diff --git a/COMPONENTS.md b/COMPONENTS.md index 1fe61c1..b935a52 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -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 + + + + + + + + +``` + +```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 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 | | `disabled` | `boolean` | `false` | Désactivé | | `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)` @@ -285,6 +362,7 @@ Liste déroulante. | `textField` | `string` | `'text-lg'` | Classe taille texte bouton | | `textValue` | `string` | `'text-lg'` | Classe taille texte valeur | | `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)` **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 | | `label` | `string` | `''` | Label | | `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)[])` diff --git a/app/components/malio/input/InputAutocomplete.test.ts b/app/components/malio/input/InputAutocomplete.test.ts new file mode 100644 index 0000000..c7a56bc --- /dev/null +++ b/app/components/malio/input/InputAutocomplete.test.ts @@ -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 + +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: '', + }, + }, + }, + }) + +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') + }) +}) diff --git a/app/components/malio/input/InputAutocomplete.vue b/app/components/malio/input/InputAutocomplete.vue new file mode 100644 index 0000000..5c57090 --- /dev/null +++ b/app/components/malio/input/InputAutocomplete.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/app/components/malio/input/InputTextArea.vue b/app/components/malio/input/InputTextArea.vue index a7c09e6..e236ca9 100644 --- a/app/components/malio/input/InputTextArea.vue +++ b/app/components/malio/input/InputTextArea.vue @@ -1,7 +1,5 @@