diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor
new file mode 100644
index 00000000..4e32178c
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor
@@ -0,0 +1,42 @@
+@*
+ Instance Picker — a combobox whose dropdown is the site's area tree (design
+ 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.
+ Bootstrap only, no JavaScript: the transparent fixed backdrop closes the panel on
+ an outside click, the same pattern TreeView uses for its context menu.
+*@
+@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
+@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
+
+
+
+
+
+
+ @if (_open)
+ {
+
+
+
+
+
+ OnRowClickAsync(node)">@RowLabel(node)
+
+ @(_hasFilter ? "No instances match." : "No instances at this site.")
+
+
+ }
+
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs
new file mode 100644
index 00000000..55b00584
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs
@@ -0,0 +1,187 @@
+using Microsoft.AspNetCore.Components;
+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).
+///
+///
+///
+/// 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.
+ [Parameter]
+ public IReadOnlyList? Areas { get; set; }
+
+ /// The site's instances; null is treated as empty.
+ [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;
+
+ // 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 if the selection went away.
+ _filter = string.Empty;
+ Rebuild();
+ if (selected is null) _open = false;
+ _text = selected is null ? string.Empty : LabelOf(selected);
+ }
+ else if (!_open)
+ {
+ _text = selected is null ? string.Empty : LabelOf(selected);
+ }
+ }
+
+ /// 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);
+ }
+
+ 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);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css
new file mode 100644
index 00000000..cb7575e9
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css
@@ -0,0 +1,32 @@
+/* Instance Picker combobox — the field is a plain input-group; only the dropdown
+ panel and its outside-click backdrop need styling. */
+
+/* Transparent full-viewport catcher: a click anywhere outside the panel closes it.
+ Sits just under the panel and above page content (Bootstrap's dropdown z-index
+ band), mirroring TreeView's context-menu overlay. */
+.ip-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1040;
+ background: transparent;
+}
+
+/* Dropdown panel anchored under the field (the wrapper is position-relative).
+ Scrolls once the site's tree outgrows 40% of the viewport. */
+.ip-panel {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+ z-index: 1041;
+ max-height: 40vh;
+ overflow-y: auto;
+ min-width: 18rem;
+ padding: 0.25rem;
+}
+
+/* Both row kinds are clickable: an area toggles, an instance selects. */
+.ip-instance,
+.ip-area {
+ cursor: pointer;
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
index e5f52320..19b84920 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
@@ -601,6 +601,18 @@ else
/// Whether the node with the given key is currently expanded.
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
+ ///
+ /// 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
+ /// NodeContent handles the click (a label that toggles its branch).
+ ///
+ /// Key of the node to toggle, as produced by KeySelector.
+ public void ToggleExpanded(object key)
+ {
+ ToggleExpand(key);
+ StateHasChanged();
+ }
+
/// Expand every branch node in the tree.
public void ExpandAll()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs
new file mode 100644
index 00000000..75669dc2
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs
@@ -0,0 +1,194 @@
+using Bunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
+
+///
+/// bUnit tests for the 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 ItemLabel hook.
+///
+public class InstancePickerTests : BunitContext
+{
+ public InstancePickerTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ }
+
+ private static List Areas() =>
+ [
+ new("Line1") { Id = 1, SiteId = 1 },
+ new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 },
+ ];
+
+ private static List Instances() =>
+ [
+ new("P1") { Id = 7, SiteId = 1, AreaId = 2 },
+ new("Zulu") { Id = 8, SiteId = 1 },
+ ];
+
+ private IRenderedComponent RenderPicker(
+ Action? onChanged = null,
+ bool disabled = false,
+ Func? itemLabel = null,
+ int selectedInstanceId = 0) =>
+ Render(ps =>
+ {
+ ps.Add(p => p.Areas, Areas());
+ ps.Add(p => p.Instances, Instances());
+ ps.Add(p => p.DataTestPrefix, "t");
+ ps.Add(p => p.Disabled, disabled);
+ ps.Add(p => p.SelectedInstanceId, selectedInstanceId);
+ if (itemLabel is not null) ps.Add(p => p.ItemLabel, itemLabel);
+ if (onChanged is not null) ps.Add(p => p.SelectedInstanceIdChanged, onChanged);
+ });
+
+ private static void Open(IRenderedComponent cut) =>
+ cut.Find("[data-test='t-toggle']").Click();
+
+ private static void ClickRow(IRenderedComponent cut, string text) =>
+ cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click();
+
+ private static string InputValue(IRenderedComponent cut) =>
+ cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty;
+
+ [Fact]
+ public void Closed_ShowsNothingButTheField()
+ {
+ var cut = RenderPicker();
+
+ Assert.Empty(cut.FindAll("[data-test='t-panel']"));
+
+ var input = cut.Find("[data-test='t-input']");
+ Assert.Equal("combobox", input.GetAttribute("role"));
+ Assert.Equal("false", input.GetAttribute("aria-expanded"));
+ }
+
+ [Fact]
+ public void Toggle_OpensPanel_WithAreasCollapsed()
+ {
+ var cut = RenderPicker();
+
+ Open(cut);
+
+ Assert.Single(cut.FindAll("[data-test='t-panel']"));
+ Assert.Equal("true", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
+
+ var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
+ Assert.Equal(new[] { "Line1", "Zulu" }, rows);
+
+ // P1 sits under a collapsed area, so its row is not rendered at all.
+ Assert.Empty(cut.FindAll("[data-instance-id='7']"));
+
+ // Area rows must not carry a data-instance-id at all (Blazor drops a null attribute).
+ var areaRow = cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == "Line1");
+ Assert.False(areaRow.HasAttribute("data-instance-id"));
+ }
+
+ [Fact]
+ public void ClickingAnAreaLabel_ExpandsIt()
+ {
+ var cut = RenderPicker();
+ Open(cut);
+
+ ClickRow(cut, "Line1");
+ Assert.Contains(cut.FindAll("[data-test='t-row']"), r => r.TextContent.Trim() == "Cell A");
+ Assert.Empty(cut.FindAll("[data-instance-id='7']"));
+
+ ClickRow(cut, "Cell A");
+ Assert.Single(cut.FindAll("[data-instance-id='7']"));
+ }
+
+ [Fact]
+ public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback()
+ {
+ int? picked = null;
+ var cut = RenderPicker(onChanged: id => picked = id);
+
+ Open(cut);
+ ClickRow(cut, "Line1");
+ ClickRow(cut, "Cell A");
+ cut.Find("[data-instance-id='7']").Click();
+
+ Assert.Equal(7, picked);
+ Assert.Empty(cut.FindAll("[data-test='t-panel']"));
+ Assert.Equal("P1", InputValue(cut));
+ }
+
+ [Fact]
+ public void Disabled_NeverOpens()
+ {
+ var cut = RenderPicker(disabled: true);
+
+ Assert.True(cut.Find("[data-test='t-input']").HasAttribute("disabled"));
+ Assert.True(cut.Find("[data-test='t-toggle']").HasAttribute("disabled"));
+
+ Open(cut);
+
+ Assert.Empty(cut.FindAll("[data-test='t-panel']"));
+ }
+
+ [Fact]
+ public void Backdrop_ClickClosesThePanel_WithoutChangingSelection()
+ {
+ int? picked = null;
+ var cut = RenderPicker(onChanged: id => picked = id);
+
+ Open(cut);
+ cut.Find("[data-test='t-backdrop']").Click();
+
+ Assert.Empty(cut.FindAll("[data-test='t-panel']"));
+ Assert.Null(picked);
+ Assert.Equal(string.Empty, InputValue(cut));
+ }
+
+ [Fact]
+ public void ParentDrivenSelection_UpdatesTheFieldText()
+ {
+ var cut = RenderPicker();
+
+ cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 8));
+ Assert.Equal("Zulu", InputValue(cut));
+
+ cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 0));
+ Assert.Equal(string.Empty, InputValue(cut));
+ }
+
+ [Fact]
+ public void SiteChange_NewInstancesList_ClearsAStaleSelectionText()
+ {
+ var cut = RenderPicker(selectedInstanceId: 8);
+ Assert.Equal("Zulu", InputValue(cut));
+
+ Open(cut);
+
+ // A different site: neither the areas nor the previously selected instance survive.
+ cut.Render(ps =>
+ {
+ ps.Add(p => p.Areas, new List());
+ ps.Add(p => p.Instances, new List { new("Other") { Id = 99, SiteId = 2 } });
+ });
+
+ Assert.Equal(string.Empty, InputValue(cut));
+ Assert.Empty(cut.FindAll("[data-test='t-panel']"));
+ }
+
+ [Fact]
+ public void ItemLabel_IsUsedForRowsAndField()
+ {
+ var cut = RenderPicker(itemLabel: i => i.UniqueName + " (x)");
+
+ Open(cut);
+
+ var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
+ Assert.Equal(new[] { "Line1", "Zulu (x)" }, rows);
+
+ ClickRow(cut, "Line1");
+ ClickRow(cut, "Cell A");
+ cut.Find("[data-instance-id='7']").Click();
+
+ Assert.Equal("P1 (x)", InputValue(cut));
+ }
+}