1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
|
'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: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
system: '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
services: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2.6"/>',
network: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="18" cy="18" r="2.4"/><path d="M8 16 16 8M8 18h7.5M18 8.5V16"/>',
vpn: '<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
docker: '<rect x="3" y="11" width="4" height="4" rx=".7"/><rect x="8" y="11" width="4" height="4" rx=".7"/><rect x="13" y="11" width="4" height="4" rx=".7"/><rect x="8" y="6" width="4" height="4" rx=".7"/><path d="M18 13c0 4-3 6-8 6-4 0-7-2-7-4"/>',
_default: '<circle cx="12" cy="12" r="8.5"/>'
});
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 <a>. 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
? '<svg class="fs-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
: '';
/* `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)) + '<span class="fs-label"></span>' + 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 <li>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();
});
}
});
|