Skip to content

Instantly share code, notes, and snippets.

@stevensacks
Last active July 16, 2022 15:08
Show Gist options
  • Save stevensacks/b2fb14ef89e33aecdb0ca0a7a8b4f11a to your computer and use it in GitHub Desktop.
Save stevensacks/b2fb14ef89e33aecdb0ca0a7a8b4f11a to your computer and use it in GitHub Desktop.
Dialog as a promise

Dialog Component

The Dialog component is a flexible component that allows you to easily create a wide variety of modals, from simple alerts and confirms, to multiple choice responses, input responses, or even something completely custom.

The Dialog component is triggered by a function (see index.js) which you pass a props object to and it returns a Promise while rendering a modal on the screen above your content based on the props you passed. Dialog.js is not meant to be modified, as every customization is handled by the props (see below).

If the user confirms the dialog, the Promise resolves and returns the results of the user's action (it can be as simple as undefined or based on a choice, etc.). If the user cancels the dialog, the Promise rejects.

The Dialog component is rendered using Bulma Modal. The dialog has a built-in style, which you can override (see below), has a close button in the top-right corner, can be canceled by clicking outside or by pressing ESCAPE, and can be submitted by pressing ENTER.

Because the Dialog Component is built to be flexible, there are many optional props that can be passed. Here's how you use each one.

Required Props

  • title - This is the only required prop and it is the title of the dialog.

Optional Props

  • icon - This is a FontAwesome icon string, such as "fas,exclamation". It appears to the left of the title.
  • cancel - If you want a cancel button, this is the string value you want the cancel button to show. You can opt to pass just a cancel prop and no submit prop if you don't care about the user's response, such as an alert, set cancel to "OK" or something along those lines and do not pass submit. When the user clicks the cancel button, the Promise rejects.
  • submit - If you want a submit button, this is the string value you want the submit button to show. When the user clicks the submit button, the Promise resolves. For a simple dialog that is a yes/no interaction, the resolve is yes and the reject is no (undefined is passed to the resolve in this case).
  • isForced - If you require the user to submit something, set this to true. The cancel button (and close button) are not displayed and the dialog will not close using any method other than the user submitting.
  • message - If you want to display a message on the dialog, set this string value. This value is rendered as HTML, so escape any entities such as < and >, etc.
  • choices - If you want to ask the user to make a choice, pass an array of objects (label, value) and they will be rendered as radio buttons below the (optional) message. Whichever value the user selects will be returned in the Promise's resolve.
  • styles - You can pass two css style names. The first is dialog and is the most common one you will use. The css class is put on the root div of the modal. The other is body and will be put on the modal-card-body tag (see Bulma Modal above).
  • type - As per the Bulma Modal usage, this can be primary, accent, info, warning, danger, or success and determines the color of the submit button, as well as being available for your css use through your own css style.
  • subtree - This is specifically and exclusively for use when you need the React context to be available, such as with Redux or React-Apollo. You always set subtree to this where this is a reference to a React.Component class that is in the main "tree" as it were, or from the React.Component calling the Dialog function.

Outside of these typical types of dialogs, you have the ability to make something custom. The Dialog component has an API for doing this, which works like this.

The custom prop is an object that has the following props:

  • View - This is required for a custom component and expects a React component. Note the capital V in View.
  • props - This object is optional and will be passed as props to the View component.
  • selected - This can be any value of any type and will be returned on submit. This can be blank, or you can use it to preselect a value in the View component, if that's how you custom Dialog works.
  • mustEqualToSubmit - This allows you to create a custom Dialog in which only one selected value will be submittable. One example is a Dialog that requires you type something (or select checkboxes/dropdown) to submit, such as submitting to do something destructive that cannot be undone (type XXX to delete something).

The View component you pass automatically receives the following props for your usage (if you need them).

  • reject - This is the reject of the Promise. It allows you to cancel the Dialog from within your custom View. One use for this is to not pass cancel or submit props to the Dialog and no buttons will be rendered so your custom View is the only thing rendered.
  • resolve - This is the resolve of the Promise. Similar to reject, it allows you to resolve the Dialog from within your custom View
  • onLoading - If your View component needs to load something asynchronously in order to render itself and you need the user to wait, this prop is a function which accepts a boolean. When you call this.props.onLoading(true), the user will not be able to cancel or submit until you call this.props.onLoading(false).
  • onSubmitDisabled - If you want to disable the Dialog's submit until your View is in a specific state, you can call this function with a boolean to disable (true) and enable (false) the Dialog's submit from within your View.

These next two are very specific functionality that give you even more flexibility in your custom dialogs. You can use them in a variety of ways.

  • onUpdate - When the value in your View changes, you pass it via this.props.onUpdate(value) and it gets stored as the selected state of the Dialog and the value is returned in the Promise resolve (submit). This can be used on its own, or in concert with mustEqualToSubmit. In that case, when the value matches, the submit action enables (click or enter). This has one other special use. If you don't pass a submit value to the Dialog component, calling onUpdate will resolve with that value automatically. This allows you to have your own custom submit button or happen in other situation of your choosing which will cause the dialog to submit with that value (assuming mustEqualToSubmit is not set or is satisfied by the value you pass).
  • onRegister - This one is a bit tricky to explain, but the way it works is you call this function with a callback function as an argument and you usually call this.props.onRegister(callback) in the constructor or componentDidMount. Your function will be called when the user clicks submit, but will not allow the Dialog's Promise to resolve, leaving you to call this.props.resolve(foo) to submit the dialog on your own. Why would you want this? A multi-step dialog, a dialog where you would validate on submit or make an asynchronous call on submit, etc. There are many uses for this.
import React, {Component} from 'react';
import {delay} from 'lodash';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {getDisabled} from '../../utils/component';
import PropTypes from 'prop-types';
import './index.css';
export default class Dialog extends Component {
static propTypes = {
cancel: PropTypes.string,
choices: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,
value: PropTypes.string,
})
),
custom: PropTypes.shape({
mustEqualToSubmit: PropTypes.any,
props: PropTypes.object,
View: PropTypes.func.isRequired,
selected: PropTypes.any,
}),
icon: PropTypes.string,
isForced: PropTypes.bool,
message: PropTypes.string,
reject: PropTypes.func,
resolve: PropTypes.func,
styles: PropTypes.shape({
body: PropTypes.string,
dialog: PropTypes.string,
}),
submit: PropTypes.string,
title: PropTypes.string.isRequired,
type: PropTypes.oneOf([
'primary',
'accent',
'info',
'warning',
'danger',
'success',
]),
};
static defaultProps = {
type: 'primary',
styles: {},
};
constructor(props) {
super(props);
this.state = {
isSubmitDisabled:
this.props.custom &&
this.props.custom.mustEqualToSubmit !== undefined,
loading: false,
selected: !this.props.custom
? !this.props.choices
? true
: this.props.choices[0].value
: this.props.custom.selected,
resolved: undefined,
};
}
componentDidMount() {
if (!this.props.isForced) {
window.addEventListener('keyup', this.onKeyUp);
}
}
componentWillUnmount() {
if (this.props.cancel) {
window.removeEventListener('keyup', this.onKeyUp);
}
}
onKeyUp = event => {
if (event.code === 'Escape' && !this.props.isForced) this.reject();
else if (event.key === 'Enter') this.onClickSubmit();
return false;
};
onChoiceClick = selected => {
this.setState({selected});
};
onCustomUpdate = selected => {
const isSubmitDisabled =
this.props.custom.mustEqualToSubmit &&
this.props.custom.mustEqualToSubmit !== selected;
this.setState({selected, isSubmitDisabled});
if (!this.props.submit && !isSubmitDisabled) this.resolve(selected);
};
onCustomRegister = customSubmit => {
this.customSubmit = customSubmit;
};
onCustomLoading = loading => this.setState({loading});
onCustomSubmitDisabled = isSubmitDisabled =>
this.setState({isSubmitDisabled});
onClickSubmit = () => {
if (this.state.isSubmitDisabled) return;
if (this.customSubmit) {
this.customSubmit();
return;
}
this.resolve(this.state.selected);
};
resolve = value => {
this.setState({resolved: 'has-submitted'}, () => {
delay(() => {
this.props.resolve(value);
}, 150);
});
};
reject = () => {
if (!this.state.loading && !!this.props.reject) {
this.setState({resolved: 'has-canceled'}, () => {
delay(() => {
this.props.reject();
}, 150);
});
}
};
render() {
return (
<div
className={`modal is-active is-${this.props.type} ${this.props
.styles.dialog || ''} ${this.state.resolved || ''}`}
>
<div
className="modal-background"
onClick={
!this.props.isForced &&
!!this.props.reject &&
this.reject
}
/>
<div className="modal-card">
<header className="modal-card-head">
{this.props.icon && (
<span className="icon">
<FontAwesomeIcon
icon={this.props.icon}
size="lg"
/>
</span>
)}
<p className="modal-card-title">{this.props.title}</p>
{!this.props.isForced && (
<button
className="delete"
aria-label="close"
onClick={!!this.props.reject && this.reject}
/>
)}
</header>
<section
className={`modal-card-body ${this.props.styles.body ||
''}`}
>
{this.props.message && (
<div
className="modal-message"
dangerouslySetInnerHTML={{
__html: this.props.message,
}}
/>
)}
{this.props.choices && (
<div className="control">
{this.props.choices.map(item => (
<label className="radio" key={item.label}>
{item.value === this.state.selected ? (
<input type="radio" checked />
) : (
<input
type="radio"
onClick={() => {
this.onChoiceClick(
item.value
);
}}
/>
)}
{item.label}
</label>
))}
</div>
)}
{this.props.custom && (
<this.props.custom.View
onLoading={this.onCustomLoading}
onRegister={this.onCustomRegister}
onSubmitDisabled={this.onCustomSubmitDisabled}
onUpdate={this.onCustomUpdate}
reject={this.reject}
resolve={this.resolve}
{...this.props.custom.props}
/>
)}
</section>
{(this.props.submit || this.props.cancel) && (
<footer className="modal-card-foot">
{this.props.submit && (
<button
className={`button is-${this.props.type} ${
this.state.loading ? 'is-loading' : ''
}`}
aria-label={this.props.submit}
onClick={this.onClickSubmit}
{...getDisabled(
this.state.isSubmitDisabled
)}
>
{this.props.submit}
</button>
)}
{this.props.cancel &&
!this.props.isForced && (
<button
className="button is-cancel"
aria-label="cancel"
onClick={this.reject}
>
{this.props.cancel}
</button>
)}
</footer>
)}
</div>
</div>
);
}
}
.modal-card {
animation: modal-card-transition 0.3s ease-out;
}
.modal-card-head .icon {
margin-right: 10px;
}
.modal-card-title {
position: relative;
margin-top: 1px;
}
.modal-card-body p:not(:first-child) {
margin-top: 20px;
}
.modal-card-foot {
justify-content: flex-end;
}
.modal-background {
animation: modal-background-transition 0.3s linear;
}
.modal .modal-card {
animation: modal-card-transition 0.3s ease-out;
}
@media screen and (max-width: 537px) {
.modal .modal-card {
min-width: 90%;
}
}
.modal.has-submitted .modal-card {
animation: modal-submit-transition 0.2s ease-out;
}
.modal.has-canceled .modal-card {
animation: modal-cancel-transition 0.2s ease-out;
}
.modal.has-dropdown .modal-card {
overflow: visible;
margin-top: -200px;
}
@media screen and (max-height: 700px) {
.modal.has-dropdown .modal-card {
margin-top: -100px;
}
}
@media screen and (max-height: 500px) {
.modal.has-dropdown .modal-card {
margin-top: -50px;
}
}
@media screen and (max-height: 410px) {
.modal.has-dropdown .modal-card {
margin-top: 0;
}
}
.modal.has-dropdown .modal-card-body {
z-index: 5;
overflow: visible;
}
@media screen and (max-width: 550px) {
.modal.has-dropdown .dropdown-menu {
max-width: 18rem;
}
}
@media screen and (max-width: 470px) {
.modal.has-dropdown .dropdown-menu {
max-width: 14rem;
}
}
@media screen and (max-width: 420px) {
.modal.has-dropdown .dropdown-menu {
max-width: 12rem;
}
}
@media screen and (max-width: 380px) {
.modal.has-dropdown .dropdown-menu {
min-width: 10rem;
max-width: 10rem;
}
}
@media screen and (max-width: 350px) {
.modal.has-dropdown .dropdown-menu {
min-width: 8rem;
max-width: 8rem;
}
}
.modal.has-dropdown .dropdown-item {
padding-right: 0.9em;
}
@media screen and (max-width: 550px) {
.modal.has-dropdown .dropdown-item {
padding-right: 0.7em;
}
}
@media screen and (max-width: 380px) {
.modal.has-dropdown .dropdown-item {
padding-left: 0.7em;
}
}
.modal.has-dropdown .dropdown-item .icon-box {
min-width: 25px;
width: 25px;
}
@media screen and (max-width: 470px) {
.modal.has-dropdown .dropdown-item label {
font-size: 0.8em;
}
}
.modal.has-dropdown .dropdown-content {
overflow-y: scroll;
max-height: 350px;
}
@media screen and (max-height: 700px) {
.modal.has-dropdown .dropdown-content {
max-height: 200px;
}
}
@media screen and (max-height: 500px) {
.modal.has-dropdown .dropdown-content {
max-height: 150px;
}
}
@media screen and (max-height: 450px) {
.modal.has-dropdown .dropdown-content {
max-height: 120px;
}
}
@media screen and (max-height: 410px) {
.modal.has-dropdown .dropdown-content {
max-height: 100px;
}
}
.osx .modal-card-foot,
.ios .modal-card-foot {
flex-direction: row-reverse;
justify-content: flex-start;
}
.osx .modal-card-foot .button:not(:last-child),
.ios .modal-card-foot .button:not(:last-child) {
margin-right: 0;
margin-left: 10px;
}
@keyframes modal-background-transition {
0% {
background-color: rgba(0, 0, 0, 0);
}
100% {
background-color: rgba(0, 0, 0, 0.4);
}
}
@keyframes modal-card-transition {
0% {
opacity: 0;
transform: translateY(30px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes modal-submit-transition {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.3);
}
}
@keyframes modal-cancel-transition {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.7);
}
}
// This class gets added/removed to the #root div where your React app is rendered
.modal-blur {
user-select: none;
filter: blur(4px);
}
#dialog-container {
z-index: 1000000;
}
import Dialog from './Dialog';
import React from 'react';
import ReactDOM from 'react-dom';
export default props => {
let container = document.getElementById('dialog-container');
if (!container) {
container = document.createElement('div');
container.id = 'dialog-container';
document.body.appendChild(container);
}
const div = container.appendChild(document.createElement('div'));
const root = document.getElementById('root');
if (process.env.NODE_ENV !== 'test') {
root.classList.add('modal-blur');
}
const cleanup = () => {
if (process.env.NODE_ENV !== 'test') {
try {
ReactDOM.unmountComponentAtNode(div);
container.removeChild(div);
} catch (e) {
// it's ok
}
root.classList.remove('modal-blur');
}
};
return new Promise((resolve, reject) => {
if (props.subtree) {
ReactDOM.unstable_renderSubtreeIntoContainer(
props.subtree,
<Dialog {...props} resolve={resolve} reject={reject} />,
div
);
} else {
ReactDOM.render(
<Dialog {...props} resolve={resolve} reject={reject} />,
div
);
}
})
.then(result => {
cleanup();
return result;
})
.catch(error => {
if (
process.env.NODE_ENV !== 'production' &&
process.env.NODE_ENV !== 'test'
) {
/* eslint-disable no-console */
console.warn(error);
}
cleanup();
return undefined;
});
};
@steph4nc
Copy link

steph4nc commented Nov 8, 2018

Where do I find: import {getDisabled} from '../../utils/component';

@stevensacks
Copy link
Author

stevensacks commented Jan 10, 2019

@steph4nc Sorry about that.

You might be able to change that to just disabled={this.state.isSubmitDisabled} in recent builds of React. If that doesn’t work, use this:

export const getDisabled = bool => (bool ? {disabled: 'disabled'} : {});

This adds the disabled value to an HTML tag via object destructuring.

@stevensacks
Copy link
Author

This is super old and has been deprecated by React Portal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment