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