diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor index 0d1be6fd..c0f4be8e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor @@ -51,7 +51,15 @@ else @oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)"> @if (isBranch) { - + } else { @@ -276,6 +284,14 @@ else PersistExpandedState(); } + private void OnToggleKey(KeyboardEventArgs e, object key) + { + if (e.Key == "Enter" || e.Key == " ") + { + ToggleExpand(key); + } + } + private void PersistExpandedState() { if (StorageKey != null) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs new file mode 100644 index 00000000..69e814d0 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/AccessibilityTests.cs @@ -0,0 +1,106 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// +/// Accessibility regression tests. +/// Locks the a11y attributes on TreeView chevron toggles and the +/// ToastNotification live-region container against future regressions. +/// +public class AccessibilityTests : BunitContext +{ + // ── TreeView harness (mirrors TreeViewTests setup) ───────────────────────── + + private record TestNode(string Key, string Label, List Children); + + private static List BranchRoots() => new() + { + new("root-1", "Root One", new() + { + new("child-1", "Child One", new()), + }), + new("leaf-1", "Leaf One", new()), + }; + + private IRenderedComponent> RenderTreeView(List? items = null) + { + return Render>(parameters => + { + parameters + .Add(p => p.Items, items ?? BranchRoots()) + .Add(p => p.ChildrenSelector, n => n.Children) + .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0) + .Add(p => p.KeySelector, n => n.Key) + .Add(p => p.NodeContent, node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + }); + }); + } + + // ── Test 1: TreeView chevron toggle a11y attributes ──────────────────────── + + [Fact] + public void TreeViewToggle_HasAccessibleNameAndRole() + { + // BranchRoots has one branch node ("root-1") and one leaf node ("leaf-1"). + // The branch node's toggle span is the only .tv-toggle rendered. + var cut = RenderTreeView(); + + var toggle = cut.Find(".tv-toggle"); + + // role="button" makes it announced as a button by screen readers. + Assert.Equal("button", toggle.GetAttribute("role")); + + // tabindex="0" puts it in the natural tab order. + Assert.Equal("0", toggle.GetAttribute("tabindex")); + + // aria-label must be non-empty (e.g. "Expand root-1"). + var label = toggle.GetAttribute("aria-label"); + Assert.NotNull(label); + Assert.NotEmpty(label!); + + // aria-expanded reflects the collapsed state ("false" when collapsed). + Assert.Equal("false", toggle.GetAttribute("aria-expanded")); + } + + [Fact] + public void TreeViewToggle_AriaExpanded_ReflectsExpandedState() + { + var cut = RenderTreeView(); + + // Collapsed initially. + var toggle = cut.Find(".tv-toggle"); + Assert.Equal("false", toggle.GetAttribute("aria-expanded")); + + // Click to expand. + toggle.Click(); + + // aria-expanded must now be "true". + var expandedToggle = cut.Find(".tv-toggle"); + Assert.Equal("true", expandedToggle.GetAttribute("aria-expanded")); + + // aria-label should also flip to "Collapse …". + var labelAfterExpand = expandedToggle.GetAttribute("aria-label"); + Assert.NotNull(labelAfterExpand); + Assert.StartsWith("Collapse", labelAfterExpand!); + } + + // ── Test 2: ToastNotification live-region regression lock ───────────────── + + [Fact] + public void ToastNotification_Container_IsAriaLivePolite() + { + // Render ToastNotification — it has no required services/cascading params. + var cut = Render(); + + // The outermost div is the live-region container. + // Find the element that carries aria-live (may be the root or a child div). + var liveRegion = cut.Find("[aria-live]"); + + Assert.Equal("polite", liveRegion.GetAttribute("aria-live")); + Assert.Equal("true", liveRegion.GetAttribute("aria-atomic")); + } +}