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
This commit is contained in:
2026-04-26 19:40:40 -04:00
parent 2d76b56deb
commit fbcaed6816
7 changed files with 192 additions and 39 deletions
+2
View File
@@ -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';
+23 -3
View File
@@ -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] > <section.title> > <item.label>` — si la section contient plusieurs items
- `[icon] > <item.label>` — 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 `<root>:new` ou `<root>:edit` :
```js
registerPage({ slug: 'posts:edit', breadcrumbLabel: "Modifier l'article" });
registerPage({ slug: 'posts:new', breadcrumbLabel: 'Nouvel article' });
```
(`<root>` = premier segment d'URL après `/admin/`, ex : `posts` pour `/admin/posts/blogue/edit/1`.)
---
### Ajouter une page
+30 -4
View File
@@ -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,
}))
}));
+48 -19
View File
@@ -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;
+1 -1
View File
@@ -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,
+81 -5
View File
@@ -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 => ({
.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: pathname === item.href || pathname.startsWith(item.href + '/'),
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;
}
+3 -3
View File
@@ -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() {