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