overlays: Extract modal methods in separate file.

This commit is contained in:
Aman Agrawal 2023-10-10 12:25:18 +00:00 committed by Tim Abbott
parent 6713ad9d4d
commit 577a384845
14 changed files with 276 additions and 246 deletions

View File

@ -144,6 +144,7 @@ EXEMPT_FILES = make_set(
"web/src/message_view_header.js",
"web/src/message_viewport.js",
"web/src/messages_overlay_ui.ts",
"web/src/modals.ts",
"web/src/muted_users_ui.js",
"web/src/narrow.js",
"web/src/narrow_history.js",

View File

@ -6,7 +6,7 @@ import render_dialog_widget from "../templates/dialog_widget.hbs";
import type {AjaxRequestHandler} from "./channel";
import {$t_html} from "./i18n";
import * as loading from "./loading";
import * as overlays from "./overlays";
import * as modals from "./modals";
import * as ui_report from "./ui_report";
/*
@ -22,7 +22,7 @@ import * as ui_report from "./ui_report";
* to avoid interference from other elements.
*
* 3) For settings, we have a click handler in settings.js
* that will close the dialog via overlays.close_active_modal.
* that will close the dialog via modals.close_active_modal.
*
* 4) We assume that since this is a modal, you will
* only ever have one confirm dialog active at any
@ -99,7 +99,7 @@ export function show_dialog_spinner(): void {
// Supports a callback to be called once the modal finishes closing.
export function close_modal(on_hidden_callback?: () => void): void {
overlays.close_modal("dialog_widget_modal", {on_hidden: on_hidden_callback});
modals.close_modal("dialog_widget_modal", {on_hidden: on_hidden_callback});
}
export function launch(conf: DialogWidgetConfig): void {
@ -210,7 +210,7 @@ export function launch(conf: DialogWidgetConfig): void {
conf.on_click(e);
});
overlays.open_modal("dialog_widget_modal", {
modals.open_modal("dialog_widget_modal", {
autoremove: true,
on_show() {
if (conf.focus_submit_on_open) {

View File

@ -31,6 +31,7 @@ import * as message_edit from "./message_edit";
import * as message_edit_history from "./message_edit_history";
import * as message_lists from "./message_lists";
import * as message_scroll_state from "./message_scroll_state";
import * as modals from "./modals";
import * as narrow from "./narrow";
import * as narrow_state from "./narrow_state";
import * as navigate from "./navigate";
@ -279,8 +280,8 @@ export function process_escape_key(e) {
return true;
}
if (overlays.is_modal_open()) {
overlays.close_active_modal();
if (modals.is_modal_open()) {
modals.close_active_modal();
return true;
}
@ -485,7 +486,7 @@ export function process_enter_key(e) {
// All custom logic for overlays/modals is above; if we're in a
// modal at this point, let the browser handle the event.
if (overlays.is_modal_open()) {
if (modals.is_modal_open()) {
return false;
}
@ -694,7 +695,7 @@ export function process_hotkey(e, hotkey) {
}
// `list_util` will process the event in send later modal.
if (overlays.is_modal_open() && overlays.active_modal() !== "#send_later_modal") {
if (modals.is_modal_open() && modals.active_modal() !== "#send_later_modal") {
return false;
}

239
web/src/modals.ts Normal file
View File

@ -0,0 +1,239 @@
import $ from "jquery";
import Micromodal from "micromodal";
import * as blueslip from "./blueslip";
import * as overlay_util from "./overlay_util";
type Hook = () => void;
export type ModalConfig = {
autoremove?: boolean;
on_show?: () => void;
on_shown?: () => void;
on_hide?: () => void;
on_hidden?: () => void;
};
const pre_open_hooks: Hook[] = [];
const pre_close_hooks: Hook[] = [];
export function register_pre_open_hook(func: Hook): void {
pre_open_hooks.push(func);
}
export function register_pre_close_hook(func: Hook): void {
pre_close_hooks.push(func);
}
function call_hooks(func_list: Hook[]): void {
for (const element of func_list) {
element();
}
}
export function is_modal_open(): boolean {
return $(".micromodal").hasClass("modal--open");
}
export function active_modal(): string | undefined {
if (!is_modal_open()) {
blueslip.error("Programming error — Called active_modal when there is no modal open");
return undefined;
}
const $micromodal = $(".micromodal.modal--open");
return `#${CSS.escape($micromodal.attr("id")!)}`;
}
// If conf.autoremove is true, the modal element will be removed from the DOM
// once the modal is hidden.
// conf also accepts the following optional properties:
// on_show: Callback to run when the modal is triggered to show.
// on_shown: Callback to run when the modal is shown.
// on_hide: Callback to run when the modal is triggered to hide.
// on_hidden: Callback to run when the modal is hidden.
export function open_modal(
modal_id: string,
conf: ModalConfig & {recursive_call_count?: number} = {},
): void {
if (modal_id === undefined) {
blueslip.error("Undefined id was passed into open_modal");
return;
}
// Don't accept hash-based selector to enforce modals to have unique ids and
// since micromodal doesn't accept hash based selectors.
if (modal_id.startsWith("#")) {
blueslip.error("hash-based selector passed in to open_modal", {modal_id});
return;
}
if (is_modal_open()) {
/*
Our modal system doesn't directly support opening a modal
when one is already open, because the `is_modal_open` CSS
class doesn't update until Micromodal has finished its
animations, which can take 100ms or more.
We can likely fix that, but in the meantime, we should
handle this situation correctly, by closing the current
modal, waiting for it to finish closing, and then attempting
to open the current modal again.
*/
if (!conf.recursive_call_count) {
conf.recursive_call_count = 1;
} else {
conf.recursive_call_count += 1;
}
if (conf.recursive_call_count > 50) {
blueslip.error("Modal incorrectly is still open", {modal_id});
return;
}
close_active_modal();
setTimeout(() => {
open_modal(modal_id, conf);
}, 10);
return;
}
blueslip.debug("open modal: " + modal_id);
// Micromodal gets elements using the getElementById DOM function
// which doesn't require the hash. We add it manually here.
const id_selector = `#${CSS.escape(modal_id)}`;
const $micromodal = $(id_selector);
$micromodal.find(".modal__container").on("animationend", (event) => {
const animation_name = (event.originalEvent as AnimationEvent).animationName;
if (animation_name === "mmfadeIn") {
// Micromodal adds the is-open class before the modal animation
// is complete, which isn't really helpful since a modal is open after the
// animation is complete. So, we manually add a class after the
// animation is complete.
$micromodal.addClass("modal--open");
$micromodal.removeClass("modal--opening");
if (conf.on_shown) {
conf.on_shown();
}
} else if (animation_name === "mmfadeOut") {
// Call the on_hidden callback after the modal finishes hiding.
$micromodal.removeClass("modal--open");
if (conf.autoremove) {
$micromodal.remove();
}
if (conf.on_hidden) {
conf.on_hidden();
}
}
});
$micromodal.find(".modal__overlay").on("click", (e) => {
/* Micromodal's data-micromodal-close feature doesn't check for
range selections; this means dragging a selection of text in an
input inside the modal too far will weirdly close the modal.
See https://github.com/ghosh/Micromodal/issues/505.
Work around this with our own implementation. */
if (!$(e.target).is(".modal__overlay")) {
return;
}
if (document.getSelection()?.type === "Range") {
return;
}
close_modal(modal_id);
});
function on_show_callback(): void {
if (conf.on_show) {
conf.on_show();
}
overlay_util.disable_scrolling();
call_hooks(pre_open_hooks);
}
function on_close_callback(): void {
if (conf.on_hide) {
conf.on_hide();
}
overlay_util.enable_scrolling();
call_hooks(pre_close_hooks);
}
Micromodal.show(modal_id, {
disableFocus: true,
openClass: "modal--opening",
onShow: on_show_callback,
onClose: on_close_callback,
});
}
// `conf` is an object with the following optional properties:
// * on_hidden: Callback to run when the modal finishes hiding.
export function close_modal(modal_id: string, conf: Pick<ModalConfig, "on_hidden"> = {}): void {
if (modal_id === undefined) {
blueslip.error("Undefined id was passed into close_modal");
return;
}
if (!is_modal_open()) {
blueslip.warn("close_active_modal() called without checking is_modal_open()");
return;
}
if (active_modal() !== `#${CSS.escape(modal_id)}`) {
blueslip.error("Trying to close modal when other is open", {modal_id, active_modal});
return;
}
blueslip.debug("close modal: " + modal_id);
const id_selector = `#${CSS.escape(modal_id)}`;
const $micromodal = $(id_selector);
// On-hidden hooks should typically be registered in
// overlays.open_modal. However, we offer this alternative
// mechanism as a convenience for hooks only known when
// closing the modal.
$micromodal.find(".modal__container").on("animationend", (event) => {
const animation_name = (event.originalEvent as AnimationEvent).animationName;
if (animation_name === "mmfadeOut" && conf.on_hidden) {
conf.on_hidden();
}
});
Micromodal.close(modal_id);
}
export function close_modal_if_open(modal_id: string): void {
if (modal_id === undefined) {
blueslip.error("Undefined id was passed into close_modal_if_open");
return;
}
if (!is_modal_open()) {
return;
}
const $micromodal = $(".micromodal.modal--open");
const active_modal_id = CSS.escape(`${CSS.escape($micromodal.attr("id") ?? "")}`);
if (active_modal_id === `${CSS.escape(modal_id)}`) {
Micromodal.close(`${CSS.escape($micromodal.attr("id") ?? "")}`);
} else {
blueslip.info(
`${active_modal_id} is the currently active modal and ${modal_id} is already closed.`,
);
}
}
export function close_active_modal(): void {
if (!is_modal_open()) {
blueslip.warn("close_active_modal() called without checking is_modal_open()");
return;
}
const $micromodal = $(".micromodal.modal--open");
Micromodal.close(`${CSS.escape($micromodal.attr("id") ?? "")}`);
}

View File

@ -1,5 +1,4 @@
import $ from "jquery";
import Micromodal from "micromodal";
import * as blueslip from "./blueslip";
import * as overlay_util from "./overlay_util";
@ -17,20 +16,9 @@ type Overlay = {
close_handler: () => void;
};
export type ModalConfig = {
autoremove?: boolean;
on_show?: () => void;
on_shown?: () => void;
on_hide?: () => void;
on_hidden?: () => void;
};
let active_overlay: Overlay | undefined;
let open_overlay_name: string | undefined;
// Used for both overlays and modals.
// We could split these hooks so that they are separate for
// overlays and modals if we need to.
const pre_open_hooks: Hook[] = [];
const pre_close_hooks: Hook[] = [];
@ -57,10 +45,6 @@ export function is_active(): boolean {
return Boolean(open_overlay_name);
}
export function is_modal_open(): boolean {
return $(".micromodal").hasClass("modal--open");
}
export function info_overlay_open(): boolean {
return open_overlay_name === "informationalOverlays";
}
@ -89,16 +73,6 @@ export function scheduled_messages_open(): boolean {
return open_overlay_name === "scheduled";
}
export function active_modal(): string | undefined {
if (!is_modal_open()) {
blueslip.error("Programming error — Called active_modal when there is no modal open");
return undefined;
}
const $micromodal = $(".micromodal.modal--open");
return `#${CSS.escape($micromodal.attr("id")!)}`;
}
export function open_overlay(opts: OverlayOptions): void {
call_hooks(pre_open_hooks);
@ -141,131 +115,6 @@ export function open_overlay(opts: OverlayOptions): void {
$("#navbar-fixed-container").attr("aria-hidden", "true");
}
// If conf.autoremove is true, the modal element will be removed from the DOM
// once the modal is hidden.
// conf also accepts the following optional properties:
// on_show: Callback to run when the modal is triggered to show.
// on_shown: Callback to run when the modal is shown.
// on_hide: Callback to run when the modal is triggered to hide.
// on_hidden: Callback to run when the modal is hidden.
export function open_modal(
modal_id: string,
conf: ModalConfig & {recursive_call_count?: number} = {},
): void {
if (modal_id === undefined) {
blueslip.error("Undefined id was passed into open_modal");
return;
}
// Don't accept hash-based selector to enforce modals to have unique ids and
// since micromodal doesn't accept hash based selectors.
if (modal_id.startsWith("#")) {
blueslip.error("hash-based selector passed in to open_modal", {modal_id});
return;
}
if (is_modal_open()) {
/*
Our modal system doesn't directly support opening a modal
when one is already open, because the `is_modal_open` CSS
class doesn't update until Micromodal has finished its
animations, which can take 100ms or more.
We can likely fix that, but in the meantime, we should
handle this situation correctly, by closing the current
modal, waiting for it to finish closing, and then attempting
to open the current modal again.
*/
if (!conf.recursive_call_count) {
conf.recursive_call_count = 1;
} else {
conf.recursive_call_count += 1;
}
if (conf.recursive_call_count > 50) {
blueslip.error("Modal incorrectly is still open", {modal_id});
return;
}
close_active_modal();
setTimeout(() => {
open_modal(modal_id, conf);
}, 10);
return;
}
blueslip.debug("open modal: " + modal_id);
// Micromodal gets elements using the getElementById DOM function
// which doesn't require the hash. We add it manually here.
const id_selector = `#${CSS.escape(modal_id)}`;
const $micromodal = $(id_selector);
$micromodal.find(".modal__container").on("animationend", (event) => {
const animation_name = (event.originalEvent as AnimationEvent).animationName;
if (animation_name === "mmfadeIn") {
// Micromodal adds the is-open class before the modal animation
// is complete, which isn't really helpful since a modal is open after the
// animation is complete. So, we manually add a class after the
// animation is complete.
$micromodal.addClass("modal--open");
$micromodal.removeClass("modal--opening");
if (conf.on_shown) {
conf.on_shown();
}
} else if (animation_name === "mmfadeOut") {
// Call the on_hidden callback after the modal finishes hiding.
$micromodal.removeClass("modal--open");
if (conf.autoremove) {
$micromodal.remove();
}
if (conf.on_hidden) {
conf.on_hidden();
}
}
});
$micromodal.find(".modal__overlay").on("click", (e) => {
/* Micromodal's data-micromodal-close feature doesn't check for
range selections; this means dragging a selection of text in an
input inside the modal too far will weirdly close the modal.
See https://github.com/ghosh/Micromodal/issues/505.
Work around this with our own implementation. */
if (!$(e.target).is(".modal__overlay")) {
return;
}
if (document.getSelection()?.type === "Range") {
return;
}
close_modal(modal_id);
});
function on_show_callback(): void {
if (conf.on_show) {
conf.on_show();
}
overlay_util.disable_scrolling();
call_hooks(pre_open_hooks);
}
function on_close_callback(): void {
if (conf.on_hide) {
conf.on_hide();
}
overlay_util.enable_scrolling();
call_hooks(pre_close_hooks);
}
Micromodal.show(modal_id, {
disableFocus: true,
openClass: "modal--opening",
onShow: on_show_callback,
onClose: on_close_callback,
});
}
export function close_overlay(name: string): void {
call_hooks(pre_close_hooks);
@ -304,74 +153,6 @@ export function close_active(): void {
close_overlay(open_overlay_name);
}
// `conf` is an object with the following optional properties:
// * on_hidden: Callback to run when the modal finishes hiding.
export function close_modal(modal_id: string, conf: Pick<ModalConfig, "on_hidden"> = {}): void {
if (modal_id === undefined) {
blueslip.error("Undefined id was passed into close_modal");
return;
}
if (!is_modal_open()) {
blueslip.warn("close_active_modal() called without checking is_modal_open()");
return;
}
if (active_modal() !== `#${CSS.escape(modal_id)}`) {
blueslip.error("Trying to close modal when other is open", {modal_id, active_modal});
return;
}
blueslip.debug("close modal: " + modal_id);
const id_selector = `#${CSS.escape(modal_id)}`;
const $micromodal = $(id_selector);
// On-hidden hooks should typically be registered in
// overlays.open_modal. However, we offer this alternative
// mechanism as a convenience for hooks only known when
// closing the modal.
$micromodal.find(".modal__container").on("animationend", (event) => {
const animation_name = (event.originalEvent as AnimationEvent).animationName;
if (animation_name === "mmfadeOut" && conf.on_hidden) {
conf.on_hidden();
}
});
Micromodal.close(modal_id);
}
export function close_modal_if_open(modal_id: string): void {
if (modal_id === undefined) {
blueslip.error("Undefined id was passed into close_modal_if_open");
return;
}
if (!is_modal_open()) {
return;
}
const $micromodal = $(".micromodal.modal--open");
const active_modal_id = CSS.escape(`${CSS.escape($micromodal.attr("id") ?? "")}`);
if (active_modal_id === `${CSS.escape(modal_id)}`) {
Micromodal.close(`${CSS.escape($micromodal.attr("id") ?? "")}`);
} else {
blueslip.info(
`${active_modal_id} is the currently active modal and ${modal_id} is already closed.`,
);
}
}
export function close_active_modal(): void {
if (!is_modal_open()) {
blueslip.warn("close_active_modal() called without checking is_modal_open()");
return;
}
const $micromodal = $(".micromodal.modal--open");
Micromodal.close(`${CSS.escape($micromodal.attr("id") ?? "")}`);
}
export function close_for_hash_change(): void {
if (open_overlay_name) {
close_overlay(open_overlay_name);

View File

@ -1,5 +1,6 @@
import * as overlays from './overlays';
import * as modals from "./modals";
import * as overlays from "./overlays";
export function any_active(): boolean {
return overlays.is_active() || overlays.is_modal_open();
return overlays.is_active() || modals.is_modal_open();
}

View File

@ -7,6 +7,7 @@ import tippy from "tippy.js";
import * as blueslip from "./blueslip";
import {media_breakpoints_num} from "./css_variables";
import * as modals from "./modals";
import * as overlays from "./overlays";
import * as popovers from "./popovers";
@ -259,6 +260,8 @@ export function initialize() {
/* Configure popovers to hide when toggling overlays. */
overlays.register_pre_open_hook(popovers.hide_all);
overlays.register_pre_close_hook(popovers.hide_all);
modals.register_pre_open_hook(popovers.hide_all);
modals.register_pre_close_hook(popovers.hide_all);
let last_scroll = 0;

View File

@ -8,13 +8,13 @@ import * as channel from "./channel";
import {$t, $t_html} from "./i18n";
import * as loading from "./loading";
import * as message_store from "./message_store";
import * as overlays from "./overlays";
import * as modals from "./modals";
import * as people from "./people";
import * as ui_report from "./ui_report";
export function show_user_list(message_id) {
$("body").append(render_read_receipts_modal());
overlays.open_modal("read_receipts_modal", {
modals.open_modal("read_receipts_modal", {
autoremove: true,
on_show() {
const message = message_store.get(message_id);

View File

@ -8,7 +8,7 @@ import render_send_later_modal_options from "../templates/send_later_modal_optio
import * as compose from "./compose";
import * as compose_validate from "./compose_validate";
import * as flatpickr from "./flatpickr";
import * as overlays from "./overlays";
import * as modals from "./modals";
import * as popover_menus from "./popover_menus";
import * as scheduled_messages from "./scheduled_messages";
import {parse_html} from "./ui_util";
@ -31,7 +31,7 @@ export function open_send_later_menu() {
$("body").append(render_send_later_modal(filtered_send_opts));
let interval;
overlays.open_modal("send_later_modal", {
modals.open_modal("send_later_modal", {
autoremove: true,
on_show() {
interval = setInterval(
@ -100,7 +100,7 @@ export function open_send_later_menu() {
}
export function do_schedule_message(send_at_time) {
overlays.close_modal_if_open("send_later_modal");
modals.close_modal_if_open("send_later_modal");
if (!Number.isInteger(send_at_time)) {
// Convert to timestamp if this is not a timestamp.

View File

@ -8,6 +8,7 @@ import render_settings_tab from "../templates/settings_tab.hbs";
import * as browser_history from "./browser_history";
import * as flatpickr from "./flatpickr";
import {$t} from "./i18n";
import * as modals from "./modals";
import * as overlays from "./overlays";
import {page_params} from "./page_params";
import * as people from "./people";
@ -25,7 +26,7 @@ export let settings_label;
$(() => {
$("#settings_overlay_container").on("click", (e) => {
if (!overlays.is_modal_open()) {
if (!modals.is_modal_open()) {
return;
}
if ($(e.target).closest(".micromodal").length > 0) {
@ -38,7 +39,7 @@ $(() => {
// event to the parent container otherwise the modal will not open. This
// is so because this event handler will get fired on any click in settings
// overlay and subsequently close any open modal.
overlays.close_active_modal();
modals.close_active_modal();
});
});

View File

@ -17,6 +17,7 @@ import * as custom_profile_fields_ui from "./custom_profile_fields_ui";
import * as dialog_widget from "./dialog_widget";
import {$t_html} from "./i18n";
import * as keydown_util from "./keydown_util";
import * as modals from "./modals";
import * as overlays from "./overlays";
import {page_params} from "./page_params";
import * as people from "./people";
@ -361,7 +362,7 @@ export function set_up() {
$("body").append(render_settings_api_key_modal());
setup_api_key_modal();
$("#api_key_status").hide();
overlays.open_modal("api_key_modal", {
modals.open_modal("api_key_modal", {
autoremove: true,
on_show() {
$("#get_api_key_password").trigger("focus");

View File

@ -10,7 +10,7 @@ import $ from "jquery";
import render_login_to_access_modal from "../templates/login_to_access.hbs";
import * as browser_history from "./browser_history";
import * as overlays from "./overlays";
import * as modals from "./modals";
import {page_params} from "./page_params";
export function current_hash_as_next(): string {
@ -40,7 +40,7 @@ export function login_to_access(empty_narrow?: boolean): void {
}),
);
overlays.open_modal("login_to_access_modal", {
modals.open_modal("login_to_access_modal", {
autoremove: true,
on_hide() {
browser_history.return_to_web_public_hash();

View File

@ -24,7 +24,7 @@ import {$t, $t_html} from "./i18n";
import * as integration_url_modal from "./integration_url_modal";
import * as ListWidget from "./list_widget";
import * as loading from "./loading";
import * as overlays from "./overlays";
import * as modals from "./modals";
import {page_params} from "./page_params";
import * as people from "./people";
import * as settings_config from "./settings_config";
@ -74,7 +74,7 @@ function compare_by_name(a, b) {
}
export function get_user_id_if_user_profile_modal_open() {
if (overlays.is_modal_open() && overlays.active_modal() === "#user-profile-modal") {
if (modals.is_modal_open() && modals.active_modal() === "#user-profile-modal") {
const user_id = $("#user-profile-modal").data("user-id");
return user_id;
}
@ -294,7 +294,7 @@ export function get_custom_profile_field_data(user, field, field_types) {
export function hide_user_profile() {
user_streams_list_widget = undefined;
overlays.close_modal_if_open("user-profile-modal");
modals.close_modal_if_open("user-profile-modal");
}
function show_manage_user_tab(target) {
@ -370,7 +370,7 @@ export function show_user_profile(user, default_tab_key = "profile-tab") {
}
$("#user-profile-modal-holder").html(render_user_profile_modal(args));
overlays.open_modal("user-profile-modal", {autoremove: true});
modals.open_modal("user-profile-modal", {autoremove: true});
$(".tabcontent").hide();
let default_tab = 0;

View File

@ -55,6 +55,10 @@ const narrow_state = mock_esm("../src/narrow_state", {
is_message_feed_visible: () => true,
});
const navigate = mock_esm("../src/navigate");
const modals = mock_esm("../src/modals", {
is_modal_open: () => false,
active_modal: () => undefined,
});
const overlays = mock_esm("../src/overlays", {
is_active: () => false,
settings_open: () => false,
@ -63,8 +67,6 @@ const overlays = mock_esm("../src/overlays", {
drafts_open: () => false,
scheduled_messages_open: () => false,
info_overlay_open: () => false,
is_modal_open: () => false,
active_modal: () => undefined,
});
const popovers = mock_esm("../src/user_card_popover", {
manage_menu: {
@ -348,7 +350,7 @@ run_test("drafts closed launch", ({override}) => {
});
run_test("modal open", ({override}) => {
override(overlays, "is_modal_open", () => true);
override(modals, "is_modal_open", () => true);
test_normal_typing();
});