Compare commits

...

2 Commits

Author SHA1 Message Date
tristan 2aded80971 fix: multi-select (#90)
Release / release (push) Successful in 1m17s
| 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: admin malio <malio@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #90
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-28 11:03:09 +00:00
tristan b5bebe3a3c fix: sidebar + autocomplete (#88)
Release / release (push) Successful in 50s
| 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: admin malio <malio@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #88
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-25 08:44:17 +00:00
12 changed files with 364 additions and 16 deletions
@@ -47,6 +47,31 @@
</p> </p>
</div> </div>
<div class="rounded-lg border-2 border-m-primary p-4 md:col-span-2">
<h2 class="mb-1 text-xl font-bold">allowCreate + BAN (test MUI-48)</h2>
<p class="mb-3 text-sm text-m-muted">
Tapez au moins 3 caractères → suggestions de la Base Adresse Nationale.
<strong>Repro :</strong> sélectionnez une adresse dans la liste, puis
<kbd>Ctrl</kbd>+<kbd>A</kbd> et <kbd>Ctrl</kbd>+<kbd>V</kbd> 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).
</p>
<MalioInputAutocomplete
v-model="banValue"
label="Adresse"
:options="banOptions"
:loading="banLoading"
:min-search-length="3"
allow-create
icon-name="mdi:map-marker-outline"
icon-position="left"
@search="onSearchBan"
@create="onCreateBan"
/>
<p class="mt-2 text-sm text-m-muted">
v-model : <code>{{ banValue ?? 'null' }}</code>
</p>
</div>
<div class="rounded-lg border p-4"> <div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2> <h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
<MalioInputAutocomplete <MalioInputAutocomplete
@@ -199,4 +224,40 @@ const onSearchApi = async (query: string) => {
const onSelectApi = (option: Option | null) => { const onSelectApi = (option: Option | null) => {
apiSelected.value = option apiSelected.value = option
} }
// allowCreate + BAN (test MUI-48) : recherche nationale sur la Base Adresse Nationale.
const banValue = ref<string | number | null>(null)
const banOptions = ref<Option[]>([])
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)
}
</script> </script>
@@ -17,6 +17,27 @@
empty-option-label=" " empty-option-label=" "
/> />
</div> </div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec tag + max tags (3)</h2>
<MalioSelectCheckbox
v-model="maxTagsValue"
:options="options"
:display-tag="true"
:max-tags="3"
label="Pays"
empty-option-label=" "
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec tag + couleurs</h2>
<MalioSelectCheckbox
v-model="colorValue"
:options="colorOptions"
:display-tag="true"
label="Pays"
empty-option-label=" "
/>
</div>
<div class="rounded-lg border p-4"> <div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec tag + label</h2> <h2 class="mb-4 text-xl font-bold">Avec tag + label</h2>
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -185,6 +206,15 @@ const options = [
{label: 'Portugal', value: 'pt'}, {label: 'Portugal', value: 'pt'},
] ]
const colorOptions = [
{label: 'France', value: 'fr', color: '#fde2e2'},
{label: 'Belgique', value: 'be', color: '#fff3cd'},
{label: 'Suisse', value: 'ch', color: '#d1e7dd'},
{label: 'Canada', value: 'ca', color: '#cfe2ff'},
{label: 'Allemagne', value: 'de'},
{label: 'Espagne', value: 'es', color: '#e2d9f3'},
]
const longOptions = [ const longOptions = [
...options, ...options,
{label: 'Pays-Bas', value: 'nl'}, {label: 'Pays-Bas', value: 'nl'},
@@ -203,6 +233,8 @@ const longOptions = [
const basicValue = ref<Array<string | number>>([]) const basicValue = ref<Array<string | number>>([])
const labelValue = ref<Array<string | number>>([]) const labelValue = ref<Array<string | number>>([])
const labelValue1 = ref<Array<string | number>>([]) const labelValue1 = ref<Array<string | number>>([])
const maxTagsValue = ref<Array<string | number>>(['fr', 'be', 'ch', 'ca', 'de'])
const colorValue = ref<Array<string | number>>(['fr', 'be', 'de'])
const selectedValue = ref<Array<string | number>>(['fr']) const selectedValue = ref<Array<string | number>>(['fr'])
const hintValue = ref<Array<string | number>>([]) const hintValue = ref<Array<string | number>>([])
const errorValue = ref<Array<string | number>>([]) const errorValue = ref<Array<string | number>>([])
@@ -10,6 +10,18 @@
<template #logo-collapsed> <template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" /> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
</template> </template>
<template #footer>
<div class="flex items-center gap-3">
<Icon name="mdi:account-circle" size="32" class="text-m-primary" />
<div class="leading-tight">
<p class="text-[14px] font-semibold text-m-text">Tristan</p>
<p class="text-[12px] text-m-muted">Administrateur</p>
</div>
</div>
</template>
<template #footer-collapsed>
<Icon name="mdi:account-circle" size="28" class="mx-auto block text-m-primary" />
</template>
</MalioSidebar> </MalioSidebar>
<MalioSidebar <MalioSidebar
@@ -22,6 +34,18 @@
<template #logo-collapsed> <template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" /> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio" />
</template> </template>
<template #footer>
<button
type="button"
class="flex w-full items-center gap-2 text-[15px] text-m-text hover:text-m-primary"
>
<Icon name="mdi:logout" size="20" />
Déconnexion
</button>
</template>
<template #footer-collapsed>
<Icon name="mdi:logout" size="20" class="mx-auto block text-m-text" />
</template>
</MalioSidebar> </MalioSidebar>
</div> </div>
</template> </template>
+4
View File
@@ -57,6 +57,8 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`. * [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée). * [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
* Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`. * Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`.
* MalioSidebar : slots `footer` / `footer-collapsed` pour ajouter un contenu en bas de la sidebar (profil, déconnexion, version…). Toujours collé en bas (la nav `flex-1` le pousse), reste visible quand la liste de liens scrolle ; bordure haute `m-primary` en mode déplié, à l'image du bloc logo.
* [#MUI-49] SelectCheckbox : refonte du style des tags (fond `m-bg`, sans bordure, texte 18px/500). Nouvelle prop `maxTags` (nombre max de tags affichés, `0` = tous ; au-delà un tag `+N` résume le surplus) et champ `color` optionnel par option (couleur de fond du tag, sinon `m-bg`).
### Changed ### Changed
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.) * Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
@@ -71,6 +73,8 @@ 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. * [#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 ### 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 `<li>` qui porte le fond de hover) était survolée mais **non cliquable**. Le padding vertical passe du `<li>` à l'`<a>` (`py-1`), si bien que toute la zone survolée devient cliquable — sans changement visuel. Les côtés n'étaient pas affectés (`<a>` en `block`, pas de padding horizontal sur le `<li>`).
* 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. * 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 `<Icon>` 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). * Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` 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).
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit. * Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
+9 -2
View File
@@ -427,8 +427,9 @@ Liste déroulante multi-sélection avec checkboxes.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
| `modelValue` | `(string \| number)[]` | `[]` | Valeurs sélectionnées (v-model) | | `modelValue` | `(string \| number)[]` | `[]` | Valeurs sélectionnées (v-model) |
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options | | `options` | `{ value: string \| number, label: string, color?: string }[]` | `[]` | Options. `color` optionnel = couleur de fond du tag (sinon `m-bg`). |
| `displayTag` | `boolean` | `false` | Afficher les tags sélectionnés | | `displayTag` | `boolean` | `false` | Afficher les tags sélectionnés |
| `maxTags` | `number` | `0` | Nombre max de tags affichés ; au-delà un tag `+N` résume le surplus. `0` = tous les tags. |
| `displaySelectAll` | `boolean` | `false` | Afficher "Tout sélectionner" | | `displaySelectAll` | `boolean` | `false` | Afficher "Tout sélectionner" |
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global | | `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
@@ -445,6 +446,8 @@ Liste déroulante multi-sélection avec checkboxes.
```vue ```vue
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" /> <MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" /> <MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
<!-- maxTags : 3 tags max + badge "+N" ; color par option pour le fond du tag -->
<MalioSelectCheckbox v-model="pays" label="Pays" :options="paysColorés" :display-tag="true" :max-tags="3" />
``` ```
--- ---
@@ -893,12 +896,16 @@ Barre latérale de navigation rétractable.
**Lien actif :** un lien est marqué actif (texte `m-primary` + semi-bold) quand la route courante **est ce lien ou une de ses sous-routes** (match par préfixe) — ex. `/supplier` reste actif sur `/supplier/1/edit`. Mettre `exact: true` sur l'item force le match strict (actif uniquement sur la route exacte). Indépendant de l'imbrication des routes côté consommateur. **Lien actif :** un lien est marqué actif (texte `m-primary` + semi-bold) quand la route courante **est ce lien ou une de ses sous-routes** (match par préfixe) — ex. `/supplier` reste actif sur `/supplier/1/edit`. Mettre `exact: true` sur l'item force le match strict (actif uniquement sur la route exacte). Indépendant de l'imbrication des routes côté consommateur.
**Events :** `update:modelValue(value: boolean)` **Events :** `update:modelValue(value: boolean)`
**Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée) **Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée), `footer` (bas, sidebar ouverte), `footer-collapsed` (bas, sidebar fermée)
Le footer est **toujours collé en bas** : la nav occupe l'espace restant (`flex-1`) et pousse le footer vers le bas, qui reste visible même quand la liste de liens scrolle.
```vue ```vue
<MalioSidebar v-model="isOpen" :sections="menuSections"> <MalioSidebar v-model="isOpen" :sections="menuSections">
<template #logo><img src="/logo.png" /></template> <template #logo><img src="/logo.png" /></template>
<template #logo-collapsed><img src="/logo-small.png" /></template> <template #logo-collapsed><img src="/logo-small.png" /></template>
<template #footer><UserProfile /></template>
<template #footer-collapsed><Icon name="mdi:account" /></template>
</MalioSidebar> </MalioSidebar>
``` ```
@@ -1,6 +1,6 @@
import {describe, expect, it, vi} from 'vitest' import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils' 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 {Icon as IconifyIcon} from '@iconify/vue'
import InputAutocomplete from './InputAutocomplete.vue' import InputAutocomplete from './InputAutocomplete.vue'
@@ -569,4 +569,40 @@ describe('MalioInputAutocomplete', () => {
expect(msg.exists()).toBe(true) expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]') 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<string | number | null>(null)
const opts = ref([{label: '10 Rue de la Paix', value: '10 Rue de la Paix'}])
return {val, opts}
},
template: '<InputAutocomplete v-model="val" :options="opts" allow-create />',
})
const wrapper = mount(Harness, {
attachTo: document.body,
global: {stubs: {IconifyIcon: {template: '<span data-test="icon" v-bind="$attrs" />'}}},
})
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()
})
}) })
@@ -393,6 +393,11 @@ const scheduleSearch = () => {
const onInput = (event: Event) => { const onInput = (event: Event) => {
const target = event.target as HTMLInputElement 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 inputValue.value = target.value
if (!isOpen.value) isOpen.value = true if (!isOpen.value) isOpen.value = true
activeIndex.value = -1 activeIndex.value = -1
@@ -6,6 +6,7 @@ import SelectCheckbox from './SelectCheckbox.vue'
type Option = { type Option = {
label: string label: string
value: string | number value: string | number
color?: string
} }
type SelectCheckboxProps = { type SelectCheckboxProps = {
@@ -21,6 +22,7 @@ type SelectCheckboxProps = {
textLabel?: string textLabel?: string
rounded?: string rounded?: string
displayTag?: boolean displayTag?: boolean
maxTags?: number
displaySelectAll?: boolean displaySelectAll?: boolean
selectAllLabel?: string selectAllLabel?: string
disabled?: boolean disabled?: boolean
@@ -380,4 +382,63 @@ describe('MalioSelectCheckbox', () => {
expect(msg.exists()).toBe(true) expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]') expect(msg.classes()).not.toContain('min-h-[1rem]')
}) })
it('affiche tous les tags par défaut (maxTags non fourni)', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'be', 'ca'], options, displayTag: true},
})
expect(wrapper.find('[data-test="tags-overflow"]').exists()).toBe(false)
expect(wrapper.text()).toContain('France')
expect(wrapper.text()).toContain('Belgique')
expect(wrapper.text()).toContain('Canada')
})
it('limite le nombre de tags affichés et ajoute un badge +N', () => {
const manyOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Allemagne', value: 'de'},
]
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'be', 'ca', 'ch', 'de'], options: manyOptions, displayTag: true, maxTags: 3},
})
expect(wrapper.text()).toContain('France')
expect(wrapper.text()).toContain('Belgique')
expect(wrapper.text()).toContain('Canada')
expect(wrapper.text()).not.toContain('Suisse')
expect(wrapper.text()).not.toContain('Allemagne')
expect(wrapper.get('[data-test="tags-overflow"]').text()).toBe('+2')
})
it('naffiche pas de badge +N quand le nombre de tags est sous la limite', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr', 'be'], options, displayTag: true, maxTags: 3},
})
expect(wrapper.find('[data-test="tags-overflow"]').exists()).toBe(false)
})
it('applique la couleur de fond fournie par loption', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options: [{label: 'France', value: 'fr', color: '#fde2e2'}], displayTag: true},
})
const tag = wrapper.findAll('span.inline-flex')[0]
expect(tag.attributes('style')).toContain('background-color')
expect(tag.classes()).not.toContain('bg-m-bg')
})
it('utilise bg-m-bg quand loption na pas de couleur', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, displayTag: true},
})
const tag = wrapper.findAll('span.inline-flex')[0]
expect(tag.classes()).toContain('bg-m-bg')
expect(tag.attributes('style')).toBeFalsy()
})
}) })
+25 -4
View File
@@ -89,13 +89,25 @@
:class="[label ? 'pt-1' : '']" :class="[label ? 'pt-1' : '']"
> >
<span <span
v-for="option in selectedOptions" v-for="option in visibleTags"
:key="String(option.value)" :key="String(option.value)"
class="inline-flex max-w-full items-center rounded-md border px-2 text-sm leading-none" class="inline-flex max-w-full items-center rounded-md px-2 pt-[2px] pb-0 text-lg font-medium leading-[normal]"
:class="disabled ? 'border-black/40 text-black/60' : 'border-black text-black'" :class="[
option.color ? '' : 'bg-m-bg',
disabled ? 'text-black/60' : 'text-black',
]"
:style="option.color ? { backgroundColor: option.color } : undefined"
> >
<span class="truncate pb-[2px]">{{ option.label }}</span> <span class="truncate pb-[2px]">{{ option.label }}</span>
</span> </span>
<span
v-if="hiddenTagsCount > 0"
data-test="tags-overflow"
class="inline-flex items-center rounded-md bg-m-bg px-2 pt-[2px] pb-0 text-lg font-medium leading-[normal]"
:class="disabled ? 'text-black/60' : 'text-black'"
>
<span class="pb-[2px]">+{{ hiddenTagsCount }}</span>
</span>
</div> </div>
<span <span
@@ -269,7 +281,8 @@ const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRin
type Option = { type Option = {
label: string; label: string;
value: string | number value: string | number;
color?: string
} }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue?: Array<string | number> modelValue?: Array<string | number>
@@ -284,6 +297,7 @@ const props = withDefaults(defineProps<{
textLabel?: string textLabel?: string
rounded?: string rounded?: string
displayTag?: boolean displayTag?: boolean
maxTags?: number
displaySelectAll?: boolean displaySelectAll?: boolean
selectAllLabel?: string selectAllLabel?: string
disabled?: boolean disabled?: boolean
@@ -305,6 +319,7 @@ const props = withDefaults(defineProps<{
textLabel: 'text-sm', textLabel: 'text-sm',
rounded: 'rounded-md', rounded: 'rounded-md',
displayTag: false, displayTag: false,
maxTags: 0,
displaySelectAll: false, displaySelectAll: false,
selectAllLabel: 'Tout sélectionner', selectAllLabel: 'Tout sélectionner',
disabled: false, disabled: false,
@@ -347,6 +362,12 @@ const selectedOptions = computed(() =>
const displayTags = computed(() => const displayTags = computed(() =>
props.displayTag && selectedOptions.value.length > 0, props.displayTag && selectedOptions.value.length > 0,
) )
const visibleTags = computed(() =>
props.maxTags > 0 ? selectedOptions.value.slice(0, props.maxTags) : selectedOptions.value,
)
const hiddenTagsCount = computed(() =>
props.maxTags > 0 ? Math.max(selectedOptions.value.length - props.maxTags, 0) : 0,
)
const shouldFloatLabel = computed(() => const shouldFloatLabel = computed(() =>
isReadonly.value ? isOptionSelected.value : (isOpen.value || displayTags.value) isReadonly.value ? isOptionSelected.value : (isOpen.value || displayTags.value)
) )
+49 -7
View File
@@ -107,19 +107,24 @@ describe('MalioSidebar', () => {
expect(links[2].attributes('href')).toBe('/fournisseurs') expect(links[2].attributes('href')).toBe('/fournisseurs')
}) })
it('hover : fond + couleur + semi-bold tous portés par le <li> (texte non figé sur le <a>)', () => { it('hover : fond + couleur + semi-bold portés par le <li>', () => {
const wrapper = mountComponent({sections}) const wrapper = mountComponent({sections})
const li = wrapper.find('li') const li = wrapper.find('li')
expect(li.classes()).toContain('hover:bg-m-primary/10') expect(li.classes()).toContain('hover:bg-m-primary/10')
expect(li.classes()).toContain('hover:text-m-primary') expect(li.classes()).toContain('hover:text-m-primary')
expect(li.classes()).toContain('hover:font-semibold') expect(li.classes()).toContain('hover:font-semibold')
expect(li.classes()).toContain('text-black') expect(li.classes()).toContain('text-black')
expect(li.classes()).toContain('pt-1') })
expect(li.classes()).toContain('pb-1')
// Le <a> ne fige PAS sa couleur (sinon le texte resterait noir sur les bandes it('zone cliquable : le padding vertical est sur le <a>, pas sur le <li> (pas de bande morte au survol)', () => {
// pt-1/pb-1 hors du <a> alors que le fond du <li> est bleu). const wrapper = mountComponent({sections})
expect(wrapper.find('a').classes()).not.toContain('text-black') // Le padding vertical doit appartenir à la cible de clic (<a>) pour que toute
expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary') // la bande survolée soit cliquable — sinon pt-1/pb-1 sur le <li> crée une
// bande colorée mais non cliquable en haut et en bas du lien.
const li = wrapper.find('li')
expect(li.classes()).not.toContain('pt-1')
expect(li.classes()).not.toContain('pb-1')
expect(wrapper.find('a').classes()).toContain('py-1')
}) })
it('actif : route exacte → lien en primary + semi-bold, sans fond', () => { it('actif : route exacte → lien en primary + semi-bold, sans fond', () => {
@@ -239,6 +244,43 @@ describe('MalioSidebar', () => {
expect(wrapper.find('img[alt="M"]').exists()).toBe(true) expect(wrapper.find('img[alt="M"]').exists()).toBe(true)
}) })
it('renders footer slot when expanded', () => {
const wrapper = mountComponent({sections}, {
footer: '<a href="/logout">Déconnexion</a>',
})
expect(wrapper.find('a[href="/logout"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Déconnexion')
})
it('renders footer-collapsed slot when collapsed', async () => {
const wrapper = mountComponent({sections}, {
'footer-collapsed': '<span>FC</span>',
})
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('FC')
})
it('footer is rendered after the nav (pushed to the bottom)', () => {
const wrapper = mountComponent({sections}, {
footer: '<span class="ft">Footer</span>',
})
const children = wrapper.find('aside').element.children
const navIndex = Array.from(children).findIndex(el => el.tagName === 'NAV')
const footerEl = wrapper.find('.ft').element
const footerWrapperIndex = Array.from(children).findIndex(el => el.contains(footerEl))
expect(footerWrapperIndex).toBeGreaterThan(navIndex)
})
it('does not render a footer container when no footer slot is provided', () => {
const wrapper = mountComponent({sections})
// Seuls le bloc logo et le <nav> sont des conteneurs (+ le bouton toggle).
// Aucun div de footer ne doit apparaître après le <nav>.
const children = Array.from(wrapper.find('aside').element.children)
const navIndex = children.findIndex(el => el.tagName === 'NAV')
const after = children.slice(navIndex + 1)
expect(after.some(el => el.tagName === 'DIV')).toBe(false)
})
it('uses custom id when provided', () => { it('uses custom id when provided', () => {
const wrapper = mountComponent({sections, id: 'my-sidebar'}) const wrapper = mountComponent({sections, id: 'my-sidebar'})
expect(wrapper.find('aside').attributes('id')).toBe('my-sidebar') expect(wrapper.find('aside').attributes('id')).toBe('my-sidebar')
+16 -2
View File
@@ -49,14 +49,14 @@
<li <li
v-for="item in section.items" v-for="item in section.items"
:key="item.to" :key="item.to"
:class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary pt-1 pb-1'" :class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary'"
> >
<NuxtLink <NuxtLink
:to="item.to" :to="item.to"
active-class="!text-m-primary font-semibold" active-class="!text-m-primary font-semibold"
:class="twMerge( :class="twMerge(
'block truncate text-[15px] leading-[150%]', 'block truncate text-[15px] leading-[150%]',
collapsed ? 'px-3 text-center' : 'pl-[32px]', collapsed ? 'px-3 text-center' : 'pl-[32px] py-1',
isActive(item) ? '!text-m-primary font-semibold' : '', isActive(item) ? '!text-m-primary font-semibold' : '',
)" )"
> >
@@ -67,6 +67,20 @@
</div> </div>
</nav> </nav>
<div
v-if="$slots.footer || $slots['footer-collapsed']"
:class="['px-[20px] py-[14px]', collapsed ? '' : 'mx-[10px] border-t-2 border-m-primary']"
>
<slot
v-if="collapsed"
name="footer-collapsed"
/>
<slot
v-else
name="footer"
/>
</div>
<button <button
type="button" type="button"
:aria-label="collapsed ? 'Déplier le menu' : 'Plier le menu'" :aria-label="collapsed ? 'Déplier le menu' : 'Plier le menu'"
+41
View File
@@ -43,6 +43,37 @@
</div> </div>
</div> </div>
</Variant> </Variant>
<Variant title="Avec footer (collé en bas)">
<div class="flex h-[600px] border rounded-lg overflow-hidden">
<MalioSidebar
v-model="collapsed3"
:sections="sectionsLong"
>
<template #logo>
<span class="text-2xl font-bold text-m-primary">Malio</span>
</template>
<template #logo-collapsed>
<span class="text-2xl font-bold text-m-primary">M</span>
</template>
<template #footer>
<div class="leading-tight">
<p class="text-[14px] font-semibold text-m-text">Tristan</p>
<p class="text-[12px] text-m-muted">Administrateur</p>
</div>
</template>
<template #footer-collapsed>
<span class="block text-center text-[14px] font-bold text-m-primary">T</span>
</template>
</MalioSidebar>
<div class="flex-1 p-6 bg-white">
<p class="text-m-muted">
Le footer reste collé en bas même quand la nav scrolle.
</p>
</div>
</div>
</Variant>
</Story> </Story>
</template> </template>
@@ -94,6 +125,15 @@ entre les deux états.
- Contenu affiché en haut quand la sidebar est pliée. - Contenu affiché en haut quand la sidebar est pliée.
### footer
- Contenu affiché en bas quand la sidebar est dépliée (profil, déconnexion, version).
- Toujours collé en bas : la nav occupe l'espace restant (`flex-1`) et pousse le footer.
### footer-collapsed
- Contenu affiché en bas quand la sidebar est pliée.
------------------------------------------------------------------------ ------------------------------------------------------------------------
## Comportement ## Comportement
@@ -127,6 +167,7 @@ import MalioSidebar from '../../components/malio/sidebar/Sidebar.vue'
const collapsed1 = ref(false) const collapsed1 = ref(false)
const collapsed2 = ref(false) const collapsed2 = ref(false)
const collapsed3 = ref(false)
const sectionsShort = [ const sectionsShort = [
{ {