feat : ajout du composant sidebar
This commit is contained in:
205
app/components/malio/sidebar/Sidebar.test.ts
Normal file
205
app/components/malio/sidebar/Sidebar.test.ts
Normal file
@@ -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<SidebarProps>
|
||||
|
||||
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: '<a :href="to" v-bind="$attrs"><slot /></a>',
|
||||
props: ['to'],
|
||||
},
|
||||
}
|
||||
|
||||
function mountComponent(props: SidebarProps, slots?: Record<string, string>) {
|
||||
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: '<img alt="Malio" src="/logo.svg" />',
|
||||
})
|
||||
expect(wrapper.find('img[alt="Malio"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders logo-collapsed slot when collapsed', async () => {
|
||||
const wrapper = mountComponent({sections}, {
|
||||
'logo-collapsed': '<img alt="M" src="/logo-m.svg" />',
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
139
app/components/malio/sidebar/Sidebar.vue
Normal file
139
app/components/malio/sidebar/Sidebar.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<aside
|
||||
:id="componentId"
|
||||
:class="twMerge(
|
||||
'relative flex h-full flex-col bg-m-bg',
|
||||
collapsed ? 'w-[72px]' : 'w-[280px]',
|
||||
sidebarClass,
|
||||
)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div :class="['px-[20px] py-[14px]', collapsed ? '' : 'mx-[10px] border-b-2 border-m-primary']">
|
||||
<slot
|
||||
v-if="collapsed"
|
||||
name="logo-collapsed"
|
||||
/>
|
||||
<slot
|
||||
v-else
|
||||
name="logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto mb-4">
|
||||
<div
|
||||
v-for="(section, sectionIndex) in sections"
|
||||
:key="sectionIndex"
|
||||
:class="collapsed ? 'first:border-t-2 first:border-m-primary' : 'mx-[10px] border-t-2 border-m-primary first:border-t-0'"
|
||||
>
|
||||
<div
|
||||
v-if="section.label"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-[10px] pt-2 pb-3',
|
||||
collapsed ? 'justify-center pt-[40px]' : '',
|
||||
]"
|
||||
>
|
||||
<IconifyIcon
|
||||
v-if="section.icon"
|
||||
:icon="section.icon"
|
||||
:width="24"
|
||||
class="shrink-0 text-m-primary"
|
||||
/>
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
class="text-[15px] font-bold uppercase text-m-primary"
|
||||
>
|
||||
{{ section.label }}
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:class="collapsed ? '' : 'pb-2 last:pb-1'"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="item.to"
|
||||
:class="twMerge(
|
||||
'block truncate rounded-md text-[15px] text-m-text text-black transition-colors hover:bg-m-tertiary leading-[150%]',
|
||||
collapsed ? 'px-3 text-center' : 'pl-[42px] pr-3',
|
||||
)"
|
||||
>
|
||||
<span v-if="!collapsed">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="collapsed ? 'Déplier le menu' : 'Plier le menu'"
|
||||
:class="twMerge(
|
||||
'absolute top-1/2 -translate-y-1/2 right-0 translate-x-1/2 z-10',
|
||||
'flex h-8 w-8 items-center justify-center rounded-full border border-m-border bg-white shadow-sm',
|
||||
'cursor-pointer transition-colors hover:bg-m-tertiary',
|
||||
toggleClass,
|
||||
)"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="collapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||
:width="18"
|
||||
/>
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
defineOptions({name: 'MalioSidebar', inheritAttrs: false})
|
||||
|
||||
export type SidebarItem = {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type SidebarSection = {
|
||||
label?: string
|
||||
icon?: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sections: SidebarSection[]
|
||||
modelValue?: boolean
|
||||
id?: string
|
||||
sidebarClass?: string
|
||||
toggleClass?: string
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
id: '',
|
||||
sidebarClass: '',
|
||||
toggleClass: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const componentId = computed(() => props.id || `malio-sidebar-${generatedId}`)
|
||||
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const localValue = ref(false)
|
||||
|
||||
const collapsed = computed(() =>
|
||||
isControlled.value ? props.modelValue! : localValue.value,
|
||||
)
|
||||
|
||||
function toggleCollapse() {
|
||||
const newValue = !collapsed.value
|
||||
if (!isControlled.value) {
|
||||
localValue.value = newValue
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user