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
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
|
'use strict';
'require baseclass';
'require fs-menutree as tree';
'require fs-prefs as prefs';
'require fs-router as router';
'require fs-widgets as widgets';
/* Find a page by typing its name instead of knowing which section owns it. A loaded router
* carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears
* in no menu list until you are already there; this indexes every node the dispatcher would
* render, tabs included.
*
* It costs no request: the tree is the same ACL-filtered /admin/menu blob the chrome loaded
* (fs-menutree), so the palette lists exactly the pages this session may open. The index is built
* on the first open, not at init — a user who never searches pays nothing, and only a full load
* can change the tree.
*
* Navigation is deliberately not a call into the router: every result is a real <a href>, so a
* click bubbles to the router's own document-level handler and no copy of that decision lives
* here. Enter synthesises the same click. */
/* ---- the index ---------------------------------------------------------- */
/* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node
* that already has two segments, so 4 admits admin/<section>/<page>/<tab>/<subtab> — one level
* more than LuCI renders, so a deeper third-party node is still findable and the depth term in
* search() ranks it last. */
const MAX_DEPTH = 4;
let _index = null;
/* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not
* through getChildren(): on an alias node it returns the alias TARGET's children, which is right
* for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones`
* leaf, so all five of its tabs vanish from the index (78 nodes instead of 238).
*
* Order does not matter (search ranks by score), so only the two filters are reimplemented, not
* the sort. */
function childrenOf(node) {
const kids = node.children || {};
const out = [];
for (const name in kids) {
const c = kids[name];
if (!c || !c.satisfied || !c.title) continue;
out.push({ name: name, node: c });
}
return out;
}
function walk(node, segs, trail, out, depth) {
childrenOf(node).forEach((entry) => {
const child = entry.node;
/* the chrome carries its own Logout (partials/logout.ut); indexing it would let a search
* open a confirmation the user did not ask for */
if (depth === 1 && entry.name === 'logout')
return;
const title = _(child.title);
const csegs = segs.concat([ entry.name ]);
out.push({
segs: csegs,
path: csegs.join('/'),
title: title,
trail: trail,
depth: depth,
/* Three haystacks, in this order of preference (see tokenScore()). The English
* node name is indexed on purpose: a translated UI otherwise hides the page from
* an admin who knows OpenWrt by its English docs. */
t: title.toLowerCase(),
p: trail.join(' ').toLowerCase(),
/* …minus the root segment, `admin`, which every node shares: indexed, every
* substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */
n: csegs.slice(1).join(' ').toLowerCase()
});
if (depth < MAX_DEPTH)
walk(child, csegs, trail.concat([ title ]), out, depth + 1);
});
}
function buildIndex() {
const root = tree.tree();
const out = [];
if (!root) return out;
/* the mode (admin) is a container, not a destination: start one level in */
childrenOf(root).forEach((mode) => {
walk(mode.node, [ mode.name ], [], out, 1);
});
return out;
}
/* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for
* the life of the document, so the palette is exactly as fresh as the sidebar beside it.
* Invalidating only this half would let the two disagree, which is worse than both being stale;
* the reload a package install prompts for refreshes them together. */
function index() {
if (!_index) _index = buildIndex();
return _index;
}
/* ---- extra sources -------------------------------------------------------
*
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
* is not its own (see keyOf).
*
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
* first gesture and most sessions never make it; a package that had to `require` it to register
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
*
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
* — and the stamp below is what rebuilds the pool when it does. */
const _sources = [];
let _pool = null, _stamp = -1;
function globalSources() {
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
}
function addSource(fn) {
_sources.push(fn);
_pool = null;
}
function refresh() {
_pool = null;
}
function pool() {
const all = _sources.concat(globalSources());
const stamp = all.length + (window.__fsSearchGen || 0);
if (_pool && stamp === _stamp) return _pool;
_stamp = stamp;
_pool = all.reduce((rows, fn) => {
try { return rows.concat(fn() || []); }
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
}, index().slice());
return _pool;
}
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
* a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the
* ranking to decide everything, which reads as random. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
if (e.t.includes(tok)) return 1; /* somewhere in the title */
if (e.n.includes(tok)) return 2; /* the English path segment */
if (e.p.includes(tok)) return 3; /* an ancestor's title */
return HIT_NONE;
}
function search(q, limit) {
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!toks.length) return [];
const hits = [];
for (const e of pool()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
if (s === HIT_NONE) { sum = HIT_NONE; break; }
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: shallower page first */
}
hits.sort((a, b) => a.score - b.score);
return hits.slice(0, limit).map((h) => h.e);
}
/* ---- recently visited --------------------------------------------------- */
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view.
*
* Only the KEY is stored, never the title — the title is resolved through the pool on every
* render, so it follows the UI language and a row whose package went away drops out instead of
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
* heading. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
function keyOf(e) {
return e.key || e.path;
}
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
* more, and a palette that only loads when it is opened cannot be what records where the admin has
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
* only "System" in the list — the page path is all either row carries. */
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20;
/* Built on the first open and kept — the overlay, its listeners and the index survive for the life
* of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
* menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */
let _built = null;
function build() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return null;
/* the list's id, said three times below as an id, a class and aria-controls (measured: 17 B x3
* -> 26 B, 25 B saved) */
const ID_SEARCH_LIST = 'fs-search-list';
/* the attribute name toggled below and stated once more as its own starting value (measured:
* 15 B x3 -> 27 B, 18 B saved) */
const ATTR_EXPANDED = 'aria-expanded';
const input = E('input', {
'type': 'text',
'class': 'fs-search-input',
'role': 'combobox',
'aria-controls': ID_SEARCH_LIST,
[ATTR_EXPANDED]: 'true',
'aria-autocomplete': 'list',
'aria-label': _('Search pages', 'footstrap'),
'placeholder': _('Search pages…', 'footstrap'),
'autocomplete': 'off',
'autocapitalize': 'off',
'spellcheck': 'false'
});
const list = E('div', { 'id': ID_SEARCH_LIST, 'class': ID_SEARCH_LIST, 'role': 'listbox', 'aria-label': _('Pages', 'footstrap') });
const note = E('div', { 'class': 'fs-search-note' });
const ico = E('span', { 'class': 'fs-search-ico' });
ico.innerHTML = widgets.svgIcon('<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>');
const box = E('div', { 'class': 'fs-search-box' }, [
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
/* the note captions the rows below it ("Recently visited") and doubles as the empty
* state, so it belongs above the list in both readings */
note,
list
]);
/* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to
* <body>, outside the <nav> that carries the mark in header.ut, so without it the fence does
* not cover the palette — the shape that once left the Appearance popover unfenced */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
'data-fs-chrome': '',
'role': 'dialog',
'aria-modal': 'true',
'aria-label': _('Search pages', 'footstrap'),
'hidden': ''
}, [ box ]);
document.body.appendChild(ov);
let opts = [], ents = [], at = -1;
/* Warm the highlighted page's module chain, debounced: render() re-runs setActive(0) on every
* keystroke, so warming at once would pull the top result of "w", "wi", "wir"… Only arrow keys
* and typing need this — the rows are real anchors, so a mouse over one already reaches the
* router's pointerover listener. fs-router's warmClass() dedupes, so a row revisited costs
* nothing. */
let warmT = null;
function warmActive() {
if (warmT) window.clearTimeout(warmT);
warmT = window.setTimeout(() => {
warmT = null;
if (ents[at]) router.prefetchSegs(ents[at].segs);
}, 200);
}
function setActive(i) {
if (!opts.length) { at = -1; input.removeAttribute('aria-activedescendant'); return; }
at = (i + opts.length) % opts.length;
opts.forEach((o, n) => {
const on = (n === at);
o.classList.toggle('active', on);
o.setAttribute('aria-selected', on ? 'true' : 'false');
});
input.setAttribute('aria-activedescendant', opts[at].id);
opts[at].scrollIntoView({ block: 'nearest' });
warmActive();
}
function render(q) {
const entries = q ? search(q, MAX_RESULTS) : recentEntries();
ents = entries;
list.innerHTML = '';
opts = entries.map((e, i) => {
/* role="option" on the <a> itself: an option may not contain an interactive element,
* and the anchor must stay a real link — it carries the click to the router and keeps
* middle-click and "copy link" working */
const a = E('a', {
'class': 'fs-search-opt',
'role': 'option',
'id': 'fs-search-opt-' + i,
'aria-selected': 'false',
'href': L.url.apply(L, e.segs)
}, [
E('span', { 'class': 'fs-search-opt-title' }, [ e.title ]),
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
]);
/* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere.
*
* `onTake` is how a row from an extra source finishes the job the href cannot: a
* section row's href can only reach the PAGE, so the source that produced it opens the
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
* Enter synthesises this very click. */
a.addEventListener('click', () => {
close(false);
if (typeof e.onTake === 'function') e.onTake();
});
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
});
note.textContent = opts.length
? (q ? '' : _('Recently visited', 'footstrap'))
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
note.hidden = !note.textContent;
setActive(0);
}
function open() {
if (!ov.hidden) return;
ov.hidden = false;
btn.setAttribute(ATTR_EXPANDED, 'true');
input.value = '';
render('');
input.focus();
}
function close(returnFocus = true) {
if (ov.hidden) return;
ov.hidden = true;
btn.setAttribute(ATTR_EXPANDED, 'false');
if (returnFocus) btn.focus();
}
input.addEventListener('input', () => render(input.value.trim()));
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
* overlay itself, where Escape must still close */
ov.addEventListener('keydown', (ev) => {
switch (ev.key) {
case 'Escape':
ev.preventDefault();
close();
return;
case 'ArrowDown':
ev.preventDefault(); setActive(at + 1); return;
case 'ArrowUp':
ev.preventDefault(); setActive(at - 1); return;
case 'Home':
if (!input.value) { ev.preventDefault(); setActive(0); }
return;
case 'End':
if (!input.value) { ev.preventDefault(); setActive(opts.length - 1); }
return;
case 'Enter':
if (at < 0 || !opts[at]) return;
ev.preventDefault();
/* a synthetic click carries detail 0, which the router reads as a keyboard
* activation, so focus lands where a keyboard navigation puts it */
opts[at].click();
return;
case 'Tab':
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input
* is the dialog's only tabbable element. Escape or an outside click is the way out,
* both handing focus back to the trigger. */
ev.preventDefault();
input.focus();
return;
}
});
/* a click on the scrim — anywhere outside the box — closes */
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
btn.addEventListener('click', () => { ov.hidden ? open() : close(); });
/* Back and Forward are navigations no listener above can see — every other close is a user act
* on the document — so without this an open palette rides a popstate onto the next page,
* aria-modal and Tab-trapped. returnFocus=false: the router places focus itself. */
router.onNavigate(() => close(false));
/* Ctrl/Cmd+K and `/`, the two shortcuts users already have. `/` only when the user is not
* typing somewhere — an <input>, a contenteditable, or a .cbi-dropdown, where fs-select.js's
* typeahead reads it as a search character. */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault();
ov.hidden ? open() : close();
return;
}
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
ev.preventDefault();
open();
});
return { open, close };
}
/* the one entry point: build if this is the first gesture, then open */
function openPalette() {
if (!_built) _built = build();
if (_built) _built.open();
}
return baseclass.extend({
open: openPalette,
/* the seam an optional package registers through; see addSource() */
addSource, refresh
});
|