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
|
||||
|
||||
Reference in New Issue
Block a user