3c3a7cbbc1
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
323 lines
12 KiB
C#
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><select></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);
|
|
}
|
|
}
|