diff --git a/test/test_web_settings_ui.py b/test/test_web_settings_ui.py index 89abb778..d6852447 100644 --- a/test/test_web_settings_ui.py +++ b/test/test_web_settings_ui.py @@ -140,3 +140,45 @@ def test_search_index_endpoint(client): assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields) # Section context is captured for grouped fields (e.g. Display hardware). assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration" + + +def test_plugin_config_partial_has_filter_and_nested_anchors(): + """Plugin config tabs expose the per-tab filter and anchor nested fields. + + The client fixture has no installed plugins, so render the partial directly + with a schema that includes a nested section (render_nested_section). + """ + from jinja2 import Environment, FileSystemLoader, select_autoescape + + env = Environment( + loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")), + autoescape=select_autoescape(["html"]), + ) + plugin = { + "id": "demo-plugin", "name": "Demo Plugin", "description": "A demo", + "enabled": True, "author": "me", "version": "1.0.0", + } + schema = { + "type": "object", + "properties": { + "title_text": {"type": "string", "title": "Title Text", + "description": "The heading."}, + "advanced": { + "type": "object", "title": "Advanced Options", + "description": "Nested options.", + "properties": { + "scroll_speed": {"type": "integer", "title": "Scroll Speed", + "description": "Pixels per second."}, + }, + }, + }, + } + config = {"title_text": "Hi", "advanced": {"scroll_speed": 50}} + html = env.get_template("v3/partials/plugin_config.html").render( + plugin=plugin, schema=schema, config=config + ) + + assert 'class="settings-filter' in html, "plugin config: per-tab filter box missing" + assert "nested-content" in html, "plugin config: nested section not rendered" + assert 'id="setting-' in html, "plugin config: no search anchors rendered" + assert 'class="help-tip"' in html, "plugin config: no tooltips rendered" diff --git a/web_interface/static/v3/js/settings-search.js b/web_interface/static/v3/js/settings-search.js index 397902ff..9a003168 100644 --- a/web_interface/static/v3/js/settings-search.js +++ b/web_interface/static/v3/js/settings-search.js @@ -209,6 +209,18 @@ } } + // 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) { @@ -221,6 +233,19 @@ } } + // 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 @@ -232,6 +257,8 @@ 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; @@ -307,6 +334,20 @@ if (e.target === input || resultsBox.contains(e.target)) return; closeResults(); }); + + // 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) ------------------------------------------- @@ -337,11 +378,36 @@ fields.forEach(function (fg) { var show = !terms.length || termsMatch(fieldHay(fg), terms); fg.style.display = show ? '' : 'none'; - if (show) anyVisible = true; + 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); + } }); - // Hide section headings whose settings all got filtered out. - var nodes = scope.querySelectorAll('h3, h4, .form-group'); + 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) { @@ -351,6 +417,9 @@ } 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) { diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index ad05d081..e427918f 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -999,6 +999,7 @@ {# Configuration Form Panel #}