Compare commits
65 Commits
v1.5.1
...
b2e3a83bb9
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e3a83bb9 | |||
| 9ed094ba86 | |||
| 1ffe63827d | |||
| eb21827686 | |||
| 6938e730b6 | |||
| 174f1f9a64 | |||
| 30efd482d8 | |||
| 7dec45b374 | |||
| ea92acff3a | |||
| a3421c02e9 | |||
| 5563d89743 | |||
| 640ff90187 | |||
| 2eb7a5247a | |||
| 3336ff0c69 | |||
| da3a4cb349 | |||
| 0ddae4dd70 | |||
| 23210e6868 | |||
| 1c0fcd24e3 | |||
| d74f3acc97 | |||
| 014a057196 | |||
| 73483b0573 | |||
| 4855923008 | |||
| fc844078a6 | |||
| 02495245a5 | |||
| 330fb2130b | |||
| 5acefc1d59 | |||
| e77bf49146 | |||
| f59f866354 | |||
| 660c3787fd | |||
| e9741ff38d | |||
| 32608c8f71 | |||
| e1965db04e | |||
| 0ad344bab9 | |||
| 96719be78d | |||
| b90baec571 | |||
| 384f86a3b3 | |||
| e8ddf4e083 | |||
| 7ee64289a8 | |||
| f09f8a91ac | |||
| bcadd46ce2 | |||
| e76337502a | |||
| 968b7087b5 | |||
| 3deba3f369 | |||
| cf46ab0c85 | |||
| 09cc3edf6f | |||
| c95a3657c0 | |||
| 9843f4d032 | |||
| 9d9b9c9dc4 | |||
| 187ef52865 | |||
| 9925f1ced4 | |||
| ded414ba1a | |||
| 11d60e687b | |||
| d3038994c3 | |||
| 0d350e12c6 | |||
| c6acaace27 | |||
| 927c7c3c70 | |||
| bf0aa92497 | |||
| 88dd76a0e4 | |||
| cc04114f89 | |||
| f456ea4ddf | |||
| 77364daa67 | |||
| 1ab7b2427a | |||
| 82ecc9cfe2 | |||
| 65d9060e26 | |||
| ec4c157226 |
@@ -1,24 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-screen">
|
|
||||||
<MalioSidebar :sections="navSections">
|
|
||||||
<template #logo>
|
|
||||||
<NuxtLink to="/">
|
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio">
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<template #logo-collapsed>
|
|
||||||
<NuxtLink to="/">
|
|
||||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</MalioSidebar>
|
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto p-6">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {navSections} from '../playground.nav'
|
|
||||||
</script>
|
|
||||||
@@ -1,88 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const drawerRight = ref(false)
|
const drawerDefault = ref(false)
|
||||||
const drawerLeft = ref(false)
|
const drawerNoClose = ref(false)
|
||||||
const drawerForm = ref(false)
|
const drawerCustomWidth = ref(false)
|
||||||
const drawerFixedFooter = ref(false)
|
const drawerWithForm = ref(false)
|
||||||
const drawerNoDismiss = ref(false)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
<div class="rounded-lg border p-6">
|
<div class="rounded-lg border p-6">
|
||||||
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
|
<h2 class="mb-6 text-xl font-bold">Drawer simple</h2>
|
||||||
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
|
<MalioButton label="Ouvrir le drawer" @click="drawerDefault = true" />
|
||||||
<MalioDrawer v-model="drawerRight">
|
<MalioDrawer v-model="drawerDefault" title="Titre du drawer">
|
||||||
<template #header>
|
<p class="text-m-text">Contenu du drawer. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||||
<h2 class="text-[24px] font-bold text-black">Détails</h2>
|
|
||||||
</template>
|
|
||||||
<p class="text-m-text">Contenu du drawer. Échap, clic backdrop et croix le ferment.</p>
|
|
||||||
</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">Drawer gauche</h2>
|
<h2 class="mb-6 text-xl font-bold">Sans bouton fermer</h2>
|
||||||
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
|
<MalioButton label="Ouvrir le drawer" variant="secondary" @click="drawerNoClose = true" />
|
||||||
<MalioDrawer v-model="drawerLeft" side="left">
|
<MalioDrawer v-model="drawerNoClose" title="Sans croix" :show-close="false">
|
||||||
<template #header>
|
<p class="text-m-text">Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
||||||
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
|
|
||||||
</template>
|
|
||||||
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
|
|
||||||
</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 collant</h2>
|
<h2 class="mb-6 text-xl font-bold">Largeur personnalisée</h2>
|
||||||
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
<MalioButton label="Ouvrir le drawer large" variant="tertiary" @click="drawerCustomWidth = true" />
|
||||||
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
<MalioDrawer v-model="drawerCustomWidth" title="Drawer large" drawer-class="max-w-2xl">
|
||||||
<template #header>
|
<p class="text-m-text">Ce drawer utilise une largeur personnalisée via drawerClass.</p>
|
||||||
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
</MalioDrawer>
|
||||||
</template>
|
</div>
|
||||||
<div class="flex flex-col gap-4 py-2">
|
|
||||||
|
<div class="rounded-lg border p-6">
|
||||||
|
<h2 class="mb-6 text-xl font-bold">Avec formulaire</h2>
|
||||||
|
<MalioButton label="Ouvrir le formulaire" variant="danger" @click="drawerWithForm = true" />
|
||||||
|
<MalioDrawer v-model="drawerWithForm" title="Formulaire">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
<MalioInputText label="Nom" />
|
<MalioInputText label="Nom" />
|
||||||
<MalioInputText label="Prénom" />
|
<MalioInputText label="Prénom" />
|
||||||
<MalioInputText label="Email" />
|
<MalioInputText label="Email" />
|
||||||
|
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
|
||||||
</div>
|
</div>
|
||||||
<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="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MalioDrawer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
|
||||||
<h2 class="mb-6 text-xl font-bold">Avec footer fixed bottom</h2>
|
|
||||||
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" />
|
|
||||||
<MalioDrawer v-model="drawerFixedFooter">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
|
||||||
</template>
|
|
||||||
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu -->
|
|
||||||
<div class="flex flex-col gap-4 pb-24">
|
|
||||||
<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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut -->
|
|
||||||
<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>
|
|
||||||
</MalioDrawer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border p-6">
|
|
||||||
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
|
|
||||||
<MalioButton label="Ouvrir" variant="danger" @click="drawerNoDismiss = true" />
|
|
||||||
<MalioDrawer v-model="drawerNoDismiss" :dismissable="false" :close-on-escape="false">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
|
|
||||||
</template>
|
|
||||||
<p class="text-m-text">Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,189 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-2xl py-16 text-center">
|
<div class="flex min-h-screen">
|
||||||
<h1 class="text-3xl font-bold text-m-text">
|
<aside class="w-72 bg-m-bg p-6 text-white">
|
||||||
Playground @malio/layer-ui
|
<button
|
||||||
</h1>
|
type="button"
|
||||||
<p class="mt-4 text-m-muted">
|
class="text-xl text-black font-semibold"
|
||||||
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
|
@click="clearSelection"
|
||||||
</p>
|
>
|
||||||
|
Liste des composants
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="mt-6 flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.category"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-black font-bold hover:bg-m-primary/10"
|
||||||
|
@click="toggleCategory(group.category)"
|
||||||
|
>
|
||||||
|
{{ group.category }}
|
||||||
|
<span
|
||||||
|
class="text-xs transition-transform duration-200"
|
||||||
|
:class="openCategories.has(group.category) ? 'rotate-90' : ''"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="openCategories.has(group.category)"
|
||||||
|
class="ml-3 flex flex-col gap-1 border-l border-gray-300 pl-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.name"
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1.5 text-left text-sm text-black hover:bg-m-primary hover:text-white"
|
||||||
|
:class="selectedName === item.name ? 'bg-m-primary/50 text-white' : ''"
|
||||||
|
@click="selectItem(item.name)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6">
|
||||||
|
<component
|
||||||
|
:is="selectedDemoComponent"
|
||||||
|
v-if="selectedDemoComponent"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else-if="selectedName"
|
||||||
|
class="text-gray-700"
|
||||||
|
>
|
||||||
|
Page de demo introuvable:
|
||||||
|
<code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
|
||||||
|
</p>
|
||||||
|
<div v-else>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">
|
||||||
|
Playground composants
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600">
|
||||||
|
Selectionne un composant dans la liste pour afficher sa page de demo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, reactive, ref, watch, shallowRef} from 'vue'
|
||||||
|
|
||||||
|
type LoadedModule = {
|
||||||
|
default: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
category: string
|
||||||
|
items: Item[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentModules = import.meta.glob('../../app/components/malio/**/*.vue')
|
||||||
|
const demoModules = import.meta.glob('./composant/**/*.vue')
|
||||||
|
|
||||||
|
const demoByName: Record<string, () => Promise<LoadedModule>> =
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(demoModules).map(([file, loader]) => {
|
||||||
|
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
|
||||||
|
return [name.toLowerCase(), loader as () => Promise<LoadedModule>]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = computed<Group[]>(() => {
|
||||||
|
const categoryMap = new Map<string, Item[]>()
|
||||||
|
|
||||||
|
Object.keys(componentModules).forEach((file) => {
|
||||||
|
const parts = file.split('/')
|
||||||
|
const name = parts.pop()?.replace('.vue', '') ?? ''
|
||||||
|
const category = parts.pop() ?? ''
|
||||||
|
|
||||||
|
if (!categoryMap.has(category)) {
|
||||||
|
categoryMap.set(category, [])
|
||||||
|
}
|
||||||
|
categoryMap.get(category)!.push({name, label: name})
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentGroups = Array.from(categoryMap.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([category, items]) => ({
|
||||||
|
category: category.charAt(0).toUpperCase() + category.slice(1),
|
||||||
|
items: items.sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
...componentGroups,
|
||||||
|
{
|
||||||
|
category: 'Form',
|
||||||
|
items: [{name: 'client', label: 'Client'}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCategories = reactive(new Set<string>())
|
||||||
|
const selectedName = ref('')
|
||||||
|
const hasInitializedSelection = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
groups,
|
||||||
|
(val) => {
|
||||||
|
if (!hasInitializedSelection.value && val.length > 0) {
|
||||||
|
openCategories.add(val[0].category)
|
||||||
|
if (val[0].items.length > 0) {
|
||||||
|
selectedName.value = val[0].items[0].name
|
||||||
|
}
|
||||||
|
hasInitializedSelection.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleCategory(category: string) {
|
||||||
|
if (openCategories.has(category)) {
|
||||||
|
openCategories.delete(category)
|
||||||
|
} else {
|
||||||
|
openCategories.add(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(name: string) {
|
||||||
|
selectedName.value = selectedName.value === name ? '' : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDemoComponent = shallowRef<unknown>(null)
|
||||||
|
|
||||||
|
watch(selectedName, async (name) => {
|
||||||
|
if (!name) {
|
||||||
|
selectedDemoComponent.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = demoByName[name.toLowerCase()]
|
||||||
|
if (!loader) {
|
||||||
|
selectedDemoComponent.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = await loader()
|
||||||
|
selectedDemoComponent.value = mod.default
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDemoFileName = computed(() => {
|
||||||
|
const name = selectedName.value
|
||||||
|
if (!name) return ''
|
||||||
|
return name.charAt(0).toLowerCase() + name.slice(1)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
|
|
||||||
|
|
||||||
export const navSections: SidebarSection[] = [
|
|
||||||
{
|
|
||||||
label: 'BOUTONS',
|
|
||||||
icon: 'mdi:gesture-tap-button',
|
|
||||||
items: [
|
|
||||||
{label: 'Button', to: '/composant/button/button'},
|
|
||||||
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'CHAMPS',
|
|
||||||
icon: 'mdi:form-textbox',
|
|
||||||
items: [
|
|
||||||
{label: 'Texte', to: '/composant/input/inputText'},
|
|
||||||
{label: 'Nombre', to: '/composant/input/inputNumber'},
|
|
||||||
{label: 'Montant', to: '/composant/input/inputAmount'},
|
|
||||||
{label: 'Email', to: '/composant/input/inputEmail'},
|
|
||||||
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
|
|
||||||
{label: 'Téléphone', to: '/composant/input/inputPhone'},
|
|
||||||
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
|
|
||||||
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
|
|
||||||
{label: 'Upload', to: '/composant/input/inputUpload'},
|
|
||||||
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'SÉLECTIONS',
|
|
||||||
icon: 'mdi:form-dropdown',
|
|
||||||
items: [
|
|
||||||
{label: 'Select', to: '/composant/select/select'},
|
|
||||||
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
|
||||||
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
|
||||||
{label: 'Radio', to: '/composant/radio/radioButton'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'NAVIGATION',
|
|
||||||
icon: 'mdi:navigation-variant',
|
|
||||||
items: [
|
|
||||||
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
|
||||||
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
|
||||||
{label: 'Onglets', to: '/composant/tab/tabList'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'DONNÉES',
|
|
||||||
icon: 'mdi:table',
|
|
||||||
items: [
|
|
||||||
{label: 'DataTable', to: '/composant/datatable/datatable'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'DIVERS',
|
|
||||||
icon: 'mdi:dots-horizontal',
|
|
||||||
items: [
|
|
||||||
{label: 'Heure', to: '/composant/time/time'},
|
|
||||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
|
||||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -30,10 +30,8 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-30] Création d'un composant email
|
* [#MUI-30] Création d'un composant email
|
||||||
* [#MUI-31] Création d'un composant téléphone
|
* [#MUI-31] Création d'un composant téléphone
|
||||||
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
||||||
* [#MUI-34] Revoir le système de playground
|
|
||||||
|
|
||||||
### 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`.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* 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)
|
||||||
|
|||||||
@@ -584,59 +584,28 @@ Barre latérale de navigation rétractable.
|
|||||||
|
|
||||||
## MalioDrawer
|
## MalioDrawer
|
||||||
|
|
||||||
Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs drawers.
|
Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transparent.
|
||||||
|
|
||||||
| Prop | Type | Défaut | Description |
|
| Prop | Type | Défaut | Description |
|
||||||
|------|------|--------|-------------|
|
|------|------|--------|-------------|
|
||||||
| `id` | `string` | auto | Identifiant HTML |
|
| `id` | `string` | auto | Identifiant HTML |
|
||||||
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
||||||
| `side` | `'right' \| 'left'` | `'right'` | Côté d'apparition |
|
| `title` | `string` | `''` | Titre affiché dans le header |
|
||||||
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
||||||
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
|
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
|
||||||
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
|
|
||||||
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
|
|
||||||
| `drawerClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-2xl` (twMerge) |
|
|
||||||
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
|
||||||
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
|
||||||
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
|
||||||
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) |
|
|
||||||
|
|
||||||
**Events :** `update:modelValue(value: boolean)`, `close()`
|
**Events :** `update:modelValue(value: boolean)`
|
||||||
|
**Slots :** `default` (contenu du drawer)
|
||||||
**Slots :**
|
|
||||||
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
|
||||||
- `default` — contenu (zone scrollable).
|
|
||||||
- `footer` — rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien).
|
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDrawer v-model="isOpen">
|
<MalioDrawer v-model="isOpen" title="Détails">
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold">Détails</h2>
|
|
||||||
</template>
|
|
||||||
<p>Contenu du drawer</p>
|
<p>Contenu du drawer</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
<MalioDrawer v-model="isOpen" title="Sans croix" :show-close="false">
|
||||||
<!-- Côté gauche, largeur custom -->
|
<p>Fermeture uniquement via backdrop</p>
|
||||||
<MalioDrawer v-model="isOpen" side="left" drawer-class="max-w-2xl">
|
|
||||||
<template #header><h2>Navigation</h2></template>
|
|
||||||
<p>Drawer large depuis la gauche</p>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
<MalioDrawer v-model="isOpen" title="Large" drawer-class="max-w-2xl">
|
||||||
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
|
<p>Drawer plus large</p>
|
||||||
<MalioDrawer v-model="isOpen">
|
|
||||||
<template #header><h2>Formulaire</h2></template>
|
|
||||||
<MalioInputText label="Nom" />
|
|
||||||
<template #footer>
|
|
||||||
<div class="sticky bottom-0 bg-white py-4">
|
|
||||||
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MalioDrawer>
|
|
||||||
|
|
||||||
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
|
|
||||||
<MalioDrawer v-model="isOpen" :dismissable="false" :close-on-escape="false">
|
|
||||||
<template #header><h2>Action requise</h2></template>
|
|
||||||
<p>Fermeture via la croix uniquement</p>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from 'vue'
|
||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import Drawer from './Drawer.vue'
|
import Drawer from './Drawer.vue'
|
||||||
|
|
||||||
type DrawerProps = {
|
type DrawerProps = {
|
||||||
id?: string
|
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
side?: 'right' | 'left'
|
title?: string
|
||||||
showClose?: boolean
|
showClose?: boolean
|
||||||
dismissable?: boolean
|
id?: string
|
||||||
closeOnEscape?: boolean
|
|
||||||
ariaLabel?: string
|
|
||||||
drawerClass?: string
|
drawerClass?: string
|
||||||
overlayClass?: string
|
|
||||||
headerClass?: string
|
|
||||||
bodyClass?: string
|
|
||||||
footerClass?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
|
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
|
||||||
@@ -25,38 +18,64 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
|
|||||||
return mount(DrawerForTest, {
|
return mount(DrawerForTest, {
|
||||||
props,
|
props,
|
||||||
slots,
|
slots,
|
||||||
global: { stubs: { Teleport: true } },
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Teleport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MalioDrawer', () => {
|
describe('MalioDrawer', () => {
|
||||||
enableAutoUnmount(afterEach)
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render when modelValue is false', () => {
|
it('does not render when modelValue is false', () => {
|
||||||
const wrapper = mountComponent({ modelValue: false })
|
const wrapper = mountComponent({ modelValue: false })
|
||||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the panel when modelValue is true', () => {
|
it('renders when modelValue is true', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders default slot in the body', () => {
|
it('renders the title', () => {
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' })
|
||||||
{ modelValue: true },
|
expect(wrapper.find('h2').text()).toBe('Mon tiroir')
|
||||||
{ default: '<p data-test="content">Contenu</p>' },
|
|
||||||
)
|
|
||||||
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('works in uncontrolled mode (defaults closed)', () => {
|
it('renders slot content', () => {
|
||||||
const wrapper = mountComponent()
|
const wrapper = mountComponent(
|
||||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
{ modelValue: true },
|
||||||
|
{ default: '<p data-test="content">Contenu du drawer</p>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false on backdrop click', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false on close button click', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="close-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows close button by default', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides close button when showClose is false', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close button renders mdi:close icon', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const icon = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(icon.props('icon')).toBe('mdi:close')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses custom id when provided', () => {
|
it('uses custom id when provided', () => {
|
||||||
@@ -66,276 +85,38 @@ describe('MalioDrawer', () => {
|
|||||||
|
|
||||||
it('generates an id when not provided', () => {
|
it('generates an id when not provided', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
|
const id = wrapper.find('.fixed').attributes('id')
|
||||||
|
expect(id).toMatch(/^malio-drawer-/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has role="dialog" and aria-modal on the panel', () => {
|
it('has role="dialog" and aria-modal on panel', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
const panel = wrapper.find('[data-test="panel"]')
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
expect(panel.attributes('role')).toBe('dialog')
|
expect(panel.attributes('role')).toBe('dialog')
|
||||||
expect(panel.attributes('aria-modal')).toBe('true')
|
expect(panel.attributes('aria-modal')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('aria-labelledby links to title id', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, id: 'test-drawer' })
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-title')
|
||||||
|
expect(wrapper.find('h2').attributes('id')).toBe('test-drawer-title')
|
||||||
|
})
|
||||||
|
|
||||||
it('applies drawerClass to the panel', () => {
|
it('applies drawerClass to the panel', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
|
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' })
|
||||||
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.classes()).toContain('max-w-lg')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the #header slot inside the header bar', () => {
|
it('works in uncontrolled mode', () => {
|
||||||
const wrapper = mountComponent(
|
const wrapper = mountComponent()
|
||||||
{ modelValue: true },
|
// Without modelValue, defaults to closed
|
||||||
{ header: '<h2 data-test="title">Titre</h2>' },
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
)
|
|
||||||
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the header bar when showClose is true even without #header', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render the header bar when no #header and showClose is false', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
|
||||||
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the close button by default', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides the close button when showClose is false', () => {
|
|
||||||
const wrapper = mountComponent(
|
|
||||||
{ modelValue: true, showClose: false },
|
|
||||||
{ header: '<h2>Titre</h2>' },
|
|
||||||
)
|
|
||||||
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('close button renders mdi:cancel-bold icon', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
const icon = wrapper.findComponent(IconifyIcon)
|
|
||||||
expect(icon.props('icon')).toBe('mdi:cancel-bold')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('close button has aria-label "Fermer"', () => {
|
it('close button has aria-label "Fermer"', () => {
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits update:modelValue false and close on close button click', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
await wrapper.find('[data-test="close-button"]').trigger('click')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
||||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets aria-labelledby to the header id when #header is provided', () => {
|
|
||||||
const wrapper = mountComponent(
|
|
||||||
{ modelValue: true, id: 'test-drawer' },
|
|
||||||
{ header: '<h2>Titre</h2>' },
|
|
||||||
)
|
|
||||||
const panel = wrapper.find('[data-test="panel"]')
|
|
||||||
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header')
|
|
||||||
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets aria-label from ariaLabel when no #header is provided', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' })
|
|
||||||
const panel = wrapper.find('[data-test="panel"]')
|
|
||||||
expect(panel.attributes('aria-label')).toBe('Panneau latéral')
|
|
||||||
expect(panel.attributes('aria-labelledby')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies headerClass to the header bar', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
|
|
||||||
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the #footer slot inside the body (scrollable zone)', () => {
|
|
||||||
const wrapper = mountComponent(
|
|
||||||
{ modelValue: true },
|
|
||||||
{ footer: '<button data-test="save">Enregistrer</button>' },
|
|
||||||
)
|
|
||||||
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render the footer wrapper when no #footer slot', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies bodyClass to the body', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
|
|
||||||
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies footerClass to the footer wrapper', () => {
|
|
||||||
const wrapper = mountComponent(
|
|
||||||
{ modelValue: true, footerClass: 'sticky bottom-0' },
|
|
||||||
{ footer: '<span>pied</span>' },
|
|
||||||
)
|
|
||||||
const footer = wrapper.find('[data-test="footer"]')
|
|
||||||
expect(footer.classes()).toContain('sticky')
|
|
||||||
expect(footer.classes()).toContain('bottom-0')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aligns to the right by default', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
expect(wrapper.find('.fixed').classes()).toContain('justify-end')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aligns to the left when side is "left"', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, side: 'left' })
|
|
||||||
expect(wrapper.find('.fixed').classes()).toContain('justify-start')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
||||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not close on backdrop click when dismissable is false', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, dismissable: false })
|
|
||||||
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies overlayClass to the backdrop', () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
|
|
||||||
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closes on Escape key when closeOnEscape is true', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true })
|
|
||||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
||||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not close on Escape when closeOnEscape is false', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
|
|
||||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('locks body scroll when opened and restores it when closed', async () => {
|
|
||||||
const wrapper = mountComponent({ modelValue: false })
|
|
||||||
expect(document.body.style.overflow).toBe('')
|
|
||||||
await wrapper.setProps({ modelValue: true })
|
|
||||||
expect(document.body.style.overflow).toBe('hidden')
|
|
||||||
await wrapper.setProps({ modelValue: false })
|
|
||||||
expect(document.body.style.overflow).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('moves focus into the panel when opened', async () => {
|
|
||||||
const wrapper = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: false, showClose: false },
|
|
||||||
slots: { default: '<button data-test="first">OK</button>' },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
await wrapper.setProps({ modelValue: true })
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
const first = wrapper.find('[data-test="first"]').element
|
|
||||||
expect(document.activeElement).toBe(first)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restores focus to the trigger when closed', async () => {
|
|
||||||
const trigger = document.createElement('button')
|
|
||||||
document.body.appendChild(trigger)
|
|
||||||
trigger.focus()
|
|
||||||
expect(document.activeElement).toBe(trigger)
|
|
||||||
|
|
||||||
const wrapper = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: false },
|
|
||||||
slots: { default: '<button>OK</button>' },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
await wrapper.setProps({ modelValue: true })
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
await wrapper.setProps({ modelValue: false })
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
expect(document.activeElement).toBe(trigger)
|
|
||||||
|
|
||||||
wrapper.unmount()
|
|
||||||
trigger.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('moves focus to the close button on open (default showClose)', async () => {
|
|
||||||
const wrapper = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: false, showClose: true },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
await wrapper.setProps({ modelValue: true })
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
|
|
||||||
const wrapper = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: true, showClose: false },
|
|
||||||
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
|
|
||||||
last.focus()
|
|
||||||
expect(document.activeElement).toBe(last)
|
|
||||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
|
|
||||||
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
|
|
||||||
const wrapper = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: true, showClose: false },
|
|
||||||
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
|
|
||||||
first.focus()
|
|
||||||
expect(document.activeElement).toBe(first)
|
|
||||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
|
|
||||||
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => {
|
|
||||||
const wrapperA = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: false },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
const wrapperB = mount(DrawerForTest, {
|
|
||||||
props: { modelValue: false },
|
|
||||||
attachTo: document.body,
|
|
||||||
global: { stubs: { Teleport: true } },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Open drawer A → scroll locked
|
|
||||||
await wrapperA.setProps({ modelValue: true })
|
|
||||||
expect(document.body.style.overflow).toBe('hidden')
|
|
||||||
|
|
||||||
// Open drawer B → still locked
|
|
||||||
await wrapperB.setProps({ modelValue: true })
|
|
||||||
expect(document.body.style.overflow).toBe('hidden')
|
|
||||||
|
|
||||||
// Close drawer B → A is still open, scroll must remain locked
|
|
||||||
await wrapperB.setProps({ modelValue: false })
|
|
||||||
expect(document.body.style.overflow).toBe('hidden')
|
|
||||||
|
|
||||||
// Close drawer A → both closed, scroll-lock released
|
|
||||||
await wrapperA.setProps({ modelValue: false })
|
|
||||||
expect(document.body.style.overflow).toBe('')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,76 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition
|
<Transition
|
||||||
:name="`drawer-${side}`"
|
name="drawer"
|
||||||
appear
|
appear
|
||||||
@after-leave="isRendered = false"
|
@after-leave="isRendered = false"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isRendered && isOpen"
|
v-if="isRendered && isOpen"
|
||||||
:id="componentId"
|
:id="componentId"
|
||||||
class="fixed inset-0 z-50 flex"
|
class="fixed inset-0 z-50 flex justify-end"
|
||||||
:class="side === 'right' ? 'justify-end' : 'justify-start'"
|
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
class="absolute inset-0 bg-black/40"
|
||||||
data-test="backdrop"
|
data-test="backdrop"
|
||||||
@click="onBackdropClick"
|
@click="close"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="panelRef"
|
|
||||||
:class="twMerge(
|
:class="twMerge(
|
||||||
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
|
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl',
|
||||||
drawerClass,
|
drawerClass,
|
||||||
)"
|
)"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
:aria-modal="true"
|
||||||
:aria-labelledby="hasHeader ? headerId : undefined"
|
:aria-labelledby="titleId"
|
||||||
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
|
||||||
tabindex="-1"
|
|
||||||
data-test="panel"
|
data-test="panel"
|
||||||
@keydown="onKeydown"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex items-center justify-between px-5 pb-8 pt-8">
|
||||||
v-if="hasHeader || showClose"
|
<h2
|
||||||
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
:id="titleId"
|
||||||
data-test="header"
|
class="text-[32px] font-semibold text-m-primary"
|
||||||
>
|
|
||||||
<div
|
|
||||||
:id="headerId"
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
data-test="header-content"
|
|
||||||
>
|
>
|
||||||
<slot name="header" />
|
{{ title }}
|
||||||
</div>
|
</h2>
|
||||||
<button
|
<button
|
||||||
v-if="showClose"
|
v-if="showClose"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Fermer"
|
aria-label="Fermer"
|
||||||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
||||||
data-test="close-button"
|
data-test="close-button"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
icon="mdi:cancel-bold"
|
icon="mdi:close"
|
||||||
:width="16"
|
:width="24"
|
||||||
:height="16"
|
:height="24"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
class="flex-1 overflow-y-auto px-5"
|
||||||
data-test="body"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<div
|
|
||||||
v-if="$slots.footer"
|
|
||||||
:class="footerClass"
|
|
||||||
data-test="footer"
|
|
||||||
>
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,17 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { computed, ref, useAttrs, useId, watch } from 'vue'
|
||||||
computed,
|
|
||||||
nextTick,
|
|
||||||
onBeforeUnmount,
|
|
||||||
onMounted,
|
|
||||||
ref,
|
|
||||||
useAttrs,
|
|
||||||
useId,
|
|
||||||
useSlots,
|
|
||||||
watch,
|
|
||||||
} from 'vue'
|
|
||||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
@@ -99,195 +72,68 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
side?: 'right' | 'left'
|
title?: string
|
||||||
showClose?: boolean
|
showClose?: boolean
|
||||||
dismissable?: boolean
|
|
||||||
closeOnEscape?: boolean
|
|
||||||
ariaLabel?: string
|
|
||||||
drawerClass?: string
|
drawerClass?: string
|
||||||
overlayClass?: string
|
|
||||||
headerClass?: string
|
|
||||||
bodyClass?: string
|
|
||||||
footerClass?: string
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
id: '',
|
id: '',
|
||||||
modelValue: undefined,
|
modelValue: undefined,
|
||||||
side: 'right',
|
title: '',
|
||||||
showClose: true,
|
showClose: true,
|
||||||
dismissable: true,
|
|
||||||
closeOnEscape: true,
|
|
||||||
ariaLabel: '',
|
|
||||||
drawerClass: '',
|
drawerClass: '',
|
||||||
overlayClass: '',
|
|
||||||
headerClass: '',
|
|
||||||
bodyClass: '',
|
|
||||||
footerClass: '',
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
(e: 'close'): void
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
|
|
||||||
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
||||||
|
const titleId = computed(() => `${componentId.value}-title`)
|
||||||
const slots = useSlots()
|
|
||||||
const headerId = computed(() => `${componentId.value}-header`)
|
|
||||||
const hasHeader = computed(() => !!slots.header)
|
|
||||||
|
|
||||||
const isControlled = computed(() => props.modelValue !== undefined)
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
const localValue = ref(false)
|
const localValue = ref(false)
|
||||||
|
|
||||||
const isOpen = computed(() =>
|
const isOpen = computed(() =>
|
||||||
isControlled.value ? props.modelValue! : localValue.value,
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isRendered = ref(isOpen.value)
|
const isRendered = ref(isOpen.value)
|
||||||
|
|
||||||
const panelRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
let previouslyFocused: HTMLElement | null = null
|
|
||||||
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
|
|
||||||
let lockedByThisInstance = false
|
|
||||||
|
|
||||||
function getFocusable(container: HTMLElement): HTMLElement[] {
|
|
||||||
return Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(
|
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
|
|
||||||
),
|
|
||||||
).filter((el) => el.tabIndex !== -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOpen() {
|
|
||||||
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
|
||||||
if (!lockedByThisInstance) {
|
|
||||||
lockedByThisInstance = true
|
|
||||||
openDrawerCount++
|
|
||||||
if (openDrawerCount === 1) {
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
|
||||||
const panel = panelRef.value
|
|
||||||
if (!panel) return
|
|
||||||
const focusable = getFocusable(panel)
|
|
||||||
;(focusable[0] ?? panel).focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClose() {
|
|
||||||
if (lockedByThisInstance) {
|
|
||||||
lockedByThisInstance = false
|
|
||||||
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
|
||||||
if (openDrawerCount === 0) {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previouslyFocused?.focus?.()
|
|
||||||
previouslyFocused = null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isOpen, (val) => {
|
watch(isOpen, (val) => {
|
||||||
if (val) {
|
if (val) isRendered.value = true
|
||||||
isRendered.value = true
|
|
||||||
onOpen()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (isOpen.value) onOpen()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
// If this instance is still holding a scroll-lock slot, release it.
|
|
||||||
if (lockedByThisInstance) {
|
|
||||||
lockedByThisInstance = false
|
|
||||||
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
|
||||||
if (openDrawerCount === 0) {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function onBackdropClick() {
|
|
||||||
if (props.dismissable) close()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape' && props.closeOnEscape) {
|
|
||||||
e.stopPropagation()
|
|
||||||
close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key !== 'Tab') return
|
|
||||||
|
|
||||||
const panel = panelRef.value
|
|
||||||
if (!panel) return
|
|
||||||
const focusable = getFocusable(panel)
|
|
||||||
if (focusable.length === 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
panel.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const first = focusable[0]!
|
|
||||||
const last = focusable[focusable.length - 1]!
|
|
||||||
if (e.shiftKey && document.activeElement === first) {
|
|
||||||
e.preventDefault()
|
|
||||||
last.focus()
|
|
||||||
}
|
|
||||||
else if (!e.shiftKey && document.activeElement === last) {
|
|
||||||
e.preventDefault()
|
|
||||||
first.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!isControlled.value) localValue.value = false
|
if (!isControlled.value) {
|
||||||
|
localValue.value = false
|
||||||
|
}
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
emit('close')
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
// Shared across all MalioDrawer instances: only the last open drawer releases the body scroll-lock.
|
|
||||||
let openDrawerCount = 0
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.drawer-right-enter-active,
|
.drawer-enter-active,
|
||||||
.drawer-right-leave-active,
|
.drawer-leave-active {
|
||||||
.drawer-left-enter-active,
|
|
||||||
.drawer-left-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-right-enter-active > div:last-child,
|
.drawer-enter-active > div:last-child,
|
||||||
.drawer-right-leave-active > div:last-child,
|
.drawer-leave-active > div:last-child {
|
||||||
.drawer-left-enter-active > div:last-child,
|
|
||||||
.drawer-left-leave-active > div:last-child {
|
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-right-enter-from,
|
.drawer-enter-from,
|
||||||
.drawer-right-leave-to,
|
.drawer-leave-to {
|
||||||
.drawer-left-enter-from,
|
|
||||||
.drawer-left-leave-to {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-right-enter-from > div:last-child,
|
.drawer-enter-from > div:last-child,
|
||||||
.drawer-right-leave-to > div:last-child {
|
.drawer-leave-to > div:last-child {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-left-enter-from > div:last-child,
|
|
||||||
.drawer-left-leave-to > div:last-child {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,51 +1,20 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'DrawerStory' })
|
|
||||||
|
|
||||||
const showRight = ref(false)
|
|
||||||
const showLeft = ref(false)
|
|
||||||
const showForm = ref(false)
|
|
||||||
const showNoDismiss = ref(false)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Story title="Overlay/Drawer">
|
<Story title="Overlay/Drawer">
|
||||||
<Variant title="Droite (défaut)">
|
<Variant title="Simple">
|
||||||
<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"
|
||||||
@click="showRight = true"
|
@click="showSimple = true"
|
||||||
>
|
>
|
||||||
Ouvrir à droite
|
Ouvrir le drawer
|
||||||
</button>
|
</button>
|
||||||
<MalioDrawer v-model="showRight">
|
<MalioDrawer v-model="showSimple" title="Détails">
|
||||||
<template #header>
|
|
||||||
<h2 class="text-xl font-bold">Détails</h2>
|
|
||||||
</template>
|
|
||||||
<p>Contenu simple du drawer.</p>
|
<p>Contenu simple du drawer.</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="Gauche">
|
<Variant title="Avec formulaire">
|
||||||
<div class="p-4">
|
|
||||||
<button
|
|
||||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
|
||||||
@click="showLeft = true"
|
|
||||||
>
|
|
||||||
Ouvrir à gauche
|
|
||||||
</button>
|
|
||||||
<MalioDrawer v-model="showLeft" side="left">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-xl font-bold">Navigation</h2>
|
|
||||||
</template>
|
|
||||||
<p>Ce drawer glisse depuis la gauche.</p>
|
|
||||||
</MalioDrawer>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Avec footer collant">
|
|
||||||
<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"
|
||||||
@@ -53,38 +22,102 @@ const showNoDismiss = ref(false)
|
|||||||
>
|
>
|
||||||
Ouvrir le formulaire
|
Ouvrir le formulaire
|
||||||
</button>
|
</button>
|
||||||
<MalioDrawer v-model="showForm">
|
<MalioDrawer v-model="showForm" title="Nouveau contact">
|
||||||
<template #header>
|
<div class="flex flex-col gap-4">
|
||||||
<h2 class="text-xl font-bold">Nouveau contact</h2>
|
<MalioInputText v-model="formNom" label="Nom" />
|
||||||
</template>
|
<MalioInputText v-model="formPrenom" label="Prénom" />
|
||||||
<div class="flex flex-col gap-4 py-2">
|
<MalioButton label="Enregistrer" button-class="w-full" @click="showForm = false" />
|
||||||
<MalioInputText label="Nom" />
|
|
||||||
<MalioInputText label="Prénom" />
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
|
||||||
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
|
||||||
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<Variant title="Non dismissable">
|
<Variant title="Sans bouton fermer">
|
||||||
<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"
|
||||||
@click="showNoDismiss = true"
|
@click="showNoClose = true"
|
||||||
>
|
>
|
||||||
Ouvrir
|
Ouvrir (sans croix)
|
||||||
</button>
|
</button>
|
||||||
<MalioDrawer v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
|
<MalioDrawer v-model="showNoClose" title="Information" :show-close="false">
|
||||||
<template #header>
|
<p>Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
||||||
<h2 class="text-xl font-bold">Action requise</h2>
|
</MalioDrawer>
|
||||||
</template>
|
</div>
|
||||||
<p>Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Largeur personnalisée">
|
||||||
|
<div class="p-4">
|
||||||
|
<button
|
||||||
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||||
|
@click="showWide = true"
|
||||||
|
>
|
||||||
|
Ouvrir (large)
|
||||||
|
</button>
|
||||||
|
<MalioDrawer v-model="showWide" title="Drawer large" drawer-class="max-w-2xl">
|
||||||
|
<p>Ce drawer utilise une largeur personnalisée via la prop drawerClass.</p>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
</Story>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioDrawer
|
||||||
|
|
||||||
|
Panneau latéral (drawer) qui s'ouvre depuis la droite avec un fond semi-transparent.
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| `id` | `string` | auto-généré | Identifiant HTML du drawer |
|
||||||
|
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
||||||
|
| `title` | `string` | `''` | Titre affiché dans le header |
|
||||||
|
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
||||||
|
| `drawerClass` | `string` | `''` | Classes CSS additionnelles sur le panneau (fusionnées via `twMerge`) |
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
|
||||||
|
- Le drawer s'ouvre en glissant depuis la droite avec une transition
|
||||||
|
- Un backdrop semi-transparent couvre le reste de la page
|
||||||
|
- Clic sur le backdrop ferme le drawer
|
||||||
|
- Bouton de fermeture (croix) en haut à droite, masquable via `showClose`
|
||||||
|
- Contenu scrollable si plus haut que la fenêtre
|
||||||
|
- Teleport vers `<body>` pour éviter les problèmes de z-index
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- `role="dialog"` et `aria-modal="true"` sur le panneau
|
||||||
|
- `aria-labelledby` lié au titre
|
||||||
|
- Bouton fermer avec `aria-label="Fermer"`
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `update:modelValue` | `boolean` | Émis à la fermeture (backdrop ou bouton) |
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
| Slot | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `default` | Contenu du drawer |
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import MalioDrawer from '../../components/malio/drawer/Drawer.vue'
|
||||||
|
import MalioInputText from '../../components/malio/input/InputText.vue'
|
||||||
|
import MalioButton from '../../components/malio/button/Button.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'DrawerStory' })
|
||||||
|
|
||||||
|
const showSimple = ref(false)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const showNoClose = ref(false)
|
||||||
|
const showWide = ref(false)
|
||||||
|
|
||||||
|
const formNom = ref('Dupont')
|
||||||
|
const formPrenom = ref('Jean')
|
||||||
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,302 +0,0 @@
|
|||||||
# Refonte du playground — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Remplacer la fausse-SPA du playground (sidebar maison + chargement dynamique dans `index.vue`) par du vrai routage Nuxt fichier + un layout par défaut qui embarque le composant `MalioSidebar` de production.
|
|
||||||
|
|
||||||
**Architecture:** Une config de navigation centralisée (`.playground/playground.nav.ts`) alimente un layout par défaut (`.playground/layouts/default.vue`) contenant `<MalioSidebar>` + `<slot />`. Les pages de démo existantes sous `.playground/pages/composant/**` deviennent automatiquement des routes et héritent du layout. `index.vue` devient une simple page d'accueil. Le `app/app.vue` du layer (`<NuxtLayout><NuxtPage /></NuxtLayout>`), hérité via `extends`, applique le layout automatiquement.
|
|
||||||
|
|
||||||
**Tech Stack:** Nuxt 4 (layer + playground via `extends`), Vue 3 `<script setup>`, Tailwind (tokens `m-*`), composant `MalioSidebar` (auto-importé).
|
|
||||||
|
|
||||||
**Note sur les tests :** Le playground est un harnais de dev, non livré. Vitest est scopé à `app/**/*.test.ts` (la bibliothèque) et aucune page playground n'a de test. Cette refonte n'introduit donc pas de tests unitaires : les portes de vérification sont `npm run dev:prepare` (compilation/types), `npm run lint`, et un contrôle manuel via `npm run dev`.
|
|
||||||
|
|
||||||
**Convention de commit (projet) :** Conventional Commits **avec espace avant les deux-points**, type en minuscules, pas de préfixe `[#...]`, suffixe ticket `(#MUI-34)`. Terminer par le trailer `Co-Authored-By`. Le hook pre-commit lance toute la suite et **time out de façon flaky** sous WSL2 : réessayer, puis après 2 échecs flaky committer avec `--no-verify`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| Fichier | Rôle | Action |
|
|
||||||
|---------|------|--------|
|
|
||||||
| `.playground/playground.nav.ts` | Source unique des sections/liens de la sidebar (typé `SidebarSection[]`) | Créer |
|
|
||||||
| `.playground/layouts/default.vue` | Layout par défaut : `MalioSidebar` + zone de contenu `<slot />` | Créer |
|
|
||||||
| `.playground/pages/index.vue` | Page d'accueil simple (remplace la fausse-SPA) | Réécrire |
|
|
||||||
| `.claude/skills/creating-malio-component/SKILL.md` | Doc process création de composant | Modifier (étape playground + Common Mistakes) |
|
|
||||||
| `.playground/pages/composant/**/*.vue` | Pages de démo | **Inchangées** (déjà des routes) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1 : Config de navigation centralisée
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.playground/playground.nav.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Créer le fichier de navigation**
|
|
||||||
|
|
||||||
Créer `.playground/playground.nav.ts` avec ce contenu exact. Chaque `to` correspond exactement à un fichier existant sous `.playground/pages/composant/`. Le type est importé du SFC `MalioSidebar`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
|
|
||||||
|
|
||||||
export const navSections: SidebarSection[] = [
|
|
||||||
{
|
|
||||||
label: 'BOUTONS',
|
|
||||||
icon: 'mdi:gesture-tap-button',
|
|
||||||
items: [
|
|
||||||
{label: 'Button', to: '/composant/button/button'},
|
|
||||||
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'CHAMPS',
|
|
||||||
icon: 'mdi:form-textbox',
|
|
||||||
items: [
|
|
||||||
{label: 'Texte', to: '/composant/input/inputText'},
|
|
||||||
{label: 'Nombre', to: '/composant/input/inputNumber'},
|
|
||||||
{label: 'Montant', to: '/composant/input/inputAmount'},
|
|
||||||
{label: 'Email', to: '/composant/input/inputEmail'},
|
|
||||||
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
|
|
||||||
{label: 'Téléphone', to: '/composant/input/inputPhone'},
|
|
||||||
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
|
|
||||||
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
|
|
||||||
{label: 'Upload', to: '/composant/input/inputUpload'},
|
|
||||||
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'SÉLECTIONS',
|
|
||||||
icon: 'mdi:form-dropdown',
|
|
||||||
items: [
|
|
||||||
{label: 'Select', to: '/composant/select/select'},
|
|
||||||
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
|
||||||
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
|
||||||
{label: 'Radio', to: '/composant/radio/radioButton'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'NAVIGATION',
|
|
||||||
icon: 'mdi:navigation-variant',
|
|
||||||
items: [
|
|
||||||
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
|
|
||||||
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
|
||||||
{label: 'Onglets', to: '/composant/tab/tabList'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'DONNÉES',
|
|
||||||
icon: 'mdi:table',
|
|
||||||
items: [
|
|
||||||
{label: 'DataTable', to: '/composant/datatable/datatable'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'DIVERS',
|
|
||||||
icon: 'mdi:dots-horizontal',
|
|
||||||
items: [
|
|
||||||
{label: 'Heure', to: '/composant/time/time'},
|
|
||||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
|
||||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier le lint du fichier**
|
|
||||||
|
|
||||||
Run: `npx eslint .playground/playground.nav.ts`
|
|
||||||
Expected: aucune erreur (0 problems). Si ESLint signale un import de type non résolu depuis le `.vue`, c'est un faux positif de résolution ; il ne bloque pas (warnings only). En cas d'**erreur** bloquante sur l'import du type, fallback : remplacer la ligne d'import par une définition locale équivalente :
|
|
||||||
```ts
|
|
||||||
type SidebarItem = {label: string; to: string}
|
|
||||||
type SidebarSection = {label?: string; icon?: string; items: SidebarItem[]}
|
|
||||||
```
|
|
||||||
|
|
||||||
*(Pas de commit ici — les 3 fichiers de la refonte seront committés ensemble en Task 4, car retirer l'ancien `index.vue` casse temporairement le glob.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2 : Layout par défaut
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.playground/layouts/default.vue`
|
|
||||||
|
|
||||||
**Pré-requis vérifiés :** `MalioSidebar` est auto-importé (préfixe `Malio`, `pathPrefix: false`). Ses slots sont `logo` et `logo-collapsed`. Sa prop requise est `sections: SidebarSection[]`. Les logos `LOGO_MALIO.png` / `LOGO_MALIO_COLLAPSED.png` sont servis depuis le `public/` du layer (donc accessibles à la racine `/`).
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Créer le layout**
|
|
||||||
|
|
||||||
Créer `.playground/layouts/default.vue`. Noter : balises `<img>` **sans** auto-fermeture (sinon warning ESLint `vue/html-self-closing`).
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="flex h-screen">
|
|
||||||
<MalioSidebar :sections="navSections">
|
|
||||||
<template #logo>
|
|
||||||
<NuxtLink to="/">
|
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio">
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<template #logo-collapsed>
|
|
||||||
<NuxtLink to="/">
|
|
||||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</MalioSidebar>
|
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto p-6">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {navSections} from '../playground.nav'
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier le lint du layout**
|
|
||||||
|
|
||||||
Run: `npx eslint .playground/layouts/default.vue`
|
|
||||||
Expected: aucune erreur bloquante (0 errors).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3 : Réécrire `index.vue` en page d'accueil
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify (réécriture complète): `.playground/pages/index.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Remplacer tout le contenu de `index.vue`**
|
|
||||||
|
|
||||||
Remplacer **l'intégralité** du fichier `.playground/pages/index.vue` (supprime la sidebar maison + le chargement dynamique par glob) par :
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="mx-auto max-w-2xl py-16 text-center">
|
|
||||||
<h1 class="text-3xl font-bold text-m-text">
|
|
||||||
Playground @malio/layer-ui
|
|
||||||
</h1>
|
|
||||||
<p class="mt-4 text-m-muted">
|
|
||||||
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
*(Page sans `<script>` : contenu purement statique. Elle hérite du layout `default` automatiquement.)*
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier le lint de la page**
|
|
||||||
|
|
||||||
Run: `npx eslint .playground/pages/index.vue`
|
|
||||||
Expected: aucune erreur bloquante (0 errors).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4 : Vérification end-to-end + commit de la refonte
|
|
||||||
|
|
||||||
**Files:** (commit groupé)
|
|
||||||
- `.playground/playground.nav.ts`
|
|
||||||
- `.playground/layouts/default.vue`
|
|
||||||
- `.playground/pages/index.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Régénérer les types Nuxt (compilation)**
|
|
||||||
|
|
||||||
Run: `npm run dev:prepare`
|
|
||||||
Expected: « Types generated in .playground/.nuxt. » sans erreur de compilation. Valide que le layout, le nav et `index.vue` compilent et que l'import du type `SidebarSection` se résout.
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Lint global**
|
|
||||||
|
|
||||||
Run: `npm run lint`
|
|
||||||
Expected: 0 errors (des warnings préexistants sur d'autres fichiers sont tolérés ; aucun nouvel **error** sur les 3 fichiers créés/modifiés).
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Contrôle manuel dans le navigateur**
|
|
||||||
|
|
||||||
Run: `npm run dev` puis ouvrir l'URL affichée.
|
|
||||||
Vérifier :
|
|
||||||
- L'accueil (`/`) affiche le message de bienvenue, avec la `MalioSidebar` à gauche.
|
|
||||||
- La sidebar liste les 6 sections et tous les liens.
|
|
||||||
- Cliquer un item (ex. « Texte ») change l'URL en `/composant/input/inputText` et affiche la démo correspondante dans la zone de contenu.
|
|
||||||
- Le bouton collapse de la sidebar fonctionne (plier/déplier).
|
|
||||||
- Cliquer le logo ramène à `/`.
|
|
||||||
|
|
||||||
Arrêter le serveur (Ctrl+C) une fois vérifié.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Commit de la refonte**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .playground/playground.nav.ts .playground/layouts/default.vue .playground/pages/index.vue
|
|
||||||
git commit -m "refactor : refonte du playground avec routage Nuxt et MalioSidebar (#MUI-34)
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Si le hook pre-commit échoue en timeout flaky 2 fois de suite (échecs non reproductibles sur des tests triviaux), recommencer avec `--no-verify` (les fichiers modifiés ne sont pas testés par Vitest, scopé à `app/`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5 : Mettre à jour le skill `creating-malio-component`
|
|
||||||
|
|
||||||
Le skill décrit encore l'ancien fonctionnement (auto-découverte par `index.vue` via glob). Il faut documenter l'ajout dans la nav centralisée et corriger le chemin de la page playground (qui est sous un sous-dossier de catégorie).
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `.claude/skills/creating-malio-component/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Réécrire l'étape 5 (page playground)**
|
|
||||||
|
|
||||||
Remplacer le bloc de l'étape « ### 5. Créer la page playground » — du titre jusqu'à la ligne `**Variantes typiques :**` exclue — par :
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### 5. Créer la page playground
|
|
||||||
|
|
||||||
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
|
|
||||||
|
|
||||||
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`.
|
|
||||||
|
|
||||||
Inclure des variantes représentatives dans une grille :
|
|
||||||
|
|
||||||
\`\`\`html
|
|
||||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
|
||||||
<div class="rounded-lg border p-4">
|
|
||||||
<h2 class="mb-4 text-xl font-bold">Titre variante</h2>
|
|
||||||
<MalioMonComposant ... />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
\`\`\`
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Mettre à jour la table « Common Mistakes »**
|
|
||||||
|
|
||||||
Remplacer la ligne :
|
|
||||||
```markdown
|
|
||||||
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
|
|
||||||
```
|
|
||||||
par :
|
|
||||||
```markdown
|
|
||||||
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Vérifier la cohérence du diagramme workflow**
|
|
||||||
|
|
||||||
Lire le bloc `digraph` en tête du skill. L'étape « 5. Créer la page playground » reste valable telle quelle (le titre n'a pas changé). Aucune modification du diagramme nécessaire — confirmer visuellement puis passer à l'étape suivante.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Commit de la mise à jour du skill**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .claude/skills/creating-malio-component/SKILL.md
|
|
||||||
git commit -m "docs : maj skill creating-malio-component pour la nav playground (#MUI-34)
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
(Ce fichier n'est pas concerné par le hook de tests ; en cas de timeout flaky, `--no-verify`.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Vérification finale (après toutes les tâches)
|
|
||||||
|
|
||||||
- [ ] `npm run lint` → 0 errors.
|
|
||||||
- [ ] `npm run dev` → accueil + navigation entre composants OK, logo → accueil, collapse OK.
|
|
||||||
- [ ] `git log --oneline -3` → 2 nouveaux commits au format `type : … (#MUI-34)`.
|
|
||||||
- [ ] Plus aucune trace de sidebar maison / `import.meta.glob` dans `.playground/pages/index.vue`.
|
|
||||||
|
|
||||||
## Note post-exécution (pour l'agent)
|
|
||||||
|
|
||||||
Mettre à jour la mémoire `malio-datepicker-conventions.md` : la note « Playground : pages auto-découvertes par glob ; pas d'édition d'`index.vue` » est désormais fausse. Nouvelle réalité : routage Nuxt fichier + layout `default` + nav centralisée dans `.playground/playground.nav.ts` à éditer pour chaque nouveau composant.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Refonte du composant `<MalioDrawer>` — Design
|
|
||||||
|
|
||||||
> Ticket : MUI-35 — Revoir le design du composant Drawer
|
|
||||||
> Date : 2026-05-21
|
|
||||||
> Statut : design validé, à implémenter
|
|
||||||
|
|
||||||
## Contexte & problème
|
|
||||||
|
|
||||||
Le `<MalioDrawer>` actuel fait le strict minimum et ne tient pas la comparaison
|
|
||||||
avec les drawers des libs modernes (shadcn/Sheet, PrimeVue, Element Plus, Nuxt UI) :
|
|
||||||
|
|
||||||
- glisse **uniquement depuis la droite**, pas de choix de côté ;
|
|
||||||
- **un seul slot** (le contenu), pas de header/footer structurés ;
|
|
||||||
- **aucune accessibilité réelle** : pas de focus-trap, pas de restitution du focus,
|
|
||||||
pas de fermeture au clavier (Échap) ;
|
|
||||||
- **pas de scroll-lock** du body quand le drawer est ouvert.
|
|
||||||
|
|
||||||
Objectif : refondre le composant en gardant l'esprit du layer Malio
|
|
||||||
(hand-rollé, 1 composant `.vue`, props communes, `twMerge`), sans introduire de
|
|
||||||
dépendance ni refondre les autres composants.
|
|
||||||
|
|
||||||
## Décisions structurantes
|
|
||||||
|
|
||||||
- **Hand-rollé**, pas de dépendance type Reka UI. Cohérence avec le reste du layer.
|
|
||||||
- **Un seul composant** `<MalioDrawer>` (props + slots). Pas de primitives composables.
|
|
||||||
- **Breaking change assumé** → bump de version **majeure** via semantic-release.
|
|
||||||
Les apps consommatrices migreront (cf. section Migration).
|
|
||||||
- Périmètre : **le drawer uniquement**. Les autres composants ne bougent pas.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Slots
|
|
||||||
|
|
||||||
| Slot | Rôle |
|
|
||||||
|------|------|
|
|
||||||
| `#header` | Contenu d'en-tête (titre + ce que veut le consommateur). **Aucune prop `title`.** |
|
|
||||||
| _défaut_ | Le body, dans la zone scrollable. |
|
|
||||||
| `#footer` | Rendu **dans la zone scrollable**, juste après le body. **Aucune classe de positionnement imposée.** |
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
| Prop | Type | Défaut | Rôle |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `modelValue` | `boolean` | `undefined` | v-model d'ouverture (pattern contrôlé/non-contrôlé Malio) |
|
|
||||||
| `id` | `string` | `''` (auto) | id du composant |
|
|
||||||
| `side` | `'right' \| 'left'` | `'right'` | côté d'apparition |
|
|
||||||
| `showClose` | `boolean` | `true` | affiche le bouton de fermeture (croix) |
|
|
||||||
| `dismissable` | `boolean` | `true` | clic sur le backdrop ferme le drawer |
|
|
||||||
| `closeOnEscape` | `boolean` | `true` | touche Échap ferme le drawer |
|
|
||||||
| `ariaLabel` | `string` | `''` | nom accessible de secours quand `#header` est absent |
|
|
||||||
| `drawerClass` | `string` | `''` | override du panneau (largeur réglée ici, ex. `max-w-2xl`) |
|
|
||||||
| `overlayClass` | `string` | `''` | override du backdrop |
|
|
||||||
| `headerClass` | `string` | `''` | override de la barre header |
|
|
||||||
| `bodyClass` | `string` | `''` | override de la zone scrollable |
|
|
||||||
| `footerClass` | `string` | `''` | override du wrapper du `#footer` |
|
|
||||||
|
|
||||||
> **Largeur/hauteur** : pas de prop `size`. Tout se règle via `drawerClass`
|
|
||||||
> (comme aujourd'hui).
|
|
||||||
|
|
||||||
### Emits
|
|
||||||
|
|
||||||
| Event | Payload | Quand |
|
|
||||||
|-------|---------|-------|
|
|
||||||
| `update:modelValue` | `boolean` | ouverture/fermeture |
|
|
||||||
| `close` | — | à la fermeture (pratique pour la logique appelante) |
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ [slot #header] [ ✕ ] │ ← barre header (rendue si #header OU showClose)
|
|
||||||
├─────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ slot par défaut (body) │ ← zone scrollable (flex-1, overflow-y-auto)
|
|
||||||
│ [slot #footer] │ ← rendu juste après le body, dans le même scroll,
|
|
||||||
│ │ SANS classe de position par défaut
|
|
||||||
└─────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- La **barre header** n'est rendue que si le slot `#header` est fourni **ou** si
|
|
||||||
`showClose` est vrai. Le bouton croix vit dans cette barre, à droite. L'icône de
|
|
||||||
fermeture est **`mdi:cancel-bold`** (on conserve l'icône actuelle ; c'est le test
|
|
||||||
qui sera adapté).
|
|
||||||
- La **zone scrollable** (`flex-1 overflow-y-auto`) contient le slot par défaut
|
|
||||||
puis, si fourni, le wrapper `#footer`.
|
|
||||||
- Le **`#footer`** n'a **aucune** classe `sticky`/`flex-shrink-0`/position. Par défaut
|
|
||||||
il scrolle avec le contenu. Pour le coller en bas, le consommateur passe
|
|
||||||
`footer-class="sticky bottom-0 bg-white"`.
|
|
||||||
|
|
||||||
## Comportements (les manques actuels corrigés)
|
|
||||||
|
|
||||||
1. **Échap** ferme le drawer si `closeOnEscape` (listener `keydown` global, ajouté à
|
|
||||||
l'ouverture, retiré à la fermeture).
|
|
||||||
2. **Scroll-lock du body** : `overflow: hidden` sur `document.body` à l'ouverture,
|
|
||||||
restauré à la fermeture.
|
|
||||||
3. **Focus-trap** : à l'ouverture, focus sur le premier élément focusable du panneau
|
|
||||||
(ou le panneau lui-même) ; `Tab`/`Shift+Tab` bouclent à l'intérieur du panneau.
|
|
||||||
4. **Restitution du focus** : mémoriser `document.activeElement` à l'ouverture, le
|
|
||||||
restaurer à la fermeture.
|
|
||||||
5. **ARIA** :
|
|
||||||
- `role="dialog"`, `aria-modal="true"` sur le panneau ;
|
|
||||||
- `aria-labelledby` pointant sur l'id du wrapper `#header` **si** le slot est fourni ;
|
|
||||||
- sinon `aria-label` = prop `ariaLabel` (fallback accessible).
|
|
||||||
|
|
||||||
## Transition
|
|
||||||
|
|
||||||
- Backdrop : fondu (`opacity`).
|
|
||||||
- Panneau : translation selon `side` —
|
|
||||||
- `right` : `translateX(100%)` → `0` ;
|
|
||||||
- `left` : `translateX(-100%)` → `0`.
|
|
||||||
- Conserver le pattern actuel `<Teleport to="body">` + `<Transition>` +
|
|
||||||
`isRendered` (démontage après l'animation de sortie).
|
|
||||||
|
|
||||||
## Migration (breaking)
|
|
||||||
|
|
||||||
| Avant | Après |
|
|
||||||
|-------|-------|
|
|
||||||
| `title="Titre"` | `<template #header><h2>Titre</h2></template>` (ou composant de titre Malio) |
|
|
||||||
| `<MalioDrawer>contenu</MalioDrawer>` | inchangé (slot par défaut = body) |
|
|
||||||
| `drawer-class` | inchangé |
|
|
||||||
| `show-close` | inchangé |
|
|
||||||
| _(nouveau)_ | `side`, `dismissable`, `closeOnEscape`, `ariaLabel`, slots `#header`/`#footer` |
|
|
||||||
|
|
||||||
Les défauts des nouvelles props reproduisent au plus près le comportement actuel
|
|
||||||
(`side="right"`, `showClose=true`, `dismissable=true`).
|
|
||||||
|
|
||||||
## Tests (Vitest + @vue/test-utils, jsdom)
|
|
||||||
|
|
||||||
À couvrir, en plus des tests de rendu/props/emits existants :
|
|
||||||
|
|
||||||
- rendu des 3 slots (`#header`, défaut, `#footer`) ;
|
|
||||||
- `side` left/right → classes/transition attendues ;
|
|
||||||
- `showClose` toggle la croix ; clic croix → ferme + emit ;
|
|
||||||
- `dismissable` : clic backdrop ferme / ne ferme pas ;
|
|
||||||
- `closeOnEscape` : Échap ferme / ne ferme pas ;
|
|
||||||
- scroll-lock : `body` `overflow:hidden` à l'ouverture, restauré à la fermeture ;
|
|
||||||
- focus-trap : focus initial dans le panneau ; restitution au déclencheur ;
|
|
||||||
- ARIA : `aria-labelledby` quand `#header`, `aria-label` sinon ;
|
|
||||||
- pattern contrôlé/non-contrôlé.
|
|
||||||
|
|
||||||
## Hors périmètre (YAGNI)
|
|
||||||
|
|
||||||
- côtés `top`/`bottom` (sheets) — extensible plus tard via `side` ;
|
|
||||||
- prop `size` sémantique — `drawerClass` suffit ;
|
|
||||||
- hook `before-close` ;
|
|
||||||
- empilement de plusieurs drawers (un seul scroll-lock géré simplement).
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# Refonte du système de playground
|
|
||||||
|
|
||||||
Date : 2026-05-21
|
|
||||||
Branche : `feature/MUI-34-revoir-le-systeme-de-playground`
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
Le playground actuel (`.playground/`) est une **fausse SPA** : une unique page
|
|
||||||
`index.vue` contient une sidebar codée à la main et charge dynamiquement les
|
|
||||||
pages de démo via `import.meta.glob` + `<component :is>`. Il n'y a ni vrai
|
|
||||||
routage, ni layout, et la sidebar ne réutilise pas le composant `MalioSidebar`
|
|
||||||
de la bibliothèque.
|
|
||||||
|
|
||||||
Les pages de démo existent déjà dans `.playground/pages/composant/<catégorie>/<nom>.vue`
|
|
||||||
mais ne sont pas exploitées comme de vraies routes.
|
|
||||||
|
|
||||||
## Objectif
|
|
||||||
|
|
||||||
Refondre le playground autour du **vrai routage fichier de Nuxt** et d'un
|
|
||||||
**layout par défaut** qui embarque le composant `MalioSidebar` de production
|
|
||||||
(dogfooding du composant).
|
|
||||||
|
|
||||||
## Décisions validées
|
|
||||||
|
|
||||||
| Sujet | Décision |
|
|
||||||
|-------|----------|
|
|
||||||
| Navigation | Vrai routage Nuxt + layout dédié |
|
|
||||||
| Construction de la sidebar | Liste manuelle centralisée |
|
|
||||||
| Habillage du layout | Sidebar + contenu seul (épuré, chaque page gère son titre) |
|
|
||||||
| Page d'accueil | Page de bienvenue simple |
|
|
||||||
| Surbrillance lien actif | Hors périmètre (MalioSidebar inchangé) |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### 1. Config de navigation centralisée
|
|
||||||
|
|
||||||
Nouveau fichier `.playground/playground.nav.ts` exportant un tableau
|
|
||||||
`SidebarSection[]` (type exporté par `MalioSidebar`). Les sections sont
|
|
||||||
définies manuellement ; chaque item est un `{ label, to }` pointant vers la
|
|
||||||
route de démo.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { SidebarSection } from '../app/components/malio/sidebar/Sidebar.vue'
|
|
||||||
|
|
||||||
export const navSections: SidebarSection[] = [
|
|
||||||
{
|
|
||||||
label: 'BOUTONS',
|
|
||||||
icon: 'mdi:gesture-tap-button',
|
|
||||||
items: [
|
|
||||||
{ label: 'Button', to: '/composant/button/button' },
|
|
||||||
{ label: 'Button Icon', to: '/composant/button/buttonIcon' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// … autres sections (Champs, Sélections, Navigation, Données, Divers)
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Les routes correspondent exactement aux fichiers existants dans
|
|
||||||
`.playground/pages/composant/`. Liste à couvrir :
|
|
||||||
|
|
||||||
- **button/** : `button`, `buttonIcon`
|
|
||||||
- **checkbox/** : `checkbox`
|
|
||||||
- **radio/** : `radioButton`
|
|
||||||
- **input/** : `inputText`, `inputNumber`, `inputAmount`, `inputEmail`,
|
|
||||||
`inputPassword`, `inputPhone`, `inputTextArea`, `inputAutocomplete`,
|
|
||||||
`inputUpload`, `inputRichText`
|
|
||||||
- **select/** : `select`, `selectCheckbox`
|
|
||||||
- **time/** : `time`
|
|
||||||
- **tab/** : `tabList`
|
|
||||||
- **sidebar/** : `sidebar`
|
|
||||||
- **drawer/** : `drawer`
|
|
||||||
- **datatable/** : `datatable`
|
|
||||||
- **site/** : `siteSelector`
|
|
||||||
- **form/** : `client`
|
|
||||||
|
|
||||||
Le regroupement en sections et les libellés affichés sont au choix du
|
|
||||||
développeur (manuel). Les routes, elles, sont imposées par les fichiers.
|
|
||||||
|
|
||||||
### 2. Layout par défaut
|
|
||||||
|
|
||||||
Nouveau fichier `.playground/layouts/default.vue` :
|
|
||||||
|
|
||||||
- Conteneur `flex` pleine hauteur (`h-screen`).
|
|
||||||
- `<MalioSidebar :sections="navSections">` à gauche.
|
|
||||||
- Slots `logo` / `logo-collapsed` : logos `LOGO_MALIO.png` /
|
|
||||||
`LOGO_MALIO_COLLAPSED.png` (servis depuis le `public/` du layer),
|
|
||||||
enveloppés dans un `<NuxtLink to="/">` pour revenir à l'accueil.
|
|
||||||
- Collapse géré en interne par le composant (mode non-contrôlé).
|
|
||||||
- `<main class="flex-1 overflow-y-auto p-6"><slot /></main>` à droite.
|
|
||||||
|
|
||||||
Le layout `default` s'applique automatiquement à toutes les pages du
|
|
||||||
playground — aucune page n'a besoin de `definePageMeta({ layout })`.
|
|
||||||
|
|
||||||
### 3. Page d'accueil
|
|
||||||
|
|
||||||
`.playground/pages/index.vue` réécrite en page de bienvenue simple :
|
|
||||||
titre + invitation à choisir un composant dans la sidebar. Toute la logique
|
|
||||||
de glob / chargement dynamique / sidebar maison est supprimée.
|
|
||||||
|
|
||||||
### 4. Pages de démo
|
|
||||||
|
|
||||||
**Inchangées.** Elles sont déjà des routes `/composant/<cat>/<nom>` et
|
|
||||||
hériteront automatiquement du layout `default`.
|
|
||||||
|
|
||||||
### 5. Mise à jour du skill `creating-malio-component`
|
|
||||||
|
|
||||||
Ajouter une étape au skill : lors de la création d'un nouveau composant,
|
|
||||||
ajouter son entrée dans `.playground/playground.nav.ts` pour qu'il apparaisse
|
|
||||||
dans la sidebar.
|
|
||||||
|
|
||||||
## Hors périmètre
|
|
||||||
|
|
||||||
- Surbrillance de l'item actif dans `MalioSidebar` (ticket dédié si besoin).
|
|
||||||
- Toute autre évolution de `MalioSidebar`.
|
|
||||||
- Refonte du contenu des pages de démo existantes.
|
|
||||||
|
|
||||||
## Critères de réussite
|
|
||||||
|
|
||||||
- `npm run dev` lance le playground avec `MalioSidebar` dans un layout.
|
|
||||||
- Cliquer sur un item de la sidebar change l'URL et affiche la bonne démo.
|
|
||||||
- Le logo ramène à l'accueil ; l'accueil affiche le message de bienvenue.
|
|
||||||
- Plus aucune trace de la sidebar maison ni du chargement dynamique dans
|
|
||||||
`index.vue`.
|
|
||||||
- `npm run lint` et `npm run test` passent.
|
|
||||||
Reference in New Issue
Block a user