'use strict'; 'require baseclass'; 'require ui'; 'require fs-fit as fit'; 'require fs-prefs as prefs'; 'require fs-widgets as widgets'; 'require menu-footstrap-common as common'; /* The theme's one menu renderer: a vertical #topmenu that the CSS also turns into the top bar and * the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets, * the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by * menu-footstrap-common, which this file composes with by injecting renderMainMenu into * common.init — a callback, not an override, since a required LuCI module is a singleton and * cannot be subclassed. Spec: docs/chrome.md */ /* Null prototype: `ICONS[key]` is keyed by a menu node name, which a third-party package picks in * its own menu.d. On a plain literal a node called `constructor` or `__proto__` resolves out of * Object.prototype to a truthy non-string ('[object Object]' for the latter), skips the * `|| ICONS._default` fallback and is concatenated into link.innerHTML. */ const ICONS = Object.assign(Object.create(null), { status: '', system: '', services: '', network: '', vpn: '', docker: '', _default: '' }); function iconSvg(name) { const key = String(name || '').toLowerCase(); const body = ICONS[key] || ((/vpn|wireguard|openvpn/).test(key) ? ICONS.vpn : null) || ((/dock|container|lxc/).test(key) ? ICONS.docker : null) || ((/net|wifi|wireless|firewall|dhcp/).test(key) ? ICONS.network : null) || ((/serv|dnsmasq|cron/).test(key) ? ICONS.services : null) || ((/stat|overview|dash/).test(key) ? ICONS.status : null) || ICONS._default; return widgets.svgIcon(body); } /* `.open` means two things: in the expanded sidebar an unfolded accordion section (several may be * open, the active one starts open); in the rail or the bar a popup panel (exactly one open, hover * drives it, a tap toggles it, cleared on outside click and once a real mouse enters). */ /* Is a section's panel a popup (flyout / bar dropdown) rather than an unfolded accordion? Reads * the same input as the stylesheet: `data-narrow`, stamped by fitShell() in fs-chrome.js before * the menu renders. Never a viewport breakpoint — the CSS turns the sidebar into a bar when the * content column drops below --fs-content-min, so a media query leaves a band of widths where the * chrome is a bar and the menu still believes it is an accordion — a `max-width: 767px` media * query left exactly that band at 770-775px. */ function flyoutMode() { return prefs.currentRail() || prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow'); } /* The trigger — a bare . widgets.setOpen keeps `.open` and aria-expanded in step. */ const TRIGGER = ':scope > a'; const OPEN_LI = '#topmenu > li.open'; /* ---- dropdown edge-clamp (the bar, at every width) ---- * A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an * item near the right edge would push its panel past the viewport. The rail flies panels out * sideways and needs a different placement, so it is excluded below. */ /* how close the panel may come to the viewport edge before it is nudged back in */ const EDGE_GAP = 8; /* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored * beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also * disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even * with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */ function barDropdown() { return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow'); } function clampDropdown(li) { if (!barDropdown()) return; const menu = li.querySelector(':scope > ul'); if (!menu) return; /* one pending measure per item: sweeping the pointer across the bar otherwise queues a * write-then-read of layout per item crossed, none cancelled once the pointer moves on */ if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf); li._fsClampRaf = window.requestAnimationFrame(() => { li._fsClampRaf = 0; menu.style.left = ''; /* back to the CSS anchor before measuring */ const r = menu.getBoundingClientRect(); if (!r.width) return; /* still hidden — nothing to place */ /* measured after a frame: on pointerenter the :hover rule revealing the panel has not * applied yet, so it still measures 0x0 */ const overflowRight = r.right - (window.innerWidth - EDGE_GAP); if (overflowRight > 0) menu.style.left = -Math.min(overflowRight, r.left - EDGE_GAP) + 'px'; }); } /* a nudge computed for the old width, or for a bar we have since left, is wrong: drop it and let * the next hover/tap recompute */ function clearClamps() { document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; }); } function setOpen(li, on) { widgets.setOpen(li, on, TRIGGER); } function closeFlyouts(except) { document.querySelectorAll(OPEN_LI).forEach((o) => { if (o !== except) setOpen(o, false); }); } /* Restore the accordion after leaving flyout mode (rail expanded, window grew). closeFlyouts() is * right going in — a stuck popup is worse than a folded section — but wrong coming out: the markup * is not rebuilt on a rail toggle, so nothing would re-apply the remembered set and "Keep open" * would mean nothing. */ function restoreAccordion() { const auto = prefs.currentAutoCollapse(); document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => { const name = li.dataset.name || ''; setOpen(li, li.classList.contains('active') || (!auto && _openSections.has(name))); }); } /* Which top-level sections are unfolded, by node name: renderChrome() rebuilds #topmenu on every * SPA nav, so a section the user opened would otherwise refold on every tab switch. Only consulted * in the expanded sidebar with auto-collapse off. Persisted in localStorage because a module-level * Set does not survive a full page load, and many LuCI pages are not SPA-able. */ const OPEN_KEY = 'fs-menu-open'; /* prefs.lsGetArr owns the parse, the corruption guard and the Array check; a Set is used because * membership is the only question asked of it */ function loadOpenSections() { return new Set(prefs.lsGetArr(OPEN_KEY)); } function saveOpenSections() { prefs.lsSet(OPEN_KEY, JSON.stringify(Array.from(_openSections))); } const _openSections = loadOpenSections(); /* main sections -> vertical sidebar list (#topmenu), collapsible */ function renderMainMenu(tree, url, level) { const ul = level ? E('ul', {}) : document.querySelector('#topmenu'); const children = ui.menu.getChildren(tree); if (!ul || children.length === 0 || level > 1) return E([]); /* dispatchpath = [mode, section, subsection, …]; sections sit at * index (level+1) because the first call gets the mode. */ const idx = (level || 0) + 1; children.forEach((child) => { /* the chrome carries its own Logout entry (partials/logout.ut), so the tree's * top-level admin/logout node would show up twice */ if (!level && child.name === 'logout') return; const submenu = renderMainMenu(child, url + '/' + child.name, (level || 0) + 1); const hasSub = !!submenu.firstElementChild; const isActive = (L.env.dispatchpath[idx] === child.name); /* expanded sidebar + Keep open: a section starts open if it is the active one OR was * left open before this re-render. Auto-collapse and flyout mode ignore the set. */ const keepOpen = hasSub && !level && !flyoutMode() && !prefs.currentAutoCollapse(); const startOpen = hasSub && !flyoutMode() && (isActive || (keepOpen && _openSections.has(child.name))); if (keepOpen && startOpen && !_openSections.has(child.name)) { _openSections.add(child.name); saveOpenSections(); } const chevron = hasSub ? '' : ''; /* `active` only paints the item; aria-current is what a screen reader reads, and it * belongs on the LEAF alone — a section header is a disclosure button, not a link to * the current page */ const link = E('a', { 'href': hasSub ? '#' : L.url(url, child.name), 'class': (isActive && !hasSub) ? 'active' : '', 'aria-current': (isActive && !hasSub) ? 'page' : null }); link.innerHTML = (level ? '' : iconSvg(child.name)) + '' + chevron; link.querySelector('.fs-label').textContent = _(child.title); /* collapsed rail: the label is hidden, so carry it as an attribute — CSS renders it as * the flyout's heading (sections) or as a tooltip (leaves) */ if (!level) { link.setAttribute('data-label', _(child.title)); if (hasSub) submenu.setAttribute('data-title', _(child.title)); } const li = E('li', { 'class': [ isActive ? 'active' : '', hasSub ? 'has-sub' : '', /* pre-opening the active section is an accordion affordance; in flyout * mode it would pop a panel open on page load */ startOpen ? 'open' : '' ].join(' ').trim() }, [ link, submenu ]); /* for restoreAccordion(): the remembered set is matched back to live
  • s by name */ if (!level) li.dataset.name = child.name; if (hasSub) { /* W3C APG disclosure navigation: a section header is a button owning a panel, not * a link to "#". Not role="menu" — APG is explicit that site navigation must not * take on the menubar pattern's arrow-key semantics. * * The id must be injective: node names come from third-party menu.d files and may * differ only in punctuation, and folding them (`[^a-z0-9]+` -> '-') makes two * triggers' aria-controls resolve to the same panel. Escaping to the code point * keeps the ordinary all-alphanumeric case readable and cannot collide. */ const subId = 'fs-sub-' + String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) + '-' + idx; submenu.id = subId; link.setAttribute('role', 'button'); link.setAttribute('aria-controls', subId); link.setAttribute('aria-expanded', startOpen ? 'true' : 'false'); link.addEventListener('click', (ev) => { ev.preventDefault(); const open = li.classList.contains('open'); /* flyout panels are exclusive, but must not touch the remembered set: it * mirrors the desktop "Keep open" state, which one tap on a phone would * otherwise wipe */ if (flyoutMode()) { closeFlyouts(); setOpen(li, !open); if (!open) clampDropdown(li); /* tap-opened panel must fit too */ return; } /* the sidebar accordion folds the others back only when asked * (Appearance -> Submenus) */ if (prefs.currentAutoCollapse()) { closeFlyouts(); _openSections.clear(); } setOpen(li, !open); /* remember the accordion state so any navigation restores it (Keep open) */ if (!open) _openSections.add(child.name); else _openSections.delete(child.name); saveOpenSections(); }); widgets.wireSpaceKey(link); /* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is * authoritative and two panels never stack. Guarded on pointerType, since a touch * tap fires pointerenter ('touch') before the click and clearing there would break * tap-to-close. */ li.addEventListener('pointerenter', (ev) => { if (ev.pointerType === 'mouse' && flyoutMode()) closeFlyouts(); /* the bar opens this panel on hover (pure CSS), so it must be placed on * hover too, not only when a tap sets .open */ clampDropdown(li); }); } ul.appendChild(li); }); return ul; } return baseclass.extend({ __init__() { common.init(renderMainMenu); /* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it * `.open` means unfolded accordion, which must not fold on a click elsewhere */ widgets.wireDismiss({ when: flyoutMode, inside: '#topmenu > li.has-sub', open: OPEN_LI, trigger: TRIGGER, close: () => closeFlyouts() }); /* Entering flyout mode folds everything, or a section left open as an accordion * reappears as a popup stuck on screen; leaving it restores the accordion. * * Watch the attribute, not the rail button: fs-chrome's wireRail() registers its click * handler from inside the ui.menu.load() promise, i.e. after this runs, so a listener * added here would fire first and read the old data-rail. data-layout rides along — * toggling the layout live is the same transition and needs no menu re-render. */ const modeChanged = () => { clearClamps(); flyoutMode() ? closeFlyouts() : restoreAccordion(); }; /* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): without * it, narrowing the window turns the sidebar into a bar with no transition handler * running and the accordion still unfolded inside it */ new MutationObserver(modeChanged).observe(document.documentElement, { attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ] }); /* No media-query listener: the top bar is measured at every width (fitChrome), so no * breakpoint flips the chrome — the attribute observer and the resize clamp-clear * below cover every transition. */ /* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame * because resize fires dozens of times a second while a window is dragged. Width only: * iOS fires `resize` continuously while the URL bar slides away, and re-clamping for an * unchanged width is work done on a page the user is scrolling. */ let lastWidth = window.innerWidth; const reclamp = fit.frame(clearClamps); window.addEventListener('resize', () => { if (window.innerWidth === lastWidth) return; lastWidth = window.innerWidth; reclamp(); }); /* Appearance -> Submenus -> auto-collapse: fs-prefs.js owns the stored value and only * announces the change; the remembered set, `.open` and aria-expanded are all ours. * restoreAccordion() is already "apply the current setting" — with the set cleared and * auto on it opens the active section alone. Skipped in flyout mode, where force-opening * the active section would leave a popup stuck on screen. */ document.addEventListener('fs-autocollapse', (ev) => { if (ev.detail && ev.detail.on) { _openSections.clear(); saveOpenSections(); } if (!flyoutMode()) restoreAccordion(); }); } });