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": {
|
"dependencies": {
|
||||||
"@askvortsov/rich-icu-message-formatter": "^0.2.4",
|
"@askvortsov/rich-icu-message-formatter": "^0.2.4",
|
||||||
"@ultraq/icu-message-formatter": "^0.12.0",
|
"@ultraq/icu-message-formatter": "^0.12.0",
|
||||||
|
"body-scroll-lock": "^4.0.0-beta.0",
|
||||||
"bootstrap": "^3.4.1",
|
"bootstrap": "^3.4.1",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
"color-thief-browser": "^2.0.2",
|
"color-thief-browser": "^2.0.2",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@flarum/prettier-config": "^1.0.0",
|
"@flarum/prettier-config": "^1.0.0",
|
||||||
|
"@types/body-scroll-lock": "^3.1.0",
|
||||||
"@types/jquery": "^3.5.10",
|
"@types/jquery": "^3.5.10",
|
||||||
"@types/mithril": "^2.0.8",
|
"@types/mithril": "^2.0.8",
|
||||||
"@types/punycode": "^2.1.0",
|
"@types/punycode": "^2.1.0",
|
||||||
|
@@ -319,8 +319,8 @@ export default class Application {
|
|||||||
|
|
||||||
protected mount(basePath: string = '') {
|
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
|
// 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('modal')!, { view: () => <ModalManager state={this.modal} /> });
|
||||||
m.mount(document.getElementById('alerts')!, { view: () => AlertManager.component({ state: this.alerts }) });
|
m.mount(document.getElementById('alerts')!, { view: () => <AlertManager state={this.alerts} /> });
|
||||||
|
|
||||||
this.drawer = new Drawer();
|
this.drawer = new Drawer();
|
||||||
|
|
||||||
|
@@ -8,6 +8,7 @@ import type ModalManagerState from '../states/ModalManagerState';
|
|||||||
import type RequestError from '../utils/RequestError';
|
import type RequestError from '../utils/RequestError';
|
||||||
import type ModalManager from './ModalManager';
|
import type ModalManager from './ModalManager';
|
||||||
import fireDebugWarning from '../helpers/fireDebugWarning';
|
import fireDebugWarning from '../helpers/fireDebugWarning';
|
||||||
|
import classList from '../utils/classList';
|
||||||
|
|
||||||
export interface IInternalModalAttrs {
|
export interface IInternalModalAttrs {
|
||||||
state: ModalManagerState;
|
state: ModalManagerState;
|
||||||
@@ -15,16 +16,63 @@ export interface IInternalModalAttrs {
|
|||||||
animateHide: ModalManager['animateHide'];
|
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
|
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
|
||||||
* should implement the `className`, `title`, and `content` methods.
|
* should implement the `className`, `title`, and `content` methods.
|
||||||
*/
|
*/
|
||||||
export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IInternalModalAttrs> extends Component<ModalAttrs> {
|
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.
|
* 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;
|
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;
|
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've just opened up a new one, and accordingly,
|
||||||
// we don't need to show a hide animation.
|
// we don't need to show a hide animation.
|
||||||
if (!this.attrs.state.modal) {
|
if (!this.attrs.state.modal) {
|
||||||
this.attrs.animateHide();
|
|
||||||
// Here, we ensure that the animation has time to complete.
|
// Here, we ensure that the animation has time to complete.
|
||||||
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
||||||
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||||
@@ -87,16 +134,16 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'Modal modal-dialog ' + this.className()}>
|
<div className={classList('Modal modal-dialog fade', this.className())}>
|
||||||
<div className="Modal-content">
|
<div className="Modal-content">
|
||||||
{(this.constructor as typeof Modal).isDismissible && (
|
{this.dismissibleOptions.viaCloseButton && (
|
||||||
<div className="Modal-close App-backControl">
|
<div className="Modal-close App-backControl">
|
||||||
{Button.component({
|
<Button
|
||||||
icon: 'fas fa-times',
|
icon="fas fa-times"
|
||||||
onclick: () => this.hide(),
|
onclick={() => this.hide()}
|
||||||
className: 'Button Button--icon Button--link',
|
className="Button Button--icon Button--link"
|
||||||
'aria-label': app.translator.trans('core.lib.modal.close'),
|
aria-label={app.translator.trans('core.lib.modal.close')}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -149,7 +196,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
|||||||
* Hides the modal.
|
* Hides the modal.
|
||||||
*/
|
*/
|
||||||
hide(): void {
|
hide(): void {
|
||||||
this.attrs.state.close();
|
this.attrs.animateHide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,4 +222,8 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
|||||||
this.onready();
|
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 { createFocusTrap, FocusTrap } from '../utils/focusTrap';
|
||||||
|
|
||||||
|
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
|
||||||
|
|
||||||
import type ModalManagerState from '../states/ModalManagerState';
|
import type ModalManagerState from '../states/ModalManagerState';
|
||||||
import type Mithril from 'mithril';
|
import type Mithril from 'mithril';
|
||||||
|
|
||||||
@@ -10,46 +12,81 @@ interface IModalManagerAttrs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
* The `ModalManager` component manages one or more modal dialogs. Stacking modals
|
||||||
* can be shown at once; loading a new component into the ModalManager will
|
* is supported. Multiple dialogs can be shown at once; loading a new component
|
||||||
* overwrite the previous one.
|
* into the ModalManager will overwrite the previous one.
|
||||||
*/
|
*/
|
||||||
export default class ModalManager extends Component<IModalManagerAttrs> {
|
export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||||
|
// Current focus trap
|
||||||
protected focusTrap: FocusTrap | undefined;
|
protected focusTrap: FocusTrap | undefined;
|
||||||
|
|
||||||
/**
|
// Keep track of the last set focus trap
|
||||||
* Whether a modal is currently shown by this modal manager.
|
protected lastSetFocusTrap: number | undefined;
|
||||||
*/
|
|
||||||
protected modalShown: boolean = false;
|
// 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 {
|
view(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): Mithril.Children {
|
||||||
const modal = this.attrs.state.modal;
|
|
||||||
const Tag = modal?.componentClass;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ModalManager modal fade">
|
<>
|
||||||
{!!Tag && (
|
{this.attrs.state.modalList.map((modal, i) => {
|
||||||
<Tag
|
const Tag = modal?.componentClass;
|
||||||
key={modal?.key}
|
|
||||||
{...modal.attrs}
|
return (
|
||||||
animateShow={this.animateShow.bind(this)}
|
<div
|
||||||
animateHide={this.animateHide.bind(this)}
|
key={modal.key}
|
||||||
state={this.attrs.state}
|
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 {
|
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
||||||
super.oncreate(vnode);
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// Ensure the modal state is notified about a closed modal, even when the
|
this.keyUpListener = this.handleEscPress.bind(this);
|
||||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
document.body.addEventListener('keyup', this.keyUpListener);
|
||||||
// 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.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 {
|
onupdate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
||||||
@@ -57,43 +94,123 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
|||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
if (this.modalShown) this.focusTrap!.activate?.();
|
// Main content should gain or lose `aria-hidden` when modals are shown/removed
|
||||||
else this.focusTrap!.deactivate?.();
|
// 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 {
|
} catch {
|
||||||
// We can expect errors to occur here due to the nature of mithril rendering
|
// 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;
|
if (!this.attrs.state.modal) return;
|
||||||
|
|
||||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
this.activeDialogElement.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
() => {
|
||||||
|
readyCallback();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
this.modalShown = true;
|
requestAnimationFrame(() => {
|
||||||
|
this.activeDialogElement.classList.add('in');
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
animateHide(): void {
|
animateHide(closedCallback: () => void = () => {}): void {
|
||||||
// @ts-expect-error: No typings available for Bootstrap modals.
|
if (this.modalClosing) return;
|
||||||
this.$().modal('hide');
|
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/affix';
|
||||||
import 'bootstrap/js/dropdown';
|
import 'bootstrap/js/dropdown';
|
||||||
import 'bootstrap/js/modal';
|
|
||||||
import 'bootstrap/js/tooltip';
|
import 'bootstrap/js/tooltip';
|
||||||
import 'bootstrap/js/transition';
|
import 'bootstrap/js/transition';
|
||||||
import 'jquery.hotkeys/jquery.hotkeys';
|
import 'jquery.hotkeys/jquery.hotkeys';
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import type Component from '../Component';
|
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:
|
* 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
|
* https://github.com/Microsoft/TypeScript/issues/1213
|
||||||
* Therefore, we have to use this ugly, messy workaround.
|
* 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.
|
* Class used to manage modal state.
|
||||||
@@ -19,11 +25,17 @@ export default class ModalManagerState {
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
modal: null | {
|
modal: ModalItem | null = null;
|
||||||
componentClass: UnsafeModalClass;
|
|
||||||
attrs?: Record<string, unknown>;
|
/**
|
||||||
key: number;
|
* @internal
|
||||||
} = null;
|
*/
|
||||||
|
modalList: ModalItem[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
backdropShown: boolean = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to force re-initialization of modals if a modal
|
* Used to force re-initialization of modals if a modal
|
||||||
@@ -31,12 +43,13 @@ export default class ModalManagerState {
|
|||||||
*/
|
*/
|
||||||
private key = 0;
|
private key = 0;
|
||||||
|
|
||||||
private closeTimeout?: NodeJS.Timeout;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a modal dialog.
|
* 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>
|
* @example <caption>Show a modal</caption>
|
||||||
* app.modal.show(MyCoolModal, { attr: 'value' });
|
* 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>
|
* @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.
|
* // This "hack" is needed due to quirks with nested redraws in Mithril.
|
||||||
* setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0);
|
* 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)) {
|
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.
|
// 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.';
|
const invalidModalWarning = 'The ModalManager can only show Modals.';
|
||||||
@@ -53,28 +69,52 @@ export default class ModalManagerState {
|
|||||||
throw new Error(invalidModalWarning);
|
throw new Error(invalidModalWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
this.backdropShown = true;
|
||||||
|
|
||||||
this.modal = { componentClass, attrs, key: this.key++ };
|
|
||||||
|
|
||||||
m.redraw.sync();
|
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 {
|
close(): void {
|
||||||
if (!this.modal) return;
|
if (!this.modal) return;
|
||||||
|
|
||||||
// Don't hide the modal immediately, because if the consumer happens to call
|
// If there are two modals, remove the most recent one
|
||||||
// the `show` method straight after to show another modal dialog, it will
|
if (this.modalList.length > 1) {
|
||||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
// Remove last modal from list
|
||||||
// bit to give the `show` method the opportunity to prevent this from going
|
this.modalList.pop();
|
||||||
// ahead.
|
|
||||||
this.closeTimeout = setTimeout(() => {
|
// Open last modal from list
|
||||||
|
this.modal = this.modalList[this.modalList.length - 1];
|
||||||
|
} else {
|
||||||
|
// Reset state
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
m.redraw();
|
this.modalList = [];
|
||||||
});
|
}
|
||||||
|
|
||||||
|
m.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,60 +1,58 @@
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Modals
|
// Modals
|
||||||
|
|
||||||
// Kill the scroll on the body
|
.Modal {
|
||||||
.modal-open {
|
padding: 0;
|
||||||
overflow: hidden;
|
border-radius: @border-radius;
|
||||||
}
|
|
||||||
|
|
||||||
// Modal background
|
transform: scale(0.9);
|
||||||
.modal-backdrop {
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out, top 0.2s ease-out;
|
||||||
position: fixed;
|
z-index: 2;
|
||||||
top: 0;
|
position: relative;
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
width: auto;
|
||||||
left: 0;
|
margin: 10px;
|
||||||
z-index: var(--zindex-modal-background);
|
max-width: 600px;
|
||||||
background-color: var(--overlay-bg);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
&.in {
|
&.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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container that the modal scrolls within
|
|
||||||
.ModalManager {
|
.ModalManager {
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: var(--zindex-modal);
|
z-index: ~"calc(var(--zindex-modal) + var(--modal-number))";
|
||||||
-webkit-overflow-scrolling: touch;
|
-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-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
|
||||||
|
|
||||||
// Shell div to position the modal with bottom padding
|
&-invisibleBackdrop {
|
||||||
.Modal {
|
position: absolute;
|
||||||
position: relative;
|
top: 0;
|
||||||
width: auto;
|
right: 0;
|
||||||
margin: 10px;
|
bottom: 0;
|
||||||
max-width: 600px;
|
left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actual modal
|
// Actual modal
|
||||||
@@ -129,9 +127,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media @phone {
|
@media @phone {
|
||||||
.ModalManager.fade {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.ModalManager {
|
.ModalManager {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -139,24 +134,28 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
transition: transform 0.2s ease-out;
|
|
||||||
transform: translate(0, 100vh);
|
|
||||||
|
|
||||||
&.in {
|
|
||||||
-webkit-transform: none !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
&:before {
|
&:before {
|
||||||
content: " ";
|
content: " ";
|
||||||
.header-background();
|
.header-background();
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.Modal {
|
.Modal {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-transform: none !important;
|
-webkit-transform: none !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
|
top: 100vh;
|
||||||
|
|
||||||
|
&.fade {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.in {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.Modal-content {
|
.Modal-content {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -174,19 +173,20 @@
|
|||||||
|
|
||||||
@media @tablet-up {
|
@media @tablet-up {
|
||||||
.Modal {
|
.Modal {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 0 7px 15px var(--shadow-color);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
margin: 120px auto;
|
margin: 120px auto;
|
||||||
}
|
}
|
||||||
.Modal-close {
|
.Modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.Modal-content {
|
.Modal-content {
|
||||||
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
box-shadow: 0 7px 15px var(--shadow-color);
|
|
||||||
}
|
}
|
||||||
.Modal--small {
|
.Modal--small {
|
||||||
max-width: 375px;
|
max-width: 375px;
|
||||||
|
10
yarn.lock
10
yarn.lock
@@ -1112,6 +1112,11 @@
|
|||||||
prop-types "^15.7.2"
|
prop-types "^15.7.2"
|
||||||
react-is "^16.6.3"
|
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":
|
"@types/eslint-scope@^3.7.3":
|
||||||
version "3.7.3"
|
version "3.7.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
|
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"
|
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
||||||
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
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:
|
bootstrap@^3.4.1:
|
||||||
version "3.4.1"
|
version "3.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.4.1.tgz#c3a347d419e289ad11f4033e3c4132b87c081d72"
|
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.4.1.tgz#c3a347d419e289ad11f4033e3c4132b87c081d72"
|
||||||
|
Reference in New Issue
Block a user