mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-03 21:43:21 +00:00 
			
		
		
		
	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:
		
				
					committed by
					
						
						Tim Abbott
					
				
			
			
				
	
			
			
			
						parent
						
							209a7023b2
						
					
				
				
					commit
					b79547d9e2
				
			@@ -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)) {
 | 
			
		||||
                                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,11 +530,48 @@ 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":
 | 
			
		||||
                            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;
 | 
			
		||||
                            }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    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;
 | 
			
		||||
@@ -482,6 +592,7 @@ export class DropdownWidget {
 | 
			
		||||
                                }
 | 
			
		||||
                                break;
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                // We want to prevent focus from moving to the list item
 | 
			
		||||
@@ -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();
 | 
			
		||||
            },
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user