diff --git a/src/Core/Components/List/FluentAutocomplete.razor.cs b/src/Core/Components/List/FluentAutocomplete.razor.cs index 89680a004e..4caf6768aa 100644 --- a/src/Core/Components/List/FluentAutocomplete.razor.cs +++ b/src/Core/Components/List/FluentAutocomplete.razor.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.AspNetCore.Components.Web; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -36,6 +37,8 @@ public FluentAutocomplete(LibraryConfiguration configuration) : base(configurati // Default values Id = Identifier.NewId(); + SelectedItemExpression = () => SelectedItem; + // Set default value: if `Width` is not already set (not null), Width ??= "160px"; @@ -187,6 +190,16 @@ public override IEnumerable SelectedItems [Parameter] public EventCallback SelectedItemChanged { get; set; } + /// + /// Gets or sets an expression that identifies the bound value. + /// This is required to enable the @bind-SelectedItem syntax (Razor automatically + /// supplies it). When using manual one-way binding through + /// and , providing this expression is optional: a + /// default expression pointing to is set in the constructor. + /// + [Parameter] + public Expression>? SelectedItemExpression { get; set; } + /// /// Gets a value indicating whether the number of selected options has reached the maximum defined by . /// diff --git a/src/Core/Components/List/FluentListBase.razor.cs b/src/Core/Components/List/FluentListBase.razor.cs index da2cf73a7a..957d8f49fb 100644 --- a/src/Core/Components/List/FluentListBase.razor.cs +++ b/src/Core/Components/List/FluentListBase.razor.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.FluentUI.AspNetCore.Components.Extensions; @@ -22,6 +23,8 @@ public abstract partial class FluentListBase : FluentInputBase< [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DropdownEventArgs))] protected FluentListBase(LibraryConfiguration configuration) : base(configuration) { + SelectedItemsExpression = () => SelectedItems; + // If TOption implements IEqualityComparer and exposes a public parameterless // constructor, use a new instance of TOption as the default OptionSelectedComparer. if (OptionSelectedComparer is null && _defaultOptionSelectedComparer.Value is { } defaultComparer) @@ -85,6 +88,16 @@ protected FluentListBase(LibraryConfiguration configuration) : base(configuratio [Parameter] public virtual EventCallback> SelectedItemsChanged { get; set; } + /// + /// Gets or sets an expression that identifies the bound value. + /// This is required to enable the @bind-SelectedItems syntax (Razor automatically + /// supplies it). When using manual one-way binding through + /// and , providing this expression is optional: a + /// default expression pointing to is set in the constructor. + /// + [Parameter] + public virtual Expression>>? SelectedItemsExpression { get; set; } + /// /// Gets or sets the template for the items. /// diff --git a/tests/Core/Components/List/FluentAutocompleteTests.razor b/tests/Core/Components/List/FluentAutocompleteTests.razor index 1001d855fa..ed0f8960d7 100644 --- a/tests/Core/Components/List/FluentAutocompleteTests.razor +++ b/tests/Core/Components/List/FluentAutocompleteTests.razor @@ -1120,6 +1120,72 @@ Assert.Null(component.OptionSelectedComparer); } + [Fact] + public void FluentAutocomplete_SelectedItemExpression_DefaultIsNotNull() + { + string? selectedItem = "Two"; + + // Arrange - Render without supplying SelectedItemExpression (manual one-way binding scenario) + var cut = Render(@); + + // Assert - The constructor sets a default expression so consumers using manual + // one-way binding (SelectedItem + SelectedItemChanged only) still get a non-null expression. + var instance = cut.FindComponent>().Instance; + Assert.NotNull(instance.SelectedItemExpression); + Assert.Equal("Two", instance.SelectedItemExpression!.Compile().Invoke()); + } + + [Fact] + public void FluentAutocomplete_SelectedItemExpression_ManualOneWayBinding() + { + // This test compiles ONLY because SelectedItemExpression exists as a [Parameter]. + // Razor's @bind-SelectedItem:get/:set syntax emits SelectedItem, SelectedItemChanged + // AND SelectedItemExpression on the component. + string? selectedItem = "One"; + + // Arrange + var cut = Render(@); + + // Assert - The component's SelectedItemExpression is supplied by Razor and reflects the bound field + var instance = cut.FindComponent>().Instance; + Assert.NotNull(instance.SelectedItemExpression); + Assert.Equal("One", instance.SelectedItemExpression!.Compile().Invoke()); + + // The badge displays the initial selection + var badge = cut.Find("span.fluent-badge[alone]"); + Assert.Equal("One", badge.GetAttribute("title")); + } + + [Fact] + public void FluentAutocomplete_SelectedItemExpression_ExplicitlyProvided() + { + string? selectedItem = "Three"; + System.Linq.Expressions.Expression> expression = () => selectedItem; + + // Arrange - Explicitly provide SelectedItemExpression + var cut = Render(@); + + // Assert - The supplied expression overrides the default set in the constructor + var instance = cut.FindComponent>().Instance; + Assert.Same(expression, instance.SelectedItemExpression); + } + public class Person : IEqualityComparer { public int Id { get; set; } diff --git a/tests/Core/Components/List/FluentSelectTests.razor b/tests/Core/Components/List/FluentSelectTests.razor index 32c3a1e630..0f11f6f208 100644 --- a/tests/Core/Components/List/FluentSelectTests.razor +++ b/tests/Core/Components/List/FluentSelectTests.razor @@ -519,6 +519,76 @@ Assert.Equal(MyDigitsEnum.Three, value); } + [Fact] + public void FluentSelect_SelectedItemsExpression_DefaultIsNotNull() + { + IEnumerable selectedItems = ["Two"]; + + // Arrange - Render without supplying SelectedItemsExpression (manual one-way binding scenario) + var cut = Render(@); + + // Assert - The constructor sets a default expression so consumers using manual + // one-way binding (SelectedItems + SelectedItemsChanged only) still get a non-null expression. + var instance = cut.FindComponent>().Instance; + Assert.NotNull(instance.SelectedItemsExpression); + Assert.Equal(new[] { "Two" }, instance.SelectedItemsExpression!.Compile().Invoke()); + } + + [Fact] + public async Task FluentSelect_SelectedItemsExpression_ManualOneWayBinding() + { + // This test compiles ONLY because SelectedItemsExpression exists as a [Parameter]. + // Razor's @bind-SelectedItems:get/:set syntax emits SelectedItems, SelectedItemsChanged + // AND SelectedItemsExpression on the component. + IEnumerable selectedItems = ["One"]; + + // Arrange + var cut = Render(@); + + var ids = cut.FindAll("fluent-option") + .Where(i => i.GetAttribute("value") == "One" || i.GetAttribute("value") == "Two") + .Select(i => i.GetAttribute("id")); + + // Act + await cut.FindComponent>().Instance.OnDropdownChangeHandlerAsync(new DropdownEventArgs + { + SelectedOptions = string.Join(';', ids), + }); + + // Assert + Assert.Equal(new[] { "One", "Two" }, selectedItems); + } + + [Fact] + public void FluentSelect_SelectedItemsExpression_ExplicitlyProvided() + { + IEnumerable selectedItems = ["Three"]; + System.Linq.Expressions.Expression>> expression = () => selectedItems; + + // Arrange - Explicitly provide SelectedItemsExpression + var cut = Render(@); + + // Assert - The supplied expression overrides the default set in the constructor + var instance = cut.FindComponent>().Instance; + Assert.Same(expression, instance.SelectedItemsExpression); + } + public record Person { public int Id { get; set; }