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:
Joseph Doherty
2026-09-08 09:45:15 -04:00
parent 3c3a7cbbc1
commit dfdf480fa1
3 changed files with 46 additions and 1 deletions
@@ -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;