mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 17:28:05 +00:00
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
This commit is contained in:
@@ -140,3 +140,45 @@ def test_search_index_endpoint(client):
|
|||||||
assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields)
|
assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields)
|
||||||
# Section context is captured for grouped fields (e.g. Display hardware).
|
# Section context is captured for grouped fields (e.g. Display hardware).
|
||||||
assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration"
|
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"
|
||||||
|
|||||||
@@ -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
|
// Reveal any collapsed nested section (from render_nested_section) so the
|
||||||
// target field is actually visible before we scroll to it.
|
// target field is actually visible before we scroll to it.
|
||||||
function revealAncestors(el) {
|
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) {
|
function flash(el) {
|
||||||
el.classList.remove('setting-flash');
|
el.classList.remove('setting-flash');
|
||||||
// force reflow so re-adding the class restarts the animation
|
// force reflow so re-adding the class restarts the animation
|
||||||
@@ -232,6 +257,8 @@
|
|||||||
|
|
||||||
function navigateToSetting(entry) {
|
function navigateToSetting(entry) {
|
||||||
closeResults();
|
closeResults();
|
||||||
|
// Clear the box so it doesn't re-open stale results when refocused.
|
||||||
|
if (input) input.value = '';
|
||||||
setActiveTab(entry.tab);
|
setActiveTab(entry.tab);
|
||||||
waitForElement(entry.anchorId, 6000).then(function (el) {
|
waitForElement(entry.anchorId, 6000).then(function (el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -307,6 +334,20 @@
|
|||||||
if (e.target === input || resultsBox.contains(e.target)) return;
|
if (e.target === input || resultsBox.contains(e.target)) return;
|
||||||
closeResults();
|
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) -------------------------------------------
|
// --- Per-tab filter (delegated) -------------------------------------------
|
||||||
@@ -337,11 +378,36 @@
|
|||||||
fields.forEach(function (fg) {
|
fields.forEach(function (fg) {
|
||||||
var show = !terms.length || termsMatch(fieldHay(fg), terms);
|
var show = !terms.length || termsMatch(fieldHay(fg), terms);
|
||||||
fg.style.display = show ? '' : 'none';
|
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.
|
if (!terms.length) {
|
||||||
var nodes = scope.querySelectorAll('h3, h4, .form-group');
|
// 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 headings = [];
|
||||||
var current = null;
|
var current = null;
|
||||||
nodes.forEach(function (node) {
|
nodes.forEach(function (node) {
|
||||||
@@ -351,6 +417,9 @@
|
|||||||
} else if (current && node.matches('.form-group[id^="setting-"]')) {
|
} else if (current && node.matches('.form-group[id^="setting-"]')) {
|
||||||
current.total++;
|
current.total++;
|
||||||
if (node.style.display !== 'none') current.visible++;
|
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) {
|
headings.forEach(function (h) {
|
||||||
|
|||||||
@@ -999,6 +999,7 @@
|
|||||||
{# Configuration Form Panel #}
|
{# Configuration Form Panel #}
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
|
||||||
|
{{ ui.settings_filter("Filter this plugin's settings…") }}
|
||||||
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
||||||
{% if schema and schema.properties %}
|
{% if schema and schema.properties %}
|
||||||
{# Use property order if defined, otherwise use natural order #}
|
{# Use property order if defined, otherwise use natural order #}
|
||||||
|
|||||||
Reference in New Issue
Block a user