diff --git a/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor b/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor index 4fee4046f58..093dd481024 100644 --- a/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor +++ b/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor @@ -442,7 +442,7 @@
@((MarkupString)Localizer["SelectsGenericDesc"].Value)
- +
diff --git a/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor.cs b/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor.cs index b5acb838af6..6bb60dc47fb 100644 --- a/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor.cs +++ b/src/BootstrapBlazor.Shared/Components/Samples/Selects.razor.cs @@ -242,7 +242,7 @@ private Task OnTimeZoneValueChanged(string timeZoneId) return Task.CompletedTask; } - private readonly List> _genericItems = + private readonly List> _genericItems = [ new() { Text = "Foo1", Value = new Foo() { Id = 1, Address = "Address_F001" } }, new() { Text = "Foo2", Value = new Foo() { Id = 2, Address = "Address_F002" } }, diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 3bd145a169b..b90291ec018 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@ - 9.1.3-beta05 + 9.1.3-beta06 diff --git a/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs b/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs index 3198a1ef19c..84bdc47bade 100644 --- a/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs +++ b/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs @@ -36,6 +36,19 @@ public partial class MultiSelect .AddClass("d-none", SelectedItems.Count != 0) .Build(); + /// + /// 获得/设置 绑定数据集 + /// + [Parameter] + [NotNull] + public IEnumerable? Items { get; set; } + + /// + /// 获得/设置 选项模板 + /// + [Parameter] + public RenderFragment? ItemTemplate { get; set; } + /// /// 获得/设置 组件 PlaceHolder 文字 默认为 点击进行多选 ... /// diff --git a/src/BootstrapBlazor/Components/Select/Select.razor b/src/BootstrapBlazor/Components/Select/Select.razor index f25cc0d349d..ce7ae4202c1 100644 --- a/src/BootstrapBlazor/Components/Select/Select.razor +++ b/src/BootstrapBlazor/Components/Select/Select.razor @@ -1,7 +1,7 @@ @namespace BootstrapBlazor.Components @using Microsoft.AspNetCore.Components.Web.Virtualization @typeparam TValue -@inherits SingleSelectBase +@inherits SelectBase @attribute [BootstrapModuleAutoLoader(JSObjectReference = true)] @if (IsShowLabel) diff --git a/src/BootstrapBlazor/Components/Select/Select.razor.cs b/src/BootstrapBlazor/Components/Select/Select.razor.cs index 8629acb5ac5..8ee49a0f29e 100644 --- a/src/BootstrapBlazor/Components/Select/Select.razor.cs +++ b/src/BootstrapBlazor/Components/Select/Select.razor.cs @@ -12,6 +12,7 @@ namespace BootstrapBlazor.Components; /// Select 组件实现类 /// /// +[ExcludeFromCodeCoverage] public partial class Select : ISelect, IModelEqualityComparer { [Inject] @@ -50,7 +51,7 @@ public partial class Select : ISelect, IModelEqualityComparer /// /// private string? ActiveItem(SelectedItem item) => CssBuilder.Default("dropdown-item") - .AddClass("active", Match(item)) + .AddClass("active", item.Value == CurrentValueAsString) .AddClass("disabled", item.IsDisabled) .Build(); @@ -191,6 +192,55 @@ public partial class Select : ISelect, IModelEqualityComparer [NotNull] private Virtualize? VirtualizeElement { get; set; } + /// + /// 获得/设置 绑定数据集 + /// + [Parameter] + [NotNull] + public IEnumerable? Items { get; set; } + + /// + /// 获得/设置 选项模板 + /// + [Parameter] + public RenderFragment? ItemTemplate { get; set; } + + /// + /// 获得/设置 下拉框项目改变前回调委托方法 返回 true 时选项值改变,否则选项值不变 + /// + [Parameter] + public Func>? OnBeforeSelectedItemChange { get; set; } + + /// + /// SelectedItemChanged 回调方法 + /// + [Parameter] + public Func? OnSelectedItemChanged { get; set; } + + /// + /// 获得/设置 Swal 图标 默认 Question + /// + [Parameter] + public SwalCategory SwalCategory { get; set; } = SwalCategory.Question; + + /// + /// 获得/设置 Swal 标题 默认 null + /// + [Parameter] + public string? SwalTitle { get; set; } + + /// + /// 获得/设置 Swal 内容 默认 null + /// + [Parameter] + public string? SwalContent { get; set; } + + /// + /// 获得/设置 Footer 默认 null + /// + [Parameter] + public string? SwalFooter { get; set; } + [Inject] [NotNull] private IStringLocalizer>? Localizer { get; set; } @@ -214,6 +264,11 @@ public partial class Select : ISelect, IModelEqualityComparer private ItemsProviderResult _result; + /// + /// 当前选择项实例 + /// + private SelectedItem? SelectedItem { get; set; } + private List Rows { get @@ -234,7 +289,7 @@ private SelectedItem? SelectedRow private SelectedItem? GetSelectedRow() { - var item = Rows.Find(Match) + var item = Rows.Find(i => i.Value == CurrentValueAsString) ?? Rows.Find(i => i.Active) ?? Rows.Where(i => !i.IsDisabled).FirstOrDefault() ?? GetVirtualizeItem(); @@ -374,8 +429,6 @@ private bool TryParseSelectItem(string value, [MaybeNullWhen(false)] out TValue /// protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, Interop, nameof(ConfirmSelectedItem)); - private bool Match(SelectedItem i) => i is SelectedItem d ? Equals(d.Value, Value) : i.Value.Equals(CurrentValueAsString, StringComparison); - /// /// 客户端回车回调方法 /// @@ -453,13 +506,13 @@ private async Task ValueTypeChanged(SelectedItem item) { if (_lastSelectedValueString != item.Value) { - _lastSelectedValueString = item.Value; item.Active = true; SelectedItem = item; // 触发 StateHasChanged - CurrentValueAsString = item.Value; + _lastSelectedValueString = item.Value ?? ""; + CurrentValueAsString = _lastSelectedValueString; // 触发 SelectedItemChanged 事件 if (OnSelectedItemChanged != null) @@ -518,41 +571,13 @@ private async Task OnChange(ChangeEventArgs args) if (item == null) { - // 判断是否为泛型 SelectedItem - var itemType = Items.GetType(); - var isGeneric = false; - if (itemType.IsGenericType) - { - isGeneric = itemType.GetGenericArguments()[0].IsGenericType; - } - if (isGeneric) - { - TValue? val = default; - if (TextConvertToValueCallback != null) - { - val = await TextConvertToValueCallback(v); - } - item = new SelectedItem() { Text = v, Value = val }; - } - else - { - item = new SelectedItem(v, v); - } + item = new SelectedItem(v, v); var items = new List() { item }; items.AddRange(Items); Items = items; - CurrentValueAsString = v; - } - - if (item is SelectedItem value) - { - CurrentValue = value.Value; - } - else - { - CurrentValueAsString = v; } + CurrentValueAsString = v; if (OnInputChangedCallback != null) { diff --git a/src/BootstrapBlazor/Components/Select/SelectBase.cs b/src/BootstrapBlazor/Components/Select/SelectBase.cs index 2c403a8e495..afe7b453cc3 100644 --- a/src/BootstrapBlazor/Components/Select/SelectBase.cs +++ b/src/BootstrapBlazor/Components/Select/SelectBase.cs @@ -16,13 +16,6 @@ public abstract class SelectBase : PopoverSelectBase [Parameter] public Color Color { get; set; } - /// - /// 获得/设置 绑定数据集 - /// - [Parameter] - [NotNull] - public IEnumerable? Items { get; set; } - /// /// 获得/设置 是否显示搜索框 默认为 false 不显示 /// @@ -54,12 +47,6 @@ public abstract class SelectBase : PopoverSelectBase [Parameter] public StringComparison StringComparison { get; set; } = StringComparison.OrdinalIgnoreCase; - /// - /// 获得/设置 选项模板 - /// - [Parameter] - public RenderFragment? ItemTemplate { get; set; } - /// /// 获得/设置 分组项模板 /// diff --git a/src/BootstrapBlazor/Components/Select/SelectOption.cs b/src/BootstrapBlazor/Components/Select/SelectOption.cs index 4841fb76b4f..a293969f2da 100644 --- a/src/BootstrapBlazor/Components/Select/SelectOption.cs +++ b/src/BootstrapBlazor/Components/Select/SelectOption.cs @@ -8,6 +8,7 @@ namespace BootstrapBlazor.Components; /// /// SelectOption 组件 /// +[ExcludeFromCodeCoverage] public class SelectOption : ComponentBase { /// diff --git a/src/BootstrapBlazor/Components/Select/SingleSelectBase.cs b/src/BootstrapBlazor/Components/Select/SingleSelectBase.cs deleted file mode 100644 index f95a0e207af..00000000000 --- a/src/BootstrapBlazor/Components/Select/SingleSelectBase.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License -// See the LICENSE file in the project root for more information. -// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone - -namespace BootstrapBlazor.Components; - -/// -/// Select 组件基类 -/// -public abstract class SingleSelectBase : SelectBase -{ - /// - /// 当前选择项实例 - /// - protected SelectedItem? SelectedItem { get; set; } - - /// - /// 获得/设置 Swal 图标 默认 Question - /// - [Parameter] - public SwalCategory SwalCategory { get; set; } = SwalCategory.Question; - - /// - /// 获得/设置 Swal 标题 默认 null - /// - [Parameter] - public string? SwalTitle { get; set; } - - /// - /// 获得/设置 Swal 内容 默认 null - /// - [Parameter] - public string? SwalContent { get; set; } - - /// - /// 获得/设置 Footer 默认 null - /// - [Parameter] - public string? SwalFooter { get; set; } - - /// - /// 获得/设置 下拉框项目改变前回调委托方法 返回 true 时选项值改变,否则选项值不变 - /// - [Parameter] - public Func>? OnBeforeSelectedItemChange { get; set; } - - /// - /// SelectedItemChanged 回调方法 - /// - [Parameter] - public Func? OnSelectedItemChanged { get; set; } -} diff --git a/src/BootstrapBlazor/Components/SelectGeneric/ISelectGeneric.cs b/src/BootstrapBlazor/Components/SelectGeneric/ISelectGeneric.cs new file mode 100644 index 00000000000..de6d9af06ca --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/ISelectGeneric.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// ISelect 接口 +/// +public interface ISelectGeneric +{ + /// + /// 增加 SelectedItem 项方法 + /// + /// + void Add(SelectedItem item); +} diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor new file mode 100644 index 00000000000..61db79a5116 --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor @@ -0,0 +1,115 @@ +@namespace BootstrapBlazor.Components +@using Microsoft.AspNetCore.Components.Web.Virtualization +@typeparam TValue +@inherits SelectBase +@attribute [BootstrapModuleAutoLoader(JSObjectReference = true)] + +@if (IsShowLabel) +{ + +} +
+ + @Options + + + + @if (GetClearable()) + { + + } + + @if (!IsPopover) + { + + } + +
+ +@code { + RenderFragment> RenderRow => item => + @
+ @if (ItemTemplate != null) + { + @ItemTemplate(item) + } + else if (IsMarkupString) + { + @((MarkupString)item.Text) + } + else + { + @item.Text + } +
; + + RenderFragment RenderPlaceHolderRow => context => + @; +} diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs new file mode 100644 index 00000000000..50c23421481 --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs @@ -0,0 +1,566 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// 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; + +/// +/// Select 组件实现类 +/// +/// +public partial class SelectGeneric : ISelectGeneric, IModelEqualityComparer +{ + [Inject] + [NotNull] + private SwalService? SwalService { get; set; } + + /// + /// 获得 样式集合 + /// + private string? ClassString => CssBuilder.Default("select dropdown") + .AddClass("cls", IsClearable) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + /// + /// 获得 样式集合 + /// + private string? InputClassString => CssBuilder.Default("form-select form-control") + .AddClass($"border-{Color.ToDescriptionString()}", Color != Color.None && !IsDisabled && !IsValid.HasValue) + .AddClass($"border-success", IsValid.HasValue && IsValid.Value) + .AddClass($"border-danger", IsValid.HasValue && !IsValid.Value) + .AddClass(CssClass).AddClass(ValidCss) + .Build(); + + 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(); + + private bool GetClearable() => IsClearable && !IsDisabled; + + /// + /// 设置当前项是否 Active 方法 + /// + /// + /// + private string? ActiveItem(SelectedItem item) => CssBuilder.Default("dropdown-item") + .AddClass("active", Equals(item.Value, Value)) + .AddClass("disabled", item.IsDisabled) + .Build(); + + private string? SearchClassString => CssBuilder.Default("search") + .AddClass("is-fixed", IsFixedSearch) + .Build(); + + private readonly List> _children = []; + + /// + /// 获得/设置 右侧清除图标 默认 fa-solid fa-angle-up + /// + [Parameter] + [NotNull] + public string? ClearIcon { get; set; } + + /// + /// 获得/设置 搜索文本发生变化时回调此方法 + /// + [Parameter] + public Func>>? OnSearchTextChanged { get; set; } + + /// + /// 获得/设置 是否固定下拉框中的搜索栏 默认 false + /// + [Parameter] + public bool IsFixedSearch { get; set; } + + /// + /// 获得/设置 是否可编辑 默认 false + /// + [Parameter] + public bool IsEditable { get; set; } + + /// + /// 获得/设置 选项输入更新后回调方法 默认 null + /// + /// 设置 后生效 + [Parameter] + public Func? OnInputChangedCallback { get; set; } + + /// + /// 获得/设置 选项输入更新后转换为 Value 回调方法 默认 null + /// + /// 设置 后生效 + [Parameter] + public Func>? TextConvertToValueCallback { get; set; } + + /// + /// 获得/设置 无搜索结果时显示文字 + /// + [Parameter] + public string? NoSearchDataText { get; set; } + + /// + /// 获得 PlaceHolder 属性 + /// + [Parameter] + public string? PlaceHolder { get; set; } + + /// + /// 获得/设置 是否可清除 默认 false + /// + [Parameter] + public bool IsClearable { get; set; } + + /// + /// 获得/设置 选项模板支持静态数据 + /// + [Parameter] + public RenderFragment? Options { get; set; } + + /// + /// 获得/设置 显示部分模板 默认 null + /// + [Parameter] + public RenderFragment?>? DisplayTemplate { get; set; } + + /// + /// 获得/设置 是否开启虚拟滚动 默认 false 未开启 注意:开启虚拟滚动后不支持 参数设置,设置初始值时请设置 + /// + [Parameter] + public bool IsVirtualize { get; set; } + + /// + /// 获得/设置 虚拟滚动行高 默认为 33 + /// + /// 需要设置 值为 true 时生效 + [Parameter] + public float RowHeight { get; set; } = 33f; + + /// + /// 获得/设置 过载阈值数 默认为 4 + /// + /// 需要设置 值为 true 时生效 + [Parameter] + public int OverscanCount { get; set; } = 4; + + /// + /// 获得/设置 默认文本 时生效 默认 null + /// + /// 开启 并且通过 提供数据源时,由于渲染时还未调用或者调用后数据集未包含 选项值,此时使用 DefaultText 值渲染 + [Parameter] + public string? DefaultVirtualizeItemText { get; set; } + + /// + /// 获得/设置 清除文本内容 OnClear 回调方法 默认 null + /// + [Parameter] + public Func? OnClearAsync { get; set; } + + /// + /// 获得/设置 禁止首次加载时触发 OnSelectedItemChanged 回调方法 默认 false + /// + [Parameter] + public bool DisableItemChangedWhenFirstRender { get; set; } + + /// + /// 获得/设置 比较数据是否相同回调方法 默认为 null + /// 提供此回调方法时忽略 属性 + /// + [Parameter] + public Func? ValueEqualityComparer { get; set; } + + Func? IModelEqualityComparer.ModelEqualityComparer + { + get => ValueEqualityComparer; + set => ValueEqualityComparer = value; + } + + /// + /// 获得/设置 数据主键标识标签 默认为 用于判断数据主键标签,如果模型未设置主键时可使用 参数自定义判断数据模型支持联合主键 + /// + [Parameter] + [NotNull] + public Type? CustomKeyAttribute { get; set; } = typeof(KeyAttribute); + + [NotNull] + private Virtualize>? VirtualizeElement { get; set; } + + /// + /// 获得/设置 绑定数据集 + /// + [Parameter] + [NotNull] + public IEnumerable>? Items { get; set; } + + /// + /// 获得/设置 选项模板 + /// + [Parameter] + public RenderFragment>? ItemTemplate { get; set; } + + /// + /// 获得/设置 下拉框项目改变前回调委托方法 返回 true 时选项值改变,否则选项值不变 + /// + [Parameter] + public Func, Task>? OnBeforeSelectedItemChange { get; set; } + + /// + /// SelectedItemChanged 回调方法 + /// + [Parameter] + public Func, Task>? OnSelectedItemChanged { get; set; } + + /// + /// 获得/设置 Swal 图标 默认 Question + /// + [Parameter] + public SwalCategory SwalCategory { get; set; } = SwalCategory.Question; + + /// + /// 获得/设置 Swal 标题 默认 null + /// + [Parameter] + public string? SwalTitle { get; set; } + + /// + /// 获得/设置 Swal 内容 默认 null + /// + [Parameter] + public string? SwalContent { get; set; } + + /// + /// 获得/设置 Footer 默认 null + /// + [Parameter] + public string? SwalFooter { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer>? Localizer { get; set; } + + /// + /// 获得 input 组件 Id 方法 + /// + /// + protected override string? RetrieveId() => InputId; + + /// + /// 获得/设置 Select 内部 Input 组件 Id + /// + private string? InputId => $"{Id}_input"; + + private TValue? _lastSelectedValue; + + private bool _init = true; + + private List>? _itemsCache; + + private ItemsProviderResult> _result; + + /// + /// 当前选择项实例 + /// + private SelectedItem? SelectedItem { get; set; } + + private List> Rows + { + get + { + _itemsCache ??= string.IsNullOrEmpty(SearchText) ? GetRowsByItems() : GetRowsBySearch(); + return _itemsCache; + } + } + + private SelectedItem? SelectedRow + { + get + { + SelectedItem ??= GetSelectedRow(); + return SelectedItem; + } + } + + private SelectedItem? GetSelectedRow() + { + var item = Rows.Find(i => Equals(i.Value, Value)) + ?? Rows.Find(i => i.Active) + ?? Rows.Where(i => !i.IsDisabled).FirstOrDefault() + ?? new SelectedItem(Value, DefaultVirtualizeItemText!); + + if (!_init || !DisableItemChangedWhenFirstRender) + { + _ = SelectedItemChanged(item); + _init = false; + } + return item; + } + + private List> GetRowsByItems() + { + var items = new List>(); + items.AddRange(Items); + items.AddRange(_children); + return items; + } + + private List> GetRowsBySearch() + { + var items = OnSearchTextChanged?.Invoke(SearchText) ?? FilterBySearchText(GetRowsByItems()); + return items.ToList(); + } + + private IEnumerable> FilterBySearchText(IEnumerable> source) => string.IsNullOrEmpty(SearchText) + ? source + : source.Where(i => i.Text.Contains(SearchText, StringComparison)); + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + Items ??= []; + PlaceHolder ??= Localizer[nameof(PlaceHolder)]; + NoSearchDataText ??= Localizer[nameof(NoSearchDataText)]; + DropdownIcon ??= IconTheme.GetIconByKey(ComponentIcons.SelectDropdownIcon); + ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.SelectClearIcon); + + // 内置对枚举类型的支持 + if (!Items.Any() && ValueType.IsEnum()) + { + var item = NullableUnderlyingType == null ? "" : PlaceHolder; + Items = ValueType.ToSelectList(string.IsNullOrEmpty(item) ? null : new SelectedItem(default!, item)); + } + + _itemsCache = null; + SelectedItem = null; + } + + /// + /// 获得/设置 数据总条目 + /// + private int TotalCount { get; set; } + + private List> GetVirtualItems() => FilterBySearchText(GetRowsByItems()).ToList(); + + /// + /// 虚拟滚动数据加载回调方法 + /// + [Parameter] + [NotNull] + public Func>>>? OnQueryAsync { get; set; } + + private async ValueTask>> LoadItems(ItemsProviderRequest request) + { + // 有搜索条件时使用原生请求数量 + // 有总数时请求剩余数量 + var count = !string.IsNullOrEmpty(SearchText) ? request.Count : GetCountByTotal(); + var data = await OnQueryAsync(new() { StartIndex = request.StartIndex, Count = count, SearchText = SearchText }); + + TotalCount = data.TotalCount; + var items = data.Items ?? []; + _result = new ItemsProviderResult>(items, TotalCount); + return _result; + + int GetCountByTotal() => TotalCount == 0 ? request.Count : Math.Min(request.Count, TotalCount - request.StartIndex); + } + + private async Task SearchTextChanged(string val) + { + SearchText = val; + _itemsCache = null; + + if (OnQueryAsync != null) + { + // 通过 ItemProvider 提供数据 + await VirtualizeElement.RefreshDataAsync(); + } + } + + /// + /// + /// + /// + protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, Interop, nameof(ConfirmSelectedItem)); + + /// + /// 客户端回车回调方法 + /// + /// + /// + [JSInvokable] + public async Task ConfirmSelectedItem(int index) + { + if (index < Rows.Count) + { + await OnClickItem(Rows[index]); + StateHasChanged(); + } + } + + /// + /// 下拉框选项点击时调用此方法 + /// + private async Task OnClickItem(SelectedItem item) + { + var ret = true; + if (OnBeforeSelectedItemChange != null) + { + ret = await OnBeforeSelectedItemChange(item); + if (ret) + { + // 返回 True 弹窗提示 + var option = new SwalOption() + { + Category = SwalCategory, + Title = SwalTitle, + Content = SwalContent + }; + if (!string.IsNullOrEmpty(SwalFooter)) + { + option.ShowFooter = true; + option.FooterTemplate = builder => builder.AddContent(0, SwalFooter); + } + ret = await SwalService.ShowModal(option); + } + else + { + // 返回 False 直接运行 + ret = true; + } + } + if (ret) + { + await SelectedItemChanged(item); + } + } + + private async Task SelectedItemChanged(SelectedItem item) + { + if (!Equals(item.Value, Value)) + { + item.Active = true; + SelectedItem = item; + + CurrentValue = item.Value; + + // 触发 SelectedItemChanged 事件 + if (OnSelectedItemChanged != null) + { + await OnSelectedItemChanged(SelectedItem); + } + } + else + { + await ValueTypeChanged(item); + } + } + + private async Task ValueTypeChanged(SelectedItem item) + { + if (!Equals(_lastSelectedValue, item.Value)) + { + _lastSelectedValue = item.Value; + + item.Active = true; + SelectedItem = item; + + // 触发 StateHasChanged + CurrentValue = item.Value; + + // 触发 SelectedItemChanged 事件 + if (OnSelectedItemChanged != null) + { + await OnSelectedItemChanged(SelectedItem); + } + } + } + + /// + /// 添加静态下拉项方法 + /// + /// + public void Add(SelectedItem item) => _children.Add(item); + + /// + /// 清空搜索栏文本内容 + /// + public void ClearSearchText() => SearchText = null; + + private async Task OnClearValue() + { + if (ShowSearch) + { + ClearSearchText(); + } + if (OnClearAsync != null) + { + await OnClearAsync(); + } + + SelectedItem? item; + if (OnQueryAsync != null) + { + await VirtualizeElement.RefreshDataAsync(); + item = _result.Items.FirstOrDefault(); + } + else + { + item = Items.FirstOrDefault(); + } + if (item != null) + { + await SelectedItemChanged(item); + } + } + + private string? ReadonlyString => IsEditable ? null : "readonly"; + + private async Task OnChange(ChangeEventArgs args) + { + if (args.Value is string v) + { + // Items 中没有时插入一个 SelectedItem + var item = Items.FirstOrDefault(i => i.Text == v); + + if (item == null) + { + TValue? val = default; + if (TextConvertToValueCallback != null) + { + val = await TextConvertToValueCallback(v); + } + item = new SelectedItem(val, v); + + var items = new List>() { item }; + items.AddRange(Items); + Items = items; + CurrentValue = val; + } + else + { + CurrentValue = item.Value; + } + + if (OnInputChangedCallback != null) + { + await OnInputChangedCallback(v); + } + } + } + + /// + /// + /// + /// + /// + /// + public bool Equals(TValue? x, TValue? y) => this.Equals(x, y); +} diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.js b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.js new file mode 100644 index 00000000000..5f18ce671ff --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.js @@ -0,0 +1,139 @@ +import { getHeight, getInnerHeight, getTransitionDelayDurationFromElement } from "../../modules/utility.js" +import Data from "../../modules/data.js" +import EventHandler from "../../modules/event-handler.js" +import Popover from "../../modules/base-popover.js" + +export function init(id, invoke, method) { + const el = document.getElementById(id) + + if (el == null) { + return + } + + const search = el.querySelector("input.search-text") + const popover = Popover.init(el) + + const shown = () => { + if (search) { + search.focus(); + } + const prev = popover.toggleMenu.querySelector('.dropdown-item.preActive') + if (prev) { + prev.classList.remove('preActive') + } + scrollToActive(popover.toggleMenu, prev) + } + + const keydown = e => { + if (popover.toggleElement.classList.contains('show')) { + const items = popover.toggleMenu.querySelectorAll('.dropdown-item:not(.search, .disabled)') + let activeItem = popover.toggleMenu.querySelector('.dropdown-item.preActive') + if (activeItem == null) activeItem = popover.toggleMenu.querySelector('.dropdown-item.active') + + if (activeItem) { + if (items.length > 1) { + activeItem.classList.remove('preActive') + if (e.key === "ArrowUp") { + do { + activeItem = activeItem.previousElementSibling + } + while (activeItem && !activeItem.classList.contains('dropdown-item')) + if (!activeItem) { + activeItem = items[items.length - 1] + } + activeItem.classList.add('preActive') + scrollToActive(popover.toggleMenu, activeItem) + e.preventDefault() + e.stopPropagation() + } + else if (e.key === "ArrowDown") { + do { + activeItem = activeItem.nextElementSibling + } + while (activeItem && !activeItem.classList.contains('dropdown-item')) + if (!activeItem) { + activeItem = items[0] + } + activeItem.classList.add('preActive') + scrollToActive(popover.toggleMenu, activeItem) + e.preventDefault() + e.stopPropagation() + } + } + + if (e.key === "Enter") { + popover.toggleMenu.classList.remove('show') + let index = indexOf(el, activeItem) + invoke.invokeMethodAsync(method, index) + } + } + } + } + + EventHandler.on(el, 'shown.bs.dropdown', shown); + EventHandler.on(el, 'keydown', keydown) + + const select = { + el, + popover + } + Data.set(id, select) +} + +export function show(id) { + const select = Data.get(id) + if (select) { + const delay = getTransitionDelayDurationFromElement(select.popover.toggleElement); + const handler = setTimeout(() => { + clearTimeout(handler); + select.popover.show(); + }, delay); + } +} + +export function hide(id) { + const select = Data.get(id) + const delay = getTransitionDelayDurationFromElement(select.popover.toggleElement); + if (select) { + const handler = setTimeout(() => { + clearTimeout(handler); + select.popover.hide(); + }, delay) + } +} + +export function dispose(id) { + const select = Data.get(id) + Data.remove(id) + + if (select) { + EventHandler.off(select.el, 'shown.bs.dropdown') + EventHandler.off(select.el, 'keydown') + Popover.dispose(select.popover) + } +} + + +function scrollToActive(el, activeItem) { + if (!activeItem) { + activeItem = el.querySelector('.dropdown-item.active') + } + + if (activeItem) { + const innerHeight = getInnerHeight(el) + const itemHeight = getHeight(activeItem); + const index = indexOf(el, activeItem) + const margin = itemHeight * index - (innerHeight - itemHeight) / 2; + if (margin >= 0) { + el.scrollTo(0, margin); + } + else { + el.scrollTo(0, 0); + } + } +} + +function indexOf(el, element) { + const items = el.querySelectorAll('.dropdown-item') + return Array.prototype.indexOf.call(items, element) +} diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.scss b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.scss new file mode 100644 index 00000000000..abb759cc40b --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.scss @@ -0,0 +1,241 @@ +.select, +.popover-dropdown { + --bb-dropdown-link-pre-active-bg: #{$bb-dropdown-link-pre-active-bg}; +} + +.select { + --bb-select-focus-shadow: #{$bb-select-focus-shadow}; + --bb-select-padding-right: #{$bb-select-padding-right}; + --bb-select-padding: #{$bb-select-padding}; + --bb-select-search-padding: #{$bb-select-search-padding}; + --bb-select-search-margin-bottom: #{$bb-select-search-margin-bottom}; + --bb-select-search-border-color: #{$bb-select-search-border-color}; + --bb-select-search-padding-right: #{$bb-select-search-padding-right}; + --bb-select-search-icon-color: #{$bb-select-search-icon-color}; + --bb-select-search-icon-right: #{$bb-select-search-icon-right}; + --bb-select-search-icon-top: #{$bb-select-search-icon-top}; + --bb-select-search-height: #{$bb-select-search-height}; + --bb-select-append-width: #{$bb-select-append-width}; + --bb-select-append-color: #{$bb-select-append-color}; +} + +.select:not(.cascade) .dropdown-menu { + overflow-x: hidden; + width: 100%; +} + +.cascade, +.select { + --bb-select-dropdown-menu-margin-top: 8px; +} + +.cascade .dropdown-menu, +.selec .dropdown-menu { + margin-block-start: var(--bb-select-dropdown-menu-margin-top) !important; +} + +.select .form-select { + background-image: none; + background-color: var(--bs-body-bg); + border: var(--bs-border-width) solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + padding: var(--bb-select-padding); + padding-inline-end: var(--bb-select-padding-right); + cursor: pointer; +} + +.select .form-select:disabled { + background-color: var(--bs-secondary-bg); +} + +.dropdown-menu { + --bs-dropdown-border-radius: var(--bs-border-radius); + overflow: auto; + max-height: var(--bb-dropdown-max-height); +} + +.dropdown-menu .dropdown-virtual { + overflow-y: auto; + margin: calc(0px - var(--bs-dropdown-padding-y)) var(--bs-dropdown-padding-x); + max-height: calc(var(--bb-dropdown-max-height) - 2px); + padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x); +} + +.dropdown-menu .search + .dropdown-virtual { + max-height: calc(var(--bb-dropdown-max-height) - var(--bb-select-search-height)); +} + +.dropdown-item { + cursor: pointer; +} + +.dropdown-item.preActive { + background-color: var(--bb-dropdown-link-pre-active-bg); +} + +.dropdown-menu-arrow { + width: 0; + height: 0; + border-width: 0 6px 6px; + border-style: solid; + border-color: transparent transparent rgba(0,0,0,.15); + position: absolute; + left: 20px; + margin-block-start: 4px; + z-index: 1001; + display: none; +} + +.dropdown-menu-arrow:after { + content: " "; + width: 0; + height: 0; + border-width: 0 6px 6px; + border-style: solid; + border-color: transparent transparent var(--bs-body-bg); + position: absolute; + top: 1px; + left: -6px; +} + +[data-bs-theme='dark'] .dropdown-menu-arrow:after { + content: none; +} + +.show > .dropdown-menu, +.show > .dropdown-menu-arrow { + display: block; +} + +.form-select:focus { + box-shadow: var(--bb-select-focus-shadow); + border-color: var(--bb-border-focus-color); +} + +.form-select:not(:disabled):hover { + border-color: var(--bb-border-hover-color); +} + +.form-select.show + .form-select-append i { + transform: rotate(0); +} + +.dropdown-menu[data-popper-placement="bottom-start"].show + .dropdown-menu-arrow, +.dropdown-menu[data-bs-popper="none"].show + .dropdown-menu-arrow { + display: block; +} + +.form-select-append { + position: absolute; + height: 100%; + width: var(--bb-select-append-width); + right: 0; + top: 0; + color: var(--bb-select-append-color); + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} + +.form-select-append i { + transition: all .3s; + transform: rotate(180deg); +} + +.show > .form-select-append i { + transform: rotate(0); +} + +.select .clear-icon { + position: absolute; + height: 100%; + width: var(--bb-select-append-width); + right: 0; + top: 0; + color: var(--bb-select-append-color); + align-items: center; + justify-content: center; + cursor: pointer; + display: none; +} + +.select:hover .clear-icon { + display: flex; +} + +.select.cls:hover .form-select-append { + display: none; +} + +.form-select.is-valid:focus, +.was-validated .form-select:valid:focus, +.form-select.is-invalid:focus, +.was-validated .form-select:invalid:focus { + box-shadow: none; +} + +.form-select.is-valid:not([multiple]):not([size]), +.form-select.is-valid:not([multiple])[size="1"], +.was-validated .form-select:valid:not([multiple]):not([size]), +.was-validated .form-select:valid:not([multiple])[size="1"], +.form-select.is-invalid:not([multiple]):not([size]), +.form-select.is-invalid:not([multiple])[size="1"], +.was-validated .form-select:invalid:not([multiple]):not([size]), +.was-validated .form-select:invalid:not([multiple])[size="1"] { + background-position: right -1rem center, center right 1.5rem; + padding-inline-end: var(--bb-select-padding-right); +} + +.arrow-danger { + border-color: transparent transparent var(--bs-danger); +} + +.arrow-success { + border-color: transparent transparent var(--bs-success); +} + +.arrow-primary { + border-color: transparent transparent var(--bs-primary); +} + +.arrow-warning { + border-color: transparent transparent var(--bs-warning); +} + +.arrow-info { + border-color: transparent transparent var(--bs-info); +} + +.dropdown-menu .search { + padding: var(--bb-select-search-padding); + position: relative; + border-block-end: var(--bs-border-width) solid var(--bb-select-search-border-color); + margin-block-end: var(--bb-select-search-margin-bottom); +} + +.dropdown-menu .search.is-fixed { + position: sticky; + top: calc(-1 * var(--bs-dropdown-padding-y)); + background-color: var(--bs-dropdown-bg); +} + +.dropdown-menu .search .search-text { + padding-inline-end: var(--bb-select-search-padding-right); +} + +.dropdown-menu .search .icon { + position: absolute; + right: var(--bb-select-search-icon-right); + top: var(--bb-select-search-icon-top); + color: var(--bb-select-search-icon-color); +} + +.select:not(.multi-select) .dropdown-toggle { + position: relative; +} + +.select .dropdown-toggle:after, +.btn-popover-confirm.dropdown-toggle:after { + content: none; +} diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectOptionGeneric.cs b/src/BootstrapBlazor/Components/SelectGeneric/SelectOptionGeneric.cs new file mode 100644 index 00000000000..8dc1751696f --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectOptionGeneric.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// SelectOptionPro 组件 +/// +public class SelectOptionGeneric : ComponentBase +{ + /// + /// 获得/设置 显示名称 + /// + [Parameter] + public string? Text { get; set; } + + /// + /// 获得/设置 选项值 + /// + [Parameter] + public TValue? Value { get; set; } + + /// + /// 获得/设置 是否选中 默认 false + /// + [Parameter] + public bool Active { get; set; } + + /// + /// 获得/设置 是否禁用 默认 false + /// + [Parameter] + public bool IsDisabled { get; set; } + + /// + /// 获得/设置 分组名称 + /// + [Parameter] + public string? GroupName { get; set; } + + /// + /// 父组件通过级联参数获得 + /// + [CascadingParameter] + private ISelectGeneric? Container { get; set; } + + /// + /// OnInitialized 方法 + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + Container?.Add(ToSelectedItem()); + } + + private SelectedItem ToSelectedItem() => new(Value, Text ?? "") + { + Active = Active, + GroupName = GroupName ?? "", + IsDisabled = IsDisabled + }; +} diff --git a/src/BootstrapBlazor/Extensions/EnumExtensions.cs b/src/BootstrapBlazor/Extensions/EnumExtensions.cs index b3b4402d5ef..ffa64d18efb 100644 --- a/src/BootstrapBlazor/Extensions/EnumExtensions.cs +++ b/src/BootstrapBlazor/Extensions/EnumExtensions.cs @@ -72,6 +72,33 @@ public static List ToSelectList(this Type type, SelectedItem? addi return ret; } + /// + /// 获取指定枚举类型的枚举值集合,默认通过 DisplayAttribute DescriptionAttribute 标签显示 DisplayName 支持资源文件 回退机制显示字段名称 + /// + /// + /// + /// + public static List> ToSelectList(this Type type, SelectedItem? additionalItem = null) + { + var ret = new List>(); + if (additionalItem != null) + { + ret.Add(additionalItem); + } + + if (type.IsEnum()) + { + var t = Nullable.GetUnderlyingType(type) ?? type; + foreach (var field in Enum.GetNames(t)) + { + var desc = Utility.GetDisplayName(t, field); + var val = (TValue)Enum.Parse(t, field); + ret.Add(new SelectedItem(val, desc)); + } + } + return ret; + } + /// /// 判断类型是否为枚举类型 /// diff --git a/src/BootstrapBlazor/Misc/SelectedItem.cs b/src/BootstrapBlazor/Misc/SelectedItem.cs index f57316ea977..d31c19112a2 100644 --- a/src/BootstrapBlazor/Misc/SelectedItem.cs +++ b/src/BootstrapBlazor/Misc/SelectedItem.cs @@ -18,12 +18,16 @@ public SelectedItem() { } /// /// 构造函数 /// - public SelectedItem(string value, string text) => (Value, Text) = (value, text); + public SelectedItem(string value, string text) + { + Value = value ?? ""; + Text = text; + } /// /// 获得/设置 显示名称 /// - public virtual string Text { get; set; } = ""; + public string Text { get; set; } = ""; /// /// 获得/设置 选项值 diff --git a/src/BootstrapBlazor/Misc/SelectedItemOfT.cs b/src/BootstrapBlazor/Misc/SelectedItemOfT.cs index 44925c38561..cb7bef8405f 100644 --- a/src/BootstrapBlazor/Misc/SelectedItemOfT.cs +++ b/src/BootstrapBlazor/Misc/SelectedItemOfT.cs @@ -6,12 +6,48 @@ namespace BootstrapBlazor.Components; /// -/// 泛型实现类 +/// 泛型实现类 /// -public class SelectedItem : SelectedItem +public class SelectedItem { /// - /// 获得/设置 泛型值 + /// 构造函数 /// - public new T? Value { get; set; } + public SelectedItem() { } + + /// + /// 构造函数 + /// + /// + /// + public SelectedItem(T? value, string text) + { + Value = value; + Text = text; + } + + /// + /// 获得/设置 显示名称 + /// + public string Text { get; set; } = ""; + + /// + /// 获得/设置 选项值 + /// + public T? Value { get; set; } + + /// + /// 获得/设置 是否选中 + /// + public bool Active { get; set; } + + /// + /// 获得/设置 是否禁用 + /// + public bool IsDisabled { get; set; } + + /// + /// 获得/设置 分组名称 + /// + public string GroupName { get; set; } = ""; } diff --git a/test/UnitTest/Components/DisplayTest.cs b/test/UnitTest/Components/DisplayTest.cs index 8fa9d600d1d..4db4b6ecc89 100644 --- a/test/UnitTest/Components/DisplayTest.cs +++ b/test/UnitTest/Components/DisplayTest.cs @@ -226,6 +226,14 @@ public void Nullable_Enum() Assert.Contains("中学", cut.Markup); } + [Fact] + public void Format_Test() + { + var cut = Context.RenderComponent(); + var result = cut.Instance.Test(new SelectedItem("1", "Test")); + Assert.Equal("1", result); + } + class DisplayGenericValueMock { [NotNull] @@ -246,4 +254,12 @@ public override string ToString() return Value.ToString(); } } + + class MockComponent : DisplayBase + { + public string? Test(SelectedItem v) + { + return base.FormatValueAsString(v); + } + } } diff --git a/test/UnitTest/Components/SelectTest.cs b/test/UnitTest/Components/SelectTest.cs index 96d6714e653..3d4680d1379 100644 --- a/test/UnitTest/Components/SelectTest.cs +++ b/test/UnitTest/Components/SelectTest.cs @@ -13,15 +13,22 @@ namespace UnitTest.Components; public class SelectTest : BootstrapBlazorTestBase { + [Fact] + public void SeletectedItem_Ok() + { + var item = new SelectedItem(null!, "Text"); + Assert.Equal(item.Value, string.Empty); + } + [Fact] public async Task OnSearchTextChanged_Null() { var cut = Context.RenderComponent(pb => { - pb.AddChildContent>(pb => + pb.AddChildContent>(pb => { pb.Add(a => a.ShowSearch, true); - pb.Add(a => a.Items, new List() + pb.Add(a => a.Items, new List>() { new("1", "Test1"), new("2", "Test2") { IsDisabled = true } @@ -29,7 +36,7 @@ public async Task OnSearchTextChanged_Null() }); }); - var ctx = cut.FindComponent>(); + var ctx = cut.FindComponent>(); await ctx.InvokeAsync(async () => { await ctx.Instance.ConfirmSelectedItem(0); @@ -54,7 +61,7 @@ await ctx.InvokeAsync(async () => pb.Add(a => a.OnSelectedItemChanged, null); pb.Add(a => a.OnSearchTextChanged, text => { - return new List() + return new List>() { new("1", "Test1") }; @@ -71,11 +78,11 @@ await ctx.InvokeAsync(() => [Fact] public void Options_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Options, builder => { - builder.OpenComponent(0); + builder.OpenComponent>(0); builder.AddAttribute(1, "Text", "Test-Select"); builder.CloseComponent(); @@ -89,13 +96,13 @@ public void Options_Ok() [Fact] public void Disabled_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.IsDisabled, true); pb.Add(a => a.Options, builder => { - builder.OpenComponent(0); - builder.AddAttribute(1, nameof(SelectOption.IsDisabled), true); + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(SelectOptionGeneric.IsDisabled), true); builder.CloseComponent(); builder.OpenComponent(2); @@ -110,10 +117,10 @@ public void Disabled_Ok() public void IsClearable_Ok() { var val = "Test2"; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.IsClearable, true); - pb.Add(a => a.Items, new List() + pb.Add(a => a.Items, new List>() { new("", "请选择"), new("2", "Test2"), @@ -137,10 +144,10 @@ public void IsClearable_Ok() pb.Add(a => a.Color, Color.Danger); }); - var validPi = typeof(Select).GetProperty("IsValid", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var validPi = typeof(SelectGeneric).GetProperty("IsValid", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; validPi.SetValue(select.Instance, true); - var pi = typeof(Select).GetProperty("ClearClassString", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var pi = typeof(SelectGeneric).GetProperty("ClearClassString", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; val = pi.GetValue(select.Instance, null)!.ToString(); Assert.Contains("text-success", val); @@ -152,7 +159,7 @@ public void IsClearable_Ok() [Fact] public void SelectOption_Ok() { - var cut = Context.RenderComponent(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Text, "Test-SelectOption"); pb.Add(a => a.GroupName, "Test-GroupName"); @@ -165,14 +172,14 @@ public void SelectOption_Ok() [Fact] public void Enum_Ok() { - var cut = Context.RenderComponent>(); + var cut = Context.RenderComponent>(); Assert.Equal(2, cut.FindAll(".dropdown-item").Count); } [Fact] public void NullableEnum_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.AdditionalAttributes, new Dictionary() { @@ -188,9 +195,9 @@ public async Task OnSelectedItemChanged_OK() var triggered = false; // 空值时,不触发 OnSelectedItemChanged 回调 - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("", "Test"), new("1", "Test2") @@ -202,35 +209,29 @@ public async Task OnSelectedItemChanged_OK() return Task.CompletedTask; }); }); - Assert.False(triggered); + Assert.True(triggered); // 切换候选项时触发 OnSelectedItemChanged 回调测试 - await cut.InvokeAsync(() => - { - var items = cut.FindAll(".dropdown-item"); - var count = items.Count; - Assert.Equal(2, count); + var items = cut.FindAll(".dropdown-item"); + var count = items.Count; + Assert.Equal(2, count); - var item = items[1]; - item.Click(); - }); + var item = items[1]; + await cut.InvokeAsync(() => { item.Click(); }); Assert.True(triggered); // 切换回 空值 触发 OnSelectedItemChanged 回调测试 triggered = false; - await cut.InvokeAsync(() => - { - var items = cut.FindAll(".dropdown-item"); - var item = items[0]; - item.Click(); - }); + items = cut.FindAll(".dropdown-item"); + item = items[0]; + await cut.InvokeAsync(() => { item.Click(); }); Assert.True(triggered); // 首次加载值不为空时触发 OnSelectedItemChanged 回调测试 triggered = false; cut.SetParametersAndRender(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("", "Test"), new("1", "Test1"), @@ -242,14 +243,11 @@ await cut.InvokeAsync(() => // 切换回 空值 触发 OnSelectedItemChanged 回调测试 triggered = false; - await cut.InvokeAsync(() => - { - var items = cut.FindAll(".dropdown-item"); - var count = items.Count; - Assert.Equal(3, count); - var item = items[0]; - item.Click(); - }); + items = cut.FindAll(".dropdown-item"); + count = items.Count; + Assert.Equal(3, count); + item = items[0]; + await cut.InvokeAsync(() => { item.Click(); }); Assert.True(triggered); } @@ -257,7 +255,7 @@ await cut.InvokeAsync(() => public async Task OnSelectedItemChanged_Generic() { Foo? selectedValue = null; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Items, new SelectedItem[] { @@ -291,9 +289,9 @@ public void DisableItemChangedWhenFirstRender_Ok() var triggered = false; // 空值时,不触发 OnSelectedItemChanged 回调 - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test"), new("2", "Test2") @@ -312,7 +310,7 @@ public void DisableItemChangedWhenFirstRender_Ok() [Fact] public void Color_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Color, Color.Danger); }); @@ -338,7 +336,7 @@ public void Validate_Ok() return Task.CompletedTask; }); builder.Add(a => a.Model, model); - builder.AddChildContent>(pb => + builder.AddChildContent>(pb => { pb.Add(a => a.Value, model.Name); pb.Add(a => a.OnValueChanged, v => @@ -347,7 +345,7 @@ public void Validate_Ok() return Task.CompletedTask; }); pb.Add(a => a.ValueExpression, model.GenerateValueExpression()); - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("", "Test"), new("1", "Test1") { GroupName = "Test1" }, @@ -363,7 +361,7 @@ public void Validate_Ok() Assert.True(valid); }); - var ctx = cut.FindComponent>(); + var ctx = cut.FindComponent>(); ctx.InvokeAsync(async () => { await ctx.Instance.ConfirmSelectedItem(0); @@ -376,9 +374,9 @@ public void Validate_Ok() [Fact] public void ItemTemplate_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1") { GroupName = "Test1" }, new("2", "Test2") { GroupName = "Test2" } @@ -398,9 +396,9 @@ public void ItemTemplate_Ok() [Fact] public void GroupItemTemplate_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1") { GroupName = "Test1" }, new("2", "Test2") { GroupName = "Test2" } @@ -421,19 +419,19 @@ public void GroupItemTemplate_Ok() [Fact] public void NullItems_Ok() { - var cut = Context.RenderComponent>(); + var cut = Context.RenderComponent>(); Assert.Contains("select", cut.Markup); } [Fact] public void NullBool_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new List + pb.Add(a => a.Items, new List> { - new("true", "True"), - new("false", "False"), + new(true, "True"), + new(false, "False"), }); pb.Add(a => a.Value, null); }); @@ -443,32 +441,12 @@ public void NullBool_Ok() Assert.True(cut.Instance.Value); } - [Fact] - public void SelectItem_Ok() - { - var v = new SelectedItem("2", "Text2"); - var cut = Context.RenderComponent>(pb => - { - pb.Add(a => a.Items, new List - { - new("1", "Text1"), - new("2", "Text2"), - }); - pb.Add(a => a.Value, v); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, i => v = i)); - }); - Assert.Equal("2", cut.Instance.Value.Value); - - cut.InvokeAsync(() => cut.Find(".dropdown-item").Click()); - Assert.Equal("1", cut.Instance.Value.Value); - } - [Fact] public void SearchIcon_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -483,9 +461,9 @@ public void SearchIcon_Ok() [Fact] public void IsFixedSearch_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -501,9 +479,9 @@ public void IsFixedSearch_Ok() [Fact] public void CustomClass_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -517,9 +495,9 @@ public void CustomClass_Ok() [Fact] public void ShowShadow_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -538,9 +516,9 @@ public void ShowShadow_Ok() [Fact] public void DropdownIcon_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -554,9 +532,9 @@ public void DropdownIcon_Ok() [Fact] public void DisplayTemplate_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -573,9 +551,9 @@ public void DisplayTemplate_Ok() [Fact] public void IsPopover_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -590,9 +568,9 @@ public void IsPopover_Ok() [Fact] public void Offset_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -607,9 +585,9 @@ public void Offset_Ok() [Fact] public void Placement_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -629,9 +607,9 @@ public void Placement_Ok() [Fact] public void ItemClick_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -651,9 +629,9 @@ public void ItemClick_Ok() [Fact] public void IsVirtualize_Items() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -676,9 +654,9 @@ public void IsVirtualize_Items() [Fact] public async Task IsVirtualize_Items_Clearable_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -727,7 +705,7 @@ public async Task IsVirtualize_OnQueryAsync_Clearable_Ok() var startIndex = 0; var requestCount = 0; var searchText = string.Empty; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.OnQueryAsync, option => { @@ -735,7 +713,7 @@ public async Task IsVirtualize_OnQueryAsync_Clearable_Ok() startIndex = option.StartIndex; requestCount = option.Count; searchText = option.SearchText; - return Task.FromResult(new QueryData() + return Task.FromResult(new QueryData>() { Items = string.IsNullOrEmpty(searchText) ? [new("", "All"), new("1", "Test1"), new("2", "Test2")] @@ -779,22 +757,22 @@ public async Task IsVirtualize_OnQueryAsync_Clearable_Ok() } [Fact] - public void IsVirtualize_BindValue() + public async Task IsVirtualize_BindValue() { - var value = new SelectedItem("3", "Test 3"); - var cut = Context.RenderComponent>(pb => + var value = "3"; + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Value, value); pb.Add(a => a.IsVirtualize, true); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, new Action(item => + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, new Action(item => { value = item; }))); pb.Add(a => a.OnQueryAsync, option => { - return Task.FromResult(new QueryData() + return Task.FromResult(new QueryData>() { - Items = new SelectedItem[] + Items = new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -804,31 +782,25 @@ public void IsVirtualize_BindValue() }); }); - cut.InvokeAsync(() => - { - var input = cut.Find(".form-select"); - Assert.Equal("Test 3", input.GetAttribute("value")); - }); - cut.Contains("Test 3"); + var input = cut.Find(".form-select"); + Assert.Null(input.GetAttribute("value")); + var select = cut.Instance; - Assert.Equal("3", select.Value?.Value); + Assert.Equal("3", select.Value); - cut.InvokeAsync(() => - { - var item = cut.Find(".dropdown-item"); - item.Click(); - Assert.Equal("1", value.Value); + var item = cut.Find(".dropdown-item"); + await cut.InvokeAsync(() => { item.Click(); }); + Assert.Equal("1", value); - var input = cut.Find(".form-select"); - Assert.Equal("Test1", input.GetAttribute("value")); - }); + input = cut.Find(".form-select"); + Assert.Equal("Test1", input.GetAttribute("value")); } [Fact] public void IsVirtualize_DefaultVirtualizeItemText() { string? value = "3"; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.IsVirtualize, true); pb.Add(a => a.DefaultVirtualizeItemText, "Test 3"); @@ -839,9 +811,9 @@ public void IsVirtualize_DefaultVirtualizeItemText() }))); pb.Add(a => a.OnQueryAsync, option => { - return Task.FromResult(new QueryData() + return Task.FromResult(new QueryData>() { - Items = new SelectedItem[] + Items = new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -861,11 +833,11 @@ public void IsVirtualize_DefaultVirtualizeItemText() [Fact] public void LoadItems_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.OnQueryAsync, option => { - return Task.FromResult(new QueryData()); + return Task.FromResult(new QueryData>()); }); pb.Add(a => a.Value, "2"); pb.Add(a => a.IsVirtualize, true); @@ -879,40 +851,12 @@ public void LoadItems_Ok() mi?.Invoke(select, [new ItemsProviderRequest(0, 1, CancellationToken.None)]); } - [Fact] - public void TryParseValueFromString_Ok() - { - var items = new SelectedItem[] - { - new("1", "Test1"), - new("2", "Test2") - }; - var cut = Context.RenderComponent>(pb => - { - pb.Add(a => a.Items, items); - pb.Add(a => a.Value, new SelectedItem("1", "Test1")); - pb.Add(a => a.IsVirtualize, true); - }); - var select = cut.Instance; - var mi = select.GetType().GetMethod("TryParseSelectItem", BindingFlags.NonPublic | BindingFlags.Instance); - - string value = ""; - SelectedItem result = new(); - string? msg = null; - mi?.Invoke(select, [value, result, msg]); - - var p = select.GetType().GetProperty("VirtualItems", BindingFlags.NonPublic | BindingFlags.Instance); - p?.SetValue(select, items); - value = "1"; - mi?.Invoke(select, [value, result, msg]); - } - [Fact] public void IsMarkupString_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "
Test1
"), new("2", "
Test2
") @@ -926,9 +870,9 @@ public void IsMarkupString_Ok() [Fact] public async Task IsEditable_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "
Test1
"), new("2", "
Test2
") @@ -947,13 +891,14 @@ public async Task IsEditable_Ok() updated = true; return Task.CompletedTask; }); + pb.Add(a => a.TextConvertToValueCallback, v => + { + return Task.FromResult(v); + }); }); Assert.False(input.IsReadOnly()); - await cut.InvokeAsync(() => - { - input.Change("Test3"); - }); + await cut.InvokeAsync(() => { input.Change("Test3"); }); Assert.Equal("Test3", cut.Instance.Value); Assert.True(updated); } @@ -966,7 +911,7 @@ public async Task IsEditable_Generic() new() { Value = new Foo() { Id = 1, Address = "Foo1" }, Text = "test1" }, new() { Value = new Foo() { Id = 2, Address = "Foo2" }, Text = "test2" } }; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Items, items); pb.Add(a => a.Value, new Foo() { Id = 1, Address = "Foo1" }); @@ -989,9 +934,9 @@ public async Task IsEditable_Generic() public async Task OnClearAsync_Ok() { var clear = false; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "
Test1
"), new("2", "
Test2
") @@ -1019,9 +964,9 @@ await cut.InvokeAsync(() => [Fact] public async Task Toggle_Ok() { - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.Items, new SelectedItem[] + pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1"), new("2", "Test2") @@ -1047,7 +992,7 @@ public void GenericValue_Ok() Text = "Foo2" } }; - var cut = Context.RenderComponent>(pb => + var cut = Context.RenderComponent>(pb => { pb.Add(a => a.Items, items); }); diff --git a/test/UnitTest/Components/SwalTest.cs b/test/UnitTest/Components/SwalTest.cs index 1f742046be0..b5313d893e8 100644 --- a/test/UnitTest/Components/SwalTest.cs +++ b/test/UnitTest/Components/SwalTest.cs @@ -212,9 +212,9 @@ public void Show_Ok() // 带确认框的 Select cut.SetParametersAndRender(pb => { - pb.AddChildContent>(pb => + pb.AddChildContent>(pb => { - pb.Add(a => a.Items, new List() + pb.Add(a => a.Items, new List>() { new("1", "Test1"), new("2", "Test2") { IsDisabled = true } @@ -228,7 +228,7 @@ public void Show_Ok() }); }); - Task.Run(() => cut.InvokeAsync(() => cut.FindComponent>().Instance.ConfirmSelectedItem(0))); + Task.Run(() => cut.InvokeAsync(() => cut.FindComponent>().Instance.ConfirmSelectedItem(0))); tick = DateTime.Now; while (!cut.Markup.Contains("test-swal-footer")) {