test(central-ui): InstancePicker — closed-panel ArrowDown, Escape no-op; dispatcher-ordering and overflow notes
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
This commit is contained in:
@@ -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.
|
||||
///
|
||||
/// <para>
|
||||
/// Reading <see cref="TreeView{TItem}.IsExpanded"/> is safe here because Blazor Server
|
||||
/// dispatches every UI event on one renderer sync context: the render and the pending
|
||||
/// <c>ExpandAll</c>/<c>CollapseAll</c> that <see cref="OnAfterRenderAsync"/> 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 <see cref="OnAfterRenderAsync"/>); it would
|
||||
/// then have to be awaited before Enter is allowed to read the tree.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user