fix(sidebar) : lien actif sur les sous-routes (match préfixe) + option exact (#81)

## Problème

L'état actif d'un lien Sidebar reposait sur l'`active-class` de NuxtLink, qui dépend de l'imbrication des routes Vue Router. Sur un routing **plat** (typique ERP), `/supplier` n'était plus actif sur `/supplier/1/edit`.

## Solution (option 2 : match par préfixe)

L'actif est désormais calculé côté composant via `useRoute().path` :
- actif si `path === item.to` **ou** `path` commence par `item.to + '/'` → `/supplier` reste actif sur `/supplier/1/edit`, quel que soit le routing du consommateur.
- nouvelle option **`exact: true`** par item pour forcer le match strict (actif uniquement sur la route exacte).

## Tests / doc
- 24 tests Sidebar (route exacte, sous-route préfixe, autres liens non actifs, `exact` strict/exact) — montés avec un router mémoire.
- COMPONENTS.md (type SidebarItem + comportement actif) et CHANGELOG mis à jour.

Branche partie de `develop` (indépendante de la PR #79).

Reviewed-on: #81
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #81.
This commit is contained in:
2026-06-19 13:58:55 +00:00
committed by Autin
parent 5a06cf642f
commit aef1550d7c
4 changed files with 73 additions and 9 deletions
+52 -7
View File
@@ -1,12 +1,14 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import {createMemoryHistory, createRouter} from 'vue-router'
import {Icon as IconifyIcon} from '@iconify/vue'
import Sidebar from './Sidebar.vue'
type SidebarItem = {
label: string
to: string
exact?: boolean
}
type SidebarSection = {
@@ -50,14 +52,30 @@ const stubs = {
},
}
function makeRouter(path = '/') {
const router = createRouter({
history: createMemoryHistory(),
routes: [{path: '/:all(.*)*', component: {template: '<div />'}}],
})
router.push(path)
return router
}
function mountComponent(props: SidebarProps, slots?: Record<string, string>) {
return mount(SidebarForTest, {
props,
slots,
global: {stubs},
global: {stubs, plugins: [makeRouter()]},
})
}
// Monte avec le router positionné sur `path` (pour tester l'état actif).
async function mountAt(path: string, props: SidebarProps = {sections}) {
const router = makeRouter(path)
await router.isReady()
return mount(SidebarForTest, {props, global: {stubs, plugins: [router]}})
}
describe('MalioSidebar', () => {
it('renders expanded by default', () => {
const wrapper = mountComponent({sections})
@@ -104,12 +122,39 @@ describe('MalioSidebar', () => {
expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary')
})
it('actif : texte primary + semi-bold, sans fond, via active-class', () => {
const wrapper = mountComponent({sections})
const activeClass = wrapper.find('a').attributes('active-class') ?? ''
expect(activeClass).toContain('text-m-primary')
expect(activeClass).toContain('font-semibold')
expect(activeClass).not.toContain('bg-')
it('actif : route exacte → lien en primary + semi-bold, sans fond', () => {
return mountAt('/reception').then((wrapper) => {
const link = wrapper.findAll('a')[0]
expect(link.classes()).toContain('font-semibold')
expect(link.classes()).toContain('!text-m-primary')
expect(link.classes().some(c => c.startsWith('bg-'))).toBe(false)
})
})
it('actif : reste actif sur une sous-route (match par préfixe)', async () => {
const wrapper = await mountAt('/reception/1/edit')
expect(wrapper.findAll('a')[0].classes()).toContain('font-semibold')
})
it('actif : les autres liens ne sont pas actifs sur une sous-route', async () => {
const wrapper = await mountAt('/reception/1/edit')
expect(wrapper.findAll('a')[1].classes()).not.toContain('font-semibold')
})
it('exact : pas actif sur une sous-route', async () => {
const exactSections: SidebarSection[] = [
{label: 'S', icon: 'mdi:home', items: [{label: 'R', to: '/reception', exact: true}]},
]
const wrapper = await mountAt('/reception/1/edit', {sections: exactSections})
expect(wrapper.find('a').classes()).not.toContain('font-semibold')
})
it('exact : actif sur la route exacte', async () => {
const exactSections: SidebarSection[] = [
{label: 'S', icon: 'mdi:home', items: [{label: 'R', to: '/reception', exact: true}]},
]
const wrapper = await mountAt('/reception', {sections: exactSections})
expect(wrapper.find('a').classes()).toContain('font-semibold')
})
it('renders section icons via IconifyIcon', () => {
+16 -1
View File
@@ -53,10 +53,10 @@
>
<NuxtLink
:to="item.to"
active-class="!text-m-primary font-semibold"
:class="twMerge(
'block truncate text-[15px] leading-[150%]',
collapsed ? 'px-3 text-center' : 'pl-[32px]',
isActive(item) ? '!text-m-primary font-semibold' : '',
)"
>
<span v-if="!collapsed">{{ item.label }}</span>
@@ -87,6 +87,7 @@
<script setup lang="ts">
import {computed, ref, useId} from 'vue'
import {useRoute} from 'vue-router'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
@@ -95,6 +96,10 @@ defineOptions({name: 'MalioSidebar', inheritAttrs: false})
export type SidebarItem = {
label: string
to: string
// Par défaut, un lien est actif sur sa route ET ses sous-routes (préfixe) :
// `/supplier` reste actif sur `/supplier/1/edit`. `exact: true` force le match
// strict (actif uniquement sur la route exacte).
exact?: boolean
}
export type SidebarSection = {
@@ -123,6 +128,16 @@ const emit = defineEmits<{
const generatedId = useId()
const componentId = computed(() => props.id || `malio-sidebar-${generatedId}`)
const route = useRoute()
// Actif si la route courante est le lien lui-même OU une de ses sous-routes
// (match par préfixe), pour que `/supplier` reste actif sur `/supplier/1/edit`.
// `item.exact` force le match strict.
function isActive(item: SidebarItem): boolean {
const path = route.path
if (item.exact) return path === item.to
return path === item.to || path.startsWith(`${item.to}/`)
}
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)