zulip/web/src/dropdown_widget.js
Aman Agrawal 7997af675b recent_view: Fix filter dropdown enabled after search for spectators.
This is because we render the filters again after search and
hence any events or classes that were attached to widget were reset.
2023-11-29 21:47:36 -08:00

349 lines
15 KiB
JavaScript

import $ from "jquery";
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 {page_params} from "./page_params";
import {default_popover_props} from "./popover_menus";
import {parse_html} from "./ui_util";
/* Sync with max-height set in zulip.css */
export const DEFAULT_DROPDOWN_HEIGHT = 210;
const noop = () => {};
export const DATA_TYPES = {
NUMBER: "number",
STRING: "string",
};
export class DropdownWidget {
constructor({
widget_name,
// You can bold the selected `option` by setting `option.bold_current_selection` to `true`.
// Currently, not implemented for stream names.
get_options,
item_click_callback,
// 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,
on_show_callback = noop,
on_mount_callback = noop,
on_hidden_callback = noop,
on_exit_with_escape_callback = noop,
render_selected_option = noop,
// 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 = true,
tippy_props = {},
// NOTE: Any value other than `null` will be rendered when class is initialized.
default_id = null,
unique_id_type = null,
// Text to show if the current value is not in `get_options()`.
text_if_current_value_not_in_options = null,
hide_search_box = false,
// Disable the widget for spectators.
disable_for_spectators = false,
}) {
this.widget_name = widget_name;
this.widget_id = `#${CSS.escape(widget_name)}_widget`;
// A widget wrapper may not exist based on the UI requirement.
this.widget_wrapper_id = `${this.widget_id}_wrapper`;
this.widget_value_selector = `${this.widget_id} .dropdown_widget_value`;
this.get_options = get_options;
this.item_click_callback = item_click_callback;
this.focus_target_on_hidden = focus_target_on_hidden;
this.on_show_callback = on_show_callback;
this.on_mount_callback = on_mount_callback;
this.on_hidden_callback = on_hidden_callback;
this.on_exit_with_escape_callback = on_exit_with_escape_callback;
this.render_selected_option = render_selected_option;
this.tippy_props = tippy_props;
this.list_widget = null;
this.instance = null;
this.default_id = default_id;
this.current_value = default_id;
this.unique_id_type = unique_id_type;
this.$events_container = $events_container;
this.text_if_current_value_not_in_options = text_if_current_value_not_in_options;
this.hide_search_box = hide_search_box;
this.disable_for_spectators = disable_for_spectators;
}
init() {
// 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 !== null) {
this.render();
}
this.$events_container.on(
"keydown",
`${this.widget_id}, ${this.widget_wrapper_id}`,
(e) => {
if (e.key === "Enter") {
$(`${this.widget_id}`).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_id}, ${this.widget_wrapper_id}`,
(e) => {
e.stopPropagation();
e.preventDefault();
},
);
}
}
show_empty_if_no_items($popper) {
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() {
this.init();
const delegate_container = this.$events_container.get(0);
if (!delegate_container) {
blueslip.error(
"Cannot initialize dropdown. `$events_container` empty.",
this.$events_container,
);
}
this.instance = tippy.delegate(delegate_container, {
...default_popover_props,
target: this.widget_id,
// Custom theme defined in popovers.css
theme: "dropdown-widget",
arrow: false,
onShow: function (instance) {
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(".dropdown-list-search-input");
this.list_widget = ListWidget.create($dropdown_list_body, this.get_options(), {
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) {
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");
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() {
const first_item = list_items[0];
return $popper.find(`.list-item[data-unique-id="${first_item.unique_id}"]`);
}
function last_item() {
const last_item = list_items.at(-1);
return $popper.find(`.list-item[data-unique-id="${last_item.unique_id}"]`);
}
const render_all_items_and_focus_last_item = function () {
// 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());
}.bind(this);
const handle_arrow_down_on_last_item = () => {
if (this.hide_search_box) {
trigger_element_focus(first_item());
} else {
trigger_element_focus($search_input);
}
};
const handle_arrow_up_on_first_item = () => {
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":
instance.hide();
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.one("click", ".list-item", (event) => {
this.current_value = $(event.currentTarget).attr("data-unique-id");
if (this.unique_id_type === DATA_TYPES.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);
}.bind(this),
onMount: function (instance) {
this.show_empty_if_no_items($(instance.popper));
this.on_mount_callback(instance);
}.bind(this),
onHidden: function (instance) {
if (this.focus_target_on_hidden) {
$(this.widget_id).trigger("focus");
}
this.on_hidden_callback(instance);
this.instance = null;
}.bind(this),
...this.tippy_props,
});
}
value() {
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) {
// Check if the value is valid otherwise just render previous value.
if (typeof value === typeof this.current_value) {
this.current_value = value;
}
const all_options = this.get_options();
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);
}
}
}