Compare commits

..

2 Commits

Author SHA1 Message Date
tristan 1d66e5dd31 fix: plusieurs retours UX/UI (#58)
Release / release (push) Successful in 1m11s
| 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é

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #58
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 13:53:52 +00:00
tristan c0c39705c7 fix: drawer footer (#57)
Release / release (push) Successful in 1m20s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #57
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:53:43 +00:00
33 changed files with 521 additions and 215 deletions
+9 -14
View File
@@ -33,7 +33,7 @@ const drawerNoDismiss = ref(false)
</div> </div>
<div class="rounded-lg border p-6"> <div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2> <h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" /> <MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg"> <MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
<template #header> <template #header>
@@ -45,32 +45,27 @@ const drawerNoDismiss = ref(false)
<MalioInputText label="Email" /> <MalioInputText label="Email" />
</div> </div>
<template #footer> <template #footer>
<div class="sticky bottom-0 flex gap-3 bg-white py-4"> <MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" /> <MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
</div>
</template> </template>
</MalioDrawer> </MalioDrawer>
</div> </div>
<div class="rounded-lg border p-6"> <div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer fixed bottom</h2> <h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" /> <MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
<MalioDrawer v-model="drawerFixedFooter"> <MalioDrawer v-model="drawerFixedFooter">
<template #header> <template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2> <h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template> </template>
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu --> <!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
<div class="flex flex-col gap-4 pb-24"> <div class="flex flex-col gap-4">
<p v-for="n in 12" :key="n" class="text-m-text"> <p v-for="n in 12" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll et montrer que le footer reste fixé en bas du viewport. Paragraphe {{ n }} contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
</p> </p>
</div> </div>
<template #footer> <template #footer>
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut --> <MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
<div class="fixed bottom-0 right-0 w-full max-w-md border-t border-m-border bg-white px-5 py-4">
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
</div>
</template> </template>
</MalioDrawer> </MalioDrawer>
</div> </div>
@@ -27,7 +27,7 @@
side="right" side="right"
drawer-class="max-w-[450px]" drawer-class="max-w-[450px]"
body-class="p-0" body-class="p-0"
footer-class="sticky bottom-0 flex justify-between gap-4 bg-white px-5 py-7" footer-class="justify-between gap-4 py-7"
> >
<template #header> <template #header>
<h2 class="text-[24px] font-bold uppercase">Filtres</h2> <h2 class="text-[24px] font-bold uppercase">Filtres</h2>
+4 -3
View File
@@ -10,7 +10,7 @@
/> />
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1> <h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
</div> </div>
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8"> <div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
label="Nom du client (Entreprise)" label="Nom du client (Entreprise)"
/> />
@@ -22,6 +22,7 @@
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-model="multiselectValue" v-model="multiselectValue"
error="test"
label="Catégorie" label="Catégorie"
:options="[ :options="[
{label: 'Catégorie 1', value: 'Catégorie 1'}, {label: 'Catégorie 1', value: 'Catégorie 1'},
@@ -75,7 +76,7 @@
<div class="mt-[60px]"> <div class="mt-[60px]">
<MalioTabList :tabs="tabs" v-model="tabsValue"> <MalioTabList :tabs="tabs" v-model="tabsValue">
<template #information> <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]"> <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"/> <MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/> <MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioDate <MalioDate
@@ -92,7 +93,7 @@
</div> </div>
</template> </template>
<template #adresses> <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]"> <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 <MalioButtonIcon
icon="mdi:delete-outline" icon="mdi:delete-outline"
aria-label="Supprimer l'adresse" aria-label="Supprimer l'adresse"
@@ -6,6 +6,7 @@
v-model="simpleValue" v-model="simpleValue"
label="Pays" label="Pays"
:options="staticOptions" :options="staticOptions"
local-filter
/> />
<p class="mt-2 text-sm text-m-muted"> <p class="mt-2 text-sm text-m-muted">
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code> Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
@@ -20,6 +21,7 @@
icon-name="mdi:magnify" icon-name="mdi:magnify"
icon-position="left" icon-position="left"
:options="staticOptions" :options="staticOptions"
local-filter
/> />
</div> </div>
+11
View File
@@ -36,10 +36,21 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe) * [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
* [#MUI-37] Création d'un composant accordéon * [#MUI-37] Création d'un composant accordéon
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) * [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
### Changed ### Changed
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`. * [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
### Fixed ### Fixed
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
* Hauteur des boutons de pagination du datatable alignée sur le select (40px) * Hauteur des boutons de pagination du datatable alignée sur le select (40px)
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus * Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
+10 -11
View File
@@ -146,7 +146,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
## MalioInputAutocomplete ## 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. 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). Par défaut 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. Pour une liste **statique** courte, activer `localFilter` fait filtrer le composant lui-même (case-insensitive `label.includes(query)`) sans avoir à brancher `@search`.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
@@ -159,6 +159,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` | | `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `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`) | | `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
| `localFilter` | `boolean` | `false` | Filtre `options` côté client par sous-chaîne du label (case-insensitive). À utiliser pour les listes statiques courtes ; en mode API on laisse `false` et le parent répond à `@search`. |
| `iconName` | `string` | `''` | Icône Iconify décorative | | `iconName` | `string` | `''` | Icône Iconify décorative |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative | | `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
| `iconSize` | `string \| number` | `24` | Taille de l'icône | | `iconSize` | `string \| number` | `24` | Taille de l'icône |
@@ -185,8 +186,8 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown. **Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
```vue ```vue
<!-- Usage statique --> <!-- Usage statique (filtrage côté client via local-filter) -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" /> <MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
<!-- Usage API (parent gère le fetch) --> <!-- Usage API (parent gère le fetch) -->
<MalioInputAutocomplete <MalioInputAutocomplete
@@ -813,14 +814,14 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) | | `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) | | `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) | | `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) | | `footerClass` | `string` | `''` | Classes CSS du footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`, `close()` **Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :** **Slots :**
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée. - `header` — en-tête (titre, etc.), fixe en haut. S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable). - `default` — contenu (zone scrollable : seul le body défile).
- `footer`rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien). - `footer`actions (boutons). Rendu en bas du panneau, fixe, hors de la zone scrollable. N'apparaît que si le slot est fourni.
```vue ```vue
<MalioDrawer v-model="isOpen"> <MalioDrawer v-model="isOpen">
@@ -836,14 +837,12 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
<p>Drawer large depuis la gauche</p> <p>Drawer large depuis la gauche</p>
</MalioDrawer> </MalioDrawer>
<!-- Footer collé en bas (le consommateur applique le positionnement) --> <!-- Footer d'actions (fixe en bas, hors zone scrollable) -->
<MalioDrawer v-model="isOpen"> <MalioDrawer v-model="isOpen">
<template #header><h2>Formulaire</h2></template> <template #header><h2>Formulaire</h2></template>
<MalioInputText label="Nom" /> <MalioInputText label="Nom" />
<template #footer> <template #footer>
<div class="sticky bottom-0 bg-white py-4"> <MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
</div>
</template> </template>
</MalioDrawer> </MalioDrawer>
+1 -1
View File
@@ -162,7 +162,7 @@ describe('MalioButton', () => {
it('applies correct dimensions', () => { it('applies correct dimensions', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
expect(wrapper.get('button').classes()).toContain('w-[240px]') expect(wrapper.get('button').classes()).toContain('w-[200px]')
expect(wrapper.get('button').classes()).toContain('h-[40px]') expect(wrapper.get('button').classes()).toContain('h-[40px]')
}) })
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() => const mergedButtonClass = computed(() =>
twMerge( twMerge(
'inline-flex w-[240px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50', 'inline-flex w-[200px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
variantClasses.value, variantClasses.value,
props.buttonClass, props.buttonClass,
), ),
+1 -2
View File
@@ -30,7 +30,6 @@
</label> </label>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="mergedMessageClass" :class="mergedMessageClass"
> >
@@ -121,7 +120,7 @@ const mergedLabelClass = computed(() =>
const mergedMessageClass = computed(() => const mergedMessageClass = computed(() =>
twMerge( twMerge(
'text-xs', 'text-xs min-h-[1rem]',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
@@ -85,11 +85,10 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
+6 -7
View File
@@ -152,12 +152,13 @@ describe('MalioDrawer', () => {
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary') expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
}) })
it('renders the #footer slot inside the body (scrollable zone)', () => { it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent( const wrapper = mountComponent(
{ modelValue: true }, { modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' }, { footer: '<button data-test="save">Enregistrer</button>' },
) )
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true) expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
}) })
it('does not render the footer wrapper when no #footer slot', () => { it('does not render the footer wrapper when no #footer slot', () => {
@@ -170,14 +171,12 @@ describe('MalioDrawer', () => {
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10') expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
}) })
it('applies footerClass to the footer wrapper', () => { it('applies footerClass to the footer', () => {
const wrapper = mountComponent( const wrapper = mountComponent(
{ modelValue: true, footerClass: 'sticky bottom-0' }, { modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' }, { footer: '<span>pied</span>' },
) )
const footer = wrapper.find('[data-test="footer"]') expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
expect(footer.classes()).toContain('sticky')
expect(footer.classes()).toContain('bottom-0')
}) })
it('aligns to the right by default', () => { it('aligns to the right by default', () => {
+7 -7
View File
@@ -64,13 +64,13 @@
data-test="body" data-test="body"
> >
<slot /> <slot />
<div </div>
v-if="$slots.footer" <div
:class="footerClass" v-if="$slots.footer"
data-test="footer" :class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
> data-test="footer"
<slot name="footer" /> >
</div> <slot name="footer" />
</div> </div>
</div> </div>
</div> </div>
+16
View File
@@ -126,6 +126,13 @@ describe('MalioInputText', () => {
expect(wrapper.get('input').classes()).toContain('text-black/60') expect(wrapper.get('input').classes()).toContain('text-black/60')
}) })
it('shows muted label color when disabled (matches border color)', () => {
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
})
it('emits update:modelValue on input change', async () => { it('emits update:modelValue on input change', async () => {
const wrapper = mountInput({modelValue: ''}) const wrapper = mountInput({modelValue: ''})
@@ -253,6 +260,15 @@ describe('MalioInputText', () => {
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test') expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
}) })
it('reserves space for the message even when no hint/error/success is set', () => {
const wrapper = mountInput({})
const p = wrapper.find('p')
expect(p.exists()).toBe(true)
expect(p.text()).toBe('')
expect(p.classes()).toContain('min-h-[1rem]')
})
it('does not render label when label prop is missing', () => { it('does not render label when label prop is missing', () => {
const wrapper = mountInput({labelClass: 'text-red-500'}) const wrapper = mountInput({labelClass: 'text-red-500'})
+5 -5
View File
@@ -44,7 +44,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -52,7 +51,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -109,7 +108,7 @@ const props = withDefaults(
hint: '', hint: '',
error: '', error: '',
success: '', success: '',
iconSize: 24, iconSize: 20,
iconColor: 'text-m-muted', iconColor: 'text-m-muted',
}, },
) )
@@ -153,12 +152,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -28,6 +28,7 @@ type InputAutocompleteProps = {
debounce?: number debounce?: number
minSearchLength?: number minSearchLength?: number
allowCreate?: boolean allowCreate?: boolean
localFilter?: boolean
iconName?: string iconName?: string
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconSize?: string | number iconSize?: string | number
@@ -427,4 +428,82 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').element.value).toBe('Custom') expect(wrapper.get('input').element.value).toBe('Custom')
}) })
it('does not filter options when localFilter is false (default)', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('fr')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
})
it('filters options client-side when localFilter is true', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('fr')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('France')
})
it('localFilter is case-insensitive and matches substrings', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('GIQ')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('Belgique')
})
it('localFilter shows all options when input is empty', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
})
it('localFilter shows the no-results state when nothing matches', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('zzzzz')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(0)
expect(wrapper.find('[data-test="no-results-text"]').exists()).toBe(true)
})
it('keeps the floating label at the same position whether focused or not (no jump)', async () => {
const wrapper = mountComponent({options, label: 'Pays', modelValue: 'fr'})
// when a value is selected and the field is not focused, the label is already floated
const labelClasses = wrapper.get('label').classes()
expect(labelClasses).toContain('-translate-y-[1.25rem]')
// and there is no extra peer-focus translate that would make it jump on click
expect(labelClasses).not.toContain('peer-focus:-translate-y-[1.55rem]')
})
it('does not shift inner text horizontally on focus (no focus:pl change)', () => {
const wrapper = mountComponent({options})
const inputClasses = wrapper.get('input').classes()
expect(inputClasses).not.toContain('focus:pl-[11px]')
})
it('keeps the bottom border allocation when open (transparent, not zero)', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
const inputClasses = wrapper.get('input').classes()
// border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// border-b-transparent keeps the 1px allocation but hides the line
expect(inputClasses).not.toContain('!border-b-0')
expect(inputClasses).toContain('!border-b-transparent')
})
}) })
@@ -107,7 +107,7 @@
{{ minSearchText }} {{ minSearchText }}
</li> </li>
<li <li
v-else-if="options.length === 0" v-else-if="filteredOptions.length === 0"
class="px-3 py-2 text-m-muted" class="px-3 py-2 text-m-muted"
data-test="no-results-text" data-test="no-results-text"
> >
@@ -115,7 +115,7 @@
</li> </li>
<template v-else> <template v-else>
<li <li
v-for="(opt, index) in options" v-for="(opt, index) in filteredOptions"
:id="optionId(index)" :id="optionId(index)"
:key="String(opt.value)" :key="String(opt.value)"
data-test="option" data-test="option"
@@ -136,11 +136,10 @@
</ul> </ul>
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -180,6 +179,7 @@ const props = withDefaults(
debounce?: number debounce?: number
minSearchLength?: number minSearchLength?: number
allowCreate?: boolean allowCreate?: boolean
localFilter?: boolean
iconName?: string iconName?: string
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconSize?: string | number iconSize?: string | number
@@ -207,6 +207,7 @@ const props = withDefaults(
debounce: 300, debounce: 300,
minSearchLength: 0, minSearchLength: 0,
allowCreate: false, allowCreate: false,
localFilter: false,
iconName: '', iconName: '',
iconPosition: 'left', iconPosition: 'left',
iconSize: 24, iconSize: 24,
@@ -253,9 +254,18 @@ const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength, props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
) )
const filteredOptions = computed(() => {
if (!props.localFilter) return props.options
const query = inputValue.value.trim().toLowerCase()
if (query === '') return props.options
return props.options.filter(opt =>
opt.label.toLowerCase().includes(query),
)
})
const optionId = (index: number) => `${inputId.value}-option-${index}` const optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() => const activeOptionId = computed(() =>
activeIndex.value >= 0 && props.options[activeIndex.value] activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
? optionId(activeIndex.value) ? optionId(activeIndex.value)
: undefined, : undefined,
) )
@@ -294,11 +304,6 @@ const iconInputPaddingClass = computed(() => {
return parts.join(' ') return parts.join(' ')
}) })
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const labelPositionClass = computed(() => const labelPositionClass = computed(() =>
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3', props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
) )
@@ -315,10 +320,9 @@ const mergedInputClass = computed(() =>
: hasSuccess.value : hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success' ? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary', : 'focus:border-m-primary',
isOpen.value ? '!rounded-b-none !border-b-0' : '', isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
props.inputClass, props.inputClass,
iconInputPaddingClass.value, iconInputPaddingClass.value,
focusPaddingClass.value,
), ),
) )
@@ -326,13 +330,14 @@ const mergedLabelClass = computed(() =>
twMerge( twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : props.disabled
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -432,8 +437,8 @@ const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault() event.preventDefault()
if (activeIndex.value >= 0 && props.options[activeIndex.value]) { if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
onSelect(props.options[activeIndex.value]) onSelect(filteredOptions.value[activeIndex.value])
return return
} }
if (props.allowCreate && inputValue.value !== '') { if (props.allowCreate && inputValue.value !== '') {
@@ -450,7 +455,7 @@ const onKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true isOpen.value = true
} }
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1) activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
return return
} }
@@ -481,12 +486,7 @@ onBeforeUnmount(() => {
} }
.grow-height { .grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+4 -4
View File
@@ -42,7 +42,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -50,7 +49,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -147,12 +146,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
+1 -2
View File
@@ -51,7 +51,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -59,7 +58,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'text-xs ml-[2px] ', 'text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
+4 -4
View File
@@ -47,7 +47,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -55,7 +54,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -155,12 +154,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3', 'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -298,6 +298,41 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('input').classes()).toContain('!pr-10') expect(wrapper.get('input').classes()).toContain('!pr-10')
}) })
it('shows default add button color when empty and unfocused', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('text-m-primary')
})
it('shows primary add button color on focus', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-primary')
})
it('shows black add button color when filled and unfocused', () => {
const wrapper = mountComponent({addable: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-black')
})
it('error overrides focus color on add button', async () => {
const wrapper = mountComponent({addable: true, error: 'Numéro invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-danger')
})
it('success applies to add button', () => {
const wrapper = mountComponent({addable: true, success: 'Numéro valide'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-success')
})
it('applies mask via maska directive', async () => { it('applies mask via maska directive', async () => {
const wrapper = mountComponent({mask: '+## # ## ## ## ##'}) const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
+6 -5
View File
@@ -60,7 +60,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -68,7 +67,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -175,19 +174,21 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
const mergedAddButtonClass = computed(() => const mergedAddButtonClass = computed(() =>
twMerge( twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70', 'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '', (props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
), ),
) )
+6 -6
View File
@@ -184,7 +184,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${editorId}-describedby`" :id="`${editorId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -192,7 +191,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px]', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -279,10 +278,11 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: isFocused.value : props.disabled
? 'text-m-primary' ? 'text-m-muted'
: 'text-m-text', : isFocused.value
props.disabled ? 'text-black/60' : '', ? 'text-m-primary'
: 'text-m-text',
props.labelClass, props.labelClass,
), ),
) )
+4 -4
View File
@@ -44,7 +44,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -52,7 +51,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -158,12 +157,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -149,4 +149,38 @@ describe('MalioInputTextArea', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false) expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error') expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
}) })
it('renders as a single root element (works as a single grid item)', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const wrapper = mount(InputTextAreaForTest, {
attachTo: host,
})
// host > div[data-v-app] > component roots
const app = host.firstElementChild as HTMLElement
expect(app.children.length).toBe(1)
wrapper.unmount()
host.remove()
})
it('applies primary scrollbar class on focus', async () => {
const wrapper = mount(InputTextAreaForTest)
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
await wrapper.get('textarea').trigger('focus')
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
})
it('removes primary scrollbar class on blur', async () => {
const wrapper = mount(InputTextAreaForTest)
await wrapper.get('textarea').trigger('focus')
await wrapper.get('textarea').trigger('blur')
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
})
}) })
+77 -70
View File
@@ -1,79 +1,82 @@
<template> <template>
<div :class="mergedGroupClass"> <div :class="mergedGroupClass">
<textarea <div class="relative w-full flex-1">
:id="inputId" <textarea
:name="name" :id="inputId"
:name="name"
:autocomplete="autocomplete" :autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto" class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[ :class="[
isFilled ? 'border-black' : 'border-m-muted', isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text', disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
hasError
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'border-m-success focus:border-m-success'
: 'focus:border-m-primary',
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
disabled ? 'text-black/60' : '',
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
textLabel,
]"
>
{{ label }}
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
v-if="hasError || hasSuccess || hint"
class="mt-1 flex items-center justify-between gap-2 text-xs"
>
<p
:id="`${inputId}-describedby`"
:class="[
hasError hasError
? 'text-m-danger' ? 'border-m-danger focus:border-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'border-m-success focus:border-m-success'
: 'text-m-muted', : 'focus:border-m-primary',
'ml-[2px]', isFocused ? 'textarea-scrollbar-primary' : '',
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
]" ]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: disabled
? 'text-m-muted'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
textLabel,
]"
>
{{ label }}
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
class="mt-1 flex items-center justify-between gap-2 text-xs min-h-[1rem]"
> >
{{ error || success || hint }} <p
</p> :id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
</div> </div>
</template> </template>
@@ -138,7 +141,7 @@ const props = withDefaults(
) )
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.groupClass), twMerge('flex flex-col w-full', props.groupClass),
) )
const attrs = useAttrs() const attrs = useAttrs()
@@ -188,4 +191,8 @@ const onInput = (event: Event) => {
background: white; background: white;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
.textarea-scrollbar-primary {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
</style> </style>
+4 -4
View File
@@ -50,7 +50,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -58,7 +57,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -144,12 +143,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3', 'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -207,4 +207,70 @@ describe('MalioSelect', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false) expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error') expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
}) })
it('shows muted chevron color when empty and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when an option is selected and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows danger chevron color on error even when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, error: 'Selection error'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
})
it('shows success chevron color on success', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, success: 'OK'},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
})
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
const buttonClasses = wrapper.get('button').classes()
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// !border-b-transparent keeps the 1px allocation but hides the line
expect(buttonClasses).not.toContain('!border-b-0')
expect(buttonClasses).toContain('!border-b-transparent')
})
}) })
+16 -15
View File
@@ -13,19 +13,19 @@
hasError hasError
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-0' ? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-0' : 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger' : 'border-m-danger'
: hasSuccess : hasSuccess
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-0' ? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-0' : 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success' : 'border-m-success'
: isOpen : isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0' ? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-0' : 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected : isOptionSelected
? 'border-black' ? 'border-black'
: 'border-m-muted', : 'border-m-muted',
@@ -73,13 +73,20 @@
</span> </span>
<span <span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2" class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[ :class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-current' : disabled
? 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]" ]"
> >
<slot name="icon"> <slot name="icon">
@@ -145,7 +152,6 @@
</ul> </ul>
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`" :id="`${buttonId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -153,7 +159,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -330,12 +336,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
} }
.grow-height { .grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -182,4 +182,70 @@ describe('MalioSelectCheckbox', () => {
const root = wrapper.find('button').element.parentElement const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4') expect(root?.className).toContain('mt-4')
}) })
it('shows muted chevron color when nothing is selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when options are selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('shows muted chevron color when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows danger chevron color on error even when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, error: 'Selection error'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
})
it('shows success chevron color on success', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, success: 'OK'},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
})
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
const buttonClasses = wrapper.get('button').classes()
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// !border-b-transparent keeps the 1px allocation but hides the line
expect(buttonClasses).not.toContain('!border-b-0')
expect(buttonClasses).toContain('!border-b-transparent')
})
}) })
+16 -15
View File
@@ -13,19 +13,19 @@
hasError hasError
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-0' ? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-0' : 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger' : 'border-m-danger'
: hasSuccess : hasSuccess
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-0' ? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-0' : 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success' : 'border-m-success'
: isOpen : isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0' ? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-0' : 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected : isOptionSelected
? 'border-black' ? 'border-black'
: 'border-m-muted', : 'border-m-muted',
@@ -101,13 +101,20 @@
</span> </span>
<span <span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2" class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[ :class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-current' : disabled
? 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]" ]"
> >
<slot name="icon"> <slot name="icon">
@@ -194,7 +201,6 @@
</ul> </ul>
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`" :id="`${buttonId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -202,7 +208,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -409,12 +415,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
} }
.grow-height { .grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+1 -2
View File
@@ -58,7 +58,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -66,7 +65,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
+1 -2
View File
@@ -78,11 +78,10 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
+2 -4
View File
@@ -45,7 +45,7 @@ const showNoDismiss = ref(false)
</div> </div>
</Variant> </Variant>
<Variant title="Avec footer collant"> <Variant title="Avec footer d'actions">
<div class="p-4"> <div class="p-4">
<button <button
class="rounded bg-m-btn-primary px-4 py-2 text-white" class="rounded bg-m-btn-primary px-4 py-2 text-white"
@@ -62,9 +62,7 @@ const showNoDismiss = ref(false)
<MalioInputText label="Prénom" /> <MalioInputText label="Prénom" />
</div> </div>
<template #footer> <template #footer>
<div class="sticky bottom-0 flex gap-3 bg-white py-4"> <MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
</div>
</template> </template>
</MalioDrawer> </MalioDrawer>
</div> </div>