diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index 5b438ecdb6..0ed81b62ec 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; @@ -127,7 +128,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// protected InputBase() { - _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + _validationStateChangedHandler = OnValidateStateChanged; } /// @@ -216,10 +217,68 @@ namespace Microsoft.AspNetCore.Components.Forms $"{nameof(Forms.EditContext)} dynamically."); } + SetAdditionalAttributesIfValidationFailed(); + // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc. return base.SetParametersAsync(ParameterView.Empty); } + private void OnValidateStateChanged(object sender, ValidationStateChangedEventArgs eventArgs) + { + SetAdditionalAttributesIfValidationFailed(); + + StateHasChanged(); + } + + private void SetAdditionalAttributesIfValidationFailed() + { + if (EditContext.GetValidationMessages(FieldIdentifier).Any()) + { + if (AdditionalAttributes != null && AdditionalAttributes.ContainsKey("aria-invalid")) + { + // Do not overwrite the attribute value + return; + } + + if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes)) + { + AdditionalAttributes = additionalAttributes; + } + + // To make the `Input` components accessible by default + // we will automatically render the `aria-invalid` attribute when the validation fails + additionalAttributes["aria-invalid"] = true; + } + } + + /// + /// Returns a dictionary with the same values as the specified . + /// + /// true, if a new dictrionary with copied values was created. false - otherwise. + private bool ConvertToDictionary(IReadOnlyDictionary source, out Dictionary result) + { + bool newDictionaryCreated = true; + if (source == null) + { + result = new Dictionary(); + } + else if (source is Dictionary currentDictionary) + { + result = currentDictionary; + newDictionaryCreated = false; + } + else + { + result = new Dictionary(); + foreach (var item in source) + { + result.Add(item.Key, item.Value); + } + } + + return newDictionaryCreated; + } + protected virtual void Dispose(bool disposing) { } diff --git a/src/Components/Web/test/Forms/InputBaseTest.cs b/src/Components/Web/test/Forms/InputBaseTest.cs index 285361d538..26464b8386 100644 --- a/src/Components/Web/test/Forms/InputBaseTest.cs +++ b/src/Components/Web/test/Forms/InputBaseTest.cs @@ -362,6 +362,7 @@ namespace Microsoft.AspNetCore.Components.Forms var inputComponentId = componentFrame1.ComponentId; var component = (TestInputComponent)componentFrame1.Component; Assert.Equal("valid", component.CssClass); + Assert.Null(component.AdditionalAttributes); // Act: update the field state in the EditContext and notify var messageStore = new ValidationMessageStore(rootComponent.EditContext); @@ -372,6 +373,8 @@ namespace Microsoft.AspNetCore.Components.Forms var batch2 = renderer.Batches.Skip(1).Single(); Assert.Equal(inputComponentId, batch2.DiffsByComponentId.Keys.Single()); Assert.Equal("invalid", component.CssClass); + Assert.NotNull(component.AdditionalAttributes); + Assert.True(component.AdditionalAttributes.ContainsKey("aria-invalid")); } [Fact] @@ -400,6 +403,73 @@ namespace Microsoft.AspNetCore.Components.Forms Assert.Empty(renderer.Batches.Skip(1)); } + [Fact] + public async Task AriaAttributeIsRenderedWhenTheValidationStateIsInvalidOnFirstRender() + { + // Arrange// Arrange + var model = new TestModel(); + var invalidContext = new EditContext(model); + + var rootComponent = new TestInputHostComponent> + { + EditContext = invalidContext, + ValueExpression = () => model.StringProperty + }; + + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + var messageStore = new ValidationMessageStore(invalidContext); + messageStore.Add(fieldIdentifier, "Test error message"); + + var renderer = new TestRenderer(); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + + // Initally, it rendered one batch and is valid + var batch1 = renderer.Batches.Single(); + var componentFrame1 = batch1.GetComponentFrames>().Single(); + var inputComponentId = componentFrame1.ComponentId; + var component = (TestInputComponent)componentFrame1.Component; + Assert.Equal("invalid", component.CssClass); + Assert.NotNull(component.AdditionalAttributes); + Assert.Equal(1, component.AdditionalAttributes.Count); + Assert.True((bool)component.AdditionalAttributes["aria-invalid"]); + } + + [Fact] + public async Task UserSpecifiedAriaValueIsNotChangedIfInvalid() + { + // Arrange// Arrange + var model = new TestModel(); + var invalidContext = new EditContext(model); + + var rootComponent = new TestInputHostComponent> + { + EditContext = invalidContext, + ValueExpression = () => model.StringProperty + }; + rootComponent.AdditionalAttributes = new Dictionary(); + rootComponent.AdditionalAttributes["aria-invalid"] = "userSpecifiedValue"; + + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + var messageStore = new ValidationMessageStore(invalidContext); + messageStore.Add(fieldIdentifier, "Test error message"); + + var renderer = new TestRenderer(); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Initally, it rendered one batch and is valid + var batch1 = renderer.Batches.Single(); + var componentFrame1 = batch1.GetComponentFrames>().Single(); + var inputComponentId = componentFrame1.ComponentId; + var component = (TestInputComponent)componentFrame1.Component; + Assert.Equal("invalid", component.CssClass); + Assert.NotNull(component.AdditionalAttributes); + Assert.Equal(1, component.AdditionalAttributes.Count); + Assert.Equal("userSpecifiedValue", component.AdditionalAttributes["aria-invalid"]); + } + private static TComponent FindComponent(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 0c5195e3f4..90d298502d 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -94,16 +94,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Browser.Equal("valid", () => nameInput.GetAttribute("class")); nameInput.SendKeys("Bert\t"); Browser.Equal("modified valid", () => nameInput.GetAttribute("class")); + EnsureAttributeRendering(nameInput, "aria-invalid", false); // Can become invalid nameInput.SendKeys("01234567890123456789\t"); Browser.Equal("modified invalid", () => nameInput.GetAttribute("class")); + EnsureAttributeRendering(nameInput, "aria-invalid"); Browser.Equal(new[] { "That name is too long" }, messagesAccessor); // Can become valid nameInput.Clear(); nameInput.SendKeys("Bert\t"); Browser.Equal("modified valid", () => nameInput.GetAttribute("class")); + EnsureAttributeRendering(nameInput, "aria-invalid", false); Browser.Empty(messagesAccessor); } @@ -491,5 +494,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests + $"elem.value = {JsonSerializer.Serialize(invalidValue, TestJsonSerializerOptionsProvider.Options)};" + "elem.dispatchEvent(new KeyboardEvent('change'));"); } + + private void EnsureAttributeRendering(IWebElement element, string attributeName, bool shouldBeRendered = true) + { + Browser.Equal(shouldBeRendered, () => element.GetAttribute(attributeName) != null); + } } }