fix: rendre le footer du Drawer hors zone scrollable (épinglé en bas)

Le slot #footer était rendu à l'intérieur du body overflow-y-auto, ce qui
faisait courir la scrollbar sur toute la hauteur, derrière le footer. Il est
désormais frère du body (comme MalioModal) : seul le body défile et le footer
reste fixé en bas. Tests, story, pages playground et doc alignés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:50:55 +02:00
parent 951acd448e
commit 280b650e49
7 changed files with 32 additions and 41 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>
+1
View File
@@ -41,5 +41,6 @@ Liste des évolutions de la librairie Malio layer UI
* [#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
+6 -8
View File
@@ -813,14 +813,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 +836,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>
+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>
+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>