+
@if (InputGroupStartText is not null)
{
@InputGroupStartText
}
@InputGroupStartTemplate
-
-
+
+
@InputGroupEndTemplate
@@ -36,26 +34,87 @@
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.cs b/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.cs
index 9bb1e4fa8..3a0254489 100644
--- a/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.cs
+++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.cs
@@ -1,84 +1,275 @@
-namespace Havit.Blazor.Components.Web.Bootstrap.Internal;
+using Microsoft.Extensions.Localization;
+using Microsoft.JSInterop;
-public partial class HxMultiSelectInternal
+namespace Havit.Blazor.Components.Web.Bootstrap.Internal;
+
+public partial class HxMultiSelectInternal : IAsyncDisposable
{
[Parameter] public string InputId { get; set; }
[Parameter] public string InputCssClass { get; set; }
+ [Parameter] public InputSize InputSizeEffective { get; set; }
+
[Parameter] public string InputText { get; set; }
[Parameter] public bool EnabledEffective { get; set; }
[Parameter] public List ItemsToRender { get; set; }
- [Parameter] public List SelectedIndexes { get; set; }
+ [Parameter] public List SelectedValues { get; set; }
[Parameter] public Func TextSelector { get; set; }
[Parameter] public Func ValueSelector { get; set; }
- [Parameter] public List Value { get; set; }
-
[Parameter] public string NullDataText { get; set; }
- [Parameter] public EventCallback ItemSelectionChanged { get; set; }
+ [Parameter] public EventCallback OnItemsSelectionChanged { get; set; }
- ///
- /// Custom CSS class to render with input-group span.
- ///
[Parameter] public string InputGroupCssClass { get; set; }
- ///
- /// Input-group at the beginning of the input.
- ///
[Parameter] public string InputGroupStartText { get; set; }
- ///
- /// Input-group at the beginning of the input.
- ///
[Parameter] public RenderFragment InputGroupStartTemplate { get; set; }
- ///
- /// Input-group at the end of the input.
- ///
[Parameter] public string InputGroupEndText { get; set; }
- ///
- /// Input-group at the end of the input.
- ///
[Parameter] public RenderFragment InputGroupEndTemplate { get; set; }
+ [Parameter] public bool AllowFiltering { get; set; }
+
+ [Parameter] public Func FilterPredicate { get; set; }
+
+ [Parameter] public bool ClearFilterOnHide { get; set; }
+
+ [Parameter] public EventCallback OnShown { get; set; }
+
+ [Parameter] public EventCallback OnHidden { get; set; }
+
+ [Parameter] public RenderFragment FilterEmptyResultTemplate { get; set; }
+
+ [Parameter] public string FilterEmptyResultText { get; set; }
+
+ [Parameter] public bool AllowSelectAll { get; set; }
+
+ [Parameter] public string SelectAllText { get; set; }
+
+ [Parameter] public IconBase FilterSearchIcon { get; set; }
+
+ [Parameter] public IconBase FilterClearIcon { get; set; }
+
///
/// Additional attributes to be splatted onto an underlying HTML element.
///
[Parameter(CaptureUnmatchedValues = true)] public Dictionary AdditionalAttributes { get; set; }
+ [Inject] private IJSRuntime JSRuntime { get; set; }
+
+ [Inject] private IStringLocalizer StringLocalizer { get; set; }
+
protected bool HasInputGroupsEffective => !String.IsNullOrWhiteSpace(InputGroupStartText) || !String.IsNullOrWhiteSpace(InputGroupEndText) || (InputGroupStartTemplate is not null) || (InputGroupEndTemplate is not null);
- private ElementReference inputElement;
+ private IJSObjectReference jsModule;
+ private readonly DotNetObjectReference> dotnetObjectReference;
+ private ElementReference elementReference;
+ private ElementReference filterInputReference;
+ private bool isShown;
+ private string filterText = string.Empty;
+ private bool selectAllChecked;
+ private bool disposed;
+
+ public HxMultiSelectInternal()
+ {
+ dotnetObjectReference = DotNetObjectReference.Create(this);
+ }
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await EnsureJsModuleAsync();
+ if (disposed)
+ {
+ return;
+ }
+
+ await jsModule.InvokeVoidAsync("initialize", elementReference, dotnetObjectReference);
+ }
+ }
+
+ private async Task EnsureJsModuleAsync()
+ {
+ jsModule ??= await JSRuntime.ImportHavitBlazorBootstrapModuleAsync(nameof(HxMultiSelect));
+ }
private async Task HandleItemSelectionChangedAsync(bool newChecked, TItem item)
{
- await ItemSelectionChanged.InvokeAsync(new SelectionChangedArgs
+ var args = new SelectionChangedArgs();
+
+ if (newChecked)
+ {
+ args.ItemsSelected.Add(item);
+ }
+ else
+ {
+ args.ItemsDeselected.Add(item);
+ }
+
+ // When a single item is clicked we always want to uncheck select all
+ selectAllChecked = false;
+
+ await OnItemsSelectionChanged.InvokeAsync(args);
+ }
+
+ private async Task HandleSelectAllClickedAsync()
+ {
+ var args = new SelectionChangedArgs();
+ var filteredItems = GetFilteredItems();
+
+ // If all items are already selected then they should be deselected, otherwise only records that aren't selected should be
+ if (selectAllChecked)
+ {
+ foreach (var item in filteredItems)
+ {
+ args.ItemsDeselected.Add(item);
+ }
+ }
+ else
+ {
+ foreach (var item in filteredItems)
+ {
+ var value = SelectorHelpers.GetValue(ValueSelector, item);
+ var itemSelected = DoSelectedValuesContainValue(value);
+
+ // If the item is already selected we don't need to reselect it
+ if (!itemSelected)
+ {
+ args.ItemsSelected.Add(item);
+ }
+ }
+ }
+
+ selectAllChecked = !selectAllChecked;
+
+ await OnItemsSelectionChanged.InvokeAsync(args);
+ }
+
+ private void HandleClearIconClick()
+ {
+ filterText = string.Empty;
+ }
+
+ private bool DoSelectedValuesContainValue(TValue value)
+ {
+ return SelectedValues?.Contains(value) ?? false;
+ }
+
+ private void HandleFilterInputChanged(ChangeEventArgs e)
+ {
+ filterText = e.Value?.ToString() ?? string.Empty;
+ selectAllChecked = false;
+ }
+
+ private List GetFilteredItems()
+ {
+ if (!AllowFiltering || string.IsNullOrEmpty(filterText))
+ {
+ return ItemsToRender;
+ }
+
+ var filterPredicate = FilterPredicate ?? DefaultFilterPredicate;
+ return ItemsToRender.Where(x => filterPredicate(x, filterText)).ToList();
+
+ bool DefaultFilterPredicate(TItem item, string filter)
+ {
+ return string.IsNullOrEmpty(filter) || TextSelector(item).Contains(filter, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ private string GetSelectAllText()
+ {
+ if (SelectAllText is not null)
+ {
+ return SelectAllText;
+ }
+
+ return StringLocalizer["SelectAllDefaultText"];
+ }
+
+ private string GetFilterEmptyResultText()
+ {
+ if (FilterEmptyResultText is not null)
{
- Checked = newChecked,
- Item = item
- });
+ return FilterEmptyResultText;
+ }
+
+ return StringLocalizer["FilterEmptyResultDefaultText"];
}
public async ValueTask FocusAsync()
{
- if (EqualityComparer.Default.Equals(inputElement, default))
+ if (EqualityComparer.Default.Equals(elementReference, default))
{
throw new InvalidOperationException($"Cannot focus {this.GetType()}. The method must be called after first render.");
}
- await inputElement.FocusAsync();
+ await elementReference.FocusAsync();
+ }
+
+ ///
+ /// Receives notification from JavaScript when item is hidden.
+ ///
+ [JSInvokable("HxMultiSelect_HandleJsHidden")]
+ public Task HandleJsHidden()
+ {
+ isShown = false;
+
+ if (ClearFilterOnHide && filterText != string.Empty)
+ {
+ filterText = string.Empty;
+ StateHasChanged();
+ }
+
+ return Task.CompletedTask;
}
- public class SelectionChangedArgs
+ [JSInvokable("HxMultiSelect_HandleJsShown")]
+ public async Task HandleJsShown()
+ {
+ isShown = true;
+ await filterInputReference.FocusAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeAsyncCore();
+ }
+
+ protected virtual async ValueTask DisposeAsyncCore()
+ {
+ disposed = true;
+
+ if (jsModule != null)
+ {
+ try
+ {
+ await jsModule.InvokeVoidAsync("dispose", elementReference);
+ await jsModule.DisposeAsync();
+ }
+ catch (JSDisconnectedException)
+ {
+ // NOOP
+ }
+ }
+
+ dotnetObjectReference?.Dispose();
+ }
+
+
+ public sealed class SelectionChangedArgs
{
- public bool Checked { get; set; }
- public TItem Item { get; set; }
+ public List ItemsDeselected { get; set; } = new();
+ public List ItemsSelected { get; set; } = new();
}
-}
+}
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.css b/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.css
index c36c5d58a..6e8b8776a 100644
--- a/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.css
+++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/Internal/HxMultiSelectInternal.razor.css
@@ -22,9 +22,36 @@ button.dropdown-item,
}
.hx-multi-select-input:hover {
- cursor: default;
+ cursor: default;
}
.form-check-label {
color: inherit;
+}
+
+.hx-multi-select-filter-input-icon {
+ display: flex;
+ align-items: center;
+ margin-right: 1rem;
+ position: absolute;
+ top: 50%;
+ right: 0;
+ transform: translateY(-50%);
+ z-index: 5;
+}
+
+.hx-multi-select-filter-input-wrapper {
+ position: relative;
+ flex: 1 1 auto;
+ width: 100%;
+ min-width: 0;
+ padding: 0.25em;
+}
+
+.form-control-sm ~ .hx-multi-select-filter-input-icon {
+ font-size: .75rem;
+}
+
+.hx-multi-select-filter-input-icon div[role="button"]:not(:hover) ::deep .hx-icon {
+ opacity: var(--hx-multi-select-filter-input-icon-opacity);
}
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/MultiSelectSettings.cs b/Havit.Blazor.Components.Web.Bootstrap/Forms/MultiSelectSettings.cs
index c9189efdb..d827ebbac 100644
--- a/Havit.Blazor.Components.Web.Bootstrap/Forms/MultiSelectSettings.cs
+++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/MultiSelectSettings.cs
@@ -11,4 +11,29 @@ public record MultiSelectSettings : InputSettings, IInputSettingsWithSize
/// Input size.
///
public InputSize? InputSize { get; set; }
+
+ ///
+ /// Enables filtering capabilities.
+ ///
+ public bool AllowFiltering { get; set; }
+
+ ///
+ /// Enables select all capabilities.
+ ///
+ public bool AllowSelectAll { get; set; }
+
+ ///
+ /// When enabled the filter will be cleared when the dropdown is closed.
+ ///
+ public bool ClearFilterOnHide { get; set; }
+
+ ///
+ /// Icon displayed in filter input for searching the filter.
+ ///
+ public IconBase FilterSearchIcon { get; set; } = BootstrapIcon.Search;
+
+ ///
+ /// Icon displayed in filter input for clearing the filter.
+ ///
+ public IconBase FilterClearIcon { get; set; } = BootstrapIcon.XLg;
}
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect-it-IT.resx b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect-it-IT.resx
new file mode 100644
index 000000000..20827f619
--- /dev/null
+++ b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect-it-IT.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Nessun risultato
+
+
+ -seleziona tutto-
+
+
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.cs.resx b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.cs.resx
new file mode 100644
index 000000000..f1c475a33
--- /dev/null
+++ b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.cs.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Nic nenalezeno
+
+
+ -vybrat vše-
+
+
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.resx b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.resx
new file mode 100644
index 000000000..c3269f2a8
--- /dev/null
+++ b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ No results
+
+
+ -select all-
+
+
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.zh-CN.resx b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.zh-CN.resx
new file mode 100644
index 000000000..1172071ee
--- /dev/null
+++ b/Havit.Blazor.Components.Web.Bootstrap/Resources/HxMultiSelect.zh-CN.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 没有结果
+
+
+ -全选-
+
+
\ No newline at end of file
diff --git a/Havit.Blazor.Components.Web.Bootstrap/wwwroot/HxMultiSelect.js b/Havit.Blazor.Components.Web.Bootstrap/wwwroot/HxMultiSelect.js
new file mode 100644
index 000000000..6d8346ed9
--- /dev/null
+++ b/Havit.Blazor.Components.Web.Bootstrap/wwwroot/HxMultiSelect.js
@@ -0,0 +1,48 @@
+export function initialize(element, hxMultiSelectDotnetObjectReference) {
+ if (!element) {
+ return;
+ }
+
+ element.hxMultiSelectDotnetObjectReference = hxMultiSelectDotnetObjectReference;
+ element.addEventListener('shown.bs.dropdown', handleDropdownShown);
+ element.addEventListener('hidden.bs.dropdown', handleDropdownHidden);
+
+ var d = new bootstrap.Dropdown(element);
+}
+
+export function show(element) {
+ var d = bootstrap.Dropdown.getInstance(element);
+ if (d) {
+ d.show();
+ }
+};
+
+export function hide(element) {
+ var d = bootstrap.Dropdown.getInstance(element);
+ if (d) {
+ d.hide();
+ }
+};
+
+function handleDropdownShown(event) {
+ event.currentTarget.hxMultiSelectDotnetObjectReference.invokeMethodAsync('HxMultiSelect_HandleJsShown');
+};
+
+function handleDropdownHidden(event) {
+ event.currentTarget.hxMultiSelectDotnetObjectReference.invokeMethodAsync('HxMultiSelect_HandleJsHidden');
+};
+
+export function dispose(element) {
+ if (!element) {
+ return;
+ }
+
+ element.removeEventListener('shown.bs.dropdown', handleDropdownShown);
+ element.removeEventListener('hidden.bs.dropdown', handleDropdownHidden);
+ element.hxMultiSelectDotnetObjectReference = null;
+
+ var d = bootstrap.Dropdown.getInstance(element);
+ if (d) {
+ d.dispose();
+ }
+}
diff --git a/Havit.Blazor.Components.Web.Bootstrap/wwwroot/defaults.css b/Havit.Blazor.Components.Web.Bootstrap/wwwroot/defaults.css
index c45dff434..73f2226dc 100644
--- a/Havit.Blazor.Components.Web.Bootstrap/wwwroot/defaults.css
+++ b/Havit.Blazor.Components.Web.Bootstrap/wwwroot/defaults.css
@@ -133,6 +133,7 @@
/* HxMultiSelect */
--hx-multi-select-background-color: var(--bs-body-bg);
--hx-multi-select-dropdown-menu-height: 300px;
+ --hx-multi-select-filter-input-icon-opacity: .25;
/* TagInput */
--hx-input-tags-tag-margin: 0 .25rem 0 0;