From f2cec83285685b84674474a3cee2e35a6904568d Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 23 Mar 2026 17:29:22 +0100 Subject: [PATCH] feat : ajout du composant sidebar --- .../pages/composant/sidebar/sidebar.vue | 131 ++++++++++ CHANGELOG.md | 1 + app/components/malio/sidebar/Sidebar.test.ts | 205 ++++++++++++++++ app/components/malio/sidebar/Sidebar.vue | 139 +++++++++++ app/story/sidebar/sidebarMenu.story.vue | 227 ++++++++++++++++++ public/LOGO_MALIO.png | Bin 0 -> 5824 bytes public/LOGO_MALIO_COLLAPSED.png | Bin 0 -> 2237 bytes 7 files changed, 703 insertions(+) create mode 100644 .playground/pages/composant/sidebar/sidebar.vue create mode 100644 app/components/malio/sidebar/Sidebar.test.ts create mode 100644 app/components/malio/sidebar/Sidebar.vue create mode 100644 app/story/sidebar/sidebarMenu.story.vue create mode 100644 public/LOGO_MALIO.png create mode 100644 public/LOGO_MALIO_COLLAPSED.png diff --git a/.playground/pages/composant/sidebar/sidebar.vue b/.playground/pages/composant/sidebar/sidebar.vue new file mode 100644 index 0000000..533306e --- /dev/null +++ b/.playground/pages/composant/sidebar/sidebar.vue @@ -0,0 +1,131 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ccaa3..3391c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-9] Création d'un composant upload * [#MUI-14] Création d'un composant bouton icône * [#MUI-11] Création d'un composant navigation par onglets +* [#MUI-20] Création d'un composant sidebar ### Changed diff --git a/app/components/malio/sidebar/Sidebar.test.ts b/app/components/malio/sidebar/Sidebar.test.ts new file mode 100644 index 0000000..ba0a3ab --- /dev/null +++ b/app/components/malio/sidebar/Sidebar.test.ts @@ -0,0 +1,205 @@ +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 Sidebar from './Sidebar.vue' + +type SidebarItem = { + label: string + to: string +} + +type SidebarSection = { + label?: string + icon?: string + items: SidebarItem[] +} + +type SidebarProps = { + sections: SidebarSection[] + modelValue?: boolean + id?: string + sidebarClass?: string + toggleClass?: string +} + +const SidebarForTest = Sidebar as DefineComponent + +const sections: SidebarSection[] = [ + { + label: 'LOGISTIQUE / TRANSPORT', + icon: 'mdi:truck-delivery', + items: [ + {label: 'Réception / Expédition', to: '/reception'}, + {label: 'Validation expédition', to: '/validation'}, + ], + }, + { + label: 'COMMERCIAL', + icon: 'mdi:handshake', + items: [ + {label: 'Répertoire Fournisseurs', to: '/fournisseurs'}, + ], + }, +] + +const stubs = { + NuxtLink: { + template: '', + props: ['to'], + }, +} + +function mountComponent(props: SidebarProps, slots?: Record) { + return mount(SidebarForTest, { + props, + slots, + global: {stubs}, + }) +} + +describe('MalioSidebar', () => { + it('renders expanded by default', () => { + const wrapper = mountComponent({sections}) + const aside = wrapper.find('aside') + expect(aside.classes()).toContain('w-[280px]') + }) + + it('renders section labels with icons when expanded', () => { + const wrapper = mountComponent({sections}) + const sectionHeaders = wrapper.findAll('nav > div > div') + expect(sectionHeaders).toHaveLength(2) + expect(sectionHeaders[0].text()).toContain('LOGISTIQUE / TRANSPORT') + expect(sectionHeaders[1].text()).toContain('COMMERCIAL') + }) + + it('renders all menu items with icons and labels', () => { + const wrapper = mountComponent({sections}) + const links = wrapper.findAll('a') + expect(links).toHaveLength(3) + expect(links[0].text()).toContain('Réception / Expédition') + expect(links[1].text()).toContain('Validation expédition') + expect(links[2].text()).toContain('Répertoire Fournisseurs') + }) + + it('renders NuxtLink with correct to prop', () => { + const wrapper = mountComponent({sections}) + const links = wrapper.findAll('a') + expect(links[0].attributes('href')).toBe('/reception') + expect(links[2].attributes('href')).toBe('/fournisseurs') + }) + + it('renders section icons via IconifyIcon', () => { + const wrapper = mountComponent({sections}) + const icons = wrapper.findAllComponents(IconifyIcon) + // 2 section icons + 1 toggle chevron = 3 + expect(icons).toHaveLength(3) + expect(icons[0].props('icon')).toBe('mdi:truck-delivery') + expect(icons[1].props('icon')).toBe('mdi:handshake') + }) + + it('toggle button shows chevron-left when expanded', () => { + const wrapper = mountComponent({sections}) + const toggleIcon = wrapper.findAllComponents(IconifyIcon).at(-1)! + expect(toggleIcon.props('icon')).toBe('mdi:chevron-left') + }) + + it('collapses on toggle click in uncontrolled mode', async () => { + const wrapper = mountComponent({sections}) + const toggleBtn = wrapper.find('button') + + await toggleBtn.trigger('click') + + const aside = wrapper.find('aside') + expect(aside.classes()).toContain('w-[72px]') + }) + + it('hides section label text when collapsed but keeps section icon', async () => { + const wrapper = mountComponent({sections}) + await wrapper.find('button').trigger('click') + + const sectionHeaders = wrapper.findAll('nav > div > div') + expect(sectionHeaders).toHaveLength(2) + // Label text spans are hidden + sectionHeaders.forEach((header) => { + expect(header.findAll('span').filter(s => s.classes().includes('text-[11px]'))).toHaveLength(0) + }) + }) + + it('hides item text when collapsed', async () => { + const wrapper = mountComponent({sections}) + await wrapper.find('button').trigger('click') + + const itemSpans = wrapper.findAll('a span') + expect(itemSpans).toHaveLength(0) + }) + + it('toggle button shows chevron-right when collapsed', async () => { + const wrapper = mountComponent({sections}) + await wrapper.find('button').trigger('click') + + const toggleIcon = wrapper.findAllComponents(IconifyIcon).at(-1)! + expect(toggleIcon.props('icon')).toBe('mdi:chevron-right') + }) + + it('emits update:modelValue on toggle click', async () => { + const wrapper = mountComponent({sections, modelValue: false}) + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true]) + }) + + it('respects modelValue in controlled mode', () => { + const wrapper = mountComponent({sections, modelValue: true}) + const aside = wrapper.find('aside') + expect(aside.classes()).toContain('w-[72px]') + }) + + it('renders logo slot when expanded', () => { + const wrapper = mountComponent({sections}, { + logo: 'Malio', + }) + expect(wrapper.find('img[alt="Malio"]').exists()).toBe(true) + }) + + it('renders logo-collapsed slot when collapsed', async () => { + const wrapper = mountComponent({sections}, { + 'logo-collapsed': 'M', + }) + await wrapper.find('button').trigger('click') + + expect(wrapper.find('img[alt="M"]').exists()).toBe(true) + }) + + it('uses custom id when provided', () => { + const wrapper = mountComponent({sections, id: 'my-sidebar'}) + expect(wrapper.find('aside').attributes('id')).toBe('my-sidebar') + }) + + it('toggle button has correct aria-label', async () => { + const wrapper = mountComponent({sections}) + const btn = wrapper.find('button') + expect(btn.attributes('aria-label')).toBe('Plier le menu') + + await btn.trigger('click') + expect(btn.attributes('aria-label')).toBe('Déplier le menu') + }) + + it('section without label does not render a section header', () => { + const noLabelSections: SidebarSection[] = [ + {items: [{label: 'Item', to: '/'}]}, + ] + const wrapper = mountComponent({sections: noLabelSections}) + expect(wrapper.findAll('nav > div > div')).toHaveLength(0) + }) + + it('renders section icon in collapsed mode', async () => { + const wrapper = mountComponent({sections}) + await wrapper.find('button').trigger('click') + + const icons = wrapper.findAllComponents(IconifyIcon) + // 2 section icons + 1 toggle = 3 + expect(icons[0].props('icon')).toBe('mdi:truck-delivery') + expect(icons[1].props('icon')).toBe('mdi:handshake') + }) +}) diff --git a/app/components/malio/sidebar/Sidebar.vue b/app/components/malio/sidebar/Sidebar.vue new file mode 100644 index 0000000..519e07f --- /dev/null +++ b/app/components/malio/sidebar/Sidebar.vue @@ -0,0 +1,139 @@ + + + diff --git a/app/story/sidebar/sidebarMenu.story.vue b/app/story/sidebar/sidebarMenu.story.vue new file mode 100644 index 0000000..b27a7e0 --- /dev/null +++ b/app/story/sidebar/sidebarMenu.story.vue @@ -0,0 +1,227 @@ + + + +# MalioSidebar + +Composant de navigation latérale avec support déplié/plié, sections groupées, +icônes et liens NuxtLink. Un bouton circulaire avec chevron permet de toggle +entre les deux états. + +------------------------------------------------------------------------ + +## Props détaillées + +### sections + +- Type: `Array<{ label?: string; items: Array<{ label: string; icon: string; to: string }> }>` +- Description: Liste des sections du menu. Chaque section a un label optionnel et une liste d'items. + +### modelValue + +- Type: `boolean` +- Description: Contrôle l'état plié/déplié (`true` = plié). Supporte `v-model`. + +### id + +- Type: `string` +- Description: ID custom pour le composant. + +### sidebarClass + +- Type: `string` +- Description: Classes Tailwind additionnelles pour le conteneur sidebar. + +### toggleClass + +- Type: `string` +- Description: Classes Tailwind additionnelles pour le bouton toggle. + +------------------------------------------------------------------------ + +## Slots + +### logo + +- Contenu affiché en haut quand la sidebar est dépliée. + +### logo-collapsed + +- Contenu affiché en haut quand la sidebar est pliée. + +------------------------------------------------------------------------ + +## Comportement + +- **Déplié** : affiche le logo, les labels de section et les items avec texte + icône. +- **Plié** : affiche le logo réduit et les icônes seules. +- **Toggle** : bouton circulaire positionné au centre du bord droit, chevron gauche/droite. +- **Contrôlé / non-contrôlé** : fonctionne avec ou sans `v-model`. + +------------------------------------------------------------------------ + +## Accessibilité + +- `aria-label` sur le bouton toggle ("Plier le menu" / "Déplier le menu"). +- Navigation sémantique avec `