From f69210b6d184336f8a57c288f2dca3d7db2733eb Mon Sep 17 00:00:00 2001 From: Jasper Vriends <4417659+jaspervriends@users.noreply.github.com> Date: Mon, 8 Aug 2022 14:29:14 +0200 Subject: [PATCH] feat(modals): support stacking modals, remove bootstrap modals dependency (#3456) * Feature: Stackable modals * Processed feedback * fix: use position in modal stack rather than key for modal number * fix: use correct base z-index * chore: simplify `className` to `class` * chore: add `key` attribute to ModalManager element * fix: backdrop flashing as modals are stacked/unstacked * chore: simplify modal close process * docs: add TS overload to indicate deprecated modal opening syntax Require explicit values for `attrs` and `stackModal` from Flarum 2.0, beginning deprecation from now. * feat: use stackable modal for forgot password modal above sign in * chore: explicitly check if modal is open before trying to focus trap * fix(a11y): add missing `aria-hidden` on main content when modal open * fix(a11y): add missing `aria-modal` on modal * chore: remove test code * chore: remove dead CSS * chore: remove overload * fix: lock page scrolling when modal is open * fix: strange scrolling behaviour * chore: convert to JSX * fix: event listener memory leak * chore: remove unneeded optional chaining * fix: incorrect return types * chore: rewrite backdrop system - use one backdrop for all modals * docs: typos in comment block * fix: show backdrop directly below top-most modal Co-authored-by: Sami Mazouz * chore: format * fix: use an invisible backdrop for each modal to exit Signed-off-by: Sami Mazouz * chore: remove debugging code Signed-off-by: Sami Mazouz * chore: remove forgotten debug code Co-authored-by: David Wheatley Co-authored-by: Sami Mazouz --- framework/core/js/package.json | 2 + framework/core/js/src/common/Application.tsx | 4 +- .../core/js/src/common/components/Modal.tsx | 71 +++++- .../js/src/common/components/ModalManager.tsx | 217 ++++++++++++++---- framework/core/js/src/common/index.ts | 1 - .../js/src/common/states/ModalManagerState.ts | 88 +++++-- framework/core/less/common/Modal.less | 102 ++++---- yarn.lock | 10 + 8 files changed, 357 insertions(+), 138 deletions(-) diff --git a/framework/core/js/package.json b/framework/core/js/package.json index 259d242ea..1a7a565c7 100644 --- a/framework/core/js/package.json +++ b/framework/core/js/package.json @@ -6,6 +6,7 @@ "dependencies": { "@askvortsov/rich-icu-message-formatter": "^0.2.4", "@ultraq/icu-message-formatter": "^0.12.0", + "body-scroll-lock": "^4.0.0-beta.0", "bootstrap": "^3.4.1", "clsx": "^1.1.1", "color-thief-browser": "^2.0.2", @@ -21,6 +22,7 @@ }, "devDependencies": { "@flarum/prettier-config": "^1.0.0", + "@types/body-scroll-lock": "^3.1.0", "@types/jquery": "^3.5.10", "@types/mithril": "^2.0.8", "@types/punycode": "^2.1.0", diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index 29c6ea80d..5db7fa84e 100644 --- a/framework/core/js/src/common/Application.tsx +++ b/framework/core/js/src/common/Application.tsx @@ -319,8 +319,8 @@ export default class Application { protected mount(basePath: string = '') { // An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html - m.mount(document.getElementById('modal')!, { view: () => ModalManager.component({ state: this.modal }) }); - m.mount(document.getElementById('alerts')!, { view: () => AlertManager.component({ state: this.alerts }) }); + m.mount(document.getElementById('modal')!, { view: () => }); + m.mount(document.getElementById('alerts')!, { view: () => }); this.drawer = new Drawer(); diff --git a/framework/core/js/src/common/components/Modal.tsx b/framework/core/js/src/common/components/Modal.tsx index b8656860d..4cde799b0 100644 --- a/framework/core/js/src/common/components/Modal.tsx +++ b/framework/core/js/src/common/components/Modal.tsx @@ -8,6 +8,7 @@ import type ModalManagerState from '../states/ModalManagerState'; import type RequestError from '../utils/RequestError'; import type ModalManager from './ModalManager'; import fireDebugWarning from '../helpers/fireDebugWarning'; +import classList from '../utils/classList'; export interface IInternalModalAttrs { state: ModalManagerState; @@ -15,16 +16,63 @@ export interface IInternalModalAttrs { animateHide: ModalManager['animateHide']; } +export interface IDismissibleOptions { + /** + * @deprecated Check specific individual attributes instead. Will be removed in Flarum 2.0. + */ + isDismissible: boolean; + viaCloseButton: boolean; + viaEscKey: boolean; + viaBackdropClick: boolean; +} + /** * The `Modal` component displays a modal dialog, wrapped in a form. Subclasses * should implement the `className`, `title`, and `content` methods. */ export default abstract class Modal extends Component { + // TODO: [Flarum 2.0] remove `isDismissible` static attribute /** * Determine whether or not the modal should be dismissible via an 'x' button. + * + * @deprecated Use the individual `isDismissibleVia...` attributes instead and remove references to this. */ static readonly isDismissible: boolean = true; + /** + * Can the model be dismissed with a close button (X)? + * + * If `false`, no close button is shown. + */ + protected static readonly isDismissibleViaCloseButton: boolean = true; + /** + * Can the modal be dismissed by pressing the Esc key on a keyboard? + */ + protected static readonly isDismissibleViaEscKey: boolean = true; + /** + * Can the modal be dismissed via a click on the backdrop. + */ + protected static readonly isDismissibleViaBackdropClick: boolean = true; + + static get dismissibleOptions(): IDismissibleOptions { + // If someone sets this to `false`, provide the same behaviour as previous versions of Flarum. + if (!this.isDismissible) { + return { + isDismissible: false, + viaCloseButton: false, + viaEscKey: false, + viaBackdropClick: false, + }; + } + + return { + isDismissible: true, + viaCloseButton: this.isDismissibleViaCloseButton, + viaEscKey: this.isDismissibleViaEscKey, + viaBackdropClick: this.isDismissibleViaBackdropClick, + }; + } + protected loading: boolean = false; /** @@ -70,7 +118,6 @@ export default abstract class Modal +
- {(this.constructor as typeof Modal).isDismissible && ( + {this.dismissibleOptions.viaCloseButton && (
- {Button.component({ - icon: 'fas fa-times', - onclick: () => this.hide(), - className: 'Button Button--icon Button--link', - 'aria-label': app.translator.trans('core.lib.modal.close'), - })} +
)} @@ -149,7 +196,7 @@ export default abstract class Modal { + // Current focus trap protected focusTrap: FocusTrap | undefined; - /** - * Whether a modal is currently shown by this modal manager. - */ - protected modalShown: boolean = false; + // Keep track of the last set focus trap + protected lastSetFocusTrap: number | undefined; + + // Keep track if there's an modal closing + protected modalClosing: boolean = false; + + protected keyUpListener: null | ((e: KeyboardEvent) => void) = null; view(vnode: Mithril.VnodeDOM): Mithril.Children { - const modal = this.attrs.state.modal; - const Tag = modal?.componentClass; - return ( -
- {!!Tag && ( - + {this.attrs.state.modalList.map((modal, i) => { + const Tag = modal?.componentClass; + + return ( +