feat(central-ui): InstancePicker search filter + keyboard (Enter/Escape/ArrowDown)
Typing in the field is the filter: the forest is rebuilt to the matching instances plus their ancestor areas and force-expanded so every match is on screen; clearing the text restores the full forest, collapsed. Both bulk expansions are deferred to OnAfterRenderAsync, because the TreeView only exists once the panel has rendered. Enter picks the first instance the operator can actually SEE — document order, descending into a branch only when the tree reports it expanded — so with areas collapsed it picks the first root instance, and with a filter (every branch expanded) it degrades to plain document order. Escape closes and restores the field text from the current selection; ArrowDown hands keyboard navigation to the tree via a new additive TreeView.FocusFirstVisibleAsync(), which reuses the existing roving-tabindex _focusNeedsApply path rather than adding a second focus mechanism. No preventDefault: Blazor's flag is all-or-nothing per element, and neither default is harmful (Enter in a text input outside a form does nothing, ArrowDown just moves the caret) while suppressing Tab would trap the control. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
2026-09-08). The field shows the selected instance; the panel hosts the shared
|
||||
TreeView over InstanceTreeBuilder's forest with every area collapsed. Clicking an
|
||||
area row toggles it, clicking an instance row selects it and closes the panel.
|
||||
Typing filters the forest to the matching instances and their ancestors; Enter picks
|
||||
the first visible match, ArrowDown hands keyboard navigation to the tree and Escape
|
||||
closes without changing the selection.
|
||||
Bootstrap only, no JavaScript: the transparent fixed backdrop closes the panel on
|
||||
an outside click, the same pattern TreeView uses for its context menu.
|
||||
*@
|
||||
@@ -16,7 +19,7 @@
|
||||
aria-expanded="@(_open ? "true" : "false")" aria-controls="@_panelId"
|
||||
placeholder="@Placeholder" disabled="@Disabled"
|
||||
data-test="@($"{DataTestPrefix}-input")"
|
||||
value="@_text" @onfocus="Open" />
|
||||
value="@_text" @onfocus="Open" @oninput="OnInput" @onkeydown="OnInputKeyDown" />
|
||||
<button type="button" class="btn btn-outline-secondary" tabindex="-1" aria-label="Browse instances"
|
||||
disabled="@Disabled" data-test="@($"{DataTestPrefix}-toggle")" @onclick="Toggle">
|
||||
<i class="bi @(_open ? "bi-chevron-up" : "bi-chevron-down")"></i>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
@@ -17,6 +18,15 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Typing in the field filters the forest to the instances whose <c>UniqueName</c> contains
|
||||
/// the text plus their ancestor areas, force-expanded so every match is on screen; clearing
|
||||
/// the text restores the full forest, collapsed. <c>Enter</c> picks the first instance the
|
||||
/// operator can actually see (document order, skipping collapsed branches), <c>ArrowDown</c>
|
||||
/// hands keyboard navigation to the tree's own roving tabindex, and <c>Escape</c> closes
|
||||
/// without changing the selection. The filter never survives a close or a pick.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A parent that swaps <see cref="Areas"/>/<see cref="Instances"/> (a site change)
|
||||
/// closes the panel and re-derives the field text from the current selection, clearing
|
||||
/// it when the selected instance is not in the new list. A parent that changes
|
||||
@@ -81,6 +91,24 @@ public partial class InstancePicker
|
||||
private IReadOnlyList<InstanceTreeNode> _roots = [];
|
||||
private TreeView<InstanceTreeNode>? _tree;
|
||||
|
||||
/// <summary>Bulk expansion the tree owes the current filter, applied after the next render.</summary>
|
||||
private enum PendingTreeAction
|
||||
{
|
||||
/// <summary>Nothing to do.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Open every branch so the filtered matches are all on screen.</summary>
|
||||
ExpandAll,
|
||||
|
||||
/// <summary>Close every branch, back to the drill-down starting point.</summary>
|
||||
CollapseAll,
|
||||
}
|
||||
|
||||
// The TreeView only exists once the panel has rendered, so keyboard/typing work that
|
||||
// targets it is deferred to OnAfterRenderAsync through these two flags.
|
||||
private PendingTreeAction _pendingTreeAction;
|
||||
private bool _pendingTreeFocus;
|
||||
|
||||
// Last parameter values seen, so OnParametersSet can tell a real change from a
|
||||
// parent that simply re-rendered with the same lists and selection.
|
||||
private IReadOnlyList<Area>? _lastAreas;
|
||||
@@ -123,6 +151,33 @@ public partial class InstancePicker
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
var action = _pendingTreeAction;
|
||||
var focus = _pendingTreeFocus;
|
||||
_pendingTreeAction = PendingTreeAction.None;
|
||||
_pendingTreeFocus = false;
|
||||
|
||||
// The panel may have closed again in the same batch (a pick, an Escape), taking
|
||||
// the tree with it — there is nothing left to expand or focus.
|
||||
if (!_open || _tree is null) return;
|
||||
|
||||
// ExpandAll/CollapseAll re-render the TREE, not this component, so consuming the
|
||||
// flag here cannot loop back into another picker render.
|
||||
switch (action)
|
||||
{
|
||||
case PendingTreeAction.ExpandAll:
|
||||
_tree.ExpandAll();
|
||||
break;
|
||||
case PendingTreeAction.CollapseAll:
|
||||
_tree.CollapseAll();
|
||||
break;
|
||||
}
|
||||
|
||||
if (focus) await _tree.FocusFirstVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>The instance <see cref="SelectedInstanceId"/> points at, or null when unresolved.</summary>
|
||||
private Instance? CurrentSelection() =>
|
||||
SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId);
|
||||
@@ -164,6 +219,74 @@ public partial class InstancePicker
|
||||
_text = selected is null ? string.Empty : LabelOf(selected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every keystroke retypes the filter: the field text IS the filter while the operator
|
||||
/// is typing, and the panel opens if it was closed. The rebuilt forest is expanded when
|
||||
/// something is being searched for and collapsed back to the drill-down view when the
|
||||
/// text is cleared — both after the next render, once the tree exists.
|
||||
/// </summary>
|
||||
private void OnInput(ChangeEventArgs e)
|
||||
{
|
||||
if (Disabled) return;
|
||||
|
||||
_text = e.Value?.ToString() ?? string.Empty;
|
||||
_filter = _text;
|
||||
_open = true;
|
||||
Rebuild();
|
||||
_pendingTreeAction = _hasFilter ? PendingTreeAction.ExpandAll : PendingTreeAction.CollapseAll;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The field's keyboard surface. Deliberately no <c>preventDefault</c>: Blazor's flag is
|
||||
/// all-or-nothing per element, and neither default is harmful here (Enter in a text input
|
||||
/// outside a form does nothing; ArrowDown just moves the caret) — while suppressing Tab
|
||||
/// would break getting out of the control.
|
||||
/// </summary>
|
||||
private Task OnInputKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (Disabled) return Task.CompletedTask;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case "Enter" when _open:
|
||||
return FirstVisibleInstance() is { } instance ? SelectAsync(instance) : Task.CompletedTask;
|
||||
|
||||
case "Escape" when _open:
|
||||
Close();
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
_open = true;
|
||||
_pendingTreeFocus = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first instance leaf in document order among the rows the operator can actually
|
||||
/// see — a collapsed area's contents are skipped, matching what Enter appears to pick.
|
||||
/// With a filter active every branch is expanded, so this degrades to plain document
|
||||
/// order over the filtered forest.
|
||||
/// </summary>
|
||||
private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots);
|
||||
|
||||
private Instance? FirstVisibleInstance(IReadOnlyList<InstanceTreeNode> nodes)
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node.Instance is { } instance) return instance;
|
||||
if (!node.HasChildren) continue;
|
||||
|
||||
// Before the tree's first render the filter's pending ExpandAll decides visibility.
|
||||
var expanded = _tree?.IsExpanded(node.Key) ?? _hasFilter;
|
||||
if (expanded && FirstVisibleInstance(node.Children) is { } hit) return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Task OnRowClickAsync(InstanceTreeNode node)
|
||||
{
|
||||
if (node.Instance is { } instance) return SelectAsync(instance);
|
||||
|
||||
@@ -601,6 +601,23 @@ else
|
||||
/// <summary>Whether the node with the given key is currently expanded.</summary>
|
||||
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
|
||||
|
||||
/// <summary>
|
||||
/// Make the first visible node the roving-tabindex target and pull browser focus to it,
|
||||
/// so a host control above the tree (a combobox field handing over on ArrowDown) can
|
||||
/// enter the tree's own arrow navigation. Focus is applied by the existing
|
||||
/// <c>_focusNeedsApply</c> path after the re-render this schedules. No-op on an empty tree.
|
||||
/// </summary>
|
||||
/// <returns>A completed task once the focus request has been scheduled.</returns>
|
||||
public Task FocusFirstVisibleAsync()
|
||||
{
|
||||
var visible = BuildVisibleNodes();
|
||||
if (visible.Count == 0) return Task.CompletedTask;
|
||||
|
||||
MoveFocusTo(visible[0].Key);
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expand the node with the given key if it is collapsed, collapse it otherwise —
|
||||
/// the same toggle the row chevron performs, for a consumer whose own
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
@@ -8,7 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
||||
/// bUnit tests for the <see cref="InstancePicker"/> combobox: the closed field, the
|
||||
/// panel that opens with every area collapsed, area expansion, instance selection
|
||||
/// (callback + close + field text), the disabled guard, the outside-click backdrop,
|
||||
/// parent-driven selection changes and the <c>ItemLabel</c> hook.
|
||||
/// parent-driven selection changes, the <c>ItemLabel</c> hook, and the search filter
|
||||
/// with its keyboard surface (Enter / Escape / ArrowDown).
|
||||
/// </summary>
|
||||
public class InstancePickerTests : BunitContext
|
||||
{
|
||||
@@ -54,6 +56,15 @@ public class InstancePickerTests : BunitContext
|
||||
private static string InputValue(IRenderedComponent<InstancePicker> cut) =>
|
||||
cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty;
|
||||
|
||||
private static void Type(IRenderedComponent<InstancePicker> cut, string text) =>
|
||||
cut.Find("[data-test='t-input']").Input(text);
|
||||
|
||||
private static void Press(IRenderedComponent<InstancePicker> cut, string key) =>
|
||||
cut.Find("[data-test='t-input']").KeyDown(new KeyboardEventArgs { Key = key });
|
||||
|
||||
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
|
||||
cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
|
||||
|
||||
[Fact]
|
||||
public void Closed_ShowsNothingButTheField()
|
||||
{
|
||||
@@ -191,4 +202,157 @@ public class InstancePickerTests : BunitContext
|
||||
|
||||
Assert.Equal("P1 (x)", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_Filters_AndExpandsToTheMatches()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "p1");
|
||||
|
||||
// The match's ancestor areas are force-expanded, so its row renders without a click.
|
||||
Assert.Single(cut.FindAll("[data-instance-id='7']"));
|
||||
|
||||
var rows = Rows(cut);
|
||||
Assert.Contains("Line1", rows);
|
||||
Assert.Contains("Cell A", rows);
|
||||
Assert.DoesNotContain("Zulu", rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_RestoresTheFullTree_Collapsed()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "p1");
|
||||
Assert.Single(cut.FindAll("[data-instance-id='7']"));
|
||||
|
||||
Type(cut, string.Empty);
|
||||
|
||||
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
|
||||
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_OpensTheClosedPanel()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
|
||||
Type(cut, "z");
|
||||
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal(new[] { "Zulu" }, Rows(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_WithNoMatch_DoesNothing()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "zzz");
|
||||
Press(cut, "Enter");
|
||||
|
||||
Assert.Null(picked);
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_PicksTheFirstVisibleMatch()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
Open(cut);
|
||||
|
||||
// "u" matches Zulu only — P1 drops out along with its now-empty ancestor areas.
|
||||
Type(cut, "u");
|
||||
Press(cut, "Enter");
|
||||
|
||||
Assert.Equal(8, picked);
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
Press(cut, "Enter");
|
||||
|
||||
// Areas render before root instances, but every area is collapsed — so the first
|
||||
// instance the operator can actually see is the root-level Zulu, not P1.
|
||||
Assert.Equal(8, picked);
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_ClosesAndRestoresTheSelectedText()
|
||||
{
|
||||
var picked = 0;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
ClickRow(cut, "Line1");
|
||||
ClickRow(cut, "Cell A");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
cut.Render(ps => ps.Add(p => p.SelectedInstanceId, picked));
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
|
||||
Open(cut);
|
||||
Type(cut, "zzz");
|
||||
Assert.Equal("zzz", InputValue(cut));
|
||||
|
||||
Press(cut, "Escape");
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrowDown_MovesFocusIntoTheTree()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Open(cut);
|
||||
|
||||
// The first row owns the tab stop by default, so move it off with the tree's own
|
||||
// arrow navigation first — otherwise the assertion below could pass by accident.
|
||||
ClickRow(cut, "Line1");
|
||||
cut.FindAll("[role='treeitem']")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" });
|
||||
Assert.Equal("-1", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
|
||||
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
var items = cut.FindAll("[role='treeitem']");
|
||||
Assert.Equal("0", items[0].GetAttribute("tabindex"));
|
||||
Assert.All(items.Skip(1), i => Assert.Equal("-1", i.GetAttribute("tabindex")));
|
||||
Assert.Contains(
|
||||
JSInterop.Invocations,
|
||||
i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterIsNotCarriedIntoSelection()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
Open(cut);
|
||||
|
||||
Type(cut, "p1");
|
||||
cut.Find("[data-instance-id='7']").Click();
|
||||
|
||||
Assert.Equal(7, picked);
|
||||
Assert.Equal("P1", InputValue(cut));
|
||||
|
||||
Open(cut);
|
||||
|
||||
Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut));
|
||||
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user