Skip to content

Render aria-invalid if the state is invalid #23131

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 22, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Component
{
protected InputBase() { }
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
public System.Collections.Generic.IReadOnlyDictionary<string, object> AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public System.Collections.Generic.Dictionary<string, object> AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected string CssClass { get { throw null; } }
protected TValue CurrentValue { get { throw null; } set { } }
protected string CurrentValueAsString { get { throw null; } set { } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Component
{
protected InputBase() { }
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
public System.Collections.Generic.IReadOnlyDictionary<string, object> AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public System.Collections.Generic.Dictionary<string, object> AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected string CssClass { get { throw null; } }
protected TValue CurrentValue { get { throw null; } set { } }
protected string CurrentValueAsString { get { throw null; } set { } }
Expand Down
32 changes: 30 additions & 2 deletions src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;

Expand All @@ -25,7 +26,7 @@ public abstract class InputBase<TValue> : ComponentBase, IDisposable
/// <summary>
/// Gets or sets a collection of additional attributes that will be applied to the created element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object> AdditionalAttributes { get; set; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of interest, is IDictionary also a viable option here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dictionary implements IReadOnlyDictionary and is not a code breaking change, where as if I change this to IDictionary instead, tests where we have something like the following, break. I expect this will also break customer code.

public new IReadOnlyDictionary<string, object> AdditionalAttributes => base.AdditionalAttributes;

Let me know if you want me to change this to IDictionary - I am not against it.


/// <summary>
/// Gets or sets the value of the input. This should be used with two-way binding.
Expand Down Expand Up @@ -127,7 +128,7 @@ protected string CurrentValueAsString
/// </summary>
protected InputBase()
{
_validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
_validationStateChangedHandler = OnValidateStateChanged;
}

/// <summary>
Expand Down Expand Up @@ -216,10 +217,37 @@ public override Task SetParametersAsync(ParameterView parameters)
$"{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())
{
// To make the `Input` components accessible by default
// we will automatically render the `aria-invalid` attribute when the validation fails
if (AdditionalAttributes == null)
{
AdditionalAttributes = new Dictionary<string, object>();
AdditionalAttributes["aria-invalid"] = true;
}
else if (!AdditionalAttributes.ContainsKey("aria-invalid"))
{
AdditionalAttributes["aria-invalid"] = true;
}
}
}

protected virtual void Dispose(bool disposing)
{
}
Expand Down
70 changes: 70 additions & 0 deletions src/Components/Web/test/Forms/InputBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ public async Task RespondsToValidationStateChangeNotifications()
var inputComponentId = componentFrame1.ComponentId;
var component = (TestInputComponent<string>)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);
Expand All @@ -372,6 +373,8 @@ public async Task RespondsToValidationStateChangeNotifications()
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]
Expand Down Expand Up @@ -400,6 +403,73 @@ public async Task UnsubscribesFromValidationStateChangeNotifications()
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<string, TestInputComponent<string>>
{
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<TestInputComponent<string>>().Single();
var inputComponentId = componentFrame1.ComponentId;
var component = (TestInputComponent<string>)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<string, TestInputComponent<string>>
{
EditContext = invalidContext,
ValueExpression = () => model.StringProperty
};
rootComponent.AdditionalAttributes = new Dictionary<string, object>();
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<TestInputComponent<string>>().Single();
var inputComponentId = componentFrame1.ComponentId;
var component = (TestInputComponent<string>)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<TComponent>(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
Expand Down
9 changes: 9 additions & 0 deletions src/Components/test/E2ETest/Tests/FormsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,20 @@ public void InputTextInteractsWithEditContext()
Browser.Equal("valid", () => nameInput.GetAttribute("class"));
nameInput.SendKeys("Bert\t");
Browser.Equal("modified valid", () => nameInput.GetAttribute("class"));
EnsureAttributeRendering(nameInput, "aria-invalid");

// Can become invalid
nameInput.SendKeys("01234567890123456789\t");
Browser.Equal("modified invalid", () => nameInput.GetAttribute("class"));
EnsureAttributeRendering(nameInput, "aria-invalid", false);
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"));
Browser.Equal(false, () => nameInput.GetAttribute("aria-invalid") != null);
EnsureAttributeRendering(nameInput, "aria-invalid");
Browser.Empty(messagesAccessor);
}

Expand Down Expand Up @@ -456,5 +460,10 @@ private void ApplyInvalidInputDateValue(string cssSelector, string invalidValue)
+ $"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);
}
}
}