diff --git a/.github/instructions/blazor.instructions.md b/.github/instructions/blazor.instructions.md
new file mode 100644
index 0000000000..9bc52fb2c4
--- /dev/null
+++ b/.github/instructions/blazor.instructions.md
@@ -0,0 +1,77 @@
+---
+description: 'Blazor component and application patterns'
+applyTo: '**/*.razor, **/*.razor.cs, **/*.razor.css'
+---
+
+## Blazor Code Style and Structure
+
+- Write idiomatic and efficient Blazor and C# code.
+- Follow .NET and Blazor conventions.
+- Use Razor Components appropriately for component-based UI development.
+- Prefer inline functions for smaller components but separate complex logic into code-behind or service classes.
+- Async/await should be used where applicable to ensure non-blocking UI operations.
+
+## Naming Conventions
+
+- Follow PascalCase for component names, method names, and public members.
+- Use camelCase for private fields and local variables.
+- Prefix interface names with "I" (e.g., IUserService).
+
+## Blazor and .NET Specific Guidelines
+
+- Utilize Blazor's built-in features for component lifecycle (e.g., OnInitializedAsync, OnParametersSetAsync).
+- Use data binding effectively with @bind.
+- Leverage Dependency Injection for services in Blazor.
+- Structure Blazor components and services following Separation of Concerns.
+- Always use the latest version C#, currently C# 13 features like record types, pattern matching, and global usings.
+
+## Error Handling and Validation
+
+- Implement proper error handling for Blazor pages and API calls.
+- Use logging for error tracking in the backend and consider capturing UI-level errors in Blazor with tools like ErrorBoundary.
+- Implement validation using FluentValidation or DataAnnotations in forms.
+
+## Blazor API and Performance Optimization
+
+- Utilize Blazor server-side or WebAssembly optimally based on the project requirements.
+- Use asynchronous methods (async/await) for API calls or UI actions that could block the main thread.
+- Optimize Razor components by reducing unnecessary renders and using StateHasChanged() efficiently.
+- Minimize the component render tree by avoiding re-renders unless necessary, using ShouldRender() where appropriate.
+- Use EventCallbacks for handling user interactions efficiently, passing only minimal data when triggering events.
+
+## Caching Strategies
+
+- Implement in-memory caching for frequently used data, especially for Blazor Server apps. Use IMemoryCache for lightweight caching solutions.
+- For Blazor WebAssembly, utilize localStorage or sessionStorage to cache application state between user sessions.
+- Consider Distributed Cache strategies (like Redis or SQL Server Cache) for larger applications that need shared state across multiple users or clients.
+- Cache API calls by storing responses to avoid redundant calls when data is unlikely to change, thus improving the user experience.
+
+## State Management Libraries
+
+- Use Blazor's built-in Cascading Parameters and EventCallbacks for basic state sharing across components.
+- Implement advanced state management solutions using libraries like Fluxor or BlazorState when the application grows in complexity.
+- For client-side state persistence in Blazor WebAssembly, consider using Blazored.LocalStorage or Blazored.SessionStorage to maintain state between page reloads.
+- For server-side Blazor, use Scoped Services and the StateContainer pattern to manage state within user sessions while minimizing re-renders.
+
+## API Design and Integration
+
+- Use HttpClient or other appropriate services to communicate with external APIs or your own backend.
+- Implement error handling for API calls using try-catch and provide proper user feedback in the UI.
+
+## Testing and Debugging in Visual Studio
+
+- All unit testing and integration testing should be done in Visual Studio Enterprise.
+- Test Blazor components and services using bUnit and xUnit.
+- Debug Blazor UI issues using browser developer tools and Visual Studio's debugging tools for backend and server-side issues.
+- For performance profiling and optimization, rely on Visual Studio's diagnostics tools.
+- Do not use grep to search file contents. Use the Select-String PowerShell cmdlet or Visual Studio's built-in search functionality.
+
+## Security and Authentication
+
+- Implement Authentication and Authorization in the Blazor app where necessary using ASP.NET Identity or JWT tokens for API authentication.
+- Use HTTPS for all web communication and ensure proper CORS policies are implemented.
+
+## API Documentation and Swagger
+
+- Use Swagger/OpenAPI for API documentation for your backend API services.
+- Ensure XML documentation for models and API methods for enhancing Swagger documentation.
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor
index 4fa04b3004..2753490da1 100644
--- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldMessageMultipleConditions.razor
@@ -2,6 +2,7 @@
TextInputType="TextInputType.Password"
@bind-Value="@MyValue"
Immediate="true"
+ Required="true"
MessageCondition="@(i => i.When(() => MyValue.Length < 8)
.Display("8+ characters", MessageState.Error)
.When(() => MyValue.Any(char.IsDigit) == false)
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor
index a4ba4b3896..c456288dd3 100644
--- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Field/Examples/FieldStatesExample.razor
@@ -1,19 +1,46 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+@code {
+ [SupplyParameterFromForm]
+ private Model model { get; set; } = new();
+
+ private void HandleValidSubmit()
+ {
+ Console.WriteLine("HandleValidSubmit called");
+
+ // Process the valid form
+ }
+
+ private class Model
+ {
+ public string? Text { get; set; } = string.Empty;
+ }
+}
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 32b0d265eb..1bed569009 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
@@ -8,7 +8,8 @@
Starfleet Starship Database
- This form uses the Fluent UI input components. It uses a `DataAnnotationsValidator` and `FluentValidationSummary`.
+ This form uses the Fluent UI input components. It uses a `DataAnnotationsValidator`, a `FluentValidationSummary`
+ and `FluentValidationMessage`s.
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.
@@ -16,39 +17,48 @@
New Ship Entry Form
-
-
-
-
-
-
-
-
-
-
-
-
-
- Select classification ...
- Exploration
- Diplomacy
- Defense
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Submit
-
+
+
+
+
+
+
+
+ @* *@
+
+
+
+ @* *@
+
+
+
+
+
+
+ Select classification ...
+ Exploration
+ Diplomacy
+ Defense
+
+ @* *@
+
+
+
+ @* *@
+
+
+
+ @* *@
+
+
+
+ @* *@
+
+
+
+
+ Submit
+
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.css b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.css
new file mode 100644
index 0000000000..174ebda420
--- /dev/null
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Examples/BasicForm.razor.css
@@ -0,0 +1,4 @@
+form div:not(.fluent-stack-vertical) {
+ display: inline-flex;
+ margin-bottom: 1rem;
+}
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 b190b5a7ca..69ed74239a 100644
--- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Forms/Forms.md
@@ -8,6 +8,7 @@ icon: Form
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
+- 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 component is based on the standard component, the same documentation applies
@@ -25,3 +26,6 @@ Not all of the library's input components are used in this form. No data is actu
{{ 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
index 4f8b0871ba..d5189a7704 100644
--- a/examples/Tools/FluentUI.Demo.SampleData/Starship.cs
+++ b/examples/Tools/FluentUI.Demo.SampleData/Starship.cs
@@ -22,6 +22,14 @@ public class Starship
[StringLength(16, ErrorMessage = "Identifier too long (16 character limit)")]
public string? Identifier { get; set; }
+ ///
+ /// The unique identifier for the starship.
+ ///
+ [Required]
+ [MinLength(8, ErrorMessage = "Password is too short")]
+ [RegularExpression(@"[A-Z][a-z]{\d}+", ErrorMessage = "Password must contain at least one uppercase letter, one lowercase letter and one digit")]
+ public string? Password { get; set; }
+
///
/// The description of the starship.
///
@@ -46,7 +54,7 @@ public class Starship
/// Maximum accommodation capacity of the starship.
///
[Required(ErrorMessage = "Maximum accommodation is required")]
- [Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")]
+ //[Range(1, 100000, ErrorMessage = "Accommodation invalid (1-100000)")]
public string? MaximumAccommodation { get; set; }
///
diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs
index f5461a963c..8d39207631 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; }
@@ -241,6 +245,8 @@ public virtual async ValueTask DisposeAsync()
_cachedServices?.DisposeTooltipAsync(this);
_cachedServices?.Dispose();
await JSModule.DisposeAsync();
+
+ GC.SuppressFinalize(this);
}
///
diff --git a/src/Core/Components/Field/Condition/FluentFieldCondition.cs b/src/Core/Components/Field/Condition/FluentFieldCondition.cs
index 3f59d8ece0..e2b4230174 100644
--- a/src/Core/Components/Field/Condition/FluentFieldCondition.cs
+++ b/src/Core/Components/Field/Condition/FluentFieldCondition.cs
@@ -76,23 +76,20 @@ public bool Build(FluentFieldConditionOptions? options = null)
{
if (item.Condition.Invoke())
{
- var coloredMessage = FluentFieldParameterSelector.StateToMessageTemplate(item.State, item.Message);
-
messages.Add(builder =>
{
- builder.OpenElement(0, "div");
- builder.AddContent(1, FluentField.CreateIcon(item.Icon ?? FluentFieldParameterSelector.StateToIcon(item.State)));
-
- if (item.State is null)
- {
- builder.AddContent(2, item.Message);
- }
- else
+ builder.OpenComponent(0);
+ builder.AddComponentParameter(1, "As", TextTag.Span);
+ builder.AddComponentParameter(2, "Color", item.State == MessageState.Error ? Color.Error : Color.Info);
+ //builder.AddAttribute(4, "slot", "message");
+ builder.AddAttribute(5, "style", "display: flex; align-items: center;");
+ builder.AddAttribute(6, "ChildContent", (RenderFragment)(contentBuilder =>
{
- builder.AddContent(2, coloredMessage);
- }
+ contentBuilder.AddContent(0, FluentField.CreateIcon(item.Icon ?? FluentFieldParameterCollector.StateToIcon(item.State)));
+ contentBuilder.AddContent(1, item.Message);
+ }));
- builder.CloseElement();
+ builder.CloseComponent();
});
}
}
diff --git a/src/Core/Components/Field/FluentField.razor b/src/Core/Components/Field/FluentField.razor
index a0646bc293..0d111bfd7d 100644
--- a/src/Core/Components/Field/FluentField.razor
+++ b/src/Core/Components/Field/FluentField.razor
@@ -4,60 +4,78 @@
@if (HideFluentField)
{
- @ChildContent
+ @ChildContent
}
else
{
-
-
- @if (HasLabel)
- {
-
- @Parameters.Label
- @Parameters.LabelTemplate
-
- }
-
- @if (ChildContent is not null)
- {
- @if (Parameters.HasInputComponent || !IncludeInputSlot)
- {
- @ChildContent
- }
- else
- {
-
- @ChildContent
-
- }
- }
-
- @if (HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true)
- {
-
-
- @CreateIcon(Parameters.MessageIcon)
-
- @if (Parameters.MessageTemplate is not null)
- {
- @Parameters.MessageTemplate
- }
- else
- {
- @Parameters.Message
- }
-
- }
-
-
+
+
+ @if (HasLabel)
+ {
+
+ @Parameters.Label
+ @Parameters.LabelTemplate
+
+ }
+
+ @if (ChildContent is not null)
+ {
+ @if (Parameters.HasInputComponent || !IncludeInputSlot)
+ {
+ @ChildContent
+ }
+ else
+ {
+
+ @ChildContent
+
+ }
+ }
+
+ @if (ValidationMessages.Where(x => !string.Equals(x, Parameters.Message, StringComparison.Ordinal)).Any() ||
+ HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true)
+ {
+
+ @foreach (var validationMessage in ValidationMessages)
+ {
+ if (!string.Equals(validationMessage, Parameters.Message, StringComparison.Ordinal))
+ {
+
+
+ @CreateIcon(FluentStatus.ErrorIcon)
+ @validationMessage
+
+ }
+ }
+
+ @if (HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true)
+ {
+ @if (Parameters.MessageTemplate is not null)
+ {
+ @Parameters.MessageTemplate
+ }
+ else
+ {
+
+
+ @CreateIcon(Parameters.MessageIcon)
+ @Parameters.Message
+
+ }
+ }
+
+ }
+
+
+
}
diff --git a/src/Core/Components/Field/FluentField.razor.cs b/src/Core/Components/Field/FluentField.razor.cs
index 8d04d20b86..892b08de0f 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.
///
@@ -121,7 +153,69 @@ public FluentField(LibraryConfiguration configuration) : base(configuration) { }
[Parameter]
public FieldSize? Size { get; set; }
- private FluentFieldParameterSelector Parameters => new(this, Localizer);
+ private FluentFieldParameterCollector 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) ?? [];
internal string? GetId(string slot)
{
@@ -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 211553c33c..378ae7d529 100644
--- a/src/Core/Components/Field/FluentField.razor.css
+++ b/src/Core/Components/Field/FluentField.razor.css
@@ -11,3 +11,7 @@ fluent-field[label-position='before'] > fluent-label[slot='label'] {
fluent-field label[disabled] {
color: var(--colorNeutralForegroundDisabled);
}
+
+fluent-field [slot="message"] {
+ display: block;
+}
diff --git a/src/Core/Components/Field/FluentFieldParameterSelector.cs b/src/Core/Components/Field/FluentFieldParameterCollector.cs
similarity index 68%
rename from src/Core/Components/Field/FluentFieldParameterSelector.cs
rename to src/Core/Components/Field/FluentFieldParameterCollector.cs
index 3ca8ab6f8c..fdf7fe6e71 100644
--- a/src/Core/Components/Field/FluentFieldParameterSelector.cs
+++ b/src/Core/Components/Field/FluentFieldParameterCollector.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;
@@ -11,18 +12,21 @@ namespace Microsoft.FluentUI.AspNetCore.Components;
/// Helper class for selecting parameters for a FluentField,
/// from the component itself or from an existing FieldInput component.
///
-internal class FluentFieldParameterSelector : IFluentField
+internal class FluentFieldParameterCollector : IFluentField
{
private readonly FluentField _component;
private readonly IFluentLocalizer _localizer;
///
- internal FluentFieldParameterSelector(FluentField component, IFluentLocalizer localizer)
+ internal FluentFieldParameterCollector(FluentField component, IFluentLocalizer localizer)
{
_component = component;
_localizer = localizer;
}
+ ///
+ public LambdaExpression? ValueExpression => _component.For ?? _component.InputComponent?.ValueExpression;
+
///
public bool HasInputComponent => _component.InputComponent != null;
@@ -92,14 +96,14 @@ public Icon? MessageIcon
///
public RenderFragment? MessageTemplate
{
- get => _component.MessageTemplate ?? _component.InputComponent?.MessageTemplate ?? StateToMessageTemplate(MessageState, Message);
+ get => _component.MessageTemplate ?? _component.InputComponent?.MessageTemplate; //?? StateToMessageTemplate(MessageState, Message);
set => throw new NotSupportedException();
}
///
public Func? MessageCondition
{
- get => _component.MessageCondition ?? _component.InputComponent?.MessageCondition ?? FluentFieldCondition.Always;
+ get => _component.MessageCondition ?? _component.InputComponent?.MessageCondition ?? FluentFieldCondition.Never;
set => throw new NotSupportedException();
}
@@ -118,7 +122,7 @@ public MessageState? MessageState
Components.MessageState.Success => FluentStatus.SuccessIcon,
Components.MessageState.Error => FluentStatus.ErrorIcon,
Components.MessageState.Warning => FluentStatus.WarningIcon,
- _ => null
+ _ => null,
};
}
@@ -130,49 +134,7 @@ public MessageState? MessageState
Components.MessageState.Success => localizer[LanguageResource.Field_SuccessMessage],
Components.MessageState.Error => localizer[LanguageResource.Field_ErrorMessage],
Components.MessageState.Warning => localizer[LanguageResource.Field_WarningMessage],
- _ => null
- };
- }
-
- ///
- internal static RenderFragment? StateToMessageTemplate(MessageState? state, string? message)
- {
- return state switch
- {
- Components.MessageState.Success => builder =>
- {
- builder.OpenComponent(0, typeof(FluentText));
- builder.AddAttribute(1, "ChildContent", (RenderFragment)(contentBuilder =>
- {
- contentBuilder.AddContent(0, message);
- }));
- builder.AddAttribute(2, "Color", Color.Success);
- builder.CloseComponent();
- }
- ,
- Components.MessageState.Error => builder =>
- {
- builder.OpenComponent(0, typeof(FluentText));
- builder.AddAttribute(1, "ChildContent", (RenderFragment)(contentBuilder =>
- {
- contentBuilder.AddContent(0, message);
- }));
- builder.AddAttribute(2, "Color", Color.Error);
- builder.CloseComponent();
- }
- ,
- Components.MessageState.Warning => builder =>
- {
- builder.OpenComponent(0, typeof(FluentText));
- builder.AddAttribute(1, "ChildContent", (RenderFragment)(contentBuilder =>
- {
- contentBuilder.AddContent(0, message);
- }));
- builder.AddAttribute(2, "Color", Color.Info);
- builder.CloseComponent();
- }
- ,
- _ => null
+ _ => null,
};
}
}
diff --git a/src/Core/Components/Field/IFluentField.cs b/src/Core/Components/Field/IFluentField.cs
index 2c25ea9c29..0a06b9b1a2 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.
@@ -76,3 +82,4 @@ public interface IFluentField
///
MessageState? MessageState { get; set; }
}
+
diff --git a/src/Core/Components/Field/Readme.md b/src/Core/Components/Field/Readme.md
index 2d0aa3e630..0b4e0148ad 100644
--- a/src/Core/Components/Field/Readme.md
+++ b/src/Core/Components/Field/Readme.md
@@ -1,13 +1,13 @@
# FluentField
-The `FluentField` component is a wrapper around a input component that add a label and a message to the input.
+The `FluentField` component is a wrapper around an input component that adds a label and a message to the input.
-> **Note:** The `InputComponent` attribute should only be set with FluentUI-Blazor components.
-> Don't use it in standalone mode.
+> **Note:** The `InputComponent` attribute should only be set with Fluent UI Blazor components.
+> Do not use this in standalone mode.
-## Usage in FluentUI-Blazor lib
+## Usage in the Fluent UI Blazor library
-You can use it by enclosing your component in a FluentField like this.
+You can use the component by enclosing your component in a FluentField like this.
You need to specify the `InputComponent="@this"`, `ForId="@Id"`, `Class="@ClassValue"` and `Style="@StyleValue"`
properties for the component to work correctly.
diff --git a/src/Core/Components/Forms/FluentValidationMessage.razor b/src/Core/Components/Forms/FluentValidationMessage.razor
new file mode 100644
index 0000000000..a53a466b55
--- /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(Icon)
+ @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..132a96b89c
--- /dev/null
+++ b/src/Core/Components/Forms/FluentValidationMessage.razor.cs
@@ -0,0 +1,130 @@
+// ------------------------------------------------------------------------
+// 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; }
+
+ ///
+ /// Gets or sets the icon to be displayed with the validation message.
+ /// The default is .
+ ///
+ [Parameter]
+ public Icon? Icon { get; set; } = new CoreIcons.Filled.Size20.DismissCircle();
+
+ ///
+ 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/TextInput/FluentTextInput.razor.cs b/src/Core/Components/TextInput/FluentTextInput.razor.cs
index cfe39651b9..9e8d837deb 100644
--- a/src/Core/Components/TextInput/FluentTextInput.razor.cs
+++ b/src/Core/Components/TextInput/FluentTextInput.razor.cs
@@ -3,6 +3,10 @@
// ------------------------------------------------------------------------
using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.ComponentModel.DataAnnotations;
+using System.Linq.Expressions;
+
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.FluentUI.AspNetCore.Components.Utilities;
@@ -20,11 +24,14 @@ public partial class FluentTextInput : FluentInputImmediateBase, IFluen
///
public FluentTextInput(LibraryConfiguration configuration) : base(configuration)
{
- // Default conditions for the message
MessageCondition = (field) =>
{
field.MessageIcon = FluentStatus.ErrorIcon;
- field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];
+
+ // 1. Start with the component's Message parameter if provided.
+ // 2. Fallback: Search for a [Required] attribute ErrorMessage on the bound property via EditContext/FieldIdentifier.
+ // 3. Fallback: Use the default localized string.
+ field.Message = Message ?? GetRequiredAttributeMessage() ?? Localizer[Localization.LanguageResource.TextInput_RequiredMessage];
return FocusLost &&
(Required ?? false)
@@ -243,4 +250,25 @@ protected virtual Task FocusOutHandlerAsync(FocusEventArgs e)
FocusLost = true;
return Task.CompletedTask;
}
+
+ private string? GetRequiredAttributeMessage()
+ {
+ if (ValueExpression is null)
+ {
+ return null;
+ }
+
+ var expression = ValueExpression.Body;
+ if (expression is UnaryExpression { NodeType: ExpressionType.Convert } unary)
+ {
+ expression = unary.Operand;
+ }
+
+ if (expression is MemberExpression memberExpression)
+ {
+ return memberExpression.Member.GetCustomAttribute()?.ErrorMessage;
+ }
+
+ return null;
+ }
}
diff --git a/tests/Core/Components/Base/ComponentBaseTests.cs b/tests/Core/Components/Base/ComponentBaseTests.cs
index e2caa17d1f..36c339d72e 100644
--- a/tests/Core/Components/Base/ComponentBaseTests.cs
+++ b/tests/Core/Components/Base/ComponentBaseTests.cs
@@ -25,6 +25,7 @@ public class ComponentBaseTests : Bunit.BunitContext
typeof(DialogOptions),
typeof(FluentRadio<>), // TODO: To update
typeof(FluentTab), // Excluded because the Tab content in rendered in the parent FluentTabs component
+ typeof(FluentValidationMessage<>), // Excluded because requires special handling of EditContext
];
///
diff --git a/tests/Core/Components/Base/InputBaseTests.cs b/tests/Core/Components/Base/InputBaseTests.cs
index 8460be920c..4a15aaa64b 100644
--- a/tests/Core/Components/Base/InputBaseTests.cs
+++ b/tests/Core/Components/Base/InputBaseTests.cs
@@ -75,7 +75,7 @@ public InputBaseTests(ITestOutputHelper testOutputHelper)
[InlineData("LabelWidth", "150px", null, "width: 150px;", "Set_LabelPosition_Before")]
[InlineData("LabelPosition", LabelPosition.Before, "label-position", "before")]
[InlineData("Message", "my-message", null, null, "Add_MessageCondition_AlwaysTrue")]
- [InlineData("MessageState", MessageState.Success, null, "color: var(--success);", "Add_MessageCondition_AlwaysTrue")]
+ [InlineData("MessageState", MessageState.Success, null, "color: var(--info);", "Add_MessageCondition_AlwaysTrue")]
[InlineData("InputSlot", "input", null, "slot=\"input\"")]
[InlineData("LostFocus", "input", null, null, "Check_LostFocus")]
public void InputBase_DefaultProperties(string attributeName, object attributeValue, string? htmlAttribute = null, object? htmlValue = null, string? extraCondition = null)
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html
index 90912623b6..ef4658ab20 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Default.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html
index 937668acd3..dc26da6ab8 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_EnableThreeState.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html
index 6e31f3f593..a013101436 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_LabelTemplate.verified.razor.html
@@ -1,7 +1,10 @@
-
-
- Title
-
-
-
-
+
+
+
+
+ Title
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html
index 29cc55a58e..4320fdc92c 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-999.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html
index 2588f2fe03..88c76e7b54 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-[empty].verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html
index 13ab6e6e43..ba2bbf1126 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-circular.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html
index 2589ee0c88..3060e6433d 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Shape-square.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html
index 937668acd3..dc26da6ab8 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_ShowIndeterminate.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html
index c132c366c2..d85f36a1e7 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-999.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html
index 2588f2fe03..88c76e7b54 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-[empty].verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html
index 2778e249d4..6309cfed07 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-large.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html
index 7b1eff3c11..269a7c7cdc 100644
--- a/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html
+++ b/tests/Core/Components/Checkbox/FluentCheckboxTests.FluentCheckbox_Size-medium.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldParameterSelectorTests.razor b/tests/Core/Components/Field/FluentFieldParameterCollectorTests.razor
similarity index 65%
rename from tests/Core/Components/Field/FluentFieldParameterSelectorTests.razor
rename to tests/Core/Components/Field/FluentFieldParameterCollectorTests.razor
index 2d5dde5d6f..fc9a61c070 100644
--- a/tests/Core/Components/Field/FluentFieldParameterSelectorTests.razor
+++ b/tests/Core/Components/Field/FluentFieldParameterCollectorTests.razor
@@ -1,11 +1,12 @@
@using Microsoft.FluentUI.AspNetCore.Components.Extensions
@using Microsoft.FluentUI.AspNetCore.Components.Utilities
+@using System.Linq.Expressions
@using Xunit
@using Bunit
@inherits FluentUITestContext
@code
{
- public FluentFieldParameterSelectorTests()
+ public FluentFieldParameterCollectorTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddFluentUIComponents();
@@ -37,7 +38,7 @@
yield return new object?[] { "Message", null, null, null };
yield return new object?[] { "MessageIcon", null, null, null };
yield return new object?[] { "MessageTemplate", null, null, null };
- yield return new object?[] { "MessageCondition", null, FluentFieldCondition.Always, null };
+ yield return new object?[] { "MessageCondition", null, FluentFieldCondition.Never, null };
yield return new object?[] { "MessageState", null, null, null };
// With default State
@@ -47,30 +48,31 @@
yield return new object?[] { "MessageIcon", null, FluentStatus.SuccessIcon, MessageState.Success };
yield return new object?[] { "MessageIcon", null, FluentStatus.WarningIcon, MessageState.Warning };
yield return new object?[] { "MessageIcon", null, FluentStatus.ErrorIcon, MessageState.Error };
- yield return new object?[] { "MessageTemplate", null, FluentFieldParameterSelector.StateToMessageTemplate(MessageState.Success, "Valid data"), MessageState.Success };
- yield return new object?[] { "MessageTemplate", null, FluentFieldParameterSelector.StateToMessageTemplate(MessageState.Warning, "Please, check this value"), MessageState.Warning };
- yield return new object?[] { "MessageTemplate", null, FluentFieldParameterSelector.StateToMessageTemplate(MessageState.Error, "An error occurred"), MessageState.Error };
+ //The collector doesn't create a defaukt template based on the state anymore
+ //yield return new object?[] { "MessageTemplate", null, FluentFieldParameterCollector.StateToMessageTemplate(MessageState.Success, "Valid data"), MessageState.Success };
+ //yield return new object?[] { "MessageTemplate", null, FluentFieldParameterCollector.StateToMessageTemplate(MessageState.Warning, "Please, check this value"), MessageState.Warning };
+ //yield return new object?[] { "MessageTemplate", null, FluentFieldParameterCollector.StateToMessageTemplate(MessageState.Error, "An error occurred"), MessageState.Error };
}
[Theory]
[MemberData(nameof(GetTestData))]
- public void FluentFieldParameterSelector_Standalone(string name, object? value, object? expectedValue, MessageState? state)
+ public void FluentFieldParameterCollector_Standalone(string name, object? value, object? expectedValue, MessageState? state)
{
CommonTest(null, name, value, expectedValue, state);
}
[Theory]
[MemberData(nameof(GetTestData))]
- public void FluentFieldParameterSelector_InputComponent(string name, object? value, object? expectedValue, MessageState? state)
+ public void FluentFieldParameterCollector_InputComponent(string name, object? value, object? expectedValue, MessageState? state)
{
CommonTest(new MockFluentField(), name, value, expectedValue, state);
}
[Theory]
[MemberData(nameof(GetTestData))]
- public void FluentFieldParameterSelector_SetNotSupported(string name, object? value, object? expectedValue, MessageState? state)
+ public void FluentFieldParameterCollector_SetNotSupported(string name, object? value, object? expectedValue, MessageState? state)
{
- var selector = new FluentFieldParameterSelector(new FluentField(LibraryConfiguration.Empty), FluentLocalizer);
+ var selector = new FluentFieldParameterCollector(new FluentField(LibraryConfiguration.Empty), FluentLocalizer);
Assert.ThrowsAny(() =>
{
@@ -83,16 +85,16 @@
}
[Fact]
- public void FluentFieldParameterSelector_FocusLost()
+ public void FluentFieldParameterCollector_FocusLost()
{
// Arrange
var field = new FluentField(LibraryConfiguration.Empty) { FocusLost = true };
- var selector = new FluentFieldParameterSelector(field, FluentLocalizer);
+ var selector = new FluentFieldParameterCollector(field, FluentLocalizer);
// Act
Assert.ThrowsAny(() =>
{
- selector.FocusLost = true;
+ selector.FocusLost = true;
});
// Assert
@@ -100,6 +102,49 @@
Assert.True(field.FocusLost);
}
+ [Fact]
+ public void FluentFieldParameterCollector_ValueExpression_Standalone()
+ {
+ // Arrange
+ Expression> expression = () => "test";
+ var field = new FluentField(LibraryConfiguration.Empty);
+ typeof(FluentField).GetProperty("For")?.SetValue(field, expression);
+ var collector = new FluentFieldParameterCollector(field, FluentLocalizer);
+
+ // Act & Assert
+ Assert.Equal(expression, collector.ValueExpression);
+ }
+
+ [Fact]
+ public void FluentFieldParameterCollector_ValueExpression_InputComponent()
+ {
+ // Arrange
+ Expression> expression = () => "test";
+ var inputComponent = new MockFluentField { ValueExpression = expression };
+ var field = new FluentField(LibraryConfiguration.Empty);
+ typeof(FluentField).GetProperty("InputComponent")?.SetValue(field, inputComponent);
+ var collector = new FluentFieldParameterCollector(field, FluentLocalizer);
+
+ // Act & Assert
+ Assert.Equal(expression, collector.ValueExpression);
+ }
+
+ [Fact]
+ public void FluentFieldParameterCollector_ValueExpression_Precedence()
+ {
+ // Arrange
+ Expression> expressionFor = () => "for";
+ Expression> expressionInput = () => "input";
+ var inputComponent = new MockFluentField { ValueExpression = expressionInput };
+ var field = new FluentField(LibraryConfiguration.Empty);
+ typeof(FluentField).GetProperty("For")?.SetValue(field, expressionFor);
+ typeof(FluentField).GetProperty("InputComponent")?.SetValue(field, inputComponent);
+ var collector = new FluentFieldParameterCollector(field, FluentLocalizer);
+
+ // Act & Assert
+ Assert.Equal(expressionFor, collector.ValueExpression);
+ }
+
private void CommonTest(MockFluentField? inputComponent, string name, object? value, object? expectedValue, MessageState? state)
{
Dictionary attributes;
@@ -137,7 +182,7 @@
// Act
var instance = cut.FindComponent().Instance;
- var selector = new FluentFieldParameterSelector(instance, FluentLocalizer);
+ var selector = new FluentFieldParameterCollector(instance, FluentLocalizer);
var propValue = selector.GetType().GetProperty(name)?.GetValue(selector);
// Assert
@@ -163,7 +208,8 @@
internal class MockFluentField : IFluentField
{
- public bool FocusLost { get; }
+ public LambdaExpression? ValueExpression { get; set; }
+ public bool FocusLost { get; set; }
public string? Label { get; set; }
public RenderFragment? LabelTemplate { get; set; }
public LabelPosition? LabelPosition { get; set; }
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html
index 2789ae02a6..99c2ac8c43 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Default.verified.razor.html
@@ -1,7 +1,10 @@
-
-
- My label
-
- Field content
- My message
-
+
+
+
+
+ My label
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html
index e794938556..4dd412181d 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_DefaultTemplate.verified.razor.html
@@ -1,14 +1,14 @@
-
-
-
- My
- label
-
-
- Field content
-
-
- My
- message
-
-
\ No newline at end of file
+
+
+
+
+
+ My
+ label
+
+
+ Field content
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html
index acbb9a68dc..b7d99bc132 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageIcon.verified.razor.html
@@ -1,8 +1,15 @@
-
-
- Field content
-
-
-
- My message
-
\ No newline at end of file
+
+
+
+
+ Field content
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html
index 6ff1c797e0..6943f09c7e 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-99.verified.razor.html
@@ -1,5 +1,8 @@
-
-
- Field content
-
-
+
+
+
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html
index 3e8dc0fcd1..6943f09c7e 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-[empty].verified.razor.html
@@ -1,5 +1,8 @@
-
-
- Field content
-
-
\ No newline at end of file
+
+
+
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html
index 91b4451568..6943f09c7e 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-error.verified.razor.html
@@ -1,10 +1,8 @@
-
-
- Field content
-
-
-
-
- An error occurred
-
-
+
+
+
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html
index 65d2342381..6943f09c7e 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-success.verified.razor.html
@@ -1,10 +1,8 @@
-
-
- Field content
-
-
-
-
- Valid data
-
-
+
+
+
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html
index bad2eb6cf1..6943f09c7e 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_MessageState-warning.verified.razor.html
@@ -1,10 +1,8 @@
-
-
- Field content
-
-
-
-
- Please, check this value
-
-
+
+
+
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html
index a17d787dce..5fe443066e 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-99.verified.razor.html
@@ -1,7 +1,10 @@
-
-
- My label
-
- Field content
-
-
\ No newline at end of file
+
+
+
+
+ My label
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html
index 4ee844932a..99c2ac8c43 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-[empty].verified.razor.html
@@ -1,7 +1,10 @@
-
-
- My label
-
- Field content
-
-
\ No newline at end of file
+
+
+
+
+ My label
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html
index 75bf1bbd37..26e4ecd734 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-large.verified.razor.html
@@ -1,7 +1,10 @@
-
-
- My label
-
- Field content
-
-
\ No newline at end of file
+
+
+
+
+ My label
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html
index 69fe813560..ab85ea3ad7 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-medium.verified.razor.html
@@ -1,7 +1,10 @@
-
-
- My label
-
- Field content
-
-
\ No newline at end of file
+
+
+
+
+ My label
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html
index 7c504f08ee..f51ee2806b 100644
--- a/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html
+++ b/tests/Core/Components/Field/FluentFieldTests.FluentField_Size-small.verified.razor.html
@@ -1,7 +1,10 @@
-
-
- My label
-
- Field content
-
-
\ No newline at end of file
+
+
+
+
+ My label
+
+ Field content
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Field/FluentFieldTests.razor b/tests/Core/Components/Field/FluentFieldTests.razor
index 29f88cede7..e7a883b13a 100644
--- a/tests/Core/Components/Field/FluentFieldTests.razor
+++ b/tests/Core/Components/Field/FluentFieldTests.razor
@@ -1,5 +1,8 @@
@using Microsoft.FluentUI.AspNetCore.Components.Extensions
@using Microsoft.FluentUI.AspNetCore.Components.Utilities
+@using Microsoft.AspNetCore.Components.Forms
+@using System.Linq.Expressions
+@using Bunit
@using Xunit;
@inherits FluentUITestContext
@code
@@ -175,7 +178,7 @@
#pragma warning disable BL0005 // Component parameter should not be set outside of its component.
var field2 = new FluentField(LibraryConfiguration.Empty)
{
- InputComponent = new FluentFieldParameterSelectorTests.MockFluentField()
+ InputComponent = new FluentFieldParameterCollectorTests.MockFluentField()
};
Assert.Throws(() =>
{
@@ -228,4 +231,179 @@
// Assert
Assert.Equal("Field content", cut.Markup);
}
+
+ [Fact]
+ public void FluentField_ValidationMessages_DisplaysErrors()
+ {
+ // Arrange
+ var model = new TestModel { Name = "" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name is required");
+
+ // Act
+ var cut = Render(
+ @
+ Field content
+ );
+
+ // Assert
+ Assert.Contains("Name is required", cut.Markup);
+ }
+
+ [Fact]
+ public void FluentField_ValidationMessages_ExcludesMessageParameter()
+ {
+ // Arrange
+ var model = new TestModel { Name = "" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Custom Message");
+
+ // Act
+ var cut = Render(
+ @
+ Field content
+ );
+
+ // Assert
+ // "Custom Message" should be excluded because it matches the Message parameter
+ Assert.DoesNotContain("Custom Message", cut.Markup);
+ }
+
+ [Fact]
+ public void FluentField_ValidationMessages_WithCondition()
+ {
+ // Arrange
+ var model = new TestModel { Name = "" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Validation Error");
+
+ // Act
+ var cut = Render(
+ @
+ Field content
+ );
+
+ // Assert
+ Assert.Contains("Validation Error", cut.Markup);
+ Assert.Contains("Condition Met", cut.Markup);
+ }
+
+ private class TestModel
+ {
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+ }
+
+ private class MockFluentField : IFluentField
+ {
+ public LambdaExpression? ValueExpression { get; set; }
+ public bool FocusLost { get; set; }
+ public string? Label { get; set; }
+ public RenderFragment? LabelTemplate { get; set; }
+ public LabelPosition? LabelPosition { get; set; }
+ public string? LabelWidth { get; set; }
+ public bool? Required { get; set; }
+ public bool? Disabled { get; set; }
+ public string? Message { get; set; }
+ public Icon? MessageIcon { get; set; }
+ public RenderFragment? MessageTemplate { get; set; }
+ public Func? MessageCondition { get; set; }
+ public MessageState? MessageState { get; set; }
+ }
+
+ [Fact]
+ public void FluentField_Field_Parameter_Takes_Precedence()
+ {
+ // Arrange
+ var model = new TestModel { Name = "Name", Description = "Desc" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+
+ var nameField = editContext.Field(nameof(TestModel.Name));
+ var descField = editContext.Field(nameof(TestModel.Description));
+
+ messageStore.Add(nameField, "Name error");
+ messageStore.Add(descField, "Description error");
+
+ // Act
+ var cut = Render(
+ @
+ Field content
+ );
+
+ // Assert
+ Assert.Contains("Description error", cut.Markup);
+ Assert.DoesNotContain("Name error", cut.Markup);
+ }
+
+ [Fact]
+ public void FluentField_Uses_InputComponent_ValueExpression()
+ {
+ // Arrange
+ var model = new TestModel { Name = "Name" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ var nameField = editContext.Field(nameof(TestModel.Name));
+ messageStore.Add(nameField, "Name error");
+
+ var inputComponent = new MockFluentField
+ {
+ ValueExpression = (Expression>)(() => model.Name)
+ };
+
+ // Act
+ var cut = Render(parameters => parameters
+ .AddCascadingValue(editContext)
+ .Add(p => p.InputComponent, inputComponent)
+ );
+
+ // Assert
+ Assert.Contains("Name error", cut.Markup);
+ }
+
+ [Fact]
+ public void FluentField_Swapping_For_Updates_Identifier()
+ {
+ // Arrange
+ var model = new TestModel { Name = "", Description = "" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Name Error");
+ messageStore.Add(editContext.Field(nameof(TestModel.Description)), "Desc Error");
+
+ // Act & Assert 1
+ var cut1 = Render(parameters => parameters
+ .AddCascadingValue(editContext)
+ .Add(p => p.For, (Expression>)(() => model.Name))
+ );
+
+ Assert.Contains("Name Error", cut1.Markup);
+ Assert.DoesNotContain("Desc Error", cut1.Markup);
+
+ // Act & Assert 2
+ var cut2 = Render(parameters => parameters
+ .AddCascadingValue(editContext)
+ .Add(p => p.For, (Expression>)(() => model.Description))
+ );
+
+ Assert.Contains("Desc Error", cut2.Markup);
+ Assert.DoesNotContain("Name Error", cut2.Markup);
+ }
+
+ [Fact]
+ public void FluentField_IFluentField_ValueExpression_Returns_For()
+ {
+ // Arrange
+ var model = new TestModel { Name = "test" };
+ Expression> expression = () => model.Name;
+ var cut = Render(parameters => parameters.Add(p => p.For, expression));
+ var field = cut.Instance;
+ IFluentField ifield = field;
+
+ // Act & Assert
+ Assert.Equal(expression, ifield.ValueExpression);
+ }
}
diff --git a/tests/Core/Components/Forms/FluentValidationMessageTests.cs b/tests/Core/Components/Forms/FluentValidationMessageTests.cs
new file mode 100644
index 0000000000..3566466b69
--- /dev/null
+++ b/tests/Core/Components/Forms/FluentValidationMessageTests.cs
@@ -0,0 +1,189 @@
+// ------------------------------------------------------------------------
+// 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
+ void 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
+ void 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(".fluent-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(".fluent-validation-message"));
+ }
+
+ [Fact]
+ public async Task FluentValidationMessage_Renders_No_Icon_When_Null()
+ {
+ // 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)
+ .Add(p => p.Icon, null)
+ );
+
+ await cut.InvokeAsync(() =>
+ {
+ messageStore.Add(editContext.Field(nameof(TestModel.Name)), "Error");
+ editContext.NotifyValidationStateChanged();
+ });
+
+ // Assert
+ Assert.Contains("Error", cut.Markup);
+ Assert.Empty(cut.FindAll("svg"));
+ }
+
+ [Fact]
+ public async Task FluentValidationMessage_Uses_Field_Parameter()
+ {
+ // Arrange
+ var model = new TestModel { Description = "" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ var fieldIdentifier = new FieldIdentifier(model, nameof(TestModel.Description));
+
+ // Act
+ var cut = Render>(parameters => parameters
+ .AddCascadingValue(editContext)
+ .Add(p => p.Field, fieldIdentifier)
+ );
+
+ await cut.InvokeAsync(() =>
+ {
+ messageStore.Add(fieldIdentifier, "Description is required");
+ editContext.NotifyValidationStateChanged();
+ });
+
+ // Assert
+ Assert.Contains("Description is required", cut.Markup);
+ }
+
+ [Fact]
+ public async Task FluentValidationMessage_Field_Takes_Precedence_Over_For()
+ {
+ // Arrange
+ var model = new TestModel { Name = "", Description = "" };
+ var editContext = new EditContext(model);
+ var messageStore = new ValidationMessageStore(editContext);
+ var nameField = new FieldIdentifier(model, nameof(TestModel.Name));
+ var descriptionField = new FieldIdentifier(model, nameof(TestModel.Description));
+
+ // Act
+ var cut = Render>(parameters => parameters
+ .AddCascadingValue(editContext)
+ .Add(p => p.For, () => model.Name)
+ .Add(p => p.Field, descriptionField)
+ );
+
+ await cut.InvokeAsync(() =>
+ {
+ messageStore.Add(nameField, "Name error");
+ messageStore.Add(descriptionField, "Description error");
+ editContext.NotifyValidationStateChanged();
+ });
+
+ // Assert
+ Assert.Contains("Description error", cut.Markup);
+ Assert.DoesNotContain("Name error", cut.Markup);
+ }
+
+ private class TestModel
+ {
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ }
+}
diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html
index be37b5d982..0b5c4911d2 100644
--- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html
+++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Default.verified.razor.html
@@ -1,12 +1,15 @@
-
-
-
-
-
- One
- Two
- Three
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html
index 81576b7f09..609f546b15 100644
--- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html
+++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Enum.verified.razor.html
@@ -1,11 +1,14 @@
-
-
-
-
- One
- Two
- Three
-
-
-
-
+
+
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html
index 27fc861c55..bad7d8dccc 100644
--- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html
+++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Label.verified.razor.html
@@ -1,14 +1,17 @@
-
-
- List of digits
-
-
-
-
- One
- Two
- Three
-
-
-
-
+
+
+
+
+ List of digits
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html
index fcb5863ae3..bd9824a4de 100644
--- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html
+++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_Manual.verified.razor.html
@@ -1,11 +1,14 @@
-
-
-
-
- One
- Two
- Three
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html
index fcb6289269..0803ac7a4f 100644
--- a/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html
+++ b/tests/Core/Components/List/FluentComboboxTests.FluentCombobox_OptionFunctions.verified.razor.html
@@ -1,12 +1,15 @@
-
-
-
-
-
- ONE
- TWO
- THREE
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+ ONE
+ TWO
+ THREE
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html
index 8d45ea9194..1c85557d3e 100644
--- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html
+++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Default.verified.razor.html
@@ -1,12 +1,15 @@
-
-
-
-
-
- One
- Two
- Three
-
-
-
-
+
+
+
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html
index d8af037844..7abddf485b 100644
--- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html
+++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Enum.verified.razor.html
@@ -1,11 +1,14 @@
-
-
-
-
- One
- Two
- Three
-
-
-
-
+
+
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html
index c4eabc0bca..dd3b595443 100644
--- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html
+++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Label.verified.razor.html
@@ -1,15 +1,17 @@
-
-
-
- List of digits
-
-
-
-
- One
- Two
- Three
-
-
-
-
+
+
+
+
+ List of digits
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html
index 852ea02b34..21cc68238c 100644
--- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html
+++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_Manual.verified.razor.html
@@ -1,11 +1,14 @@
-
-
-
-
- One
- Two
- Three
-
-
-
-
+
+
+
+
+
+
+ One
+ Two
+ Three
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html b/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html
index f29747171b..e9a3b3a127 100644
--- a/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html
+++ b/tests/Core/Components/List/FluentSelectTests.FluentSelect_OptionFunctions.verified.razor.html
@@ -1,12 +1,15 @@
-
-
-
-
-
- ONE
- TWO
- THREE
-
-
-
-
+
+
+
+
+
+
+
+ ONE
+ TWO
+ THREE
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html
index 7fb637dfda..bfad181edc 100644
--- a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Default.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html
index 2252d11e68..d857f7f4e2 100644
--- a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_Items.verified.razor.html
@@ -1,28 +1,27 @@
-
-
-
-
-
-
-
-
- ONE
-
-
-
-
-
- TWO
-
-
-
-
-
- FOUR
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+ ONE
+
+
+
+
+ TWO
+
+
+
+
+ FOUR
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html
index d145f1ae41..ddb98d1f62 100644
--- a/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioGroupTests.FluentRadioGroup_WithChildContent.verified.razor.html
@@ -1,27 +1,24 @@
-
-
-
-
-
- Option 1
-
-
-
-
-
-
- Option 2
-
-
-
-
-
-
- Option 3
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Option 1
+
+
+
+
+ Option 2
+
+
+
+
+ Option 3
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html
index a453e5460d..62b20e1cd4 100644
--- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Default.verified.razor.html
@@ -1,10 +1,12 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html
index 6302e10f1c..db35e0e247 100644
--- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_HorizontalOrientation.verified.razor.html
@@ -1,20 +1,19 @@
-
-
-
-
-
- Option 1
-
-
-
-
-
-
- Option 2
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Option 1
+
+
+
+
+ Option 2
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html
index a96fc588c0..9d2507f05b 100644
--- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_Label.verified.razor.html
@@ -1,13 +1,14 @@
-
-
-
-
-
- Test Label
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Test Label
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html
index 1e30b0dd81..4b7a51aa74 100644
--- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelTemplate.verified.razor.html
@@ -1,13 +1,15 @@
-
-
-
-
-
- Custom Label Template
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Custom Label Template
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html
index 1957ee46d3..1251f22005 100644
--- a/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html
+++ b/tests/Core/Components/Radio/FluentRadioTests.FluentRadio_LabelWidth.verified.razor.html
@@ -1,13 +1,14 @@
-
-
-
-
-
- Test Label
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Test Label
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html
index 9b3919b073..bc48ef06bd 100644
--- a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html
+++ b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Default.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html
index 000eba463b..caba336429 100644
--- a/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html
+++ b/tests/Core/Components/Slider/FluentSliderTests.FluentSlider_Slot_Thumb.verified.razor.html
@@ -1,9 +1,12 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html
index a2cec02432..7495f82973 100644
--- a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html
+++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_LabelTemplate.verified.razor.html
@@ -1,8 +1,12 @@
-
-
-
- Label with bold text
-
-
-
-
+
+
+
+
+
+ Label with
+ bold
+ text
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html
index 684053cb51..09272b2283 100644
--- a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html
+++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_ReadOnly.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html
index 354d12805e..b28e493965 100644
--- a/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html
+++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentCheckbox_Required.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html b/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html
index 5c085f5efb..56bfa04837 100644
--- a/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html
+++ b/tests/Core/Components/Switch/FluentSwitchTests.FluentSwitch_Default.verified.razor.html
@@ -1,5 +1,8 @@
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Core/Components/Switch/FluentSwitchTests.razor b/tests/Core/Components/Switch/FluentSwitchTests.razor
index 831e0776fa..14a1779839 100644
--- a/tests/Core/Components/Switch/FluentSwitchTests.razor
+++ b/tests/Core/Components/Switch/FluentSwitchTests.razor
@@ -122,7 +122,7 @@
var cut = Render(@ );
// Act
- var label = cut.Find("fluent-field > label").InnerHtml.Trim(' ', '\n', '\r');
+ var label = cut.Find("fluent-field > label").InnerHtml.Trim(' ', '\n', '\r', '\t');
// Assert
Assert.Equal(expectedLabel, label);