dropdown_widget: Add sticky_bottom_option element.

Adds `sticky_bottom_option` element to the `dropdown_widget` to
support moving of "Add a new saved snippet" button to the bottom
sticky area of the dropdown for better accessibility and UX.
This commit is contained in:
Vector73
2025-01-29 06:58:53 +00:00
committed by Tim Abbott
parent 794bf5bfe2
commit 51b47f4233
3 changed files with 91 additions and 8 deletions

View File

@@ -45,6 +45,7 @@ export type DropdownWidgetOptions = {
event: JQuery.ClickEvent,
instance: tippy.Instance,
widget: DropdownWidget,
is_sticky_bottom_option_clicked: boolean,
) => void;
// Provide an parent element to widget which will be re-rendered if the widget is setup again.
// It is important to not pass `$("body")` here for widgets that would be `setup()`
@@ -55,6 +56,8 @@ export type DropdownWidgetOptions = {
on_hidden_callback?: (instance: tippy.Instance) => void;
on_exit_with_escape_callback?: () => void;
render_selected_option?: () => void;
// Used to add a sticky button at the bottom of the dropdown.
sticky_bottom_option?: string;
// Used to focus the `target` after dropdown is closed. This is important since the dropdown is
// appended to `body` and hence `body` is focused when the dropdown is closed, which makes
// it hard for the user to get focus back to the `target`.
@@ -82,6 +85,7 @@ export class DropdownWidget {
event: JQuery.ClickEvent,
instance: tippy.Instance,
widget: DropdownWidget,
is_sticky_bottom_option_clicked: boolean,
) => void;
focus_target_on_hidden: boolean;
on_show_callback: (instance: tippy.Instance) => void;
@@ -89,6 +93,7 @@ export class DropdownWidget {
on_hidden_callback: (instance: tippy.Instance) => void;
on_exit_with_escape_callback: () => void;
render_selected_option: () => void;
sticky_bottom_option: string | undefined;
tippy_props: Partial<tippy.Props>;
list_widget: ListWidgetType<Option, Option> | undefined;
instance: tippy.Instance | undefined;
@@ -116,6 +121,7 @@ export class DropdownWidget {
this.on_hidden_callback = options.on_hidden_callback ?? noop;
this.on_exit_with_escape_callback = options.on_exit_with_escape_callback ?? noop;
this.render_selected_option = options.render_selected_option ?? noop;
this.sticky_bottom_option = options.sticky_bottom_option;
// These properties can override any tippy props.
this.tippy_props = options.tippy_props ?? {};
this.list_widget = undefined;
@@ -256,6 +262,7 @@ export class DropdownWidget {
render_dropdown_list_container({
widget_name: this.widget_name,
hide_search_box: this.hide_search_box,
sticky_bottom_option: this.sticky_bottom_option,
}),
),
);
@@ -299,9 +306,14 @@ export class DropdownWidget {
}
const $search_input = $popper.find(".dropdown-list-search-input");
const $sticky_bottom_option = $popper.find(".sticky-bottom-option");
assert(this.list_widget !== undefined);
const list_items = this.list_widget.get_current_list();
if (list_items.length === 0 && !(e.key === "Escape")) {
if (
list_items.length === 0 &&
!(e.key === "Escape") &&
!this.sticky_bottom_option
) {
// Let the browser handle it.
return;
}
@@ -328,6 +340,16 @@ export class DropdownWidget {
};
const handle_arrow_down_on_last_item = (): void => {
if (this.sticky_bottom_option) {
trigger_element_focus($sticky_bottom_option);
} else if (this.hide_search_box) {
trigger_element_focus(first_item());
} else {
trigger_element_focus($search_input);
}
};
const handle_arrow_down_on_sticky_bottom_option = (): void => {
if (this.hide_search_box) {
trigger_element_focus(first_item());
} else {
@@ -335,6 +357,30 @@ export class DropdownWidget {
}
};
const handle_arrow_up_on_sticky_bottom_option = (): void => {
if (list_items.length > 0) {
render_all_items_and_focus_last_item();
} else if (!this.hide_search_box) {
trigger_element_focus($search_input);
}
};
const handle_arrow_down_on_search_input = (): void => {
if (list_items.length > 0) {
trigger_element_focus(first_item());
} else if (this.sticky_bottom_option) {
trigger_element_focus($sticky_bottom_option);
}
};
const handle_arrow_up_on_search_input = (): void => {
if (this.sticky_bottom_option) {
trigger_element_focus($sticky_bottom_option);
} else {
render_all_items_and_focus_last_item();
}
};
const handle_arrow_up_on_first_item = (): void => {
if (this.hide_search_box) {
render_all_items_and_focus_last_item();
@@ -365,12 +411,15 @@ export class DropdownWidget {
case "Tab":
case "ArrowDown":
switch (e.target) {
case $search_input.get(0):
handle_arrow_down_on_search_input();
break;
case $sticky_bottom_option.get(0):
handle_arrow_down_on_sticky_bottom_option();
break;
case last_item().get(0):
handle_arrow_down_on_last_item();
break;
case $search_input.get(0):
trigger_element_focus(first_item());
break;
default:
trigger_element_focus($(e.target).next());
}
@@ -378,12 +427,15 @@ export class DropdownWidget {
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;
case $search_input.get(0):
render_all_items_and_focus_last_item();
break;
default:
trigger_element_focus($(e.target).prev());
}
@@ -399,7 +451,12 @@ export class DropdownWidget {
if (this.unique_id_type === DataTypes.NUMBER) {
this.current_value = Number.parseInt(this.current_value, 10);
}
this.item_click_callback(event, instance, this);
this.item_click_callback(event, instance, this, false);
});
// Click on $sticky_bottom_option.
$popper.on("click", ".sticky-bottom-option", (event) => {
this.item_click_callback(event, instance, this, true);
});
// Set focus on first element when dropdown opens.