Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs
T
Joseph Doherty 3c3a7cbbc1 fix(central-ui): InstancePicker — field above the backdrop; site change always closes; reference contract documented; tests
Three Task 2 review findings:

1. The outside-click backdrop is a fixed full-viewport layer at z-index 1040,
   but the input-group was unpositioned — so once the panel was open a click on
   the field or the chevron hit the backdrop and closed the panel instead of
   letting the operator keep typing. The field now carries .ip-field
   (position:relative, z-index 1042), stacking backdrop < panel < field. Pinned
   in bUnit by the class (the z-index itself is scoped CSS, verified visually).

2. OnParametersSet closes the panel on ANY lists change, not only when the
   selection disappeared: an open panel is showing the previous site's tree,
   which is wrong even when the selected instance happens to exist in both.
   The reference-equality contract on Areas/Instances is now stated on both
   parameters — pass a stable reference, replace it only when the set changed.

3. Tests: the chevron closes an open panel; two pickers mint distinct panel ids
   (and matching aria-controls); an unrelated parameter change with the SAME
   Instances reference leaves a half-typed filter and the open panel alone.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:39:46 -04:00

323 lines
12 KiB
C#

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;
/// <summary>
/// A combobox that picks one instance out of a site's area tree: a text field over a
/// dropdown panel hosting <see cref="TreeView{TItem}"/> across the forest that
/// <see cref="InstanceTreeBuilder"/> builds from the site's areas and instances.
///
/// <para>
/// It replaces the flat instance <c>&lt;select&gt;</c> on Flow Debugger and Debug View,
/// where a site with a few hundred instances made the list unusable. The panel opens
/// with every area collapsed so the operator drills down; an outside click closes it
/// through a transparent fixed backdrop (no JavaScript, the same pattern
/// <see cref="TreeView{TItem}"/> uses for its context menu).
/// </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
/// <see cref="SelectedInstanceId"/> (a deep link or a localStorage restore) updates the
/// field text while the panel is closed.
/// </para>
/// </summary>
public partial class InstancePicker
{
/// <summary>
/// The site's areas, flat and linked by <see cref="Area.ParentAreaId"/>; null is treated
/// as empty. Compared by REFERENCE: a new list object means "the site changed" and closes
/// the panel, drops the filter and re-derives the field text. Hold a stable reference and
/// only hand the picker a new list when the underlying set actually changed — rebuilding
/// the list on every parent render would close the panel out from under the operator.
/// </summary>
[Parameter]
public IReadOnlyList<Area>? Areas { get; set; }
/// <summary>
/// The site's instances; null is treated as empty. Compared by REFERENCE on the same
/// contract as <see cref="Areas"/> — pass a stable reference and only replace it when the
/// underlying set changed.
/// </summary>
[Parameter]
public IReadOnlyList<Instance>? Instances { get; set; }
/// <summary>Id of the currently selected instance; <c>0</c> means nothing is selected.</summary>
[Parameter]
public int SelectedInstanceId { get; set; }
/// <summary>Raised with the instance id when the operator picks a row.</summary>
[Parameter]
public EventCallback<int> SelectedInstanceIdChanged { get; set; }
/// <summary>Disables the field and the chevron; the panel can no longer be opened.</summary>
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Maps an instance to the text shown on its row and, once picked, in the field.
/// Defaults to <see cref="Instance.UniqueName"/>. Note that searching always matches
/// on <c>UniqueName</c> (the builder's contract), whatever this renders.
/// </summary>
[Parameter]
public Func<Instance, string>? ItemLabel { get; set; }
/// <summary>
/// <c>data-test</c> root for the control: <c>{prefix}-picker</c>, <c>-input</c>,
/// <c>-toggle</c>, <c>-panel</c>, <c>-backdrop</c> and <c>-row</c> (instance rows also
/// carry <c>data-instance-id</c>).
/// </summary>
[Parameter]
public string DataTestPrefix { get; set; } = "instance";
/// <summary>Placeholder shown in the field while nothing is selected.</summary>
[Parameter]
public string Placeholder { get; set; } = "Search or browse instances…";
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
private bool _open;
private string _text = string.Empty;
/// <summary>
/// The name filter applied to the forest. Empty until the search box lands; kept
/// separate from <see cref="_text"/> so a selected label never doubles as a filter.
/// </summary>
private string _filter = string.Empty;
private bool _hasFilter;
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;
private IReadOnlyList<Instance>? _lastInstances;
private int _lastSelectedId;
private bool _parametersSeen;
private string _inputId => $"ip-{_idSuffix}-input";
private string _panelId => $"ip-{_idSuffix}-panel";
/// <inheritdoc />
protected override void OnParametersSet()
{
var listsChanged = !_parametersSeen
|| !ReferenceEquals(_lastAreas, Areas)
|| !ReferenceEquals(_lastInstances, Instances);
var selectionChanged = !_parametersSeen || _lastSelectedId != SelectedInstanceId;
_parametersSeen = true;
_lastAreas = Areas;
_lastInstances = Instances;
_lastSelectedId = SelectedInstanceId;
if (!listsChanged && !selectionChanged) return;
var selected = CurrentSelection();
if (listsChanged)
{
// A site change: drop any filter, rebuild, and close unconditionally — the panel
// is showing the OLD site's tree, so leaving it open would be showing a lie even
// when the selected instance happens to exist in the new list too.
_filter = string.Empty;
Rebuild();
_open = false;
_text = selected is null ? string.Empty : LabelOf(selected);
}
else if (!_open)
{
_text = selected is null ? string.Empty : LabelOf(selected);
}
}
/// <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);
/// <summary>Rebuilds the forest for the current filter.</summary>
private void Rebuild()
{
_hasFilter = !string.IsNullOrWhiteSpace(_filter);
_roots = InstanceTreeBuilder.Build(Areas, Instances, _filter);
}
private string LabelOf(Instance instance) => ItemLabel?.Invoke(instance) ?? instance.UniqueName;
private string RowLabel(InstanceTreeNode node) =>
node.Instance is { } instance ? LabelOf(instance) : node.Label;
private void Open()
{
if (Disabled || _open) return;
_open = true;
}
private void Toggle()
{
if (Disabled) return;
if (_open) Close(); else _open = true;
}
private void Close()
{
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
var selected = CurrentSelection();
_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);
_tree?.ToggleExpanded(node.Key);
return Task.CompletedTask;
}
private async Task SelectAsync(Instance instance)
{
_text = LabelOf(instance);
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
await SelectedInstanceIdChanged.InvokeAsync(instance.Id);
}
}