From c20602596d16e9c9e177b58d11dc8b0a5ce97770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Hobl=C3=ADk?= Date: Fri, 2 Sep 2022 18:36:44 +0200 Subject: [PATCH 01/16] [HxSearchBox] Keyboard navigation #348 --- .../HxSearchBoxDoc/HxSearchBox_Demo.razor | 17 ++-- .../Forms/SearchBox/HxSearchBox.razor | 26 +++++- .../Forms/SearchBox/HxSearchBox.razor.cs | 84 +++++++++++++++++++ .../Forms/SearchBox/HxSearchBox.razor.css | 4 + 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/Havit.Blazor.Components.Web.Bootstrap.Documentation/Pages/Components/HxSearchBoxDoc/HxSearchBox_Demo.razor b/Havit.Blazor.Components.Web.Bootstrap.Documentation/Pages/Components/HxSearchBoxDoc/HxSearchBox_Demo.razor index c8f9433f9..72958f395 100644 --- a/Havit.Blazor.Components.Web.Bootstrap.Documentation/Pages/Components/HxSearchBoxDoc/HxSearchBox_Demo.razor +++ b/Havit.Blazor.Components.Web.Bootstrap.Documentation/Pages/Components/HxSearchBoxDoc/HxSearchBox_Demo.razor @@ -28,11 +28,12 @@ private string textQuery; List Data { get; set; } = new() - { - new() { Title = "Table", Subtitle = "50 000", Icon = BootstrapIcon.Table }, - new() { Title = "Mouse", Subtitle = "400", Icon = BootstrapIcon.Mouse }, - new() { Title = "Door", Subtitle = "1000", Icon = BootstrapIcon.DoorClosed } - }; + { + new() { Title = "Table", Subtitle = "50 000", Icon = BootstrapIcon.Table }, + new() { Title = "Mouse", Subtitle = "400", Icon = BootstrapIcon.Mouse }, + new() { Title = "Door", Subtitle = "1000", Icon = BootstrapIcon.DoorClosed }, + new() { Title = "Tasks", Subtitle = "720", Icon = BootstrapIcon.ListTask } + }; private void OnItemSelected(SearchBoxItem item) { @@ -49,9 +50,9 @@ await Task.Delay(400); // imitate slower server API return new() - { - Data = Data.Where(i => i.Title.Contains(request.UserInput, StringComparison.OrdinalIgnoreCase)) - }; + { + Data = Data.Where(i => i.Title.Contains(request.UserInput, StringComparison.OrdinalIgnoreCase)) + }; } class SearchBoxItem diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/SearchBox/HxSearchBox.razor b/Havit.Blazor.Components.Web.Bootstrap/Forms/SearchBox/HxSearchBox.razor index fb1060c84..c54f13620 100644 --- a/Havit.Blazor.Components.Web.Bootstrap/Forms/SearchBox/HxSearchBox.razor +++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/SearchBox/HxSearchBox.razor @@ -15,7 +15,7 @@
- + InputGroupEndTemplate="@InputGroupEndTemplate" />*@ + @if (InputGroupEndText is null && InputGroupEndTemplate is null) { @@ -66,7 +74,12 @@ IconBase icon = ItemIconSelector?.Invoke(item) ?? null;
  • -
  • - + + @if (ItemTemplate != null) { @@ -71,6 +77,7 @@ @EmptyTemplate + } diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.cs b/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.cs index 2fd931e38..cae6de3a6 100644 --- a/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.cs +++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.cs @@ -223,19 +223,6 @@ private async Task HandleInputInput(string newUserInput) } } - /// - /// Select the first suggested item when an enter key is pressed. - /// - /// - private async Task HandleInputEnterKeyDown() - { - if (HighlightFirstSuggestionEffective) - { - await DestroyDropdownAsync(); - await HandleItemClick(suggestions.FirstOrDefault()); - } - } - private async void HandleTimerElapsed(object sender, System.Timers.ElapsedEventArgs e) { // when a time interval reached, update suggestions @@ -310,6 +297,17 @@ private async Task UpdateSuggestionsAsync() } dataProviderInProgress = false; + + // KeyboardNavigation + if (HighlightFirstSuggestionEffective) + { + focusedItemIndex = 0; // First item in the searchResults collection. + } + else + { + focusedItemIndex = InputKeyboardNavigationIndex; + } + suggestions = result.Data?.ToList(); if ((suggestions?.Any() ?? false) || EmptyTemplate != null) @@ -324,9 +322,82 @@ private async Task UpdateSuggestionsAsync() StateHasChanged(); } - private async Task HandleItemClick(TItem item) + #region KeyboardNavigation + private int focusedItemIndex = -1; + + private const string ArrowUpKeyCode = "ArrowUp"; + private const string ArrowDownKeyCode = "ArrowDown"; + + private const string EnterKeyCode = "Enter"; + private const string NumpadEnterKeyCode = "NumpadEnter"; + + /// + /// Input's index for the keyboard navigation. If this is the current index, then no item is selected. + /// + private const int InputKeyboardNavigationIndex = -1; + + private TItem GetFocusedItem() + { + if (focusedItemIndex > InputKeyboardNavigationIndex) + { + TItem focusedItem = GetItemByIndex(focusedItemIndex); + if ((focusedItem is not null) && (!focusedItem.Equals(default))) + { + return focusedItem; + } + } + + return default; + } + + private async Task UpdateFocusedItem(KeyboardEventArgs keyboardEventArgs) + { + // Confirm selection on the focused item if an item is focused and the enter key is pressed. + TItem focusedItem = GetItemByIndex(focusedItemIndex); + if (keyboardEventArgs.Code == EnterKeyCode || keyboardEventArgs.Code == NumpadEnterKeyCode) + { + if ((focusedItem is not null) && (!focusedItem.Equals(default))) + { + await DestroyDropdownAsync(); + await HandleItemSelected(focusedItem); + } + } + + // Move focus up or down. + if (keyboardEventArgs.Code == ArrowUpKeyCode) + { + int previousItemIndex = focusedItemIndex - 1; + if (previousItemIndex >= InputKeyboardNavigationIndex) // If the index equals InputKeyboardNavigationIndex, no item is focused. + { + focusedItemIndex = previousItemIndex; + } + } + else if (keyboardEventArgs.Code == ArrowDownKeyCode) + { + int nextItemIndex = focusedItemIndex + 1; + if (nextItemIndex < suggestions.Count) + { + focusedItemIndex = nextItemIndex; + } + } + } + + private TItem GetItemByIndex(int index) + { + if (index >= 0 && index < suggestions?.Count) + { + return suggestions[index]; + } + else + { + return default; + } + } + #endregion KeyboardNavigation + + private async Task HandleItemSelected(TItem item) { - // user clicked on an item in the "dropdown". + // user selected an item in the "dropdown". await SetValueItemWithEventCallback(item); userInput = TextSelectorEffective(item); userInputModified = false; diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.css b/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.css index 9c58e1800..bfd36aea4 100644 --- a/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.css +++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestInternal.razor.css @@ -14,4 +14,8 @@ .hx-autosuggest-input-icon div[role="button"]:not(:hover) ::deep .hx-icon { opacity: var(--hx-autosuggest-input-close-icon-opacity); -} \ No newline at end of file +} + +::deep .hx-dropdown-item-focused { + background-color: var(--hx-autosuggest-item-highlighted-background-color); +} diff --git a/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestItems.razor b/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestItems.razor index 3207a1a88..b9048ce9d 100644 --- a/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestItems.razor +++ b/Havit.Blazor.Components.Web.Bootstrap/Forms/Autosuggests/Internal/HxAutosuggestItems.razor @@ -9,9 +9,9 @@ TItem currentItem = item;