From f195aa1bd7cbd80f669ada9fcd46b6833d18aafd Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Fri, 30 Jan 2026 00:05:59 +0100 Subject: [PATCH 1/9] Prevent silently ignoring date formatting failures --- src/Core/Components/DateTime/FluentCalendar.razor.cs | 6 +++--- src/Core/Components/DateTime/FluentDatePicker.razor.cs | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Core/Components/DateTime/FluentCalendar.razor.cs b/src/Core/Components/DateTime/FluentCalendar.razor.cs index 97d578f29c..a4573338b1 100644 --- a/src/Core/Components/DateTime/FluentCalendar.razor.cs +++ b/src/Core/Components/DateTime/FluentCalendar.razor.cs @@ -293,9 +293,9 @@ private async Task PickerYearSelectAsync(DateTime? year) /// protected override bool TryParseValueFromString(string? value, out DateTime? result, [NotNullWhen(false)] out string? validationErrorMessage) { - BindConverter.TryConvertTo(value, Culture, out result); - validationErrorMessage = null; - return true; + bool success = BindConverter.TryConvertTo(value, Culture, out result); + validationErrorMessage = success? null : $"The {DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField)} field is not in a valid format."; + return success; } /// diff --git a/src/Core/Components/DateTime/FluentDatePicker.razor.cs b/src/Core/Components/DateTime/FluentDatePicker.razor.cs index ee6452c12d..ad43727e27 100644 --- a/src/Core/Components/DateTime/FluentDatePicker.razor.cs +++ b/src/Core/Components/DateTime/FluentDatePicker.razor.cs @@ -145,10 +145,9 @@ protected override bool TryParseValueFromString(string? value, out DateTime? res value = new DateTime(year, 1, 1).ToString(Culture.DateTimeFormat.ShortDatePattern); } - BindConverter.TryConvertTo(value, Culture, out result); - - validationErrorMessage = null; - return true; + bool success = BindConverter.TryConvertTo(value, Culture, out result); + validationErrorMessage = success ? null : $"The {DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField)} field is not in a valid format."; + return success; } private string PlaceholderAccordingToView() From 182e79e25a93178b09ab81b799329e05dd86b45b Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Fri, 30 Jan 2026 13:03:18 +0100 Subject: [PATCH 2/9] Add extra test, refactor ParsingErrorMessage property for all fields. --- ...crosoft.FluentUI.AspNetCore.Components.xml | 26 +++++++++++++---- src/Core/Components/Base/FluentInputBase.cs | 12 ++++++++ .../DateTime/FluentCalendar.razor.cs | 2 +- .../DateTime/FluentDatePicker.razor.cs | 2 +- .../NumberField/FluentNumberField.razor.cs | 11 ++++--- .../Components/Rating/FluentRating.razor.cs | 8 +++-- src/Core/Extensions/FluentInputExtensions.cs | 2 +- tests/Core/DateTime/FluentDatePickerTests.cs | 29 +++++++++++++++++++ 8 files changed, 76 insertions(+), 16 deletions(-) diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 316d94749e..8c8a9447e5 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -658,6 +658,11 @@ If true, the ClassValue property will not include the EditContext's FieldCssClass. + + + Gets or sets the error message to show when the field can not be parsed. + + Gets the associated . @@ -669,6 +674,12 @@ Gets the for the bound value. + + + Gets the display name of the field, using the specified display name if set; otherwise, uses the field + identifier's name if the field is bound. + + Gets or sets the current value of the input. @@ -8031,11 +8042,6 @@ An Id value must be set to use this property. - - - Gets or sets the error message to show when the field can not be parsed. - - Gets or sets the content to be rendered inside the component. @@ -8047,6 +8053,11 @@ unless an explicit value for Min or Max is provided. + + + Gets or sets the error message to show when the field can not be parsed. + + Formats the value as a string. Derived classes can override this to determine the formatting used for CurrentValueAsString. @@ -8995,6 +9006,11 @@ Fires when hovered value changes. Value will be null if no rating item is hovered. + + + Gets or sets the error message to show when the field can not be parsed. + + diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index fafe6c1762..9617091880 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -132,6 +132,12 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi [Parameter] public virtual bool Embedded { get; set; } = false; + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + [Parameter] + public virtual string ParsingErrorMessage { get; set; } = "The {0} field must be a valid format."; + /// /// Gets the associated . /// This property is uninitialized if the input does not have a parent . @@ -145,6 +151,12 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi internal virtual bool FieldBound => Field is not null || ValueExpression is not null || ValueChanged.HasDelegate; + /// + /// Gets the display name of the field, using the specified display name if set; otherwise, uses the field + /// identifier's name if the field is bound. + /// + internal string FieldDisplayName => DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); + protected async Task SetCurrentValueAsync(TValue? value) { var hasChanged = !EqualityComparer.Default.Equals(value, Value); diff --git a/src/Core/Components/DateTime/FluentCalendar.razor.cs b/src/Core/Components/DateTime/FluentCalendar.razor.cs index a4573338b1..9cdd3edaeb 100644 --- a/src/Core/Components/DateTime/FluentCalendar.razor.cs +++ b/src/Core/Components/DateTime/FluentCalendar.razor.cs @@ -294,7 +294,7 @@ private async Task PickerYearSelectAsync(DateTime? year) protected override bool TryParseValueFromString(string? value, out DateTime? result, [NotNullWhen(false)] out string? validationErrorMessage) { bool success = BindConverter.TryConvertTo(value, Culture, out result); - validationErrorMessage = success? null : $"The {DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField)} field is not in a valid format."; + validationErrorMessage = success ? null : string.Format(ParsingErrorMessage, FieldDisplayName); return success; } diff --git a/src/Core/Components/DateTime/FluentDatePicker.razor.cs b/src/Core/Components/DateTime/FluentDatePicker.razor.cs index ad43727e27..35e018c657 100644 --- a/src/Core/Components/DateTime/FluentDatePicker.razor.cs +++ b/src/Core/Components/DateTime/FluentDatePicker.razor.cs @@ -146,7 +146,7 @@ protected override bool TryParseValueFromString(string? value, out DateTime? res } bool success = BindConverter.TryConvertTo(value, Culture, out result); - validationErrorMessage = success ? null : $"The {DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField)} field is not in a valid format."; + validationErrorMessage = success ? null : string.Format(ParsingErrorMessage, FieldDisplayName); return success; } diff --git a/src/Core/Components/NumberField/FluentNumberField.razor.cs b/src/Core/Components/NumberField/FluentNumberField.razor.cs index 3c2014fa49..1e4d82771a 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor.cs +++ b/src/Core/Components/NumberField/FluentNumberField.razor.cs @@ -86,12 +86,6 @@ public partial class FluentNumberField : FluentInputBase, IAsync [Parameter] public string? AutoComplete { get; set; } - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - [Parameter] - public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; - /// /// Gets or sets the content to be rendered inside the component. /// @@ -105,6 +99,11 @@ public partial class FluentNumberField : FluentInputBase, IAsync [Parameter] public bool UseTypeConstraints { get; set; } + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + public override string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + private static readonly string _stepAttributeValue = GetStepAttributeValue(); // If type constraints is true and min is null, set min to the minimum value of TValue. diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs index 538a28e28b..8197681be3 100644 --- a/src/Core/Components/Rating/FluentRating.razor.cs +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -74,6 +74,11 @@ public partial class FluentRating : FluentInputBase [Parameter] public EventCallback OnHoverValueChanged { get; set; } + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + public override string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; + /// private string GroupName => Id ?? $"rating-{Id}"; @@ -90,8 +95,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa } else { - validationErrorMessage = string.Format(CultureInfo.InvariantCulture, - "The {0} field must be a number.", + validationErrorMessage = string.Format(ParsingErrorMessage, FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); return false; } diff --git a/src/Core/Extensions/FluentInputExtensions.cs b/src/Core/Extensions/FluentInputExtensions.cs index 893e7353dc..f48da289b5 100644 --- a/src/Core/Extensions/FluentInputExtensions.cs +++ b/src/Core/Extensions/FluentInputExtensions.cs @@ -42,7 +42,7 @@ internal static class FluentInputExtensions } result = default; - validationErrorMessage = $"The {input.DisplayName ?? (input.FieldBound ? input.FieldIdentifier.FieldName : input.UnknownBoundField)} field is not valid."; + validationErrorMessage = string.Format(input.ParsingErrorMessage, input.FieldDisplayName); return false; } catch (InvalidOperationException ex) diff --git a/tests/Core/DateTime/FluentDatePickerTests.cs b/tests/Core/DateTime/FluentDatePickerTests.cs index b0b06383eb..2b3a21e5db 100644 --- a/tests/Core/DateTime/FluentDatePickerTests.cs +++ b/tests/Core/DateTime/FluentDatePickerTests.cs @@ -6,6 +6,7 @@ using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Xunit; namespace Microsoft.FluentUI.AspNetCore.Components.Tests.DateTime; @@ -292,4 +293,32 @@ public void FluentDatePicker_OnDoubleClickEventTriggers() // Assert Assert.Equal(expected, actual); } + + [Theory] + [InlineData("02-22-2000", "en-GB", "02/22/2000")] + [InlineData("02-22-2000", "nl-BE", null)] + [InlineData("22-12-2000", "nl-BE", "22/12/2000")] + [InlineData("02/22/2000", "en-GB", "02/22/2000")] + [InlineData("abc", "en-GB", null)] + [InlineData("02022000", "en-GB", null)] + public void FluentDatePicker_TryParseValueFromString(string? value, string? cultureName, string? expectedValue) + { + // Arrange + var picker = new FluentDatePicker(); + + // Act + picker.Culture = cultureName != null ? CultureInfo.GetCultureInfo(cultureName) : CultureInfo.InvariantCulture; + var successfullParse = picker.TryParseSelectableValueFromString(value, out var resultDate, out var validationErrorMessage); + + // Assert + if (successfullParse) + { + Assert.Equal(expectedValue, resultDate?.ToShortDateString()); + } + else + { + Assert.Null(resultDate); + Assert.NotNull(validationErrorMessage); + } + } } From f863b7a131d84386cb4bf6205fe6af9ebc923a9f Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Fri, 30 Jan 2026 13:54:36 +0100 Subject: [PATCH 3/9] Attempt to fix culture comparison on the pipeline for failing tests. --- tests/Core/DateTime/FluentDatePickerTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Core/DateTime/FluentDatePickerTests.cs b/tests/Core/DateTime/FluentDatePickerTests.cs index 2b3a21e5db..0e1ea1a465 100644 --- a/tests/Core/DateTime/FluentDatePickerTests.cs +++ b/tests/Core/DateTime/FluentDatePickerTests.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using System.Globalization; +using System.Runtime.Serialization; using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; @@ -305,15 +306,16 @@ public void FluentDatePicker_TryParseValueFromString(string? value, string? cult { // Arrange var picker = new FluentDatePicker(); + var culture = cultureName != null ? CultureInfo.GetCultureInfo(cultureName) : CultureInfo.InvariantCulture; // Act - picker.Culture = cultureName != null ? CultureInfo.GetCultureInfo(cultureName) : CultureInfo.InvariantCulture; + picker.Culture = culture; var successfullParse = picker.TryParseSelectableValueFromString(value, out var resultDate, out var validationErrorMessage); // Assert if (successfullParse) { - Assert.Equal(expectedValue, resultDate?.ToShortDateString()); + Assert.Equal(expectedValue, resultDate?.ToString(culture.DateTimeFormat.ShortDatePattern, culture)); } else { From 053aa4ce842cda94732e22531b743312edbb06ab Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Fri, 30 Jan 2026 14:03:43 +0100 Subject: [PATCH 4/9] Remove using that somehow got introduced. --- tests/Core/DateTime/FluentDatePickerTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Core/DateTime/FluentDatePickerTests.cs b/tests/Core/DateTime/FluentDatePickerTests.cs index 0e1ea1a465..6325ec2168 100644 --- a/tests/Core/DateTime/FluentDatePickerTests.cs +++ b/tests/Core/DateTime/FluentDatePickerTests.cs @@ -3,7 +3,6 @@ // ------------------------------------------------------------------------ using System.Globalization; -using System.Runtime.Serialization; using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; From 998a876645f66e6f5d7454d4d5f78e859b6fcef2 Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Fri, 30 Jan 2026 14:24:37 +0100 Subject: [PATCH 5/9] Run test against protected method instead of extension method *Facepalm*. --- tests/Core/DateTime/FluentDatePickerTests.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/Core/DateTime/FluentDatePickerTests.cs b/tests/Core/DateTime/FluentDatePickerTests.cs index 6325ec2168..a680496858 100644 --- a/tests/Core/DateTime/FluentDatePickerTests.cs +++ b/tests/Core/DateTime/FluentDatePickerTests.cs @@ -2,11 +2,11 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; -using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Xunit; namespace Microsoft.FluentUI.AspNetCore.Components.Tests.DateTime; @@ -304,12 +304,12 @@ public void FluentDatePicker_OnDoubleClickEventTriggers() public void FluentDatePicker_TryParseValueFromString(string? value, string? cultureName, string? expectedValue) { // Arrange - var picker = new FluentDatePicker(); + var picker = new TestDatePicker(); var culture = cultureName != null ? CultureInfo.GetCultureInfo(cultureName) : CultureInfo.InvariantCulture; // Act picker.Culture = culture; - var successfullParse = picker.TryParseSelectableValueFromString(value, out var resultDate, out var validationErrorMessage); + var successfullParse = picker.CallTryParseValueFromString(value, out var resultDate, out var validationErrorMessage); // Assert if (successfullParse) @@ -322,4 +322,13 @@ public void FluentDatePicker_TryParseValueFromString(string? value, string? cult Assert.NotNull(validationErrorMessage); } } + + // Temporary class to expose protected method + private class TestDatePicker : FluentDatePicker + { + public bool CallTryParseValueFromString(string? value, out System.DateTime? result, [NotNullWhen(false)] out string? validationErrorMessage) + { + return base.TryParseValueFromString(value, out result, out validationErrorMessage); + } + } } From 6d5fa8a8ecf94912f770824829ead08a9482c114 Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Fri, 30 Jan 2026 20:39:20 +0100 Subject: [PATCH 6/9] Fix copy paste issue --- src/Core/Components/Base/FluentInputBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index 9617091880..266a1a3ad3 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -136,7 +136,7 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi /// Gets or sets the error message to show when the field can not be parsed. /// [Parameter] - public virtual string ParsingErrorMessage { get; set; } = "The {0} field must be a valid format."; + public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; /// /// Gets the associated . From ec6cb1eec67fb065f65fe7f09ebeb61f987ed7c1 Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Sun, 1 Feb 2026 12:49:18 +0100 Subject: [PATCH 7/9] Move parsing away from input base. --- ...crosoft.FluentUI.AspNetCore.Components.xml | 67 ++++---- src/Core/Components/Base/FluentInputBase.cs | 90 +--------- .../Base/FluentInputBaseHandlers.cs | 24 +-- .../Base/FluentParsableInputBase.cs | 157 ++++++++++++++++++ .../Checkbox/FluentCheckbox.razor.cs | 4 - .../Components/DateTime/FluentCalendarBase.cs | 2 +- .../DateTime/FluentTimePicker.razor | 2 +- .../DateTime/FluentTimePicker.razor.cs | 2 +- .../List/ListComponentBase.razor.cs | 5 - .../NumberField/FluentNumberField.razor | 2 +- .../NumberField/FluentNumberField.razor.cs | 2 +- .../Components/Radio/FluentRadioGroup.razor | 2 +- .../Radio/FluentRadioGroup.razor.cs | 2 +- src/Core/Components/Rating/FluentRating.razor | 2 +- .../Components/Rating/FluentRating.razor.cs | 2 +- .../Components/Search/FluentSearch.razor.cs | 8 - src/Core/Components/Slider/FluentSlider.razor | 2 +- .../Components/Slider/FluentSlider.razor.cs | 2 +- .../Components/Switch/FluentSwitch.razor.cs | 2 - .../TextArea/FluentTextArea.razor.cs | 9 - .../TextField/FluentTextField.razor.cs | 8 - src/Core/Extensions/FluentInputExtensions.cs | 2 +- 22 files changed, 211 insertions(+), 187 deletions(-) create mode 100644 src/Core/Components/Base/FluentParsableInputBase.cs diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 8c8a9447e5..c44f7f4bed 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -658,11 +658,6 @@ If true, the ClassValue property will not include the EditContext's FieldCssClass. - - - Gets or sets the error message to show when the field can not be parsed. - - Gets the associated . @@ -685,17 +680,6 @@ Gets or sets the current value of the input. - - - Gets or sets the current value of the input, represented as a string. - - - - - Attempts to set the current value of the input, represented as a string. - - - Constructs an instance of . @@ -703,21 +687,11 @@ - Formats the value as a string. Derived classes can override this to determine the formating used for . + Formats the value as a string. The value to format. A string representation of the value. - - - Parses a string to create an instance of . Derived classes can override this to change how - interprets incoming values. - - The string value to be parsed. - An instance of . - If the value could not be parsed, provides a validation error message. - True if the value could be parsed; otherwise false. - Gets a CSS class string that combines the class attribute and and a string indicating @@ -778,6 +752,39 @@ + + + Gets or sets the error message to show when the field can not be parsed. + + + + + Gets or sets the current value of the input, represented as a string. + + + + + Attempts to set the current value of the input, represented as a string. + + + + + + Parses a string to create an instance of . Derived classes can override this to change how + interprets incoming values. + + The string value to be parsed. + An instance of . + If the value could not be parsed, provides a validation error message. + True if the value could be parsed; otherwise false. + + + + Handler for the OnChange event. + + + + Because of the limitation of the web component, the maximum value is set to 9999999999 for really large numbers. @@ -1080,9 +1087,6 @@ - - - If true, the region is expaned, otherwise it is collapsed. @@ -6621,9 +6625,6 @@ - - - diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index 266a1a3ad3..2269c44c95 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -21,16 +21,10 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi internal readonly string UnknownBoundField = "(unknown)"; private readonly EventHandler _validationStateChangedHandler; - private bool _hasInitializedParameters; - private bool _parsingFailed; - private string? _incomingValueBeforeParsing; - private bool _previousParsingAttemptFailed; - private ValidationMessageStore? _parsingValidationMessages; - private Type? _nullableUnderlyingType; [CascadingParameter] - private EditContext? CascadedEditContext { get; set; } + private protected EditContext? CascadedEditContext { get; set; } /// /// When true, the control will be immutable by user interaction. readonly HTML attribute for more information. @@ -132,12 +126,6 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi [Parameter] public virtual bool Embedded { get; set; } = false; - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - [Parameter] - public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; - /// /// Gets the associated . /// This property is uninitialized if the input does not have a parent . @@ -157,7 +145,7 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi /// internal string FieldDisplayName => DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); - protected async Task SetCurrentValueAsync(TValue? value) + protected virtual async Task SetCurrentValueAsync(TValue? value) { var hasChanged = !EqualityComparer.Default.Equals(value, Value); if (!hasChanged) @@ -165,8 +153,6 @@ protected async Task SetCurrentValueAsync(TValue? value) return; } - _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 @@ -197,65 +183,6 @@ protected TValue? CurrentValue set => _ = SetCurrentValueAsync(value); } - /// - /// Gets or sets the current value of the input, represented as a string. - /// - 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 => _ = SetCurrentValueAsStringAsync(value); - - } - - /// - /// Attempts to set the current value of the input, represented as a string. - /// - /// - protected async Task SetCurrentValueAsStringAsync(string? value) - { - _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 based on the type itself). - _parsingFailed = false; - CurrentValue = default!; - } - else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) - { - _parsingFailed = false; - await SetCurrentValueAsync(parsedValue); - } - else - { - _parsingFailed = true; - - // EditContext may be null if the input is not a child component of EditForm. - if (EditContext is not null && FieldBound) - { - _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; - } - } - /// /// Constructs an instance of . /// @@ -266,23 +193,13 @@ protected FluentInputBase() } /// - /// Formats the value as a string. Derived classes can override this to determine the formating used for . + /// Formats the value as a string. /// /// The value to format. /// A string representation of the value. protected virtual string? FormatValueAsString(TValue? value) => value?.ToString(); - /// - /// Parses a string to create an instance of . Derived classes can override this to change how - /// interprets incoming values. - /// - /// The string value to be parsed. - /// An instance of . - /// If the value could not be parsed, provides a validation error message. - /// True if the value could be parsed; otherwise false. - protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage); - /// /// Gets a CSS class string that combines the class attribute and and a string indicating /// the status of the field being edited (a combination of "modified", "valid", and "invalid"). @@ -339,7 +256,6 @@ public override Task SetParametersAsync(ParameterView parameters) EditContext.OnValidationStateChanged += _validationStateChangedHandler; } - _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); _hasInitializedParameters = true; } else if (CascadedEditContext != EditContext) diff --git a/src/Core/Components/Base/FluentInputBaseHandlers.cs b/src/Core/Components/Base/FluentInputBaseHandlers.cs index d5e8624e77..f22aaa6c19 100644 --- a/src/Core/Components/Base/FluentInputBaseHandlers.cs +++ b/src/Core/Components/Base/FluentInputBaseHandlers.cs @@ -3,7 +3,6 @@ // ------------------------------------------------------------------------ using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -32,28 +31,15 @@ public partial class FluentInputBase protected virtual async Task ChangeHandlerAsync(ChangeEventArgs e) { var _notifyCalled = false; - var isValid = TryParseValueFromString(e.Value?.ToString(), out TValue? result, out var validationErrorMessage); - if (isValid) + if (typeof(TValue) == typeof(string)) { - await SetCurrentValueAsync(result ?? default); - _notifyCalled = true; - - if (FieldBound && CascadedEditContext != null) - { - _parsingValidationMessages?.Clear(); // Clear any previous errors - } - } - else - { - if (FieldBound && CascadedEditContext != null) - { - _parsingValidationMessages ??= new ValidationMessageStore(CascadedEditContext); + object? value = e.Value?.ToString(); + await SetCurrentValueAsync((TValue?)(value ?? default)); - _parsingValidationMessages.Clear(); - _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage ?? "Unknown parsing error"); - } + _notifyCalled = true; } + if (FieldBound && !_notifyCalled) { CascadedEditContext?.NotifyFieldChanged(FieldIdentifier); diff --git a/src/Core/Components/Base/FluentParsableInputBase.cs b/src/Core/Components/Base/FluentParsableInputBase.cs new file mode 100644 index 0000000000..8b1e386311 --- /dev/null +++ b/src/Core/Components/Base/FluentParsableInputBase.cs @@ -0,0 +1,157 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +public abstract class FluentParsableInputBase : FluentInputBase +{ + private bool _hasInitializedParameters; + private bool _parsingFailed; + private string? _incomingValueBeforeParsing; + private bool _previousParsingAttemptFailed; + private ValidationMessageStore? _parsingValidationMessages; + private Type? _nullableUnderlyingType; + + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + [Parameter] + public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; + + /// + /// Gets or sets the current value of the input, represented as a string. + /// + 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 => _ = SetCurrentValueAsStringAsync(value); + + } + + /// + /// Attempts to set the current value of the input, represented as a string. + /// + /// + protected async Task SetCurrentValueAsStringAsync(string? value) + { + _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 based on the type itself). + _parsingFailed = false; + CurrentValue = default!; + } + else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) + { + _parsingFailed = false; + await SetCurrentValueAsync(parsedValue); + } + else + { + _parsingFailed = true; + + // EditContext may be null if the input is not a child component of EditForm. + if (EditContext is not null && FieldBound) + { + _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; + } + } + + public override Task SetParametersAsync(ParameterView parameters) + { + if(!_hasInitializedParameters) + { + var type = typeof(TValue); + + _nullableUnderlyingType = Nullable.GetUnderlyingType(type); + + _hasInitializedParameters = true; + } + return base.SetParametersAsync(parameters); + } + + /// + /// Parses a string to create an instance of . Derived classes can override this to change how + /// interprets incoming values. + /// + /// The string value to be parsed. + /// An instance of . + /// If the value could not be parsed, provides a validation error message. + /// True if the value could be parsed; otherwise false. + protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage); + + protected override Task SetCurrentValueAsync(TValue? value) + { + var hasChanged = !EqualityComparer.Default.Equals(value, Value); + + if (!hasChanged) + { + return Task.CompletedTask; + } + + _parsingFailed = false; + + return base.SetCurrentValueAsync(value); + } + + /// + /// Handler for the OnChange event. + /// + /// + /// + protected override async Task ChangeHandlerAsync(ChangeEventArgs e) + { + var _notifyCalled = false; + var isValid = TryParseValueFromString(e.Value?.ToString(), out TValue? result, out var validationErrorMessage); + + if (isValid) + { + await SetCurrentValueAsync(result ?? default); + _notifyCalled = true; + + if (FieldBound && CascadedEditContext != null) + { + _parsingValidationMessages?.Clear(); // Clear any previous errors + } + } + else + { + if (FieldBound && CascadedEditContext != null) + { + _parsingValidationMessages ??= new ValidationMessageStore(CascadedEditContext); + + _parsingValidationMessages.Clear(); + _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage ?? "Unknown parsing error"); + } + } + if (FieldBound && !_notifyCalled) + { + CascadedEditContext?.NotifyFieldChanged(FieldIdentifier); + } + } +} diff --git a/src/Core/Components/Checkbox/FluentCheckbox.razor.cs b/src/Core/Components/Checkbox/FluentCheckbox.razor.cs index 4cda52dab7..53ad54ea9d 100644 --- a/src/Core/Components/Checkbox/FluentCheckbox.razor.cs +++ b/src/Core/Components/Checkbox/FluentCheckbox.razor.cs @@ -237,8 +237,4 @@ private async Task UpdateAndRaiseCheckStateEventAsync(bool? value) } } } - - /// - protected override bool TryParseValueFromString(string? value, out bool result, [NotNullWhen(false)] out string? validationErrorMessage) => throw new NotSupportedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); - } diff --git a/src/Core/Components/DateTime/FluentCalendarBase.cs b/src/Core/Components/DateTime/FluentCalendarBase.cs index df0ae1ff8b..49ef8091ae 100644 --- a/src/Core/Components/DateTime/FluentCalendarBase.cs +++ b/src/Core/Components/DateTime/FluentCalendarBase.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public abstract class FluentCalendarBase : FluentInputBase +public abstract class FluentCalendarBase : FluentParsableInputBase { /// /// Gets or sets the culture of the component. diff --git a/src/Core/Components/DateTime/FluentTimePicker.razor b/src/Core/Components/DateTime/FluentTimePicker.razor index 4a5685c988..c6a21a4f66 100644 --- a/src/Core/Components/DateTime/FluentTimePicker.razor +++ b/src/Core/Components/DateTime/FluentTimePicker.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentInputBase +@inherits FluentParsableInputBase +public partial class FluentTimePicker : FluentParsableInputBase { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DateTime/FluentTimePicker.razor.js"; diff --git a/src/Core/Components/List/ListComponentBase.razor.cs b/src/Core/Components/List/ListComponentBase.razor.cs index a21bef098b..e716627468 100644 --- a/src/Core/Components/List/ListComponentBase.razor.cs +++ b/src/Core/Components/List/ListComponentBase.razor.cs @@ -2,7 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; @@ -420,10 +419,6 @@ protected override void OnParametersSet() } } - /// - protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out string? result, [NotNullWhen(false)] out string? validationErrorMessage) - => this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage); - /// protected override string? FormatValueAsString(string? value) { diff --git a/src/Core/Components/NumberField/FluentNumberField.razor b/src/Core/Components/NumberField/FluentNumberField.razor index 9ad70719b9..4c48cb3df1 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor +++ b/src/Core/Components/NumberField/FluentNumberField.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentInputBase +@inherits FluentParsableInputBase @typeparam TValue where TValue : new() @using System.Globalization; diff --git a/src/Core/Components/NumberField/FluentNumberField.razor.cs b/src/Core/Components/NumberField/FluentNumberField.razor.cs index 1e4d82771a..073cc5d81d 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor.cs +++ b/src/Core/Components/NumberField/FluentNumberField.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentNumberField : FluentInputBase, IAsyncDisposable +public partial class FluentNumberField : FluentParsableInputBase, IAsyncDisposable { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/TextField/FluentTextField.razor.js"; diff --git a/src/Core/Components/Radio/FluentRadioGroup.razor b/src/Core/Components/Radio/FluentRadioGroup.razor index 94fd4d4aa7..59ca812cb0 100644 --- a/src/Core/Components/Radio/FluentRadioGroup.razor +++ b/src/Core/Components/Radio/FluentRadioGroup.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentInputBase +@inherits FluentParsableInputBase @typeparam TValue diff --git a/src/Core/Components/Radio/FluentRadioGroup.razor.cs b/src/Core/Components/Radio/FluentRadioGroup.razor.cs index 3fdaa59322..bb7b64cb89 100644 --- a/src/Core/Components/Radio/FluentRadioGroup.razor.cs +++ b/src/Core/Components/Radio/FluentRadioGroup.razor.cs @@ -15,7 +15,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// [CascadingTypeParameter(nameof(TValue))] -public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentInputBase +public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentParsableInputBase { private readonly string _defaultGroupName = Identifier.NewId(); private FluentRadioContext? _context; diff --git a/src/Core/Components/Rating/FluentRating.razor b/src/Core/Components/Rating/FluentRating.razor index 5d1dffd56d..548f74bd20 100644 --- a/src/Core/Components/Rating/FluentRating.razor +++ b/src/Core/Components/Rating/FluentRating.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentInputBase +@inherits FluentParsableInputBase @if (!string.IsNullOrEmpty(Label) || LabelTemplate is not null) { diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs index 8197681be3..0d0a0e0635 100644 --- a/src/Core/Components/Rating/FluentRating.razor.cs +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentRating : FluentInputBase +public partial class FluentRating : FluentParsableInputBase { private bool _updatingCurrentValue = false; private int? _hoverValue = null; diff --git a/src/Core/Components/Search/FluentSearch.razor.cs b/src/Core/Components/Search/FluentSearch.razor.cs index 321bafc23a..7b22b66888 100644 --- a/src/Core/Components/Search/FluentSearch.razor.cs +++ b/src/Core/Components/Search/FluentSearch.razor.cs @@ -2,7 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Microsoft.JSInterop; @@ -104,11 +103,4 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await Module.InvokeVoidAsync("setDataList", Id, DataList); } } - - protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage) - { - result = value; - validationErrorMessage = null; - return true; - } } diff --git a/src/Core/Components/Slider/FluentSlider.razor b/src/Core/Components/Slider/FluentSlider.razor index fec1347481..dbab70c9a9 100644 --- a/src/Core/Components/Slider/FluentSlider.razor +++ b/src/Core/Components/Slider/FluentSlider.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentInputBase +@inherits FluentParsableInputBase @typeparam TValue @attribute [CascadingTypeParameter(nameof(TValue))] diff --git a/src/Core/Components/Slider/FluentSlider.razor.cs b/src/Core/Components/Slider/FluentSlider.razor.cs index 3c8311201d..77050801e6 100644 --- a/src/Core/Components/Slider/FluentSlider.razor.cs +++ b/src/Core/Components/Slider/FluentSlider.razor.cs @@ -13,7 +13,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentSlider : FluentInputBase, IAsyncDisposable +public partial class FluentSlider : FluentParsableInputBase, IAsyncDisposable where TValue : System.Numerics.INumber { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Slider/FluentSlider.razor.js"; diff --git a/src/Core/Components/Switch/FluentSwitch.razor.cs b/src/Core/Components/Switch/FluentSwitch.razor.cs index 7f9489e74e..6e4ccd1ecc 100644 --- a/src/Core/Components/Switch/FluentSwitch.razor.cs +++ b/src/Core/Components/Switch/FluentSwitch.razor.cs @@ -44,6 +44,4 @@ protected override string? ClassValue .Build(); } } - - protected override bool TryParseValueFromString(string? value, out bool result, [NotNullWhen(false)] out string? validationErrorMessage) => throw new NotSupportedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); } diff --git a/src/Core/Components/TextArea/FluentTextArea.razor.cs b/src/Core/Components/TextArea/FluentTextArea.razor.cs index 0dd74e306e..d614b93539 100644 --- a/src/Core/Components/TextArea/FluentTextArea.razor.cs +++ b/src/Core/Components/TextArea/FluentTextArea.razor.cs @@ -2,8 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; - using Microsoft.AspNetCore.Components; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -70,11 +68,4 @@ public partial class FluentTextArea : FluentInputBase /// [Parameter] public RenderFragment? ChildContent { get; set; } - - protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage) - { - result = value; - validationErrorMessage = null; - return true; - } } diff --git a/src/Core/Components/TextField/FluentTextField.razor.cs b/src/Core/Components/TextField/FluentTextField.razor.cs index b8c256f71b..6c92f44588 100644 --- a/src/Core/Components/TextField/FluentTextField.razor.cs +++ b/src/Core/Components/TextField/FluentTextField.razor.cs @@ -2,7 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Microsoft.JSInterop; @@ -91,13 +90,6 @@ public partial class FluentTextField : FluentInputBase [Parameter] public InputMode? InputMode { get; set; } - protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage) - { - result = value; - validationErrorMessage = null; - return true; - } - protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); diff --git a/src/Core/Extensions/FluentInputExtensions.cs b/src/Core/Extensions/FluentInputExtensions.cs index f48da289b5..096dd3cf53 100644 --- a/src/Core/Extensions/FluentInputExtensions.cs +++ b/src/Core/Extensions/FluentInputExtensions.cs @@ -11,7 +11,7 @@ internal static class FluentInputExtensions { public static bool TryParseSelectableValueFromString<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue>( - this FluentInputBase input, string? value, + this FluentParsableInputBase input, string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) { From b356e92056f3ab0a77c5bb66d2f9090a336ce852 Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Sun, 1 Feb 2026 18:44:30 +0100 Subject: [PATCH 8/9] Revert "Move parsing away from input base." This reverts commit ec6cb1eec67fb065f65fe7f09ebeb61f987ed7c1. --- ...crosoft.FluentUI.AspNetCore.Components.xml | 67 ++++---- src/Core/Components/Base/FluentInputBase.cs | 90 +++++++++- .../Base/FluentInputBaseHandlers.cs | 24 ++- .../Base/FluentParsableInputBase.cs | 157 ------------------ .../Checkbox/FluentCheckbox.razor.cs | 4 + .../Components/DateTime/FluentCalendarBase.cs | 2 +- .../DateTime/FluentTimePicker.razor | 2 +- .../DateTime/FluentTimePicker.razor.cs | 2 +- .../List/ListComponentBase.razor.cs | 5 + .../NumberField/FluentNumberField.razor | 2 +- .../NumberField/FluentNumberField.razor.cs | 2 +- .../Components/Radio/FluentRadioGroup.razor | 2 +- .../Radio/FluentRadioGroup.razor.cs | 2 +- src/Core/Components/Rating/FluentRating.razor | 2 +- .../Components/Rating/FluentRating.razor.cs | 2 +- .../Components/Search/FluentSearch.razor.cs | 8 + src/Core/Components/Slider/FluentSlider.razor | 2 +- .../Components/Slider/FluentSlider.razor.cs | 2 +- .../Components/Switch/FluentSwitch.razor.cs | 2 + .../TextArea/FluentTextArea.razor.cs | 9 + .../TextField/FluentTextField.razor.cs | 8 + src/Core/Extensions/FluentInputExtensions.cs | 2 +- 22 files changed, 187 insertions(+), 211 deletions(-) delete mode 100644 src/Core/Components/Base/FluentParsableInputBase.cs diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index c44f7f4bed..8c8a9447e5 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -658,6 +658,11 @@ If true, the ClassValue property will not include the EditContext's FieldCssClass. + + + Gets or sets the error message to show when the field can not be parsed. + + Gets the associated . @@ -680,6 +685,17 @@ Gets or sets the current value of the input. + + + Gets or sets the current value of the input, represented as a string. + + + + + Attempts to set the current value of the input, represented as a string. + + + Constructs an instance of . @@ -687,11 +703,21 @@ - Formats the value as a string. + Formats the value as a string. Derived classes can override this to determine the formating used for . The value to format. A string representation of the value. + + + Parses a string to create an instance of . Derived classes can override this to change how + interprets incoming values. + + The string value to be parsed. + An instance of . + If the value could not be parsed, provides a validation error message. + True if the value could be parsed; otherwise false. + Gets a CSS class string that combines the class attribute and and a string indicating @@ -752,39 +778,6 @@ - - - Gets or sets the error message to show when the field can not be parsed. - - - - - Gets or sets the current value of the input, represented as a string. - - - - - Attempts to set the current value of the input, represented as a string. - - - - - - Parses a string to create an instance of . Derived classes can override this to change how - interprets incoming values. - - The string value to be parsed. - An instance of . - If the value could not be parsed, provides a validation error message. - True if the value could be parsed; otherwise false. - - - - Handler for the OnChange event. - - - - Because of the limitation of the web component, the maximum value is set to 9999999999 for really large numbers. @@ -1087,6 +1080,9 @@ + + + If true, the region is expaned, otherwise it is collapsed. @@ -6625,6 +6621,9 @@ + + + diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index 2269c44c95..266a1a3ad3 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -21,10 +21,16 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi internal readonly string UnknownBoundField = "(unknown)"; private readonly EventHandler _validationStateChangedHandler; + private bool _hasInitializedParameters; + private bool _parsingFailed; + private string? _incomingValueBeforeParsing; + private bool _previousParsingAttemptFailed; + private ValidationMessageStore? _parsingValidationMessages; + private Type? _nullableUnderlyingType; [CascadingParameter] - private protected EditContext? CascadedEditContext { get; set; } + private EditContext? CascadedEditContext { get; set; } /// /// When true, the control will be immutable by user interaction. readonly HTML attribute for more information. @@ -126,6 +132,12 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi [Parameter] public virtual bool Embedded { get; set; } = false; + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + [Parameter] + public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; + /// /// Gets the associated . /// This property is uninitialized if the input does not have a parent . @@ -145,7 +157,7 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi /// internal string FieldDisplayName => DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); - protected virtual async Task SetCurrentValueAsync(TValue? value) + protected async Task SetCurrentValueAsync(TValue? value) { var hasChanged = !EqualityComparer.Default.Equals(value, Value); if (!hasChanged) @@ -153,6 +165,8 @@ protected virtual async Task SetCurrentValueAsync(TValue? value) return; } + _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 @@ -183,6 +197,65 @@ protected TValue? CurrentValue set => _ = SetCurrentValueAsync(value); } + /// + /// Gets or sets the current value of the input, represented as a string. + /// + 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 => _ = SetCurrentValueAsStringAsync(value); + + } + + /// + /// Attempts to set the current value of the input, represented as a string. + /// + /// + protected async Task SetCurrentValueAsStringAsync(string? value) + { + _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 based on the type itself). + _parsingFailed = false; + CurrentValue = default!; + } + else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) + { + _parsingFailed = false; + await SetCurrentValueAsync(parsedValue); + } + else + { + _parsingFailed = true; + + // EditContext may be null if the input is not a child component of EditForm. + if (EditContext is not null && FieldBound) + { + _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; + } + } + /// /// Constructs an instance of . /// @@ -193,13 +266,23 @@ protected FluentInputBase() } /// - /// Formats the value as a string. + /// Formats the value as a string. Derived classes can override this to determine the formating used for . /// /// The value to format. /// A string representation of the value. protected virtual string? FormatValueAsString(TValue? value) => value?.ToString(); + /// + /// Parses a string to create an instance of . Derived classes can override this to change how + /// interprets incoming values. + /// + /// The string value to be parsed. + /// An instance of . + /// If the value could not be parsed, provides a validation error message. + /// True if the value could be parsed; otherwise false. + protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage); + /// /// Gets a CSS class string that combines the class attribute and and a string indicating /// the status of the field being edited (a combination of "modified", "valid", and "invalid"). @@ -256,6 +339,7 @@ public override Task SetParametersAsync(ParameterView parameters) EditContext.OnValidationStateChanged += _validationStateChangedHandler; } + _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); _hasInitializedParameters = true; } else if (CascadedEditContext != EditContext) diff --git a/src/Core/Components/Base/FluentInputBaseHandlers.cs b/src/Core/Components/Base/FluentInputBaseHandlers.cs index f22aaa6c19..d5e8624e77 100644 --- a/src/Core/Components/Base/FluentInputBaseHandlers.cs +++ b/src/Core/Components/Base/FluentInputBaseHandlers.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -31,15 +32,28 @@ public partial class FluentInputBase protected virtual async Task ChangeHandlerAsync(ChangeEventArgs e) { var _notifyCalled = false; + var isValid = TryParseValueFromString(e.Value?.ToString(), out TValue? result, out var validationErrorMessage); - if (typeof(TValue) == typeof(string)) + if (isValid) { - object? value = e.Value?.ToString(); - await SetCurrentValueAsync((TValue?)(value ?? default)); - + await SetCurrentValueAsync(result ?? default); _notifyCalled = true; + + if (FieldBound && CascadedEditContext != null) + { + _parsingValidationMessages?.Clear(); // Clear any previous errors + } + } + else + { + if (FieldBound && CascadedEditContext != null) + { + _parsingValidationMessages ??= new ValidationMessageStore(CascadedEditContext); + + _parsingValidationMessages.Clear(); + _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage ?? "Unknown parsing error"); + } } - if (FieldBound && !_notifyCalled) { CascadedEditContext?.NotifyFieldChanged(FieldIdentifier); diff --git a/src/Core/Components/Base/FluentParsableInputBase.cs b/src/Core/Components/Base/FluentParsableInputBase.cs deleted file mode 100644 index 8b1e386311..0000000000 --- a/src/Core/Components/Base/FluentParsableInputBase.cs +++ /dev/null @@ -1,157 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Forms; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -public abstract class FluentParsableInputBase : FluentInputBase -{ - private bool _hasInitializedParameters; - private bool _parsingFailed; - private string? _incomingValueBeforeParsing; - private bool _previousParsingAttemptFailed; - private ValidationMessageStore? _parsingValidationMessages; - private Type? _nullableUnderlyingType; - - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - [Parameter] - public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; - - /// - /// Gets or sets the current value of the input, represented as a string. - /// - 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 => _ = SetCurrentValueAsStringAsync(value); - - } - - /// - /// Attempts to set the current value of the input, represented as a string. - /// - /// - protected async Task SetCurrentValueAsStringAsync(string? value) - { - _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 based on the type itself). - _parsingFailed = false; - CurrentValue = default!; - } - else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage)) - { - _parsingFailed = false; - await SetCurrentValueAsync(parsedValue); - } - else - { - _parsingFailed = true; - - // EditContext may be null if the input is not a child component of EditForm. - if (EditContext is not null && FieldBound) - { - _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; - } - } - - public override Task SetParametersAsync(ParameterView parameters) - { - if(!_hasInitializedParameters) - { - var type = typeof(TValue); - - _nullableUnderlyingType = Nullable.GetUnderlyingType(type); - - _hasInitializedParameters = true; - } - return base.SetParametersAsync(parameters); - } - - /// - /// Parses a string to create an instance of . Derived classes can override this to change how - /// interprets incoming values. - /// - /// The string value to be parsed. - /// An instance of . - /// If the value could not be parsed, provides a validation error message. - /// True if the value could be parsed; otherwise false. - protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage); - - protected override Task SetCurrentValueAsync(TValue? value) - { - var hasChanged = !EqualityComparer.Default.Equals(value, Value); - - if (!hasChanged) - { - return Task.CompletedTask; - } - - _parsingFailed = false; - - return base.SetCurrentValueAsync(value); - } - - /// - /// Handler for the OnChange event. - /// - /// - /// - protected override async Task ChangeHandlerAsync(ChangeEventArgs e) - { - var _notifyCalled = false; - var isValid = TryParseValueFromString(e.Value?.ToString(), out TValue? result, out var validationErrorMessage); - - if (isValid) - { - await SetCurrentValueAsync(result ?? default); - _notifyCalled = true; - - if (FieldBound && CascadedEditContext != null) - { - _parsingValidationMessages?.Clear(); // Clear any previous errors - } - } - else - { - if (FieldBound && CascadedEditContext != null) - { - _parsingValidationMessages ??= new ValidationMessageStore(CascadedEditContext); - - _parsingValidationMessages.Clear(); - _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage ?? "Unknown parsing error"); - } - } - if (FieldBound && !_notifyCalled) - { - CascadedEditContext?.NotifyFieldChanged(FieldIdentifier); - } - } -} diff --git a/src/Core/Components/Checkbox/FluentCheckbox.razor.cs b/src/Core/Components/Checkbox/FluentCheckbox.razor.cs index 53ad54ea9d..4cda52dab7 100644 --- a/src/Core/Components/Checkbox/FluentCheckbox.razor.cs +++ b/src/Core/Components/Checkbox/FluentCheckbox.razor.cs @@ -237,4 +237,8 @@ private async Task UpdateAndRaiseCheckStateEventAsync(bool? value) } } } + + /// + protected override bool TryParseValueFromString(string? value, out bool result, [NotNullWhen(false)] out string? validationErrorMessage) => throw new NotSupportedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); + } diff --git a/src/Core/Components/DateTime/FluentCalendarBase.cs b/src/Core/Components/DateTime/FluentCalendarBase.cs index 49ef8091ae..df0ae1ff8b 100644 --- a/src/Core/Components/DateTime/FluentCalendarBase.cs +++ b/src/Core/Components/DateTime/FluentCalendarBase.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public abstract class FluentCalendarBase : FluentParsableInputBase +public abstract class FluentCalendarBase : FluentInputBase { /// /// Gets or sets the culture of the component. diff --git a/src/Core/Components/DateTime/FluentTimePicker.razor b/src/Core/Components/DateTime/FluentTimePicker.razor index c6a21a4f66..4a5685c988 100644 --- a/src/Core/Components/DateTime/FluentTimePicker.razor +++ b/src/Core/Components/DateTime/FluentTimePicker.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentParsableInputBase +@inherits FluentInputBase +public partial class FluentTimePicker : FluentInputBase { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DateTime/FluentTimePicker.razor.js"; diff --git a/src/Core/Components/List/ListComponentBase.razor.cs b/src/Core/Components/List/ListComponentBase.razor.cs index e716627468..a21bef098b 100644 --- a/src/Core/Components/List/ListComponentBase.razor.cs +++ b/src/Core/Components/List/ListComponentBase.razor.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; @@ -419,6 +420,10 @@ protected override void OnParametersSet() } } + /// + protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out string? result, [NotNullWhen(false)] out string? validationErrorMessage) + => this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage); + /// protected override string? FormatValueAsString(string? value) { diff --git a/src/Core/Components/NumberField/FluentNumberField.razor b/src/Core/Components/NumberField/FluentNumberField.razor index 4c48cb3df1..9ad70719b9 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor +++ b/src/Core/Components/NumberField/FluentNumberField.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentParsableInputBase +@inherits FluentInputBase @typeparam TValue where TValue : new() @using System.Globalization; diff --git a/src/Core/Components/NumberField/FluentNumberField.razor.cs b/src/Core/Components/NumberField/FluentNumberField.razor.cs index 073cc5d81d..1e4d82771a 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor.cs +++ b/src/Core/Components/NumberField/FluentNumberField.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentNumberField : FluentParsableInputBase, IAsyncDisposable +public partial class FluentNumberField : FluentInputBase, IAsyncDisposable { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/TextField/FluentTextField.razor.js"; diff --git a/src/Core/Components/Radio/FluentRadioGroup.razor b/src/Core/Components/Radio/FluentRadioGroup.razor index 59ca812cb0..94fd4d4aa7 100644 --- a/src/Core/Components/Radio/FluentRadioGroup.razor +++ b/src/Core/Components/Radio/FluentRadioGroup.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentParsableInputBase +@inherits FluentInputBase @typeparam TValue diff --git a/src/Core/Components/Radio/FluentRadioGroup.razor.cs b/src/Core/Components/Radio/FluentRadioGroup.razor.cs index bb7b64cb89..3fdaa59322 100644 --- a/src/Core/Components/Radio/FluentRadioGroup.razor.cs +++ b/src/Core/Components/Radio/FluentRadioGroup.razor.cs @@ -15,7 +15,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// [CascadingTypeParameter(nameof(TValue))] -public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentParsableInputBase +public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentInputBase { private readonly string _defaultGroupName = Identifier.NewId(); private FluentRadioContext? _context; diff --git a/src/Core/Components/Rating/FluentRating.razor b/src/Core/Components/Rating/FluentRating.razor index 548f74bd20..5d1dffd56d 100644 --- a/src/Core/Components/Rating/FluentRating.razor +++ b/src/Core/Components/Rating/FluentRating.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentParsableInputBase +@inherits FluentInputBase @if (!string.IsNullOrEmpty(Label) || LabelTemplate is not null) { diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs index 0d0a0e0635..8197681be3 100644 --- a/src/Core/Components/Rating/FluentRating.razor.cs +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentRating : FluentParsableInputBase +public partial class FluentRating : FluentInputBase { private bool _updatingCurrentValue = false; private int? _hoverValue = null; diff --git a/src/Core/Components/Search/FluentSearch.razor.cs b/src/Core/Components/Search/FluentSearch.razor.cs index 7b22b66888..321bafc23a 100644 --- a/src/Core/Components/Search/FluentSearch.razor.cs +++ b/src/Core/Components/Search/FluentSearch.razor.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Microsoft.JSInterop; @@ -103,4 +104,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await Module.InvokeVoidAsync("setDataList", Id, DataList); } } + + protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } } diff --git a/src/Core/Components/Slider/FluentSlider.razor b/src/Core/Components/Slider/FluentSlider.razor index dbab70c9a9..fec1347481 100644 --- a/src/Core/Components/Slider/FluentSlider.razor +++ b/src/Core/Components/Slider/FluentSlider.razor @@ -1,5 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentParsableInputBase +@inherits FluentInputBase @typeparam TValue @attribute [CascadingTypeParameter(nameof(TValue))] diff --git a/src/Core/Components/Slider/FluentSlider.razor.cs b/src/Core/Components/Slider/FluentSlider.razor.cs index 77050801e6..3c8311201d 100644 --- a/src/Core/Components/Slider/FluentSlider.razor.cs +++ b/src/Core/Components/Slider/FluentSlider.razor.cs @@ -13,7 +13,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentSlider : FluentParsableInputBase, IAsyncDisposable +public partial class FluentSlider : FluentInputBase, IAsyncDisposable where TValue : System.Numerics.INumber { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Slider/FluentSlider.razor.js"; diff --git a/src/Core/Components/Switch/FluentSwitch.razor.cs b/src/Core/Components/Switch/FluentSwitch.razor.cs index 6e4ccd1ecc..7f9489e74e 100644 --- a/src/Core/Components/Switch/FluentSwitch.razor.cs +++ b/src/Core/Components/Switch/FluentSwitch.razor.cs @@ -44,4 +44,6 @@ protected override string? ClassValue .Build(); } } + + protected override bool TryParseValueFromString(string? value, out bool result, [NotNullWhen(false)] out string? validationErrorMessage) => throw new NotSupportedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); } diff --git a/src/Core/Components/TextArea/FluentTextArea.razor.cs b/src/Core/Components/TextArea/FluentTextArea.razor.cs index d614b93539..0dd74e306e 100644 --- a/src/Core/Components/TextArea/FluentTextArea.razor.cs +++ b/src/Core/Components/TextArea/FluentTextArea.razor.cs @@ -2,6 +2,8 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; + using Microsoft.AspNetCore.Components; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -68,4 +70,11 @@ public partial class FluentTextArea : FluentInputBase /// [Parameter] public RenderFragment? ChildContent { get; set; } + + protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } } diff --git a/src/Core/Components/TextField/FluentTextField.razor.cs b/src/Core/Components/TextField/FluentTextField.razor.cs index 6c92f44588..b8c256f71b 100644 --- a/src/Core/Components/TextField/FluentTextField.razor.cs +++ b/src/Core/Components/TextField/FluentTextField.razor.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Microsoft.JSInterop; @@ -90,6 +91,13 @@ public partial class FluentTextField : FluentInputBase [Parameter] public InputMode? InputMode { get; set; } + protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage) + { + result = value; + validationErrorMessage = null; + return true; + } + protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); diff --git a/src/Core/Extensions/FluentInputExtensions.cs b/src/Core/Extensions/FluentInputExtensions.cs index 096dd3cf53..f48da289b5 100644 --- a/src/Core/Extensions/FluentInputExtensions.cs +++ b/src/Core/Extensions/FluentInputExtensions.cs @@ -11,7 +11,7 @@ internal static class FluentInputExtensions { public static bool TryParseSelectableValueFromString<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue>( - this FluentParsableInputBase input, string? value, + this FluentInputBase input, string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) { From f2f012154e28c49c75fc9c2b640a30724a6d4995 Mon Sep 17 00:00:00 2001 From: Tyme_Bleyaert Date: Sun, 8 Feb 2026 13:55:19 +0100 Subject: [PATCH 9/9] Use interfaces instead to use for parsable components. --- ...crosoft.FluentUI.AspNetCore.Components.xml | 37 +++++++++++++------ src/Core/Components/Base/FluentInputBase.cs | 6 --- .../Base/ICultureSensitiveComponent.cs | 18 +++++++++ .../Base/IStringParsableComponent.cs | 16 ++++++++ .../Components/DateTime/FluentCalendarBase.cs | 6 ++- .../List/ListComponentBase.razor.cs | 6 ++- .../NumberField/FluentNumberField.razor.cs | 9 ++--- .../Radio/FluentRadioGroup.razor.cs | 6 ++- .../Components/Rating/FluentRating.razor.cs | 9 ++--- src/Core/Extensions/FluentInputExtensions.cs | 10 +++-- 10 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 src/Core/Components/Base/ICultureSensitiveComponent.cs create mode 100644 src/Core/Components/Base/IStringParsableComponent.cs diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 8c8a9447e5..a0d0073f4c 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -658,11 +658,6 @@ If true, the ClassValue property will not include the EditContext's FieldCssClass. - - - Gets or sets the error message to show when the field can not be parsed. - - Gets the associated . @@ -778,6 +773,16 @@ + + + Defines an interface for components with values that are sensitive to culture settings, eg : parsing to string. + + + + + Gets or sets the culture of the component. + + Because of the limitation of the web component, the maximum value is set to 9999999999 for really large numbers. @@ -790,6 +795,11 @@ The minimum value for the underlying type + + + Gets or sets the error message to show when the field can not be parsed. + + Gets or sets the content to be rendered inside the component. @@ -2914,6 +2924,9 @@ By default to display using the OS culture. + + + Function to know if a specific day must be disabled. @@ -6618,6 +6631,9 @@ ⚠️ Only available when Multiple = true. + + + @@ -8054,9 +8070,7 @@ - - Gets or sets the error message to show when the field can not be parsed. - + @@ -8951,6 +8965,9 @@ Gets or sets the child content to be rendering inside the . + + + @@ -9007,9 +9024,7 @@ - - Gets or sets the error message to show when the field can not be parsed. - + diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index 266a1a3ad3..5cef9e3f3c 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -132,12 +132,6 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi [Parameter] public virtual bool Embedded { get; set; } = false; - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - [Parameter] - public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; - /// /// Gets the associated . /// This property is uninitialized if the input does not have a parent . diff --git a/src/Core/Components/Base/ICultureSensitiveComponent.cs b/src/Core/Components/Base/ICultureSensitiveComponent.cs new file mode 100644 index 0000000000..b49ed2d482 --- /dev/null +++ b/src/Core/Components/Base/ICultureSensitiveComponent.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Globalization; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Defines an interface for components with values that are sensitive to culture settings, eg : parsing to string. +/// +public interface ICultureSensitiveComponent +{ + /// + /// Gets or sets the culture of the component. + /// + public CultureInfo Culture { get; set; } +} diff --git a/src/Core/Components/Base/IStringParsableComponent.cs b/src/Core/Components/Base/IStringParsableComponent.cs new file mode 100644 index 0000000000..3b9cc72ec3 --- /dev/null +++ b/src/Core/Components/Base/IStringParsableComponent.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Defines an interface for components with values that can be parsed from a string. +/// +public interface IStringParsableComponent +{ + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + public string ParsingErrorMessage { get; set; } +} diff --git a/src/Core/Components/DateTime/FluentCalendarBase.cs b/src/Core/Components/DateTime/FluentCalendarBase.cs index df0ae1ff8b..0b8a60dfb8 100644 --- a/src/Core/Components/DateTime/FluentCalendarBase.cs +++ b/src/Core/Components/DateTime/FluentCalendarBase.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public abstract class FluentCalendarBase : FluentInputBase +public abstract class FluentCalendarBase : FluentInputBase , ICultureSensitiveComponent, IStringParsableComponent { /// /// Gets or sets the culture of the component. @@ -16,6 +16,10 @@ public abstract class FluentCalendarBase : FluentInputBase [Parameter] public virtual CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + /// + [Parameter] + public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; + /// /// Function to know if a specific day must be disabled. /// diff --git a/src/Core/Components/List/ListComponentBase.razor.cs b/src/Core/Components/List/ListComponentBase.razor.cs index a21bef098b..7ae9a4218a 100644 --- a/src/Core/Components/List/ListComponentBase.razor.cs +++ b/src/Core/Components/List/ListComponentBase.razor.cs @@ -17,7 +17,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// Component that provides a list of options. /// /// -public abstract partial class ListComponentBase : FluentInputBase, IAsyncDisposable where TOption : notnull +public abstract partial class ListComponentBase : FluentInputBase, IAsyncDisposable, IStringParsableComponent where TOption : notnull { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/List/ListComponentBase.razor.js"; @@ -212,6 +212,10 @@ protected string? InternalValue [Parameter] public Expression>>? SelectedOptionsExpression { get; set; } + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + /// protected ListComponentBase() { diff --git a/src/Core/Components/NumberField/FluentNumberField.razor.cs b/src/Core/Components/NumberField/FluentNumberField.razor.cs index 1e4d82771a..9c725eb167 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor.cs +++ b/src/Core/Components/NumberField/FluentNumberField.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentNumberField : FluentInputBase, IAsyncDisposable +public partial class FluentNumberField : FluentInputBase, IAsyncDisposable, IStringParsableComponent { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/TextField/FluentTextField.razor.js"; @@ -99,10 +99,9 @@ public partial class FluentNumberField : FluentInputBase, IAsync [Parameter] public bool UseTypeConstraints { get; set; } - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - public override string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; private static readonly string _stepAttributeValue = GetStepAttributeValue(); diff --git a/src/Core/Components/Radio/FluentRadioGroup.razor.cs b/src/Core/Components/Radio/FluentRadioGroup.razor.cs index 3fdaa59322..d4a5243b0a 100644 --- a/src/Core/Components/Radio/FluentRadioGroup.razor.cs +++ b/src/Core/Components/Radio/FluentRadioGroup.razor.cs @@ -15,7 +15,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// [CascadingTypeParameter(nameof(TValue))] -public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentInputBase +public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentInputBase, IStringParsableComponent { private readonly string _defaultGroupName = Identifier.NewId(); private FluentRadioContext? _context; @@ -40,6 +40,10 @@ public FluentRadioGroup() [CascadingParameter] private FluentRadioContext? CascadedContext { get; set; } + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + /// protected override void OnParametersSet() { diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs index 8197681be3..86ef821410 100644 --- a/src/Core/Components/Rating/FluentRating.razor.cs +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentRating : FluentInputBase +public partial class FluentRating : FluentInputBase, IStringParsableComponent { private bool _updatingCurrentValue = false; private int? _hoverValue = null; @@ -74,10 +74,9 @@ public partial class FluentRating : FluentInputBase [Parameter] public EventCallback OnHoverValueChanged { get; set; } - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - public override string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; /// private string GroupName => Id ?? $"rating-{Id}"; diff --git a/src/Core/Extensions/FluentInputExtensions.cs b/src/Core/Extensions/FluentInputExtensions.cs index f48da289b5..e011d1b72e 100644 --- a/src/Core/Extensions/FluentInputExtensions.cs +++ b/src/Core/Extensions/FluentInputExtensions.cs @@ -10,13 +10,15 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Extensions; internal static class FluentInputExtensions { - public static bool TryParseSelectableValueFromString<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue>( - this FluentInputBase input, string? value, + public static bool TryParseSelectableValueFromString( + this TInput input, string? value, [MaybeNullWhen(false)] out TValue result, - [NotNullWhen(false)] out string? validationErrorMessage) + [NotNullWhen(false)] out string? validationErrorMessage) where TInput : FluentInputBase, IStringParsableComponent { try { + var culture = (input as ICultureSensitiveComponent)?.Culture ?? CultureInfo.CurrentCulture; + // We special-case bool values because BindConverter reserves bool conversion for conditional attributes. if (typeof(TValue) == typeof(bool)) { @@ -34,7 +36,7 @@ internal static class FluentInputExtensions return true; } } - else if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue)) + else if (BindConverter.TryConvertTo(value, culture, out var parsedValue)) { result = parsedValue; validationErrorMessage = null;