| File: Forms\InputBase.cs | Web Access |
| Project: src\aspnetcore\src\Components\Web\src\Microsoft.AspNetCore.Components.Web.csproj (Microsoft.AspNetCore.Components.Web) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Linq.Expressions; using Microsoft.AspNetCore.Components.Forms.Mapping; namespace Microsoft.AspNetCore.Components.Forms; /// <summary> /// A base class for form input components. This base class automatically /// integrates with an <see cref="Forms.EditContext"/>, which must be supplied /// as a cascading parameter. /// </summary> public abstract class InputBase<TValue> : ComponentBase, IDisposable { private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler; private bool _hasInitializedParameters; private bool _parsingFailed; private string? _incomingValueBeforeParsing; private string? _formattedValueExpression; private bool _previousParsingAttemptFailed; private ValidationMessageStore? _parsingValidationMessages; private Type? _nullableUnderlyingType; private bool _shouldGenerateFieldNames; [CascadingParameter] private EditContext? CascadedEditContext { get; set; } [CascadingParameter] private HtmlFieldPrefix FieldPrefix { get; set; } = default!; /// <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; } /// <summary> /// Gets or sets the value of the input. This should be used with two-way binding. /// </summary> /// <example> /// @bind-Value="model.PropertyName" /// </example> [Parameter] public TValue? Value { get; set; } /// <summary> /// Gets or sets a callback that updates the bound value. /// </summary> [Parameter] public EventCallback<TValue> ValueChanged { get; set; } /// <summary> /// Gets or sets an expression that identifies the bound value. /// </summary> [Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; } /// <summary> /// Gets or sets the display name for this field. /// <para>This value is used when generating error messages when the input value fails to parse correctly.</para> /// </summary> [Parameter] public string? DisplayName { get; set; } /// <summary> /// Gets the associated <see cref="Forms.EditContext"/>. /// This property is uninitialized if the input does not have a parent <see cref="EditForm"/>. /// </summary> protected EditContext EditContext { get; set; } = default!; /// <summary> /// Gets the <see cref="FieldIdentifier"/> for the bound value. /// </summary> protected internal FieldIdentifier FieldIdentifier { get; set; } /// <summary> /// Gets or sets the current value of the input. /// </summary> protected TValue? CurrentValue { get => Value; set { var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value); if (hasChanged) { _parsingFailed = false; // If we don't do this, then when the user edits from A to B, we'd: // - Do a render that changes back to A // - Then send the updated value to the parent, which sends the B back to this component // - Do another render that changes it to B again // The unnecessary reversion from B to A can cause selection to be lost while typing // A better solution would be somehow forcing the parent component's render to occur first, // but that would involve a complex change in the renderer to keep the render queue sorted // by component depth or similar. Value = value; _ = ValueChanged.InvokeAsync(Value); EditContext?.NotifyFieldChanged(FieldIdentifier); } } } /// <summary> /// Gets or sets the current value of the input, represented as a string. /// </summary> protected string? CurrentValueAsString { // InputBase-derived components can hold invalid states (e.g., an InputNumber being blank even when bound // to an int value). So, if parsing fails, we keep the rejected string in the UI even though it doesn't // match what's on the .NET model. This avoids interfering with typing, but still notifies the EditContext // about the validation error message. get => _parsingFailed ? _incomingValueBeforeParsing : FormatValueAsString(CurrentValue); set { _incomingValueBeforeParsing = value; _parsingValidationMessages?.Clear(); if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value)) { // Assume if it's a nullable type, null/empty inputs should correspond to default(T) // Then all subclasses get nullable support almost automatically (they just have to // not reject Nullable<T> based on the type itself). _parsingFailed = false; CurrentValue = default!; } else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) { _parsingFailed = false; CurrentValue = parsedValue!; } else { _parsingFailed = true; // EditContext may be null if the input is not a child component of EditForm. if (EditContext is not null) { _parsingValidationMessages ??= new ValidationMessageStore(EditContext); _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage); // Since we're not writing to CurrentValue, we'll need to notify about modification from here EditContext.NotifyFieldChanged(FieldIdentifier); } } // We can skip the validation notification if we were previously valid and still are if (_parsingFailed || _previousParsingAttemptFailed) { EditContext?.NotifyValidationStateChanged(); _previousParsingAttemptFailed = _parsingFailed; } } } /// <summary> /// Constructs an instance of <see cref="InputBase{TValue}"/>. /// </summary> protected InputBase() { _validationStateChangedHandler = OnValidateStateChanged; } /// <summary> /// Formats the value as a string. Derived classes can override this to determine the formatting used for <see cref="CurrentValueAsString"/>. /// </summary> /// <param name="value">The value to format.</param> /// <returns>A string representation of the value.</returns> protected virtual string? FormatValueAsString(TValue? value) => value?.ToString(); /// <summary> /// Parses a string to create an instance of <typeparamref name="TValue"/>. Derived classes can override this to change how /// <see cref="CurrentValueAsString"/> interprets incoming values. /// </summary> /// <param name="value">The string value to be parsed.</param> /// <param name="result">An instance of <typeparamref name="TValue"/>.</param> /// <param name="validationErrorMessage">If the value could not be parsed, provides a validation error message.</param> /// <returns>True if the value could be parsed; otherwise false.</returns> protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage); /// <summary> /// Gets a CSS class string that combines the <c>class</c> attribute and a string indicating /// the status of the field being edited (a combination of "modified", "valid", and "invalid"). /// Derived components should typically use this value for the primary HTML element's 'class' attribute. /// </summary> protected string CssClass { get { var fieldClass = EditContext?.FieldCssClass(FieldIdentifier); return AttributeUtilities.CombineClassNames(AdditionalAttributes, fieldClass) ?? string.Empty; } } /// <summary> /// Gets the value to be used for the input's "name" attribute. /// </summary> protected string NameAttributeValue { get { if (AdditionalAttributes?.TryGetValue("name", out var nameAttributeValue) ?? false) { return Convert.ToString(nameAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty; } if (_shouldGenerateFieldNames) { return GetFieldName(); } return string.Empty; } } /// <summary> /// Gets the value to be used for the input's "id" attribute. /// </summary> /// <remarks> /// If an explicit "id" is provided via <see cref="AdditionalAttributes"/>, that value takes precedence. /// Otherwise, the id is a sanitized version of <see cref="NameAttributeValue"/> in SSR mode; generated independently in interactive mode. /// </remarks> protected string IdAttributeValue { get { if (AdditionalAttributes?.TryGetValue("id", out var idAttributeValue) ?? false) { return Convert.ToString(idAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty; } var fieldName = NameAttributeValue; if (string.IsNullOrEmpty(fieldName)) { fieldName = GetFieldName(); } return FieldIdGenerator.SanitizeHtmlId(fieldName); } } private string GetFieldName() => _formattedValueExpression ??= FieldPrefix?.GetFieldName(ValueExpression!) ?? ExpressionFormatter.FormatLambda(ValueExpression!); /// <inheritdoc /> public override Task SetParametersAsync(ParameterView parameters) { parameters.SetParameterProperties(this); if (!_hasInitializedParameters) { // This is the first run // Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit() if (ValueExpression == null) { throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " + $"parameter. Normally this is provided automatically when using 'bind-Value'."); } FieldIdentifier = FieldIdentifier.Create(ValueExpression); if (CascadedEditContext != null) { EditContext = CascadedEditContext; EditContext.OnValidationStateChanged += _validationStateChangedHandler; _shouldGenerateFieldNames = EditContext.ShouldUseFieldIdentifiers; if (AssignedRenderMode is null) { // Register the input for client-side validation if rendered in static SSR mode. RenderedFieldRegistry.GetOrCreate(EditContext).Register(FieldIdentifier, NameAttributeValue); } } else { // Ideally we'd know if we were in an SSR context but we don't _shouldGenerateFieldNames = !OperatingSystem.IsBrowser(); } _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); _hasInitializedParameters = true; } else if (CascadedEditContext != EditContext) { // Not the first run // We don't support changing EditContext because it's messy to be clearing up state and event // handlers for the previous one, and there's no strong use case. If a strong use case // emerges, we can consider changing this. throw new InvalidOperationException($"{GetType()} does not support changing the " + $"{nameof(Forms.EditContext)} dynamically."); } UpdateAdditionalValidationAttributes(); // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc. return base.SetParametersAsync(ParameterView.Empty); } private void OnValidateStateChanged(object? sender, ValidationStateChangedEventArgs eventArgs) { UpdateAdditionalValidationAttributes(); StateHasChanged(); } private void UpdateAdditionalValidationAttributes() { if (EditContext is null) { return; } var hasAriaInvalidAttribute = AdditionalAttributes != null && AdditionalAttributes.ContainsKey("aria-invalid"); if (EditContext.GetValidationMessages(FieldIdentifier).Any()) { // If this input is associated with an incoming value from an HTTP form post (via model binding), // retain the attempted value even if it's unparseable var attemptedValue = EditContext.GetAttemptedValue(NameAttributeValue); if (attemptedValue != null) { _parsingFailed = true; _incomingValueBeforeParsing = attemptedValue; } if (hasAriaInvalidAttribute) { // 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 // value must be "true" see https://www.w3.org/TR/wai-aria-1.1/#aria-invalid additionalAttributes["aria-invalid"] = "true"; } else if (hasAriaInvalidAttribute) { // No validation errors. Need to remove `aria-invalid` if it was rendered already if (AdditionalAttributes!.Count == 1) { // Only aria-invalid argument is present which we don't need any more AdditionalAttributes = null; } else { if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes)) { AdditionalAttributes = additionalAttributes; } additionalAttributes.Remove("aria-invalid"); } } } /// <summary> /// Returns a dictionary with the same values as the specified <paramref name="source"/>. /// </summary> /// <returns>true, if a new dictionary with copied values was created. false - otherwise.</returns> private static bool ConvertToDictionary(IReadOnlyDictionary<string, object>? source, out Dictionary<string, object> result) { var 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; } /// <inheritdoc/> protected virtual void Dispose(bool disposing) { } void IDisposable.Dispose() { // When initialization in the SetParametersAsync method fails, the EditContext property can remain equal to null EditContext?.OnValidationStateChanged -= _validationStateChangedHandler; // Clear parsing validation messages store owned by the input when the input is disposed. if (_parsingValidationMessages != null) { _parsingValidationMessages.Clear(); EditContext!.NotifyValidationStateChanged(); // when _parsingValidationMessages is not null, EditContext is also not null. } Dispose(disposing: true); } }