Skip to content

Commit fdccd5d

Browse files
committed
Fixes #46
1 parent 66158e1 commit fdccd5d

File tree

4 files changed

+419
-8
lines changed

4 files changed

+419
-8
lines changed
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Globalization;
8+
using System.Linq;
9+
using System.Linq.Expressions;
10+
using System.Threading.Tasks;
11+
using Microsoft.AspNetCore.Components;
12+
using Microsoft.AspNetCore.Components.Forms;
13+
14+
namespace LinkDotNet.Blog.Web.Shared;
15+
16+
/// <summary>
17+
/// A base class for form input components. This base class automatically
18+
/// integrates with an <see cref="Microsoft.AspNetCore.Components.Forms.EditContext"/>, which must be supplied
19+
/// as a cascading parameter.
20+
/// </summary>
21+
public abstract class InputBase<TValue> : ComponentBase, IDisposable
22+
{
23+
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
24+
private bool _hasInitializedParameters;
25+
private bool _previousParsingAttemptFailed;
26+
private ValidationMessageStore? _parsingValidationMessages;
27+
private Type? _nullableUnderlyingType;
28+
29+
[CascadingParameter] private EditContext? CascadedEditContext { get; set; }
30+
31+
/// <summary>
32+
/// Gets or sets a collection of additional attributes that will be applied to the created element.
33+
/// </summary>
34+
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
35+
36+
/// <summary>
37+
/// Gets or sets the value of the input. This should be used with two-way binding.
38+
/// </summary>
39+
/// <example>
40+
/// @bind-Value="model.PropertyName"
41+
/// </example>
42+
[Parameter]
43+
public TValue? Value { get; set; }
44+
45+
/// <summary>
46+
/// Gets or sets a callback that updates the bound value.
47+
/// </summary>
48+
[Parameter] public EventCallback<TValue> ValueChanged { get; set; }
49+
50+
/// <summary>
51+
/// Gets or sets an expression that identifies the bound value.
52+
/// </summary>
53+
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
54+
55+
/// <summary>
56+
/// Gets or sets the display name for this field.
57+
/// <para>This value is used when generating error messages when the input value fails to parse correctly.</para>
58+
/// </summary>
59+
[Parameter] public string? DisplayName { get; set; }
60+
61+
/// <summary>
62+
/// Gets the associated <see cref="Microsoft.AspNetCore.Components.Forms.EditContext"/>.
63+
/// This property is uninitialized if the input does not have a parent <see cref="EditForm"/>.
64+
/// </summary>
65+
protected EditContext EditContext { get; set; } = default!;
66+
67+
/// <summary>
68+
/// Gets the <see cref="FieldIdentifier"/> for the bound value.
69+
/// </summary>
70+
protected internal FieldIdentifier FieldIdentifier { get; set; }
71+
72+
/// <summary>
73+
/// Gets or sets the current value of the input.
74+
/// </summary>
75+
protected TValue? CurrentValue
76+
{
77+
get => Value;
78+
set
79+
{
80+
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
81+
if (hasChanged)
82+
{
83+
Value = value;
84+
_ = ValueChanged.InvokeAsync(Value);
85+
EditContext?.NotifyFieldChanged(FieldIdentifier);
86+
}
87+
}
88+
}
89+
90+
/// <summary>
91+
/// Gets or sets the current value of the input, represented as a string.
92+
/// </summary>
93+
protected string? CurrentValueAsString
94+
{
95+
get => FormatValueAsString(CurrentValue);
96+
set
97+
{
98+
_parsingValidationMessages?.Clear();
99+
100+
bool parsingFailed;
101+
102+
if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value))
103+
{
104+
// Assume if it's a nullable type, null/empty inputs should correspond to default(T)
105+
// Then all subclasses get nullable support almost automatically (they just have to
106+
// not reject Nullable<T> based on the type itself).
107+
parsingFailed = false;
108+
CurrentValue = default!;
109+
}
110+
else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage))
111+
{
112+
parsingFailed = false;
113+
CurrentValue = parsedValue!;
114+
}
115+
else
116+
{
117+
parsingFailed = true;
118+
119+
// EditContext may be null if the input is not a child component of EditForm.
120+
if (EditContext is not null)
121+
{
122+
_parsingValidationMessages ??= new ValidationMessageStore(EditContext);
123+
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);
124+
125+
// Since we're not writing to CurrentValue, we'll need to notify about modification from here
126+
EditContext.NotifyFieldChanged(FieldIdentifier);
127+
}
128+
}
129+
130+
// We can skip the validation notification if we were previously valid and still are
131+
if (parsingFailed || _previousParsingAttemptFailed)
132+
{
133+
EditContext?.NotifyValidationStateChanged();
134+
_previousParsingAttemptFailed = parsingFailed;
135+
}
136+
}
137+
}
138+
139+
/// <summary>
140+
/// Constructs an instance of <see cref="InputBase{TValue}"/>.
141+
/// </summary>
142+
protected InputBase()
143+
{
144+
_validationStateChangedHandler = OnValidateStateChanged;
145+
}
146+
147+
/// <summary>
148+
/// Formats the value as a string. Derived classes can override this to determine the formating used for <see cref="CurrentValueAsString"/>.
149+
/// </summary>
150+
/// <param name="value">The value to format.</param>
151+
/// <returns>A string representation of the value.</returns>
152+
protected virtual string? FormatValueAsString(TValue? value)
153+
=> value?.ToString();
154+
155+
/// <summary>
156+
/// Parses a string to create an instance of <typeparamref name="TValue"/>. Derived classes can override this to change how
157+
/// <see cref="CurrentValueAsString"/> interprets incoming values.
158+
/// </summary>
159+
/// <param name="value">The string value to be parsed.</param>
160+
/// <param name="result">An instance of <typeparamref name="TValue"/>.</param>
161+
/// <param name="validationErrorMessage">If the value could not be parsed, provides a validation error message.</param>
162+
/// <returns>True if the value could be parsed; otherwise false.</returns>
163+
protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage);
164+
165+
/// <summary>
166+
/// Gets a CSS class string that combines the <c>class</c> attribute and and a string indicating
167+
/// the status of the field being edited (a combination of "modified", "valid", and "invalid").
168+
/// Derived components should typically use this value for the primary HTML element's 'class' attribute.
169+
/// </summary>
170+
protected string CssClass
171+
{
172+
get
173+
{
174+
var fieldClass = EditContext?.FieldCssClass(FieldIdentifier) ?? string.Empty;
175+
return AttributeUtilities.CombineClassNames(AdditionalAttributes, fieldClass);
176+
}
177+
}
178+
179+
/// <inheritdoc />
180+
public override Task SetParametersAsync(ParameterView parameters)
181+
{
182+
parameters.SetParameterProperties(this);
183+
184+
if (!_hasInitializedParameters)
185+
{
186+
// This is the first run
187+
// Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit()
188+
189+
if (ValueExpression == null)
190+
{
191+
throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " +
192+
$"parameter. Normally this is provided automatically when using 'bind-Value'.");
193+
}
194+
195+
FieldIdentifier = FieldIdentifier.Create(ValueExpression);
196+
197+
if (CascadedEditContext != null)
198+
{
199+
EditContext = CascadedEditContext;
200+
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
201+
}
202+
203+
_nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));
204+
_hasInitializedParameters = true;
205+
}
206+
else if (CascadedEditContext != EditContext)
207+
{
208+
// Not the first run
209+
210+
// We don't support changing EditContext because it's messy to be clearing up state and event
211+
// handlers for the previous one, and there's no strong use case. If a strong use case
212+
// emerges, we can consider changing this.
213+
throw new InvalidOperationException($"{GetType()} does not support changing the " +
214+
$"{nameof(Microsoft.AspNetCore.Components.Forms.EditContext)} dynamically.");
215+
}
216+
217+
UpdateAdditionalValidationAttributes();
218+
219+
// For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc.
220+
return base.SetParametersAsync(ParameterView.Empty);
221+
}
222+
223+
private void OnValidateStateChanged(object? sender, ValidationStateChangedEventArgs eventArgs)
224+
{
225+
UpdateAdditionalValidationAttributes();
226+
227+
StateHasChanged();
228+
}
229+
230+
private void UpdateAdditionalValidationAttributes()
231+
{
232+
if (EditContext is null)
233+
{
234+
return;
235+
}
236+
237+
var hasAriaInvalidAttribute = AdditionalAttributes != null && AdditionalAttributes.ContainsKey("aria-invalid");
238+
if (EditContext.GetValidationMessages(FieldIdentifier).Any())
239+
{
240+
if (hasAriaInvalidAttribute)
241+
{
242+
// Do not overwrite the attribute value
243+
return;
244+
}
245+
246+
if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes))
247+
{
248+
AdditionalAttributes = additionalAttributes;
249+
}
250+
251+
// To make the `Input` components accessible by default
252+
// we will automatically render the `aria-invalid` attribute when the validation fails
253+
// value must be "true" see https://www.w3.org/TR/wai-aria-1.1/#aria-invalid
254+
additionalAttributes["aria-invalid"] = "true";
255+
}
256+
else if (hasAriaInvalidAttribute)
257+
{
258+
// No validation errors. Need to remove `aria-invalid` if it was rendered already
259+
260+
if (AdditionalAttributes!.Count == 1)
261+
{
262+
// Only aria-invalid argument is present which we don't need any more
263+
AdditionalAttributes = null;
264+
}
265+
else
266+
{
267+
if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes))
268+
{
269+
AdditionalAttributes = additionalAttributes;
270+
}
271+
272+
additionalAttributes.Remove("aria-invalid");
273+
}
274+
}
275+
}
276+
277+
/// <summary>
278+
/// Returns a dictionary with the same values as the specified <paramref name="source"/>.
279+
/// </summary>
280+
/// <returns>true, if a new dictrionary with copied values was created. false - otherwise.</returns>
281+
private static bool ConvertToDictionary(IReadOnlyDictionary<string, object>? source, out Dictionary<string, object> result)
282+
{
283+
var newDictionaryCreated = true;
284+
if (source == null)
285+
{
286+
result = new Dictionary<string, object>();
287+
}
288+
else if (source is Dictionary<string, object> currentDictionary)
289+
{
290+
result = currentDictionary;
291+
newDictionaryCreated = false;
292+
}
293+
else
294+
{
295+
result = new Dictionary<string, object>();
296+
foreach (var item in source)
297+
{
298+
result.Add(item.Key, item.Value);
299+
}
300+
}
301+
302+
return newDictionaryCreated;
303+
}
304+
305+
/// <inheritdoc/>
306+
protected virtual void Dispose(bool disposing)
307+
{
308+
}
309+
310+
void IDisposable.Dispose()
311+
{
312+
// When initialization in the SetParametersAsync method fails, the EditContext property can remain equal to null
313+
if (EditContext is not null)
314+
{
315+
EditContext.OnValidationStateChanged -= _validationStateChangedHandler;
316+
}
317+
318+
Dispose(disposing: true);
319+
}
320+
}
321+
322+
internal static class AttributeUtilities
323+
{
324+
public static string CombineClassNames(IReadOnlyDictionary<string, object>? additionalAttributes, string classNames)
325+
{
326+
if (additionalAttributes is null || !additionalAttributes.TryGetValue("class", out var @class))
327+
{
328+
return classNames;
329+
}
330+
331+
var classAttributeValue = Convert.ToString(@class, CultureInfo.InvariantCulture);
332+
333+
if (string.IsNullOrEmpty(classAttributeValue))
334+
{
335+
return classNames;
336+
}
337+
338+
if (string.IsNullOrEmpty(classNames))
339+
{
340+
return classAttributeValue;
341+
}
342+
343+
return $"{classAttributeValue} {classNames}";
344+
}
345+
}

0 commit comments

Comments
 (0)