Merge branch 'worktree-agent-a710cd8085a729732' into feat/instance-picker
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
Instance Picker — a combobox whose dropdown is the site's area tree (design
|
Instance Picker — a combobox whose dropdown is the site's area tree (design
|
||||||
2026-09-08). The field shows the selected instance; the panel hosts the shared
|
2026-09-08). The field shows the selected instance; the panel hosts the shared
|
||||||
TreeView over InstanceTreeBuilder's forest with every area collapsed. Clicking an
|
TreeView over InstanceTreeBuilder's forest with every area collapsed. Clicking an
|
||||||
area row toggles it, clicking an instance row selects it and closes the panel.
|
area row toggles it, activating an instance row selects it and closes the panel —
|
||||||
|
and "activating" is one path for both input modes, because the tree runs in
|
||||||
|
Selectable mode and the mouse click reaches it through TreeView's own content span.
|
||||||
Typing filters the forest to the matching instances and their ancestors; Enter picks
|
Typing filters the forest to the matching instances and their ancestors; Enter picks
|
||||||
the first visible match, ArrowDown hands keyboard navigation to the tree and Escape
|
the first visible match, ArrowDown hands keyboard navigation to the tree and Escape
|
||||||
closes without changing the selection.
|
closes without changing the selection.
|
||||||
@@ -10,14 +12,13 @@
|
|||||||
an outside click, the same pattern TreeView uses for its context menu.
|
an outside click, the same pattern TreeView uses for its context menu.
|
||||||
*@
|
*@
|
||||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
|
|
||||||
|
|
||||||
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
|
<div class="ip-wrap position-relative" data-test="@($"{DataTestPrefix}-picker")">
|
||||||
@* ip-field lifts the field above the open panel's backdrop — see the .razor.css note. *@
|
@* ip-field lifts the field above the open panel's backdrop — see the .razor.css note. *@
|
||||||
<div class="input-group input-group-sm ip-field">
|
<div class="input-group input-group-sm ip-field">
|
||||||
<input type="text" class="form-control" role="combobox" autocomplete="off"
|
<input type="text" class="form-control" role="combobox" autocomplete="off"
|
||||||
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree"
|
id="@_inputId" aria-autocomplete="list" aria-haspopup="tree" aria-label="@AriaLabel"
|
||||||
aria-expanded="@(_open ? "true" : "false")" aria-controls="@_panelId"
|
aria-expanded="@(_open ? "true" : "false")" aria-controls="@(_open ? _panelId : null)"
|
||||||
placeholder="@Placeholder" disabled="@Disabled"
|
placeholder="@Placeholder" disabled="@Disabled"
|
||||||
data-test="@($"{DataTestPrefix}-input")"
|
data-test="@($"{DataTestPrefix}-input")"
|
||||||
value="@_text" @onfocus="Open" @oninput="OnInput" @onkeydown="OnInputKeyDown" />
|
value="@_text" @onfocus="Open" @oninput="OnInput" @onkeydown="OnInputKeyDown" />
|
||||||
@@ -30,14 +31,25 @@
|
|||||||
{
|
{
|
||||||
<div class="ip-backdrop" data-test="@($"{DataTestPrefix}-backdrop")" @onclick="Close"></div>
|
<div class="ip-backdrop" data-test="@($"{DataTestPrefix}-backdrop")" @onclick="Close"></div>
|
||||||
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
|
<div class="ip-panel border rounded bg-body shadow-sm" id="@_panelId" data-test="@($"{DataTestPrefix}-panel")">
|
||||||
|
@* Selectable is what makes the tree's own Enter/Space activation reach us: it
|
||||||
|
routes BOTH a click on TreeView's content span and a key press on the
|
||||||
|
treeitem through SelectedKeyChanged, so mouse and keyboard share one path
|
||||||
|
(the row label below therefore carries no @onclick of its own — a second
|
||||||
|
handler there would fire twice on a click, since the click bubbles into the
|
||||||
|
content span). SelectedKey is deliberately left unbound: a combobox shows
|
||||||
|
its pick in the field, not as a row highlight, and binding it would also arm
|
||||||
|
TreeView's "selected key no longer in the tree" reset — which fires on every
|
||||||
|
render while the pick is filtered out, and this component would never clear
|
||||||
|
it. SelectedCssClass is emptied to match; with no SelectedKey nothing is ever
|
||||||
|
marked selected, so it is belt-and-braces. *@
|
||||||
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
|
<TreeView @ref="_tree" TItem="InstanceTreeNode" Items="_roots"
|
||||||
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
|
ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren"
|
||||||
KeySelector="n => n.Key" IndentPx="16">
|
KeySelector="n => n.Key" IndentPx="16"
|
||||||
|
Selectable="true" SelectedCssClass="" SelectedKeyChanged="OnTreeKeyActivatedAsync">
|
||||||
<NodeContent Context="node">
|
<NodeContent Context="node">
|
||||||
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
|
<span class="tv-glyph"><i class="bi @(node.IsInstance ? "bi-box" : "bi-diagram-3")"></i></span>
|
||||||
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
|
<span class="tv-label @(node.IsInstance ? "ip-instance" : "ip-area")" title="@node.Label"
|
||||||
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)"
|
data-test="@($"{DataTestPrefix}-row")" data-instance-id="@(node.Instance?.Id)">@RowLabel(node)</span>
|
||||||
@onclick="() => OnRowClickAsync(node)">@RowLabel(node)</span>
|
|
||||||
</NodeContent>
|
</NodeContent>
|
||||||
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
|
<EmptyContent><span class="text-muted fst-italic small px-2">@(_hasFilter ? "No instances match." : "No instances at this site.")</span></EmptyContent>
|
||||||
</TreeView>
|
</TreeView>
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// Inside the tree, <c>Enter</c>/<c>Space</c> on the focused row does exactly what a click on
|
||||||
|
/// it does — picks an instance, toggles an area — because the tree runs in
|
||||||
|
/// <c>Selectable</c> mode and both gestures arrive at
|
||||||
|
/// <see cref="OnTreeKeyActivatedAsync"/> through TreeView's single selection callback.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
/// A parent that swaps <see cref="Areas"/>/<see cref="Instances"/> (a site change)
|
/// A parent that swaps <see cref="Areas"/>/<see cref="Instances"/> (a site change)
|
||||||
/// closes the panel and re-derives the field text from the current selection, clearing
|
/// closes the panel and re-derives the field text from the current selection, clearing
|
||||||
/// it when the selected instance is not in the new list. A parent that changes
|
/// it when the selected instance is not in the new list. A parent that changes
|
||||||
@@ -86,6 +93,22 @@ public partial class InstancePicker
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string Placeholder { get; set; } = "Search or browse instances…";
|
public string Placeholder { get; set; } = "Search or browse instances…";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The combobox's accessible name, rendered as <c>aria-label</c> on the field. A page
|
||||||
|
/// that renders its own visible <c><label for></c> should point it at
|
||||||
|
/// <see cref="InputId"/>; the label wins for a screen reader either way, so this stays
|
||||||
|
/// as the fallback name rather than being suppressed.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string AriaLabel { get; set; } = "Instance";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DOM id for the text field, so a page can address it from a <c><label for></c>.
|
||||||
|
/// Unset means the per-instance generated id — two pickers on one page never collide.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? InputId { get; set; }
|
||||||
|
|
||||||
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
|
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
private bool _open;
|
private bool _open;
|
||||||
@@ -126,7 +149,7 @@ public partial class InstancePicker
|
|||||||
private int _lastSelectedId;
|
private int _lastSelectedId;
|
||||||
private bool _parametersSeen;
|
private bool _parametersSeen;
|
||||||
|
|
||||||
private string _inputId => $"ip-{_idSuffix}-input";
|
private string _inputId => InputId is { Length: > 0 } id ? id : $"ip-{_idSuffix}-input";
|
||||||
|
|
||||||
private string _panelId => $"ip-{_idSuffix}-panel";
|
private string _panelId => $"ip-{_idSuffix}-panel";
|
||||||
|
|
||||||
@@ -309,11 +332,34 @@ public partial class InstancePicker
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task OnRowClickAsync(InstanceTreeNode node)
|
/// <summary>
|
||||||
|
/// The single activation path for a tree row, reached from TreeView's
|
||||||
|
/// <c>SelectedKeyChanged</c> — which a mouse click on the row and an
|
||||||
|
/// <c>Enter</c>/<c>Space</c> on the focused treeitem both run through, so the two input
|
||||||
|
/// modes can never diverge. An instance key picks that instance; an area key toggles the
|
||||||
|
/// branch (there is nothing to select on an area, and a keyboard user who cannot open a
|
||||||
|
/// branch cannot reach the instances inside it).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Keys are the ones <see cref="InstanceTreeBuilder"/> mints: <c>i:{instanceId}</c> and
|
||||||
|
/// <c>a:{areaId}</c>. Anything else — including the null TreeView raises when it clears a
|
||||||
|
/// selection — is ignored rather than guessed at.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private Task OnTreeKeyActivatedAsync(object? key)
|
||||||
{
|
{
|
||||||
if (node.Instance is { } instance) return SelectAsync(instance);
|
if (Disabled || key?.ToString() is not { Length: > 0 } keyStr) return Task.CompletedTask;
|
||||||
|
|
||||||
|
if (keyStr.StartsWith("i:", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return int.TryParse(keyStr.AsSpan(2), out var instanceId)
|
||||||
|
&& Instances?.FirstOrDefault(i => i.Id == instanceId) is { } instance
|
||||||
|
? SelectAsync(instance)
|
||||||
|
: Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyStr.StartsWith("a:", StringComparison.Ordinal)) _tree?.ToggleExpanded(keyStr);
|
||||||
|
|
||||||
_tree?.ToggleExpanded(node.Key);
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ public class InstancePickerTests : BunitContext
|
|||||||
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
|
private static string[] Rows(IRenderedComponent<InstancePicker> cut) =>
|
||||||
cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray();
|
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]
|
[Fact]
|
||||||
public void Closed_ShowsNothingButTheField()
|
public void Closed_ShowsNothingButTheField()
|
||||||
{
|
{
|
||||||
@@ -450,4 +454,106 @@ public class InstancePickerTests : BunitContext
|
|||||||
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
Assert.Single(cut.FindAll("[data-test='t-panel']"));
|
||||||
Assert.Equal(new[] { "Zulu" }, Rows(cut));
|
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