From 9d4af11a0f64e3af1cf845c28c456d38a00f9a8c Mon Sep 17 00:00:00 2001 From: Argo-AsicoTech Date: Fri, 13 Dec 2024 20:42:09 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20SelectGeneric?= =?UTF-8?q?=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SelectGeneric/ISelectGeneric.cs | 18 + .../SelectGeneric/SelectGeneric.razor | 115 ++++ .../SelectGeneric/SelectGeneric.razor.cs | 569 ++++++++++++++++++ .../SelectGeneric/SelectGeneric.razor.scss | 241 ++++++++ .../SelectGeneric/SelectOptionGeneric.cs | 67 +++ .../Extensions/EnumExtensions.cs | 27 + src/BootstrapBlazor/Misc/SelectedItemOfT.cs | 53 ++ 7 files changed, 1090 insertions(+) create mode 100644 src/BootstrapBlazor/Components/SelectGeneric/ISelectGeneric.cs create mode 100644 src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor create mode 100644 src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs create mode 100644 src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.scss create mode 100644 src/BootstrapBlazor/Components/SelectGeneric/SelectOptionGeneric.cs create mode 100644 src/BootstrapBlazor/Misc/SelectedItemOfT.cs 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..f5764ae07d9 --- /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 [JSModuleAutoLoader("./_content/BootstrapBlazor/Components/Select/Select.razor.js", 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..7107cf8a963 --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs @@ -0,0 +1,569 @@ +// 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; +using Microsoft.AspNetCore.Components.Web.Virtualization; +using Microsoft.Extensions.Localization; +using System.ComponentModel.DataAnnotations; + +namespace BootstrapBlazor.Components; + +/// +/// Select 组件实现类 +/// +/// +[CascadingTypeParameter(nameof(TValue))] +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.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..01299f97134 --- /dev/null +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectOptionGeneric.cs @@ -0,0 +1,67 @@ +// 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; + +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/SelectedItemOfT.cs b/src/BootstrapBlazor/Misc/SelectedItemOfT.cs new file mode 100644 index 00000000000..cb7bef8405f --- /dev/null +++ b/src/BootstrapBlazor/Misc/SelectedItemOfT.cs @@ -0,0 +1,53 @@ +// 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; + +/// +/// 泛型实现类 +/// +public class SelectedItem +{ + /// + /// 构造函数 + /// + 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; } = ""; +} From 41e5e9256a795699bebb502af4b6c40e726b2afd Mon Sep 17 00:00:00 2001 From: Argo-AsicoTech Date: Fri, 13 Dec 2024 20:42:17 +0800 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/UnitTest/Components/SelectGenericTest.cs | 1040 +++++++++++++++++ 1 file changed, 1040 insertions(+) create mode 100644 test/UnitTest/Components/SelectGenericTest.cs diff --git a/test/UnitTest/Components/SelectGenericTest.cs b/test/UnitTest/Components/SelectGenericTest.cs new file mode 100644 index 00000000000..07aeef6caa5 --- /dev/null +++ b/test/UnitTest/Components/SelectGenericTest.cs @@ -0,0 +1,1040 @@ +// 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 AngleSharp.Dom; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Components.Web.Virtualization; +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace UnitTest.Components; + +public class SelectGenericTest : 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.Add(a => a.ShowSearch, true); + pb.Add(a => a.Items, new List>() + { + new("1", "Test1"), + new("2", "Test2") { IsDisabled = true } + }); + }); + }); + + var ctx = cut.FindComponent>(); + await ctx.InvokeAsync(async () => + { + await ctx.Instance.ConfirmSelectedItem(0); + + // 搜索 T + ctx.Find(".search-text").Input("T"); + await ctx.Instance.ConfirmSelectedItem(0); + }); + + ctx.SetParametersAndRender(pb => + { + pb.Add(a => a.OnBeforeSelectedItemChange, item => Task.FromResult(false)); + pb.Add(a => a.OnSelectedItemChanged, item => Task.CompletedTask); + }); + await ctx.InvokeAsync(() => ctx.Instance.ConfirmSelectedItem(0)); + + ctx.Instance.ClearSearchText(); + + ctx.SetParametersAndRender(pb => + { + pb.Add(a => a.OnBeforeSelectedItemChange, null); + pb.Add(a => a.OnSelectedItemChanged, null); + pb.Add(a => a.OnSearchTextChanged, text => + { + return new List>() + { + new("1", "Test1") + }; + }); + }); + + await ctx.InvokeAsync(() => + { + ctx.Find(".search-text").Input("T"); + }); + cut.DoesNotContain("Test2"); + } + + [Fact] + public void Options_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Options, builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Text", "Test-Select"); + builder.CloseComponent(); + + builder.OpenComponent(2); + builder.CloseComponent(); + }); + }); + Assert.Contains("Test-Select", cut.Markup); + } + + [Fact] + public void Disabled_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.IsDisabled, true); + pb.Add(a => a.Options, builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(SelectOptionGeneric.IsDisabled), true); + builder.CloseComponent(); + + builder.OpenComponent(2); + builder.CloseComponent(); + }); + }); + Assert.Contains("_input\" disabled=\"disabled\"", cut.Markup); + Assert.Contains("dropdown-item active disabled", cut.Markup); + } + + [Fact] + public void IsClearable_Ok() + { + var val = "Test2"; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.Items, new List>() + { + new("", "请选择"), + new("2", "Test2"), + new("3", "Test3") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.OnValueChanged, v => + { + val = v; + return Task.CompletedTask; + }); + }); + var clearButton = cut.Find(".clear-icon"); + cut.InvokeAsync(() => clearButton.Click()); + Assert.Empty(val); + + // 提高代码覆盖率 + var select = cut; + select.SetParametersAndRender(pb => + { + pb.Add(a => a.Color, Color.Danger); + }); + + var validPi = typeof(SelectGeneric).GetProperty("IsValid", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + validPi.SetValue(select.Instance, true); + + 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); + + validPi.SetValue(select.Instance, false); + val = pi.GetValue(select.Instance, null)!.ToString(); + Assert.Contains("text-danger", val); + } + + [Fact] + public void SelectOption_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Text, "Test-SelectOption"); + pb.Add(a => a.GroupName, "Test-GroupName"); + pb.Add(a => a.IsDisabled, false); + pb.Add(a => a.Active, true); + pb.Add(a => a.Value, ""); + }); + } + + [Fact] + public void Enum_Ok() + { + var cut = Context.RenderComponent>(); + Assert.Equal(2, cut.FindAll(".dropdown-item").Count); + } + + [Fact] + public void NullableEnum_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.AdditionalAttributes, new Dictionary() + { + ["placeholder"] = "" + }); + }); + Assert.Equal(3, cut.FindAll(".dropdown-item").Count); + } + + [Fact] + public async Task OnSelectedItemChanged_OK() + { + var triggered = false; + + // 空值时,不触发 OnSelectedItemChanged 回调 + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("", "Test"), + new("1", "Test2") + }); + pb.Add(a => a.Value, ""); + pb.Add(a => a.OnSelectedItemChanged, item => + { + triggered = true; + return Task.CompletedTask; + }); + }); + Assert.True(triggered); + + // 切换候选项时触发 OnSelectedItemChanged 回调测试 + var items = cut.FindAll(".dropdown-item"); + var count = items.Count; + Assert.Equal(2, count); + + var item = items[1]; + await cut.InvokeAsync(() => { item.Click(); }); + Assert.True(triggered); + + // 切换回 空值 触发 OnSelectedItemChanged 回调测试 + triggered = false; + 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[] + { + new("", "Test"), + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + }); + Assert.True(triggered); + + // 切换回 空值 触发 OnSelectedItemChanged 回调测试 + triggered = false; + items = cut.FindAll(".dropdown-item"); + count = items.Count; + Assert.Equal(3, count); + item = items[0]; + await cut.InvokeAsync(() => { item.Click(); }); + Assert.True(triggered); + } + + [Fact] + public async Task OnSelectedItemChanged_Generic() + { + Foo? selectedValue = null; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new() { Value = new Foo() { Id = 1, Address = "Foo1" }, Text = "test1" }, + new() { Value = new Foo() { Id = 2, Address = "Foo2" }, Text = "test2" } + }); + pb.Add(a => a.Value, new Foo() { Id = 1, Address = "Foo1" }); + pb.Add(a => a.OnSelectedItemChanged, v => + { + if (v is SelectedItem d) + { + selectedValue = d.Value; + } + return Task.CompletedTask; + }); + pb.Add(a => a.CustomKeyAttribute, typeof(KeyAttribute)); + }); + + IModelEqualityComparer comparer = cut.Instance as IModelEqualityComparer; + Assert.NotNull(comparer); + comparer.ModelEqualityComparer = (x, y) => x.Id == y.Id; + + var items = cut.FindAll(".dropdown-item"); + await cut.InvokeAsync(() => items[1].Click()); + Assert.NotNull(selectedValue); + } + + [Fact] + public void DisableItemChangedWhenFirstRender_Ok() + { + var triggered = false; + + // 空值时,不触发 OnSelectedItemChanged 回调 + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test"), + new("2", "Test2") + }); + pb.Add(a => a.Value, ""); + pb.Add(a => a.OnSelectedItemChanged, item => + { + triggered = true; + return Task.CompletedTask; + }); + pb.Add(a => a.DisableItemChangedWhenFirstRender, true); + }); + Assert.False(triggered); + } + + [Fact] + public void Color_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Color, Color.Danger); + }); + Assert.Contains("border-danger", cut.Markup); + } + + [Fact] + public void Validate_Ok() + { + var valid = false; + var invalid = false; + var model = new Foo() { Name = "Test-Select1" }; + 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.Value, model.Name); + pb.Add(a => a.OnValueChanged, v => + { + model.Name = v; + return Task.CompletedTask; + }); + pb.Add(a => a.ValueExpression, model.GenerateValueExpression()); + pb.Add(a => a.Items, new SelectedItem[] + { + new("", "Test"), + new("1", "Test1") { GroupName = "Test1" }, + new("2", "Test2") { GroupName = "Test2" } + }); + }); + }); + + cut.InvokeAsync(() => + { + var form = cut.Find("form"); + form.Submit(); + Assert.True(valid); + }); + + var ctx = cut.FindComponent>(); + ctx.InvokeAsync(async () => + { + await ctx.Instance.ConfirmSelectedItem(0); + var form = cut.Find("form"); + form.Submit(); + Assert.True(invalid); + }); + } + + [Fact] + public void ItemTemplate_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1") { GroupName = "Test1" }, + new("2", "Test2") { GroupName = "Test2" } + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.ItemTemplate, item => builder => + { + builder.OpenElement(0, "div"); + builder.AddContent(1, item.Text); + builder.CloseComponent(); + }); + }); + + cut.Find(".dropdown-item").Click(); + } + + [Fact] + public void GroupItemTemplate_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1") { GroupName = "Test1" }, + new("2", "Test2") { GroupName = "Test2" } + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.GroupItemTemplate, title => builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "group-key"); + builder.AddContent(2, title); + builder.CloseComponent(); + }); + }); + cut.Contains("
Test1
"); + cut.Contains("
Test2
"); + } + + [Fact] + public void NullItems_Ok() + { + var cut = Context.RenderComponent>(); + Assert.Contains("select", cut.Markup); + } + + [Fact] + public void NullBool_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new List> + { + new(true, "True"), + new(false, "False"), + }); + pb.Add(a => a.Value, null); + }); + + // 值为 null + // 候选项中无,导致默认选择第一个 Value 被更改为 true + Assert.True(cut.Instance.Value); + } + + [Fact] + public void SearchIcon_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.ShowSearch, true); + pb.Add(a => a.SearchIcon, "search-icon"); + }); + Assert.Contains("search-icon", cut.Markup); + } + + [Fact] + public void IsFixedSearch_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.ShowSearch, true); + pb.Add(a => a.IsFixedSearch, true); + }); + Assert.Contains("search is-fixed", cut.Markup); + Assert.Contains("class=\"icon", cut.Markup); + } + + [Fact] + public void CustomClass_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.CustomClass, "test-custom-class"); + }); + Assert.Contains("test-custom-class", cut.Markup); + } + + [Fact] + public void ShowShadow_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + }); + Assert.Contains("shadow", cut.Markup); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.ShowShadow, false); + }); + Assert.DoesNotContain("shadow", cut.Markup); + } + + [Fact] + public void DropdownIcon_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.DropdownIcon, "search-icon"); + }); + Assert.Contains("search-icon", cut.Markup); + } + + [Fact] + public void DisplayTemplate_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.DisplayTemplate, item => builder => + { + builder.AddContent(0, $"test-display-template-{item?.Text}"); + }); + }); + Assert.Contains("test-display-template-Test2", cut.Markup); + } + + [Fact] + public void IsPopover_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsPopover, true); + }); + Assert.DoesNotContain("dropdown-menu-arrow", cut.Markup); + Assert.DoesNotContain("data-bs-toggle=\"dropdown\"", cut.Markup); + } + + [Fact] + public void Offset_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsPopover, false); + pb.Add(a => a.Offset, "[0, 11]"); + }); + Assert.Contains("data-bs-offset=\"[0, 11]\"", cut.Markup); + } + + [Fact] + public void Placement_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.Placement, Placement.Top); + }); + cut.Contains($"data-bs-placement=\"{Placement.Top.ToDescriptionString()}\""); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.Placement, Placement.Auto); + }); + cut.DoesNotContain("data-bs-placement"); + } + + [Fact] + public void ItemClick_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsPopover, true); + }); + + cut.InvokeAsync(() => + { + var item = cut.Find(".dropdown-item"); + item.Click(); + Assert.True(item.ClassList.Contains("active")); + }); + } + + [Fact] + public void IsVirtualize_Items() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.RowHeight, 33f); + pb.Add(a => a.OverscanCount, 4); + }); + + cut.SetParametersAndRender(pb => pb.Add(a => a.ShowSearch, true)); + cut.InvokeAsync(async () => + { + // 搜索 T + cut.Find(".search-text").Input("T"); + await cut.Instance.ConfirmSelectedItem(0); + }); + } + + [Fact] + public async Task IsVirtualize_Items_Clearable_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.RowHeight, 33f); + pb.Add(a => a.OverscanCount, 4); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.ShowSearch, true); + }); + + // 覆盖有搜索条件时,点击清空按钮 + // 期望 UI 显示值为默认值 + // 期望 下拉框为全数据 + var input = cut.Find(".search-text"); + await cut.InvokeAsync(() => input.Input("2")); + + // 下拉框仅显示一个选项 Test2 + var items = cut.FindAll(".dropdown-item"); + Assert.Single(items); + + // UI 值为 Test2 + await cut.InvokeAsync(() => items[0].Click()); + var el = cut.Find(".form-select") as IHtmlInputElement; + Assert.NotNull(el); + Assert.Equal("Test2", el.Value); + Assert.Equal("2", cut.Instance.Value); + + // 点击 Clear 按钮 + var button = cut.Find(".clear-icon"); + await cut.InvokeAsync(() => button.Click()); + + // UI 恢复 Test1 + Assert.Equal("Test1", el.Value); + + // 下拉框显示所有选项 + items = cut.FindAll(".dropdown-item"); + Assert.Equal(2, items.Count); + } + + [Fact] + public async Task IsVirtualize_OnQueryAsync_Clearable_Ok() + { + var query = false; + var startIndex = 0; + var requestCount = 0; + var searchText = string.Empty; + 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) + ? [new("", "All"), new("1", "Test1"), new("2", "Test2")] + : [new("2", "Test2")], + TotalCount = string.IsNullOrEmpty(searchText) ? 2 : 1 + }); + }); + pb.Add(a => a.Value, ""); + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.ShowSearch, true); + }); + + // 覆盖有搜索条件时,点击清空按钮 + // 期望 UI 显示值为默认值 + // 期望 下拉框为全数据 + var input = cut.Find(".search-text"); + await cut.InvokeAsync(() => input.Input("2")); + + // 下拉框仅显示一个选项 Test2 + var items = cut.FindAll(".dropdown-item"); + Assert.Single(items); + + // UI 值为 Test2 + await cut.InvokeAsync(() => items[0].Click()); + var el = cut.Find(".form-select") as IHtmlInputElement; + Assert.NotNull(el); + Assert.Equal("Test2", el.Value); + Assert.Equal("2", cut.Instance.Value); + + query = false; + // 点击 Clear 按钮 + var button = cut.Find(".clear-icon"); + await cut.InvokeAsync(() => button.Click()); + + // UI 恢复 Test1 + Assert.Equal("All", el.Value); + + // 下拉框显示所有选项 + Assert.True(query); + } + + [Fact] + public async Task IsVirtualize_BindValue() + { + 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 => + { + value = item; + }))); + pb.Add(a => a.OnQueryAsync, option => + { + return Task.FromResult(new QueryData>() + { + Items = new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }, + TotalCount = 2 + }); + }); + }); + + var input = cut.Find(".form-select"); + Assert.Null(input.GetAttribute("value")); + + var select = cut.Instance; + Assert.Equal("3", select.Value); + + var item = cut.Find(".dropdown-item"); + await cut.InvokeAsync(() => { item.Click(); }); + Assert.Equal("1", 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 => + { + pb.Add(a => a.IsVirtualize, true); + pb.Add(a => a.DefaultVirtualizeItemText, "Test 3"); + pb.Add(a => a.Value, value); + 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>() + { + Items = new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }, + TotalCount = 2 + }); + }); + }); + + cut.InvokeAsync(() => + { + var input = cut.Find(".form-select"); + Assert.Equal("Test 3", input.GetAttribute("value")); + }); + } + + [Fact] + public void LoadItems_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.OnQueryAsync, option => + { + return Task.FromResult(new QueryData>()); + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsVirtualize, true); + }); + var select = cut.Instance; + var mi = select.GetType().GetMethod("LoadItems", BindingFlags.NonPublic | BindingFlags.Instance); + mi?.Invoke(select, [new ItemsProviderRequest(0, 1, CancellationToken.None)]); + + var totalCountProperty = select.GetType().GetProperty("TotalCount", BindingFlags.NonPublic | BindingFlags.Instance); + totalCountProperty?.SetValue(select, 2); + mi?.Invoke(select, [new ItemsProviderRequest(0, 1, CancellationToken.None)]); + } + + [Fact] + public void IsMarkupString_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "
Test1
"), + new("2", "
Test2
") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.IsMarkupString, true); + }); + Assert.Contains("
Test1
", cut.Markup); + } + + [Fact] + public async Task IsEditable_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "
Test1
"), + new("2", "
Test2
") + }); + pb.Add(a => a.Value, "2"); + }); + var input = cut.Find(".form-select"); + Assert.True(input.IsReadOnly()); + + var updated = false; + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.IsEditable, true); + pb.Add(a => a.OnInputChangedCallback, v => + { + 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"); }); + Assert.Equal("Test3", cut.Instance.Value); + Assert.True(updated); + } + + [Fact] + public async Task IsEditable_Generic() + { + var items = new List>() + { + 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 => + { + pb.Add(a => a.Items, items); + pb.Add(a => a.Value, new Foo() { Id = 1, Address = "Foo1" }); + pb.Add(a => a.IsEditable, true); + pb.Add(a => a.TextConvertToValueCallback, v => + { + return Task.FromResult(new Foo() { Id = 3, Address = "Foo3" }); + }); + }); + + var input = cut.Find(".form-select"); + await cut.InvokeAsync(() => { input.Change("test2"); }); + Assert.Equal("Foo2", cut.Instance.Value.Address); + + await cut.InvokeAsync(() => { input.Change("test3"); }); + Assert.Equal("Foo3", cut.Instance.Value.Address); + } + + [Fact] + public async Task OnClearAsync_Ok() + { + var clear = false; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "
Test1
"), + new("2", "
Test2
") + }); + pb.Add(a => a.Value, "2"); + pb.Add(a => a.ShowSearch, true); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.OnClearAsync, () => + { + clear = true; + return Task.CompletedTask; + }); + }); + + var span = cut.Find(".clear-icon"); + Assert.NotNull(span); + + await cut.InvokeAsync(() => + { + span.Click(); + }); + Assert.True(clear); + } + + [Fact] + public async Task Toggle_Ok() + { + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, new SelectedItem[] + { + new("1", "Test1"), + new("2", "Test2") + }); + }); + await cut.Instance.Show(); + await cut.Instance.Hide(); + } + + [Fact] + public void GenericValue_Ok() + { + var items = new List>() + { + new() + { + Value = new Foo() { Id = 1, Name = "Foo1" }, + Text = "Foo1" + }, + new() + { + Value = new Foo() { Id = 2, Name = "Foo2" }, + Text = "Foo2" + } + }; + var cut = Context.RenderComponent>(pb => + { + pb.Add(a => a.Items, items); + }); + } + + [Fact] + public async Task OnBeforeSelectedItemChange_OK() + { + var cut = Context.RenderComponent(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.Items, new List>() + { + new("1", "Test1"), + new("2", "Test2") { IsDisabled = true } + }); + pb.Add(a => a.SwalCategory, SwalCategory.Question); + pb.Add(a => a.SwalTitle, "Swal-Title"); + pb.Add(a => a.SwalContent, "Swal-Content"); + pb.Add(a => a.OnBeforeSelectedItemChange, item => Task.FromResult(true)); + pb.Add(a => a.OnSelectedItemChanged, item => Task.CompletedTask); + pb.Add(a => a.SwalFooter, "test-swal-footer"); + }); + }); + var modals = cut.FindComponents(); + var modal = modals[modals.Count - 1]; + _ = Task.Run(() => cut.InvokeAsync(() => cut.FindComponent>().Instance.ConfirmSelectedItem(0))); + var tick = DateTime.Now; + while (!cut.Markup.Contains("test-swal-footer")) + { + Thread.Sleep(100); + if (DateTime.Now > tick.AddSeconds(2)) + { + break; + } + } + var button = cut.Find(".btn-danger"); + await cut.InvokeAsync(() => + { + button.Click(); + }); + await cut.InvokeAsync(() => modal.Instance.CloseCallback()); + } +} From 7ff987d60a4c2137f0b0d54e655304fab34355ff Mon Sep 17 00:00:00 2001 From: Argo-AsicoTech Date: Fri, 13 Dec 2024 20:43:06 +0800 Subject: [PATCH 3/6] chore: bump version 9.1.3-beta08 --- src/BootstrapBlazor/BootstrapBlazor.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 08cdf115551..81fcfa984f7 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@ - 9.1.3-beta07 + 9.1.3-beta08 From b327fd56f1fa456bc698df114002157b67ee089d Mon Sep 17 00:00:00 2001 From: Argo-AsicoTech Date: Sat, 14 Dec 2024 16:08:38 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E5=90=8E=E6=97=A0=E6=B3=95=E5=B1=95=E5=BC=80=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E6=A1=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Dropdown/Dropdown.razor | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor b/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor index 458c13ee96c..413eda5acc0 100644 --- a/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor +++ b/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor @@ -22,31 +22,28 @@ { } - @if (!IsDisabled) - { -
- @if (ItemsTemplate == null) +
+ @if (ItemsTemplate == null) + { + @foreach (var item in GetItems()) { - @foreach (var item in GetItems()) + if (ItemTemplate != null) { - if (ItemTemplate != null) - { - @ItemTemplate.Invoke(item) - } - else if (item.IsDisabled) - { -
@item.Text
- } - else - { -
@item.Text
- } + @ItemTemplate.Invoke(item) + } + else if (item.IsDisabled) + { +
@item.Text
+ } + else + { +
@item.Text
} } - else - { - @ItemsTemplate - } -
- } + } + else + { + @ItemsTemplate + } +
From 48f0aa2ad60ba3aa0a8d3baab1a0df571ca928dc Mon Sep 17 00:00:00 2001 From: Argo-AsicoTech Date: Sat, 14 Dec 2024 17:19:31 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Item=20?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/BootstrapBlazor/Components/Dropdown/Dropdown.razor | 2 +- .../Components/Dropdown/Dropdown.razor.cs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor b/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor index 413eda5acc0..26b69d56ad4 100644 --- a/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor +++ b/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor @@ -33,7 +33,7 @@ } else if (item.IsDisabled) { -
@item.Text
+
@item.Text
} else { diff --git a/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor.cs b/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor.cs index 08985d29657..61d0c09f0f9 100644 --- a/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor.cs +++ b/src/BootstrapBlazor/Components/Dropdown/Dropdown.razor.cs @@ -62,6 +62,15 @@ public partial class Dropdown .AddClass("active", () => item.Value == CurrentValueAsString) .Build(); + /// + /// 获得/设置 设置当前项是否 Active 方法 + /// + /// + /// + protected string? DisableItem(SelectedItem item) => CssBuilder.Default("dropdown-item") + .AddClass("is-disabled", item.IsDisabled) + .Build(); + /// /// 获得/设置 颜色 默认 Color.None 无设置 /// From ff57f9057477f54fcb1b420399828cfde79412f9 Mon Sep 17 00:00:00 2001 From: Argo-AsicoTech Date: Sat, 14 Dec 2024 17:19:38 +0800 Subject: [PATCH 6/6] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/UnitTest/Components/DropdownTest.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/UnitTest/Components/DropdownTest.cs b/test/UnitTest/Components/DropdownTest.cs index dac0f0cd6f4..f2a7a4838e6 100644 --- a/test/UnitTest/Components/DropdownTest.cs +++ b/test/UnitTest/Components/DropdownTest.cs @@ -226,17 +226,14 @@ public void Disabled_Ok() { var cut = Context.RenderComponent>(pb => { - pb.Add(a => a.IsDisabled, true); + pb.Add(a => a.IsDisabled, false); pb.Add(a => a.Items, new SelectedItem[] { new("1", "Test1") { IsDisabled = true }, new("2", "Test2") }); }); - // 禁用组件不生成 下拉菜单 - cut.DoesNotContain("dropdown-menu"); - - cut.SetParametersAndRender(pb => pb.Add(a => a.IsDisabled, false)); + cut.Contains("
Test1
"); } [Fact]