fix(central-ui): InstancePicker — keyboard activation selects/toggles through TreeView selection; aria-label/InputId; aria-controls only while open
ArrowDown moved focus into the tree, but the picker passed no Selectable /
SelectedKeyChanged, so Enter/Space on a treeitem only moved the roving tabindex —
the pick lived on the label span's @onclick, i.e. mouse only. The tree now runs in
Selectable mode and BOTH gestures arrive at one handler (OnTreeKeyActivatedAsync)
through TreeView's SelectedKeyChanged: an `i:{id}` key selects that instance, an
`a:{id}` key toggles the branch. The label's own @onclick is gone — the click
already bubbles into TreeView's content span, so a second handler there would fire
the callback twice. SelectedKey stays unbound (a combobox shows its pick in the
field, not as a row highlight, and binding it would arm TreeView's
selected-key-not-in-tree reset on every render while the pick is filtered out);
SelectedCssClass is emptied to match.
a11y: the field gains an accessible name via a new AriaLabel parameter (default
"Instance") and an optional InputId so a page can point a <label for> at it;
aria-controls is rendered only while the panel exists, instead of always pointing
at an id that is absent when closed.
Also drops the unused @using of Commons.Entities.Instances from the .razor.
Tests: keyboard activation on an instance row and on an area row, a
one-selection-per-gesture guard across mouse and keyboard, and the labelling
assertions. CentralUI suite 1331 passed / 0 failed; build 0 warnings.
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -65,6 +65,10 @@ public class InstancePickerTests : BunitContext
|
||||
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
|
||||
cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
|
||||
|
||||
/// <summary>The nth treeitem in document order - i.e. the nth row the operator can see.</summary>
|
||||
private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent<InstancePicker> cut, int index) =>
|
||||
cut.FindAll("[role='treeitem']")[index];
|
||||
|
||||
[Fact]
|
||||
public void Closed_ShowsNothingButTheField()
|
||||
{
|
||||
@@ -450,4 +454,106 @@ public class InstancePickerTests : BunitContext
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal(new[] { "Zulu" }, Rows(cut));
|
||||
}
|
||||
|
||||
// -- Keyboard activation on the tree itself --------------------------------
|
||||
|
||||
[Fact]
|
||||
public void KeyboardActivation_OnATreeRow_SelectsTheInstance()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
// Areas render before root instances, so Zulu is the SECOND treeitem - the first
|
||||
// one is the Line1 area (see KeyboardActivation_OnAnAreaRow_TogglesIt).
|
||||
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
|
||||
Assert.Equal(8, picked);
|
||||
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Equal("Zulu", InputValue(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardActivation_OnAnAreaRow_TogglesIt()
|
||||
{
|
||||
int? picked = null;
|
||||
var cut = RenderPicker(onChanged: id => picked = id);
|
||||
|
||||
Open(cut);
|
||||
Press(cut, "ArrowDown");
|
||||
|
||||
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
|
||||
// An area activates as an expand, not as a pick: its children appear and the
|
||||
// panel stays open with no callback raised.
|
||||
Assert.Contains("Cell A", Rows(cut));
|
||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||
Assert.Null(picked);
|
||||
|
||||
// The same key on the same row closes it again - activation is a toggle.
|
||||
TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
Assert.DoesNotContain("Cell A", Rows(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection()
|
||||
{
|
||||
// The label click and Enter both run through TreeView's single selection path,
|
||||
// so neither input mode may double-fire the callback.
|
||||
var picks = new List<int>();
|
||||
var cut = RenderPicker(onChanged: picks.Add);
|
||||
|
||||
Open(cut);
|
||||
ClickRow(cut, "Zulu");
|
||||
Assert.Equal(new[] { 8 }, picks);
|
||||
|
||||
Open(cut);
|
||||
TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" });
|
||||
Assert.Equal(new[] { 8, 8 }, picks);
|
||||
}
|
||||
|
||||
// -- Accessible name / labelling -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
Assert.Equal("Instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
|
||||
|
||||
cut.Render(ps => ps.Add(p => p.AriaLabel, "Target instance"));
|
||||
Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter()
|
||||
{
|
||||
// A page that wants its own <label for> supplies the id; two pickers left to the
|
||||
// generated one must still not collide.
|
||||
var first = RenderPicker();
|
||||
var second = RenderPicker();
|
||||
|
||||
var generated = first.Find("[data-test='t-input']").Id;
|
||||
Assert.False(string.IsNullOrEmpty(generated));
|
||||
Assert.NotEqual(generated, second.Find("[data-test='t-input']").Id);
|
||||
|
||||
first.Render(ps => ps.Add(p => p.InputId, "flow-instance"));
|
||||
Assert.Equal("flow-instance", first.Find("[data-test='t-input']").Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AriaControls_IsRenderedOnlyWhileThePanelIsOpen()
|
||||
{
|
||||
var cut = RenderPicker();
|
||||
|
||||
// Closed: the panel element does not exist, so a dangling aria-controls would
|
||||
// point screen readers at nothing.
|
||||
Assert.False(cut.Find("[data-test='t-input']").HasAttribute("aria-controls"));
|
||||
|
||||
Open(cut);
|
||||
Assert.Equal(
|
||||
cut.Find("[data-test='t-panel']").Id,
|
||||
cut.Find("[data-test='t-input']").GetAttribute("aria-controls"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user