dropdown_widget: Add focus management options.

This commit add configuration options to improve dropdown behavior:
- `keep_focus_to_search`: Keeps focus in the search input during
navigation.
- `tab_moves_focus_to_target`: Moves focus to the target item on
Tab key press.
This commit is contained in:
Aditya Kumar Kasaudhan
2025-03-08 01:58:42 +05:30
committed by Tim Abbott
parent 209a7023b2
commit b79547d9e2
3 changed files with 142 additions and 22 deletions

View File

@@ -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();
},

View File

@@ -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;
}

View File

@@ -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;
}