1
0
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:
Jasper Vriends
2022-08-08 14:29:14 +02:00
committed by GitHub
parent bb5f7b87bc
commit f69210b6d1
8 changed files with 357 additions and 138 deletions

View File

@@ -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",

View File

@@ -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();

View File

@@ -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;
}
} }

View File

@@ -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();
}
}
} }
} }

View File

@@ -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';

View File

@@ -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();
} }
/** /**

View File

@@ -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;

View File

@@ -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"