diff --git a/src/Core/Components/List/FluentAutocomplete.razor.cs b/src/Core/Components/List/FluentAutocomplete.razor.cs index 4caf6768aa..63540cb29b 100644 --- a/src/Core/Components/List/FluentAutocomplete.razor.cs +++ b/src/Core/Components/List/FluentAutocomplete.razor.cs @@ -22,9 +22,13 @@ public partial class FluentAutocomplete : FluentListBase ValueComparer = EqualityComparer.Default; + private readonly EqualityComparer OptionComparer = EqualityComparer.Default; + private string? _textInput; private bool _isOpen; private bool _inProgress; + private TValue? _previousValue; // List of items used in the internally filtered listbox private List _internalFilteredItems = []; @@ -218,34 +222,21 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } /// - public override async Task SetParametersAsync(ParameterView parameters) + protected override async Task OnParametersSetAsync() { - // Check if SelectedItem is being supplied and has changed - if (parameters.TryGetValue(nameof(SelectedItem), out var newSelectedItem)) + // This part of code cannot be moved to SetParametersAsync because we are invoking `OnSetValue` which is async, + // and SetParametersAsync doesn't allow awaiting other async calls inside it. + if (!ValueComparer.Equals(Value, _previousValue) && OnSetValue.HasDelegate) { - var comparer = OptionSelectedComparer ?? EqualityComparer.Default; - var currentSelectedItem = _internalSelectedItem; - - if (!comparer.Equals(newSelectedItem, currentSelectedItem)) - { - // Sync _internalSelectedItems with the new value - _internalSelectedItems = newSelectedItem is not null ? [newSelectedItem] : []; - SelectedItem = newSelectedItem; - } - } + _previousValue = Value; - // Check if Value is being supplied and has changed (e.g. set by the developer during initialization). - // If so, resolve the associated option via the OnSetValue callback and synchronize the selection. - if (parameters.TryGetValue(nameof(Value), out var newValue)) - { - var valueComparer = EqualityComparer.Default; var currentValue = GetOptionValue(_internalSelectedItem); - if (!valueComparer.Equals(newValue, currentValue) && OnSetValue.HasDelegate) + if (!ValueComparer.Equals(Value, currentValue)) { var args = new SetValueEventArgs { - Value = newValue, + Value = Value, }; await OnSetValue.InvokeAsync(args); @@ -263,6 +254,26 @@ public override async Task SetParametersAsync(ParameterView parameters) } } + await base.OnParametersSetAsync(); + } + + /// + public override async Task SetParametersAsync(ParameterView parameters) + { + // Check if SelectedItem is being supplied and has changed + if (parameters.TryGetValue(nameof(SelectedItem), out var newSelectedItem)) + { + var comparer = OptionSelectedComparer ?? OptionComparer; + var currentSelectedItem = _internalSelectedItem; + + if (!comparer.Equals(newSelectedItem, currentSelectedItem)) + { + // Sync _internalSelectedItems with the new value + _internalSelectedItems = newSelectedItem is not null ? [newSelectedItem] : []; + SelectedItem = newSelectedItem; + } + } + await base.SetParametersAsync(parameters); } @@ -271,7 +282,7 @@ public override async Task SetParametersAsync(ParameterView parameters) /// private async Task InternalSelectedItemsChangedHandlerAsync(IEnumerable items) { - var comparer = OptionSelectedComparer ?? EqualityComparer.Default; + var comparer = OptionSelectedComparer ?? OptionComparer; var itemsToAdd = items.Where(item => !_internalSelectedItems.Contains(item, comparer)).ToList(); var itemsToRemove = _internalFilteredItems.Where(item => !items.Contains(item, comparer)).ToList(); diff --git a/tests/Core/Components/List/FluentAutocompleteTests.razor b/tests/Core/Components/List/FluentAutocompleteTests.razor index ed0f8960d7..7f6c1fde36 100644 --- a/tests/Core/Components/List/FluentAutocompleteTests.razor +++ b/tests/Core/Components/List/FluentAutocompleteTests.razor @@ -1084,6 +1084,71 @@ Assert.Empty(cut.FindAll("span.fluent-badge")); } + [Fact] + public void FluentAutocomplete_OnSetValue_InitialBoundValue_SelectsItemOnFirstRender() + { + // Arrange - Bind Value with an initial value BEFORE the component renders + SetValueEventArgs? receivedArgs = null; + var value = "Three"; + + // Act - Render the component; OnParametersSetAsync should invoke OnSetValue to resolve the initial Value + var cut = Render(@); + + // Assert - OnSetValue was invoked with the initial Value + Assert.NotNull(receivedArgs); + Assert.Equal("Three", receivedArgs!.Value); + + // Assert - The resolved item is selected and displayed as a badge on the first render + var component = cut.FindComponent>().Instance; + Assert.Equal("Three", component.SelectedItem); + Assert.Single(component.SelectedItems); + Assert.Contains("Three", component.SelectedItems); + + var badge = cut.Find("span.fluent-badge[alone]"); + Assert.Equal("Three", badge.GetAttribute("title")); + } + + [Fact] + public async Task FluentAutocomplete_OnSetValue_AsyncHandler_SelectsResolvedItem() + { + // Arrange - OnSetValue is an async handler; bind an initial Value before the first render + var value = "Six"; + + var cut = Render(@); + + // Assert - The async handler resolved the initial Value and selected the matching item + var component = cut.FindComponent>().Instance; + cut.WaitForAssertion(() => Assert.Equal("Six", cut.Find("span.fluent-badge[alone]").GetAttribute("title"))); + Assert.Equal("Six", component.SelectedItem); + Assert.Single(component.SelectedItems); + Assert.Contains("Six", component.SelectedItems); + + // Act - Change the Value externally to validate that the async handler also runs after render + var newParameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(FluentAutocomplete.Value), "Two" } + }); + await cut.InvokeAsync(() => component.SetParametersAsync(newParameters)); + + // Assert - The async handler resolved the new Value and the selection was updated + cut.WaitForAssertion(() => Assert.Equal("Two", cut.Find("span.fluent-badge[alone]").GetAttribute("title"))); + Assert.Equal("Two", component.SelectedItem); + Assert.Single(component.SelectedItems); + Assert.Contains("Two", component.SelectedItems); + } + private void OnOptionsSearch(OptionsSearchEventArgs e) { e.Items = Digits.Where(i => i.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase))