From 0978ab3da0e0d2b2872f4970e21f173f9d7a2b45 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 28 Feb 2019 15:55:58 -0700 Subject: [PATCH] Support stacking dialogs to prevent unmounting Fixes https://github.com/vector-im/riot-web/issues/8371 --- res/css/_common.scss | 28 ++++++- src/Modal.js | 100 ++++++++++++++++++++---- src/components/structures/MatrixChat.js | 3 +- src/stores/RoomViewStore.js | 2 +- 4 files changed, 114 insertions(+), 19 deletions(-) diff --git a/res/css/_common.scss b/res/css/_common.scss index 9725340978..1e388c4531 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -228,6 +228,17 @@ textarea { color: $roomsublist-label-bg-color; } +/* Expected z-indexes for dialogs: + 4000 - Default wrapper index + 4009 - Static dialog background + 4010 - Static dialog itself + 4011 - Standard dialog background + 4012 - Standard dialog itself + + These are set up such that the static dialog always appears + underneath the standard dialogs. + */ + .mx_Dialog_wrapper { position: fixed; z-index: 4000; @@ -252,7 +263,7 @@ textarea { .mx_Dialog { background-color: $primary-bg-color; color: $light-fg-color; - z-index: 4010; + z-index: 4012; font-weight: 300; font-size: 15px; position: relative; @@ -264,6 +275,10 @@ textarea { overflow-y: auto; } +.mx_Dialog_staticWrapper .mx_Dialog { + z-index: 4010; +} + .mx_Dialog_background { position: fixed; top: 0; @@ -272,6 +287,17 @@ textarea { height: 100%; background-color: $dialog-backdrop-color; opacity: 0.8; + z-index: 4011; +} + +.mx_Dialog_background.mx_Dialog_staticBackground { + z-index: 4009; +} + +.mx_Dialog_wrapperWithStaticUnder .mx_Dialog_background { + // Roughly half of what it would normally be - we don't want to black out + // the app, just make it clear that the dialogs are stacked. + opacity: 0.4; } .mx_Dialog_lightbox .mx_Dialog_background { diff --git a/src/Modal.js b/src/Modal.js index 960e0e5c30..4d90e313ce 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -26,6 +26,7 @@ import dis from './dispatcher'; import { _t } from './languageHandler'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; +const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; /** * Wrap an asynchronous loader function with a react component which shows a @@ -106,7 +107,12 @@ class ModalManager { // this modal. Remove all other modals from the stack when this modal // is closed. this._priorityModal = null; + // The modal to keep open underneath other modals if possible. Useful + // for cases like Settings where the modal should remain open while the + // user is prompted for more information/errors. + this._staticModal = null; // A list of the modals we have stacked up, with the most recent at [0] + // Neither the static nor priority modal will be in this list. this._modals = [ /* { elem: React component for this dialog @@ -130,6 +136,18 @@ class ModalManager { return container; } + getOrCreateStaticContainer() { + let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID); + + if (!container) { + container = document.createElement("div"); + container.id = STATIC_DIALOG_CONTAINER_ID; + document.body.appendChild(container); + } + + return container; + } + createTrackedDialog(analyticsAction, analyticsInfo, ...rest) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialog(...rest); @@ -166,8 +184,13 @@ class ModalManager { * of other modals that are currently in the stack. * Also, when closed, all modals will be removed * from the stack. + * @param {boolean} isStaticModal if true, this modal will be displayed under other + * modals in the stack. When closed, all modals will + * also be removed from the stack. This is not compatible + * with being a priority modal. Only one modal can be + * static at a time. */ - createDialogAsync(prom, props, className, isPriorityModal) { + createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) { const self = this; const modal = {}; @@ -188,6 +211,13 @@ class ModalManager { self._modals = []; } + if (self._staticModal === modal) { + self._staticModal = null; + + // XXX: This is destructive + self._modals = []; + } + self._reRender(); }; @@ -207,6 +237,9 @@ class ModalManager { if (isPriorityModal) { // XXX: This is destructive this._priorityModal = modal; + } else if (isStaticModal) { + // This is intentionally destructive + this._staticModal = modal; } else { this._modals.unshift(modal); } @@ -216,12 +249,18 @@ class ModalManager { } closeAll() { - const modals = this._modals; + const modalsToClose = [...this._modals, this._priorityModal]; this._modals = []; + this._priorityModal = null; - for (let i = 0; i < modals.length; i++) { - const m = modals[i]; - if (m.onFinished) { + if (this._staticModal && modalsToClose.length === 0) { + modalsToClose.push(this._staticModal); + this._staticModal = null; + } + + for (let i = 0; i < modalsToClose.length; i++) { + const m = modalsToClose[i]; + if (m && m.onFinished) { m.onFinished(false); } } @@ -230,13 +269,14 @@ class ModalManager { } _reRender() { - if (this._modals.length == 0 && !this._priorityModal) { + if (this._modals.length === 0 && !this._priorityModal && !this._staticModal) { // If there is no modal to render, make all of Riot available // to screen reader users again dis.dispatch({ action: 'aria_unhide_main_app', }); ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); + ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer()); return; } @@ -247,17 +287,45 @@ class ModalManager { action: 'aria_hide_main_app', }); - const modal = this._priorityModal ? this._priorityModal : this._modals[0]; - const dialog = ( -
-
- { modal.elem } -
-
-
- ); + if (this._staticModal) { + const classes = "mx_Dialog_wrapper mx_Dialog_staticWrapper " + + (this._staticModal.className ? this._staticModal.className : ''); - ReactDOM.render(dialog, this.getOrCreateContainer()); + const staticDialog = ( +
+
+ { this._staticModal.elem } +
+
+
+ ); + + ReactDOM.render(staticDialog, this.getOrCreateStaticContainer()); + } else { + // This is safe to call repeatedly if we happen to do that + ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer()); + } + + const modal = this._priorityModal ? this._priorityModal : this._modals[0]; + if (modal) { + const classes = "mx_Dialog_wrapper " + + (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '') + + (modal.className ? modal.className : ''); + + const dialog = ( +
+
+ {modal.elem} +
+
+
+ ); + + ReactDOM.render(dialog, this.getOrCreateContainer()); + } else { + // This is safe to call repeatedly if we happen to do that + ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); + } } } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 1bd0f02026..0a098c5f4f 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -584,7 +584,8 @@ export default React.createClass({ break; case 'view_user_settings': { const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); - Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog'); + Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog', + /*isPriority=*/false, /*isStatic=*/true); // View the welcome or home page if we need something to look at this._viewSomethingBehindModal(); diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 73eff6f6d4..d8556f661e 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -120,7 +120,7 @@ class RoomViewStore extends Store { const RoomSettingsDialog = sdk.getComponent("dialogs.RoomSettingsDialog"); Modal.createTrackedDialog('Room settings', '', RoomSettingsDialog, { roomId: payload.room_id || this._state.roomId, - }, 'mx_SettingsDialog'); + }, 'mx_SettingsDialog', /*isPriority=*/false, /*isStatic=*/true); break; } }