diff --git a/web/src/dropdown_widget.ts b/web/src/dropdown_widget.ts index ce9b0d578f..e6b6ad98bd 100644 --- a/web/src/dropdown_widget.ts +++ b/web/src/dropdown_widget.ts @@ -78,6 +78,10 @@ export type DropdownWidgetOptions = { // Boolean variable to check whether the dropdown is opened // with a keyboard trigger or not. dropdown_triggered_via_keyboard?: boolean; + // Keep focus on search box while navigation. + keep_focus_on_search?: boolean; + // When this is set, pressing tab will move focus to the target element. + tab_moves_focus_to_target?: string | (() => string); }; export class DropdownWidget { @@ -112,6 +116,9 @@ export class DropdownWidget { dropdown_input_visible_selector: string; prefer_top_start_placement: boolean; dropdown_triggered_via_keyboard: boolean; + keep_focus_on_search: boolean; + tab_moves_focus_to_target: string | (() => string) | undefined; + current_hover_index: number; // TODO: This is only used in one widget, with no implementation // here, so should be generalized or reworked. @@ -147,6 +154,9 @@ export class DropdownWidget { options.dropdown_input_visible_selector ?? this.widget_selector; this.prefer_top_start_placement = options.prefer_top_start_placement ?? false; this.dropdown_triggered_via_keyboard = false; + this.keep_focus_on_search = options.keep_focus_on_search ?? false; + this.tab_moves_focus_to_target = options.tab_moves_focus_to_target; + this.current_hover_index = 0; } init(): void { @@ -245,6 +255,36 @@ export class DropdownWidget { } } + update_hover_state($popper: JQuery): void { + assert(this.list_widget !== undefined); + const list_items = this.list_widget.get_current_list(); + if (list_items.length === 0) { + return; + } + $popper.find(".list-item.current_selection").removeClass("current_selection"); + if (this.sticky_bottom_option) { + $popper + .find(".sticky-bottom-option.current_selection") + .removeClass("current_selection"); + } + if (this.current_hover_index === list_items.length && this.sticky_bottom_option) { + $popper.find(".sticky-bottom-option").addClass("current_selection"); + } else { + const current_hover_item = list_items[this.current_hover_index]; + assert(current_hover_item !== undefined); + const $item = $popper + .find(`.list-item[data-unique-id="${current_hover_item.unique_id}"]`) + .addClass("current_selection"); + if ($item.length === 0) { + this.list_widget.render(this.current_hover_index + 1); + } + const element = $item[0]; + if (element) { + element.scrollIntoView({block: "nearest"}); + } + } + } + setup(): void { this.init(); const delegate_container = util.the(this.$events_container); @@ -321,6 +361,11 @@ export class DropdownWidget { $search_input.on("input.list_widget_filter", () => { this.show_empty_if_no_items($popper); + if (this.keep_focus_on_search) { + $search_input.trigger("focus"); + this.current_hover_index = 0; + this.update_hover_state($popper); + } }); // Keyboard handler @@ -437,6 +482,22 @@ export class DropdownWidget { } }; + const update_highlighted_index = (new_index: number): void => { + let length = list_items.length; + if (this.sticky_bottom_option) { + length += 1; + } + if (new_index >= length) { + this.current_hover_index = 0; + } else if (new_index < 0) { + render_all_items(); + this.current_hover_index = length - 1; + } else { + this.current_hover_index = new_index; + } + this.update_hover_state($popper); + }; + switch (e.key) { case "Enter": if ( @@ -445,8 +506,20 @@ export class DropdownWidget { ) { $sticky_bottom_option.trigger("click"); } else if (e.target === $search_input.get(0)) { - // Select first item if in search input. - first_item().trigger("click"); + if (this.keep_focus_on_search) { + if ( + this.sticky_bottom_option && + list_items.length === this.current_hover_index + ) { + $sticky_bottom_option.trigger("click"); + } else { + const $item = get_item_by_index(this.current_hover_index); + $item.trigger("click"); + } + } else { + // Select first item if in search input. + first_item().trigger("click"); + } } else if (list_items.length > 0) { $(e.target).trigger("click"); } @@ -457,30 +530,68 @@ export class DropdownWidget { case "Escape": popover_menus.hide_current_popover_if_visible(instance); this.on_exit_with_escape_callback(); + this.current_hover_index = 0; e.stopPropagation(); e.preventDefault(); break; case "Tab": - case "ArrowDown": - handle_arrow_down_on_sequential_focus(); - break; - - case "ArrowUp": - switch (e.target) { - case $search_input.get(0): - handle_arrow_up_on_search_input(); - break; - case $sticky_bottom_option.get(0): - handle_arrow_up_on_sticky_bottom_option(); - break; - case first_item().get(0): - handle_arrow_up_on_first_item(); - break; - default: - trigger_element_focus($(e.target).prev()); + if (this.tab_moves_focus_to_target) { + e.preventDefault(); + e.stopPropagation(); + popover_menus.hide_current_popover_if_visible(instance); + this.current_hover_index = 0; + const target = + typeof this.tab_moves_focus_to_target === "function" + ? this.tab_moves_focus_to_target() + : this.tab_moves_focus_to_target; + $(target).trigger("focus"); + } else if (!this.hide_search_box && this.keep_focus_on_search) { + e.preventDefault(); + e.stopPropagation(); + update_highlighted_index(this.current_hover_index + 1); + break; + } else { + handle_arrow_down_on_sequential_focus(); + break; } - break; + } + + if (!this.hide_search_box && this.keep_focus_on_search) { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + e.stopPropagation(); + update_highlighted_index(this.current_hover_index + 1); + break; + case "ArrowUp": + e.preventDefault(); + e.stopPropagation(); + update_highlighted_index(this.current_hover_index - 1); + break; + } + } else { + switch (e.key) { + case "ArrowDown": + handle_arrow_down_on_sequential_focus(); + break; + + case "ArrowUp": + switch (e.target) { + case $search_input.get(0): + handle_arrow_up_on_search_input(); + break; + case $sticky_bottom_option.get(0): + handle_arrow_up_on_sticky_bottom_option(); + break; + case first_item().get(0): + handle_arrow_up_on_first_item(); + break; + default: + trigger_element_focus($(e.target).prev()); + } + break; + } } }); @@ -502,11 +613,13 @@ export class DropdownWidget { this.current_value = Number.parseInt(this.current_value, 10); } this.item_click_callback(event, instance, this, false); + this.current_hover_index = 0; }); // Click on $sticky_bottom_option. $popper.on("click", ".sticky-bottom-option", (event) => { this.item_click_callback(event, instance, this, true); + this.current_hover_index = 0; }); // Adjust focus based on how the dropdown was opened @@ -544,6 +657,10 @@ export class DropdownWidget { } } else { $search_input.trigger("focus"); + if (this.keep_focus_on_search) { + this.current_hover_index = 0; + this.update_hover_state($popper); + } } }, 0); @@ -558,6 +675,7 @@ export class DropdownWidget { if (this.focus_target_on_hidden) { $(this.widget_selector).trigger("focus"); } + this.current_hover_index = 0; this.on_hidden_callback(instance); instance.destroy(); }, diff --git a/web/styles/app_components.css b/web/styles/app_components.css index cb79ba457c..56ce06a99d 100644 --- a/web/styles/app_components.css +++ b/web/styles/app_components.css @@ -1324,7 +1324,8 @@ input.settings_text_input { background-color: var(--background-color-active-dropdown-item); } - &:focus { + &:focus, + &.current_selection { background-color: var(--background-color-active-dropdown-item); outline: none; } diff --git a/web/styles/zulip.css b/web/styles/zulip.css index 1b1306afbd..fd3b9300ef 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -2231,7 +2231,8 @@ body:not(.spectator-view) { .dropdown-list-container .list-item { color: var(--color-dropdown-item); - &:focus { + &:focus, + &.current_selection { background-color: var(--background-color-active-dropdown-item); outline: none; }