diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor index 12b1799cc..2be259be9 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor @@ -395,9 +395,9 @@ - Automatically associates label with input via generated ID + Label's for points at the trigger button via a generated ID, so screen readers announce the label with the trigger Description linked via aria-describedby - Validation errors displayed with aria-invalid + Trigger gets aria-invalid when ErrorText is set or the bound field has validation errors Tab / Shift + Tab diff --git a/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor b/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor index 27245c76e..2ff080886 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor @@ -6,7 +6,7 @@ @if (Label is not null) { - @Label + @Label } - + diff --git a/tests/BlazorBlueprint.Tests/Components/FormFieldSelectTriggerTests.cs b/tests/BlazorBlueprint.Tests/Components/FormFieldSelectTriggerTests.cs new file mode 100644 index 000000000..8f492fa96 --- /dev/null +++ b/tests/BlazorBlueprint.Tests/Components/FormFieldSelectTriggerTests.cs @@ -0,0 +1,115 @@ +using BlazorBlueprint.Components; +using BlazorBlueprint.Primitives; +using Bunit; +using Microsoft.AspNetCore.Components.Forms; + +namespace BlazorBlueprint.Tests.Components; + +/// +/// BbFormFieldSelect gives its trigger button the field's generated id and invalid state, so the +/// label, error text and aria-invalid all reach it. +/// +public class FormFieldSelectTriggerTests +{ + private static readonly SelectOption[] Options = [new("a", "Alpha")]; + + private sealed class Model + { + public string? Fruit { get; set; } + } + + [Fact] + public async Task LabelPointsAtTheTrigger() + { + await using var context = CreateContext(); + + var cut = context.Render>(parameters => parameters + .Add(p => p.Label, "Fruit") + .Add(p => p.Options, Options)); + + var labelFor = cut.Find("label").GetAttribute("for"); + Assert.False(string.IsNullOrEmpty(labelFor)); + Assert.Equal(labelFor, Trigger(cut).Id); + Assert.Single(cut.FindAll($"[id='{labelFor}']")); + } + + [Theory] + [InlineData(false, "Pick one you like")] + [InlineData(true, "Pick a fruit")] + public async Task TriggerFollowsErrorText(bool invalid, string message) + { + await using var context = CreateContext(); + + var cut = context.Render>(parameters => + { + parameters.Add(p => p.Options, Options); + if (invalid) + { + parameters.Add(p => p.ErrorText, message); + } + else + { + parameters.Add(p => p.HelperText, message); + } + }); + + AssertInvalid(cut, invalid); + AssertDescribedBy(cut, message); + } + + // Validation runs after the first render, the way a submit does; the trigger and its + // description follow it both ways. + [Fact] + public async Task TriggerFollowsFormValidation() + { + await using var context = CreateContext(); + var model = new Model(); + var editContext = new EditContext(model); + var messages = new ValidationMessageStore(editContext); + var field = editContext.Field(nameof(Model.Fruit)); + const string message = "Pick a fruit."; + + var cut = context.Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.Options, Options) + .Add(p => p.ValueExpression, () => model.Fruit)); + + AssertInvalid(cut, false); + + await cut.InvokeAsync(() => + { + messages.Add(field, message); + editContext.NotifyValidationStateChanged(); + }); + AssertInvalid(cut, true); + AssertDescribedBy(cut, message); + + await cut.InvokeAsync(() => + { + messages.Clear(field); + editContext.NotifyValidationStateChanged(); + }); + AssertInvalid(cut, false); + } + + // The content's portal loads its JS module on first render, open or not. + private static ComponentBunitContext CreateContext() + { + var context = new ComponentBunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + return context; + } + + private static AngleSharp.Dom.IElement Trigger(IRenderedComponent> cut) => + cut.Find("button[role='combobox']"); + + private static void AssertInvalid(IRenderedComponent> cut, bool invalid) => + Assert.Equal(invalid ? "true" : null, Trigger(cut).GetAttribute("aria-invalid")); + + private static void AssertDescribedBy(IRenderedComponent> cut, string message) + { + var describedBy = Trigger(cut).GetAttribute("aria-describedby"); + Assert.False(string.IsNullOrEmpty(describedBy)); + Assert.Contains(message, cut.Find($"[id='{describedBy}']").TextContent, StringComparison.Ordinal); + } +}