Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Core/Components/List/FluentAutocomplete.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -187,6 +190,16 @@ public override IEnumerable<TOption> SelectedItems
[Parameter]
public EventCallback<TOption> SelectedItemChanged { get; set; }

/// <summary>
/// Gets or sets an expression that identifies the bound <see cref="SelectedItem"/> value.
/// This is required to enable the <c>@bind-SelectedItem</c> syntax (Razor automatically
/// supplies it). When using manual one-way binding through <see cref="SelectedItem"/>
/// and <see cref="SelectedItemChanged"/>, providing this expression is optional: a
/// default expression pointing to <see cref="SelectedItem"/> is set in the constructor.
/// </summary>
[Parameter]
public Expression<Func<TOption?>>? SelectedItemExpression { get; set; }

/// <summary>
/// Gets a value indicating whether the number of selected options has reached the maximum defined by <see cref="MaximumSelectedOptions"/>.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions src/Core/Components/List/FluentListBase.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,8 @@ public abstract partial class FluentListBase<TOption, TValue> : FluentInputBase<
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DropdownEventArgs))]
protected FluentListBase(LibraryConfiguration configuration) : base(configuration)
{
SelectedItemsExpression = () => SelectedItems;

// If TOption implements IEqualityComparer<TOption> and exposes a public parameterless
// constructor, use a new instance of TOption as the default OptionSelectedComparer.
if (OptionSelectedComparer is null && _defaultOptionSelectedComparer.Value is { } defaultComparer)
Expand Down Expand Up @@ -85,6 +88,16 @@ protected FluentListBase(LibraryConfiguration configuration) : base(configuratio
[Parameter]
public virtual EventCallback<IEnumerable<TOption>> SelectedItemsChanged { get; set; }

/// <summary>
/// Gets or sets an expression that identifies the bound <see cref="SelectedItems"/> value.
/// This is required to enable the <c>@bind-SelectedItems</c> syntax (Razor automatically
/// supplies it). When using manual one-way binding through <see cref="SelectedItems"/>
/// and <see cref="SelectedItemsChanged"/>, providing this expression is optional: a
/// default expression pointing to <see cref="SelectedItems"/> is set in the constructor.
/// </summary>
[Parameter]
public virtual Expression<Func<IEnumerable<TOption>>>? SelectedItemsExpression { get; set; }

/// <summary>
/// Gets or sets the template for the <see cref="FluentListBase{TOption, TValue}.Items"/> items.
/// </summary>
Expand Down
66 changes: 66 additions & 0 deletions tests/Core/Components/List/FluentAutocompleteTests.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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(@<FluentAutocomplete TOption="string"
TValue="string"
Multiple="false"
Items="@Digits"
SelectedItem="@selectedItem"
SelectedItemChanged="@((string v) => selectedItem = v)" />);

// 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<FluentAutocomplete<string, string>>().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(@<FluentAutocomplete TOption="string"
TValue="string"
Multiple="false"
Items="@Digits"
@bind-SelectedItem:get="selectedItem"
@bind-SelectedItem:set="@((string v) => { selectedItem = v; return Task.CompletedTask; })" />);

// Assert - The component's SelectedItemExpression is supplied by Razor and reflects the bound field
var instance = cut.FindComponent<FluentAutocomplete<string, string>>().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<Func<string?>> expression = () => selectedItem;

// Arrange - Explicitly provide SelectedItemExpression
var cut = Render(@<FluentAutocomplete TOption="string"
TValue="string"
Multiple="false"
Items="@Digits"
SelectedItem="@selectedItem"
SelectedItemChanged="@((string v) => selectedItem = v)"
SelectedItemExpression="@expression" />);

// Assert - The supplied expression overrides the default set in the constructor
var instance = cut.FindComponent<FluentAutocomplete<string, string>>().Instance;
Assert.Same(expression, instance.SelectedItemExpression);
}

public class Person : IEqualityComparer<Person>
{
public int Id { get; set; }
Expand Down
70 changes: 70 additions & 0 deletions tests/Core/Components/List/FluentSelectTests.razor
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,76 @@
Assert.Equal(MyDigitsEnum.Three, value);
}

[Fact]
public void FluentSelect_SelectedItemsExpression_DefaultIsNotNull()
{
IEnumerable<string> selectedItems = ["Two"];

// Arrange - Render without supplying SelectedItemsExpression (manual one-way binding scenario)
var cut = Render(@<FluentSelect TOption="string"
TValue="string"
Multiple="true"
Items="@Digits"
SelectedItems="@selectedItems"
SelectedItemsChanged="@((IEnumerable<string> v) => selectedItems = v)" />);

// 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<FluentSelect<string, string>>().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<string> selectedItems = ["One"];

// Arrange
var cut = Render(@<FluentSelect TOption="string"
TValue="string"
Multiple="true"
Items="@Digits"
@bind-SelectedItems:get="selectedItems"
@bind-SelectedItems:set="@((IEnumerable<string> v) => { selectedItems = v; return Task.CompletedTask; })" />);

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<FluentSelect<string?, string?>>().Instance.OnDropdownChangeHandlerAsync(new DropdownEventArgs
{
SelectedOptions = string.Join(';', ids),
});

// Assert
Assert.Equal(new[] { "One", "Two" }, selectedItems);
}

[Fact]
public void FluentSelect_SelectedItemsExpression_ExplicitlyProvided()
{
IEnumerable<string> selectedItems = ["Three"];
System.Linq.Expressions.Expression<Func<IEnumerable<string>>> expression = () => selectedItems;

// Arrange - Explicitly provide SelectedItemsExpression
var cut = Render(@<FluentSelect TOption="string"
TValue="string"
Multiple="true"
Items="@Digits"
SelectedItems="@selectedItems"
SelectedItemsChanged="@((IEnumerable<string> v) => selectedItems = v)"
SelectedItemsExpression="@expression" />);

// Assert - The supplied expression overrides the default set in the constructor
var instance = cut.FindComponent<FluentSelect<string, string>>().Instance;
Assert.Same(expression, instance.SelectedItemsExpression);
}

public record Person
{
public int Id { get; set; }
Expand Down
Loading