diff --git a/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor b/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor index 52221f3d69e..d190cb37bff 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor @@ -9,7 +9,8 @@
@((MarkupString)@Localizer["NormalDesc"].Value)
- +
@@ -71,4 +72,72 @@ + +
+

@((MarkupString)Localizer["IsVirtualizeDescription"].Value)

+
+
+ + + + +
+
+
+ +

1. 使用 OnQueryAsync 作为数据源

+
+
+ + +
+
+ +
+
+
@context.Name
+
@Foo.GetTitle(context.Id)
+
+
+
+
+
+ @if (Model4 != null) + { + + } +
+
+
+ +

2. 使用 Items 作为数据源

+
+
+ + +
+
+ +
+
+
@context.Name
+
@Foo.GetTitle(context.Id)
+
+
+
+
+
+ @if (Model4 != null) + { + + } +
+
+
+
+ diff --git a/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor.cs index 2803d12ea49..f75b92cc280 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/AutoFills.razor.cs @@ -15,9 +15,13 @@ partial class AutoFills [NotNull] private Foo Model2 { get; set; } = new(); + [NotNull] private Foo Model3 { get; set; } = new(); + [NotNull] + private Foo Model4 { get; set; } = new(); + private static string? OnGetDisplayText(Foo? foo) => foo?.Name; [NotNull] @@ -29,10 +33,15 @@ partial class AutoFills [NotNull] private IEnumerable? Items3 { get; set; } + [NotNull] + private IEnumerable? Items4 { get; set; } + [Inject] [NotNull] private IStringLocalizer? LocalizerFoo { get; set; } + private bool _isClearable = true; + /// protected override void OnInitialized() { @@ -46,6 +55,9 @@ protected override void OnInitialized() Items3 = Foo.GenerateFoo(LocalizerFoo); Model3 = Items3.First(); + + Items4 = Foo.GenerateFoo(LocalizerFoo); + Model4 = Items3.First(); } private Task> OnCustomFilter(string searchText) @@ -54,6 +66,27 @@ private Task> OnCustomFilter(string searchText) return Task.FromResult(items); } + private Task> OnCustomVirtulizeFilter(string searchText) + { + var items = string.IsNullOrEmpty(searchText) ? Items4 : Items4.Where(i => i.Name!.Contains(searchText)); + return Task.FromResult(items); + } + + private async Task> OnQueryAsync(VirtualizeQueryOption option) + { + await Task.Delay(200); + var items = Foo.GenerateFoo(LocalizerFoo); + if (!string.IsNullOrEmpty(option.SearchText)) + { + items = [.. items.Where(i => i.Name!.Contains(option.SearchText, StringComparison.OrdinalIgnoreCase))]; + } + return new QueryData + { + Items = items.Skip(option.StartIndex).Take(option.Count), + TotalCount = items.Count + }; + } + /// /// Get property method /// @@ -163,6 +196,14 @@ private AttributeItem[] GetAttributes() => Type = "bool", ValueList = "true/false", DefaultValue = "false" + }, + new() + { + Name = nameof(AutoFill.IsVirtualize), + Description = Localizer["AttrIsVirtualize"], + Type = "bool", + ValueList = "true/false", + DefaultValue = "false" } ]; } diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 04fa69ff71f..b4222f97bd4 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2151,7 +2151,11 @@ "Att10": "Whether to expand the dropdown candidate menu when it gains focus", "Att11": "Candidate template", "Att12": "Whether to skip Enter key handling", - "Att13": "Whether to skip Esc key processing" + "Att13": "Whether to skip Esc key processing", + "IsVirtualizeTitle": "Virtualize", + "IsVirtualizeIntro": "Set IsVirtualize to true enable virtual scroll for large data", + "IsVirtualizeDescription": "Component virtual scrolling supports two ways of providing data through Items or OnQueryAsync callback methods", + "AttrIsVirtualize": "Wether to enable virtualize" }, "BootstrapBlazor.Server.Components.Samples.AutoCompletes": { "Title": "AutoComplete", @@ -3026,7 +3030,8 @@ "MultiSelectVirtualizeTitle": "Virtualize", "MultiSelectVirtualizeIntro": "Set IsVirtualize to true enable virtual scroll for large data", "MultiSelectVirtualizeDescription": "Component virtual scrolling supports two ways of providing data through Items or OnQueryAsync callback methods", - "MultiSelectsAttribute_ShowSearch": "Whether to display the search box" + "MultiSelectsAttribute_ShowSearch": "Whether to display the search box", + "MultiSelectsAttribute_IsVirtualize": "Wether to enable virtualize" }, "BootstrapBlazor.Server.Components.Samples.Radios": { "RadiosTitle": "Radio", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index f457e614eb6..38185aa07b4 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2151,7 +2151,11 @@ "Att10": "获得焦点时是否展开下拉候选菜单", "Att11": "候选项模板", "Att12": "是否跳过 Enter 按键处理", - "Att13": "是否跳过 Esc 按键处理" + "Att13": "是否跳过 Esc 按键处理", + "IsVirtualizeTitle": "虚拟滚动", + "IsVirtualizeIntro": "通过设置 IsVirtualize 参数开启组件虚拟功能特性", + "IsVirtualizeDescription": "组件虚拟滚动支持两种形式通过 Items 或者 OnQueryAsync 回调方法提供数据", + "AttrIsVirtualize": "是否开启虚拟滚动" }, "BootstrapBlazor.Server.Components.Samples.AutoCompletes": { "Title": "AutoComplete 自动完成", @@ -3026,7 +3030,8 @@ "MultiSelectVirtualizeTitle": "虚拟滚动", "MultiSelectVirtualizeIntro": "通过设置 IsVirtualize 参数开启组件虚拟功能特性", "MultiSelectVirtualizeDescription": "组件虚拟滚动支持两种形式通过 Items 或者 OnQueryAsync 回调方法提供数据", - "MultiSelectsAttribute_ShowSearch": "是否显示搜索框" + "MultiSelectsAttribute_ShowSearch": "是否显示搜索框", + "MultiSelectsAttribute_IsVirtualize": "是否开启虚拟滚动" }, "BootstrapBlazor.Server.Components.Samples.Radios": { "RadiosTitle": "Radio 单选框", diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 0ea624384ea..cbd8853a581 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 9.5.0-beta05 + 9.5.0-beta07 diff --git a/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.scss b/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.scss index d3841ca69d3..2c037528fcd 100644 --- a/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.scss +++ b/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.scss @@ -1,10 +1,5 @@ .auto-complete { --bb-ac-padding-right: #{$bb-ac-padding-right}; - --bb-ac-menu-top: #{$bb-ac-menu-top}; - --bb-ac-menu-left: #{$bb-ac-menu-left}; - --bb-ac-menu-right: #{$bb-ac-menu-right}; - --bb-ac-menu-shadow: #{$bb-ac-menu-shadow}; - --bb-ac-dropdown-max-height: var(--bb-dropdown-max-height); --bb-select-append-width: #{$bb-select-append-width}; --bb-select-append-color: #{$bb-select-append-color}; position: relative; @@ -15,11 +10,7 @@ } .dropdown-menu { - top: var(--bb-ac-menu-top); - left: var(--bb-ac-menu-left); - right: var(--bb-ac-menu-right); - box-shadow: var(--bb-ac-menu-shadow); - max-height: var(--bb-ac-dropdown-max-height); + width: 100%; } .ac-loading { diff --git a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor index f500090e190..d7200fcd4dd 100644 --- a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor +++ b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor @@ -1,4 +1,5 @@ @namespace BootstrapBlazor.Components +@using Microsoft.AspNetCore.Components.Web.Virtualization @typeparam TValue @inherits PopoverCompleteBase @@ -17,23 +18,58 @@ placeholder="@PlaceHolder" disabled="@Disabled" @ref="FocusElement" /> + @if (GetClearable()) + { + + }
} - @if (ShowNoDataTip && Rows.Count == 0) + else if (ShowNoDataTip && Rows.Count == 0) { } + else + { + + }
+ +@code { + RenderFragment RenderRow => item => + @; + + RenderFragment RenderPlaceHolderRow => context => + @; +} diff --git a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs index 11eff0e19e1..e2d07b6dde0 100644 --- a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs +++ b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs @@ -3,106 +3,164 @@ // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone +using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.Extensions.Localization; namespace BootstrapBlazor.Components; /// -/// AutoFill 组件 +/// AutoFill component /// +/// The type of the value. public partial class AutoFill { /// - /// 获得 组件样式 + /// Gets the component style. /// private string? ClassString => CssBuilder.Default("auto-complete auto-fill") + .AddClass("is-clearable", IsClearable) .AddClassFromAttributes(AdditionalAttributes) .Build(); - private List? _filterItems; - /// - /// 获得/设置 组件数据集合 + /// Gets or sets the collection of items for the component. /// [Parameter] [NotNull] public IEnumerable? Items { get; set; } /// - /// 获得/设置 匹配数据时显示的数量 默认 null 未设置 + /// Gets or sets the number of items to display when matching data. Default is null. /// [Parameter] [NotNull] public int? DisplayCount { get; set; } /// - /// 获得/设置 是否开启模糊查询,默认为 false + /// Gets or sets whether to enable fuzzy search. Default is false. /// [Parameter] public bool IsLikeMatch { get; set; } /// - /// 获得/设置 匹配时是否忽略大小写,默认为 true + /// Gets or sets whether to ignore case when matching. Default is true. /// [Parameter] public bool IgnoreCase { get; set; } = true; /// - /// 获得/设置 获得焦点时是否展开下拉候选菜单 默认 true + /// Gets or sets whether to expand the dropdown candidate menu when focused. Default is true. /// [Parameter] public bool ShowDropdownListOnFocus { get; set; } = true; /// - /// 获得/设置 通过模型获得显示文本方法 默认使用 ToString 重载方法 + /// Gets or sets the method to get the display text from the model. Default is to use the ToString override method. /// [Parameter] [NotNull] public Func? OnGetDisplayText { get; set; } /// - /// 图标 + /// Gets or sets the icon. /// [Parameter] public string? Icon { get; set; } /// - /// 获得/设置 加载图标 + /// Gets or sets the loading icon. /// [Parameter] public string? LoadingIcon { get; set; } /// - /// 获得/设置 自定义集合过滤规则 + /// Gets or sets the custom collection filtering rules. /// [Parameter] public Func>>? OnCustomFilter { get; set; } /// - /// 获得/设置 是否显示无匹配数据选项 默认 true 显示 + /// Gets or sets whether to show the no matching data option. Default is true. /// [Parameter] public bool ShowNoDataTip { get; set; } = true; /// - /// 获得/设置 候选项模板 默认 null + /// Gets or sets the candidate item template. Default is null. /// [Parameter] [Obsolete("已弃用,请使用 ItemTemplate 代替;Deprecated please use ItemTemplate parameter")] [ExcludeFromCodeCoverage] public RenderFragment? Template { get => ItemTemplate; set => ItemTemplate = value; } - [Inject] + /// + /// Gets or sets whether virtual scrolling is enabled. Default is false. + /// + [Parameter] + public bool IsVirtualize { get; set; } + + /// + /// Gets or sets the row height for virtual scrolling. Default is 33. + /// + /// Effective when is set to true. + [Parameter] + public float RowHeight { get; set; } = 33f; + + /// + /// Gets or sets the overscan count for virtual scrolling. Default is 4. + /// + /// Effective when is set to true. + [Parameter] + public int OverscanCount { get; set; } = 4; + + /// + /// Gets or sets the callback method for loading virtualized items. + /// + [Parameter] [NotNull] - private IStringLocalizer? Localizer { get; set; } + public Func>>? OnQueryAsync { get; set; } + + /// + /// Gets or sets whether the select component is clearable. Default is false. + /// + [Parameter] + public bool IsClearable { get; set; } + + /// + /// Gets or sets the right-side clear icon. Default is fa-solid fa-angle-up. + /// + [Parameter] + [NotNull] + public string? ClearIcon { get; set; } /// - /// 获得 获得焦点自动显示下拉框设置字符串 + /// Gets or sets the callback method when the clear button is clicked. Default is null. /// + [Parameter] + public Func? OnClearAsync { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + private string? ShowDropdownListOnFocusString => ShowDropdownListOnFocus ? "true" : null; private string? _displayText; + private List? _filterItems; + + [NotNull] + private Virtualize? _virtualizeElement = default; + + /// + /// Gets the clear icon class string. + /// + private string? ClearClassString => CssBuilder.Default("clear-icon") + .AddClass($"text-{Color.ToDescriptionString()}", Color != Color.None) + .AddClass($"text-success", IsValid.HasValue && IsValid.Value) + .AddClass($"text-danger", IsValid.HasValue && !IsValid.Value) + .Build(); + /// /// /// @@ -114,14 +172,41 @@ protected override void OnParametersSet() PlaceHolder ??= Localizer[nameof(PlaceHolder)]; Icon ??= IconTheme.GetIconByKey(ComponentIcons.AutoFillIcon); LoadingIcon ??= IconTheme.GetIconByKey(ComponentIcons.LoadingIcon); + ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.SelectClearIcon); _displayText = GetDisplayText(Value); Items ??= []; } + private bool IsNullable() => !ValueType.IsValueType || NullableUnderlyingType != null; + + /// + /// Gets whether show the clear button. + /// + /// + private bool GetClearable() => IsClearable && !IsDisabled && IsNullable(); + + /// + /// + /// + /// + private async Task OnClearValue() + { + if (OnClearAsync != null) + { + await OnClearAsync(); + } + CurrentValue = default; + if (OnQueryAsync != null) + { + await _virtualizeElement.RefreshDataAsync(); + } + } + /// - /// 鼠标点击候选项时回调此方法 + /// Callback method when a candidate item is clicked. /// + /// The value of the clicked item. private async Task OnClickItem(TValue val) { CurrentValue = val; @@ -135,23 +220,45 @@ private async Task OnClickItem(TValue val) private string? GetDisplayText(TValue item) => OnGetDisplayText?.Invoke(item) ?? item?.ToString(); - private List Rows => _filterItems ?? Items.ToList(); + private List Rows => _filterItems ?? [.. Items]; + + private int _totalCount; + + private async ValueTask> LoadItems(ItemsProviderRequest request) + { + var count = _totalCount == 0 ? request.Count : Math.Min(request.Count, _totalCount - request.StartIndex); + var data = await OnQueryAsync(new() { StartIndex = request.StartIndex, Count = count, SearchText = _searchText }); + + _totalCount = data.TotalCount; + var items = data.Items ?? []; + return new ItemsProviderResult(items, _totalCount); + } + + private string? _searchText; /// - /// TriggerFilter 方法 + /// Triggers the filter method. /// - /// + /// The value to filter by. [JSInvokable] public override async Task TriggerFilter(string val) { + if (OnQueryAsync != null) + { + _searchText = val; + await _virtualizeElement.RefreshDataAsync(); + StateHasChanged(); + return; + } + if (OnCustomFilter != null) { var items = await OnCustomFilter(val); - _filterItems = items.ToList(); + _filterItems = [.. items]; } else if (string.IsNullOrEmpty(val)) { - _filterItems = Items.ToList(); + _filterItems = [.. Items]; } else { @@ -159,20 +266,20 @@ public override async Task TriggerFilter(string val) var items = IsLikeMatch ? Items.Where(i => OnGetDisplayText?.Invoke(i)?.Contains(val, comparision) ?? false) : Items.Where(i => OnGetDisplayText?.Invoke(i)?.StartsWith(val, comparision) ?? false); - _filterItems = items.ToList(); + _filterItems = [.. items]; } - if (DisplayCount != null) + if (!IsVirtualize && DisplayCount != null) { - _filterItems = _filterItems.Take(DisplayCount.Value).ToList(); + _filterItems = [.. _filterItems.Take(DisplayCount.Value)]; } StateHasChanged(); } /// - /// TriggerOnChange 方法 + /// Triggers the change method. /// - /// + /// The value to change to. [JSInvokable] public override Task TriggerChange(string val) { diff --git a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.scss b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.scss index a480f4f5b89..a57e8e34655 100644 --- a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.scss +++ b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.scss @@ -1,7 +1,3 @@ .auto-fill { - --bb-af-dropdown-max-height: var(--bb-dropdown-max-height); - - .dropdown-menu { - max-height: var(--bb-af-dropdown-max-height); - } + --bb-dropdown-max-height: 330px; } diff --git a/src/BootstrapBlazor/wwwroot/scss/theme/bootstrapblazor.scss b/src/BootstrapBlazor/wwwroot/scss/theme/bootstrapblazor.scss index ceca09a679a..952013d53b7 100644 --- a/src/BootstrapBlazor/wwwroot/scss/theme/bootstrapblazor.scss +++ b/src/BootstrapBlazor/wwwroot/scss/theme/bootstrapblazor.scss @@ -21,10 +21,6 @@ $bb-anchor-link-opacity-transition: opacity .3s linear; // AutoComplete $bb-ac-padding-right: 30px; -$bb-ac-menu-top: 40px; -$bb-ac-menu-left: 0; -$bb-ac-menu-right: 0; -$bb-ac-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.176); // Avatar $bb-avatar-width: 50px; diff --git a/test/UnitTest/Components/AutoFillTest.cs b/test/UnitTest/Components/AutoFillTest.cs index 0800022f904..a61f52d2ee4 100644 --- a/test/UnitTest/Components/AutoFillTest.cs +++ b/test/UnitTest/Components/AutoFillTest.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone -using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Localization; +using System.ComponentModel.DataAnnotations; namespace UnitTest.Components; @@ -306,4 +306,184 @@ public void ShowDropdownListOnFocus_Ok() }); cut.DoesNotContain("data-bb-auto-dropdown-focus"); } + + [Fact] + public async Task IsVirtualize_Items() + { + var items = new List() { new() { Name = "test1" }, new() { Name = "test2" } }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.IsLikeMatch, true); + pb.Add(a => a.Items, items); + pb.Add(a => a.Value, items[0]); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.RowHeight, 58f); + pb.Add(a => a.OverscanCount, 4); + pb.Add(a => a.OnGetDisplayText, f => f?.Name); + }); + + await cut.InvokeAsync(() => cut.Instance.TriggerFilter("2")); + cut.Contains("
test2
"); + } + + [Fact] + public async Task IsVirtualize_Items_Clearable_Ok() + { + var items = new List() { new() { Name = "test1" }, new() { Name = "test2" } }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + pb.Add(a => a.Value, items[0]); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.OnGetDisplayText, f => f?.Name); + }); + await cut.InvokeAsync(() => cut.Instance.TriggerFilter("2")); + + // 点击 Clear 按钮 + var button = cut.Find(".clear-icon"); + await cut.InvokeAsync(() => button.Click()); + cut.SetParametersAndRender(); + + var input = cut.Find(".form-control"); + Assert.Null(input.NodeValue); + } + + [Fact] + public async Task IsVirtualize_OnQueryAsync_Clearable_Ok() + { + var query = false; + var startIndex = 0; + var requestCount = 0; + var searchText = string.Empty; + var cleared = false; + var items = new List() { new() { Name = "test1" }, new() { Name = "test2" } }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.OnQueryAsync, option => + { + query = true; + startIndex = option.StartIndex; + requestCount = option.Count; + searchText = option.SearchText; + return Task.FromResult(new QueryData() + { + Items = string.IsNullOrEmpty(searchText) ? items : items.Where(i => i.Name!.Contains(searchText, StringComparison.OrdinalIgnoreCase)), + TotalCount = string.IsNullOrEmpty(searchText) ? 2 : 1 + }); + }); + pb.Add(a => a.Value, items[0]); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.OnGetDisplayText, f => f?.Name); + pb.Add(a => a.OnClearAsync, () => + { + cleared = true; + return Task.CompletedTask; + }); + }); + await cut.InvokeAsync(() => cut.Instance.TriggerFilter("2")); + Assert.Equal("2", searchText); + Assert.Contains("
test2
", cut.Markup); + + query = false; + // 点击 Clear 按钮 + var button = cut.Find(".clear-icon"); + await cut.InvokeAsync(() => button.Click()); + + Assert.True(query); + Assert.True(cleared); + + // OnQueryAsync 返回空集合 + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.OnQueryAsync, option => + { + return Task.FromResult(new QueryData() + { + Items = null, + TotalCount = 0 + }); + }); + }); + await cut.InvokeAsync(() => button.Click()); + } + + [Fact] + public void Clearable_Ok() + { + var items = new List() { 1, 2 }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + pb.Add(a => a.Value, items[0]); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.OnGetDisplayText, f => f?.ToString()); + }); + cut.Contains("clear-icon"); + } + + + [Fact] + public async Task Validate_Ok() + { + var valid = false; + var invalid = false; + var model = new MockModel() { Value = new Foo() { Name = "Test-Select1" } }; + var items = new List() + { + new() { Name = "test1" }, + new() { Name = "test2" } + }; + var cut = Context.RenderComponent(builder => + { + builder.Add(a => a.OnValidSubmit, context => + { + valid = true; + return Task.CompletedTask; + }); + builder.Add(a => a.OnInvalidSubmit, context => + { + invalid = true; + return Task.CompletedTask; + }); + builder.Add(a => a.Model, model); + builder.AddChildContent>(pb => + { + pb.Add(a => a.Items, items); + pb.Add(a => a.Value, model.Value); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.OnGetDisplayText, f => f?.Name); + pb.Add(a => a.OnValueChanged, v => + { + model.Value = v; + return Task.CompletedTask; + }); + pb.Add(a => a.ValueExpression, Utility.GenerateValueExpression(model, "Value", typeof(Foo))); + }); + }); + + await cut.InvokeAsync(() => + { + var form = cut.Find("form"); + form.Submit(); + }); + Assert.True(valid); + Assert.Equal("Test-Select1", model.Value.Name); + + // 点击 Clear 按钮 + var button = cut.Find(".clear-icon"); + await cut.InvokeAsync(() => button.Click()); + + var form = cut.Find("form"); + form.Submit(); + Assert.True(invalid); + } + + class MockModel + { + [Required] + public Foo? Value { get; set; } + } }