Skip to content

Commit b8afdf0

Browse files
StevenRasmussendvoituronvnbaaij
authored
FluentAutoComplete: Enable "Multiple = false" when selecting a single item is desired. (microsoft#3571)
* Autocomplete working with single item selected. * Added a unit test and updated the results of the failing unit tests due to the class change. * Fixed whitespace issues. * Updates to demo page. * Revert changes to class names so that it won't break anyone that is depending on the current name. * Revert change to css. * Accessibility changes. * Updated to use a new attribute instead of a new class. * Fix unit tests. * Fix whitespace change. * Reverted test files to remove noise from PR. * Fixed an issue with really long text * Improve performance of line of code. * Removed the usage of addiing to the 'AdditionalAttributes' in favor of directly putting the attribute on the element and evaluating with a function. * Added the tab stop for accessibility. * Fixed broken unit test after adding in the tabindex property. * Update the subgrid to handle N:N relationships. * Multiple = false working. * Fixed unit tests. Removed unnecessary unit test. * Fixed code standards. Guarded against a null property. --------- Co-authored-by: Denis Voituron <dvoituron@outlook.com> Co-authored-by: Vincent Baaij <vnbaaij@outlook.com>
1 parent d0fcacd commit b8afdf0

9 files changed

+250
-203
lines changed

examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5918,12 +5918,6 @@
59185918
For the FluentAutocomplete component, use the <see cref="P:Microsoft.FluentUI.AspNetCore.Components.FluentAutocomplete`1.ValueText"/> property instead.
59195919
</summary>
59205920
</member>
5921-
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentAutocomplete`1.Multiple">
5922-
<summary>
5923-
For <see cref="T:Microsoft.FluentUI.AspNetCore.Components.FluentAutocomplete`1"/>, this property must be True.
5924-
Set the <see cref="P:Microsoft.FluentUI.AspNetCore.Components.FluentAutocomplete`1.MaximumSelectedOptions"/> property to 1 to select just one item.
5925-
</summary>
5926-
</member>
59275921
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentAutocomplete`1.Appearance">
59285922
<summary>
59295923
Gets or sets the visual appearance. See <seealso cref="T:Microsoft.FluentUI.AspNetCore.Components.Appearance"/>

examples/Demo/Shared/Pages/List/Autocomplete/AutocompletePage.razor

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
<DemoSection Title="Customized options" Component="@typeof(AutocompleteCustomized)" />
2020

21+
<DemoSection Title="Multiple == false" Component="@typeof(AutoCompleteMaxSingleItem)" />
22+
2123
<DemoSection Title="Many Items" Component="@typeof(AutocompleteManyItems)">
2224
<Description>
2325
<p>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
@inject DataSource Data
2+
3+
<FluentAutocomplete TOption="Country"
4+
AutoComplete="off"
5+
Autofocus="true"
6+
Label="Select a country"
7+
Width="250px"
8+
Placeholder="Select a country"
9+
OnOptionsSearch="@OnSearchAsync"
10+
OptionDisabled="@(e => e.Code == "au")"
11+
Multiple=false
12+
OptionText="@(item => item.Name)"
13+
@bind-SelectedOption=SelectedItem />
14+
15+
<p>
16+
<b>Selected</b>: @(SelectedItem?.Name)
17+
</p>
18+
19+
@code
20+
{
21+
Country? SelectedItem = null;
22+
23+
private async Task OnSearchAsync(OptionsSearchEventArgs<Country> e)
24+
{
25+
var allCountries = await Data.GetCountriesAsync();
26+
e.Items = allCountries.Where(i => i.Name.StartsWith(e.Text, StringComparison.OrdinalIgnoreCase))
27+
.OrderBy(i => i.Name);
28+
}
29+
}

src/Core/Components/List/FluentAutocomplete.razor

Lines changed: 67 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<div class="@ClassValue fluent-autocomplete-multiselect"
77
style="@StyleValue"
88
@attributes="AdditionalAttributes"
9+
single-select="@GetSingleSelect()"
910
auto-height="@(!string.IsNullOrEmpty(MaxAutoHeight))">
1011
<FluentKeyCode Anchor="@Id" OnKeyDown="@KeyDownHandlerAsync" Only="@CatchOnly" PreventDefaultOnly="@PreventOnly" />
1112

@@ -31,7 +32,7 @@
3132
autofocus="@Autofocus"
3233
Style="@ComponentWidth">
3334
@* Selected Items *@
34-
@if (this.SelectedOptions?.Any() == true)
35+
@if (this.SelectedOptions?.Any() == true || this.SelectedOption is not null)
3536
{
3637
@* Normal (single) line height *@
3738
if (string.IsNullOrEmpty(MaxAutoHeight))
@@ -67,36 +68,35 @@
6768
@RenderSelectedOptions
6869
</div>
6970
}
70-
7171
}
7272
@if (!Disabled && !ReadOnly)
7373
{
74-
if (this.SelectedOptions?.Any() == true || !string.IsNullOrEmpty(ValueText))
74+
if (this.SelectedOptions?.Any() == true || !string.IsNullOrEmpty(ValueText) || this.SelectedOption is not null)
7575
{
7676
if (IconDismiss != null)
7777
{
78-
<FluentIcon Value="@IconDismiss"
79-
Width="12px"
80-
Style="cursor: pointer;"
81-
Slot="end"
82-
Title="@AccessibilityIconDismiss"
83-
Focusable="true"
84-
@onfocus="@(e => { IsReachedMaxItems = false; IsMultiSelectOpened = false; })"
85-
OnClick="@OnClearAsync" />
78+
<FluentIcon Value="@IconDismiss"
79+
Width="12px"
80+
Style="cursor: pointer;"
81+
Slot="end"
82+
Title="@(Multiple == false ? string.Format(AccessibilityRemoveItem, GetOptionText(SelectedOption)) : AccessibilityIconDismiss)"
83+
Focusable="true"
84+
@onfocus="@(e => { IsReachedMaxItems = false; IsMultiSelectOpened = false; })"
85+
OnClick="@OnClearAsync" />
8686
}
8787
}
8888
else
8989
{
9090
if (IconSearch != null)
9191
{
92-
<FluentIcon Value="@IconSearch"
93-
Width="16px"
94-
Style="cursor: pointer;"
95-
Slot="end"
96-
Title="@AccessibilityIconSearch"
97-
Focusable="true"
98-
@onfocus="@(e => { IsReachedMaxItems = false; IsMultiSelectOpened = false; })"
99-
OnClick="@OnDropDownExpandedAsync" />
92+
<FluentIcon Value="@IconSearch"
93+
Width="16px"
94+
Style="cursor: pointer;"
95+
Slot="end"
96+
Title="@AccessibilityIconSearch"
97+
Focusable="true"
98+
@onfocus="@(e => { IsReachedMaxItems = false; IsMultiSelectOpened = false; })"
99+
OnClick="@OnDropDownExpandedAsync" />
100100
}
101101
}
102102
}
@@ -107,10 +107,10 @@
107107
{
108108
@if (SelectValueOnTab)
109109
{
110-
<FluentKeyCode Anchor="@Id"
111-
OnKeyDown="@KeyDownHandlerAsync"
112-
Only="@SelectValueOnTabOnly"
113-
PreventDefaultOnly="@SelectValueOnTabOnly" />
110+
<FluentKeyCode Anchor="@Id"
111+
OnKeyDown="@KeyDownHandlerAsync"
112+
Only="@SelectValueOnTabOnly"
113+
PreventDefaultOnly="@SelectValueOnTabOnly" />
114114
}
115115

116116
<FluentOverlay OnClose="@(e => IsMultiSelectOpened = false)" Visible="true" Transparent="true" FullScreen="true" />
@@ -122,33 +122,33 @@
122122
Shadow="ElevationShadow.Flyout">
123123
@if (HeaderContent != null)
124124
{
125-
@HeaderContent(Items ?? Array.Empty<TOption>())
125+
@HeaderContent(Items ?? Array.Empty<TOption>())
126126
}
127127

128128
<div id="@IdPopup" role="listbox" style="@ListStyleValue" tabindex="0">
129129
@if (Items != null)
130130
{
131-
var selectableItem = GetOptionValue(SelectableItem);
131+
var selectableItem = GetOptionValue(SelectableItem);
132132

133-
@if (Virtualize)
134-
{
135-
<Virtualize ItemsProvider="LoadFilteredItemsAsync" @ref="VirtualizationContainer" ItemSize="ItemSize">
136-
@RenderOption((context, selectableItem))
137-
</Virtualize>
138-
}
139-
else
140-
{
141-
foreach (TOption item in Items)
133+
@if (Virtualize)
134+
{
135+
<Virtualize ItemsProvider="LoadFilteredItemsAsync" @ref="VirtualizationContainer" ItemSize="ItemSize">
136+
@RenderOption((context, selectableItem))
137+
</Virtualize>
138+
}
139+
else
142140
{
143-
@RenderOption((item, selectableItem))
141+
foreach (TOption item in Items)
142+
{
143+
@RenderOption((item, selectableItem))
144+
}
144145
}
145-
}
146146
}
147147
</div>
148148

149149
@if (FooterContent != null)
150150
{
151-
@FooterContent(Items ?? Array.Empty<TOption>())
151+
@FooterContent(Items ?? Array.Empty<TOption>())
152152
}
153153
</FluentAnchoredRegion>
154154
}
@@ -174,15 +174,15 @@
174174
{
175175
var optionValue = GetOptionValue(context.Item);
176176
<FluentOption TOption="TOption"
177-
@key="@context.Item"
178-
Value="@optionValue"
179-
Style="@OptionStyle"
180-
Class="@OptionClass"
181-
Selected="@GetOptionSelected(context.Item)"
182-
Disabled="@(GetOptionDisabled(context.Item) ?? false)"
183-
OnSelect="@OnSelectCallback(context.Item)"
184-
aria-selected="@(GetOptionSelected(context.Item) || optionValue == context.SelectableItem ? "true" : "false")"
185-
selectable="@(optionValue == context.SelectableItem)">
177+
@key="@context.Item"
178+
Value="@optionValue"
179+
Style="@OptionStyle"
180+
Class="@OptionClass"
181+
Selected="@GetOptionSelected(context.Item)"
182+
Disabled="@(GetOptionDisabled(context.Item) ?? false)"
183+
OnSelect="@OnSelectCallback(context.Item)"
184+
aria-selected="@(GetOptionSelected(context.Item) || optionValue == context.SelectableItem ? "true" : "false")"
185+
selectable="@(optionValue == context.SelectableItem)">
186186
@if (OptionTemplate == null)
187187
{
188188
@GetOptionText(context.Item)
@@ -196,27 +196,41 @@
196196

197197
private RenderFragment RenderSelectedOptions => __builder =>
198198
{
199-
if (SelectedOptions != null)
199+
var selectedOptions = new List<TOption>();
200+
if (Multiple && (SelectedOptions?.Any() ?? false))
201+
{
202+
selectedOptions.AddRange(SelectedOptions);
203+
}
204+
if (!Multiple && SelectedOption is not null)
200205
{
201-
foreach (var item in SelectedOptions)
206+
selectedOptions.Add(SelectedOption);
207+
}
208+
209+
if (selectedOptions.Any())
210+
{
211+
foreach (var item in selectedOptions)
202212
{
203213
if (SelectedOptionTemplate == null)
204214
{
205215
var text = @GetOptionText(item);
206216

207-
if (ReadOnly || Disabled)
217+
if (Multiple == false)
218+
{
219+
<FluentLabel tabindex="0" aria-label="@GetOptionText(item)" role="checkbox" aria-checked="true">@text</FluentLabel>
220+
}
221+
else if (ReadOnly || Disabled)
208222
{
209223
<FluentBadge Appearance="@AspNetCore.Components.Appearance.Neutral"
210-
aria-label="@GetOptionText(item)">
224+
aria-label="@GetOptionText(item)">
211225
@text
212226
</FluentBadge>
213227
}
214228
else
215229
{
216230
<FluentBadge Appearance="@AspNetCore.Components.Appearance.Neutral"
217-
OnDismissClick="@(e => RemoveSelectedItemAsync(item))"
218-
DismissTitle="@(string.Format(AccessibilityRemoveItem, text))"
219-
aria-label="@GetOptionText(item)">
231+
OnDismissClick="@(e => RemoveSelectedItemAsync(item))"
232+
DismissTitle="@(string.Format(AccessibilityRemoveItem, text))"
233+
aria-label="@GetOptionText(item)">
220234
@text
221235
</FluentBadge>
222236
}

src/Core/Components/List/FluentAutocomplete.razor.cs

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,6 @@ public override string? Value
7676
set => base.Value = ValueText;
7777
}
7878

79-
/// <summary>
80-
/// For <see cref="FluentAutocomplete{TOption}"/>, this property must be True.
81-
/// Set the <see cref="MaximumSelectedOptions"/> property to 1 to select just one item.
82-
/// </summary>
83-
public override bool Multiple
84-
{
85-
get
86-
{
87-
return base.Multiple;
88-
}
89-
90-
set
91-
{
92-
if (value == false)
93-
{
94-
throw new ArgumentException("For FluentAutocomplete, this property must be True. Set the MaximumSelectedOptions property to 1 to select just one item.");
95-
}
96-
97-
base.Multiple = true;
98-
}
99-
}
100-
10179
/// <summary>
10280
/// Gets or sets the visual appearance. See <seealso cref="AspNetCore.Components.Appearance"/>
10381
/// </summary>
@@ -245,6 +223,8 @@ public override bool Multiple
245223
.AddStyle("display", "none", when: (Items == null || !Items.Any()) && (HeaderContent != null || FooterContent != null))
246224
.Build();
247225

226+
private bool GetSingleSelect() => Multiple == false && SelectedOption is not null;
227+
248228
/// <summary />
249229
private string ComponentWidth
250230
{
@@ -552,6 +532,7 @@ protected async Task OnClearAsync()
552532
{
553533
RemoveAllSelectedItems();
554534
ValueText = string.Empty;
535+
SelectedOption = default;
555536
await RaiseValueTextChangedAsync(ValueText);
556537
await RaiseChangedEventsAsync();
557538

src/Core/Components/List/FluentAutocomplete.razor.css

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,26 @@
5252
margin-bottom: 2px;
5353
}
5454

55+
.fluent-autocomplete-multiselect[single-select] ::deep fluent-text-field::part(control) {
56+
display: none;
57+
}
58+
59+
.fluent-autocomplete-multiselect[single-select] ::deep fluent-text-field::part(start) {
60+
max-width: calc(100% - 40px);
61+
text-overflow: ellipsis;
62+
overflow: hidden;
63+
white-space: nowrap;
64+
}
65+
5566
@media (forced-colors: active) {
5667

5768
.fluent-autocomplete-multiselect div[role=listbox] {
5869
border: calc(var(--stroke-width)* 1px) solid transparent;
5970
}
6071

61-
.fluent-autocomplete-multiselect div[role=listbox] ::deep fluent-option:not([disabled]):not([selected])[selectable] {
62-
forced-color-adjust: none;
63-
background: highlight;
64-
color: highlighttext;
65-
}
72+
.fluent-autocomplete-multiselect div[role=listbox] ::deep fluent-option:not([disabled]):not([selected])[selectable] {
73+
forced-color-adjust: none;
74+
background: highlight;
75+
color: highlighttext;
76+
}
6677
}

src/Core/Components/List/ListComponentBase.razor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ protected virtual bool GetOptionSelected(TOption item)
490490
{
491491
if (item != null)
492492
{
493-
return OptionValue.Invoke(item) ?? OptionText.Invoke(item) ?? item.ToString();
493+
return OptionValue?.Invoke(item) ?? OptionText?.Invoke(item) ?? item?.ToString();
494494
}
495495
else
496496
{
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
2+
<div class=" fluent-autocomplete-multiselect" style="width: 100%;" single-select="" b-hg72r5b4ox="">
3+
<fluent-text-field style="width: 100%; min-width: 100%;" placeholder="" id="xxx" value="" current-value="" appearance="outline" blazor:onchange="1" role="combobox" aria-expanded="false" aria-controls="" blazor:onclick="2" blazor:oninput="3" blazor:elementreference="xxx">
4+
<fluent-horizontal-scroll id="xxx" style="width: 100%;" slot="start" b-hg72r5b4ox="">
5+
<fluent-flipper onclick="event.stopPropagation(); document.getElementById('myComponent-scroll').scrollToPrevious();" slot="previous-flipper" aria-hidden="false" aria-label="Previous" title="Previous" role="button" tabindex="0" class="previous fluent-autocomplete-previous" direction="previous" b-hg72r5b4ox=""></fluent-flipper>
6+
<fluent-flipper onclick="event.stopPropagation(); document.getElementById('myComponent-scroll').scrollToNext();" slot="next-flipper" aria-hidden="false" aria-label="Next" title="Next" role="button" tabindex="0" class="next fluent-autocomplete-next" direction="next" b-hg72r5b4ox=""></fluent-flipper>
7+
<p tabindex="0" aria-label="1-Denis Voituron" role="checkbox" aria-checked="true" typo="body" class="fluent-typography" b-1nnnfjehkp="">1-Denis Voituron</p>
8+
</fluent-horizontal-scroll>
9+
<svg slot="end" style="width: 12px; fill: var(--accent-fill-rest); cursor: pointer;" focusable="true" tabindex="0" role="button" viewBox="0 0 16 16" blazor:onkeydown="4" blazor:onclick="5" blazor:onfocus="6">
10+
<title>Remove 1-Denis Voituron</title>
11+
<path d="m2.59 2.72.06-.07a.5.5 0 0 1 .63-.06l.07.06L8 7.29l4.65-4.64a.5.5 0 0 1 .7.7L8.71 8l4.64 4.65c.18.17.2.44.06.63l-.06.07a.5.5 0 0 1-.63.06l-.07-.06L8 8.71l-4.65 4.64a.5.5 0 0 1-.7-.7L7.29 8 2.65 3.35a.5.5 0 0 1-.06-.63l.06-.07-.06.07Z"></path>
12+
</svg>
13+
</fluent-text-field>
14+
</div>

0 commit comments

Comments
 (0)