All files / boundless/packages/boundless-dialog index.js

97.92% Statements 47/48
95.24% Branches 40/42
94.12% Functions 16/17
97.78% Lines 44/45
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252            20x 1x 1x                                                                                                                                                                                                                                                   75x   41x           41x   41x       37x 37x 37x 37x 37x   37x 31x         37x 37x 37x 37x 37x       20x       40x 4x 2x     2x       36x   36x 1x 1x         5x 4x 2x       5x 5x         4x 2x         4x 2x         4x 2x         82x 70x             41x     82x               82x                                
import React, {PropTypes} 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 non-blocking, focus-stealing container.__
 
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 React.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 functio
         * 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);
            }
        }
 
        Eif (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'>&nbsp;</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='dialog'
                    tabIndex='0'>
                    {this.props.children}
                </this.props.dialogComponent>
 
                {this.props.after}
 
                {this.renderFocusBoundary()}
            </this.props.component>
        );
    }
}