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; /// /// A combobox that picks one instance out of a site's area tree: a text field over a /// dropdown panel hosting across the forest that /// builds from the site's areas and instances. /// /// /// It replaces the flat instance <select> 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 /// uses for its context menu). /// /// /// /// Typing in the field filters the forest to the instances whose UniqueName contains /// the text plus their ancestor areas, force-expanded so every match is on screen; clearing /// the text restores the full forest, collapsed. Enter picks the first instance the /// operator can actually see (document order, skipping collapsed branches), ArrowDown /// hands keyboard navigation to the tree's own roving tabindex, and Escape closes /// without changing the selection. The filter never survives a close or a pick. /// /// /// /// A parent that swaps / (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 /// (a deep link or a localStorage restore) updates the /// field text while the panel is closed. /// /// public partial class InstancePicker { /// /// The site's areas, flat and linked by ; 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. /// [Parameter] public IReadOnlyList? Areas { get; set; } /// /// The site's instances; null is treated as empty. Compared by REFERENCE on the same /// contract as — pass a stable reference and only replace it when the /// underlying set changed. /// [Parameter] public IReadOnlyList? Instances { get; set; } /// Id of the currently selected instance; 0 means nothing is selected. [Parameter] public int SelectedInstanceId { get; set; } /// Raised with the instance id when the operator picks a row. [Parameter] public EventCallback SelectedInstanceIdChanged { get; set; } /// Disables the field and the chevron; the panel can no longer be opened. [Parameter] public bool Disabled { get; set; } /// /// Maps an instance to the text shown on its row and, once picked, in the field. /// Defaults to . Note that searching always matches /// on UniqueName (the builder's contract), whatever this renders. /// [Parameter] public Func? ItemLabel { get; set; } /// /// data-test root for the control: {prefix}-picker, -input, /// -toggle, -panel, -backdrop and -row (instance rows also /// carry data-instance-id). /// [Parameter] public string DataTestPrefix { get; set; } = "instance"; /// Placeholder shown in the field while nothing is selected. [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; /// /// The name filter applied to the forest. Empty until the search box lands; kept /// separate from so a selected label never doubles as a filter. /// private string _filter = string.Empty; private bool _hasFilter; private IReadOnlyList _roots = []; private TreeView? _tree; /// Bulk expansion the tree owes the current filter, applied after the next render. private enum PendingTreeAction { /// Nothing to do. None, /// Open every branch so the filtered matches are all on screen. ExpandAll, /// Close every branch, back to the drill-down starting point. 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? _lastAreas; private IReadOnlyList? _lastInstances; private int _lastSelectedId; private bool _parametersSeen; private string _inputId => $"ip-{_idSuffix}-input"; private string _panelId => $"ip-{_idSuffix}-panel"; /// 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); } } /// 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(); } /// The instance points at, or null when unresolved. private Instance? CurrentSelection() => SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId); /// Rebuilds the forest for the current filter. 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); } /// /// 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. /// 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; } /// /// The field's keyboard surface. Deliberately no preventDefault: 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. /// 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; } /// /// 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. /// private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots); private Instance? FirstVisibleInstance(IReadOnlyList 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); } }