mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): harden, polish and optimize the web UI per the September 2026 audit Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20). Implementation integrity (P0) - app.css now defines every utility class the templates and JS use, including .hidden, so the ~145 JS show/hide toggles work. Button reset, and base component rules (.btn, .form-control) wrapped in :where() so utility classes on the same element win. New static-audit test fails when a used utility class has no rule. Accessibility - Focus rings render (the old ring rule referenced undefined variables); one :focus-visible outline everywhere; skip link; labelled nav landmarks. - Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap, Escape, focus return, applied to every modal. - Named icon-only buttons and labelled ~70 form fields. - Toasts announced once; errors persist >= 10s; one showNotification. - Captive WiFi page: live region, timeouts, dark mode, 16px inputs. Performance (Pi Zero 2 W) - SSE streams and tab timers pause when hidden or off-tab; the display stream only runs while a preview is visible. app-shell.js deferred. - Widget scripts served as one versioned bundle (/assets/widgets.js): 52 -> 21 script tags, 66 -> 35 requests on first load. - Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS 1358 KB -> 291 KB on the wire. SSE untouched. Theming and responsive - File managers, form fields and Fonts upload on theme tokens; bare inputs themed in dark mode; no more white surfaces. - No horizontal overflow at 375px on any tab; 44px touch targets on coarse pointers; reduced-motion respected; header title truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings on #568 - json-file-manager: focus-trap releases kept in a Map (no dynamic property access or delete; no value-returning forEach callback) - notification / schedule-picker: style and day-label lookups via Map - app.js: move the pending-queue assignment out of the expression - diff_viewer / error_handler: named function declarations instead of arrow consts No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: check the OAuth widget ships in the widget bundle base.html no longer tags widget scripts one by one; they load through /assets/widgets.js. Assert the page requests the bundle and the bundle contains google-oauth.js, which is what the test was protecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): address review feedback on #568 - widget bundle version fingerprints every file (name, mtime_ns, size) - gzip fallback appends Accept-Encoding to an existing Vary header - dialog helper: releasing a non-top dialog no longer moves focus out of the dialog the user is in - labels: file-upload targets its file input; fallback config fields get label for/id pairs; native color input has a fallback name - utility audit also reads class names inside bound :class expressions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): give the native color-picker input an accessible name CodeRabbit flagged this on PR #568 as an outside-diff finding (never posted inline, so it was missed in the round of fixes that addressed the other 6 review comments). The <input type="color"> only carried a title attribute; screen readers don't reliably announce title, and there's no other label naming the control when showHexInput is false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings in app-shell.js - drop the unused catch binding on the SSE JSON parse - move the pending-notification queue assignment out of the expression No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): contain plugin widgets/ dir and bound style-editor retries From CodeRabbit review on #568 (code that arrived with the main merge): - serve_plugin_widget resolves widgets/ with resolve_under before resolving the manifest script under it, so a symlinked widgets directory can't become the containment base (CWE-22). New test. - style-editor init stops polling after ~10s when the widget never registers and leaves the plain fallback fields in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
483 lines
20 KiB
JavaScript
483 lines
20 KiB
JavaScript
/*
|
|
* settings-search.js — global settings search + per-tab filter for the v3 UI.
|
|
*
|
|
* Two features share one lightweight index built from the same markup the
|
|
* tooltip work standardizes (.form-group[id^="setting-"] + <label> +
|
|
* .help-tip[data-tooltip]), so it can never drift from what is rendered:
|
|
*
|
|
* 1. Global search (header box): finds settings across ALL tabs, even ones
|
|
* not yet opened, by fetching a single server-side JSON index
|
|
* (/v3/settings/search-index) built from all rendered partials.
|
|
* Clicking a result switches to the tab, waits for the field to load,
|
|
* then scrolls to and flashes it.
|
|
* 2. Per-tab filter (the .settings-filter box under a partial title):
|
|
* hides non-matching fields on the current tab. Delegated, so it keeps
|
|
* working across HTMX swaps.
|
|
*
|
|
* The server owns index generation (including plugin enumeration) and caches
|
|
* it per installed-plugin set, so the client makes exactly one JSON request.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (window._settingsSearchInit) return;
|
|
window._settingsSearchInit = true;
|
|
|
|
var MAX_RESULTS = 25;
|
|
|
|
function debounce(fn, ms) {
|
|
var t;
|
|
return function () {
|
|
var args = arguments, ctx = this;
|
|
clearTimeout(t);
|
|
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
|
|
};
|
|
}
|
|
|
|
// True when every search term is present in the haystack.
|
|
function termsMatch(hay, terms) {
|
|
return terms.every(function (t) { return hay.indexOf(t) !== -1; });
|
|
}
|
|
|
|
function textOf(el) {
|
|
return (el && el.textContent ? el.textContent : '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
// --- Index building -------------------------------------------------------
|
|
|
|
var buildPromise = null;
|
|
|
|
// Fetch the prebuilt index from the server (one literal-URL JSON request)
|
|
// and cache it for the session. Each entry gets a lowercased `hay` haystack
|
|
// for matching. The server owns which tabs/plugins are included.
|
|
function buildIndex(force) {
|
|
if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex);
|
|
if (buildPromise && !force) return buildPromise;
|
|
|
|
buildPromise = fetch('/v3/settings/search-index', { headers: { 'X-Requested-With': 'settings-search' } })
|
|
.then(function (r) { return r.ok ? r.json() : { fields: [] }; })
|
|
.then(function (data) {
|
|
var fields = (data && data.fields) || [];
|
|
fields.forEach(function (f) {
|
|
f.hay = [f.label, f.help, f.key, f.tabLabel, f.section].join(' ').toLowerCase();
|
|
});
|
|
window._settingsIndex = fields;
|
|
return fields;
|
|
})
|
|
.catch(function () {
|
|
// Don't cache the failure: clear the in-flight promise so a
|
|
// later call can retry after a transient fetch error.
|
|
buildPromise = null;
|
|
return [];
|
|
});
|
|
return buildPromise;
|
|
}
|
|
|
|
// --- Global search UI -----------------------------------------------------
|
|
|
|
var input = null, resultsBox = null, activeIndex = -1, currentResults = [];
|
|
|
|
function search(q) {
|
|
q = q.trim().toLowerCase();
|
|
if (!q) return [];
|
|
var terms = q.split(/\s+/);
|
|
var out = [];
|
|
(window._settingsIndex || []).some(function (entry) {
|
|
if (termsMatch(entry.hay, terms)) out.push(entry);
|
|
return out.length >= MAX_RESULTS; // stop once we have enough
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function span(cls, text) {
|
|
var s = document.createElement('span');
|
|
s.className = cls;
|
|
s.textContent = text;
|
|
return s;
|
|
}
|
|
|
|
// Build the dropdown with DOM nodes + textContent (never innerHTML) so
|
|
// setting labels/help can never be interpreted as markup.
|
|
function renderResults(results) {
|
|
currentResults = results;
|
|
activeIndex = -1;
|
|
resultsBox.textContent = '';
|
|
if (!results.length) {
|
|
resultsBox.appendChild(span('ssr-empty', 'No settings found.'));
|
|
openResults();
|
|
return;
|
|
}
|
|
var lastTab = null;
|
|
results.forEach(function (r, i) {
|
|
if (r.tabLabel !== lastTab) {
|
|
const group = document.createElement('div');
|
|
group.className = 'ssr-group';
|
|
group.textContent = r.tabLabel;
|
|
resultsBox.appendChild(group);
|
|
lastTab = r.tabLabel;
|
|
}
|
|
var sub = r.section ? (r.section + ' · ') : '';
|
|
var snippet = r.help ? r.help.split('\n')[0] : '';
|
|
var opt = document.createElement('button');
|
|
opt.type = 'button';
|
|
opt.className = 'ssr-option';
|
|
opt.setAttribute('role', 'option');
|
|
opt.id = 'ssr-' + i;
|
|
opt.setAttribute('data-idx', String(i));
|
|
opt.appendChild(span('ssr-label', r.label));
|
|
var helpText = snippet ? (sub + snippet) : (sub ? r.section : '');
|
|
if (helpText) opt.appendChild(span('ssr-help', helpText));
|
|
resultsBox.appendChild(opt);
|
|
});
|
|
openResults();
|
|
}
|
|
|
|
function openResults() {
|
|
resultsBox.classList.remove('hidden');
|
|
// Clear any inline display left by closeResults(); the class does the rest.
|
|
resultsBox.style.display = '';
|
|
if (input) input.setAttribute('aria-expanded', 'true');
|
|
}
|
|
function closeResults() {
|
|
resultsBox.classList.add('hidden');
|
|
resultsBox.style.display = 'none';
|
|
activeIndex = -1;
|
|
if (input) {
|
|
input.setAttribute('aria-expanded', 'false');
|
|
input.removeAttribute('aria-activedescendant');
|
|
}
|
|
}
|
|
|
|
function highlight(idx) {
|
|
var opts = resultsBox.querySelectorAll('.ssr-option');
|
|
opts.forEach(function (o) { o.classList.remove('active'); });
|
|
if (idx < 0 || idx >= opts.length) { activeIndex = -1; return; }
|
|
activeIndex = idx;
|
|
var el = opts.item(idx);
|
|
el.classList.add('active');
|
|
el.scrollIntoView({ block: 'nearest' });
|
|
input.setAttribute('aria-activedescendant', el.id);
|
|
}
|
|
|
|
// --- Navigation to a setting ---------------------------------------------
|
|
|
|
function getAppData() {
|
|
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
if (!appEl) return null;
|
|
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
|
|
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
|
|
return null;
|
|
}
|
|
|
|
function setActiveTab(tab) {
|
|
var data = getAppData();
|
|
if (data) {
|
|
data.activeTab = tab;
|
|
// Navigating from a search result should also dismiss the mobile
|
|
// nav drawer (harmless no-op on desktop, where the drawer CSS
|
|
// doesn't apply).
|
|
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function waitForElement(id, timeout) {
|
|
return new Promise(function (resolve) {
|
|
var existing = document.getElementById(id);
|
|
if (existing) { resolve(existing); return; }
|
|
var host = document.getElementById('tab-content') || document.body;
|
|
var done = false;
|
|
var obs = new MutationObserver(function () {
|
|
var el = document.getElementById(id);
|
|
if (el && !done) {
|
|
done = true;
|
|
obs.disconnect();
|
|
resolve(el);
|
|
}
|
|
});
|
|
obs.observe(host, { childList: true, subtree: true });
|
|
setTimeout(function () {
|
|
if (!done) { done = true; obs.disconnect(); resolve(document.getElementById(id)); }
|
|
}, timeout || 6000);
|
|
});
|
|
}
|
|
|
|
function isNodeHidden(node) {
|
|
return node.classList.contains('hidden') ||
|
|
(node.style && node.style.display === 'none') ||
|
|
window.getComputedStyle(node).display === 'none';
|
|
}
|
|
|
|
function revealNode(node) {
|
|
// toggleSection handles the class, inline display, and chevron.
|
|
if (node.id && typeof window.toggleSection === 'function') {
|
|
window.toggleSection(node.id);
|
|
} else {
|
|
node.classList.remove('hidden');
|
|
node.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Re-collapse a nested section the filter previously opened. toggleSection is
|
|
// state-based, so only toggle while the node is actually visible.
|
|
function collapseNode(node) {
|
|
if (isNodeHidden(node)) return;
|
|
if (node.id && typeof window.toggleSection === 'function') {
|
|
window.toggleSection(node.id);
|
|
} else {
|
|
node.classList.add('hidden');
|
|
node.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Reveal any collapsed nested section (from render_nested_section) so the
|
|
// target field is actually visible before we scroll to it.
|
|
function revealAncestors(el) {
|
|
var node = el.parentElement;
|
|
while (node && node !== document.body) {
|
|
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
|
|
revealNode(node);
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
}
|
|
|
|
// Like revealAncestors, but tags each section we open so the per-tab filter
|
|
// can restore the original collapsed layout once the query is cleared.
|
|
function expandNestedFor(el) {
|
|
var node = el.parentElement;
|
|
while (node && node !== document.body) {
|
|
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
|
|
revealNode(node);
|
|
node.dataset.filterExpanded = '1';
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
}
|
|
|
|
function flash(el) {
|
|
el.classList.remove('setting-flash');
|
|
// force reflow so re-adding the class restarts the animation
|
|
void el.offsetWidth;
|
|
el.classList.add('setting-flash');
|
|
var clear = function () { el.classList.remove('setting-flash'); el.removeEventListener('animationend', clear); };
|
|
el.addEventListener('animationend', clear);
|
|
}
|
|
|
|
function navigateToSetting(entry) {
|
|
closeResults();
|
|
// Clear the box so it doesn't re-open stale results when refocused.
|
|
if (input) input.value = '';
|
|
setActiveTab(entry.tab);
|
|
waitForElement(entry.anchorId, 6000).then(function (el) {
|
|
if (!el) return;
|
|
revealAncestors(el);
|
|
// Let the tab transition settle before scrolling.
|
|
setTimeout(function () {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
flash(el);
|
|
}, 60);
|
|
});
|
|
}
|
|
|
|
// --- Wire up the header search box ----------------------------------------
|
|
|
|
function initSearchBox() {
|
|
input = document.getElementById('settings-search');
|
|
resultsBox = document.getElementById('settings-search-results');
|
|
if (!input || !resultsBox) return;
|
|
|
|
// Warm the index in the background so the first search is instant.
|
|
var warm = function () { buildIndex().catch(function () {}); };
|
|
if ('requestIdleCallback' in window) {
|
|
requestIdleCallback(warm, { timeout: 4000 });
|
|
} else {
|
|
setTimeout(warm, 3000);
|
|
}
|
|
|
|
input.addEventListener('focus', function () {
|
|
buildIndex().then(function () {
|
|
if (input.value.trim()) renderResults(search(input.value));
|
|
});
|
|
});
|
|
|
|
input.addEventListener('input', debounce(function () {
|
|
var q = input.value;
|
|
if (!q.trim()) { closeResults(); return; }
|
|
// Focus may have left during the debounce (typed then clicked away);
|
|
// don't re-open a dropdown the user has already dismissed.
|
|
if (document.activeElement !== input) return;
|
|
buildIndex().then(function () {
|
|
if (document.activeElement === input) renderResults(search(q));
|
|
});
|
|
}, 200));
|
|
|
|
input.addEventListener('keydown', function (e) {
|
|
var opts = resultsBox.querySelectorAll('.ssr-option');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (resultsBox.classList.contains('hidden')) { renderResults(search(input.value)); return; }
|
|
highlight(Math.min(activeIndex + 1, opts.length - 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
highlight(Math.max(activeIndex - 1, 0));
|
|
} else if (e.key === 'Enter') {
|
|
const chosen = currentResults.at(activeIndex >= 0 ? activeIndex : 0);
|
|
if (chosen) {
|
|
e.preventDefault();
|
|
navigateToSetting(chosen);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
closeResults();
|
|
input.blur();
|
|
}
|
|
});
|
|
|
|
resultsBox.addEventListener('mousedown', function (e) {
|
|
// mousedown (not click) so it fires before the input blur closes us
|
|
var opt = e.target.closest('.ssr-option');
|
|
if (!opt) return;
|
|
e.preventDefault();
|
|
const idx = parseInt(opt.getAttribute('data-idx'), 10);
|
|
const chosen = currentResults.at(idx);
|
|
if (chosen) navigateToSetting(chosen);
|
|
});
|
|
|
|
// Close when a click/tap lands outside the search widget. Capture phase
|
|
// (the `true`) runs on the way DOWN, before any bubbling stopPropagation
|
|
// from Alpine/HTMX/widget handlers can swallow the event — a plain
|
|
// bubble-phase document listener was being eaten and never closing us.
|
|
// pointerdown also covers touch (Raspberry Pi screen).
|
|
document.addEventListener('pointerdown', function (e) {
|
|
if (!input || resultsBox.classList.contains('hidden')) return;
|
|
var wrap = document.getElementById('settings-search-wrap');
|
|
var inside = wrap ? wrap.contains(e.target)
|
|
: (e.target === input || resultsBox.contains(e.target));
|
|
if (!inside) closeResults();
|
|
}, true);
|
|
|
|
// Reliable dismiss: close shortly after focus leaves the box. Result
|
|
// selection uses mousedown + preventDefault (focus stays on the input),
|
|
// so this never fires on a result click; the guard covers focus landing
|
|
// in the results list (e.g. dragging its scrollbar).
|
|
input.addEventListener('blur', function () {
|
|
setTimeout(function () {
|
|
if (resultsBox && resultsBox.contains(document.activeElement)) return;
|
|
closeResults();
|
|
}, 120);
|
|
});
|
|
|
|
// A tab swap (including our own search navigation) should dismiss it.
|
|
document.body.addEventListener('htmx:afterSwap', closeResults);
|
|
}
|
|
|
|
// --- Per-tab filter (delegated) -------------------------------------------
|
|
|
|
function filterScope(input) {
|
|
// Return the nearest tab/content container, or null — never `document`,
|
|
// which would let the filter hide setting fields across unrelated tabs.
|
|
return input.closest('.plugin-config-tab') ||
|
|
input.closest('[id$="-content"]') ||
|
|
input.closest('.bg-white') ||
|
|
null;
|
|
}
|
|
|
|
function fieldHay(fg) {
|
|
var label = textOf(fg.querySelector('label'));
|
|
var tip = fg.querySelector('.help-tip');
|
|
var help = tip ? (tip.getAttribute('data-tooltip') || '') : '';
|
|
var key = fg.getAttribute('data-setting-key') || fg.id.replace(/^setting-/, '');
|
|
return (label + ' ' + help + ' ' + key).toLowerCase();
|
|
}
|
|
|
|
function applyTabFilter(scope, q) {
|
|
q = q.trim().toLowerCase();
|
|
var terms = q ? q.split(/\s+/) : [];
|
|
var fields = scope.querySelectorAll('.form-group[id^="setting-"]');
|
|
var anyVisible = false;
|
|
|
|
fields.forEach(function (fg) {
|
|
var show = !terms.length || termsMatch(fieldHay(fg), terms);
|
|
fg.style.display = show ? '' : 'none';
|
|
if (show) {
|
|
anyVisible = true;
|
|
// Expand any collapsed nested section holding this match so it
|
|
// is actually visible (plugin tabs default their sections shut).
|
|
if (terms.length) expandNestedFor(fg);
|
|
}
|
|
});
|
|
|
|
if (!terms.length) {
|
|
// Filter cleared: restore the sections we opened and un-hide every
|
|
// nested-section wrapper, leaving user-expanded sections untouched.
|
|
scope.querySelectorAll('.nested-content[data-filter-expanded]').forEach(function (nc) {
|
|
collapseNode(nc);
|
|
delete nc.dataset.filterExpanded;
|
|
});
|
|
scope.querySelectorAll('.nested-section').forEach(function (ns) { ns.style.display = ''; });
|
|
} else {
|
|
// Hide nested-section wrappers whose fields all filtered out.
|
|
scope.querySelectorAll('.nested-section').forEach(function (ns) {
|
|
var secFields = ns.querySelectorAll('.form-group[id^="setting-"]');
|
|
var visible = 0;
|
|
secFields.forEach(function (f) { if (f.style.display !== 'none') visible++; });
|
|
ns.style.display = (secFields.length > 0 && visible === 0) ? 'none' : '';
|
|
});
|
|
}
|
|
|
|
// Hide section headings whose settings all got filtered out. A visible
|
|
// nested-section (plugin tabs) counts as content for its parent heading,
|
|
// so a heading isn't hidden while a subsection below it still has matches.
|
|
var nodes = scope.querySelectorAll('h3, h4, .form-group, .nested-section');
|
|
var headings = [];
|
|
var current = null;
|
|
nodes.forEach(function (node) {
|
|
if (node.tagName === 'H3' || node.tagName === 'H4') {
|
|
current = { el: node, total: 0, visible: 0 };
|
|
headings.push(current);
|
|
} else if (current && node.matches('.form-group[id^="setting-"]')) {
|
|
current.total++;
|
|
if (node.style.display !== 'none') current.visible++;
|
|
} else if (current && node.classList.contains('nested-section')) {
|
|
current.total++;
|
|
if (node.style.display !== 'none') current.visible++;
|
|
}
|
|
});
|
|
headings.forEach(function (h) {
|
|
// Only auto-hide headings that exclusively group settings fields.
|
|
h.el.style.display = (terms.length && h.total > 0 && h.visible === 0) ? 'none' : '';
|
|
});
|
|
|
|
// Toggle the "no matches" note if the filter box provides one.
|
|
const wrap = scope.querySelector('.settings-filter-wrap');
|
|
if (wrap) {
|
|
const empty = wrap.querySelector('.settings-filter-empty');
|
|
if (empty) empty.classList.toggle('hidden', !(terms.length && !anyVisible));
|
|
}
|
|
}
|
|
|
|
document.addEventListener('input', function (e) {
|
|
var box = e.target.closest ? e.target.closest('.settings-filter') : null;
|
|
if (!box) return;
|
|
var scope = filterScope(box);
|
|
if (scope) applyTabFilter(scope, box.value);
|
|
});
|
|
|
|
// --- Boot -----------------------------------------------------------------
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initSearchBox);
|
|
} else {
|
|
initSearchBox();
|
|
}
|
|
|
|
// Expose for debugging / programmatic use.
|
|
window.LEDMatrixSettingsSearch = {
|
|
buildIndex: buildIndex,
|
|
navigateToSetting: navigateToSetting
|
|
};
|
|
|
|
console.log('[SettingsSearch] registered');
|
|
})();
|