From fbcaed6816dedeba8a01e43bc3c7c425c0160ef6 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sun, 26 Apr 2026 19:40:40 -0400 Subject: [PATCH] docs(admin): document active item and breadcrumb logic for nav registration - add note in DEV.md explaining basePath auto-deduction and breadcrumb slug convention - update README.md to document new `basePath` param in `registerNavItem` and detail active item/breadcrumb behavior - update navigation file listing in README.md to include new exported helpers - implement `getNavItemBasePath` and `findActiveNavContext` in navigation.js - use `basePath` in AdminSidebar to determine active item via longest-prefix match - use `basePath` in AdminTop to build breadcrumb with section, item, and action labels - expose new navigation helpers from admin index.js and registry.js --- docs/DEV.md | 2 + src/features/admin/README.md | 26 ++++- src/features/admin/components/AdminSidebar.js | 34 ++++++- src/features/admin/components/AdminTop.js | 67 +++++++++---- src/features/admin/index.js | 2 +- src/features/admin/navigation.js | 94 +++++++++++++++++-- src/features/admin/registry.js | 6 +- 7 files changed, 192 insertions(+), 39 deletions(-) diff --git a/docs/DEV.md b/docs/DEV.md index 7220cdf..7734ccd 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -101,6 +101,8 @@ registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', h registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' }); ``` +L'item actif dans la sidebar et le fil d'Ariane sont calculés automatiquement à partir d'un `basePath` déduit de `href` (si `href` finit par `/list`, on retire le suffixe). Une convention `slug:edit` / `slug:new` permet de personnaliser les labels du breadcrumb sur les sous-routes — voir [src/features/admin/README.md](../src/features/admin/README.md#item-actif-et-fil-dariane) pour le détail. + ```js // app/layout.js — un seul import suffit ; les side effects enregistrent tout. import './zen.extensions'; diff --git a/src/features/admin/README.md b/src/features/admin/README.md index 98c2020..239a1f4 100644 --- a/src/features/admin/README.md +++ b/src/features/admin/README.md @@ -12,7 +12,7 @@ Ce répertoire fournit l'interface d'administration complète : layout, navigati src/features/admin/ ├── index.js buildNavigationSections, registre (Next.js-free) ├── protect.js gardes d'accès -├── navigation.js buildNavigationSections, buildBottomNavItems +├── navigation.js buildNavigationSections, buildBottomNavItems, getNavItemBasePath, findActiveNavContext ├── registry.js registre runtime d'extensions ├── AdminLayout.server.js layout RSC de l'admin ├── AdminPage.server.js page RSC racine (protège + collecte les données widgets) @@ -204,19 +204,39 @@ registerNavItem({ | `icon` | `string` | Nom d'icône Hugeicons | | `order` | `number` | Ordre d'affichage (défaut : `0`) | -**`registerNavItem({ id, label, icon, href, sectionId?, order?, position?, permission? })`** +**`registerNavItem({ id, label, icon, href, basePath?, sectionId?, order?, position?, permission? })`** | Paramètre | Type | Description | |-----------|------|-------------| | `id` | `string` | Identifiant unique de l'entrée | | `label` | `string` | Texte affiché | | `icon` | `string` | Nom d'icône Hugeicons | -| `href` | `string` | URL de destination | +| `href` | `string` | URL de destination (cible du clic dans la sidebar) | +| `basePath` | `string` | Préfixe de routes considérées « sous » cette entrée — utilisé pour souligner l'item actif et construire le fil d'Ariane sur les sous-routes (`/edit/:id`, `/new`...). Auto-déduit : si `href` finit par `/list`, on retire le `/list` ; sinon `basePath = href`. À spécifier explicitement uniquement quand l'auto-déduction ne suffit pas. | | `sectionId` | `string` | Section parente (défaut : `'main'`) | | `order` | `number` | Ordre d'affichage (défaut : `0`) | | `position` | `string` | `'bottom'` pour épingler en bas de la sidebar | | `permission` | `string` | Clé de permission requise pour voir cette entrée (ex. `'orders.view'`). L'entrée est masquée si l'utilisateur ne possède pas cette permission. | +#### Item actif et fil d'Ariane + +L'item « actif » dans la sidebar est celui dont le `basePath` matche le préfixe **le plus long** du pathname courant. Sur `/admin/posts/blogue/edit/1`, l'item « Posts » (basePath `/admin/posts/blogue`) est souligné ; sur `/admin/posts/blogue/taxonomies/categories`, c'est l'item « Catégories » qui gagne car son basePath est plus spécifique. + +Le fil d'Ariane se construit automatiquement à partir de cet item : + +- `[icon] > > ` — si la section contient plusieurs items +- `[icon] > ` — si la section ne contient que cet item et qu'ils portent le même nom (ex : section "Utilisateurs" + item "Utilisateurs") +- Suivi de `> Nouveau` ou `> Modification` quand l'URL contient `/new` ou `/edit/:id` après le basePath. + +Pour personnaliser les labels d'action, enregistrer une page avec un slug `:new` ou `:edit` : + +```js +registerPage({ slug: 'posts:edit', breadcrumbLabel: "Modifier l'article" }); +registerPage({ slug: 'posts:new', breadcrumbLabel: 'Nouvel article' }); +``` + +(`` = premier segment d'URL après `/admin/`, ex : `posts` pour `/admin/posts/blogue/edit/1`.) + --- ### Ajouter une page diff --git a/src/features/admin/components/AdminSidebar.js b/src/features/admin/components/AdminSidebar.js index 0002b83..c744b75 100644 --- a/src/features/admin/components/AdminSidebar.js +++ b/src/features/admin/components/AdminSidebar.js @@ -23,12 +23,15 @@ function resolveIcon(iconNameOrComponent) { const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections, bottomNavItems = [] }) => { const pathname = usePathname(); + const isItemActive = (item) => { + const basePath = item.basePath || item.href; + return pathname === item.href || pathname === basePath || pathname.startsWith(basePath + '/'); + }; + const [collapsedSections, setCollapsedSections] = useState(() => { const initial = new Set(); serverNavigationSections.forEach(section => { - const isActive = section.items.some(item => - pathname === item.href || pathname.startsWith(item.href + '/') - ); + const isActive = section.items.some(isItemActive); if (!isActive) initial.add(section.id); }); return initial; @@ -70,11 +73,34 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM section.items[0].name.toLowerCase() === section.title.toLowerCase(); }; + // Recalcule `current` côté client pour suivre les navigations Link sans + // dépendre d'un re-render serveur. Règle alignée sur navigation.js : + // match le plus long (href exact > basePath préfixe), un seul item actif. + const matchLen = (item) => { + if (pathname === item.href) return item.href.length; + const basePath = item.basePath || item.href; + if (pathname === basePath) return basePath.length; + if (pathname.startsWith(basePath + '/')) return basePath.length; + return 0; + }; + + let activeItemRef = null; + let activeItemLen = 0; + for (const section of serverNavigationSections) { + for (const item of section.items) { + const len = matchLen(item); + if (len > activeItemLen) { + activeItemRef = item; + activeItemLen = len; + } + } + } + const navigationSections = serverNavigationSections.map(section => ({ ...section, items: section.items.map(item => ({ ...item, - current: pathname === item.href || pathname.startsWith(item.href + '/') + current: item === activeItemRef, })) })); diff --git a/src/features/admin/components/AdminTop.js b/src/features/admin/components/AdminTop.js index 3c208b0..2fa6fe3 100644 --- a/src/features/admin/components/AdminTop.js +++ b/src/features/admin/components/AdminTop.js @@ -5,7 +5,7 @@ import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/r import { ArrowDown01Icon, ArrowRight01Icon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons'; import { UserAvatar } from '@zen/core/shared/components'; import { useRouter, usePathname } from 'next/navigation'; -import { getPage, getPages } from '../registry.js'; +import { getPage } from '../registry.js'; import { useTheme, getThemeIcon } from '@zen/core/themes'; import Link from 'next/link'; @@ -53,30 +53,59 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa return crumbs; } - const allItems = navigationSections.flatMap(s => s.items); - const navItem = allItems.find(item => { - const itemSegs = item.href.replace('/admin/', '').split('/').filter(Boolean); - return itemSegs.length <= segments.length && itemSegs.every((seg, i) => segments[i] === seg); - }); + // Localise l'item actif via match le plus long (href exact > basePath préfixe). + // On recalcule côté client pour rester aligné sur AdminSidebar et suivre les + // navigations Link sans dépendre d'un re-render serveur du layout. + const matchLen = (item) => { + if (pathname === item.href) return item.href.length; + const basePath = item.basePath || item.href; + if (pathname === basePath) return basePath.length; + if (pathname.startsWith(basePath + '/')) return basePath.length; + return 0; + }; - const itemSegCount = navItem - ? navItem.href.replace('/admin/', '').split('/').filter(Boolean).length - : 1; - const hasSubPage = segments.length > itemSegCount; + let activeSection = null; + let activeItem = null; + let activeLen = 0; + for (const section of navigationSections) { + for (const item of section.items) { + const len = matchLen(item); + if (len > activeLen) { + activeSection = section; + activeItem = item; + activeLen = len; + } + } + } - if (navItem) { - crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined }); - } else if (!hasSubPage) { + if (!activeItem) { + // Page enregistrée hors navigation (ex : /admin/profile). crumbs.push({ label: pageTitle }); return crumbs; } - const subSegment = segments[itemSegCount]; - if (subSegment === 'new') { - crumbs.push({ label: 'Nouveau' }); - } else if (subSegment === 'edit') { - const page = getPages().find(p => p.slug === `${segments[0]}:edit`); - crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modifier' }); + // Préfixer la section uniquement si elle ne se résume pas à un item unique + // du même nom (cas « direct link » dans la sidebar — section.title === item.name, + // ex : section "Utilisateurs" + item "Utilisateurs"). + if (activeSection.title !== activeItem.name) { + crumbs.push({ label: activeSection.title }); + } + + const isExactPage = pathname === activeItem.href; + crumbs.push({ label: activeItem.name, href: isExactPage ? undefined : activeItem.href }); + + // Action de sous-route : segment juste après le basePath (/edit/:id, /new). + const basePath = activeItem.basePath || activeItem.href; + const trail = pathname.startsWith(basePath) + ? pathname.slice(basePath.length).split('/').filter(Boolean) + : []; + const action = trail[0]; + if (action === 'new') { + const page = getPage(`${segments[0]}:new`); + crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Nouveau' }); + } else if (action === 'edit') { + const page = getPage(`${segments[0]}:edit`); + crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modification' }); } return crumbs; diff --git a/src/features/admin/index.js b/src/features/admin/index.js index c877073..193aaea 100644 --- a/src/features/admin/index.js +++ b/src/features/admin/index.js @@ -15,7 +15,7 @@ * Client components sous @zen/core/features/admin/components. */ -export { buildNavigationSections } from './navigation.js'; +export { buildNavigationSections, buildBottomNavItems, getNavItemBasePath, findActiveNavContext } from './navigation.js'; export { registerWidget, registerWidgetFetcher, diff --git a/src/features/admin/navigation.js b/src/features/admin/navigation.js index a74405c..9afcfcb 100644 --- a/src/features/admin/navigation.js +++ b/src/features/admin/navigation.js @@ -26,6 +26,47 @@ if (isDevkitEnabled()) { registerNavItem({ id: 'devkit-icons', label: 'Icônes', icon: 'Image01Icon', href: '/admin/devkit/icons', sectionId: 'devkit', order: 20 }); } +/** + * Zone d'appartenance d'un nav item — toute URL préfixée par ce path est + * considérée comme « sous » l'item, même si elle ne correspond pas à `href`. + * + * Auto-déduction : un href du type `/admin/posts/blogue/list` couvre aussi + * `/edit/:id` et `/new` du même parent — on retire `/list` pour obtenir le + * basePath. Un module peut surcharger via `registerNavItem({ basePath })`. + */ +export function getNavItemBasePath(item) { + if (item.basePath) return item.basePath; + if (item.href.endsWith('/list')) return item.href.replace(/\/list$/, ''); + return item.href; +} + +function matchLength(pathname, item) { + if (pathname === item.href) return item.href.length; + const basePath = getNavItemBasePath(item); + if (pathname === basePath) return basePath.length; + if (pathname.startsWith(basePath + '/')) return basePath.length; + return 0; +} + +/** + * Sélectionne l'item « actif » pour un pathname donné parmi une liste d'items + * (déjà filtrés par permission / position). Le match le plus long gagne : + * sur `/admin/posts/blogue/taxonomies/categories`, l'item « Catégories » + * (basePath complet) bat l'item « Posts » (basePath parent). + */ +function pickActiveItem(pathname, items) { + let best = null; + let bestLen = 0; + for (const item of items) { + const len = matchLength(pathname, item); + if (len > bestLen) { + best = item; + bestLen = len; + } + } + return best; +} + /** * Build sections for AdminSidebar. Items are sérialisables (pas de composants), * icônes en chaînes résolues côté client. @@ -41,14 +82,17 @@ export function buildNavigationSections(pathname, userPermissions = []) { return true; }); + const activeItem = pickActiveItem(pathname, items); + const bySection = new Map(); for (const item of items) { const list = bySection.get(item.sectionId) || []; list.push({ name: item.label, href: item.href, + basePath: getNavItemBasePath(item), icon: item.icon, - current: pathname === item.href || pathname.startsWith(item.href + '/'), + current: item === activeItem, }); bySection.set(item.sectionId, list); } @@ -62,13 +106,45 @@ export function buildNavigationSections(pathname, userPermissions = []) { * Build the list of bottom-pinned nav items for AdminSidebar. */ export function buildBottomNavItems(pathname) { - return getNavItems() + const items = getNavItems() .filter(item => item.position === 'bottom') - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map(item => ({ - name: item.label, - href: item.href, - icon: item.icon, - current: pathname === item.href || pathname.startsWith(item.href + '/'), - })); + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const activeItem = pickActiveItem(pathname, items); + + return items.map(item => ({ + name: item.label, + href: item.href, + basePath: getNavItemBasePath(item), + icon: item.icon, + current: item === activeItem, + })); +} + +/** + * Localise dans `navigationSections` (forme retournée par `buildNavigationSections`) + * l'item marqué comme actif et la section qui le contient. Utilisé par AdminTop + * pour construire le fil d'Ariane à partir de la même source de vérité que la + * sidebar — pas de double calcul de matching. + * + * Retourne `{ section, item, isExactPage, basePath }` ou `null`. + * - `isExactPage` : on est sur l'URL exacte du nav item (page liste / page racine). + * Permet de décider si l'item doit être cliquable dans le breadcrumb. + * - `basePath` : zone d'appartenance, sert à isoler le « trail » d'action + * (ex : `edit/1`, `new`) qui suit l'item dans le pathname. + */ +export function findActiveNavContext(pathname, navigationSections) { + for (const section of navigationSections) { + for (const item of section.items) { + if (item.current) { + return { + section, + item, + isExactPage: pathname === item.href, + basePath: item.basePath || item.href, + }; + } + } + } + return null; } diff --git a/src/features/admin/registry.js b/src/features/admin/registry.js index d9024bb..2d29a56 100644 --- a/src/features/admin/registry.js +++ b/src/features/admin/registry.js @@ -21,7 +21,7 @@ if (!globalThis[REGISTRY_KEY]) { globalThis[REGISTRY_KEY] = { widgetFetchers: new Map(), // id -> async () => data widgetComponents: new Map(), // id -> { Component, order, permission } - navItems: new Map(), // id -> { id, label, icon, href, order, sectionId, position, permission } + navItems: new Map(), // id -> { id, label, icon, href, basePath, order, sectionId, position, permission } navSections: new Map(), // id -> { id, title, icon, order } pages: new Map(), // slug -> { slug, Component, title?, breadcrumbLabel? } }; @@ -66,8 +66,8 @@ export function registerNavSection({ id, title, icon, order = 0 }) { navSections.set(id, { id, title, icon, order }); } -export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position, permission }) { - navItems.set(id, { id, label, icon, href, order, sectionId, position, permission }); +export function registerNavItem({ id, label, icon, href, basePath, order = 0, sectionId = 'main', position, permission }) { + navItems.set(id, { id, label, icon, href, basePath, order, sectionId, position, permission }); } export function getNavSections() {