From abda901dfbbbfdf66bc94367af18e42f922baadd Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 13 Jan 2026 08:55:58 +0100 Subject: [PATCH 01/10] Store --- .../Components/Forms/Examples/BasicForm.razor | 98 ++++++++++++++ .../Documentation/Components/Forms/Forms.md | 31 +++++ .../FluentUI.Demo.SampleData/Starship.cs | 69 ++++++++++ src/Core/Components/Base/FluentInputBase.cs | 4 + src/Core/Components/Field/FluentField.razor | 12 +- .../Components/Field/FluentField.razor.cs | 99 +++++++++++++- .../Components/Field/FluentField.razor.css | 5 + .../Field/FluentFieldParameterSelector.cs | 4 + src/Core/Components/Field/IFluentField.cs | 6 + .../Forms/FluentValidationMessage.razor | 12 ++ .../Forms/FluentValidationMessage.razor.cs | 123 ++++++++++++++++++ .../Forms/FluentValidationMessage.razor.css | 7 + .../Forms/FluentValidationSummary.razor | 13 ++ .../Forms/FluentValidationSummary.razor.cs | 49 +++++++ .../Forms/FluentValidationSummary.razor.css | 4 + .../Forms/FluentValidationMessageTests.cs | 109 ++++++++++++++++ .../Forms/FluentValidationSummaryTests.cs | 88 +++++++++++++ 17 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md create mode 100644 examples/Tools/FluentUI.Demo.SampleData/Starship.cs create mode 100644 src/Core/Components/Forms/FluentValidationMessage.razor create mode 100644 src/Core/Components/Forms/FluentValidationMessage.razor.cs create mode 100644 src/Core/Components/Forms/FluentValidationMessage.razor.css create mode 100644 src/Core/Components/Forms/FluentValidationSummary.razor create mode 100644 src/Core/Components/Forms/FluentValidationSummary.razor.cs create mode 100644 src/Core/Components/Forms/FluentValidationSummary.razor.css create mode 100644 tests/Core/Components/Forms/FluentValidationMessageTests.cs create mode 100644 tests/Core/Components/Forms/FluentValidationSummaryTests.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor new file mode 100644 index 0000000000..bc5787a2f1 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor @@ -0,0 +1,98 @@ +@page "/basicform-fluentui-components" +@using FluentUI.Demo.SampleData +@using static FluentUI.Demo.SampleData.Olympics2024 + + + +@App.PageTitle("Basic Form FluentUI") + +

Starfleet Starship Database

+

+ This form uses the Fluent UI input components. It uses a `DataAnnotationsValidator`, a `FluentValidationSummary` + and `FluentValidationMessage`s. + + The `EditForm` has the attribute `novalidate="true"` to disable the browser's native validation UI. +

+ +

New Ship Entry Form

+ + + + + + + +
+ + +
+
+ + +
+ @*
+ + +
*@ +
+ + Select classification ... + Exploration + Diplomacy + Defense + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ Submit +
+
+ +
Star Trek, ©1966-2023 CBS Studios, Inc. and Paramount Pictures
+@code { + + + protected override void OnInitialized() + { + starship.ProductionDate = DateTime.Now; + } + + IEnumerable SelectedItems = Array.Empty(); + + + [SupplyParameterFromForm] + private Starship starship { get; set; } = new(); + + private void HandleValidSubmit() + { + Console.WriteLine("HandleValidSubmit called"); + + // Process the valid form + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md new file mode 100644 index 0000000000..34354e7b3c --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md @@ -0,0 +1,31 @@ +--- +title: Forms +route: /Forms +icon: Form +--- + +## Validation +The Fluent UI Razor components work with validation messages in the same way the standard Blazor (input) components do. Two extra components are provided to make it possible to show validation messages that follow the Fluent Design guidelines: + +- FluentValidationSummary +- FluentValidationMessage + +See the [documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/validation?view=aspnetcore-10.0#validation-summary-and-validation-message-components) on the Learn site for more information on the standard components. As the Fluent components are based on the standard components, the same documentation applies + +## Example form with validation + +This is a copy of the example from the standard [Blazor input components documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/input-components?view=aspnetcore-10.0#example-form), implemented with the Fluent UI Blazor components. It uses the FluentValidationSummary and FluentValidationMessage to give feedback on the state of the form. It +uses the same Starship model as the standard docs and a DataAnnotationsValidator to use the data annotations set in the model. + +Not all of the library's input components are used in this form. No data is actually being stored or saved. + +{{ BasicForm }} + + +## API FluentValidationSummary + +{{ API Type=FluentValidationSummary }} + +## API FluentValidationMessage + +{{ API Type=FluentValidationMessage }} diff --git a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs new file mode 100644 index 0000000000..471da2fc53 --- /dev/null +++ b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using static FluentUI.Demo.SampleData.Olympics2024; + +namespace FluentUI.Demo.SampleData; + +/// +/// A class representing a starship with various properties and validation attributes. +/// +[RequiresUnreferencedCode("Necessary because of RangeAttribute usage")] +public class Starship +{ + /// + /// The unique identifier for the starship. + /// + [Required] + [MinLength(3, ErrorMessage = "Identifier is too short")] + [StringLength(16, ErrorMessage = "Identifier too long (16 character limit)")] + public string? Identifier { get; set; } + + /// + /// The description of the starship. + /// + [Required(ErrorMessage = "Description is required")] + [MinLength(10, ErrorMessage = "Description is too short")] + public string? Description { get; set; } + + /// + /// Countries where the starship is registered. + /// + [Required(ErrorMessage = "Countries are required")] + [MinLength(1, ErrorMessage = "Countries are required")] + public IEnumerable? Countries { get; set; } + + /// + /// Classification of the starship. + /// + [Required(ErrorMessage = "A classification is required")] + public string? Classification { get; set; } + + /// + /// Maximum accommodation capacity of the starship. + /// + ///[Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")] + public string? MaximumAccommodation { get; set; } + + /// + /// Indicates whether the starship design has been validated. + /// + [Required] + [Range(typeof(bool), "true", "true", + ErrorMessage = "This form disallows unapproved ships")] + public bool IsValidatedDesign { get; set; } + + /// + /// The production date of the starship. + /// + [Required] + public DateTime? ProductionDate { get; set; } + + /// + /// Gets or sets a value indicating whether the starship is equipped with a teleporter. + /// + public bool HasTeleporter { get; set; } +} diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index f5461a963c..136b365870 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -84,6 +85,9 @@ protected FluentInputBase(LibraryConfiguration configuration) #region IFluentField + /// + LambdaExpression? IFluentField.ValueExpression => ValueExpression; + /// public virtual bool FocusLost { get; protected set; } diff --git a/src/Core/Components/Field/FluentField.razor b/src/Core/Components/Field/FluentField.razor index a0646bc293..487d775445 100644 --- a/src/Core/Components/Field/FluentField.razor +++ b/src/Core/Components/Field/FluentField.razor @@ -42,9 +42,19 @@ else } } + @foreach (var validationMessage in ValidationMessages) + { + + + @CreateIcon(FluentStatus.ErrorIcon) + @validationMessage + + + } + @if (HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true) { - + @CreateIcon(Parameters.MessageIcon) diff --git a/src/Core/Components/Field/FluentField.razor.cs b/src/Core/Components/Field/FluentField.razor.cs index 8d04d20b86..f9adacddf0 100644 --- a/src/Core/Components/Field/FluentField.razor.cs +++ b/src/Core/Components/Field/FluentField.razor.cs @@ -2,7 +2,10 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Linq.Expressions; +using System.Reflection; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -13,9 +16,16 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentField : FluentComponentBase, IFluentField { private readonly string _defaultId = Identifier.NewId(); + private EditContext? _previousEditContext; + private LambdaExpression? _previousFieldAccessor; + private readonly EventHandler? _validationStateChangedHandler; + private FieldIdentifier _fieldIdentifier; /// - public FluentField(LibraryConfiguration configuration) : base(configuration) { } + public FluentField(LibraryConfiguration configuration) : base(configuration) + { + _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + } [Inject] private LibraryConfiguration Configuration { get; set; } = default!; @@ -23,6 +33,7 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } /// protected string? ClassValue => DefaultClassBuilder .AddClass(Configuration.DefaultStyles.FluentFieldClass, when: HasLabel) + .AddClass("invalid", when: ValidationMessages.Any()) .Build(); /// @@ -39,6 +50,12 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } [CascadingParameter(Name = "HideFluentField")] internal bool HideFluentField { get; set; } + /// + /// Gets or sets the for the form. + /// + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + /// /// Gets or sets an existing FieldInput component to use in the field. /// Setting this parameter will define the parameters @@ -49,6 +66,21 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } [Parameter] public IFluentField? InputComponent { get; set; } + /// + /// Gets or sets the for which validation messages should be displayed. + /// If set, this parameter takes precedence over . + /// + [Parameter] + public FieldIdentifier? Field { get; set; } + + /// + /// Gets or sets the field for which validation messages should be displayed. + /// + [Parameter] + public LambdaExpression? For { get; set; } + + LambdaExpression? IFluentField.ValueExpression => For; + /// /// Gets or sets the ID of the FieldInput component to associate with the field. /// @@ -123,6 +155,68 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { } private FluentFieldParameterSelector Parameters => new(this, Localizer); + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (Field != null) + { + _fieldIdentifier = Field.Value; + } + else if (For != null) + { + if (For != _previousFieldAccessor) + { + _fieldIdentifier = CreateFieldIdentifier(For); + _previousFieldAccessor = For; + } + } + else if (InputComponent?.ValueExpression != null) + { + if (InputComponent.ValueExpression != _previousFieldAccessor) + { + _fieldIdentifier = CreateFieldIdentifier(InputComponent.ValueExpression); + _previousFieldAccessor = InputComponent.ValueExpression; + } + } + + if (CurrentEditContext != _previousEditContext) + { + DetachValidationStateChangedListener(); + if (CurrentEditContext != null) + { + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + } + + _previousEditContext = CurrentEditContext; + } + } + + /// + public override ValueTask DisposeAsync() + { + DetachValidationStateChangedListener(); + GC.SuppressFinalize(this); + return base.DisposeAsync(); + } + + private void DetachValidationStateChangedListener() + { + if (_previousEditContext != null) + { + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + } + + private static FieldIdentifier CreateFieldIdentifier(LambdaExpression accessor) + { + var method = typeof(FieldIdentifier).GetMethod("Create", BindingFlags.Public | BindingFlags.Static)!; + return (FieldIdentifier)method.MakeGenericMethod(accessor.ReturnType).Invoke(null, [accessor])!; + } + + private IEnumerable ValidationMessages => CurrentEditContext?.GetValidationMessages(_fieldIdentifier) ?? Enumerable.Empty(); + internal string? GetId(string slot) { // Wrapper of an FieldInput component @@ -156,7 +250,8 @@ private bool HasMessage => !string.IsNullOrWhiteSpace(Parameters.Message) || Parameters.MessageTemplate is not null || Parameters.MessageIcon is not null - || Parameters.MessageState is not null; + || Parameters.MessageState is not null + || ValidationMessages.Any(); private bool HasMessageOrCondition => HasMessage || Parameters.MessageCondition is not null; diff --git a/src/Core/Components/Field/FluentField.razor.css b/src/Core/Components/Field/FluentField.razor.css index f8d571c958..dd693891d9 100644 --- a/src/Core/Components/Field/FluentField.razor.css +++ b/src/Core/Components/Field/FluentField.razor.css @@ -19,3 +19,8 @@ fluent-field div[slot='input'] { min-height: -webkit-fill-available; min-height: -moz-available; } + +fluent-field.invalid fluent-text-input { + border: 1px solid var(--colorPaletteRedBorder2); + border-radius: var(--borderRadiusMedium); +} diff --git a/src/Core/Components/Field/FluentFieldParameterSelector.cs b/src/Core/Components/Field/FluentFieldParameterSelector.cs index 3ca8ab6f8c..8e93857d8d 100644 --- a/src/Core/Components/Field/FluentFieldParameterSelector.cs +++ b/src/Core/Components/Field/FluentFieldParameterSelector.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Localization; @@ -23,6 +24,9 @@ internal FluentFieldParameterSelector(FluentField component, IFluentLocalizer lo _localizer = localizer; } + /// + public LambdaExpression? ValueExpression => _component.For ?? _component.InputComponent?.ValueExpression; + /// public bool HasInputComponent => _component.InputComponent != null; diff --git a/src/Core/Components/Field/IFluentField.cs b/src/Core/Components/Field/IFluentField.cs index 2c25ea9c29..d5afb317de 100644 --- a/src/Core/Components/Field/IFluentField.cs +++ b/src/Core/Components/Field/IFluentField.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -11,6 +12,11 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public interface IFluentField { + /// + /// Gets the that identifies the field to which the component is bound. + /// + LambdaExpression? ValueExpression { get; } + /// /// Gets a value indicating whether the input component has already lost the focus. /// As long as the user has been in this field at least once and has left it, this property remains false. diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor b/src/Core/Components/Forms/FluentValidationMessage.razor new file mode 100644 index 0000000000..8d13d513ca --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationMessage.razor @@ -0,0 +1,12 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase +@typeparam TValue + +@foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier)) +{ +
+ @CreateIcon(new CoreIcons.Filled.Size20.DismissCircle()) + @message +
+} + diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor.cs b/src/Core/Components/Forms/FluentValidationMessage.razor.cs new file mode 100644 index 0000000000..c277a4c03c --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationMessage.razor.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Displays a list of validation messages for a specified field within a cascaded . +/// +public partial class FluentValidationMessage : FluentComponentBase +{ + private EditContext? _previousEditContext; + private Expression>? _previousFieldAccessor; + private readonly EventHandler? _validationStateChangedHandler; + private FieldIdentifier _fieldIdentifier; + + [CascadingParameter] + private EditContext CurrentEditContext { get; set; } = default!; + + /// + /// Gets or sets the for which validation messages should be displayed. + /// If set, this parameter takes precedence over . + /// + [Parameter] + public FieldIdentifier? Field { get; set; } + + /// + /// Gets or sets the field for which validation messages should be displayed. + /// + [Parameter] + public Expression>? For { get; set; } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-validation-message") + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// ` + /// Constructs an instance of . + /// + public FluentValidationMessage(LibraryConfiguration configuration) : base(configuration) + { + _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + } + + /// + protected override void OnParametersSet() + { + if (CurrentEditContext == null) + { + throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + + $"of type {nameof(EditContext)}. For example, you can use {GetType()} inside " + + $"an {nameof(EditForm)}."); + } + + if (Field != null) + { + _fieldIdentifier = Field.Value; + } + else + { + if (For == null) + { + throw new InvalidOperationException($"{GetType()} requires a value for either " + + $"the {nameof(Field)} or {nameof(For)} parameter."); + } + + if (For != _previousFieldAccessor) + { + _fieldIdentifier = FieldIdentifier.Create(For); + _previousFieldAccessor = For; + } + } + + if (CurrentEditContext != _previousEditContext) + { + DetachValidationStateChangedListener(); + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + _previousEditContext = CurrentEditContext; + } + } + + /// + public override ValueTask DisposeAsync() + { + DetachValidationStateChangedListener(); + GC.SuppressFinalize(this); + return base.DisposeAsync(); + } + + private void DetachValidationStateChangedListener() + { + if (_previousEditContext != null) + { + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + } + + internal static RenderFragment? CreateIcon(Icon? icon) + { + if (icon is null) + { + return null; + } + + return builder => + { + builder.OpenComponent(0, typeof(FluentIcon)); + builder.AddAttribute(1, "Value", icon); + builder.AddAttribute(2, "Width", "12px"); + builder.AddAttribute(3, "Margin", "0px 4px 0 0"); + builder.CloseComponent(); + }; + } +} diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor.css b/src/Core/Components/Forms/FluentValidationMessage.razor.css new file mode 100644 index 0000000000..8c9ffb771e --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationMessage.razor.css @@ -0,0 +1,7 @@ +.fluent-validation-message { + display: flex; + color: var(--colorPaletteRedForeground1); + font-size: var(--fontSizeBase200); + align-items: center; + column-gap: var(--spacingHorizontalXS); +} diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor b/src/Core/Components/Forms/FluentValidationSummary.razor new file mode 100644 index 0000000000..e7fc0e5274 --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationSummary.razor @@ -0,0 +1,13 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@inherits ValidationSummary + +@if (_validationMessages is not null && _validationMessages.Any()) +{ + +} diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor.cs b/src/Core/Components/Forms/FluentValidationSummary.razor.cs new file mode 100644 index 0000000000..bf3daac1f0 --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationSummary.razor.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Displays a summary of validation messages for a specified model within a cascaded . +/// +public partial class FluentValidationSummary +{ + private IEnumerable? _validationMessages; + + /// + protected string? ClassValue => new CssBuilder() + .AddClass("fluent-validation-errors") + .Build(); + + /// + protected string? StyleValue => new StyleBuilder() + .AddStyle("color", "var(--colorPaletteRedForeground1)", when: UseErrorTextColor) + .AddStyle("color", "var(--colorNeutralForeground2)", when: !UseErrorTextColor) + .Build(); + + /// + /// Gets or sets a value indicating whether error messages are displayed using a distinct error text color. + /// + [Parameter] + public bool UseErrorTextColor { get; set; } = true; + + /// + /// Gets or sets the for the form. + /// + [CascadingParameter] + public EditContext FluentEditContext { get; set; } = default!; + + /// + protected override void OnInitialized() + { + + _validationMessages = Model is null + ? FluentEditContext.GetValidationMessages() + : FluentEditContext.GetValidationMessages(new FieldIdentifier(Model, string.Empty)); + } +} diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor.css b/src/Core/Components/Forms/FluentValidationSummary.razor.css new file mode 100644 index 0000000000..00edba57cd --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationSummary.razor.css @@ -0,0 +1,4 @@ +.fluent-validation-errors .fluent-validation-message { + display: list-item; + font-size: var(--fontSizeBase300); +} diff --git a/tests/Core/Components/Forms/FluentValidationMessageTests.cs b/tests/Core/Components/Forms/FluentValidationMessageTests.cs new file mode 100644 index 0000000000..3f78bc06b5 --- /dev/null +++ b/tests/Core/Components/Forms/FluentValidationMessageTests.cs @@ -0,0 +1,109 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Forms; + +public class FluentValidationMessageTests : Verify.FluentUITestContext +{ + [Fact] + public void FluentValidationMessage_Throws_When_No_EditContext() + { + // Arrange & Act + Action cut = () => Render>(parameters => parameters + .Add(p => p.For, () => "test") + ); + + // Assert + Assert.Throws(cut); + } + + [Fact] + public void FluentValidationMessage_Throws_When_No_Field_Or_For() + { + // Arrange + var model = new TestModel(); + var editContext = new EditContext(model); + + // Act + Action cut = () => Render>(parameters => parameters + .AddCascadingValue(editContext) + ); + + // Assert + Assert.Throws(cut); + } + + [Fact] + public async Task FluentValidationMessage_Renders_Validation_Messages() + { + // Arrange + var model = new TestModel { Name = "" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + // Act + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, () => model.Name) + ); + + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is required"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + var message = cut.Find(".validation-message"); + Assert.NotNull(message); + Assert.Contains("Name is required", message.InnerHtml); + Assert.NotNull(cut.Find("svg")); + } + + [Fact] + public async Task FluentValidationMessage_Updates_When_Validation_State_Changes() + { + // Arrange + var model = new TestModel { Name = "Initial" }; + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + var cut = Render>(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.For, () => model.Name) + ); + + // Act + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is invalid"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + Assert.Contains("Name is invalid", cut.Markup); + + // Act: Clear messages + await cut.InvokeAsync(() => + { + messageStore.Clear(); + editContext.NotifyValidationStateChanged(); + }); + + // Assert: No messages should be rendered + Assert.DoesNotContain("Name is invalid", cut.Markup); + Assert.Empty(cut.FindAll(".validation-message")); + } + + private class TestModel + { + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + } +} diff --git a/tests/Core/Components/Forms/FluentValidationSummaryTests.cs b/tests/Core/Components/Forms/FluentValidationSummaryTests.cs new file mode 100644 index 0000000000..08390d2f5c --- /dev/null +++ b/tests/Core/Components/Forms/FluentValidationSummaryTests.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Forms; + +public class FluentValidationSummaryTests : Verify.FluentUITestContext +{ + [Fact] + public async Task FluentValidationSummary_Renders_Validation_Messages() + { + // Arrange + var model = new TestModel(); + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is required"); + + // Act + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + ); + + // Assert + var messages = cut.FindAll(".validation-message"); + Assert.Single(messages); + Assert.Contains("Name is required", messages[0].InnerHtml); + } + + [Fact] + public async Task FluentValidationSummary_Filters_By_Model() + { + // Arrange + var model1 = new TestModel(); + var model2 = new TestModel(); + var editContext = new EditContext(model1); + var messageStore = new ValidationMessageStore(editContext); + + messageStore.Add(new FieldIdentifier(model1, string.Empty), "Model1 error"); + messageStore.Add(new FieldIdentifier(model2, string.Empty), "Model2 error"); + + // Act + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.Model, model1) + ); + + // Assert + var messages = cut.FindAll(".validation-message"); + Assert.Single(messages); + Assert.Contains("Model1 error", messages[0].InnerHtml); + } + + [Fact] + public async Task FluentValidationSummary_Updates_When_Validation_State_Changes() + { + // Arrange + var model = new TestModel(); + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + ); + + // Act + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is invalid"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + var messages = cut.FindAll(".validation-message"); + Assert.Single(messages); + Assert.Contains("Name is invalid", messages[0].InnerHtml); + } + + private class TestModel + { + public string Name { get; set; } = string.Empty; + } +} From 749dce94413e6fc9cb882c713c20d7eb3c6585c0 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 13 Jan 2026 08:55:58 +0100 Subject: [PATCH 02/10] Add FluentValidationSummary --- .../Components/Forms/Examples/BasicForm.razor | 88 ++++++++++++++++++ .../Documentation/Components/Forms/Forms.md | 27 ++++++ .../FluentUI.Demo.SampleData/Starship.cs | 69 ++++++++++++++ .../Components/Field/FluentField.razor.css | 8 -- .../Forms/FluentValidationSummary.razor | 13 +++ .../Forms/FluentValidationSummary.razor.cs | 49 ++++++++++ .../Forms/FluentValidationSummary.razor.css | 4 + ...soft.FluentUI.AspNetCore.Components.csproj | 3 + .../Forms/FluentValidationSummaryTests.cs | 91 +++++++++++++++++++ 9 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md create mode 100644 examples/Tools/FluentUI.Demo.SampleData/Starship.cs create mode 100644 src/Core/Components/Forms/FluentValidationSummary.razor create mode 100644 src/Core/Components/Forms/FluentValidationSummary.razor.cs create mode 100644 src/Core/Components/Forms/FluentValidationSummary.razor.css create mode 100644 tests/Core/Components/Forms/FluentValidationSummaryTests.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor new file mode 100644 index 0000000000..e938e8cf0d --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor @@ -0,0 +1,88 @@ +@page "/basicform-fluentui-components" +@using FluentUI.Demo.SampleData +@using static FluentUI.Demo.SampleData.Olympics2024 + + + +@App.PageTitle("Basic Form FluentUI") + +

Starfleet Starship Database

+

+ This form uses the Fluent UI input components. It uses a `DataAnnotationsValidator` and `FluentValidationSummary`. + + On the `EditForm` component, the `novalidate="true"` attribute is set to disable the browser's native validation UI, allowing the Fluent UI components to handle validation feedback. +

+ +

New Ship Entry Form

+ + + + + + + +
+ +
+
+ +
+ @*
+ +
*@ +
+ + Select classification ... + Exploration + Diplomacy + Defense + +
+
+ +
+
+ +
+
+ +
+
+ +
+ Submit +
+
+ +
Star Trek, ©1966-2023 CBS Studios, Inc. and Paramount Pictures
+@code { + + + protected override void OnInitialized() + { + starship.ProductionDate = DateTime.Now; + } + + IEnumerable SelectedItems = Array.Empty(); + + + [SupplyParameterFromForm] + private Starship starship { get; set; } = new(); + + private void HandleValidSubmit() + { + Console.WriteLine("HandleValidSubmit called"); + // Processing the valid form is not implemented for demo purposes + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md new file mode 100644 index 0000000000..c1b4086f13 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md @@ -0,0 +1,27 @@ +--- +title: Forms +route: /Forms +icon: Form +--- + +## Validation +The Fluent UI Razor components work with a validation summary in the same way the standard Blazor (input) components do. An extra components is provided to make it possible to show a validation summary that follow the Fluent Design guidelines: + +- FluentValidationSummary + +See the [documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/validation?view=aspnetcore-10.0#validation-summary-and-validation-message-components) on the Learn site for more information on the standard components. As the Fluent component is based on the standard component, the same documentation applies + +## Example form with validation + +This is a copy of the example from the standard [Blazor input components documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/input-components?view=aspnetcore-10.0#example-form), implemented with the Fluent UI Blazor components. It uses the `FluentValidationSummary` to give feedback on the state of the form. It +uses the same `Starship` model as the standard docs and a DataAnnotationsValidator to use the data annotations set in the model. + +Not all of the library's input components are used in this form. No data is actually being stored or saved. + +{{ BasicForm }} + + +## API FluentValidationSummary + +{{ API Type=FluentValidationSummary }} + diff --git a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs new file mode 100644 index 0000000000..471da2fc53 --- /dev/null +++ b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using static FluentUI.Demo.SampleData.Olympics2024; + +namespace FluentUI.Demo.SampleData; + +/// +/// A class representing a starship with various properties and validation attributes. +/// +[RequiresUnreferencedCode("Necessary because of RangeAttribute usage")] +public class Starship +{ + /// + /// The unique identifier for the starship. + /// + [Required] + [MinLength(3, ErrorMessage = "Identifier is too short")] + [StringLength(16, ErrorMessage = "Identifier too long (16 character limit)")] + public string? Identifier { get; set; } + + /// + /// The description of the starship. + /// + [Required(ErrorMessage = "Description is required")] + [MinLength(10, ErrorMessage = "Description is too short")] + public string? Description { get; set; } + + /// + /// Countries where the starship is registered. + /// + [Required(ErrorMessage = "Countries are required")] + [MinLength(1, ErrorMessage = "Countries are required")] + public IEnumerable? Countries { get; set; } + + /// + /// Classification of the starship. + /// + [Required(ErrorMessage = "A classification is required")] + public string? Classification { get; set; } + + /// + /// Maximum accommodation capacity of the starship. + /// + ///[Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")] + public string? MaximumAccommodation { get; set; } + + /// + /// Indicates whether the starship design has been validated. + /// + [Required] + [Range(typeof(bool), "true", "true", + ErrorMessage = "This form disallows unapproved ships")] + public bool IsValidatedDesign { get; set; } + + /// + /// The production date of the starship. + /// + [Required] + public DateTime? ProductionDate { get; set; } + + /// + /// Gets or sets a value indicating whether the starship is equipped with a teleporter. + /// + public bool HasTeleporter { get; set; } +} diff --git a/src/Core/Components/Field/FluentField.razor.css b/src/Core/Components/Field/FluentField.razor.css index f8d571c958..211553c33c 100644 --- a/src/Core/Components/Field/FluentField.razor.css +++ b/src/Core/Components/Field/FluentField.razor.css @@ -11,11 +11,3 @@ fluent-field[label-position='before'] > fluent-label[slot='label'] { fluent-field label[disabled] { color: var(--colorNeutralForegroundDisabled); } - -/* To use with input element without specifi width/height */ -fluent-field div[slot='input'] { - min-width: -webkit-fill-available; - min-width: -moz-available; - min-height: -webkit-fill-available; - min-height: -moz-available; -} diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor b/src/Core/Components/Forms/FluentValidationSummary.razor new file mode 100644 index 0000000000..5947a1ef1b --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationSummary.razor @@ -0,0 +1,13 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@inherits ValidationSummary + +@if (_validationMessages is not null && _validationMessages.Any()) +{ + +} diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor.cs b/src/Core/Components/Forms/FluentValidationSummary.razor.cs new file mode 100644 index 0000000000..bf3daac1f0 --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationSummary.razor.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Displays a summary of validation messages for a specified model within a cascaded . +/// +public partial class FluentValidationSummary +{ + private IEnumerable? _validationMessages; + + /// + protected string? ClassValue => new CssBuilder() + .AddClass("fluent-validation-errors") + .Build(); + + /// + protected string? StyleValue => new StyleBuilder() + .AddStyle("color", "var(--colorPaletteRedForeground1)", when: UseErrorTextColor) + .AddStyle("color", "var(--colorNeutralForeground2)", when: !UseErrorTextColor) + .Build(); + + /// + /// Gets or sets a value indicating whether error messages are displayed using a distinct error text color. + /// + [Parameter] + public bool UseErrorTextColor { get; set; } = true; + + /// + /// Gets or sets the for the form. + /// + [CascadingParameter] + public EditContext FluentEditContext { get; set; } = default!; + + /// + protected override void OnInitialized() + { + + _validationMessages = Model is null + ? FluentEditContext.GetValidationMessages() + : FluentEditContext.GetValidationMessages(new FieldIdentifier(Model, string.Empty)); + } +} diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor.css b/src/Core/Components/Forms/FluentValidationSummary.razor.css new file mode 100644 index 0000000000..00edba57cd --- /dev/null +++ b/src/Core/Components/Forms/FluentValidationSummary.razor.css @@ -0,0 +1,4 @@ +.fluent-validation-errors .fluent-validation-message { + display: list-item; + font-size: var(--fontSizeBase300); +} diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index d96bf7ad2e..f0a521428f 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -153,4 +153,7 @@ CS1591 + + + diff --git a/tests/Core/Components/Forms/FluentValidationSummaryTests.cs b/tests/Core/Components/Forms/FluentValidationSummaryTests.cs new file mode 100644 index 0000000000..43ff208ee5 --- /dev/null +++ b/tests/Core/Components/Forms/FluentValidationSummaryTests.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Forms; + +public class FluentValidationSummaryTests : Verify.FluentUITestContext +{ + [Fact] + public async Task FluentValidationSummary_Renders_Validation_Messages() + { + // Arrange + var model = new TestModel(); + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is required"); + + // Act + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + ); + + // Assert + var messages = cut.FindAll(".fluent-validation-message"); + Assert.Single(messages); + Assert.Contains("Name is required", messages[0].InnerHtml); + } + + [Fact] + public async Task FluentValidationSummary_Filters_By_Model() + { + // Arrange + var model1 = new TestModel(); + var model2 = new TestModel(); + var editContext = new EditContext(model1); + var messageStore = new ValidationMessageStore(editContext); + + messageStore.Add(new FieldIdentifier(model1, string.Empty), "Model1 error"); + messageStore.Add(new FieldIdentifier(model2, string.Empty), "Model2 error"); + + // Act + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + .Add(p => p.Model, model1) + ); + + // Assert + var messages = cut.FindAll(".fluent-validation-message"); + Assert.Single(messages); + Assert.Contains("Model1 error", messages[0].InnerHtml); + } + + [Fact] + public async Task FluentValidationSummary_Updates_When_Validation_State_Changes() + { + // Arrange + var model = new TestModel(); + var editContext = new EditContext(model); + var messageStore = new ValidationMessageStore(editContext); + + var cut = Render(parameters => parameters + .AddCascadingValue(editContext) + ); + + // Act + var messages = cut.FindAll(".fluent-validation-message"); + Assert.Empty(messages); + + await cut.InvokeAsync(() => + { + messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is invalid"); + editContext.NotifyValidationStateChanged(); + }); + + // Assert + messages = cut.FindAll(".fluent-validation-message"); + Assert.Single(messages); + Assert.Contains("Name is invalid", messages[0].InnerHtml); + } + + private class TestModel + { + public string Name { get; set; } = string.Empty; + } +} From 3d7774b2161d1fd16ecfd5a30de20f5e4243790f Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Thu, 15 Jan 2026 12:24:10 +0100 Subject: [PATCH 03/10] Update form and class Move form code to codebehind Process review comments --- .../Components/Forms/Examples/BasicForm.razor | 37 +++++-------------- .../Forms/Examples/BasicForm.razor.cs | 24 ++++++++++++ .../Documentation/Components/Forms/Forms.md | 2 +- .../FluentUI.Demo.SampleData/Starship.cs | 3 +- .../Forms/FluentValidationSummary.razor | 4 +- .../Forms/FluentValidationSummary.razor.cs | 19 +++------- .../Forms/FluentValidationSummaryTests.cs | 4 +- 7 files changed, 46 insertions(+), 47 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor index e938e8cf0d..6294e029d5 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor @@ -15,7 +15,7 @@

New Ship Entry Form

- + @@ -25,7 +25,7 @@
- +
@*
*@
- - Select classification ... - Exploration - Diplomacy - Defense + + Select classification ... + Exploration + Diplomacy + Defense
- +
@@ -59,30 +59,11 @@
- +
Submit
Star Trek, ©1966-2023 CBS Studios, Inc. and Paramount Pictures
-@code { - - protected override void OnInitialized() - { - starship.ProductionDate = DateTime.Now; - } - - IEnumerable SelectedItems = Array.Empty(); - - - [SupplyParameterFromForm] - private Starship starship { get; set; } = new(); - - private void HandleValidSubmit() - { - Console.WriteLine("HandleValidSubmit called"); - // Processing the valid form is not implemented for demo purposes - } -} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.cs b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.cs new file mode 100644 index 0000000000..dbf18cf518 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using FluentUI.Demo.SampleData; + +namespace FluentUI.Demo.Client.Documentation.Components.Forms.Examples; + +public partial class BasicForm +{ + //private readonly IEnumerable SelectedItems = Array.Empty(); + private Starship starship { get; set; } = new(); + + protected override void OnInitialized() + { + starship.ProductionDate = System.DateTime.Now; + } + + private static void HandleValidSubmit() + { + Console.WriteLine("HandleValidSubmit called"); + // Processing the valid form is not implemented for demo purposes + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md index c1b4086f13..b190b5a7ca 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md @@ -5,7 +5,7 @@ icon: Form --- ## Validation -The Fluent UI Razor components work with a validation summary in the same way the standard Blazor (input) components do. An extra components is provided to make it possible to show a validation summary that follow the Fluent Design guidelines: +The Fluent UI Razor components work with a validation summary in the same way the standard Blazor (input) components do. An extra component is provided to make it possible to show a validation summary that follows the Fluent Design guidelines: - FluentValidationSummary diff --git a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs index 471da2fc53..4f8b0871ba 100644 --- a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs +++ b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs @@ -45,7 +45,8 @@ public class Starship /// /// Maximum accommodation capacity of the starship. /// - ///[Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")] + [Required(ErrorMessage = "Maximum accommodation is required")] + [Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")] public string? MaximumAccommodation { get; set; } /// diff --git a/src/Core/Components/Forms/FluentValidationSummary.razor b/src/Core/Components/Forms/FluentValidationSummary.razor index 5947a1ef1b..0949ab4f97 100644 --- a/src/Core/Components/Forms/FluentValidationSummary.razor +++ b/src/Core/Components/Forms/FluentValidationSummary.razor @@ -2,10 +2,10 @@ @using Microsoft.AspNetCore.Components.Forms @inherits ValidationSummary -@if (_validationMessages is not null && _validationMessages.Any()) +@if (ValidationMessages.Any()) {