diff --git a/.editorconfig b/.editorconfig index d303be84ce..ba5e5c6f96 100644 --- a/.editorconfig +++ b/.editorconfig @@ -109,6 +109,8 @@ indent_size = 4 end_of_line = lf [*.{razor,cshtml}] +indent_size = 4 +indent_style = space charset = utf-8-bom [*.{cs,vb}] diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/DebugPages/AutocompleteDebug.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/DebugPages/AutocompleteDebug.razor new file mode 100644 index 0000000000..9533b8d60a --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/DebugPages/AutocompleteDebug.razor @@ -0,0 +1,110 @@ +@page "/Lists/Autocomplete/Debug" + +@using static FluentUI.Demo.SampleData.Olympics2024 + +
+ +
Selected items: @string.Join("; ", SelectedCountries.Select(c => c.Name))
+
Search Text: @SearchText
+ + + + @* Drop-down item template *@ + + + + + @context.Name + + + + + @* Content displayed at the top of the drop-down list *@ + + + Suggested contacts + + + + + @* Content displayed at the bottom of the drop-down list *@ + + @if (!context.Items.Any()) + { + + No results found + + } + + + + + + + + + + + + + Set SelectedCountries to [be, fr] + + + + + + +
+ +@code +{ + bool ShowProgressIndicator { get; set; } + bool MaxAutoHeight { get; set; } + bool MaxSelectedWidth { get; set; } + bool ShowDismiss { get; set; } = true; + bool Multiple { get; set; } = true; + bool SetMaximumSelectedOptions { get; set; } + string? SearchText { get; set; } + IEnumerable SelectedCountries { get; set; } = []; + + async Task OnSearchAsync(OptionsSearchEventArgs e) + { + if (ShowProgressIndicator) + { + await Task.Delay(500); // Simulate async search + } + + e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.Name); + } + + async Task SetSelectedCountriesAsync() + { + SelectedCountries = Countries.Where(i => i.Code == "be" || i.Code == "fr"); + await Task.CompletedTask; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteComparer.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteComparer.razor new file mode 100644 index 0000000000..613b362b3e --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteComparer.razor @@ -0,0 +1,41 @@ +
Selected: @string.Join("; ", Selected.Select(x => x.Name))
+ + + +@code +{ + IEnumerable Selected { get; set; } = []; + + /// Search method called when the user types in the input or opens the options list. + /// A new list of MyUser is assigned with new object instances. + /// The component will use the OptionSelectedComparer to check if any of the new items are already selected. + Task OnSearchAsync(OptionsSearchEventArgs e) + { + e.Items = [ + new MyUser(1, "Marvin Klein"), + new MyUser(2, "Denis Voituron"), + ]; + + return Task.CompletedTask; + } + + record MyUser(int UserId, string Name); + + class MyComparer : IEqualityComparer + { + public static readonly MyComparer Instance = new(); + + public bool Equals(MyUser? x, MyUser? y) => x?.UserId == y?.UserId; + + public int GetHashCode(MyUser obj) => obj.UserId.GetHashCode(); + } +} \ No newline at end of file diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteCustomized.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteCustomized.razor new file mode 100644 index 0000000000..4a2a88f191 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteCustomized.razor @@ -0,0 +1,82 @@ +@using static FluentUI.Demo.SampleData.Olympics2024 + +
Selected: @string.Join("; ", SelectedCountries.Select(c => c.Name))
+
Search Text: @SearchText
+ + + + @* Template used with each Option items *@ + + + + + @context.Name + + + + + + @* Content display at the top of the Popup area *@ + + + Suggested contacts + + + + + @* Content display at the bottom of the Popup area *@ + + @if (!context.InProgress && !context.Items.Any()) + { + + No results found + + } + + + + + + + + + + + +@code +{ + bool ShowProgressIndicator { get; set; } + bool MaxAutoHeight { get; set; } + bool MaxSelectedWidth { get; set; } + bool ShowDismiss { get; set; } = true; + string? SearchText { get; set; } + IEnumerable SelectedCountries { get; set; } = []; + + async Task OnSearchAsync(OptionsSearchEventArgs e) + { + if (ShowProgressIndicator) + { + await Task.Delay(500); // Simulate async search + } + + e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.Name); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteDefault.razor new file mode 100644 index 0000000000..3f221afbbf --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteDefault.razor @@ -0,0 +1,25 @@ +@using static FluentUI.Demo.SampleData.Olympics2024 + +
Selected: @string.Join("; ", SelectedCountries.Select(c => c.Name))
+ + +@code +{ + IEnumerable SelectedCountries { get; set; } = []; + + Task OnSearchAsync(OptionsSearchEventArgs e) + { + e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.Name); + + return Task.CompletedTask; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteMultipleFalse.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteMultipleFalse.razor new file mode 100644 index 0000000000..c7e7791e3f --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/Examples/AutocompleteMultipleFalse.razor @@ -0,0 +1,31 @@ +@using static FluentUI.Demo.SampleData.Olympics2024 + +
Selected: @SelectedCountry?.Name
+ + +@code +{ + IEnumerable SelectedCountries { get; set; } = []; + + Country? SelectedCountry + { + get => SelectedCountries.FirstOrDefault() ?? default; + set => SelectedCountries = value is not null ? [value] : []; + } + + Task OnSearchAsync(OptionsSearchEventArgs e) + { + e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.Name); + + return Task.CompletedTask; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/FluentAutocomplete.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/FluentAutocomplete.md new file mode 100644 index 0000000000..cef71fefbe --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Autocomplete/FluentAutocomplete.md @@ -0,0 +1,67 @@ +--- +title: Autocomplete +route: /Lists/Autocomplete +--- + +# Autocomplete + +An **Autocomplete** component is a text input that provides real-time suggestions as the user types. +It combines a free-text input with a filtered list of options, allowing users to either select from the suggestions or type their own value. + +This is particularly useful when the list of options is large, as the user can narrow down the list op options without needing to scroll through all available items. + +By default, the `FluentAutocomplete` component compares search results by instance with its internal selected items. +You can control this behavior by providing the `OptionSelectedComparer` parameter. + +> **Note:** Accessibility requirements are not yet implemented for this component. + +## Keyboard interaction + +| Key | Behavior | +|---|---| +| **Type text** | Filters the list of options and triggers the `OnSearchAsync` method to fetch matching results. | +| **Arrow Down / Arrow Up** | Opens the suggestion list and navigates through the items in the suggestion list. | +| **Enter** | Selects the currently highlighted item. | +| **Backspace** | Deletes the most recently selected item (in multi-select mode). | +| **Escape** | Closes the suggestion list without selecting an item. | + +

+ +## Default + +A basic autocomplete that filters a list of countries as the user types. +Multiple items can be selected, and one option is disabled (`OptionDisabled`). + +{{ AutocompleteDefault }} + +## Single item (Multiple=false) + +Set the `Multiple` parameter to `false` to restrict the selection to a single item. +In this mode, the selected value replaces the input text and no tags are displayed. + +{{ AutocompleteMultipleFalse }} + +## Customized options + +Demonstrates advanced features: a custom `OptionTemplate` to render each option with a flag, a progress indicator during async search, +a configurable max dropdown height, and a max width for selected items. + +{{ AutocompleteCustomized }} + +## Different object instances from search result + +When the `OnOptionsSearch` method returns **new object instances** on each call (e.g. from an API or database query), +the component cannot match them to already-selected items by **reference**. + +Use the `OptionSelectedComparer` parameter to provide a custom `IEqualityComparer` that compares items by a unique key (such as an ID) +instead of by reference. Without this, previously selected items may not appear as checked in the refreshed list. + +{{ AutocompleteComparer }} + +## API FluentAutocomplete + +{{ API Type=FluentAutocomplete }} + +## Migrating to v5 + +{{ INCLUDE File=MigrationFluentAutocomplete }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Listbox/DebugPages/DebugList.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Listbox/DebugPages/DebugList.razor index f0691d9f2d..d11a77cb34 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Listbox/DebugPages/DebugList.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Listbox/DebugPages/DebugList.razor @@ -12,7 +12,7 @@ TOption="string" TValue="string" @bind-SelectedItems="@SelectedItems" - Multiple="true" /> + Multiple="false" /> Yellow, Purple, Cyan diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentAutocomplete.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentAutocomplete.md new file mode 100644 index 0000000000..88de6b037e --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentAutocomplete.md @@ -0,0 +1,102 @@ +--- +title: Migration FluentAutocomplete +route: /Migration/Autocomplete +hidden: true +--- + +### Type parameter change 💥 + +The component now requires **two** type parameters: `TOption` and `TValue`. + +```csharp +// v4 + + +// v5 + +``` + +### Renamed parameters 💥 + +- `@bind-SelectedOptions` → `@bind-SelectedItems` +- `@bind-SelectedOption` → Use `Multiple="false"` with `@bind-SelectedItems` +- `Appearance` (`FluentInputAppearance`) → `InputAppearance` (`TextInputAppearance`) +- `OptionComparer` → `OptionSelectedComparer` +- `ValueText` / `ValueTextChangedAsync` → `@bind-Value` + +### Removed parameters 💥 + +- `AutoComplete` — browser autocomplete attribute, no longer exposed. +- `Position` (`SelectPosition?`) — popup positioning is now handled internally. +- `OptionStyle` / `OptionClass` — use `OptionTemplate` to customize option rendering. +- `TitleScrollToPrevious` / `TitleScrollToNext` — horizontal scroll navigation has been removed. +- `ShowOverlayOnEmptyResults` — overlay behavior has been removed. +- `Virtualize` / `ItemSize` — virtualization support has not yet implemented. +- `SelectValueOnTab` — tab key behavior has been removed. +- `KeepOpen` — dropdown close behavior is now managed internally. + +### New parameters + +- `TValue` — second type parameter for the value type. +- `MaxSelectedWidth` (`string?`) — maximum width of each selected item badge. +- `ShowDismiss` (`bool`) — controls whether the Search/Clear icon button is displayed. Default is `true`. +- `OptionValue` (`Func?`) — function to extract the value from an option. +- `OptionValueToString` (`Func?`) — function to convert a value to string. +- Various inherited input parameters: `Message`, `MessageTemplate`, `MessageState`, `MessageIcon`, `LabelPosition`, `LabelWidth`, `Margin`, `Padding`, `Tooltip`. + +### HeaderContent / FooterContent context type change 💥 + +The context type for `HeaderContent` and `FooterContent` has been renamed +from `HeaderFooterContent` to `AutocompleteHeaderFooterContent`. +The properties remain the same (`Items` and `InProgress`). + +### Single selection mode 💥 + +In v4, single selection used a separate `@bind-SelectedOption` binding. +In v5, use `Multiple="false"` with `@bind-SelectedItems`. + +```csharp +// v4 + + +// v5 + + +@code +{ + Country? SelectedCountry + { + get => SelectedCountries.FirstOrDefault() ?? default; + set => SelectedCountries = value is not null ? [value] : []; + } +} +``` + +### Automatic height growth + +In v4, the component used horizontal scroll navigation (`FluentFlipper`) when selected items exceeded the available width. +In v5, this horizontal navigation has been removed, and the component **grows vertically** to display all selected items: +set the `MaxAutoHeight` parameter to `unset` or a specific value. +You can also use `MaxSelectedWidth` to truncate long selected item labels, reducing the horizontal space each badge occupies. +This may break existing layouts if you relied on the fixed-height behavior. + +### Migrating to v5 + +| v4 | v5 | +|---|---| +| `TOption` only | `TOption` + `TValue` | +| `@bind-SelectedOptions` | `@bind-SelectedItems` | +| `@bind-SelectedOption` | `Multiple="false"` + `@bind-SelectedItems` | +| `Appearance="FluentInputAppearance.Outline"` | `InputAppearance="TextInputAppearance.Outline"` | +| `OptionComparer` | `OptionSelectedComparer` | +| `ValueText` / `ValueTextChangedAsync` | `@bind-Value` | +| `HeaderFooterContent` | `AutocompleteHeaderFooterContent` | +| `SelectValueOnTab="true"` | _(removed)_ | +| `KeepOpen="true"` | _(removed)_ | + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentList.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentList.md index 0d97a3baf1..a5e31294c3 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentList.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentList.md @@ -1,26 +1,9 @@ --- -title: Migration FluentList (Autocomplete, Combobox, Listbox) +title: Migration FluentList (Combobox, Listbox, Select) route: /Migration/List hidden: true --- -- ### FluentAutocomplete removed 💥 - - `FluentAutocomplete` has been **removed** in V5. - Use `FluentCombobox` with the `FreeOption` parameter as a replacement for autocomplete behavior. - - ```xml - - - - - - Custom option template - - ``` - - ### Two type parameters required 💥 All list components now require two type parameters: `TOption` and `TValue`. diff --git a/src/Core.Scripts/src/Components/List/FluentAutocomplete.ts b/src/Core.Scripts/src/Components/List/FluentAutocomplete.ts new file mode 100644 index 0000000000..d244bcd6e3 --- /dev/null +++ b/src/Core.Scripts/src/Components/List/FluentAutocomplete.ts @@ -0,0 +1,218 @@ +import { DropdownOption, TextInput } from "@fluentui/web-components"; + +export namespace Microsoft.FluentUI.Blazor.Components.Autocomplete { + + /** + * Initializes the FluentAutocomplete component by attaching keyboard navigation event listeners + * to the input element and managing the popover state for option selection. + * @param id The ID of the input element to initialize. + */ + export function initialize(id: string) { + const input = document.getElementById(id) as TextInput; + if (!input) return; + + detectWrappedItems(input); + new AutocompleteKeyboardNav(id, input); + } + + /** + * Sets focus to the input element of the FluentAutocomplete component, allowing users to start typing immediately. + * @param id The ID of the input element to focus. + */ + export function setFocus(id: string) { + const input = document.getElementById(id) as TextInput; + if (!input) return; + + input.focus(); + + // Move the cursor to the end of the input value + const control = (input as any).control as HTMLInputElement; + if (control) { + const len = control.value.length; + control.setSelectionRange(len, len); + } + } + + /** + * Detects if the items in the start slot of the autocomplete input are wrapped to multiple lines and sets an attribute accordingly. + */ + function detectWrappedItems(input: TextInput): void { + + const startSlot = input.querySelector("div[slot='start'] > div") as HTMLElement; + if (!startSlot) return; + + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + if (isFlexWrapped(input, startSlot)) { + input.setAttribute("items-wrapped", "true"); + } else { + input.removeAttribute("items-wrapped"); + } + } + }); + + observer.observe(startSlot); + } + + /** + * Returns true if the items in the container are wrapped to multiple lines. + */ + function isFlexWrapped(input: TextInput, container: HTMLElement): boolean { + const items = Array.from(container.children) as HTMLElement[]; + if (items.length < 2) return false; + + if (input.getAttribute("dismiss") === "hidden") return false; + if (input.hasAttribute("items-wrapped")) return true; + + const firstTop = items[0].offsetTop; + return items.some(item => item.offsetTop !== firstTop); + } + + /** + * Handles keyboard navigation for the FluentAutocomplete component. + * - ArrowDown: Moves hover to the next option. + * - ArrowUp: Moves hover to the previous option. + * - Enter: Selects the currently hovered option. + */ + class AutocompleteKeyboardNav { + + private inputId: string; + private input: TextInput; + + /** + * Initializes the keyboard navigation for the autocomplete input. + */ + constructor(inputId: string, input: TextInput) { + this.inputId = inputId; + this.input = input; + + const popover = this.getPopover(); + if (!popover) throw new Error(`Popover not found for input with id ${inputId}`); + this.Popover = popover as IFluentPopover; + + this.input.addEventListener('keydown', this.keydownHandler); + this.input.addEventListener('input', this.inputChangeHandler); + this.Popover.addEventListener('toggle', this.popoverToggleHandler); + } + + private Popover: IFluentPopover; + + /** + * Handles keydown events on the autocomplete input to manage option hovering and selection. + */ + private keydownHandler = (e: KeyboardEvent): void => { + + const options = this.getOptions(); + const currentIndex = options.findIndex(o => o.hasAttribute('hovered')); + + switch (e.key) { + + case 'ArrowDown': { + e.preventDefault(); + + if (!this.isPopoverOpen()) this.Popover.showPopover(); + + const nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : options.length - 1; + this.setHover(options, nextIndex); + + break; + } + + case 'ArrowUp': { + e.preventDefault(); + + if (!this.isPopoverOpen()) this.Popover.showPopover(); + + const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0; + this.setHover(options, prevIndex); + + break; + } + + case 'Enter': { + if (currentIndex >= 0 && this.isPopoverOpen()) { + e.preventDefault(); + options[currentIndex].click(); + + // Close the popover after selection + this.Popover.closePopover(); + } + break; + } + } + } + + /** + * Returns the popover element associated with the autocomplete input. + */ + private getPopover(): HTMLElement | null { + return document.querySelector(`fluent-popover-b[anchor-id="${this.inputId}"]`); + } + + /** + * Returns true if the popover is currently open, false otherwise. + */ + private isPopoverOpen(): boolean { + return this.Popover.hasAttribute('opened') && (this.Popover.getAttribute('opened') === 'true' || this.Popover.getAttribute('opened') === '' || this.Popover.getAttribute('opened') === null); + } + + /** + * Returns an array of enabled options within the popover. + */ + private getOptions(): DropdownOption[] { + return Array.from(this.Popover.querySelectorAll('fluent-option:not([disabled])')); + } + + /** + * Sets the hovered attribute on the specified option. + */ + private setHover(options: DropdownOption[], index: number): void { + if (index < 0 || index >= options.length) return; + this.clearAllHovers(); + options[index].setAttribute('hovered', ''); + options[index].tabIndex = 0; + } + + /** + * Sets the hover on the first option in the popover. + */ + private setHoverFirstOption(): void { + const options = this.getOptions(); + if (options.length === 0) return; + this.setHover(options, 0); + } + + /** + * Clears the hovered attribute from all options in the popover. + */ + private clearAllHovers(): void { + this.Popover.querySelectorAll('fluent-option[hovered]').forEach((opt: Element) => { + const option = opt as DropdownOption; + option.removeAttribute('hovered'); + option.tabIndex = -1; + }); + } + + /** + * Clears all hovers when the input value changes to ensure the hover state is reset when the user types. + */ + private inputChangeHandler = (): void => { + this.setHoverFirstOption(); + } + + /** + * Hovers the first option when the popover is opened. + */ + private popoverToggleHandler = (e: Event): void => { + if ((e as CustomEvent).detail?.newState === 'open') { + this.setHoverFirstOption(); + } + } + + } + + interface IFluentPopover extends HTMLElement { + showPopover(): void; + closePopover(): void; + } +} diff --git a/src/Core.Scripts/src/Components/List/ListBoxContainer.ts b/src/Core.Scripts/src/Components/List/ListBoxContainer.ts index a4312b960e..e6be5a5aec 100644 --- a/src/Core.Scripts/src/Components/List/ListBoxContainer.ts +++ b/src/Core.Scripts/src/Components/List/ListBoxContainer.ts @@ -43,6 +43,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.ListBoxContainer { private isInitialized: boolean = false; private container: HTMLElement; private listbox: FluentUIComponents.Listbox; + private pendingSelectedOptionsChange: boolean = false; /** * Initializes a new instance of the ListboxExtended class. @@ -70,7 +71,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.ListBoxContainer { this.listbox.multiple = (this.container.hasAttribute('multiple')) ?? false; // Set initial selected options based on the current state - if (this.listbox.multiple) { + if (this.listbox.multiple && this.listbox.options) { const selectedIds = this.listbox.selectedOptions.map(option => option.id); for (let i = 0; i < this.listbox.options.length; i++) { const option = this.listbox.options[i]; @@ -252,8 +253,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.ListBoxContainer { }); } - if (hasSelectedOptionsChanged) { - this.raiseSelectedOptionsChangeEvent(); + if (hasSelectedOptionsChanged && !this.pendingSelectedOptionsChange) { + this.pendingSelectedOptionsChange = true; + queueMicrotask(() => { + this.pendingSelectedOptionsChange = false; + this.raiseSelectedOptionsChangeEvent(); + }); } }); diff --git a/src/Core.Scripts/src/ExportedMethods.ts b/src/Core.Scripts/src/ExportedMethods.ts index 4eb065ff0e..d15ac2e5ee 100644 --- a/src/Core.Scripts/src/ExportedMethods.ts +++ b/src/Core.Scripts/src/ExportedMethods.ts @@ -10,6 +10,7 @@ import { Microsoft as FluentTextMaskedFile } from './Components/TextInput/TextMa import { Microsoft as FluentTextInput } from './Components/TextInput/TextInput'; import { Microsoft as FluentOverlayFile } from './Components/Overlay/FluentOverlay'; import { Microsoft as FluentListBoxContainerFile } from './Components/List/ListBoxContainer'; +import { Microsoft as FluentAutocompleteFile } from './Components/List/FluentAutocomplete'; export namespace Microsoft.FluentUI.Blazor.ExportedMethods { @@ -40,6 +41,7 @@ export namespace Microsoft.FluentUI.Blazor.ExportedMethods { (window as any).Microsoft.FluentUI.Blazor.Components.TextInput = FluentTextInput.FluentUI.Blazor.Components.TextInput; (window as any).Microsoft.FluentUI.Blazor.Components.Overlay = FluentOverlayFile.FluentUI.Blazor.Components.Overlay; (window as any).Microsoft.FluentUI.Blazor.Components.ListBoxContainer = FluentListBoxContainerFile.FluentUI.Blazor.Components.ListBoxContainer; + (window as any).Microsoft.FluentUI.Blazor.Components.Autocomplete = FluentAutocompleteFile.FluentUI.Blazor.Components.Autocomplete; // [^^^ Add your other exported methods before this line ^^^] } diff --git a/src/Core/Components/Base/FluentInputImmediateBase.cs b/src/Core/Components/Base/FluentInputImmediateBase.cs index 6b61de091a..52312f4f2f 100644 --- a/src/Core/Components/Base/FluentInputImmediateBase.cs +++ b/src/Core/Components/Base/FluentInputImmediateBase.cs @@ -26,10 +26,11 @@ protected FluentInputImmediateBase(LibraryConfiguration configuration) : base(co public bool Immediate { get; set; } = false; /// - /// Gets or sets the delay, in milliseconds, before to raise the event. + /// Gets or sets the delay, in milliseconds, before to raise the event. + /// Default is 200 milliseconds. /// [Parameter] - public int ImmediateDelay { get; set; } = 0; + public int ImmediateDelay { get; set; } = 200; /// /// Handler for the OnInput event, with an optional delay to avoid to raise the event too often. diff --git a/src/Core/Components/Icons/FluentIcon.razor b/src/Core/Components/Icons/FluentIcon.razor index 06e29159d5..47629dcaf9 100644 --- a/src/Core/Components/Icons/FluentIcon.razor +++ b/src/Core/Components/Icons/FluentIcon.razor @@ -15,6 +15,8 @@ aria-hidden="@(Focusable ? null : "true")" @onkeydown="@OnKeyDownAsync" @onclick="@OnClickHandlerAsync" + @onclick:stopPropagation="@OnClickStopPropagation" + @onclick:preventDefault="@OnClickPreventDefault" @attributes="@AdditionalAttributes"> @if (!string.IsNullOrEmpty(Title)) { @@ -35,7 +37,9 @@ else role="@AdditionalAttributes.GetValueIfNoAdditionalAttribute("role", "button", when: () => Focusable)" @attributes="@AdditionalAttributes" @onkeydown="@OnKeyDownAsync" - @onclick="@OnClickHandlerAsync"> + @onclick="@OnClickHandlerAsync" + @onclick:stopPropagation="@OnClickStopPropagation" + @onclick:preventDefault="@OnClickPreventDefault"> @((MarkupString)@_icon.Content) } diff --git a/src/Core/Components/Icons/FluentIcon.razor.cs b/src/Core/Components/Icons/FluentIcon.razor.cs index 37e0611c8a..5294874261 100644 --- a/src/Core/Components/Icons/FluentIcon.razor.cs +++ b/src/Core/Components/Icons/FluentIcon.razor.cs @@ -86,6 +86,18 @@ public Icon Value [Parameter] public EventCallback OnClick { get; set; } + /// + /// Gets or sets whether the click event should stop propagation. + /// + [Parameter] + public bool OnClickStopPropagation { get; set; } + + /// + /// Gets or sets whether the click event should prevent the default action. + /// + [Parameter] + public bool OnClickPreventDefault { get; set; } + /// /// Gets or sets whether the icon is focusable (adding tab-index="0" and role="button"), /// allows the icon to be focused sequentially (generally with the Tab key). diff --git a/src/Core/Components/List/AutocompleteHeaderFooterContent.cs b/src/Core/Components/List/AutocompleteHeaderFooterContent.cs new file mode 100644 index 0000000000..8f2d3875ed --- /dev/null +++ b/src/Core/Components/List/AutocompleteHeaderFooterContent.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// This class represents the content to be displayed in the header or footer of the FluentAutocomplete component. +/// +/// +public class AutocompleteHeaderFooterContent +{ + /// + internal AutocompleteHeaderFooterContent(IEnumerable? items, bool inProgress) + { + Items = items ?? []; + InProgress = inProgress; + } + + /// + /// Gets a value indicating whether the operation is currently in progress. + /// Set to true to refresh this property in the header or footer. + /// + public bool InProgress { get; init; } + + /// + /// Gets the items to display in the header or footer. + /// + public IEnumerable Items { get; init; } +} diff --git a/src/Core/Components/List/FluentAutocomplete.razor b/src/Core/Components/List/FluentAutocomplete.razor new file mode 100644 index 0000000000..87bcabac13 --- /dev/null +++ b/src/Core/Components/List/FluentAutocomplete.razor @@ -0,0 +1,199 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentListBase +@typeparam TOption +@typeparam TValue + + + + + + @* Selected items *@ + + @if (Multiple) + { +
+ @* Show selected items as badges with an "x" button to remove them. + This is shown when the listbox is closed, when it's open the selected items are highlighted + in the listbox and we don't show the badges in the input. *@ + @if (_internalSelectedItems is not null) + { + @foreach (var item in _internalSelectedItems) + { + if (SelectedOptionTemplate is null) + { + + @GetOptionText(item) + + + } + else + { + @SelectedOptionTemplate(item) + } + } + } +
+ } + else + { + @if (_internalSelectedItem is not null) + { +
+ @if (SelectedOptionTemplate is null) + { + + @GetOptionText(_internalSelectedItem) + + } + else + { + @SelectedOptionTemplate(_internalSelectedItem) + } +
+ } + } +
+ + @* Search, Dismiss, Waiting spinner *@ + + @if (ShowDismiss) + { + @if (ShowProgressIndicator && _inProgress) + { + + } + else + { + if (_internalSelectedItems.Any()) + { + + } + else + { + + } + } + } + +
+ + + + @if (IsReachedMaxItems) + { + @if (MaximumSelectedOptionsMessage is null) + { + + @Localizer[Localization.LanguageResource.AutoComplete_MaximumSelectedOptionsMessage, MaximumSelectedOptions ?? 0] + + } + else + { + @MaximumSelectedOptionsMessage + } + } + else + { + + @if (HeaderContent is null && ShowProgressIndicator) + { + + } + + @if (HeaderContent != null) + { + @HeaderContent(new AutocompleteHeaderFooterContent(_internalFilteredItems, _inProgress)) + } + + @if (_internalFilteredItems.Count > 0 && !IsReachedMaxItems) + { + @* The listbox is always in multiple selection mode, because the Listbox with Multiple=false does not support the items changes correctly *@ + + } + + @if (FooterContent != null) + { + @FooterContent(new AutocompleteHeaderFooterContent(_internalFilteredItems, _inProgress)) + } + + + } + + +
+
diff --git a/src/Core/Components/List/FluentAutocomplete.razor.cs b/src/Core/Components/List/FluentAutocomplete.razor.cs new file mode 100644 index 0000000000..cd0cb9db87 --- /dev/null +++ b/src/Core/Components/List/FluentAutocomplete.razor.cs @@ -0,0 +1,408 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// A FluentAutocomplete allows for selecting one or more options from a list of options with autocomplete functionality. +/// +/// +/// +[CascadingTypeParameter(nameof(TValue))] +public partial class FluentAutocomplete : FluentListBase +{ + private static readonly Icon SearchIcon = new CoreIcons.Regular.Size20.Search(); + private static readonly Icon BadgeCloseIcon = new CoreIcons.Regular.Size20.Dismiss(); + private static readonly Icon ClearIcon = new CoreIcons.Regular.Size20.Dismiss(); + + private string? _textInput; + private bool _isOpen; + private bool _inProgress; + + // List of items used in the internally filtered listbox + private List _internalFilteredItems = []; + private List _internalSelectedItems = []; + private TOption? _internalSelectedItem => _internalSelectedItems.FirstOrDefault(); + + /// + public FluentAutocomplete(LibraryConfiguration configuration) : base(configuration) + { + // Default values + Id = Identifier.NewId(); + Multiple = true; + Width = "160px"; + } + + /// + protected override string? StyleValue => new StyleBuilder(base.StyleValue) + .AddStyle("--max-selected-width", MaxSelectedWidth) + .Build(); + + /// + /// Gets or sets the appearance of the text input. + /// Default is . + /// + [Parameter] + public TextInputAppearance InputAppearance { get; set; } = TextInputAppearance.Outline; + + /// + /// Gets or sets the short hint displayed in the input before the user enters a value. + /// + [Parameter] + public string? Placeholder { get; set; } + + /// + /// Gets or sets the delay, in milliseconds, before to raise the event. + /// Default is 400 milliseconds. + /// + [Parameter] + public int ImmediateDelay { get; set; } = 400; + + /// + /// Filter the list of options (items) using the text written by the user. + /// + [Parameter] + public EventCallback> OnOptionsSearch { get; set; } + + /// + public override IEnumerable SelectedItems + { + get => _internalSelectedItems; + set => _internalSelectedItems = Multiple ? [.. value] : [.. value.Take(1)]; + } + + /// + /// Gets or sets the number of maximum options (items) returned by . + /// Default value is 9. + /// + [Parameter] + public int MaximumOptionsSearch { get; set; } = 9; + + /// + /// Gets or sets the maximum number of options (items) selected. + /// Exceeding this value requires the user to delete some elements in order to select new ones. + /// See the . + /// + [Parameter] + public int? MaximumSelectedOptions { get; set; } + + /// + /// Gets or sets the message displayed when the is reached. + /// + [Parameter] + public RenderFragment? MaximumSelectedOptionsMessage { get; set; } + + /// + /// Gets or sets whether the component will display a progress indicator while fetching data. + /// A progress ring will be shown at the end of the component, when the is invoked. + /// You can customize the progress indicator by using the or parameters: see . + /// + [Parameter] + public bool ShowProgressIndicator { get; set; } + + /// + /// Gets or sets the maximum height of the selected items panel. A common value is 'unset' (unlimited) or '200px'. + /// If this parameter is not set, all selected items will be shown on a single line. + /// + [Parameter] + public string? MaxAutoHeight { get; set; } + + /// + /// Gets or sets the maximum width of the selected items. + /// + [Parameter] + public string? MaxSelectedWidth { get; set; } + + /// + /// Gets or sets whether the Search icon or Clear button is displayed. + /// + [Parameter] + public bool ShowDismiss { get; set; } = true; + + /// + /// Gets or sets the icon used for the Clear button. By default: Dismiss icon. + /// + [Parameter] + public Icon IconDismiss { get; set; } = ClearIcon; + + /// + /// Gets or sets the icon used for the Search button. By default: Search icon. + /// + [Parameter] + public Icon IconSearch { get; set; } = SearchIcon; + + /// + /// Gets or sets the template for the selected options, displayed in the autocomplete input text. + /// + [Parameter] + public RenderFragment? SelectedOptionTemplate { get; set; } + + /// + /// Gets or sets the header content, placed at the top of the popup panel. + /// + [Parameter] + public RenderFragment>? HeaderContent { get; set; } + + /// + /// Gets or sets the footer content, placed at the bottom of the popup panel. + /// + [Parameter] + public RenderFragment>? FooterContent { get; set; } + + /// + /// Gets a value indicating whether the number of selected options has reached the maximum defined by . + /// + public bool IsReachedMaxItems => MaximumSelectedOptions.HasValue && _internalSelectedItems.Count >= MaximumSelectedOptions.Value; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Import the JavaScript module + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.Autocomplete.initialize", Id); + } + + await base.OnAfterRenderAsync(firstRender); + } + + /// + /// Raised when the FluentListbox.SelectedItems property changes. + /// + private async Task InternalSelectedItemsChangedHandlerAsync(IEnumerable items) + { + var comparer = OptionSelectedComparer ?? EqualityComparer.Default; + var itemsToAdd = items.Where(item => !_internalSelectedItems.Contains(item, comparer)).ToList(); + var itemsToRemove = _internalFilteredItems.Where(item => !items.Contains(item, comparer)).ToList(); + + // Multiple = True + if (Multiple) + { + // Add items that are in 'items' but not already in _internalSelectedItems + _internalSelectedItems.AddRange(itemsToAdd); + + // Remove items that are in '_internalFilteredItems' but not in 'items' anymore + foreach (var item in itemsToRemove) + { + _internalSelectedItems.RemoveAll(selectedItem => comparer.Equals(selectedItem, item)); + } + } + + // Multiple = False + else + { + var selectedItem = _internalSelectedItems.FirstOrDefault(); + var isInsideFilteredItems = _internalFilteredItems.Any(item => comparer.Equals(item, selectedItem)); + if (!items.Any() && isInsideFilteredItems) + { + _internalSelectedItems.Clear(); + } + else + { + var singleItemToAdd = itemsToAdd.FirstOrDefault(); + if (singleItemToAdd != null) + { + _internalSelectedItems.Clear(); + _internalSelectedItems.Add(singleItemToAdd); + } + } + } + + // Raise event + if (SelectedItemsChanged.HasDelegate) + { + await SelectedItemsChanged.InvokeAsync(_internalSelectedItems); + } + + await SetInputFocusAsync(); + } + + /// + /// Detect when the user presses 'Backspace' or 'ArrowDown' keys in the text input. + /// + /// + /// + private async Task OnTextInputKeyDownAsync(KeyboardEventArgs args) + { + switch (args.Key) + { + // When Backspace is pressed and there is no text in the input, remove the last selected item + case "Backspace": + case "Delete": + if (string.IsNullOrEmpty(_textInput) && _internalSelectedItems.Any()) + { + await RemoveSelectedItemAsync(_internalSelectedItems.Last()); + } + + break; + + // When ArrowDown is pressed and the listbox is closed, open it + // If there are no yet any items in the list, it means the user hasn't typed anything, so we can open the listbox and show all the options + case "ArrowDown": + if (!_isOpen) + { + await DisplayFilteredOptionsAsync(showWhenInputIsEmpty: true); + } + + break; + + case "Enter": + // WARN: The option selection feature is done using JS code (FluentAutocomplete.ts) + + // If not yet open, do the same as pressing ArrowDown. + if (!_isOpen) + { + await OnTextInputKeyDownAsync(new KeyboardEventArgs { Key = "ArrowDown" }); + } + + // If already open, close the listbox and let the JS code handle the rest of the logic for selecting the option. + else + { + _isOpen = false; + + if (!string.IsNullOrEmpty(_textInput)) + { + _textInput = string.Empty; + if (ValueChanged.HasDelegate) + { + await ValueChanged.InvokeAsync((TValue)(object)_textInput); + } + } + } + + break; + } + } + + /// + /// When the user types in the input, display the listbox with the filtered options. + /// + /// + internal async Task DisplayFilteredOptionsAsync(bool showWhenInputIsEmpty) + { + // Raise the ValueChanged event to notify the parent component. + if (ValueChanged.HasDelegate) + { + var value = _textInput ?? string.Empty; + await ValueChanged.InvokeAsync((TValue)(object)value); + } + + // If the input is empty, we don't show any options in the listbox, and we close it if it was open + if (!showWhenInputIsEmpty && string.IsNullOrEmpty(_textInput)) + { + _isOpen = false; + StateHasChanged(); + return; + } + + _inProgress = true; + _isOpen = true; + + StateHasChanged(); + + // Raise the OnOptionsSearch event to get the filtered list of items. + if (OnOptionsSearch.HasDelegate) + { + var args = new OptionsSearchEventArgs() + { + Items = [], + Text = _textInput ?? string.Empty, + }; + + await OnOptionsSearch.InvokeAsync(args); + + _internalFilteredItems = [.. args.Items?.Take(MaximumOptionsSearch) ?? []]; + } + + // Use the Items parameter to filter the list of items + else if (Items != null) + { + _internalFilteredItems = [.. Items.Where(item => GetOptionText(item)?.StartsWith(_textInput ?? string.Empty, StringComparison.InvariantCultureIgnoreCase) == true).Take(MaximumOptionsSearch)]; + } + + // No source of items provided + else + { + _internalFilteredItems = []; + } + + _inProgress = false; + } + + /// + private Task DisplayFilteredOptionsAsync() => DisplayFilteredOptionsAsync(showWhenInputIsEmpty: true); + + /// + /// When the user clicks the "x" button or presses Backspace with an empty input, remove the selected or latest item. + /// + /// + /// + internal async Task RemoveSelectedItemAsync(TOption? item) + { + if (item is null) + { + return; + } + + _isOpen = false; + _internalSelectedItems.Remove(item); + + if (SelectedItemsChanged.HasDelegate) + { + await SelectedItemsChanged.InvokeAsync(_internalSelectedItems); + } + } + + /// + /// When the user clicks the search icon, open or close the listbox with the filtered options depending on its current state. + /// + private async Task SwitchOptionsPopupAsync() + { + if (_isOpen) + { + _isOpen = false; + } + else + { + await DisplayFilteredOptionsAsync(showWhenInputIsEmpty: true); + } + } + + /// + /// When the user clicks the "x" button to clear the selection, remove all selected items and close the listbox. + /// + /// + private async Task ClearSelectionAsync() + { + _isOpen = false; + _internalSelectedItems.Clear(); + + if (SelectedItemsChanged.HasDelegate) + { + await SelectedItemsChanged.InvokeAsync(_internalSelectedItems); + } + } + + private async Task OnOptionsPopupClosedAsync() + { + // After closing the popup + if (!_isOpen) + { + await SetInputFocusAsync(); + } + } + + /// + /// Sets the focus to the text input element. + /// + private async Task SetInputFocusAsync() + { + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.Autocomplete.setFocus", Id); + } +} diff --git a/src/Core/Components/List/FluentAutocomplete.razor.css b/src/Core/Components/List/FluentAutocomplete.razor.css new file mode 100644 index 0000000000..99ccabff66 --- /dev/null +++ b/src/Core/Components/List/FluentAutocomplete.razor.css @@ -0,0 +1,130 @@ +fluent-text-input[auto-complete] { + --max-selected-width: unset; + --min-input-width: 48px; +} + +.fluent-listbox[auto-complete] { + border-radius: unset; + border: unset; + box-shadow: unset; + padding: unset; + max-height: 400px; +} + +fluent-text-input[auto-complete] div[slot="start"] div { + display: flex; +} + +.fluent-listbox[auto-complete] fluent-option[hovered] { + background-color: var(--colorNeutralBackground1Hover); + color: var(--colorNeutralForeground2Hover); +} + +fluent-text-input[auto-complete] .fluent-badge[auto-complete] { + background: var(--colorNeutralBackground2); + color: var(--colorNeutralForeground1); + border-radius: var(--borderRadiusMedium); + margin-right: var(--spacingHorizontalS); + align-items: center; + white-space: nowrap; +} + +fluent-text-input[auto-complete] .fluent-badge[auto-complete] > svg { + margin-left: var(--spacingHorizontalXS); +} + +fluent-text-input[auto-complete] .fluent-badge[auto-complete] > span { + display: inline-block; + max-width: var(--max-selected-width, unset); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Single selection - 'alone' attribute */ +fluent-text-input[auto-complete] .fluent-badge[auto-complete][alone] { + background: unset; + border: unset; + margin-right: 0; + font-weight: var(--fontWeightRegular); + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); +} + +fluent-text-input:has(span[auto-complete][alone])::part(control) { + display: none; +} + +fluent-text-input:has(span[auto-complete][alone]) div[slot="start"] { + width: calc(100% - 24px); + justify-content: start; +} + +/* MaxAutoHeight */ + +fluent-text-input[auto-complete]:not([items-wrapped]):not([dismiss]):has(div[slot="start"] div[style*="max-height"])::part(root) { + height: unset; + min-height: 32px; + display: grid; + grid-template-columns: auto minmax(var(--min-input-width, 48px), 1fr) 24px; /* Selected items, input (min-width: 48px), clear button (24px) */ +} + +fluent-text-input[auto-complete] div[slot="start"] div[style*="max-height"] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--spacingVerticalXS) 0; + padding: var(--spacingVerticalXS) 0; +} + +/* MaxAutoHeight - Items wrapped - Show Dismiss */ + +fluent-text-input[auto-complete][items-wrapped]:not([dismiss])::part(root) { + height: unset; + min-height: 32px; + display: grid; + grid-template-columns: 1fr 24px; + grid-template-rows: auto auto; +} + +fluent-text-input[auto-complete][items-wrapped]:not([dismiss]) div[slot="start"] { + grid-column: 1 / -1; +} + +fluent-text-input[auto-complete][items-wrapped]:not([dismiss]) div[slot="start"] div[style*="max-height"] { + width: 100%; +} + +fluent-text-input[auto-complete][items-wrapped]:not([dismiss])::part(control) { + grid-column: 1; + width: 100%; + padding: var(--spacingVerticalS) 0; +} + +fluent-text-input[auto-complete][items-wrapped]:not([dismiss]) div[slot="end"] { + grid-column: 2; +} + +/* MaxAutoHeight - Items wrapped - Hide Dismiss */ + +fluent-text-input[auto-complete][dismiss=hidden]::part(root) { + height: unset; + min-height: 32px; + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +fluent-text-input[auto-complete][dismiss=hidden] div[slot="start"] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: fit-content; +} + +fluent-text-input[auto-complete][dismiss=hidden]::part(control) { + flex: 1; + flex-shrink: 0; + min-width: var(--min-input-width, 48px); + padding: var(--spacingVerticalXS) 0; +} \ No newline at end of file diff --git a/src/Core/Components/List/FluentListBase.razor.cs b/src/Core/Components/List/FluentListBase.razor.cs index 436d9bc9cd..6b307eabdb 100644 --- a/src/Core/Components/List/FluentListBase.razor.cs +++ b/src/Core/Components/List/FluentListBase.razor.cs @@ -46,7 +46,7 @@ protected FluentListBase(LibraryConfiguration configuration) : base(configuratio /// Default is `null`. Internally the component uses as default. /// [Parameter] - public ListAppearance? Appearance { get; set; } + public virtual ListAppearance? Appearance { get; set; } /// /// Gets or sets the content to be rendered inside the component. @@ -65,19 +65,19 @@ protected FluentListBase(LibraryConfiguration configuration) : base(configuratio /// Gets or sets whether the list allows multiple selections. /// [Parameter] - public bool Multiple { get; set; } + public virtual bool Multiple { get; set; } /// /// Gets or sets the items that are selected in the list. /// [Parameter] - public IEnumerable SelectedItems { get; set; } = []; + public virtual IEnumerable SelectedItems { get; set; } = []; /// /// Event callback that is invoked when the selected items change. /// [Parameter] - public EventCallback> SelectedItemsChanged { get; set; } + public virtual EventCallback> SelectedItemsChanged { get; set; } /// /// Gets or sets the template for the items. @@ -202,7 +202,12 @@ protected virtual bool GetOptionSelected(TOption? item) return OptionSelectedComparer.Equals(item, currentAsOption); } - return Equals(GetOptionValue(item), CurrentValue); + if (OptionValue is not null || typeof(TOption) == typeof(TValue)) + { + return Equals(GetOptionValue(item), CurrentValue); + } + + return item is null && CurrentValue is null; } /// diff --git a/src/Core/Components/List/FluentListbox.razor b/src/Core/Components/List/FluentListbox.razor index f4e9646b2e..3ec625a394 100644 --- a/src/Core/Components/List/FluentListbox.razor +++ b/src/Core/Components/List/FluentListbox.razor @@ -22,7 +22,15 @@ @onfocusout="@FocusOutHandlerAsync" @attributes="@AdditionalAttributes"> - @RenderOptions() + @if (ChildContent is null && (Items is null || !Items.Any())) + { + @* The web component requires at least one option *@ + + } + else + { + @RenderOptions() + } @RenderExtraFragment() diff --git a/src/Core/Components/List/OptionsSearchEventArgs.cs b/src/Core/Components/List/OptionsSearchEventArgs.cs new file mode 100644 index 0000000000..afa12c1573 --- /dev/null +++ b/src/Core/Components/List/OptionsSearchEventArgs.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// uses this event to return the list of items to display. +/// +/// +public class OptionsSearchEventArgs +{ + /// + /// Gets or sets the text to search. + /// + public string Text { get; set; } = string.Empty; + + /// + /// Gets or sets the list of items to display. + /// + public IEnumerable? Items { get; set; } +} \ No newline at end of file diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index 03696b109d..ded2d0c36c 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -351,4 +351,7 @@ No items + + The maximum number of {0} selected items has been reached. + \ No newline at end of file diff --git a/tests/Core/Components/Base/ComponentBaseTests.cs b/tests/Core/Components/Base/ComponentBaseTests.cs index 7a8e760f28..33de7a6256 100644 --- a/tests/Core/Components/Base/ComponentBaseTests.cs +++ b/tests/Core/Components/Base/ComponentBaseTests.cs @@ -48,6 +48,7 @@ public class ComponentBaseTests : Bunit.BunitContext { typeof(FluentSelect<,>), Loader.MakeGenericType(typeof(int), typeof(int))}, { typeof(FluentCombobox<,>), Loader.MakeGenericType(typeof(int), typeof(int))}, { typeof(FluentListbox<,>), Loader.MakeGenericType(typeof(int), typeof(int))}, + { typeof(FluentAutocomplete<,>), Loader.MakeGenericType(typeof(int), typeof(int))}, { typeof(FluentOption<>), Loader.MakeGenericType(typeof(int))}, { typeof(FluentSlider<>), Loader.MakeGenericType(typeof(int))}, { typeof(FluentRadioGroup<>), Loader.MakeGenericType(typeof(string)) }, diff --git a/tests/Core/Components/Base/InputBaseTests.cs b/tests/Core/Components/Base/InputBaseTests.cs index ebba3c3203..c17c97b22b 100644 --- a/tests/Core/Components/Base/InputBaseTests.cs +++ b/tests/Core/Components/Base/InputBaseTests.cs @@ -35,6 +35,7 @@ public class InputBaseTests : Bunit.BunitContext { typeof(FluentSelect<,>), type => type.MakeGenericType(typeof(string), typeof(string)) }, { typeof(FluentCombobox<,>), type => type.MakeGenericType(typeof(string), typeof(string)) }, { typeof(FluentListbox<,>), type => type.MakeGenericType(typeof(string), typeof(string)) }, + { typeof(FluentAutocomplete<,>), type => type.MakeGenericType(typeof(string), typeof(string)) }, { typeof(FluentSlider<>), type => type.MakeGenericType(typeof(int)) }, { typeof(FluentRadioGroup<>), type => type.MakeGenericType(typeof(string)) }, { typeof(FluentCalendar<>), type => type.MakeGenericType(typeof(DateTime)) }, diff --git a/tests/Core/Components/List/FluentAutocompleteTests.FluentAutocomplete_Default_Items.verified.razor.html b/tests/Core/Components/List/FluentAutocompleteTests.FluentAutocomplete_Default_Items.verified.razor.html new file mode 100644 index 0000000000..eb6506a6db --- /dev/null +++ b/tests/Core/Components/List/FluentAutocompleteTests.FluentAutocomplete_Default_Items.verified.razor.html @@ -0,0 +1,21 @@ + + + + + +
+
+
+
+ + + +
+
+ +
+ +
+
+ + diff --git a/tests/Core/Components/List/FluentAutocompleteTests.FluentAutocomplete_Label.verified.razor.html b/tests/Core/Components/List/FluentAutocompleteTests.FluentAutocomplete_Label.verified.razor.html new file mode 100644 index 0000000000..b11087d944 --- /dev/null +++ b/tests/Core/Components/List/FluentAutocompleteTests.FluentAutocomplete_Label.verified.razor.html @@ -0,0 +1,23 @@ + + + + + + +
+
+
+
+ + + +
+
+ +
+ +
+
+ + diff --git a/tests/Core/Components/List/FluentAutocompleteTests.razor b/tests/Core/Components/List/FluentAutocompleteTests.razor new file mode 100644 index 0000000000..8a879b9779 --- /dev/null +++ b/tests/Core/Components/List/FluentAutocompleteTests.razor @@ -0,0 +1,965 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using Xunit; +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples; +@inherits FluentUITestContext +@code +{ + private readonly string[] Digits = new[] { "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine" }; + + public FluentAutocompleteTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentAutocomplete_Default_Items() + { + // Arrange and Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentAutocomplete_Label() + { + // Arrange and Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentAutocomplete_OnOptionsSearch_NoFilter() + { + // Arrange + var cut = Render(@); + + // Act + cut.Find("fluent-text-input").Click(); + + // Assert + var optionsCount = cut.FindAll("fluent-option").Count(); + Assert.Equal(9, optionsCount); + } + + [Fact] + public void FluentAutocomplete_OnOptionsSearch_Filter() + { + // Arrange + var cut = Render(@); + + // Act + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "F" }); + + // Assert + var optionsCount = cut.FindAll("fluent-option").Count(); + Assert.Equal(2, optionsCount); // "Four" and "Five" + } + + [Fact] + public void FluentAutocomplete_SelectTwoItems_DisplayedInStartTemplate() + { + // Arrange + var cut = Render(@); + + // Act - Open the dropdown to display all options + cut.Find("fluent-text-input").Click(); + + // Get the IDs of the first two options ("Eight" and "Five" - sorted alphabetically) + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + + // Select those two options via the listchange event + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Assert - Verify the 2 selected items appear as badges in the StartTemplate + var badges = cut.FindAll("span.fluent-badge"); + Assert.Equal(2, badges.Count); + Assert.Equal("Eight", badges[0].GetAttribute("title")); + Assert.Equal("Five", badges[1].GetAttribute("title")); + } + + [Fact] + public void FluentAutocomplete_RemoveSelectedItem_ByClickingBadgeClose() + { + // Arrange + IEnumerable? lastSelectedItems = null; + var cut = Render(@); + + // Act - Open the dropdown and select two items + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Verify 2 badges are present + var badges = cut.FindAll("span.fluent-badge"); + Assert.Equal(2, badges.Count); + + // Act - Click the close icon on the first badge ("Eight") to remove it + cut.Find("span.fluent-badge svg").Click(); + + // Assert - Only "Five" remains + badges = cut.FindAll("span.fluent-badge"); + Assert.Single(badges); + Assert.Equal("Five", badges[0].GetAttribute("title")); + + // Assert - SelectedItemsChanged should have been raised with only "Five" + Assert.NotNull(lastSelectedItems); + Assert.Single(lastSelectedItems!); + Assert.Contains("Five", lastSelectedItems!); + } + + [Fact] + public void FluentAutocomplete_SelectedOptionTemplate() + { + // Arrange + var cut = Render(@ + +
@context
+
+
); + + // Act - Open the dropdown and select two items + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Assert - Verify the custom template is used instead of default badges + var badges = cut.FindAll("span.fluent-badge"); + Assert.Empty(badges); + + // Verify the custom template renders
Eight
and
Five
+ var markup = cut.Markup; + Assert.Contains("
Eight
", markup); + Assert.Contains("
Five
", markup); + } + + [Fact] + public void FluentAutocomplete_SingleSelect_SecondItemReplacesFirst() + { + // Arrange + var cut = Render(@); + + // Act - Open the dropdown to display all options + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); // "Eight" + var id2 = options[1].GetAttribute("id"); // "Five" + + // Select the first item ("Eight") + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = id1 }); + + // Assert - "Eight" is displayed in the StartTemplate + var badge = cut.Find("span.fluent-badge[alone]"); + Assert.Equal("Eight", badge.GetAttribute("title")); + + // Act - Open dropdown again and select the second item ("Five") + cut.Find("fluent-text-input").Click(); + + options = cut.FindAll("fluent-option"); + id2 = options[1].GetAttribute("id"); // "Five" (re-read after re-render) + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = id2 }); + + // Assert - "Five" replaced "Eight" in the StartTemplate + badge = cut.Find("span.fluent-badge[alone]"); + Assert.Equal("Five", badge.GetAttribute("title")); + + // Only one badge should be present + var badges = cut.FindAll("span.fluent-badge"); + Assert.Single(badges); + } + + [Fact] + public void FluentAutocomplete_SingleSelect_SelectedOptionTemplate() + { + // Arrange + var cut = Render(@ + +
@context
+
+
); + + // Act - Open the dropdown and select an item + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); // "Eight" + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = id1 }); + + // Assert - No default badge is rendered + var badges = cut.FindAll("span.fluent-badge"); + Assert.Empty(badges); + + // Verify the custom template is used + var customElement = cut.Find("div.custom-selected"); + Assert.Equal("Eight", customElement.TextContent); + } + + [Fact] + public void FluentAutocomplete_ShowProgressIndicator_DisplaysSpinner() + { + // Arrange - Use a slow OnOptionsSearch that never completes, keeping _inProgress = true + var tcs = new TaskCompletionSource(); + var cut = Render(@); + + // Act - Type to trigger the search (which will stay in progress) + cut.Find("fluent-text-input").Click(); + + // Assert - A FluentSpinner (fluent-spinner) should be rendered in the EndTemplate + var spinner = cut.Find("fluent-spinner"); + Assert.NotNull(spinner); + + // Complete the task to avoid leaking + tcs.SetResult(); + return; + + // Local slow callback + async Task OnSlowOptionsSearch(OptionsSearchEventArgs e) + { + await tcs.Task; + e.Items = Digits; + } + } + + [Fact] + public void FluentAutocomplete_FocusSpinner_ClosesPopover() + { + // Arrange - Use a slow OnOptionsSearch that never completes, keeping _inProgress = true + var tcs = new TaskCompletionSource(); + var cut = Render(@); + + // Act - Open the dropdown (search stays in progress, spinner is shown) + cut.Find("fluent-text-input").Click(); + + // Verify popover is open + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Focus the spinner in the EndTemplate + var spinner = cut.Find("fluent-spinner"); + spinner.TriggerEvent("onfocus", new FocusEventArgs()); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Complete the task to avoid leaking + tcs.SetResult(); + return; + + // Local slow callback + async Task OnSlowOptionsSearch(OptionsSearchEventArgs e) + { + await tcs.Task; + e.Items = Digits; + } + } + + [Fact] + public void FluentAutocomplete_FocusSearchIcon_ClosesPopover() + { + // Arrange + var cut = Render(@); + + // Act - Open the dropdown + cut.Find("fluent-text-input").Click(); + + // Verify popover is open + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Focus the search icon (no items selected, so the search icon is shown) + var icon = cut.Find("div[slot='end'] svg[focusable='true']"); + icon.TriggerEvent("onfocus", new FocusEventArgs()); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + } + + [Fact] + public void FluentAutocomplete_FocusDismissIcon_ClosesPopover() + { + // Arrange + var cut = Render(@); + + // Act - Open the dropdown and select an item + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = id1 }); + + // Re-open the dropdown + cut.Find("fluent-text-input").Click(); + + // Verify popover is open + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Focus the dismiss icon (items selected, so the dismiss icon is shown) + var icon = cut.Find("div[slot='end'] svg[focusable='true']"); + icon.TriggerEvent("onfocus", new FocusEventArgs()); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + } + + [Fact] + public void FluentAutocomplete_IsReachedMaxItems_DefaultMessage() + { + // Arrange + var cut = Render(@); + + // Act - Open the dropdown and select 2 items (reaching the max) + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Re-open the dropdown + cut.Find("fluent-text-input").Click(); + + // Assert - The default max message (FluentText) should be displayed instead of the listbox + var maxMessage = cut.Find("fluent-text"); + Assert.NotNull(maxMessage); + + // The listbox should not be rendered + var listboxes = cut.FindAll(".fluent-listbox"); + Assert.Empty(listboxes); + } + + [Fact] + public void FluentAutocomplete_IsReachedMaxItems_CustomMessage() + { + // Arrange + var cut = Render(@ + +
Maximum reached
+
+
); + + // Act - Open the dropdown and select 2 items (reaching the max) + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Re-open the dropdown + cut.Find("fluent-text-input").Click(); + + // Assert - The custom message should be displayed + var customMessage = cut.Find("div.custom-max-message"); + Assert.Equal("Maximum reached", customMessage.TextContent); + + // The listbox should not be rendered + var listboxes = cut.FindAll(".fluent-listbox"); + Assert.Empty(listboxes); + } + + [Fact] + public void FluentAutocomplete_HeaderContent() + { + // Arrange + var cut = Render(@ + +
Found @context.Items.Count() items
+
+
); + + // Act - Open the dropdown to display options + cut.Find("fluent-text-input").Click(); + + // Assert - The header content should be rendered with the correct item count + var header = cut.Find("div.custom-header"); + Assert.Equal("Found 9 items", header.TextContent); + } + + [Fact] + public void FluentAutocomplete_FooterContent() + { + // Arrange + var cut = Render(@ + + + + ); + + // Act - Open the dropdown to display options + cut.Find("fluent-text-input").Click(); + + // Assert - The footer content should be rendered with the correct item count + var footer = cut.Find("div.custom-footer"); + Assert.Equal("Total: 9", footer.TextContent); + } + + [Fact] + public void FluentAutocomplete_SelectedItemsChanged_RaisedOnSelection() + { + // Arrange + IEnumerable? selectedItems = null; + var cut = Render(@); + + // Act - Open the dropdown and select two items + cut.Find("fluent-text-input").Click(); + + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Assert - The event should have been raised with the 2 selected items + Assert.NotNull(selectedItems); + Assert.Equal(2, selectedItems!.Count()); + Assert.Contains("Eight", selectedItems); + Assert.Contains("Five", selectedItems); + } + + [Fact] + public void FluentAutocomplete_KeyDown_Backspace_RemovesLastSelectedItem() + { + // Arrange + var cut = Render(@); + + // Select two items + cut.Find("fluent-text-input").Click(); + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Verify 2 badges + Assert.Equal(2, cut.FindAll("span.fluent-badge").Count); + + // Act - Press Backspace with empty input + cut.Find("fluent-text-input").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Backspace" }); + + // Assert - Last item ("Five") should be removed, only "Eight" remains + var badges = cut.FindAll("span.fluent-badge"); + Assert.Single(badges); + Assert.Equal("Eight", badges[0].GetAttribute("title")); + } + + [Fact] + public void FluentAutocomplete_KeyDown_Delete_RemovesLastSelectedItem() + { + // Arrange + var cut = Render(@); + + // Select two items + cut.Find("fluent-text-input").Click(); + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + Assert.Equal(2, cut.FindAll("span.fluent-badge").Count); + + // Act - Press Delete with empty input + cut.Find("fluent-text-input").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Delete" }); + + // Assert - Last item ("Five") should be removed, only "Eight" remains + var badges = cut.FindAll("span.fluent-badge"); + Assert.Single(badges); + Assert.Equal("Eight", badges[0].GetAttribute("title")); + } + + [Fact] + public void FluentAutocomplete_KeyDown_ArrowDown_OpensPopover() + { + // Arrange + var cut = Render(@); + + // Verify popover is closed + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Act - Press ArrowDown + cut.Find("fluent-text-input").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" }); + + // Assert - Popover should be open + popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + } + + [Fact] + public void FluentAutocomplete_KeyDown_Enter_TogglesPopover() + { + // Arrange + var cut = Render(@); + + // Verify popover is closed + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Act - Press Enter when closed → should open + cut.Find("fluent-text-input").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" }); + + popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Press Enter when open → should close + cut.Find("fluent-text-input").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" }); + + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + } + + [Fact] + public void FluentAutocomplete_KeyDown_Enter_ClearsTextInputAndRaisesValueChanged() + { + // Arrange + string? valueChangedResult = null; + var cut = Render(@); + + // Act - Type "F" to set the text input and open the popover + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "F" }); + + // Verify popover is open and options are filtered + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + Assert.Equal(2, cut.FindAll("fluent-option").Count); + + // Act - Press Enter while popover is open and text input has content + cut.Find("fluent-text-input").TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" }); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Assert - ValueChanged should have been called with empty string (text cleared) + Assert.Equal(string.Empty, valueChangedResult); + } + + [Fact] + public void FluentAutocomplete_DisplayFilteredOptions_RaisesValueChanged() + { + // Arrange + var valueChangedHistory = new List(); + var cut = Render(@); + + // Act - Type "F" to trigger DisplayFilteredOptionsAsync via @bind-Value:after + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "F" }); + + // Assert - ValueChanged should have been raised with the typed text "F" + Assert.Contains("F", valueChangedHistory); + + // Act - Type "Fo" to trigger again + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "Fo" }); + + // Assert - ValueChanged should have been raised with "Fo" + Assert.Contains("Fo", valueChangedHistory); + } + + [Fact] + public void FluentAutocomplete_DisplayFilteredOptions_EmptyInput_ClosesPopover() + { + // Arrange + var cut = Render(@); + + // Act - Type "F" to open the popover with filtered options + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "F" }); + + // Verify popover is open + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Clear the text input (set to empty string) + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "" }); + + // Assert - Popover should still be open (showWhenInputIsEmpty: true from @bind-Value:after) + // and all 9 options should be displayed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + Assert.Equal(9, cut.FindAll("fluent-option").Count); + } + + [Fact] + public void FluentAutocomplete_Items_BuiltInFilter() + { + // Arrange - Use Items directly without OnOptionsSearch to trigger the built-in filtering + var cut = Render(@); + + // Act - Type "F" to filter using the built-in StartsWith logic + cut.Find("fluent-text-input") + .TriggerEvent("ontextimmediate", new ChangeEventArgs() { Value = "F" }); + + // Assert - Only items starting with "F" should be displayed: "Four" and "Five" + var options = cut.FindAll("fluent-option"); + Assert.Equal(2, options.Count); + } + + [Fact] + public void FluentAutocomplete_NoItemsNoSearch_EmptyFilteredList() + { + // Arrange - No Items and no OnOptionsSearch provided + var cut = Render(@); + + // Act - Click to open the dropdown + cut.Find("fluent-text-input").Click(); + + // Assert - No options should be displayed + var options = cut.FindAll("fluent-option"); + Assert.Empty(options); + } + + [Fact] + public void FluentAutocomplete_SearchIcon_Click_OpensPopover() + { + // Arrange - No items selected, so the search icon is shown + var cut = Render(@); + + // Verify popover is closed + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Act - Click the search icon to trigger SwitchOptionsPopupAsync (else branch) + var icon = cut.Find("div[slot='end'] svg[focusable='true']"); + icon.Click(); + + // Assert - Popover should be open with all options displayed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + Assert.Equal(9, cut.FindAll("fluent-option").Count); + } + + [Fact] + public void FluentAutocomplete_SearchIcon_Click_ClosesOpenPopover() + { + // Arrange + var cut = Render(@); + + // Open the popover first by clicking the input + cut.Find("fluent-text-input").Click(); + + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Click the search icon to trigger SwitchOptionsPopupAsync (if branch: _isOpen = false) + var icon = cut.Find("div[slot='end'] svg[focusable='true']"); + icon.Click(); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + } + + [Fact] + public void FluentAutocomplete_ClearSelection_RemovesAllItemsAndRaisesEvent() + { + // Arrange + IEnumerable? lastSelectedItems = null; + var cut = Render(@); + + // Select two items + cut.Find("fluent-text-input").Click(); + var options = cut.FindAll("fluent-option"); + var id1 = options[0].GetAttribute("id"); + var id2 = options[1].GetAttribute("id"); + cut.Find(".fluent-listbox") + .TriggerEvent("onlistchange", new DropdownEventArgs { SelectedOptions = $"{id1};{id2}" }); + + // Verify 2 badges and SelectedItemsChanged was raised with 2 items + Assert.Equal(2, cut.FindAll("span.fluent-badge").Count); + Assert.Equal(2, lastSelectedItems!.Count()); + + // Re-open the dropdown so the dismiss icon is clickable + cut.Find("fluent-text-input").Click(); + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Click the dismiss icon to trigger ClearSelectionAsync + var dismissIcon = cut.Find("div[slot='end'] svg[focusable='true']"); + dismissIcon.Click(); + + // Assert - All selected items should be cleared + var badges = cut.FindAll("span.fluent-badge"); + Assert.Empty(badges); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Assert - SelectedItemsChanged should have been raised with an empty collection + Assert.NotNull(lastSelectedItems); + Assert.Empty(lastSelectedItems!); + } + + [Fact] + public async Task FluentAutocomplete_DisplayFilteredOptions_EmptyInput_NotShowWhenEmpty_ClosesPopover() + { + // Arrange + var cut = Render(@); + + // Open the popover first + cut.Find("fluent-text-input").Click(); + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Act - Call DisplayFilteredOptionsAsync with showWhenInputIsEmpty: false and empty text input + var component = cut.FindComponent>().Instance; + await cut.InvokeAsync(() => component.DisplayFilteredOptionsAsync(showWhenInputIsEmpty: false)); + + // Assert - Popover should be closed (early return branch) + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + } + + [Fact] + public void FluentAutocomplete_PopoverClosed_SetsFocusToInput() + { + // Arrange + var cut = Render(@); + + // Open the popover + cut.Find("fluent-text-input").Click(); + var popover = cut.Find("fluent-popover-b"); + Assert.Equal("true", popover.GetAttribute("opened")); + + // Get the popover's Id for the dialogtoggle event + var popoverId = popover.GetAttribute("id"); + + // Act - Close the popover by triggering the dialogtoggle event (simulates popover closing) + popover.TriggerEvent("ondialogtoggle", new DialogToggleEventArgs { Id = popoverId, NewState = "closed", OldState = "open" }); + + // Assert - Popover should be closed + popover = cut.Find("fluent-popover-b"); + Assert.Equal("false", popover.GetAttribute("opened")); + + // Assert - SetInputFocusAsync should have been called via OnOptionsPopupClosedAsync + var jsInvocation = JSInterop.Invocations + .FirstOrDefault(i => i.Identifier == "Microsoft.FluentUI.Blazor.Components.Autocomplete.setFocus"); + + Assert.Equal("Microsoft.FluentUI.Blazor.Components.Autocomplete.setFocus", jsInvocation.Identifier); + Assert.Equal("my-list", jsInvocation.Arguments[0]); + } + + [Fact] + public void FluentAutocomplete_BindSelectedItems_Multiple_AllItemsPreselected() + { + // Arrange - Pre-select "Three" and "Six" via @bind-SelectedItems with Multiple=true + IEnumerable selectedItems = new List { "Three", "Six" }; + var cut = Render(@); + + // Assert - Both items should appear as badges in the StartTemplate + var badges = cut.FindAll("span.fluent-badge"); + Assert.Equal(2, badges.Count); + Assert.Equal("Three", badges[0].GetAttribute("title")); + Assert.Equal("Six", badges[1].GetAttribute("title")); + + // Assert - Reading SelectedItems from the component returns the same items (get => _internalSelectedItems) + var component = cut.FindComponent>().Instance; + Assert.Equal(2, component.SelectedItems.Count()); + Assert.Contains("Three", component.SelectedItems); + Assert.Contains("Six", component.SelectedItems); + } + + [Fact] + public void FluentAutocomplete_BindSelectedItems_SingleSelect_TakesOnlyFirstItem() + { + // Arrange - Pass two items via @bind-SelectedItems with Multiple=false + IEnumerable selectedItems = new List { "Three", "Six" }; + var cut = Render(@); + + // Assert - Only the first item should be selected (value.Take(1)) + var badge = cut.Find("span.fluent-badge[alone]"); + Assert.Equal("Three", badge.GetAttribute("title")); + + // Only one badge should be present + var badges = cut.FindAll("span.fluent-badge"); + Assert.Single(badges); + } + + [Fact] + public async Task FluentAutocomplete_RemoveSelectedItemAsync_NullItem_DoesNothing() + { + // Arrange - Render with two pre-selected items + var selectedItemsChangedCount = 0; + IEnumerable selectedItems = new List { "Three", "Six" }; + var cut = Render(@); + + // Act - Call RemoveSelectedItemAsync with null + var component = cut.FindComponent>().Instance; + await cut.InvokeAsync(() => component.RemoveSelectedItemAsync(null)); + + // Assert - Nothing changed: still 2 badges, event not raised + var badges = cut.FindAll("span.fluent-badge"); + Assert.Equal(2, badges.Count); + Assert.Equal(0, selectedItemsChangedCount); + } + + private void OnOptionsSearch(OptionsSearchEventArgs e) + { + e.Items = Digits.Where(i => i.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i); + } +}