dialog_widget: Migrate modal to Micromodal.

Also removed the `danger_submit_button` config option
from the dialog_widget since it isn't needed in the new modals.
This commit is contained in:
Ganesh Pawar 2021-07-04 12:17:08 +05:30 committed by Tim Abbott
parent 6a07a90499
commit 1e8bfa710e
17 changed files with 363 additions and 119 deletions

View File

@ -496,6 +496,16 @@ class CommonUtils {
);
}
async wait_for_micromodal_to_open(page: Page): Promise<void> {
// We manually add the `modal--open` class to the modal after the modal animation completes.
await page.waitForFunction(() => document.querySelector(".modal--open") !== null);
}
async wait_for_micromodal_to_close(page: Page): Promise<void> {
// This function will ensure that the mouse events are enabled for the background for further tests.
await page.waitForFunction(() => document.querySelector(".modal--open") === null);
}
async run_test_async(test_function: (page: Page) => Promise<void>): Promise<void> {
// Pass a page instance to test so we can take
// a screenshot of it when the test fails.

View File

@ -18,13 +18,11 @@ async function delete_message_test(page: Page): Promise<void> {
const messages_quantitiy = await page.evaluate(() => $("#zhome .message_row").length);
const last_message_id = await click_delete_and_return_last_msg_id(page);
await page.waitForSelector("#dialog_widget_modal", {visible: true});
await page.click(".dialog_submit_button");
const confirm_span = ".dialog_submit_button span";
await page.waitForSelector(confirm_span, {hidden: true});
await page.waitForSelector("#dialog_widget_modal", {hidden: true});
await common.wait_for_micromodal_to_open(page);
await page.evaluate(() => {
(document.querySelector(".dialog_submit_button") as HTMLButtonElement)?.click();
});
await common.wait_for_micromodal_to_close(page);
await page.waitForFunction(
(expected_length: number) => $("#zhome .message_row").length === expected_length,

View File

@ -56,7 +56,7 @@ async function test_add_invalid_linkifier_pattern(page: Page): Promise<void> {
async function test_edit_linkifier(page: Page): Promise<void> {
await page.click(".linkifier_row .edit");
await page.waitForFunction(() => document.activeElement?.id === "dialog_widget_modal");
await common.wait_for_micromodal_to_open(page);
await common.fill_form(page, "form.linkifier-edit-form", {
pattern: "(?P<num>[0-9a-f]{40})",
url_format_string: "https://trac.example.com/commit/%(num)s",
@ -64,7 +64,7 @@ async function test_edit_linkifier(page: Page): Promise<void> {
await page.click(".dialog_submit_button");
await page.waitForSelector("#dialog_widget_modal", {hidden: true});
await common.wait_for_modal_to_close(page);
await common.wait_for_micromodal_to_close(page);
await page.waitForSelector(".linkifier_row", {visible: true});
await page.waitForFunction(
@ -81,7 +81,7 @@ async function test_edit_linkifier(page: Page): Promise<void> {
async function test_edit_invalid_linkifier(page: Page): Promise<void> {
await page.click(".linkifier_row .edit");
await page.waitForFunction(() => document.activeElement?.id === "dialog_widget_modal");
await common.wait_for_micromodal_to_open(page);
await common.fill_form(page, "form.linkifier-edit-form", {
pattern: "#(?P<id>d????)",
url_format_string: "????",
@ -107,7 +107,7 @@ async function test_edit_invalid_linkifier(page: Page): Promise<void> {
);
assert.strictEqual(edit_linkifier_format_status, "Failed: Enter a valid URL.");
await page.click(".close-modal-btn");
await page.click(".dialog_cancel_button");
await page.waitForSelector("#dialog_widget_modal", {hidden: true});
await page.waitForSelector(".linkifier_row", {visible: true});

View File

@ -23,7 +23,7 @@ async function test_deactivate_user(page: Page): Promise<void> {
await page.waitForSelector(cordelia_user_row, {visible: true});
await page.waitForSelector(cordelia_user_row + " .fa-user-times");
await page.click(cordelia_user_row + " .deactivate");
await page.waitForSelector("#dialog_widget_modal", {visible: true});
await common.wait_for_micromodal_to_open(page);
assert.strictEqual(
await common.get_text_from_selector(page, ".dialog_heading"),
@ -36,7 +36,7 @@ async function test_deactivate_user(page: Page): Promise<void> {
"Deactivate button has incorrect text.",
);
await page.click("#dialog_widget_modal .dialog_submit_button");
await page.waitForSelector("#user-field-status", {hidden: true});
await common.wait_for_micromodal_to_close(page);
}
async function test_reactivate_user(page: Page): Promise<void> {

View File

@ -47,6 +47,7 @@
"jquery-validation": "^1.19.0",
"katex": "^0.13.2",
"lodash": "^4.17.19",
"micromodal": "^0.4.6",
"mini-css-extract-plugin": "^2.2.2",
"plotly.js": "^2.0.0",
"postcss": "^8.0.3",

View File

@ -872,6 +872,7 @@ export function initialize() {
!$(e.target).closest(".overlay").length &&
!$(e.target).closest(".popover").length &&
!$(e.target).closest(".modal").length &&
!$(e.target).closest(".micromodal").length &&
!$(e.target).closest("[data-tippy-root]").length &&
!$(e.target).closest(".modal-backdrop").length &&
$(e.target).closest("body").length

View File

@ -5,7 +5,6 @@ export function launch(conf) {
dialog_widget.launch({
...conf,
close_on_submit: true,
danger_submit_button: true,
focus_submit_on_open: true,
html_submit_button: $t_html({defaultMessage: "Confirm"}),
// Used to control button colors in the template.

View File

@ -1,62 +1,66 @@
import $ from "jquery";
import Micromodal from "micromodal";
import render_dialog_widget from "../templates/dialog_widget.hbs";
import render_dialog_heading from "../templates/dialog_widget_heading.hbs";
import * as blueslip from "./blueslip";
import {$t_html} from "./i18n";
import * as loading from "./loading";
import * as overlays from "./overlays";
import * as settings_data from "./settings_data";
/*
Look for dialog_widget in settings_users
to see an example of how to use this widget. It's
pretty simple to use!
Some things to note:
1) We create DOM on the fly, and we remove
the DOM once it's closed.
2) We attach the DOM for the modal to the body element
to avoid style interference from other elements.
3) The cancel button is driven by bootstrap.js.
4) For settings, we have a click handler in settings.js
that will close the dialog via overlays.close_active_modal.
5) We assume that since this is a modal, you will
only ever have one dialog active at any
time.
6) If a modal wants a loading spinner, it should pass loading_spinner: true.
This will show a loading spinner when the yes button is clicked.
The caller is responsible for calling hide_dialog_spinner()
to hide the spinner in both success and error handlers.
7) If a caller needs to run code after the modal body is added
to DOM, it can do so by passing a post_render hook.
*/
* Look for confirm_dialog in settings_user_groups
* to see an example of how to use this widget. It's
* pretty simple to use!
*
* Some things to note:
* 1) We create DOM on the fly, and we remove
* the DOM once it's closed.
*
* 2) We attach the DOM for the modal to the body element
* 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.
*
* 4) We assume that since this is a modal, you will
* only ever have one confirm dialog active at any
* time.
*
* 5) If a modal wants a loading spinner, it should pass loading_spinner: true.
* This will show a loading spinner when the yes button is clicked.
* The caller is responsible for calling hide_confirm_dialog_spinner()
* to hide the spinner in both success and error handlers.
*
* 6) If loading_spinner is used, don't hide it on `success`. This modal has a fade out
* animation. This causes the `Confirm` button to be shown for a split second if the
* spinner is hidden.
* Just close the modal. This will remove the whole modal from the DOM without
* needing to remove the spinner.
*
* 7) If a caller needs to run code after the modal body is added
* to DOM, it can do so by passing a post_render hook.
*/
export function hide_dialog_spinner() {
$(".dialog_submit_button .loader").hide();
$(".dialog_submit_button span").show();
$(".dialog_submit_button").prop("disabled", false);
$("#dialog_widget_modal .close-modal-btn").prop("disabled", false);
$("#dialog_widget_modal .modal__btn").prop("disabled", false);
const spinner = $("#dialog_widget_modal .modal__spinner");
loading.destroy_indicator(spinner);
}
export function show_dialog_spinner() {
const using_dark_theme = settings_data.using_dark_theme();
loading.show_button_spinner($(".dialog_submit_button .loader"), using_dark_theme);
$(".dialog_submit_button span").hide();
$(".dialog_submit_button").prop("disabled", true);
$("#dialog_widget_modal .close-modal-btn").prop("disabled", true);
// Disable both the buttons.
$("#dialog_widget_modal .modal__btn").prop("disabled", true);
const spinner = $("#dialog_widget_modal .modal__spinner");
loading.make_indicator(spinner);
}
export function close_modal() {
overlays.close_modal("#dialog_widget_modal");
Micromodal.close("dialog_widget_modal");
}
export function launch(conf) {
@ -73,7 +77,6 @@ export function launch(conf) {
// * html_submit_button: Submit button text.
// * close_on_submit: Whether to close modal on clicking submit.
// * focus_submit_on_open: Whether to focus submit button on open.
// * danger_submit_button: Whether to use danger button styling for submit button.
// * help_link: A help link in the heading area.
for (const f of mandatory_fields) {
@ -89,15 +92,11 @@ export function launch(conf) {
}
const html_submit_button = conf.html_submit_button || $t_html({defaultMessage: "Save changes"});
const html_dialog_heading = render_dialog_heading({
const html = render_dialog_widget({
heading_text: conf.html_heading,
link: conf.help_link,
});
const html = render_dialog_widget({
html_submit_button,
html_dialog_heading,
html_body: conf.html_body,
danger_submit_button: conf.danger_submit_button,
});
const dialog = $(html);
$("body").append(dialog);
@ -118,16 +117,15 @@ export function launch(conf) {
conf.on_click(e);
});
dialog.on("hidden.bs.modal", () => {
dialog.remove();
overlays.open_modal("dialog_widget_modal", {
autoremove: true,
micromodal: true,
micromodal_opts: {
onShow: () => {
if (conf.focus_submit_on_open) {
submit_button.trigger("focus");
}
},
},
});
if (conf.focus_submit_on_open) {
dialog.on("shown.bs.modal", () => {
submit_button.trigger("focus");
});
}
// Open the modal
overlays.open_modal("#dialog_widget_modal");
}

View File

@ -1,4 +1,5 @@
import $ from "jquery";
import Micromodal from "micromodal";
import * as blueslip from "./blueslip";
import * as browser_history from "./browser_history";
@ -19,7 +20,8 @@ export function is_active() {
}
export function is_modal_open() {
return $(".modal").hasClass("in");
// Check for both Bootstrap and Micromodal modals.
return $(".modal").hasClass("in") || $(".micromodal").hasClass("modal--open");
}
export function info_overlay_open() {
@ -65,6 +67,12 @@ export function active_modal() {
blueslip.error("Programming error — Called active_modal when there is no modal open");
return undefined;
}
// Check for Micromodal modals.
const micromodal = $(".micromodal.modal--open");
if (micromodal.length) {
return `#${CSS.escape(micromodal.attr("id"))}`;
}
return `#${CSS.escape($(".modal.in").attr("id"))}`;
}
@ -113,17 +121,25 @@ export function open_overlay(opts) {
// If conf.autoremove is true, the modal element will be removed from the DOM
// once the modal is hidden.
// If conf.micromodal is true, open a micromodal modal else open a bootstrap modal
export function open_modal(selector, conf) {
if (selector === undefined) {
blueslip.error("Undefined selector was passed into open_modal");
return;
}
if (selector[0] !== "#") {
if ((!conf || (conf && !conf.micromodal)) && selector[0] !== "#") {
blueslip.error("Non-id-based selector passed in to open_modal: " + selector);
return;
}
// Don't accept hash-based selector to enforce modals to have unique ids and
// since micromodal doesn't accept hash based selectors.
if (conf && conf.micromodal && selector[0] === "#") {
blueslip.error("hash-based selector passed in to micromodal-based open_modal: " + selector);
return;
}
if (is_modal_open()) {
blueslip.error("open_modal() was called while " + active_modal() + " modal was open.");
return;
@ -131,6 +147,46 @@ export function open_modal(selector, conf) {
blueslip.debug("open modal: " + selector);
// Show a modal using micromodal.
if (conf && conf.micromodal) {
// Micromodal gets elements using the getElementById DOM function
// which doesn't require the hash. We add it manually here.
const id_selector = `#${selector}`;
const micromodal = $(id_selector);
micromodal.find(".modal__container").on("animationend", (event) => {
// Micromodal doesn't support Bootstrap-style `shown.bs.modal` and
// `hidden.bs.modal` events. We workaround this by using the animationName
// from the native event and running the required functions after the
// animation ends.
const animation_name = event.originalEvent.animationName;
if (animation_name === "mmfadeIn") {
// Equivalent to bootstrap's "shown.bs.modal" event
// 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");
} else if (animation_name === "mmfadeOut") {
// Equivalent to bootstrap's "hidden.bs.modal" event
micromodal.removeClass("modal--open");
if (conf.autoremove) {
micromodal.remove();
}
}
});
Micromodal.show(selector, {
disableFocus: true,
openClass: "modal--opening",
...conf.micromodal_opts,
});
return;
}
const elem = $(selector).expectOne();
elem.modal("show").attr("aria-hidden", false);
// Disable background mouse events when modal is active
@ -185,7 +241,8 @@ export function close_active() {
close_overlay(open_overlay_name);
}
export function close_modal(selector) {
// If conf.micromodal is true, close a micromodal modal else close a bootstrap modal
export function close_modal(selector, conf) {
if (selector === undefined) {
blueslip.error("Undefined selector was passed into close_modal");
return;
@ -196,7 +253,10 @@ export function close_modal(selector) {
return;
}
if (active_modal() !== selector) {
if (
(!conf && active_modal() !== selector) ||
(conf && conf.micromodal && active_modal() !== `#${selector}`)
) {
blueslip.error(
"Trying to close " + selector + " modal when " + active_modal() + " is open.",
);
@ -205,6 +265,11 @@ export function close_modal(selector) {
blueslip.debug("close modal: " + selector);
if (conf && conf.micromodal) {
Micromodal.close(selector);
return;
}
const elem = $(selector).expectOne();
elem.modal("hide").attr("aria-hidden", true);
}
@ -215,6 +280,13 @@ export function close_active_modal() {
return;
}
// Check for Micromodal modals.
const micromodal = $(".micromodal.modal--open");
if (micromodal.length) {
Micromodal.close(`${CSS.escape(micromodal.attr("id"))}`);
return;
}
$(".modal.in").modal("hide").attr("aria-hidden", true);
}

View File

@ -28,7 +28,7 @@ $("body").ready(() => {
if (!overlays.is_modal_open()) {
return;
}
if ($(e.target).closest(".modal").length > 0) {
if ($(e.target).closest(".modal, .micromodal").length > 0) {
return;
}
e.preventDefault();

View File

@ -35,3 +35,170 @@
.modal-bg {
background-color: hsl(0, 0%, 98%);
}
/* Styles for the Micromodal-based modals */
.modal__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: hsla(0, 0%, 0%, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 105;
}
.modal__container {
display: flex;
flex-direction: column;
background-color: hsl(0, 0%, 100%);
max-width: calc(100% - 32px);
max-height: 96%;
width: 32.5rem;
border-radius: 4px;
box-sizing: border-box;
}
.modal__header {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal__footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 20px 24px;
}
.modal__title {
margin: 0;
font-size: 1.375rem;
line-height: 1.25;
}
.modal__close {
&::before {
content: "\2715";
}
margin-right: -4px;
background: transparent;
border: 0;
&:hover {
background: hsl(0, 0%, 90%);
}
}
.modal__content {
font-size: 1rem;
overflow-y: auto;
padding: 0 24px;
line-height: 1.5;
}
.modal__btn {
font-size: 0.875rem;
padding: 0.5rem 1rem;
background-color: hsl(0, 0%, 90%);
border-radius: 0.25rem;
border-width: 0;
cursor: pointer;
appearance: button;
text-transform: none;
overflow: visible;
outline: none !important;
line-height: 1.15;
margin: 0;
will-change: transform;
backface-visibility: hidden;
transform: translateZ(0);
transition: transform 0.25s ease-out;
&:focus {
box-shadow: hsl(198, 76%, 47%) 0 0 0 1px,
hsla(198, 76%, 47%, 0.3) 0 0 0 5px;
}
}
.modal__btn:focus,
.modal__btn:hover {
transform: scale(1.05);
}
.dialog_cancel_button {
background: hsl(0, 0%, 100%);
border: 1px solid hsla(300, 2%, 11%, 0.3);
&:hover {
background: hsl(0, 0%, 97%);
}
}
.dialog_submit_button {
margin-left: 12px;
background-color: hsl(214, 100%, 31%);
color: hsl(0, 0%, 100%);
}
@keyframes mmfadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mmfadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.micromodal {
display: none;
}
.micromodal.modal--opening,
.micromodal.modal--open {
display: block;
}
.micromodal[aria-hidden="true"] .modal__overlay {
animation: mmfadeOut 75ms cubic-bezier(0, 0, 0.2, 1);
}
.micromodal[aria-hidden="false"] .modal__overlay {
animation: mmfadeIn 120ms cubic-bezier(0, 0, 0.2, 1);
}
.micromodal[aria-hidden="true"] .modal__container {
animation: mmfadeOut 75ms cubic-bezier(0, 0, 0.2, 1);
}
.micromodal[aria-hidden="false"] .modal__container {
animation: mmfadeIn 120ms cubic-bezier(0, 0, 0.2, 1);
}
.micromodal .modal__container,
.micromodal .modal__overlay {
will-change: transform;
}
.modal__spinner .loading_indicator_spinner {
height: 16px;
path {
fill: hsl(0, 0%, 100%);
}
}

View File

@ -128,10 +128,21 @@ body.night-mode {
border-color: hsla(0, 0%, 100%, 0.4);
}
.modal-bg {
.modal-bg,
.modal__container {
background-color: hsl(212, 28%, 18%);
}
.modal__close {
&::before {
color: hsl(236, 33%, 90%);
}
&:hover {
background: hsla(0, 0%, 91%, 0.1);
}
}
.streams_popover .sp-container {
background-color: transparent;

View File

@ -1577,17 +1577,6 @@ input[type="checkbox"] {
}
}
/* Dialog widgets should be centered, which this roughly achieves. */
#dialog_widget_modal {
top: calc(50% - 120px);
}
/* In the settings overlay, we need slightly different CSS for alignment. */
#settings_overlay_container #dialog_widget_modal {
top: 50%;
vertical-align: center;
}
/* These have enough space for all the options in German. */
.setting_desktop_icon_count_display,
#id_realm_waiting_period_setting,
@ -1618,16 +1607,6 @@ input[type="checkbox"] {
margin-top: 10px;
}
.dialog_submit_button .loader {
display: none;
vertical-align: top;
position: relative;
height: 30px;
margin-top: -10px;
top: 5px;
width: 30px;
}
.dropdown-list-widget {
button {
margin: 0 5px;

View File

@ -1,17 +1,26 @@
<div class="modal modal-bg new-style hide" id="dialog_widget_modal" tabindex="-1" role="dialog" aria-labelledby="dialog_widget_modal" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close close-modal-btn" data-dismiss="modal" aria-label="{{t 'Close' }}"><span aria-hidden="true">&times;</span></button>
<div class="dialog_heading">{{{ html_dialog_heading }}}</div>
</div>
<div class="modal-body dialog_body">
<div id="dialog_error" class="alert"></div>
{{{ html_body }}}
</div>
<div class="modal-footer">
<button class="button rounded close-modal-btn" data-dismiss="modal">{{t "Cancel" }}</button>
<button class="button rounded {{#if danger_submit_button}}btn-danger{{else}}sea-green{{/if}} dialog_submit_button">
<img class="loader" alt="" src="" />
<span>{{{ html_submit_button }}}</span>
</button>
<div class="micromodal" id="dialog_widget_modal" aria-hidden="true">
<div class="modal__overlay" tabindex="-1" data-micromodal-close>
<div class="modal__container" role="dialog" aria-modal="true" aria-labelledby="dialog_title">
<header class="modal__header">
<h1 class="modal__title dialog_heading">
{{{ heading_text }}}
{{#if link}}
{{> help_link_widget }}
{{/if}}
</h1>
<button class="modal__close" aria-label="{{t 'Close modal' }}" data-micromodal-close></button>
</header>
<main class="modal__content">
<div class="alert" id="dialog_error"></div>
{{{ html_body }}}
</main>
<footer class="modal__footer">
<button class="modal__btn dialog_cancel_button" aria-label="{{t 'Close this dialog window' }}" data-micromodal-close>{{t "Cancel" }}</button>
<button class="modal__btn dialog_submit_button">
<span>{{{ html_submit_button }}}</span>
<div class="modal__spinner"></div>
</button>
</footer>
</div>
</div>
</div>

View File

@ -1,6 +0,0 @@
<h3>
{{{ heading_text }}}
{{#if link}}
{{> help_link_widget }}
{{/if}}
</h3>

View File

@ -48,4 +48,4 @@ API_FEATURE_LEVEL = 106
# historical commits sharing the same major version, in which case a
# minor version bump suffices.
PROVISION_VERSION = "164.1"
PROVISION_VERSION = "164.2"

View File

@ -7985,6 +7985,11 @@ micromist@1.1.0:
dependencies:
lodash.camelcase "^4.3.0"
micromodal@^0.4.6:
version "0.4.6"
resolved "https://registry.yarnpkg.com/micromodal/-/micromodal-0.4.6.tgz#0425ad026c47923208cf826de6b58ed0693cb25a"
integrity sha512-2VDso2a22jWPpqwuWT/4RomVpoU3Bl9qF9D01xzwlNp5UVsImeA0gY4nSpF44vqcQtQOtkiMUV9EZkAJSRxBsg==
mime-db@1.50.0, "mime-db@>= 1.43.0 < 2":
version "1.50.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.50.0.tgz#abd4ac94e98d3c0e185016c67ab45d5fde40c11f"