boundless-dialog
Version:
A non-blocking, focus-stealing container.
251 lines (208 loc) • 9.03 kB
JavaScript
import PropTypes from 'prop-types';
import { createElement, PureComponent } from 'react';
import cx from 'classnames';
import Portal from 'boundless-portal';
import omit from 'boundless-utils-omit-keys';
const isFunction = (x) => typeof x === 'function';
const noop = () => {};
const toArray = Array.prototype.slice;
/**
A dialog differs from a modal in that it does not come with a masking layer (to obscure the rest of the page)
and the user can choose to shift focus away from the dialog contents via mouse click or a keyboard shortcut.
If you decide to provide a header inside your dialog, it's recommended to configure the [`aria-labelledby`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-labelledby_attribute) attribute, which can be added to `props.dialogProps`.
*/
export default class Dialog extends PureComponent {
static propTypes = {
/**
* any [React-supported attribute](https://facebook.github.io/react/docs/tags-and-attributes.html#html-attributes)
*/
'*': PropTypes.any,
/**
* arbitrary content to be rendered after the dialog in the DOM
*/
after: PropTypes.node,
/**
* arbitrary content to be rendered before the dialog in the DOM
*/
before: PropTypes.node,
/**
* determines if focus is allowed to move away from the dialog
*/
captureFocus: PropTypes.bool,
/**
* enable detection of "Escape" keypresses to trigger `props.onClose`; if a function is provided, the return
* value determines if the dialog will be closed
*/
closeOnEscKey: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.func,
]),
/**
* enable detection of clicks inside the dialog area to trigger `props.onClose`; if a function is provided, the return
* value determines if the dialog will be closed
*/
closeOnInsideClick: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.func,
]),
/**
* enable detection of clicks outside the dialog area to trigger `props.onClose`; if a function is provided, the return
* value determines if the dialog will be closed
*/
closeOnOutsideClick: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.func,
]),
/**
* enable detection of focus outside the dialog area to trigger `props.onClose`; if a function is provided, the return
* value determines if the dialog will be closed
*/
closeOnOutsideFocus: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.func,
]),
/**
* enable detection of scroll and mousewheel events outside the dialog area to trigger `props.onClose`; if a function
* is provided, the return value determines if the dialog will be closed
*/
closeOnOutsideScroll: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.func,
]),
/**
* override the type of `.b-dialog-wrapper` HTML element
*/
component: PropTypes.string,
/**
* override the type of `.b-dialog` HTML element
*/
dialogComponent: PropTypes.string,
dialogProps: PropTypes.shape({
/**
* any [React-supported attribute](https://facebook.github.io/react/docs/tags-and-attributes.html#html-attributes)
*/
'*': PropTypes.any,
}),
/**
* a custom event handler that is called to indicate that the dialog should be unrendered by its parent; the event occurs if one or more of the "closeOn" props (`closeOnEscKey`, `closeOnOutsideClick`, etc.) are passed as `true` and the dismissal criteria are satisfied
*/
onClose: PropTypes.func,
}
static defaultProps = {
after: null,
before: null,
captureFocus: true,
children: null,
closeOnEscKey: false,
closeOnInsideClick: false,
closeOnOutsideClick: false,
closeOnOutsideFocus: false,
closeOnOutsideScroll: false,
component: 'div',
dialogComponent: 'div',
dialogProps: {},
onClose: noop,
onKeyDown: noop,
}
static internalKeys = Object.keys(Dialog.defaultProps)
mounted = false
isPartOfDialog(node) {
if (!node || node === window) { return false; }
const roots = [ this.$wrapper ].concat(
toArray.call(
this.$wrapper.querySelectorAll(`[${Portal.PORTAL_DATA_ATTRIBUTE}]`)
).map((dom) => document.getElementById(dom.getAttribute(Portal.PORTAL_DATA_ATTRIBUTE)))
);
const element = node.nodeType !== Node.ELEMENT_NODE ? node.parentNode : node;
return roots.some((dom) => dom.contains(element));
}
componentDidMount() {
window.addEventListener('click', this.handleOutsideClick, true);
window.addEventListener('contextmenu', this.handleOutsideClick, true);
window.addEventListener('focus', this.handleFocus, true);
window.addEventListener('scroll', this.handleOutsideScrollWheel, true);
window.addEventListener('wheel', this.handleOutsideScrollWheel, true);
if (this.props.captureFocus && !this.isPartOfDialog(document.activeElement)) {
this.$dialog.focus();
}
}
componentWillUnmount() {
window.removeEventListener('click', this.handleOutsideClick, true);
window.removeEventListener('contextmenu', this.handleOutsideClick, true);
window.removeEventListener('focus', this.handleFocus, true);
window.removeEventListener('scroll', this.handleOutsideScrollWheel, true);
window.removeEventListener('wheel', this.handleOutsideScrollWheel, true);
}
shouldDialogCloseOnEvent(prop, event) {
return isFunction(this.props[prop]) ? this.props[prop](event) : this.props[prop];
}
handleFocus = (nativeEvent) => {
if (!this.props.captureFocus) {
if (this.shouldDialogCloseOnEvent('closeOnOutsideFocus', nativeEvent) && !this.isPartOfDialog(nativeEvent.target)) {
return window.setTimeout(this.props.onClose, 0);
}
return;
}
// explicitOriginalTarget is for Firefox, as it doesn't support relatedTarget
let previous = nativeEvent.explicitOriginalTarget || nativeEvent.relatedTarget;
if (this.isPartOfDialog(previous) && !this.isPartOfDialog(nativeEvent.target)) {
nativeEvent.preventDefault();
previous.focus(); // restore focus
}
}
handleKeyDown = (event) => {
if (event.key === 'Escape') {
if (this.shouldDialogCloseOnEvent('closeOnEscKey', event)) {
window.setTimeout(this.props.onClose, 0);
}
}
if (this.props.onKeyDown) {
this.props.onKeyDown(event);
}
}
handleInsideClick = (event) => {
if (this.shouldDialogCloseOnEvent('closeOnInsideClick', event)) {
window.setTimeout(this.props.onClose, 0);
}
}
handleOutsideClick = (nativeEvent) => {
if (this.shouldDialogCloseOnEvent('closeOnOutsideClick', nativeEvent) && !this.isPartOfDialog(nativeEvent.target)) {
window.setTimeout(this.props.onClose, 0);
}
}
handleOutsideScrollWheel = (nativeEvent) => {
if (this.shouldDialogCloseOnEvent('closeOnOutsideScroll', nativeEvent) && !this.isPartOfDialog(nativeEvent.target)) {
window.setTimeout(this.props.onClose, 0);
}
}
renderFocusBoundary() {
if (this.props.captureFocus) {
return (
<div className='b-offscreen' tabIndex='0' aria-hidden='true'> </div>
);
}
} // used to lock focus into a particular subset of DOM
render() {
return (
<this.props.component
{...omit(this.props, Dialog.internalKeys)}
ref={(node) => (this.$wrapper = node)}
className={cx('b-dialog-wrapper', this.props.className)}>
{this.renderFocusBoundary()}
{this.props.before}
<this.props.dialogComponent
{...this.dialogProps}
ref={(node) => (this.$dialog = node)}
className={cx('b-dialog', this.props.dialogProps.className)}
onClick={this.handleInsideClick}
onKeyDown={this.handleKeyDown}
role={this.props.captureFocus ? 'alertdialog' : 'dialog'}
tabIndex='0'>
{this.props.children}
</this.props.dialogComponent>
{this.props.after}
{this.renderFocusBoundary()}
</this.props.component>
);
}
}