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 all 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
61 changes: 60 additions & 1 deletion 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 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,68 @@ 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())
{
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;
}
}

/// <summary>
/// Returns a dictionary with the same values as the specified <paramref name="source"/>.
/// </summary>
/// <returns>true, if a new dictrionary with copied values was created. false - otherwise.</returns>
private bool ConvertToDictionary(IReadOnlyDictionary<string, object> source, out Dictionary<string, object> result)
{
bool newDictionaryCreated = true;
if (source == null)
{
result = new Dictionary<string, object>();
}
else if (source is Dictionary<string, object> currentDictionary)
{
result = currentDictionary;
newDictionaryCreated = false;
}
else
{
result = new Dictionary<string, object>();
foreach (var item in source)
{
result.Add(item.Key, item.Value);
}
}

return newDictionaryCreated;
}

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
8 changes: 8 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,19 @@ public void InputTextInteractsWithEditContext()
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);
}

Expand Down Expand Up @@ -456,5 +459,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);
}
}
}