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:
@@ -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