From dfdf480fa189f1c95daaa5016128c1182114a299 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 09:45:15 -0400 Subject: [PATCH] =?UTF-8?q?test(central-ui):=20InstancePicker=20=E2=80=94?= =?UTF-8?q?=20closed-panel=20ArrowDown,=20Escape=20no-op;=20dispatcher-ord?= =?UTF-8?q?ering=20and=20overflow=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArrowDown on a picker that has never been opened pins the deferred path where _tree is still null when the keydown is handled: the panel opens in that render pass and the roving tab stop lands on the first row in the next one. Escape on a closed picker is pinned as a no-op. Two notes, no behaviour change: FirstVisibleInstance records WHY it may trust TreeView.IsExpanded (Blazor Server's single renderer sync context runs the render plus the pending ExpandAll/CollapseAll before the next keydown is dispatched — an await that genuinely yields inside OnAfterRenderAsync would break that), and the stylesheet warns integrators that the absolutely-positioned panel is clipped by any ancestor with overflow: hidden/auto. The ArrowDown focus interop assertion is kept — it was stable across repeated runs — with a comment marking the tabindex as the behavioural check. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../Components/Shared/InstancePicker.razor.cs | 10 +++++++ .../Shared/InstancePicker.razor.css | 7 ++++- .../Shared/InstancePickerTests.cs | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) 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() {