Skip to content

Commit 398c027

Browse files
authored
[dev-v5] Add FluentAutocomplete (#4662)
1 parent e28e4ff commit 398c027

29 files changed

Lines changed: 2532 additions & 31 deletions

.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ indent_size = 4
109109
end_of_line = lf
110110

111111
[*.{razor,cshtml}]
112+
indent_size = 4
113+
indent_style = space
112114
charset = utf-8-bom
113115

114116
[*.{cs,vb}]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
@page "/Lists/Autocomplete/Debug"
2+
3+
@using static FluentUI.Demo.SampleData.Olympics2024
4+
5+
<div style="margin: 24px;">
6+
7+
<div>Selected items: <b>@string.Join("; ", SelectedCountries.Select(c => c.Name))</b></div>
8+
<div>Search Text: <b>@SearchText</b></div>
9+
10+
<FluentAutocomplete TOption="Country"
11+
TValue="string"
12+
Width="100%"
13+
Label="Select countries"
14+
Placeholder="Type to search..."
15+
ShowProgressIndicator="@ShowProgressIndicator"
16+
MaxAutoHeight="@((MaxAutoHeight) ? "unset" : null)"
17+
OnOptionsSearch="@OnSearchAsync"
18+
Items="@Countries"
19+
OptionText="@(item => item.Name)"
20+
MaxSelectedWidth="@(MaxSelectedWidth ? "40px" : null)"
21+
ShowDismiss="@ShowDismiss"
22+
Multiple="@Multiple"
23+
MaximumSelectedOptions="@(SetMaximumSelectedOptions ? 4 : (int?)null)"
24+
@bind-Value="@SearchText"
25+
@bind-SelectedItems="@SelectedCountries">
26+
27+
@* Drop-down item template *@
28+
<OptionTemplate>
29+
<FluentStack Style="pointer-events: none;" VerticalAlignment="VerticalAlignment.Center">
30+
<FluentAvatar Image="@context.Flag()"
31+
Name="@context.Name"
32+
Size="AvatarSize.Size20" />
33+
<FluentText Margin="@Margin.Left4">
34+
@context.Name
35+
</FluentText>
36+
</FluentStack>
37+
</OptionTemplate>
38+
39+
@* Content displayed at the top of the drop-down list *@
40+
<HeaderContent>
41+
<FluentText Size="TextSize.Size200" Color="Color.Primary" Align="TextAlign.Center">
42+
Suggested contacts
43+
</FluentText>
44+
<FluentProgressBar Thickness="ProgressThickness.Large" Visible="@context.InProgress" />
45+
</HeaderContent>
46+
47+
@* Content displayed at the bottom of the drop-down list *@
48+
<FooterContent>
49+
@if (!context.Items.Any())
50+
{
51+
<FluentText Size="TextSize.Size200" Align="TextAlign.Center" Color="Color.Error" Style="width: 100%;">
52+
No results found
53+
</FluentText>
54+
}
55+
</FooterContent>
56+
57+
</FluentAutocomplete>
58+
59+
<FluentStack Orientation="Orientation.Vertical">
60+
<FluentSwitch @bind-Value="@ShowProgressIndicator" Label="Show progress indicator" />
61+
<FluentSwitch @bind-Value="@MaxAutoHeight" Label="Auto height" />
62+
<FluentSwitch @bind-Value="@MaxSelectedWidth" Label="Set a max selected width (40px)" />
63+
<FluentSwitch @bind-Value="@ShowDismiss" Label="Show search or dismiss button" />
64+
<FluentSwitch @bind-Value="@Multiple" Label="Multiple selection" />
65+
<FluentSwitch @bind-Value="@SetMaximumSelectedOptions" Label="Set MaximumSelectedOptions to 4" />
66+
<FluentButton OnClick="@SetSelectedCountriesAsync">
67+
Set SelectedCountries to [be, fr]
68+
</FluentButton>
69+
</FluentStack>
70+
71+
72+
<FluentAutocomplete TOption="Country"
73+
TValue="string"
74+
Width="100%"
75+
MaximumOptionsSearch="int.MaxValue"
76+
Label="Select countries from Items"
77+
Items="@Countries"
78+
OptionText="@(item => item.Name)"
79+
@bind-SelectedItems="@SelectedCountries" />
80+
81+
</div>
82+
83+
@code
84+
{
85+
bool ShowProgressIndicator { get; set; }
86+
bool MaxAutoHeight { get; set; }
87+
bool MaxSelectedWidth { get; set; }
88+
bool ShowDismiss { get; set; } = true;
89+
bool Multiple { get; set; } = true;
90+
bool SetMaximumSelectedOptions { get; set; }
91+
string? SearchText { get; set; }
92+
IEnumerable<Country> SelectedCountries { get; set; } = [];
93+
94+
async Task OnSearchAsync(OptionsSearchEventArgs<Country> e)
95+
{
96+
if (ShowProgressIndicator)
97+
{
98+
await Task.Delay(500); // Simulate async search
99+
}
100+
101+
e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase))
102+
.OrderBy(i => i.Name);
103+
}
104+
105+
async Task SetSelectedCountriesAsync()
106+
{
107+
SelectedCountries = Countries.Where(i => i.Code == "be" || i.Code == "fr");
108+
await Task.CompletedTask;
109+
}
110+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<div>Selected: <b>@string.Join("; ", Selected.Select(x => x.Name))</b></div>
2+
3+
<FluentAutocomplete TOption="MyUser"
4+
TValue="int"
5+
Width="100%"
6+
Label="Name"
7+
Placeholder="Select a user"
8+
OptionText="(option) => option.Name"
9+
OptionValue="(option) => option.UserId"
10+
OptionSelectedComparer="MyComparer.Instance"
11+
OnOptionsSearch="@OnSearchAsync"
12+
@bind-SelectedItems="@Selected" />
13+
14+
@code
15+
{
16+
IEnumerable<MyUser> Selected { get; set; } = [];
17+
18+
/// Search method called when the user types in the input or opens the options list.
19+
/// A new list of MyUser is assigned with new object instances.
20+
/// The component will use the OptionSelectedComparer to check if any of the new items are already selected.
21+
Task OnSearchAsync(OptionsSearchEventArgs<MyUser> e)
22+
{
23+
e.Items = [
24+
new MyUser(1, "Marvin Klein"),
25+
new MyUser(2, "Denis Voituron"),
26+
];
27+
28+
return Task.CompletedTask;
29+
}
30+
31+
record MyUser(int UserId, string Name);
32+
33+
class MyComparer : IEqualityComparer<MyUser>
34+
{
35+
public static readonly MyComparer Instance = new();
36+
37+
public bool Equals(MyUser? x, MyUser? y) => x?.UserId == y?.UserId;
38+
39+
public int GetHashCode(MyUser obj) => obj.UserId.GetHashCode();
40+
}
41+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
@using static FluentUI.Demo.SampleData.Olympics2024
2+
3+
<div>Selected: <b>@string.Join("; ", SelectedCountries.Select(c => c.Name))</b></div>
4+
<div>Search Text: <b>@SearchText</b></div>
5+
6+
<FluentAutocomplete TOption="Country"
7+
TValue="string"
8+
Width="100%"
9+
Label="Select countries"
10+
Placeholder="Type to search..."
11+
ShowProgressIndicator="@ShowProgressIndicator"
12+
MaxAutoHeight="@((MaxAutoHeight) ? "unset" : null)"
13+
OnOptionsSearch="@OnSearchAsync"
14+
OptionText="@(item => item.Name)"
15+
MaxSelectedWidth="@(MaxSelectedWidth ? "40px" : null)"
16+
ShowDismiss="@ShowDismiss"
17+
@bind-Value="@SearchText"
18+
@bind-SelectedItems="@SelectedCountries">
19+
20+
@* Template used with each Option items *@
21+
<OptionTemplate>
22+
<FluentStack Style="pointer-events: none;" VerticalAlignment="VerticalAlignment.Center">
23+
<FluentAvatar Image="@context.Flag()"
24+
Name="@context.Name"
25+
Size="AvatarSize.Size20" />
26+
<FluentText Margin="@Margin.Left4">
27+
@context.Name
28+
</FluentText>
29+
</FluentStack>
30+
</OptionTemplate>
31+
32+
33+
@* Content display at the top of the Popup area *@
34+
<HeaderContent>
35+
<FluentText Size="TextSize.Size200" Color="Color.Primary" Align="TextAlign.Center">
36+
Suggested contacts
37+
</FluentText>
38+
<FluentProgressBar Thickness="ProgressThickness.Large" Visible="@context.InProgress" />
39+
</HeaderContent>
40+
41+
@* Content display at the bottom of the Popup area *@
42+
<FooterContent>
43+
@if (!context.InProgress && !context.Items.Any())
44+
{
45+
<FluentText Size="TextSize.Size200"
46+
Align="TextAlign.Center"
47+
Color="Color.Error"
48+
Style="width: 100%;">
49+
No results found
50+
</FluentText>
51+
}
52+
</FooterContent>
53+
54+
</FluentAutocomplete>
55+
56+
<FluentStack Orientation="Orientation.Vertical">
57+
<FluentSwitch @bind-Value="@ShowProgressIndicator" Label="Show progress indicator" />
58+
<FluentSwitch @bind-Value="@MaxAutoHeight" Label="Auto height" />
59+
<FluentSwitch @bind-Value="@MaxSelectedWidth" Label="Set a max selected width (40px)" />
60+
<FluentSwitch @bind-Value="@ShowDismiss" Label="Show search or dismiss button" />
61+
</FluentStack>
62+
63+
@code
64+
{
65+
bool ShowProgressIndicator { get; set; }
66+
bool MaxAutoHeight { get; set; }
67+
bool MaxSelectedWidth { get; set; }
68+
bool ShowDismiss { get; set; } = true;
69+
string? SearchText { get; set; }
70+
IEnumerable<Country> SelectedCountries { get; set; } = [];
71+
72+
async Task OnSearchAsync(OptionsSearchEventArgs<Country> e)
73+
{
74+
if (ShowProgressIndicator)
75+
{
76+
await Task.Delay(500); // Simulate async search
77+
}
78+
79+
e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase))
80+
.OrderBy(i => i.Name);
81+
}
82+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
@using static FluentUI.Demo.SampleData.Olympics2024
2+
3+
<div>Selected: <b>@string.Join("; ", SelectedCountries.Select(c => c.Name))</b></div>
4+
5+
<FluentAutocomplete TOption="Country"
6+
TValue="string"
7+
Width="100%"
8+
Label="Select countries"
9+
Placeholder="Type to search..."
10+
OnOptionsSearch="@OnSearchAsync"
11+
OptionText="@(item => item.Name)"
12+
OptionDisabled="@(e => e.Code == "au")"
13+
@bind-SelectedItems="@SelectedCountries" />
14+
@code
15+
{
16+
IEnumerable<Country> SelectedCountries { get; set; } = [];
17+
18+
Task OnSearchAsync(OptionsSearchEventArgs<Country> e)
19+
{
20+
e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase))
21+
.OrderBy(i => i.Name);
22+
23+
return Task.CompletedTask;
24+
}
25+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
@using static FluentUI.Demo.SampleData.Olympics2024
2+
3+
<div>Selected: <b>@SelectedCountry?.Name</b></div>
4+
5+
<FluentAutocomplete TOption="Country"
6+
TValue="string"
7+
Label="Select a country"
8+
Multiple="false"
9+
Placeholder="Type to search..."
10+
OnOptionsSearch="@OnSearchAsync"
11+
OptionText="@(item => item.Name)"
12+
OptionDisabled="@(e => e.Code == "au")"
13+
@bind-SelectedItems="@SelectedCountries" />
14+
@code
15+
{
16+
IEnumerable<Country> SelectedCountries { get; set; } = [];
17+
18+
Country? SelectedCountry
19+
{
20+
get => SelectedCountries.FirstOrDefault() ?? default;
21+
set => SelectedCountries = value is not null ? [value] : [];
22+
}
23+
24+
Task OnSearchAsync(OptionsSearchEventArgs<Country> e)
25+
{
26+
e.Items = Countries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase))
27+
.OrderBy(i => i.Name);
28+
29+
return Task.CompletedTask;
30+
}
31+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
title: Autocomplete
3+
route: /Lists/Autocomplete
4+
---
5+
6+
# Autocomplete
7+
8+
An **Autocomplete** component is a text input that provides real-time suggestions as the user types.
9+
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.
10+
11+
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.
12+
13+
By default, the `FluentAutocomplete` component compares search results by instance with its internal selected items.
14+
You can control this behavior by providing the `OptionSelectedComparer` parameter.
15+
16+
> **Note:** Accessibility requirements are not yet implemented for this component.
17+
18+
## Keyboard interaction
19+
20+
| Key | Behavior |
21+
|---|---|
22+
| **Type text** | Filters the list of options and triggers the `OnSearchAsync` method to fetch matching results. |
23+
| **Arrow Down / Arrow Up** | Opens the suggestion list and navigates through the items in the suggestion list. |
24+
| **Enter** | Selects the currently highlighted item. |
25+
| **Backspace** | Deletes the most recently selected item (in multi-select mode). |
26+
| **Escape** | Closes the suggestion list without selecting an item. |
27+
28+
<br /><br />
29+
30+
## Default
31+
32+
A basic autocomplete that filters a list of countries as the user types.
33+
Multiple items can be selected, and one option is disabled (`OptionDisabled`).
34+
35+
{{ AutocompleteDefault }}
36+
37+
## Single item (Multiple=false)
38+
39+
Set the `Multiple` parameter to `false` to restrict the selection to a single item.
40+
In this mode, the selected value replaces the input text and no tags are displayed.
41+
42+
{{ AutocompleteMultipleFalse }}
43+
44+
## Customized options
45+
46+
Demonstrates advanced features: a custom `OptionTemplate` to render each option with a flag, a progress indicator during async search,
47+
a configurable max dropdown height, and a max width for selected items.
48+
49+
{{ AutocompleteCustomized }}
50+
51+
## Different object instances from search result
52+
53+
When the `OnOptionsSearch` method returns **new object instances** on each call (e.g. from an API or database query),
54+
the component cannot match them to already-selected items by **reference**.
55+
56+
Use the `OptionSelectedComparer` parameter to provide a custom `IEqualityComparer<TOption>` that compares items by a unique key (such as an ID)
57+
instead of by reference. Without this, previously selected items may not appear as checked in the refreshed list.
58+
59+
{{ AutocompleteComparer }}
60+
61+
## API FluentAutocomplete
62+
63+
{{ API Type=FluentAutocomplete<string,string> }}
64+
65+
## Migrating to v5
66+
67+
{{ INCLUDE File=MigrationFluentAutocomplete }}

examples/Demo/FluentUI.Demo.Client/Documentation/Components/List/Listbox/DebugPages/DebugList.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
TOption="string"
1313
TValue="string"
1414
@bind-SelectedItems="@SelectedItems"
15-
Multiple="true" />
15+
Multiple="false" />
1616

1717
<FluentButton OnClick="@(e => Colors = new[] { "Yellow", "Purple", "Cyan" })">
1818
Yellow, Purple, Cyan

0 commit comments

Comments
 (0)