Reusable modal component (#50)

* README proposal.

* First pass at implementation of proposal on Footer and Header. Footer could use some work.

* Cleanup, and readme additions

* First pass at basic modal

* Modal open / close

* Modal close on esc key.

* Freeze body scrolling when modal is open.

* Use the index bestowed upon me.

* No close on shade click

* Only render children if modal is open. Also, dont show cursor on shade

* Use flexbox for simpler content height / scroll behavior.

* type modal, fix body scroll when mounted opened

* Modal width is sized by content, not hard coded sizes.

* Remove size from flow prop types.
This commit is contained in:
William O'Beirne 2017-07-15 02:26:43 -04:00 committed by Daniel Ternyak
parent 8ed09dfa06
commit ccd946a08d
5 changed files with 265 additions and 1 deletions

View File

@ -85,3 +85,8 @@
height: auto;
max-width: 100%;
}
.no-scroll {
height: 100%;
overflow: hidden;
}

View File

@ -0,0 +1,125 @@
// @flow
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import closeIcon from 'assets/images/icon-x.svg';
import './Modal.scss';
type Props = {
isOpen?: boolean,
title: string,
buttons: {
text: string,
type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger' | 'link',
onClick: () => void
}[],
handleClose: () => void,
children: any
};
export default class Modal extends Component {
props: Props;
static propTypes = {
isOpen: PropTypes.bool,
title: PropTypes.node.isRequired,
children: PropTypes.node,
buttons: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.node.isRequired,
type: PropTypes.oneOf([
'default',
'primary',
'success',
'info',
'warning',
'danger',
'link'
]),
onClick: PropTypes.func.isRequired
})
),
handleClose: PropTypes.func.isRequired
};
componentDidMount() {
this.updateBodyClass();
document.addEventListener('keydown', this._escapeListner);
}
componentDidUpdate() {
this.updateBodyClass();
}
updateBodyClass() {
// $FlowFixMe
document.body.classList.toggle('no-scroll', !!this.props.isOpen);
}
componentWillUnmount() {
document.removeEventListener('keydown', this._escapeListner);
// $FlowFixMe
document.body.classList.remove('no-scroll');
}
_escapeListner = (ev: KeyboardEvent) => {
// Don't trigger if they hit escape while on an input
if (ev.target) {
if (
ev.target.tagName === 'INPUT' ||
ev.target.tagName === 'SELECT' ||
ev.target.tagName === 'TEXTAREA' ||
ev.target.isContentEditable
) {
return;
}
}
if (ev.key === 'Escape' || ev.keyCode === 27) {
this.props.handleClose();
}
};
_renderButtons() {
return this.props.buttons.map((btn, idx) => {
let btnClass = 'Modal-footer-btn btn';
if (btn.type) {
btnClass += ` btn-${btn.type}`;
}
return (
<button className={btnClass} onClick={btn.onClick} key={idx}>
{btn.text}
</button>
);
});
}
render() {
const { isOpen, title, children, buttons, handleClose } = this.props;
const hasButtons = buttons && buttons.length;
return (
<div>
<div className={`Modalshade ${isOpen ? 'is-open' : ''}`} />
<div className={`Modal ${isOpen ? 'is-open' : ''}`}>
<div className="Modal-header">
<h2 className="Modal-header-title">
{title}
</h2>
<button className="Modal-header-close" onClick={handleClose}>
<img className="Modal-header-close-icon" src={closeIcon} />
</button>
</div>
<div className="Modal-content">
{isOpen && children}
</div>
{hasButtons &&
<div className="Modal-footer">
{this._renderButtons()}
</div>}
</div>
</div>
);
}
}

View File

@ -0,0 +1,125 @@
@import "common/sass/variables";
@import "common/sass/mixins";
$m-background: #fff;
$m-window-padding: 20px;
$m-header-padding: 15px;
$m-header-height: 62px;
$m-content-padding: 20px;
$m-footer-height: 58px;
$m-anim-speed: 400ms;
@keyframes modalshade-open {
0% {
opacity: 0;
}
70%,
100% {
opacity: 1;
}
}
@keyframes modal-open {
0%,
30% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.88);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.Modalshade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(#000, 0.82);
z-index: $zindex-modal-background;
display: none;
animation: modalshade-open $m-anim-speed ease 1;
&.is-open {
display: block;
}
}
.Modal {
position: fixed;
top: 50%;
left: 50%;
max-width: 95%;
max-width: calc(100% - #{$m-window-padding * 2});
max-height: 95%;
max-height: calc(100% - #{$m-window-padding * 2});
background: $m-background;
border-radius: 4px;
transform: translate(-50%, -50%);
z-index: $zindex-modal;
overflow: hidden;
display: none;
flex-direction: column;
animation: modal-open $m-anim-speed ease 1;
&.is-open {
display: flex;
}
&-header {
position: relative;
padding: 0 $m-header-padding;
height: $m-header-height;
border-bottom: 1px solid $gray-lighter;
background: $m-background;
&-title {
margin: 0;
font-size: $font-size-large;
line-height: $m-header-height;
}
&-close {
@include reset-button;
position: absolute;
top: 50%;
right: $m-header-padding;
height: 26px;
width: 26px;
opacity: 0.8;
transform: translateY(-50%) translateZ(0);
&:hover {
opacity: 1;
}
&-icon {
width: 100%;
}
}
}
&-content {
display: flex;
flex: 1;
flex-direction: column;
padding: $m-content-padding;
overflow: auto;
}
&-footer {
height: $m-footer-height;
padding: 7px 10px;
border-top: 1px solid $gray-lighter;
background: $m-background;
// Selector needs a little extra oomph to override bootstrap
&-btn.btn {
float: right;
margin: 0 0 0 8px;
min-width: 100px;
}
}
}

View File

@ -1,5 +1,6 @@
// @flow
export { default as Dropdown } from './Dropdown';
export { default as UnlockHeader } from './UnlockHeader';
export { default as Identicon } from './Identicon';
export { default as Modal } from './Modal';
export { default as UnlockHeader } from './UnlockHeader';

View File

@ -4,3 +4,11 @@
background: $ether-navy;
background: linear-gradient(149deg, #132a45, #143a56, #21a4ce, #19b4ad);
}
@mixin reset-button {
margin: 0;
padding: 0;
background: none;
border: none;
cursor: pointer;
}