Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3a18ace1d |
@@ -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">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const drawerRight = ref(false)
|
||||
const drawerLeft = ref(false)
|
||||
const drawerForm = ref(false)
|
||||
const drawerFixedFooter = ref(false)
|
||||
const drawerNoDismiss = ref(false)
|
||||
const drawerDefault = ref(false)
|
||||
const drawerNoClose = ref(false)
|
||||
const drawerCustomWidth = ref(false)
|
||||
const drawerWithForm = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
|
||||
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
|
||||
<MalioDrawer v-model="drawerRight">
|
||||
<template #header>
|
||||
<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>
|
||||
<h2 class="mb-6 text-xl font-bold">Drawer simple</h2>
|
||||
<MalioButton label="Ouvrir le drawer" @click="drawerDefault = true" />
|
||||
<MalioDrawer v-model="drawerDefault" title="Titre du drawer">
|
||||
<p class="text-m-text">Contenu du drawer. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Drawer gauche</h2>
|
||||
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
|
||||
<MalioDrawer v-model="drawerLeft" side="left">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
|
||||
</template>
|
||||
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
|
||||
<h2 class="mb-6 text-xl font-bold">Sans bouton fermer</h2>
|
||||
<MalioButton label="Ouvrir le drawer" variant="secondary" @click="drawerNoClose = true" />
|
||||
<MalioDrawer v-model="drawerNoClose" title="Sans croix" :show-close="false">
|
||||
<p class="text-m-text">Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2>
|
||||
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
||||
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<h2 class="mb-6 text-xl font-bold">Largeur personnalisée</h2>
|
||||
<MalioButton label="Ouvrir le drawer large" variant="tertiary" @click="drawerCustomWidth = true" />
|
||||
<MalioDrawer v-model="drawerCustomWidth" title="Drawer large" drawer-class="max-w-2xl">
|
||||
<p class="text-m-text">Ce drawer utilise une largeur personnalisée via drawerClass.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<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="Prénom" />
|
||||
<MalioInputText label="Email" />
|
||||
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,189 @@
|
||||
<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 class="flex min-h-screen">
|
||||
<aside class="w-72 bg-m-bg p-6 text-white">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xl text-black font-semibold"
|
||||
@click="clearSelection"
|
||||
>
|
||||
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>
|
||||
</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-31] Création d'un composant téléphone
|
||||
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
||||
* [#MUI-34] Revoir le système de playground
|
||||
|
||||
### 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
|
||||
* 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
|
||||
|
||||
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 |
|
||||
|------|------|--------|-------------|
|
||||
| `id` | `string` | auto | Identifiant HTML |
|
||||
| `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) |
|
||||
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
|
||||
| `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) |
|
||||
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
|
||||
|
||||
**Events :** `update:modelValue(value: boolean)`, `close()`
|
||||
|
||||
**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).
|
||||
**Events :** `update:modelValue(value: boolean)`
|
||||
**Slots :** `default` (contenu du drawer)
|
||||
|
||||
```vue
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">Détails</h2>
|
||||
</template>
|
||||
<MalioDrawer v-model="isOpen" title="Détails">
|
||||
<p>Contenu du drawer</p>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Côté gauche, largeur custom -->
|
||||
<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 v-model="isOpen" title="Sans croix" :show-close="false">
|
||||
<p>Fermeture uniquement via backdrop</p>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
|
||||
<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 v-model="isOpen" title="Large" drawer-class="max-w-2xl">
|
||||
<p>Drawer plus large</p>
|
||||
</MalioDrawer>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import Drawer from './Drawer.vue'
|
||||
|
||||
type DrawerProps = {
|
||||
id?: string
|
||||
modelValue?: boolean
|
||||
side?: 'right' | 'left'
|
||||
title?: string
|
||||
showClose?: boolean
|
||||
dismissable?: boolean
|
||||
closeOnEscape?: boolean
|
||||
ariaLabel?: string
|
||||
id?: string
|
||||
drawerClass?: string
|
||||
overlayClass?: string
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
footerClass?: string
|
||||
}
|
||||
|
||||
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
|
||||
@@ -25,38 +18,64 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
|
||||
return mount(DrawerForTest, {
|
||||
props,
|
||||
slots,
|
||||
global: { stubs: { Teleport: true } },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MalioDrawer', () => {
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('does not render when modelValue is false', () => {
|
||||
const wrapper = mountComponent({ modelValue: 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 })
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders default slot in the body', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true },
|
||||
{ default: '<p data-test="content">Contenu</p>' },
|
||||
)
|
||||
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
||||
it('renders the title', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' })
|
||||
expect(wrapper.find('h2').text()).toBe('Mon tiroir')
|
||||
})
|
||||
|
||||
it('works in uncontrolled mode (defaults closed)', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ 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', () => {
|
||||
@@ -66,276 +85,38 @@ describe('MalioDrawer', () => {
|
||||
|
||||
it('generates an id when not provided', () => {
|
||||
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 panel = wrapper.find('[data-test="panel"]')
|
||||
expect(panel.attributes('role')).toBe('dialog')
|
||||
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', () => {
|
||||
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
|
||||
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
||||
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' })
|
||||
const panel = wrapper.find('[data-test="panel"]')
|
||||
expect(panel.classes()).toContain('max-w-lg')
|
||||
})
|
||||
|
||||
it('renders the #header slot inside the header bar', () => {
|
||||
const wrapper = mountComponent(
|
||||
{ modelValue: true },
|
||||
{ header: '<h2 data-test="title">Titre</h2>' },
|
||||
)
|
||||
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('works in uncontrolled mode', () => {
|
||||
const wrapper = mountComponent()
|
||||
// Without modelValue, defaults to closed
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('close button has aria-label "Fermer"', () => {
|
||||
const wrapper = mountComponent({ modelValue: true })
|
||||
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>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
:name="`drawer-${side}`"
|
||||
name="drawer"
|
||||
appear
|
||||
@after-leave="isRendered = false"
|
||||
>
|
||||
<div
|
||||
v-if="isRendered && isOpen"
|
||||
:id="componentId"
|
||||
class="fixed inset-0 z-50 flex"
|
||||
:class="side === 'right' ? 'justify-end' : 'justify-start'"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<div
|
||||
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
||||
class="absolute inset-0 bg-black/40"
|
||||
data-test="backdrop"
|
||||
@click="onBackdropClick"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="panelRef"
|
||||
: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,
|
||||
)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="hasHeader ? headerId : undefined"
|
||||
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
||||
tabindex="-1"
|
||||
:aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
data-test="panel"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div
|
||||
v-if="hasHeader || showClose"
|
||||
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
||||
data-test="header"
|
||||
>
|
||||
<div
|
||||
:id="headerId"
|
||||
class="min-w-0 flex-1"
|
||||
data-test="header-content"
|
||||
<div class="flex items-center justify-between px-5 pb-8 pt-8">
|
||||
<h2
|
||||
:id="titleId"
|
||||
class="text-[32px] font-semibold text-m-primary"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<button
|
||||
v-if="showClose"
|
||||
type="button"
|
||||
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"
|
||||
@click="close"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:cancel-bold"
|
||||
:width="16"
|
||||
:height="16"
|
||||
icon="mdi:close"
|
||||
:width="24"
|
||||
:height="24"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
||||
data-test="body"
|
||||
class="flex-1 overflow-y-auto px-5"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
:class="footerClass"
|
||||
data-test="footer"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,17 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useAttrs,
|
||||
useId,
|
||||
useSlots,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { computed, ref, useAttrs, useId, watch } from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -99,195 +72,68 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
modelValue?: boolean
|
||||
side?: 'right' | 'left'
|
||||
title?: string
|
||||
showClose?: boolean
|
||||
dismissable?: boolean
|
||||
closeOnEscape?: boolean
|
||||
ariaLabel?: string
|
||||
drawerClass?: string
|
||||
overlayClass?: string
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
footerClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
modelValue: undefined,
|
||||
side: 'right',
|
||||
title: '',
|
||||
showClose: true,
|
||||
dismissable: true,
|
||||
closeOnEscape: true,
|
||||
ariaLabel: '',
|
||||
drawerClass: '',
|
||||
overlayClass: '',
|
||||
headerClass: '',
|
||||
bodyClass: '',
|
||||
footerClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
|
||||
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
||||
|
||||
const slots = useSlots()
|
||||
const headerId = computed(() => `${componentId.value}-header`)
|
||||
const hasHeader = computed(() => !!slots.header)
|
||||
const titleId = computed(() => `${componentId.value}-title`)
|
||||
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const localValue = ref(false)
|
||||
|
||||
const isOpen = computed(() =>
|
||||
isControlled.value ? props.modelValue! : localValue.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) => {
|
||||
if (val) {
|
||||
isRendered.value = true
|
||||
onOpen()
|
||||
}
|
||||
else {
|
||||
onClose()
|
||||
}
|
||||
if (val) isRendered.value = true
|
||||
})
|
||||
|
||||
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() {
|
||||
if (!isControlled.value) localValue.value = false
|
||||
if (!isControlled.value) {
|
||||
localValue.value = false
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
</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>
|
||||
.drawer-right-enter-active,
|
||||
.drawer-right-leave-active,
|
||||
.drawer-left-enter-active,
|
||||
.drawer-left-leave-active {
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drawer-right-enter-active > div:last-child,
|
||||
.drawer-right-leave-active > div:last-child,
|
||||
.drawer-left-enter-active > div:last-child,
|
||||
.drawer-left-leave-active > div:last-child {
|
||||
.drawer-enter-active > div:last-child,
|
||||
.drawer-leave-active > div:last-child {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-right-enter-from,
|
||||
.drawer-right-leave-to,
|
||||
.drawer-left-enter-from,
|
||||
.drawer-left-leave-to {
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-right-enter-from > div:last-child,
|
||||
.drawer-right-leave-to > div:last-child {
|
||||
.drawer-enter-from > div:last-child,
|
||||
.drawer-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-left-enter-from > div:last-child,
|
||||
.drawer-left-leave-to > div:last-child {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
</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>
|
||||
<Story title="Overlay/Drawer">
|
||||
<Variant title="Droite (défaut)">
|
||||
<Variant title="Simple">
|
||||
<div class="p-4">
|
||||
<button
|
||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||
@click="showRight = true"
|
||||
@click="showSimple = true"
|
||||
>
|
||||
Ouvrir à droite
|
||||
Ouvrir le drawer
|
||||
</button>
|
||||
<MalioDrawer v-model="showRight">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">Détails</h2>
|
||||
</template>
|
||||
<MalioDrawer v-model="showSimple" title="Détails">
|
||||
<p>Contenu simple du drawer.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Gauche">
|
||||
<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">
|
||||
<Variant title="Avec formulaire">
|
||||
<div class="p-4">
|
||||
<button
|
||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||
@@ -53,38 +22,102 @@ const showNoDismiss = ref(false)
|
||||
>
|
||||
Ouvrir le formulaire
|
||||
</button>
|
||||
<MalioDrawer v-model="showForm">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">Nouveau contact</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<MalioInputText label="Nom" />
|
||||
<MalioInputText label="Prénom" />
|
||||
<MalioDrawer v-model="showForm" title="Nouveau contact">
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputText v-model="formNom" label="Nom" />
|
||||
<MalioInputText v-model="formPrenom" label="Prénom" />
|
||||
<MalioButton label="Enregistrer" button-class="w-full" @click="showForm = false" />
|
||||
</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>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Non dismissable">
|
||||
<Variant title="Sans bouton fermer">
|
||||
<div class="p-4">
|
||||
<button
|
||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||
@click="showNoDismiss = true"
|
||||
@click="showNoClose = true"
|
||||
>
|
||||
Ouvrir
|
||||
Ouvrir (sans croix)
|
||||
</button>
|
||||
<MalioDrawer v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">Action requise</h2>
|
||||
</template>
|
||||
<p>Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
||||
<MalioDrawer v-model="showNoClose" title="Information" :show-close="false">
|
||||
<p>Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</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