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