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