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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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