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 index f31c074c..198d9cff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs @@ -281,6 +281,16 @@ public partial class InstancePicker /// 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. + /// + /// + /// Reading is safe here because Blazor Server + /// dispatches every UI event on one renderer sync context: the render and the pending + /// ExpandAll/CollapseAll that applies both + /// complete before the NEXT keydown is dispatched, so the tree's expansion state can never + /// be one keystroke stale. That would stop holding if the pending action ever became + /// genuinely async (an await that yields inside ); it would + /// then have to be awaited before Enter is allowed to read the tree. + /// /// private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots); 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 index 7a756125..abc9d954 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css @@ -22,7 +22,12 @@ } /* Dropdown panel anchored under the field (the wrapper is position-relative). - Scrolls once the site's tree outgrows 40% of the viewport. */ + Scrolls once the site's tree outgrows 40% of the viewport. + + INTEGRATORS: the panel is absolutely positioned INSIDE the picker's wrapper, so any + ancestor with `overflow: hidden`/`auto` (a card body, a scrolling toolbar, a table cell) + clips it — the tree appears truncated or invisible while the field still says it is open. + Place the picker outside such a container, or let that container overflow visibly. */ .ip-panel { position: absolute; left: 0; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs index 765bf160..07dec031 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs @@ -332,11 +332,41 @@ public class InstancePickerTests : BunitContext var items = cut.FindAll("[role='treeitem']"); Assert.Equal("0", items[0].GetAttribute("tabindex")); Assert.All(items.Skip(1), i => Assert.Equal("-1", i.GetAttribute("tabindex"))); + + // The tab stop above is the behavioural assertion; this is the belt-and-braces check + // that real browser focus was requested too. ElementReference.FocusAsync surfaces as a + // JS invocation under Loose mode — drop this line if a bUnit upgrade stops recording it. Assert.Contains( JSInterop.Invocations, i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow() + { + // The tree does not exist yet when the keydown is handled, so this pins the deferred + // path: the panel opens in this render pass and the focus lands in the next one. + var cut = RenderPicker(); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + + Press(cut, "ArrowDown"); + + Assert.Single(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex")); + } + + [Fact] + public void Escape_WhenClosed_IsANoOp() + { + var cut = RenderPicker(selectedInstanceId: 8); + Assert.Equal("Zulu", InputValue(cut)); + + Press(cut, "Escape"); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("Zulu", InputValue(cut)); + } + [Fact] public void FilterIsNotCarriedIntoSelection() {