mirror of
https://github.com/flarum/core.git
synced 2025-07-23 09:41:26 +02:00
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 <sychocouldy@gmail.com> * chore: format * fix: use an invisible backdrop for each modal to exit Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com> * chore: remove debugging code Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com> * chore: remove forgotten debug code Co-authored-by: David Wheatley <david@davwheat.dev> Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
This commit is contained in:
@@ -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",
|
||||
|
@@ -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: () => <ModalManager state={this.modal} /> });
|
||||
m.mount(document.getElementById('alerts')!, { view: () => <AlertManager state={this.alerts} /> });
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
|
@@ -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<ModalAttrs extends IInternalModalAttrs = IInternalModalAttrs> extends Component<ModalAttrs> {
|
||||
// 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<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
// we've just opened up a new one, and accordingly,
|
||||
// we don't need to show a hide animation.
|
||||
if (!this.attrs.state.modal) {
|
||||
this.attrs.animateHide();
|
||||
// Here, we ensure that the animation has time to complete.
|
||||
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
||||
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
@@ -87,16 +134,16 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'Modal modal-dialog ' + this.className()}>
|
||||
<div className={classList('Modal modal-dialog fade', this.className())}>
|
||||
<div className="Modal-content">
|
||||
{(this.constructor as typeof Modal).isDismissible && (
|
||||
{this.dismissibleOptions.viaCloseButton && (
|
||||
<div className="Modal-close App-backControl">
|
||||
{Button.component({
|
||||
icon: 'fas fa-times',
|
||||
onclick: () => this.hide(),
|
||||
className: 'Button Button--icon Button--link',
|
||||
'aria-label': app.translator.trans('core.lib.modal.close'),
|
||||
})}
|
||||
<Button
|
||||
icon="fas fa-times"
|
||||
onclick={() => this.hide()}
|
||||
className="Button Button--icon Button--link"
|
||||
aria-label={app.translator.trans('core.lib.modal.close')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -149,7 +196,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
* Hides the modal.
|
||||
*/
|
||||
hide(): void {
|
||||
this.attrs.state.close();
|
||||
this.attrs.animateHide();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,4 +222,8 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
this.onready();
|
||||
}
|
||||
}
|
||||
|
||||
private get dismissibleOptions(): IDismissibleOptions {
|
||||
return (this.constructor as typeof Modal).dismissibleOptions;
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,8 @@ import Component from '../Component';
|
||||
|
||||
import { createFocusTrap, FocusTrap } from '../utils/focusTrap';
|
||||
|
||||
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
|
||||
|
||||
import type ModalManagerState from '../states/ModalManagerState';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
@@ -10,46 +12,81 @@ interface IModalManagerAttrs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
||||
* can be shown at once; loading a new component into the ModalManager will
|
||||
* overwrite the previous one.
|
||||
* The `ModalManager` component manages one or more modal dialogs. Stacking modals
|
||||
* is supported. Multiple dialogs can be shown at once; loading a new component
|
||||
* into the ModalManager will overwrite the previous one.
|
||||
*/
|
||||
export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||
// 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<IModalManagerAttrs, this>): Mithril.Children {
|
||||
const modal = this.attrs.state.modal;
|
||||
const Tag = modal?.componentClass;
|
||||
|
||||
return (
|
||||
<div className="ModalManager modal fade">
|
||||
{!!Tag && (
|
||||
<Tag
|
||||
key={modal?.key}
|
||||
{...modal.attrs}
|
||||
animateShow={this.animateShow.bind(this)}
|
||||
animateHide={this.animateHide.bind(this)}
|
||||
state={this.attrs.state}
|
||||
<>
|
||||
{this.attrs.state.modalList.map((modal, i) => {
|
||||
const Tag = modal?.componentClass;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modal.key}
|
||||
class="ModalManager modal"
|
||||
data-modal-key={modal.key}
|
||||
data-modal-number={i}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ '--modal-number': i }}
|
||||
aria-hidden={this.attrs.state.modal !== modal && 'true'}
|
||||
>
|
||||
{!!Tag && [
|
||||
<Tag
|
||||
key={modal.key}
|
||||
{...modal.attrs}
|
||||
animateShow={this.animateShow.bind(this)}
|
||||
animateHide={this.animateHide.bind(this)}
|
||||
state={this.attrs.state}
|
||||
/>,
|
||||
/* This backdrop is invisible and used for outside clicks to close the modal. */
|
||||
<div
|
||||
key={modal.key}
|
||||
className="ModalManager-invisibleBackdrop"
|
||||
onclick={this.handlePossibleBackdropClick.bind(this)} />
|
||||
]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{this.attrs.state.backdropShown && (
|
||||
<div
|
||||
class="Modal-backdrop backdrop"
|
||||
ontransitionend={this.onBackdropTransitionEnd.bind(this)}
|
||||
data-showing={!!this.attrs.state.modalList.length}
|
||||
style={{ '--modal-count': this.attrs.state.modalList.length }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Ensure the modal state is notified about a closed modal, even when the
|
||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||
// e.g. via ESC key or a click on the modal backdrop.
|
||||
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
|
||||
this.keyUpListener = this.handleEscPress.bind(this);
|
||||
document.body.addEventListener('keyup', this.keyUpListener);
|
||||
}
|
||||
|
||||
this.focusTrap = createFocusTrap(this.element as HTMLElement, { allowOutsideClick: true });
|
||||
onbeforeremove(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
||||
super.onbeforeremove(vnode);
|
||||
|
||||
this.keyUpListener && document.body.removeEventListener('keyup', this.keyUpListener);
|
||||
this.keyUpListener = null;
|
||||
}
|
||||
|
||||
onupdate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
||||
@@ -57,43 +94,123 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (this.modalShown) this.focusTrap!.activate?.();
|
||||
else this.focusTrap!.deactivate?.();
|
||||
// Main content should gain or lose `aria-hidden` when modals are shown/removed
|
||||
// See: http://web-accessibility.carnegiemuseums.org/code/dialogs/
|
||||
|
||||
if (!this.attrs.state.isModalOpen()) {
|
||||
document.getElementById('app')?.setAttribute('aria-hidden', 'false');
|
||||
this.focusTrap!.deactivate?.();
|
||||
clearAllBodyScrollLocks();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('app')?.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Get current dialog key
|
||||
const dialogKey = this.attrs.state.modal!.key;
|
||||
|
||||
// Deactivate focus trap if there's a new dialog/closed
|
||||
if (this.focusTrap && this.lastSetFocusTrap !== dialogKey) {
|
||||
this.focusTrap!.deactivate?.();
|
||||
|
||||
clearAllBodyScrollLocks();
|
||||
}
|
||||
|
||||
// Activate focus trap if there's a new dialog which is not trapped yet
|
||||
if (this.activeDialogElement && this.lastSetFocusTrap !== dialogKey) {
|
||||
this.focusTrap = createFocusTrap(this.activeDialogElement as HTMLElement, { allowOutsideClick: true });
|
||||
this.focusTrap!.activate?.();
|
||||
|
||||
disableBodyScroll(this.activeDialogManagerElement!, { reserveScrollBarGap: true });
|
||||
}
|
||||
|
||||
// Update key of current opened modal
|
||||
this.lastSetFocusTrap = dialogKey;
|
||||
} catch {
|
||||
// We can expect errors to occur here due to the nature of mithril rendering
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
animateShow(readyCallback: () => void): void {
|
||||
/**
|
||||
* Get current active dialog
|
||||
*/
|
||||
private get activeDialogElement(): HTMLElement {
|
||||
return document.body.querySelector(`.ModalManager[data-modal-key="${this.attrs.state.modal?.key}"] .Modal`) as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active dialog
|
||||
*/
|
||||
private get activeDialogManagerElement(): HTMLElement {
|
||||
return document.body.querySelector(`.ModalManager[data-modal-key="${this.attrs.state.modal?.key}"]`) as HTMLElement;
|
||||
}
|
||||
|
||||
animateShow(readyCallback: () => void = () => {}): void {
|
||||
if (!this.attrs.state.modal) return;
|
||||
|
||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
||||
this.activeDialogElement.addEventListener(
|
||||
'transitionend',
|
||||
() => {
|
||||
readyCallback();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
this.modalShown = true;
|
||||
|
||||
// If we are opening this modal while another modal is already open,
|
||||
// the shown event will not run, because the modal is already open.
|
||||
// So, we need to manually trigger the readyCallback.
|
||||
if (this.$().hasClass('in')) {
|
||||
readyCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$()
|
||||
.one('shown.bs.modal', readyCallback)
|
||||
// @ts-expect-error: No typings available for Bootstrap modals.
|
||||
.modal({
|
||||
backdrop: dismissible || 'static',
|
||||
keyboard: dismissible,
|
||||
})
|
||||
.modal('show');
|
||||
requestAnimationFrame(() => {
|
||||
this.activeDialogElement.classList.add('in');
|
||||
});
|
||||
}
|
||||
|
||||
animateHide(): void {
|
||||
// @ts-expect-error: No typings available for Bootstrap modals.
|
||||
this.$().modal('hide');
|
||||
animateHide(closedCallback: () => void = () => {}): void {
|
||||
if (this.modalClosing) return;
|
||||
this.modalClosing = true;
|
||||
|
||||
this.modalShown = false;
|
||||
const afterModalClosedCallback = () => {
|
||||
this.modalClosing = false;
|
||||
|
||||
// Close the dialog
|
||||
this.attrs.state.close();
|
||||
|
||||
closedCallback();
|
||||
};
|
||||
|
||||
this.activeDialogElement.addEventListener('transitionend', afterModalClosedCallback, { once: true });
|
||||
|
||||
this.activeDialogElement.classList.remove('in');
|
||||
this.activeDialogElement.classList.add('out');
|
||||
}
|
||||
|
||||
protected handleEscPress(e: KeyboardEvent): void {
|
||||
if (!this.attrs.state.modal) return;
|
||||
|
||||
const dismissibleState = this.attrs.state.modal.componentClass.dismissibleOptions;
|
||||
|
||||
// Close the dialog if the escape key was pressed
|
||||
// Check if closing via escape key is enabled
|
||||
if (e.key === 'Escape' && dismissibleState.viaEscKey) {
|
||||
e.preventDefault();
|
||||
|
||||
this.animateHide();
|
||||
}
|
||||
}
|
||||
|
||||
protected handlePossibleBackdropClick(e: MouseEvent): void {
|
||||
if (!this.attrs.state.modal || !this.attrs.state.modal.componentClass.dismissibleOptions.viaBackdropClick) return;
|
||||
|
||||
this.animateHide();
|
||||
}
|
||||
|
||||
protected onBackdropTransitionEnd(e: TransitionEvent) {
|
||||
if (e.propertyName === 'opacity') {
|
||||
const backdrop = e.currentTarget as HTMLDivElement;
|
||||
|
||||
if (backdrop.getAttribute('data-showing') === null) {
|
||||
// Backdrop is fading out
|
||||
this.attrs.state.backdropShown = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@ import 'expose-loader?exposes=dayjs!dayjs';
|
||||
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
import 'bootstrap/js/modal';
|
||||
import 'bootstrap/js/tooltip';
|
||||
import 'bootstrap/js/transition';
|
||||
import 'jquery.hotkeys/jquery.hotkeys';
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import type Component from '../Component';
|
||||
import Modal from '../components/Modal';
|
||||
import Modal, { IDismissibleOptions } from '../components/Modal';
|
||||
|
||||
/**
|
||||
* Ideally, `show` would take a higher-kinded generic, ala:
|
||||
@@ -8,7 +8,13 @@ import Modal from '../components/Modal';
|
||||
* https://github.com/Microsoft/TypeScript/issues/1213
|
||||
* Therefore, we have to use this ugly, messy workaround.
|
||||
*/
|
||||
type UnsafeModalClass = ComponentClass<any, Modal> & { isDismissible: boolean; component: typeof Component.component };
|
||||
type UnsafeModalClass = ComponentClass<any, Modal> & { get dismissibleOptions(): IDismissibleOptions; component: typeof Component.component };
|
||||
|
||||
type ModalItem = {
|
||||
componentClass: UnsafeModalClass;
|
||||
attrs?: Record<string, unknown>;
|
||||
key: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Class used to manage modal state.
|
||||
@@ -19,11 +25,17 @@ export default class ModalManagerState {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
modal: null | {
|
||||
componentClass: UnsafeModalClass;
|
||||
attrs?: Record<string, unknown>;
|
||||
key: number;
|
||||
} = null;
|
||||
modal: ModalItem | null = null;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
modalList: ModalItem[] = [];
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
backdropShown: boolean = false;
|
||||
|
||||
/**
|
||||
* Used to force re-initialization of modals if a modal
|
||||
@@ -31,12 +43,13 @@ export default class ModalManagerState {
|
||||
*/
|
||||
private key = 0;
|
||||
|
||||
private closeTimeout?: NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Shows a modal dialog.
|
||||
*
|
||||
* If a modal is already open, the existing one will close and the new modal will replace it.
|
||||
* If `stackModal` is `true`, the modal will be shown on top of the current modal.
|
||||
*
|
||||
* If a value for `stackModal` is not provided, opening a new modal will close
|
||||
* any others currently being shown for backwards compatibility.
|
||||
*
|
||||
* @example <caption>Show a modal</caption>
|
||||
* app.modal.show(MyCoolModal, { attr: 'value' });
|
||||
@@ -44,8 +57,11 @@ export default class ModalManagerState {
|
||||
* @example <caption>Show a modal from a lifecycle method (`oncreate`, `view`, etc.)</caption>
|
||||
* // This "hack" is needed due to quirks with nested redraws in Mithril.
|
||||
* setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0);
|
||||
*
|
||||
* @example <caption>Stacking modals</caption>
|
||||
* app.modal.show(MyCoolStackedModal, { attr: 'value' }, true);
|
||||
*/
|
||||
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}): void {
|
||||
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): void {
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
const invalidModalWarning = 'The ModalManager can only show Modals.';
|
||||
@@ -53,28 +69,52 @@ export default class ModalManagerState {
|
||||
throw new Error(invalidModalWarning);
|
||||
}
|
||||
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
|
||||
this.modal = { componentClass, attrs, key: this.key++ };
|
||||
|
||||
this.backdropShown = true;
|
||||
m.redraw.sync();
|
||||
|
||||
// We use requestAnimationFrame here, since we need to wait for the backdrop to be added
|
||||
// to the DOM before actually adding the modal to the modal list.
|
||||
//
|
||||
// This is because we use RAF inside the ModalManager onupdate lifecycle hook, and if we
|
||||
// skip this RAF call, the hook will attempt to add a focus trap as well as lock scroll
|
||||
// onto the newly added modal before it's in the DOM, creating an extra scrollbar.
|
||||
requestAnimationFrame(() => {
|
||||
// Set current modal
|
||||
this.modal = { componentClass, attrs, key: this.key++ };
|
||||
|
||||
// We want to stack this modal
|
||||
if (stackModal) {
|
||||
// Remember previously opened modal and add new modal to the modal list
|
||||
this.modalList.push(this.modal);
|
||||
} else {
|
||||
// Override last modals
|
||||
this.modalList = [this.modal];
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the currently open dialog, if one is open.
|
||||
* Closes the topmost currently open dialog, if one is open.
|
||||
*/
|
||||
close(): void {
|
||||
if (!this.modal) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
// If there are two modals, remove the most recent one
|
||||
if (this.modalList.length > 1) {
|
||||
// Remove last modal from list
|
||||
this.modalList.pop();
|
||||
|
||||
// Open last modal from list
|
||||
this.modal = this.modalList[this.modalList.length - 1];
|
||||
} else {
|
||||
// Reset state
|
||||
this.modal = null;
|
||||
m.redraw();
|
||||
});
|
||||
this.modalList = [];
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,60 +1,58 @@
|
||||
// ------------------------------------
|
||||
// Modals
|
||||
|
||||
// Kill the scroll on the body
|
||||
.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
.Modal {
|
||||
padding: 0;
|
||||
border-radius: @border-radius;
|
||||
|
||||
// Modal background
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: var(--zindex-modal-background);
|
||||
background-color: var(--overlay-bg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
transform: scale(0.9);
|
||||
transition: transform 0.2s ease-out, opacity 0.2s ease-out, top 0.2s ease-out;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
|
||||
width: auto;
|
||||
margin: 10px;
|
||||
max-width: 600px;
|
||||
|
||||
&.in {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.Modal-backdrop {
|
||||
background: var(--overlay-bg);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
z-index: ~"calc(var(--zindex-modal) + var(--modal-count) - 2)";
|
||||
|
||||
&[data-showing] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Container that the modal scrolls within
|
||||
.ModalManager {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: var(--zindex-modal);
|
||||
z-index: ~"calc(var(--zindex-modal) + var(--modal-number))";
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// When fading in the modal, animate it to slide down
|
||||
.Modal {
|
||||
transform: scale(0.9);
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
&.in .Modal {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.modal-open .ModalManager {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Shell div to position the modal with bottom padding
|
||||
.Modal {
|
||||
position: relative;
|
||||
width: auto;
|
||||
margin: 10px;
|
||||
max-width: 600px;
|
||||
&-invisibleBackdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Actual modal
|
||||
@@ -129,9 +127,6 @@
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.ModalManager.fade {
|
||||
opacity: 1;
|
||||
}
|
||||
.ModalManager {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -139,24 +134,28 @@
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
overflow: auto;
|
||||
transition: transform 0.2s ease-out;
|
||||
transform: translate(0, 100vh);
|
||||
|
||||
&.in {
|
||||
-webkit-transform: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
&:before {
|
||||
content: " ";
|
||||
.header-background();
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
.Modal {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
-webkit-transform: none !important;
|
||||
transform: none !important;
|
||||
transform: none !important;
|
||||
top: 100vh;
|
||||
|
||||
&.fade {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.in {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
.Modal-content {
|
||||
border-radius: 0;
|
||||
@@ -174,19 +173,20 @@
|
||||
|
||||
@media @tablet-up {
|
||||
.Modal {
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 7px 15px var(--shadow-color);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 120px auto;
|
||||
}
|
||||
.Modal-close {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
.Modal-content {
|
||||
|
||||
border: 0;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 7px 15px var(--shadow-color);
|
||||
}
|
||||
.Modal--small {
|
||||
max-width: 375px;
|
||||
|
10
yarn.lock
10
yarn.lock
@@ -1112,6 +1112,11 @@
|
||||
prop-types "^15.7.2"
|
||||
react-is "^16.6.3"
|
||||
|
||||
"@types/body-scroll-lock@^3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/body-scroll-lock/-/body-scroll-lock-3.1.0.tgz#435f6abf682bf58640e1c2ee5978320b891970e7"
|
||||
integrity sha512-3owAC4iJub5WPqRhxd8INarF2bWeQq1yQHBgYhN0XLBJMpd5ED10RrJ3aKiAwlTyL5wK7RkBD4SZUQz2AAAMdA==
|
||||
|
||||
"@types/eslint-scope@^3.7.3":
|
||||
version "3.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
|
||||
@@ -1488,6 +1493,11 @@ big.js@^5.2.2:
|
||||
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
||||
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
||||
|
||||
body-scroll-lock@^4.0.0-beta.0:
|
||||
version "4.0.0-beta.0"
|
||||
resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-4.0.0-beta.0.tgz#4f78789d10e6388115c0460cd6d7d4dd2bbc4f7e"
|
||||
integrity sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==
|
||||
|
||||
bootstrap@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.4.1.tgz#c3a347d419e289ad11f4033e3c4132b87c081d72"
|
||||
|
Reference in New Issue
Block a user