diff --git a/components/date-picker/date-picker.jsx b/components/date-picker/date-picker.jsx index 155cebd1d3..c001f754b7 100644 --- a/components/date-picker/date-picker.jsx +++ b/components/date-picker/date-picker.jsx @@ -146,24 +146,6 @@ const propTypes = { * Custom function to parse date string into and return a `Date` object. Default function passes the input value to `Date()` and prays for a miracle. Use an external library such as [MomentJS](https://github.com/moment/moment/) if additional date parsing is needed. _Tested with snapshot testing._ */ parser: PropTypes.func, - /** - * Absolutely positioned DOM nodes, such as a datepicker dialog, may need their own React DOM tree root. They may need their alignment "flipped" if extended beyond the window or outside the bounds of an overflow-hidden scrolling modal. This library's portal mounts are added as a child node of `body`. This prop will be triggered instead of the default `ReactDOM.mount()` when this dialog is mounted. This prop is useful for testing and simliar to a "callback ref." Two arguments,`reactElement` and `domContainerNode` are passed in. Consider the following code that bypasses the internal mount and uses an Enzyme wrapper to mount the React root tree to the DOM. - * - * ``` - * { - portalWrapper = Enzyme.mount(reactElement, { attachTo: domContainerNode }); - }} - onOpen={() => { - expect(portalWrapper.find(`#my-heading`)).to.exist; - done(); - }} - /> - ``` - * _Tested with Mocha framework._ - */ - portalMount: PropTypes.func, /** * Offset of year from current year that can be selected in the year selection dropdown. (2017 - 5 = 2012). _Tested with snapshot testing._ */ @@ -234,8 +216,6 @@ const defaultProps = { * The calendar is rendered with time/dates based on local browser time of the client. All dates are in local user timezones. Another way to put it is if a user selects a date, they are selecting midnight their time on that day and not mightnight in UTC. If this component is being used in conjuction with a timezone input, you may want to convert dates provided to UTC in that timezone. * * This component is wrapped in a [higher order component to listen for clicks outside itself](https://github.com/kentor/react-click-outside) and thus requires use of `ReactDOM`. - * - * This component may use a portalMount (a disconnected React subtree mount) within an absolutely positioned DOM node created with [Drop](http://github.hubspot.com/drop/). */ class Datepicker extends React.Component { constructor (props) { @@ -397,7 +377,6 @@ class Datepicker extends React.Component { flippable={!this.props.hasStaticAlignment} onClose={this.handleClose} onOpen={this.handleOpen} - portalMount={this.props.portalMount} targetElement={this.inputRef} > {this.getDatePicker({ labels, assistiveText })} diff --git a/components/lookup/lookup.jsx b/components/lookup/lookup.jsx index 616eb1e5f8..367608308a 100644 --- a/components/lookup/lookup.jsx +++ b/components/lookup/lookup.jsx @@ -46,8 +46,6 @@ const defaultFilter = (term, item) => { * Lookup is an advanced inline search form. The lookup can parse through single or multi scoped datasets. The parsed dataset can be filtered by single or multi option selects. * * This component is wrapped in a [higher order component to listen for clicks outside itself](https://github.com/kentor/react-click-outside) and thus requires use of `ReactDOM`. - * - * This component may use a portalMount (a disconnected React subtree mount) within an absolutely positioned DOM node created with [Drop](http://github.hubspot.com/drop/). */ const Lookup = React.createClass({ displayName: LOOKUP, diff --git a/components/menu-dropdown/menu-dropdown.jsx b/components/menu-dropdown/menu-dropdown.jsx index 6070d8ace3..691a4576dd 100644 --- a/components/menu-dropdown/menu-dropdown.jsx +++ b/components/menu-dropdown/menu-dropdown.jsx @@ -80,9 +80,6 @@ const DropdownNubbinPositions = [ * support is needed. * * This component is wrapped in a [higher order component to listen for clicks outside itself](https://github.com/kentor/react-click-outside) and thus requires use of `ReactDOM`. - * - * This component may use a portalMount (a disconnected React subtree mount) within an absolutely positioned DOM node created with [Drop](http://github.hubspot.com/drop/). - */ const MenuDropdown = React.createClass({ // ### Display Name diff --git a/components/modal/index.jsx b/components/modal/index.jsx index bc7b1d54bc..ac4acb7bef 100644 --- a/components/modal/index.jsx +++ b/components/modal/index.jsx @@ -140,7 +140,6 @@ const defaultProps = { * settings.setAppElement('#mount'); * ``` * - * This component uses a portalMount (a disconnected React subtree mount) to create a modal as a child of `body`. */ class Modal extends React.Component { diff --git a/components/popover/popover.jsx b/components/popover/popover.jsx index f4e290304f..42ad372925 100644 --- a/components/popover/popover.jsx +++ b/components/popover/popover.jsx @@ -163,23 +163,6 @@ const Popover = React.createClass({ * This function is triggered when the user clicks outside the Popover or clicks the close button. You will want to define this if Popover is to be a controlled component. Most of the time you will want wnat to set `isOpen` to `false` when this is triggered unless you need to validate something. */ onRequestClose: PropTypes.func, - /** - * Absolutely positioned DOM nodes, such as a popover dialog, may need their own React DOM tree root. They may need their alignment "flipped" if extended beyond the window or outside the bounds of an overflow-hidden scrolling modal. This library's portal mounts are added as a child node of `body`. This prop will be triggered instead of the default `ReactDOM.mount()` when this dialog is mounted. This prop is useful for testing and simliar to a "callback ref." Two arguments,`reactElement` and `domContainerNode` are passed in. Consider the following code that bypasses the internal mount and uses an Enzyme wrapper to mount the React root tree to the DOM. - * - * ``` - * { - portalWrapper = Enzyme.mount(reactElement, { attachTo: domContainerNode }); - }} - onOpen={() => { - expect(portalWrapper.find(`#my-heading`)).to.exist; - done(); - }} - /> - ``` - */ - portalMount: PropTypes.func, /** * An object of CSS styles that are applied to the `slds-popover` DOM element. */ @@ -417,7 +400,6 @@ const Popover = React.createClass({ onMouseEnter={(props.openOn === 'hover') ? this.handleMouseEnter : null} onMouseLeave={(props.openOn === 'hover') ? this.handleMouseLeave : null} outsideClickIgnoreClass={outsideClickIgnoreClass} - portalMount={this.props.portalMount} variant="popover" >
context so repassing it here. import IconSettings from '../../iconSettings'; +// Translates the prop into a string popper can use https://popper.js.org/popper-documentation.html#Popper.placements +function mapPropToPopperPlacement (propString) { + let placement; + switch (propString) { + case 'top left': + placement = 'top-start'; + break; + case 'top right': + placement = 'top-end'; + break; + case 'right top': + placement = 'right-start'; + break; + case 'right bottom': + placement = 'right-end'; + break; + case 'bottom left': + placement = 'bottom-start'; + break; + case 'bottom right': + placement = 'bottom-end'; + break; + case 'left top': + placement = 'left-start'; + break; + case 'left bottom': + placement = 'left-end'; + break; + default: + placement = propString; + } + return placement; +} + /* Dialog creates a new top-level React tree and injects its child into it. This is necessary for proper styling (especially positioning). A dialog is a non-modal container that separates content from the rest of the web application. This library uses the Drop library (https://github.com/HubSpot/drop which is based on TetherJS) to absolutely position and align content to another item on the page. This component is not meant for external consumption or part of the published component API. */ const Dialog = React.createClass({ @@ -144,23 +180,6 @@ const Dialog = React.createClass({ * Triggered when an item in the menu is clicked. */ outsideClickIgnoreClass: PropTypes.string, - /** - * Absolutely positioned DOM nodes, such as a popover dialog, may need their own React DOM tree root. They may need their alignment "flipped" if extended beyond the window or outside the bounds of an overflow-hidden scrolling modal. This library's portal mounts are added as a child node of `body`. This prop will be triggered instead of the default `ReactDOM.mount()` when this dialog is mounted. This prop is useful for testing and simliar to a "callback ref." Two arguments,`reactElement` and `domContainerNode` are passed in. Consider the following code that bypasses the internal mount and uses an Enzyme wrapper to mount the React root tree to the DOM. - * - * ``` - * { - portalWrapper = Enzyme.mount(reactElement, { attachTo: domContainerNode }); - }} - onOpen={() => { - expect(portalWrapper.find(`#my-heading`)).to.exist; - done(); - }} - /> - ``` - */ - portalMount: PropTypes.func, /** * An object of CSS styles that are applied to the immediate parent `div` of the contents. */ @@ -181,6 +200,7 @@ const Dialog = React.createClass({ getDefaultProps () { return { + align: 'bottom left', verticalAlign: 'bottom', horizontalAlign: 'left', closeOnTabKey: false, @@ -203,16 +223,78 @@ const Dialog = React.createClass({ }, componentWillMount () { - this.dialogElement = document.createElement('span'); - document.querySelector('body').appendChild(this.dialogElement); + const onOpen = this.props.onOpen; + this.boundOnOpen = onOpen ? onOpen.bind(this) : () => {}; }, componentDidMount () { - this.renderDialog(); + this._createPopper(); }, - componentDidUpdate () { - this.renderDialog(); + componentWillUnmount () { + this._destroyPopper(); + this.handleClose(undefined, { componentWillUnmount: true }); + }, + + target () { + return this.props.targetElement ? ReactDOM.findDOMNode(this.props.targetElement) : ReactDOM.findDOMNode(this).parentNode; // eslint-disable-line react/no-find-dom-node + }, + + _createPopper () { + const reference = this.target(); + const popper = this.dialogContent; + const placement = mapPropToPopperPlacement(this.props.align); + const eventsEnabled = true; // Lets popper listen to events (resize, scroll, etc.) + // FIXME: how does props.constrainToScrollParent map to Popper's options? + const modifiers = { + applyStyle: { enabled: false }, + preventOverflow: { enabled: this.props.flippable }, + flip: { enabled: this.props.flippable }, + updateState: { + enabled: true, + order: 900, + fn: data => { + if ( + (this.state.data && !isEqual(data.offsets, this.state.data.offsets)) || + !this.state.data + ) { + this.setState({ data }); + } + return data; + } + } + // arrow property can also point to an element + }; + if (!reference || !popper) { + console.error('Target node not found!', reference); + console.error('Popper node not found!', popper); + } + this._popper = new Popper(reference, popper, { + placement, + eventsEnabled, + modifiers, + onCreate: this.boundOnOpen + }); + this._popper.scheduleUpdate(); + }, + + _getPopperStyles () { + const { data } = this.state; + if (!this._popper || !data) { + return { + position: 'absolute', + pointerEvents: 'none' + }; + } + + const { position } = data.offsets.popper; + const left = `${data.offsets.popper.left}px`; + const top = `${data.offsets.popper.top}px`; + return { ...data.style, left, top, position }; + }, + + _destroyPopper () { + if (this._popper) this._popper.destroy(); }, handleClickOutside () { @@ -245,27 +327,36 @@ const Dialog = React.createClass({ } }, - renderDialogContents () { - if (!this.state.isOpen) { - return ; + handleOpen () { + this.setState({ isOpen: true }); + + if (this.props.variant === 'popover') { + DOMElementFocus.storeActiveElement(); + DOMElementFocus.setupScopedFocus({ ancestorElement: ReactDOM.findDOMNode(this.dialogElement).querySelector('.slds-popover') }); // eslint-disable-line react/no-find-dom-node + // Don't steal focus from inner elements + if (!DOMElementFocus.hasOrAncestorHasFocus()) { + DOMElementFocus.focusAncestor(); + } + } + + if (this.props.onOpen) { + this.props.onOpen(undefined, { portal: this.portal }); } + }, - let style = { - transform: 'none', - WebkitTransform: 'none', + render () { + let style = assign(this._getPopperStyles(), { marginTop: this.props.marginTop, marginBottom: this.props.marginBottom, marginLeft: this.props.marginLeft, - marginRight: this.props.marginRight, - float: 'inherit', - position: 'inherit' - }; + marginRight: this.props.marginRight + }); if (this.props.inheritTargetWidth) { style.width = this.target().getBoundingClientRect().width; } if (this.props.style) { - style = Object.assign({}, style, this.props.style); + style = assign({}, style, this.props.style); } return ( @@ -284,165 +375,6 @@ const Dialog = React.createClass({
); - }, - - getHorizontalAlign (align) { - if (align.indexOf('left') > -1) { - return 'left'; - } else if (align.indexOf('right') > -1) { - return 'right'; - } - return 'center'; - }, - - getVerticalAlign (align) { - if (align.indexOf('bottom') > -1) { - return 'bottom'; - } else if (align.indexOf('top') > -1) { - return 'top'; - } - return 'middle'; - }, - - isHorizontalAlign (align) { - return ( - align === 'left' || - align === 'right' || - align === 'center' - ); - }, - - isVerticalAlign (align) { - return ( - align === 'bottom' || - align === 'top' || - align === 'middle' - ); - }, - - getPosition () { - if (this.props.align) { - let align = []; - if (this.props.align) { - const splits = this.props.align.split(' '); - if (this.isHorizontalAlign(splits[0])) { - const verticalAlign = splits.length > 1 ? this.getVerticalAlign(splits[1]) : this.getVerticalAlign(''); - align = [ - this.getHorizontalAlign(splits[0]), - verticalAlign - ]; - } else { - const horizontalAlign = splits.length > 1 ? this.getHorizontalAlign(splits[1]) : this.getHorizontalAlign(''); - align = [ - this.getVerticalAlign(splits[0]), - horizontalAlign - ]; - } - } - - return align.join(' '); - } - - const positions = []; - if (this.props.verticalAlign === 'top' || this.props.verticalAlign === 'bottom') { - positions.push(this.props.verticalAlign); - positions.push(this.props.horizontalAlign); - } else { - positions.push(this.props.horizontalAlign); - positions.push(this.props.verticalAlign); - } - - return positions.join(' '); - }, - - target () { - return this.props.targetElement ? ReactDOM.findDOMNode(this.props.targetElement) : ReactDOM.findDOMNode(this).parentNode; // eslint-disable-line react/no-find-dom-node - }, - - tetherDropOptions () { - // Please reference http://github.hubspot.com/drop/ for options. - const position = this.getPosition(); - - return { - beforeClose: this.beforeClose, - constrainToWindow: this.props.flippable, - constrainToScrollParent: this.props.constrainToScrollParent, - content: this.dialogElement, - openOn: 'always', - position, - remove: true, - target: this.target(), - tetherOptions: { - offset: this.props.offset - } - }; - }, - - handleOpen () { - this.setState({ isOpen: true }); - - if (this.props.variant === 'popover') { - DOMElementFocus.storeActiveElement(); - DOMElementFocus.setupScopedFocus({ ancestorElement: ReactDOM.findDOMNode(this.dialogElement).querySelector('.slds-popover') }); // eslint-disable-line react/no-find-dom-node - // Don't steal focus from inner elements - if (!DOMElementFocus.hasOrAncestorHasFocus()) { - DOMElementFocus.focusAncestor(); - } - } - - if (this.props.onOpen) { - this.props.onOpen(undefined, { portal: this.portal }); - } - }, - - renderDialog () { - // By default ReactDOM is used to create a portal mount on the `body` tag. This can be overridden with the `portalMount` prop. - let mount = ReactDOM.render; - - if (this.props.portalMount) { - mount = this.props.portalMount; - } - - // nextElement, container, callback - this.portal = mount(this.renderDialogContents(), this.dialogElement); - - if (this.dialogElement && - this.dialogElement.parentNode && - this.dialogElement.parentNode.parentNode && - this.dialogElement.parentNode.parentNode.className && - this.dialogElement.parentNode.parentNode.className.indexOf('drop ') > -1) { - this.dialogElement.parentNode.parentNode.style.zIndex = 10001; - } - - if (this.drop !== null && this.drop !== undefined) { - if (this.drop && this.drop) { - this.drop.position(); - } - } else if (window && document) { - this.drop = new TetherDrop(this.tetherDropOptions()); - this.drop.once('open', this.handleOpen); - } - }, - - componentWillUnmount () { - if (this.props.variant === 'popover') { - DOMElementFocus.teardownScopedFocus(); - DOMElementFocus.returnFocusToStoredElement(); - } - - this.drop.destroy(); - ReactDOM.unmountComponentAtNode(this.dialogElement); - - if (this.dialogElement.parentNode) { - this.dialogElement.parentNode.removeChild(this.dialogElement); - } - - this.handleClose(undefined, { componentWillUnmount: true }); - }, - - render () { - // Must use `` in order for `this.drop` to not be undefined when unmounting - return