Files
zulip/web/src/dropdown_widget.ts
Vector73 b2a07971ec dropdown_widget: Allow more than one click on dropdown.
The new saved replies feature requires removing saved replies by clicking on
trash icon on dropdown item. So dropdown widget should allow more than
one click without closing it to delete multiple saved replies.
2024-09-03 09:20:27 -07:00

483 lines
21 KiB
TypeScript

import $ from "jquery";
import assert from "minimalistic-assert";
import * as tippy from "tippy.js";
import render_dropdown_current_value_not_in_options from "../templates/dropdown_current_value_not_in_options.hbs";
import render_dropdown_disabled_state from "../templates/dropdown_disabled_state.hbs";
import render_dropdown_list from "../templates/dropdown_list.hbs";
import render_dropdown_list_container from "../templates/dropdown_list_container.hbs";
import render_inline_decorated_stream_name from "../templates/inline_decorated_stream_name.hbs";
import * as blueslip from "./blueslip";
import * as ListWidget from "./list_widget";
import type {ListWidget as ListWidgetType} from "./list_widget";
import {page_params} from "./page_params";
import * as popover_menus from "./popover_menus";
import type {StreamSubscription} from "./sub_store";
import {parse_html} from "./ui_util";
import * as util from "./util";
/* Sync with max-height set in zulip.css */
export const DEFAULT_DROPDOWN_HEIGHT = 210;
const noop = (): void => {
// Empty function for default values.
};
export enum DataTypes {
NUMBER = "number",
STRING = "string",
}
export type Option = {
unique_id: number | string;
name: string;
is_setting_disabled?: boolean;
stream?: StreamSubscription;
};
export type DropdownWidgetOptions = {
widget_name: string;
// You can bold the selected `option` by setting `option.bold_current_selection` to `true`.
// Currently, not implemented for stream names.
get_options: (current_value: string | number | undefined) => Option[];
item_click_callback: (
event: JQuery.ClickEvent,
instance: tippy.Instance,
widget: DropdownWidget,
) => 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()`
// multiple times, so that we don't have duplicate event handlers.
$events_container: JQuery;
on_show_callback?: (instance: tippy.Instance) => void;
on_mount_callback?: (instance: tippy.Instance) => void;
on_hidden_callback?: (instance: tippy.Instance) => void;
on_exit_with_escape_callback?: () => void;
render_selected_option?: () => void;
// 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`.
focus_target_on_hidden?: boolean;
tippy_props?: Partial<tippy.Props>;
// NOTE: Any value other than `undefined` will be rendered when class is initialized.
default_id?: string | number | undefined;
unique_id_type?: DataTypes;
// Text to show if the current value is not in `get_options()`.
text_if_current_value_not_in_options?: string;
hide_search_box?: boolean;
// Disable the widget for spectators.
disable_for_spectators?: boolean;
dropdown_input_visible_selector?: string;
prefer_top_start_placement?: boolean;
};
export class DropdownWidget {
widget_name: string;
widget_selector: string;
widget_wrapper_id: string;
widget_value_selector: string;
get_options: (current_value: string | number | undefined) => Option[];
item_click_callback: (
event: JQuery.ClickEvent,
instance: tippy.Instance,
widget: DropdownWidget,
) => void;
focus_target_on_hidden: boolean;
on_show_callback: (instance: tippy.Instance) => void;
on_mount_callback: (instance: tippy.Instance) => void;
on_hidden_callback: (instance: tippy.Instance) => void;
on_exit_with_escape_callback: () => void;
render_selected_option: () => void;
tippy_props: Partial<tippy.Props>;
list_widget: ListWidgetType<Option, Option> | undefined;
instance: tippy.Instance | undefined;
default_id: string | number | undefined;
current_value: string | number | undefined;
unique_id_type: DataTypes | undefined;
$events_container: JQuery;
text_if_current_value_not_in_options: string;
hide_search_box: boolean;
disable_for_spectators: boolean;
dropdown_input_visible_selector: string;
prefer_top_start_placement: boolean;
constructor(options: DropdownWidgetOptions) {
this.widget_name = options.widget_name;
this.widget_selector = `#${CSS.escape(this.widget_name)}_widget`;
// A widget wrapper may not exist based on the UI requirement.
this.widget_wrapper_id = `${this.widget_selector}_wrapper`;
this.widget_value_selector = `${this.widget_selector} .dropdown_widget_value`;
this.get_options = options.get_options;
this.item_click_callback = options.item_click_callback;
this.focus_target_on_hidden = options.focus_target_on_hidden ?? true;
this.on_show_callback = options.on_show_callback ?? noop;
this.on_mount_callback = options.on_mount_callback ?? noop;
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;
// These properties can override any tippy props.
this.tippy_props = options.tippy_props ?? {};
this.list_widget = undefined;
this.default_id = options.default_id;
this.current_value = this.default_id;
this.unique_id_type = options.unique_id_type;
this.$events_container = options.$events_container;
this.text_if_current_value_not_in_options =
options.text_if_current_value_not_in_options ?? "";
this.hide_search_box = options.hide_search_box ?? false;
this.disable_for_spectators = options.disable_for_spectators ?? false;
this.dropdown_input_visible_selector =
options.dropdown_input_visible_selector ?? this.widget_selector;
this.prefer_top_start_placement = options.prefer_top_start_placement ?? false;
}
init(): void {
// NOTE: Widget should only be initialized again if the events_container was rendered again to
// avoid duplicate events to be attached to events_container.
// Don't attach any events or classes to any element other than `events_container` here, otherwise
// the attached events / classes will be lost when the widget is rendered again without initialing the widget again.
if (this.current_value !== undefined) {
this.render();
}
this.$events_container.on(
"keydown",
`${this.widget_selector}, ${this.widget_wrapper_id}`,
(e) => {
if (e.key === "Enter") {
$(this.widget_selector).trigger("click");
e.stopPropagation();
e.preventDefault();
}
},
);
if (this.disable_for_spectators && page_params.is_spectator) {
this.$events_container.addClass("dropdown-widget-disabled-for-spectators");
this.$events_container.on(
"click",
`${this.widget_selector}, ${this.widget_wrapper_id}`,
(e) => {
e.stopPropagation();
e.preventDefault();
},
);
}
}
adjust_dropdown_position_post_list_render(tippy_instance: tippy.Instance): void {
let top_offset = 0;
let left_offset = 0;
// Use offset if provided by the widget callers.
if (typeof this.tippy_props?.offset === "object" && this.tippy_props?.offset.length === 2) {
left_offset = this.tippy_props.offset[0];
top_offset = this.tippy_props.offset[1];
}
const window_height = window.innerHeight;
let dropdown_search_box_and_padding_height = 50;
if (this.hide_search_box) {
dropdown_search_box_and_padding_height = 0;
}
const dropdown_input_props = $(this.dropdown_input_visible_selector).get_offset_to_window();
const dropdown_input_top = dropdown_input_props.top;
// Pixels above the dropdown input.
const top_space = dropdown_input_top - top_offset - dropdown_search_box_and_padding_height;
// Pixels below the top of dropdown input.
const bottom_space =
window_height - dropdown_input_top - dropdown_search_box_and_padding_height;
// Show dropdown at bottom by default if we `DEFAULT_DROPDOWN_HEIGHT`
// space available unless the dropdown caller prefers to show at top.
// If we don't have `DEFAULT_DROPDOWN_HEIGHT` space available above
// or below the dropdown input, show the dropdown at maximum space.
let placement: tippy.Placement = "top-start";
let height: number = Math.min(DEFAULT_DROPDOWN_HEIGHT, Math.max(top_space, bottom_space));
if (this.prefer_top_start_placement && top_space > DEFAULT_DROPDOWN_HEIGHT) {
height = DEFAULT_DROPDOWN_HEIGHT;
} else if (!this.prefer_top_start_placement && bottom_space > DEFAULT_DROPDOWN_HEIGHT) {
placement = "bottom-start";
height = DEFAULT_DROPDOWN_HEIGHT;
// Use the provided offset if we have enough space. Otherwise,
// we overlap the top of dropdown with top of dropdown input.
if (this.tippy_props?.offset === undefined) {
top_offset = -1 * dropdown_input_props.height;
}
} else if (bottom_space > top_space) {
placement = "bottom-start";
top_offset = -1 * dropdown_input_props.height;
}
const offset: [number, number] = [left_offset, top_offset];
tippy_instance.setProps({placement, offset});
const $popper = $(tippy_instance.popper);
$popper.find(".dropdown-list-wrapper").css("max-height", height + "px");
}
show_empty_if_no_items($popper: JQuery): void {
assert(this.list_widget !== undefined);
const list_items = this.list_widget.get_current_list();
const $no_search_results = $popper.find(".no-dropdown-items");
if (list_items.length === 0) {
$no_search_results.show();
} else {
$no_search_results.hide();
}
}
setup(): void {
this.init();
const delegate_container = this.$events_container.get(0);
if (delegate_container === undefined) {
blueslip.error(
"Cannot initialize dropdown. `$events_container` empty.",
this.$events_container,
);
return;
}
if (this.disable_for_spectators && page_params.is_spectator) {
return;
}
tippy.delegate(delegate_container, {
...popover_menus.default_popover_props,
target: this.widget_selector,
// Custom theme defined in popovers.css
theme: "dropdown-widget",
arrow: false,
onShow: (instance: tippy.Instance) => {
if (util.is_mobile()) {
// The dropdown trigger button can be hidden by the
// keyboard on mobile or if it is scrolled out of
// view to keyboard being displayed.
// So, we show the dropdown even if reference is hidden on
// mobile.
$(instance.popper).find(".tippy-box").addClass("show-when-reference-hidden");
}
instance.setContent(
parse_html(
render_dropdown_list_container({
widget_name: this.widget_name,
hide_search_box: this.hide_search_box,
}),
),
);
const $popper = $(instance.popper);
const $dropdown_list_body = $popper.find(".dropdown-list");
const $search_input = $popper.find<HTMLInputElement>(
"input.dropdown-list-search-input",
);
this.list_widget = ListWidget.create(
$dropdown_list_body,
this.get_options(this.current_value),
{
name: `${CSS.escape(this.widget_name)}-list-widget`,
get_item: ListWidget.default_get_item,
modifier_html(item) {
return render_dropdown_list({item});
},
filter: {
$element: $search_input,
predicate(item, value) {
return item.name.toLowerCase().includes(value);
},
},
$simplebar_container: $popper.find(".dropdown-list-wrapper"),
},
);
$search_input.on("input.list_widget_filter", () => {
this.show_empty_if_no_items($popper);
});
// Keyboard handler
$popper.on("keydown", (e) => {
function trigger_element_focus($element: JQuery): void {
e.preventDefault();
e.stopPropagation();
// When bringing a non-visible element into view, scroll as minimum as possible.
$element[0]?.scrollIntoView({block: "nearest"});
$element.trigger("focus");
}
const $search_input = $popper.find(".dropdown-list-search-input");
assert(this.list_widget !== undefined);
const list_items = this.list_widget.get_current_list();
if (list_items.length === 0 && !(e.key === "Escape")) {
// Let the browser handle it.
return;
}
function first_item(): JQuery {
const first_item = list_items[0];
assert(first_item !== undefined);
return $popper.find(`.list-item[data-unique-id="${first_item.unique_id}"]`);
}
function last_item(): JQuery {
const last_item = list_items.at(-1);
assert(last_item !== undefined);
return $popper.find(`.list-item[data-unique-id="${last_item.unique_id}"]`);
}
const render_all_items_and_focus_last_item = (): void => {
assert(this.list_widget !== undefined);
// List widget doesn't render all items by default, so we need to render all
// the items and focus on the last element.
const list_items = this.list_widget.get_current_list();
this.list_widget.render(list_items.length);
trigger_element_focus(last_item());
};
const handle_arrow_down_on_last_item = (): void => {
if (this.hide_search_box) {
trigger_element_focus(first_item());
} else {
trigger_element_focus($search_input);
}
};
const handle_arrow_up_on_first_item = (): void => {
if (this.hide_search_box) {
render_all_items_and_focus_last_item();
} else {
trigger_element_focus($search_input);
}
};
switch (e.key) {
case "Enter":
if (e.target === $search_input.get(0)) {
// Select first item if in search input.
first_item().trigger("click");
} else if (list_items.length !== 0) {
$(e.target).trigger("click");
}
e.stopPropagation();
e.preventDefault();
break;
case "Escape":
popover_menus.hide_current_popover_if_visible(instance);
this.on_exit_with_escape_callback();
e.stopPropagation();
e.preventDefault();
break;
case "Tab":
case "ArrowDown":
switch (e.target) {
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());
}
break;
case "ArrowUp":
switch (e.target) {
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());
}
break;
}
});
// Click on item.
$popper.on("click", ".list-item", (event) => {
const selected_unique_id = $(event.currentTarget).attr("data-unique-id");
assert(selected_unique_id !== undefined);
this.current_value = selected_unique_id;
if (this.unique_id_type === DataTypes.NUMBER) {
this.current_value = Number.parseInt(this.current_value, 10);
}
this.item_click_callback(event, instance, this);
});
// Set focus on first element when dropdown opens.
setTimeout(() => {
if (this.hide_search_box) {
$dropdown_list_body.find(".list-item:first-child").trigger("focus");
} else {
$search_input.trigger("focus");
}
}, 0);
this.on_show_callback(instance);
this.adjust_dropdown_position_post_list_render(instance);
},
onMount: (instance: tippy.Instance) => {
this.show_empty_if_no_items($(instance.popper));
this.on_mount_callback(instance);
},
onHidden: (instance: tippy.Instance) => {
if (this.focus_target_on_hidden) {
$(this.widget_selector).trigger("focus");
}
this.on_hidden_callback(instance);
instance.destroy();
},
...this.tippy_props,
});
}
value(): number | string | undefined {
return this.current_value;
}
// NOTE: This function needs to be explicitly called when you want to update the
// current value of the widget. We don't call this automatically since some of our
// dropdowns don't need it. Maybe we can follow a reverse approach in the future.
render(value?: number | string): void {
// Check if the value is valid otherwise just render previous value.
if (value !== undefined && typeof value === typeof this.current_value) {
this.current_value = value;
}
const all_options = this.get_options(this.current_value);
const option = all_options.find((option) => option.unique_id === this.current_value);
// If provided, show custom text if cannot find current option.
if (!option && this.text_if_current_value_not_in_options) {
$(this.widget_value_selector).html(
render_dropdown_current_value_not_in_options({
name: this.text_if_current_value_not_in_options,
}),
);
return;
}
if (!option) {
blueslip.error(`Cannot find current value: ${this.current_value} in provided options.`);
return;
}
if (option.is_setting_disabled) {
$(this.widget_value_selector).html(render_dropdown_disabled_state({name: option.name}));
} else if (option.stream) {
$(this.widget_value_selector).html(
render_inline_decorated_stream_name({
stream: option.stream,
show_colored_icon: true,
}),
);
} else {
$(this.widget_value_selector).text(option.name);
}
}
}