mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Add settings tooltips and search to the web UI (#387)
* Add settings tooltips and search to the web UI Help users quickly find settings and understand how each one works. Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an accessible (i) info tooltip that appears on hover, keyboard focus, and tap. A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger; the plugin config macro and the core settings partials now surface help text through it. Per the design, the always-visible field help paragraphs are folded into the tooltip to declutter the forms, and the hardware/display settings carry authored detail (default, range, recommendation). Search: a global header search box finds settings across every settings tab — even ones not yet opened — via a lazy client-side index built by scanning the same field markup (static/v3/js/settings-search.js). Selecting a result switches tabs, waits for the field to load, then scrolls to and flashes it. A per-tab filter box hides non-matching fields on the current tab. Plugin settings get tooltips for free by reusing each field's schema `description`; every settings field also gets a stable `setting-<tab>-<key>` anchor id for search navigation. Styling uses the existing --color-* theme vars so light/dark mode both work, and honors prefers-reduced-motion. Adds Flask render smoke tests that assert each settings partial ships tooltips, anchors, and a filter box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address Codacy static-analysis findings in settings JS Refactor the two new modules to clear the flagged patterns without any behavior change: - Build the search dropdown with DOM nodes + textContent instead of innerHTML string concatenation, removing the XSS sinks and the manual escapeHtml helper it needed. - Replace numeric index access (index[i], terms[j], opts[idx], currentResults[i]) with array iteration methods, NodeList.item(), and Array.prototype.at() to clear detect-object-injection. - Use === via a shared termsMatch() helper, optional-catch binding, and drop a useless initial assignment. Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero findings and re-ran the headless-Chromium behavior test (tooltip, per-tab filter, global search navigation) — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Reduce complexity of revealAncestors in settings search Extract isNodeHidden() and revealNode() helpers so revealAncestors drops below the cyclomatic-complexity threshold. No behavior change; verified with the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Resolve remaining Codacy findings in settings search - Validate plugin ids against a strict allowlist (mirroring the server's _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request URL is never built from unvalidated input (Codacy: user-controlled URL). - Document that the fetched HTML is parsed into an inert document (scripts never run, never inserted into the live DOM) purely to read field text for the search index. - Declare block-scoped locals with const instead of var where they were nested inside conditionals (Codacy: var not at function root). No behavior change; re-verified with ESLint and the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Serve the settings search index from the server as JSON Move index building off the client so there is no client-side HTML fetching or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the read-only, same-origin index build). - Add GET /v3/settings/search-index (pages_v3.py): renders the settings partials server-side and extracts each field's anchor id, key, label, tooltip, and section with a small stdlib HTMLParser, then caches the result keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor ids identical to the live DOM, so the index cannot drift. - settings-search.js: buildIndex() now does a single fetch of the literal endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc, CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation, and the per-tab filter are unchanged. Net: fewer requests and no client-side HTML parsing. Verified with a new endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium test (tooltip, filter, search navigate + flash all green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address CodeRabbit review on settings search/tooltips - pages_v3: include Durations tab in the search index so setting-durations-* fields are actually indexed - test_web_settings_ui: assert setting-durations-clock is present in the search-index endpoint response - settings-search.js: on index fetch failure, reset buildPromise instead of caching an empty (truthy) index so search can retry - settings-search.js: filterScope returns null (not document) when no tab container matches, and the caller guards, so the per-tab filter can't hide fields across unrelated tabs - settings-search.js: refresh the stale header comment to describe the server-side JSON index flow - app.css: cap #settings-search-results height with overflow-y so the dropdown scrolls instead of overflowing small screens Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix stuck search dropdown; add plugin-tab nested-settings filter Global search: - Close the results dropdown on input blur (guarded, with a short delay) so it reliably dismisses when focus leaves — previously it could linger because the only outside-close was a document click that Alpine/HTMX handlers can swallow. - Clear the query text after navigating to a result so refocusing the box doesn't re-open stale results. - Also dismiss on htmx:afterSwap (tab changes / navigation). Per-tab filter (now on plugin tabs too): - Render the shared settings_filter box in the plugin Configuration panel. It auto-wires: the delegated input handler and filterScope already target .plugin-config-tab. - Teach applyTabFilter to reveal matches inside collapsed nested sections (render_nested_section defaults them shut), hide nested-section wrappers with no matches, and restore the original collapsed layout when cleared (only re-collapsing sections the filter itself opened). - Count a visible nested-section as content for its parent heading so the heading isn't hidden while a subsection below still has matches. Adds a plugin-config render test (filter box + nested anchors + tooltips). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Close search dropdown via capture-phase outside-click The dropdown could stay open after clicking away because the only outside-click close was a bubble-phase document listener. The v3 UI is one Alpine app() component full of HTMX/Alpine/widget click handlers; when a click lands inside an element that calls stopPropagation(), the event never bubbles to document and the close never runs. - Replace the bubble-phase document 'click' close with a capture-phase 'pointerdown' listener scoped to #settings-search-wrap. Capture runs before any bubbling stopPropagation can swallow the event, so it always fires; pointerdown also covers touch on the Pi screen. Clicking a result stays inside the wrap, so selection is unaffected. - Guard the debounced input handler so a delayed render can't re-open the box after focus has left (type-then-click-away race). Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix settings search dropdown not visually closing .hidden has no effect in this app: app.css is a hand-picked utility subset (no Tailwind build step) and never defines .hidden { display: none }. openResults()/closeResults() only toggled the class, so the dropdown stayed rendered (display: block) even once closeResults() ran - confirmed via computed style in a headless browser. Set style.display directly, matching the fallback already used by revealNode()/collapseNode() elsewhere in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -766,6 +766,153 @@ button.bg-white {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================ */
|
||||
/* Settings tooltips (help_tip macro + tooltips.js) */
|
||||
/* ============================================================================ */
|
||||
|
||||
/* The (i) info trigger placed next to a setting label. */
|
||||
.help-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
margin-left: 0.375rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
border-radius: 9999px;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.help-tip:hover,
|
||||
.help-tip:focus-visible {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.help-tip:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Singleton tooltip panel appended to <body> by tooltips.js. */
|
||||
#ledm-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: 20rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-line; /* renders authored "\n" line breaks */
|
||||
pointer-events: none; /* never steals hover/click from the page */
|
||||
animation: tooltipFade 0.12s ease;
|
||||
}
|
||||
|
||||
#ledm-tooltip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes tooltipFade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ============================================================================ */
|
||||
/* Settings search — global header dropdown + per-tab filter */
|
||||
/* ============================================================================ */
|
||||
|
||||
#settings-search-results {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 50;
|
||||
padding: 0.25rem;
|
||||
max-height: min(60vh, 24rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ssr-group {
|
||||
padding: 0.375rem 0.625rem 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-tertiary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ssr-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.ssr-option:hover,
|
||||
.ssr-option.active {
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.ssr-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ssr-help {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ssr-empty {
|
||||
padding: 0.75rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Flash highlight applied to a field after search navigation. */
|
||||
@keyframes settingFlash {
|
||||
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
||||
25% { box-shadow: 0 0 0 3px var(--color-primary); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
||||
}
|
||||
|
||||
.setting-flash {
|
||||
animation: settingFlash 1.4s ease;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#ledm-tooltip { animation: none; }
|
||||
.setting-flash {
|
||||
animation: none;
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Removed .divider and .divider-light - not used anywhere */
|
||||
|
||||
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
* 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');
|
||||
// .hidden has no effect without a matching CSS rule (this app's stylesheet
|
||||
// is a hand-picked utility subset, not full Tailwind) - force it directly,
|
||||
// same as the revealNode/collapseNode fallback below.
|
||||
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; 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');
|
||||
})();
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
|
||||
*
|
||||
* A single controller handles every `.help-tip` trigger on the page, including
|
||||
* ones inside partials that HTMX swaps in later, with zero per-field wiring.
|
||||
* Triggers are emitted by the `help_tip` Jinja macro (partials/_macros.html) as
|
||||
* <button class="help-tip" data-tooltip="..."><i class="fas fa-circle-info">.
|
||||
*
|
||||
* Behaviour:
|
||||
* - hover (mouse) -> show / hide
|
||||
* - keyboard focus -> show / hide (only for :focus-visible)
|
||||
* - click / tap -> toggle (the touch path)
|
||||
* - Escape / outside click -> hide
|
||||
* The tooltip text is set via textContent (XSS-safe) and supports "\n" line
|
||||
* breaks via CSS `white-space: pre-line`. Styling lives in app.css and uses the
|
||||
* --color-* theme vars, so light/dark mode work automatically.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window._tooltipsInit) return;
|
||||
window._tooltipsInit = true;
|
||||
|
||||
var panel = null;
|
||||
var currentTrigger = null;
|
||||
|
||||
function getPanel() {
|
||||
if (panel) return panel;
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'ledm-tooltip';
|
||||
panel.setAttribute('role', 'tooltip');
|
||||
panel.hidden = true;
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function positionPanel(trigger) {
|
||||
var p = getPanel();
|
||||
var margin = 8;
|
||||
var rect = trigger.getBoundingClientRect();
|
||||
var pw = p.offsetWidth;
|
||||
var ph = p.offsetHeight;
|
||||
var vw = document.documentElement.clientWidth;
|
||||
var vh = document.documentElement.clientHeight;
|
||||
|
||||
// Prefer above the trigger; flip below if it would clip the top.
|
||||
var top = rect.top - ph - margin;
|
||||
var placedBelow = false;
|
||||
if (top < margin) {
|
||||
top = rect.bottom + margin;
|
||||
placedBelow = true;
|
||||
}
|
||||
// Keep it on screen vertically as a last resort.
|
||||
if (top + ph > vh - margin) top = Math.max(margin, vh - ph - margin);
|
||||
|
||||
// Center horizontally on the trigger, clamped to the viewport.
|
||||
var left = rect.left + rect.width / 2 - pw / 2;
|
||||
if (left < margin) left = margin;
|
||||
if (left + pw > vw - margin) left = Math.max(margin, vw - pw - margin);
|
||||
|
||||
p.style.top = Math.round(top) + 'px';
|
||||
p.style.left = Math.round(left) + 'px';
|
||||
p.setAttribute('data-placement', placedBelow ? 'below' : 'above');
|
||||
}
|
||||
|
||||
function show(trigger) {
|
||||
var text = trigger.getAttribute('data-tooltip');
|
||||
if (!text) return;
|
||||
var p = getPanel();
|
||||
p.textContent = text;
|
||||
p.hidden = false;
|
||||
// Measure after it is displayed, then position.
|
||||
positionPanel(trigger);
|
||||
trigger.setAttribute('aria-describedby', 'ledm-tooltip');
|
||||
currentTrigger = trigger;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!panel) return;
|
||||
panel.hidden = true;
|
||||
if (currentTrigger) {
|
||||
currentTrigger.removeAttribute('aria-describedby');
|
||||
currentTrigger = null;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFrom(target) {
|
||||
return target && target.closest ? target.closest('.help-tip') : null;
|
||||
}
|
||||
|
||||
// --- Delegated listeners on document (survive HTMX swaps) ---
|
||||
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
var t = triggerFrom(e.target);
|
||||
if (t) show(t);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', function (e) {
|
||||
var t = triggerFrom(e.target);
|
||||
if (!t) return;
|
||||
// Ignore moves that stay within the same trigger.
|
||||
var to = e.relatedTarget;
|
||||
if (to && t.contains(to)) return;
|
||||
if (currentTrigger === t) hide();
|
||||
});
|
||||
|
||||
document.addEventListener('focusin', function (e) {
|
||||
var t = triggerFrom(e.target);
|
||||
if (!t) return;
|
||||
// Only auto-show on keyboard focus, so a mouse/touch focus does not
|
||||
// fight the click handler below.
|
||||
var focusVisible;
|
||||
try {
|
||||
focusVisible = t.matches(':focus-visible');
|
||||
} catch { // older browsers without :focus-visible
|
||||
focusVisible = true;
|
||||
}
|
||||
if (focusVisible) show(t);
|
||||
});
|
||||
|
||||
document.addEventListener('focusout', function (e) {
|
||||
var t = triggerFrom(e.target);
|
||||
if (t && currentTrigger === t) hide();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = triggerFrom(e.target);
|
||||
if (t) {
|
||||
// Prevent an enclosing <label> from toggling its control, and
|
||||
// prevent form submission.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (currentTrigger === t && !getPanel().hidden) {
|
||||
hide();
|
||||
} else {
|
||||
show(t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Click anywhere else closes an open tooltip.
|
||||
if (panel && !panel.hidden && !panel.contains(e.target)) hide();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && panel && !panel.hidden) hide();
|
||||
});
|
||||
|
||||
// Reposition while visible; close when content is swapped out.
|
||||
window.addEventListener('scroll', function () {
|
||||
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
|
||||
}, true);
|
||||
window.addEventListener('resize', function () {
|
||||
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
|
||||
});
|
||||
document.body.addEventListener('htmx:afterSwap', function () {
|
||||
// The current trigger may have been removed by the swap.
|
||||
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
|
||||
});
|
||||
|
||||
console.log('[Tooltips] controller registered');
|
||||
})();
|
||||
Reference in New Issue
Block a user