@mostajs/menu-html 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @mostajs/menu-html
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+
5
+ Menu de navigation **server-rendered (no-build)**, **patron dialecte** (un menu par application : Salsabil, AgoraScope, CRM, MostaGare — sélectionnable/extensible) + **accessibilité** (WCAG 2.2 / WAI-ARIA : skip link, `aria-current`, disclosure clavier, focus visible, contraste, gros texte, cible 24px) + **responsive** (hamburger mobile, tablette, PC). Métier "menu" séparé du layout (`@mostajs/app-shell-ui`).
package/llms.txt ADDED
@@ -0,0 +1,17 @@
1
+ # @mostajs/menu-html — fiche LLM
2
+ > Menu de navigation server-rendered (no-build), patron DIALECTE (un menu par app) + ACCESSIBILITÉ (WCAG/ARIA).
3
+
4
+ ## EXPORTS
5
+ registerMenuDialect(d)/getMenuDialect(key)/listMenuDialects()/clearMenuDialects() — registre (effet de bord builtin)
6
+ renderMenu(dialectKey,{can,role,current,a11y:{contrast,large,simple},showcaseAll,mainId}) -> {html(=hamburger+nav),skip,brand,tagline}
7
+ menuScript() -> <script> (bascule hamburger mobile + aria-expanded + Échap) — inclure une fois
8
+ menuStyles() -> <style> (RESPONSIVE mobile/PC/tablette + skip-link, focus visible, contraste, gros texte, cible 24px) · menuCatalog() -> [{key,app,count}]
9
+
10
+ ## DIALECTES BUILTIN
11
+ salsabil, agorascope, crm, mostagare (modifiables/extensibles via registerMenuDialect).
12
+
13
+ ## ACCESSIBILITÉ (WCAG 2.2 / WAI-ARIA APG)
14
+ nav landmark + aria-label · skip link (2.4.1) · aria-current=page (lien actif) · groupes = disclosure <details> (clavier natif) · focus visible (2.4.7) · variantes contraste (1.4.3/1.4.11) + gros texte (1.4.4) · prefers-reduced-motion. item.perm filtré par can(role,perm).
15
+
16
+ ## RESPONSIVE
17
+ Hamburger accessible (<640px), nav verticale + sous-menus en pile sur mobile, compact tablette, confort PC. Inclure menuScript() une fois.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@mostajs/menu-html",
3
+ "version": "0.1.0",
4
+ "description": "Menu de navigation server-rendered (no-build), patron dialecte (un menu par application) + accessibilité (WCAG/ARIA : skip link, aria-current, disclosure clavier, contraste, gros texte).",
5
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
+ "license": "AGPL-3.0-or-later",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "llms.txt"
16
+ ],
17
+ "keywords": [
18
+ "menu",
19
+ "navigation",
20
+ "accessibilite",
21
+ "a11y",
22
+ "wcag",
23
+ "aria",
24
+ "dialect",
25
+ "mostajs"
26
+ ],
27
+ "devDependencies": {
28
+ "@mostajs/mjs-unit": "^0.3.0"
29
+ },
30
+ "scripts": {
31
+ "test": "node test-scripts/unit/menu-html.test.mjs && node examples/run.mjs"
32
+ }
33
+ }
@@ -0,0 +1,69 @@
1
+ // Dialectes de menu builtin (un par application) — enregistrés par EFFET DE BORD. @author Dr Hamid MADANI <drmdh@msn.com>
2
+ import { registerMenuDialect as M } from '../registry.js';
3
+
4
+ M({ key: 'salsabil', app: 'Salsabil', brand: '🌿 Salsabil', tagline: 'Association caritative',
5
+ items: [
6
+ { href: '/espace', essential: true, label: 'Espace', icon: '🏠' },
7
+ { href: '/m/beneficiaries', essential: true, label: 'Bénéficiaires', icon: '👪', perm: 'beneficiaries:read' },
8
+ { href: '/m/medical-cases', label: 'Dossiers médicaux', icon: '🏥', perm: 'medical-cases:read' },
9
+ { href: '/m/donors', label: 'Donateurs', icon: '🤝', perm: 'donors:read' },
10
+ { href: '/m/aids', label: 'Aides', icon: '💶', perm: 'aid-grants:read' },
11
+ { href: '/m/certificates', label: 'Attestations', icon: '🎖', perm: 'certificates:read' },
12
+ { href: '/m/compta', label: 'Compta', icon: '📒', perm: 'aid-grants:read' },
13
+ { href: '/m/queue', label: 'Accueil/File', icon: '🏥', perm: 'queue:read' },
14
+ { href: '/m/stocks', label: 'Stocks', icon: '📦', perm: 'beneficiaries:read' },
15
+ { href: '/dashboard', essential: true, label: 'Tableau de bord', icon: '📊', perm: 'reporting:read' },
16
+ { href: '/m/admin/users', label: 'Utilisateurs', icon: '👤', perm: 'users:read' },
17
+ { href: '/m/admin/rbac', label: 'Rôles & permissions', icon: '🔐', perm: 'admin:read' },
18
+ { href: '/m/admin/badge', label: 'Badge (config)', icon: '🪪', perm: 'admin:read' },
19
+ { href: '/license', label: 'Licence', icon: '📜', perm: 'reporting:read' },
20
+ ],
21
+ public: [{ href: '/aide', label: 'Aide', icon: '❓' }, { href: '/apropos', label: 'À propos', icon: 'ℹ️' }] });
22
+
23
+ M({ key: 'agorascope', app: 'AgoraScope', brand: '🏛️ AgoraScope', tagline: "veille d'appels d'offres",
24
+ items: [
25
+ { href: '/', essential: true, label: 'Tableau de bord', icon: '📊' },
26
+ { href: '/clients', essential: true, label: 'Clients', icon: '👥' },
27
+ { href: '/tenders', essential: true, label: "Appels d'offres", icon: '📋' },
28
+ { href: '/discover', essential: true, label: 'Découverte', icon: '🔎' },
29
+ { href: '/optimize', label: 'Portefeuille', icon: '🎯' },
30
+ { href: '/responses', label: 'Réponses', icon: '📝' },
31
+ { href: '/benchmark', label: 'Benchmark', icon: '📐' },
32
+ { href: '/settings/providers', label: 'Fournisseurs', icon: '🔌', perm: 'admin:read' },
33
+ ] });
34
+
35
+ M({ key: 'crm', app: 'CRM / TRADING', brand: '💼 CRM', tagline: 'gestion commerciale',
36
+ items: [
37
+ { href: '/', essential: true, label: 'Tableau de bord', icon: '▦' },
38
+ { href: '/commandes', essential: true, label: 'Commandes', icon: '🧾' },
39
+ { href: '/proformats', label: 'Proformats', icon: '📄' },
40
+ { href: '/clients', essential: true, label: 'Clients', icon: '👥' },
41
+ { href: '/employes', label: 'Employés', icon: '🧑‍💼' },
42
+ { href: '/fournisseurs', label: 'Fournisseurs', icon: '🏭' },
43
+ { href: '/assistant', label: 'Mon assistant', icon: '💬' },
44
+ { href: '/admin', label: 'Configuration', icon: '⚙', perm: 'app.config', children: [
45
+ { href: '/admin/roles', label: 'Rôles', icon: '🧑‍💼', perm: 'app.config' },
46
+ { href: '/admin/permissions', label: 'Permissions', icon: '🔐', perm: 'app.config' },
47
+ { href: '/admin/workflows', label: 'Workflows', icon: '🔀', perm: 'app.config' },
48
+ { href: '/admin/audit', label: 'Audit', icon: '📋', perm: 'app.config' },
49
+ ] },
50
+ ],
51
+ public: [{ href: '/aide', label: 'Aide', icon: '❓' }, { href: '/about', label: 'À propos', icon: 'ℹ️' }] });
52
+
53
+ M({ key: 'mostagare', app: 'MostaGare', brand: '🚌 MostaGare', tagline: 'transport voyageurs',
54
+ items: [
55
+ { href: '/dashboard', essential: true, label: 'Tableau de bord', icon: '📊' },
56
+ { href: '/exploitation', essential: true, label: 'Exploitation', icon: '🚍', perm: 'sales.view', children: [
57
+ { href: '/sales', label: 'Ventes', icon: '🎫', perm: 'sales.view' },
58
+ { href: '/departures', label: 'Départs', icon: '🛫', perm: 'sales.view' },
59
+ { href: '/planning', label: 'Planning', icon: '🗓', perm: 'sales.view' },
60
+ ] },
61
+ { href: '/configuration', label: 'Configuration', icon: '⚙', perm: 'clients.view', children: [
62
+ { href: '/clients', essential: true, label: 'Clients', icon: '👥', perm: 'clients.view' },
63
+ { href: '/destinations', label: 'Destinations', icon: '📍', perm: 'clients.view' },
64
+ ] },
65
+ { href: '/accounting', label: 'Comptabilité', icon: '💰', perm: 'accounting.view' },
66
+ { href: '/admin', label: 'Administration', icon: '🛡', perm: 'users.view', children: [
67
+ { href: '/admin/users', label: 'Utilisateurs', icon: '👤', perm: 'users.view' },
68
+ ] },
69
+ ] });
package/src/index.js ADDED
@@ -0,0 +1,106 @@
1
+ // @mostajs/menu-html — menu de navigation server-rendered (no-build), patron DIALECTE (un menu par app)
2
+ // + ACCESSIBILITÉ (WCAG/ARIA : nav landmark, skip link, aria-current, disclosure clavier, focus visible,
3
+ // variantes contraste/gros texte). COMPOSE rien (autonome). @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0
4
+ import './dialects/index.js'; // enregistre les dialectes builtin (effet de bord, comme @mostajs/llm)
5
+ import { registerMenuDialect, getMenuDialect, listMenuDialects, clearMenuDialects } from './registry.js';
6
+ export { registerMenuDialect, getMenuDialect, listMenuDialects, clearMenuDialects };
7
+
8
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
9
+ const allowed = (it, can, role) => !it.perm || (typeof can === 'function' ? can(role, it.perm) : true);
10
+ const isCurrent = (href, current) => href === current || (href !== '/' && current && current.startsWith(href));
11
+
12
+ /** Styles (incl. accessibilité : skip-link, focus visible, contraste élevé, gros texte). À inclure une fois. */
13
+ export function menuStyles() {
14
+ return `<style>
15
+ .mm-skip{position:absolute;left:-999px;top:0;z-index:100;background:#111;color:#fff;padding:8px 12px;border-radius:0 0 6px 0}
16
+ .mm-skip:focus{left:0}
17
+ .mm-nav{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
18
+ /* WCAG 2.2 — 2.5.8 cible ≥24px ; 2.4.11 focus non masqué (scroll-margin) */
19
+ .mm-nav a,.mm-grp>summary{min-height:24px;display:inline-flex;align-items:center;scroll-margin:80px}
20
+ .mm-nav a{color:inherit;text-decoration:none;padding:6px 10px;border-radius:6px}
21
+ .mm-nav a:hover{background:rgba(255,255,255,.12)}
22
+ .mm-nav a[aria-current=page]{background:rgba(255,255,255,.22);font-weight:600}
23
+ .mm-nav a:focus-visible,.mm-nav summary:focus-visible{outline:3px solid #fde047;outline-offset:2px}
24
+ .mm-grp{position:relative} .mm-grp>summary{cursor:pointer;list-style:none;padding:6px 10px;border-radius:6px}
25
+ .mm-grp>summary::-webkit-details-marker{display:none}
26
+ .mm-grp[open]>summary{background:rgba(255,255,255,.18)}
27
+ .mm-sub{position:absolute;background:#0f172a;border-radius:8px;padding:6px;min-width:200px;z-index:50;box-shadow:0 8px 24px rgba(0,0,0,.3)}
28
+ .mm-sub a{display:block}
29
+ /* Accessibilité : préférences utilisateur + variantes */
30
+ .mm-a11y-contrast .mm-nav a{color:#fff} .mm-a11y-contrast .mm-nav a[aria-current=page]{background:#fff;color:#000}
31
+ .mm-a11y-large .mm-nav,.mm-a11y-large .mm-nav a{font-size:18px}
32
+ @media (prefers-reduced-motion:reduce){.mm-nav a,.mm-grp{transition:none}}
33
+ /* RESPONSIVE — bouton hamburger (mobile) + nav adaptative (PC/tablette/mobile) */
34
+ .mm-burger{display:none;min-width:44px;min-height:44px;align-items:center;justify-content:center;font-size:20px;background:transparent;color:inherit;border:1px solid currentColor;border-radius:8px;cursor:pointer}
35
+ .mm-burger:focus-visible{outline:3px solid #fde047;outline-offset:2px}
36
+ @media (min-width:1024px){ .mm-nav{gap:8px} } /* PC : confort */
37
+ @media (max-width:1023px) and (min-width:641px){ .mm-nav a,.mm-grp>summary{padding:6px 8px} } /* tablette : compact */
38
+ @media (max-width:640px){ /* mobile : repli derrière le hamburger */
39
+ .mm-burger{display:inline-flex}
40
+ .mm-nav{display:none;flex-direction:column;align-items:stretch;width:100%;order:99}
41
+ .mm-nav.mm-open{display:flex}
42
+ .mm-nav a,.mm-grp,.mm-grp>summary{width:100%}
43
+ .mm-sub{position:static;box-shadow:none;min-width:0;padding-left:16px} /* sous-menus en pile */
44
+ }
45
+ </style>`;
46
+ }
47
+
48
+ /** Lien (avec aria-current si actif). */
49
+ function link(it, current) {
50
+ const cur = isCurrent(it.href, current) ? ' aria-current="page"' : '';
51
+ return `<a href="${esc(it.href)}"${cur}>${it.icon ? `<span aria-hidden="true">${esc(it.icon)}</span> ` : ''}${esc(it.label)}</a>`;
52
+ }
53
+ /** Groupe avec enfants → disclosure accessible (<details>, clavier natif). */
54
+ function group(it, can, role, current) {
55
+ const kids = (it.children || []).filter((c) => allowed(c, can, role));
56
+ if (!kids.length) return allowed(it, can, role) ? link(it, current) : '';
57
+ const open = kids.some((c) => isCurrent(c.href, current)) ? ' open' : '';
58
+ return `<details class="mm-grp"${open}><summary>${it.icon ? `<span aria-hidden="true">${esc(it.icon)}</span> ` : ''}${esc(it.label)} ▾</summary>
59
+ <div class="mm-sub" role="group" aria-label="${esc(it.label)}">${kids.map((c) => link(c, current)).join('')}</div></details>`;
60
+ }
61
+
62
+ /**
63
+ * Rend le menu d'une application (par clé de dialecte).
64
+ * @param {string} dialectKey 'salsabil' | 'agorascope' | 'crm' | 'mostagare' | …
65
+ * @param {object} [opts] { can(role,perm), role, current, a11y:{contrast,large}, showcaseAll, mainId='main' }
66
+ * @returns {{ html, skip, brand }} html du <nav>, lien d'évitement, marque.
67
+ */
68
+ export function renderMenu(dialectKey, { can, role, current = '', a11y = {}, showcaseAll = false, mainId = 'main' } = {}) {
69
+ const d = getMenuDialect(dialectKey);
70
+ if (!d) return { html: '', skip: '', brand: '' };
71
+ // Variante cognitive (a11y.simple) : n'afficher que les entrées essentielles (réduit la charge).
72
+ const essentialOk = (it) => !a11y.simple || it.essential;
73
+ const pass = (it) => (showcaseAll || allowed(it, can, role)) && essentialOk(it);
74
+ const items = d.items.filter((it) => pass(it) || (it.children || []).some((c) => pass(c)))
75
+ .map((it) => (it.children ? group(it, showcaseAll ? null : can, role, current) : link(it, current))).join('');
76
+ const pub = (d.public || []).map((it) => link(it, current)).join('');
77
+ const cls = ['mm-root', a11y.contrast ? 'mm-a11y-contrast' : '', a11y.large ? 'mm-a11y-large' : '', a11y.simple ? 'mm-a11y-simple' : ''].filter(Boolean).join(' ');
78
+ const navId = `mm-nav-${esc(dialectKey)}`;
79
+ // Hamburger accessible (visible mobile uniquement) — pilote l'ouverture de la nav.
80
+ const burger = `<button class="mm-burger" aria-expanded="false" aria-controls="${navId}" aria-label="Ouvrir le menu de navigation"><span aria-hidden="true">☰</span></button>`;
81
+ const html = `${burger}<nav id="${navId}" class="mm-nav ${cls}" role="navigation" aria-label="Navigation principale — ${esc(d.app)}">${items}${pub}</nav>`;
82
+ return { html, skip: `<a class="mm-skip" href="#${esc(mainId)}">Aller au contenu principal</a>`, brand: d.brand || d.app, tagline: d.tagline || '' };
83
+ }
84
+
85
+ /** Script minimal (à inclure une fois) : bascule le hamburger (mobile) + aria-expanded. Sans dépendance. */
86
+ export function menuScript() {
87
+ return `<script>document.addEventListener('click',function(e){var b=e.target.closest&&e.target.closest('.mm-burger');if(!b)return;var n=document.getElementById(b.getAttribute('aria-controls'));if(!n)return;var open=n.classList.toggle('mm-open');b.setAttribute('aria-expanded',open?'true':'false');});document.addEventListener('keydown',function(e){if(e.key==='Escape'){document.querySelectorAll('.mm-nav.mm-open').forEach(function(n){n.classList.remove('mm-open');var b=document.querySelector('[aria-controls='+n.id+']');if(b)b.setAttribute('aria-expanded','false');});}});</script>`;
88
+ }
89
+
90
+ /**
91
+ * Rend une nav accessible depuis un TABLEAU d'items déjà filtré (pour composer dans un layout, ex.
92
+ * @mostajs/app-shell-ui). item = { href, label, icon?, children? }. Ne filtre pas (l'appelant l'a fait).
93
+ * @returns {{ html, skip }}
94
+ */
95
+ export function renderItems(items = [], { current = '', navEnd = [], a11y = {}, mainId = 'main', ariaLabel = 'Navigation principale', navId = 'mm-nav-shell' } = {}) {
96
+ const top = items.map((it) => ((it.children || []).length ? group(it, null, null, current) : link(it, current))).join('');
97
+ const end = navEnd.map((it) => link(it, current)).join('');
98
+ const cls = [a11y.contrast ? 'mm-a11y-contrast' : '', a11y.large ? 'mm-a11y-large' : ''].filter(Boolean).join(' ');
99
+ const burger = `<button class="mm-burger" aria-expanded="false" aria-controls="${esc(navId)}" aria-label="Ouvrir le menu de navigation"><span aria-hidden="true">☰</span></button>`;
100
+ const html = `${burger}<nav id="${esc(navId)}" class="mm-nav ${cls}" role="navigation" aria-label="${esc(ariaLabel)}">${top}<span style="flex:1"></span>${end}</nav>`;
101
+ return { html, skip: `<a class="mm-skip" href="#${esc(mainId)}">Aller au contenu principal</a>` };
102
+ }
103
+
104
+ /** Liste des menus disponibles (pour choisir lequel par application). */
105
+ export const menuCatalog = () => listMenuDialects().map((d) => ({ key: d.key, app: d.app, count: d.items.length }));
106
+ export default { renderMenu, renderItems, menuStyles, menuScript, menuCatalog, registerMenuDialect, getMenuDialect, listMenuDialects };
@@ -0,0 +1,13 @@
1
+ // @mostajs/menu-html — registre de DIALECTES de menu (un menu par application, patron @mostajs/llm).
2
+ // @author Dr Hamid MADANI <drmdh@msn.com>
3
+ const REGISTRY = new Map();
4
+ /** Enregistre un dialecte de menu. d = { key, app, brand?, tagline?, items[], public?[] } ;
5
+ * item = { href, label, perm?, icon?, children?[] }. */
6
+ export function registerMenuDialect(d) {
7
+ if (!d?.key || !Array.isArray(d?.items)) throw new Error('menu dialect: { key, items[] } requis');
8
+ REGISTRY.set(d.key, { public: [], ...d });
9
+ return d;
10
+ }
11
+ export function getMenuDialect(key) { return REGISTRY.get(key) || null; }
12
+ export function listMenuDialects() { return [...REGISTRY.values()]; }
13
+ export function clearMenuDialects() { REGISTRY.clear(); }