From 3652e690a9b72d334ade88e65b809250d191a3ef Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Tue, 3 Jul 2018 10:49:02 -0600 Subject: [PATCH 01/17] create EuiMutationObserver util; implement it on accordion and popover --- .../__snapshots__/accordion.test.js.snap | 40 +++++++++---- src/components/accordion/accordion.js | 37 +++++++----- src/components/popover/popover.js | 15 ++++- src/utils/index.js | 1 + src/utils/mutation_observer/index.js | 1 + .../mutation_observer/mutation_observer.js | 60 +++++++++++++++++++ 6 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 src/utils/mutation_observer/index.js create mode 100644 src/utils/mutation_observer/mutation_observer.js diff --git a/src/components/accordion/__snapshots__/accordion.test.js.snap b/src/components/accordion/__snapshots__/accordion.test.js.snap index b85b500c308..0b2e9e332c3 100644 --- a/src/components/accordion/__snapshots__/accordion.test.js.snap +++ b/src/components/accordion/__snapshots__/accordion.test.js.snap @@ -122,11 +122,21 @@ exports[`EuiAccordion behavior closes when clicked twice 1`] = ` className="euiAccordion__childWrapper" id="6" > -
-
-
+ +
+
+
+
@@ -253,11 +263,21 @@ exports[`EuiAccordion behavior opens when clicked once 1`] = ` className="euiAccordion__childWrapper" id="5" > -
-
-
+ +
+
+
+
diff --git a/src/components/accordion/accordion.js b/src/components/accordion/accordion.js index 858b8835b48..f5133a26703 100644 --- a/src/components/accordion/accordion.js +++ b/src/components/accordion/accordion.js @@ -13,6 +13,10 @@ import { EuiFlexItem, } from '../flex'; +import { + EuiMutationObserver, +} from '../../utils/mutation_observer'; + const paddingSizeToClassNameMap = { none: null, xs: 'euiAccordion__padding--xs', @@ -58,16 +62,16 @@ export class EuiAccordion extends Component { setChildContentRef = (node) => { this.childContent = node; - - if (this.observer) { - this.observer.disconnect(); - this.observer = null; - } - - if (node) { - this.observer = new MutationObserver(this.setChildContentHeight); - this.observer.observe(this.childContent, { childList: true, subtree: true }); - } + // + // if (this.observer) { + // this.observer.disconnect(); + // this.observer = null; + // } + // + // if (node) { + // this.observer = new MutationObserver(this.setChildContentHeight); + // this.observer.observe(this.childContent, { childList: true, subtree: true }); + // } } render() { @@ -154,11 +158,16 @@ export class EuiAccordion extends Component { ref={node => { this.childWrapper = node; }} id={id} > -
-
- {children} + +
+
+ {children} +
-
+
); diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 81612557181..749b02203de 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -16,6 +16,8 @@ import { EuiPanel, SIZES } from '../panel'; import { EuiPortal } from '../portal'; +import { EuiMutationObserver } from '../../utils/mutation_observer'; + import { findPopoverPosition, getElementZIndex } from '../../services/popover/popover_positioning'; const anchorPositionToPopoverPositionMap = { @@ -328,7 +330,18 @@ export class EuiPopover extends Component {
{button}
- {panel} + { + panel + ? ( + + {panel} + + ) + : null + } ); diff --git a/src/utils/index.js b/src/utils/index.js index f4a6a69a4cc..b57a93b5f79 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1 +1,2 @@ +export * from './mutation_observer'; export * from './prop_types'; diff --git a/src/utils/mutation_observer/index.js b/src/utils/mutation_observer/index.js new file mode 100644 index 00000000000..fb7dde9bf34 --- /dev/null +++ b/src/utils/mutation_observer/index.js @@ -0,0 +1 @@ +export { EuiMutationObserver } from './mutation_observer'; diff --git a/src/utils/mutation_observer/mutation_observer.js b/src/utils/mutation_observer/mutation_observer.js new file mode 100644 index 00000000000..15f2de3f527 --- /dev/null +++ b/src/utils/mutation_observer/mutation_observer.js @@ -0,0 +1,60 @@ +import { Component, cloneElement } from 'react'; +import { findDOMNode } from 'react-dom'; +import PropTypes from 'prop-types'; + +class EuiMutationObserver extends Component { + constructor(...args) { + super(...args); + this.childrenRef = null; + this.observer = null; + } + + onMutation = (...args) => { + console.log('::onMutation'); + this.props.onMutation(...args); + } + + updateRef = ref => { + if (this.props.children.ref) { + this.props.children.ref(ref); + } + + if (this.observer != null) { + this.observer.disconnect(); + this.observer = null; + } + + if (ref != null) { + const node = findDOMNode(ref); + this.observer = new MutationObserver(this.onMutation); + this.observer.observe(node, this.props.observerOptions); + } + } + + render() { + const children = cloneElement( + this.props.children, + { + ...this.props.children.props, + ref: this.updateRef, + } + ); + return children; + } +} + +EuiMutationObserver.propTypes = { + children: PropTypes.element.isRequired, + observerOptions: PropTypes.shape({ // matches a [MutationObserverInit](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit) + attributeFilter: PropTypes.arrayOf(PropTypes.string), + attributeOldValue: PropTypes.bool, + attributes: PropTypes.bool, + characterData: PropTypes.bool, + characterDataOldValue: PropTypes.bool, + childList: PropTypes.bool, + subtree: PropTypes.bool, + }).isRequired, + onMutation: PropTypes.func.isRequired, +}; + +export { EuiMutationObserver }; From 29b80b8b513ec843ab5b41125952c5d04d944d86 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Fri, 6 Jul 2018 10:19:56 -0600 Subject: [PATCH 02/17] updated EuiContextMenu to trigger popover's mutation observer during height transition --- .../__snapshots__/context_menu.test.js.snap | 2 + src/components/context_menu/context_menu.js | 45 +++++++++++++++++-- src/components/popover/popover.js | 35 +++++++++------ .../mutation_observer/mutation_observer.js | 1 - 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/components/context_menu/__snapshots__/context_menu.test.js.snap b/src/components/context_menu/__snapshots__/context_menu.test.js.snap index bee9cd17c18..3b1131299f4 100644 --- a/src/components/context_menu/__snapshots__/context_menu.test.js.snap +++ b/src/components/context_menu/__snapshots__/context_menu.test.js.snap @@ -11,6 +11,7 @@ exports[`EuiContextMenu is rendered 1`] = ` exports[`EuiContextMenu props panels and initialPanelId allows you to click the title button to go back to the previous panel 1`] = `
{ + const transitionCount = this.menu.hasAttribute('data-transitioncount') + ? parseInt(this.menu.getAttribute('data-transitioncount'), 10) + : 0; + this.menu.setAttribute('data-transitioncount', transitionCount + 1); + } + + onFrameAnimation = () => { + this.updateTransitionCount(); + if (this._isTransitioning) { + requestAnimationFrame(this.onFrameAnimation); + } + } + + onTransitionStart = () => { + this._isTransitioning = true; + this.onFrameAnimation(); + } + + onTransitionEnd = () => { + this.updateTransitionCount(); + this._isTransitioning = false; + } + hasPreviousPanel = panelId => { const previousPanelId = this.state.idToPreviousPanelIdMap[panelId]; return typeof previousPanelId !== 'undefined'; @@ -173,8 +197,13 @@ export class EuiContextMenu extends Component { }; onIncomingPanelHeightChange = height => { - this.setState({ - height, + this.setState(({ height: prevHeight }) => { + if (height === prevHeight) { + return null; + } else { + this.onTransitionStart(); + return { height }; + } }); }; @@ -203,6 +232,16 @@ export class EuiContextMenu extends Component { this.setState({ idToRenderedItemsMap }); }; + setMenuRef = node => { + if (node != null) { + node.addEventListener('transitionend', this.onTransitionEnd); + } else { + this.menu.removeEventListener('transitionend', this.onTransitionEnd); + } + + this.menu = node; + }; + renderItems(items = []) { return items.map((item, index) => { const { @@ -299,7 +338,7 @@ export class EuiContextMenu extends Component { return (
{ this.menu = node; }} + ref={this.setMenuRef} className={classes} style={{ height: this.state.height }} {...rest} diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 749b02203de..e0e60d4e029 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -205,7 +205,13 @@ export class EuiPopover extends Component { const arrowStyles = arrow; const arrowPosition = position; - this.setState({ popoverStyles, arrowStyles, arrowPosition }); + this.setState(({ popoverStyles: lastPosition }) => { + // only call setState if the top or left values have changed + if (lastPosition.top === popoverStyles.top && lastPosition.left === popoverStyles.left) { + return null; + } + return { popoverStyles, arrowStyles, arrowPosition }; + }); } panelRef = node => { @@ -312,7 +318,19 @@ export class EuiPopover extends Component { style={this.state.popoverStyles} >
- {children} + { + children + ? ( + + {children} + + ) + : null + + } @@ -330,18 +348,7 @@ export class EuiPopover extends Component {
{button}
- { - panel - ? ( - - {panel} - - ) - : null - } + {panel}
); diff --git a/src/utils/mutation_observer/mutation_observer.js b/src/utils/mutation_observer/mutation_observer.js index 15f2de3f527..84af81b31a0 100644 --- a/src/utils/mutation_observer/mutation_observer.js +++ b/src/utils/mutation_observer/mutation_observer.js @@ -10,7 +10,6 @@ class EuiMutationObserver extends Component { } onMutation = (...args) => { - console.log('::onMutation'); this.props.onMutation(...args); } From 7b6a23c2d3c019875ed9a6d04c55183da73f2754 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Fri, 6 Jul 2018 10:44:25 -0600 Subject: [PATCH 03/17] another approach to the popover conundrum --- .../__snapshots__/context_menu.test.js.snap | 2 - src/components/context_menu/context_menu.js | 58 +++++++++---------- src/components/popover/popover.js | 24 +++++++- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/components/context_menu/__snapshots__/context_menu.test.js.snap b/src/components/context_menu/__snapshots__/context_menu.test.js.snap index 3b1131299f4..bee9cd17c18 100644 --- a/src/components/context_menu/__snapshots__/context_menu.test.js.snap +++ b/src/components/context_menu/__snapshots__/context_menu.test.js.snap @@ -11,7 +11,6 @@ exports[`EuiContextMenu is rendered 1`] = ` exports[`EuiContextMenu props panels and initialPanelId allows you to click the title button to go back to the previous panel 1`] = `
{ - const transitionCount = this.menu.hasAttribute('data-transitioncount') - ? parseInt(this.menu.getAttribute('data-transitioncount'), 10) - : 0; - this.menu.setAttribute('data-transitioncount', transitionCount + 1); - } - - onFrameAnimation = () => { - this.updateTransitionCount(); - if (this._isTransitioning) { - requestAnimationFrame(this.onFrameAnimation); - } - } - - onTransitionStart = () => { - this._isTransitioning = true; - this.onFrameAnimation(); - } - - onTransitionEnd = () => { - this.updateTransitionCount(); - this._isTransitioning = false; - } + // updateTransitionCount = () => { + // const transitionCount = this.menu.hasAttribute('data-transitioncount') + // ? parseInt(this.menu.getAttribute('data-transitioncount'), 10) + // : 0; + // this.menu.setAttribute('data-transitioncount', transitionCount + 1); + // } + // + // onFrameAnimation = () => { + // this.updateTransitionCount(); + // if (this._isTransitioning) { + // requestAnimationFrame(this.onFrameAnimation); + // } + // } + // + // onTransitionStart = () => { + // this._isTransitioning = true; + // this.onFrameAnimation(); + // } + // + // onTransitionEnd = () => { + // this.updateTransitionCount(); + // this._isTransitioning = false; + // } hasPreviousPanel = panelId => { const previousPanelId = this.state.idToPreviousPanelIdMap[panelId]; @@ -201,7 +201,7 @@ export class EuiContextMenu extends Component { if (height === prevHeight) { return null; } else { - this.onTransitionStart(); + // this.onTransitionStart(); return { height }; } }); @@ -233,11 +233,11 @@ export class EuiContextMenu extends Component { }; setMenuRef = node => { - if (node != null) { - node.addEventListener('transitionend', this.onTransitionEnd); - } else { - this.menu.removeEventListener('transitionend', this.onTransitionEnd); - } + // if (node != null) { + // node.addEventListener('transitionend', this.onTransitionEnd); + // } else { + // this.menu.removeEventListener('transitionend', this.onTransitionEnd); + // } this.menu = node; }; diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index e0e60d4e029..03bfd6f4d8d 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -178,6 +178,28 @@ export class EuiPopover extends Component { clearTimeout(this.closingTransitionTimeout); } + onMutation = (records) => { + const waitDuration = records.reduce( + (waitDuration, record) => { + const computedDuration = window.getComputedStyle(record.target).getPropertyValue('transition-duration'); + const durationMatch = computedDuration.match(/^([\d\.]+)/); + if (durationMatch != null) { + waitDuration = Math.max(waitDuration, parseFloat(durationMatch[1]) * 1000); + } + return waitDuration; + }, + 0 + ); + this.positionPopover(); + + if (waitDuration > 0) { + setTimeout( + this.positionPopover, + waitDuration + ); + } + } + positionPopover = () => { const { top, left, position, arrow } = findPopoverPosition({ position: getPopoverPositionFromAnchorPosition(this.props.anchorPosition), @@ -323,7 +345,7 @@ export class EuiPopover extends Component { ? ( {children} From a842dcd47fe7d1fcfe2bfe7b7734fbc88127d5c5 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Fri, 6 Jul 2018 11:26:53 -0600 Subject: [PATCH 04/17] change popover to call requestAnimationFrame while a transition may be active --- src/components/context_menu/context_menu.js | 35 --------------------- src/components/popover/popover.js | 20 ++++++++---- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/src/components/context_menu/context_menu.js b/src/components/context_menu/context_menu.js index 1bd1a11b299..6a03e5b2961 100644 --- a/src/components/context_menu/context_menu.js +++ b/src/components/context_menu/context_menu.js @@ -124,30 +124,6 @@ export class EuiContextMenu extends Component { } } - // updateTransitionCount = () => { - // const transitionCount = this.menu.hasAttribute('data-transitioncount') - // ? parseInt(this.menu.getAttribute('data-transitioncount'), 10) - // : 0; - // this.menu.setAttribute('data-transitioncount', transitionCount + 1); - // } - // - // onFrameAnimation = () => { - // this.updateTransitionCount(); - // if (this._isTransitioning) { - // requestAnimationFrame(this.onFrameAnimation); - // } - // } - // - // onTransitionStart = () => { - // this._isTransitioning = true; - // this.onFrameAnimation(); - // } - // - // onTransitionEnd = () => { - // this.updateTransitionCount(); - // this._isTransitioning = false; - // } - hasPreviousPanel = panelId => { const previousPanelId = this.state.idToPreviousPanelIdMap[panelId]; return typeof previousPanelId !== 'undefined'; @@ -232,16 +208,6 @@ export class EuiContextMenu extends Component { this.setState({ idToRenderedItemsMap }); }; - setMenuRef = node => { - // if (node != null) { - // node.addEventListener('transitionend', this.onTransitionEnd); - // } else { - // this.menu.removeEventListener('transitionend', this.onTransitionEnd); - // } - - this.menu = node; - }; - renderItems(items = []) { return items.map((item, index) => { const { @@ -338,7 +304,6 @@ export class EuiContextMenu extends Component { return (
{ const computedDuration = window.getComputedStyle(record.target).getPropertyValue('transition-duration'); - const durationMatch = computedDuration.match(/^([\d\.]+)/); + const durationMatch = computedDuration.match(/^([\d.]+)/); if (durationMatch != null) { waitDuration = Math.max(waitDuration, parseFloat(durationMatch[1]) * 1000); } @@ -193,10 +193,18 @@ export class EuiPopover extends Component { this.positionPopover(); if (waitDuration > 0) { - setTimeout( - this.positionPopover, - waitDuration - ); + const startTime = Date.now(); + const endTime = startTime + waitDuration; + + const onFrame = () => { + this.positionPopover(); + + if (endTime > Date.now()) { + requestAnimationFrame(onFrame); + } + }; + + requestAnimationFrame(onFrame); } } @@ -344,7 +352,7 @@ export class EuiPopover extends Component { children ? ( {children} From 0add8cdd118b2a29b32823e4fe20b102efa6e4a0 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 9 Jul 2018 12:40:44 -0600 Subject: [PATCH 05/17] refactor EuiMutationObserver to support multiple children --- src/components/popover/popover.js | 20 +++- .../mutation_observer/mutation_observer.js | 99 ++++++++++++++----- 2 files changed, 89 insertions(+), 30 deletions(-) diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index e889822f580..b12f4dfc20d 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -74,6 +74,8 @@ const DEFAULT_POPOVER_STYLES = { left: 50, }; +const GROUP_MUMERIC = /^([\d.]+)/; + export class EuiPopover extends Component { static getDerivedStateFromProps(nextProps, prevState) { if (prevState.prevProps.isOpen && !nextProps.isOpen) { @@ -181,11 +183,17 @@ export class EuiPopover extends Component { onMutation = (records) => { const waitDuration = records.reduce( (waitDuration, record) => { - const computedDuration = window.getComputedStyle(record.target).getPropertyValue('transition-duration'); - const durationMatch = computedDuration.match(/^([\d.]+)/); - if (durationMatch != null) { - waitDuration = Math.max(waitDuration, parseFloat(durationMatch[1]) * 1000); - } + const computedStyle = window.getComputedStyle(record.target); + + const computedDuration = computedStyle.getPropertyValue('transition-duration'); + let durationMatch = computedDuration.match(GROUP_MUMERIC); + durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0; + + const computedDelay = computedStyle.getPropertyValue('transition-delay'); + let delayMatch = computedDelay.match(GROUP_MUMERIC); + delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0; + + waitDuration = Math.max(waitDuration, durationMatch + delayMatch); return waitDuration; }, 0 @@ -209,6 +217,8 @@ export class EuiPopover extends Component { } positionPopover = () => { + if (this.button == null || this.panel == null) return; + const { top, left, position, arrow } = findPopoverPosition({ position: getPopoverPositionFromAnchorPosition(this.props.anchorPosition), align: getPopoverAlignFromAnchorPosition(this.props.anchorPosition), diff --git a/src/utils/mutation_observer/mutation_observer.js b/src/utils/mutation_observer/mutation_observer.js index 84af81b31a0..46c49d61dad 100644 --- a/src/utils/mutation_observer/mutation_observer.js +++ b/src/utils/mutation_observer/mutation_observer.js @@ -1,49 +1,98 @@ -import { Component, cloneElement } from 'react'; +import React, { Component } from 'react'; import { findDOMNode } from 'react-dom'; import PropTypes from 'prop-types'; +/** + * EuiMutationObserver watches its children with the MutationObserver API + * There are a couple constraints which inform how this component works + * + * 1. React refs cannot be added to functional components + * 2. findDOMNode will only return the first element from an array of children + * or from a fragment. + * + * Because of #1, we can't blindly attach refs to children and expect them to work in all cases + * Because of #2, we can't observe all children for mutations, only the first + * + * When only one child is passed its found by findDOMNode and the mutation observer is attached + * When children is an array the render function maps over them wrapping each child + * with another EuiMutationObserver, e.g.: + * + * + *
First
+ *
Second
+ *
+ * + * becomes + * + * + *
First
+ *
Second
+ *
+ * + * each descendant-Observer has only one child and can independently watch for mutations, + * triggering the parent's onMutation callback when an event is observed + */ class EuiMutationObserver extends Component { constructor(...args) { super(...args); - this.childrenRef = null; + this.childNode = null; this.observer = null; } - onMutation = (...args) => { - this.props.onMutation(...args); + componentDidMount() { + this.updateChildNode(); } - updateRef = ref => { - if (this.props.children.ref) { - this.props.children.ref(ref); - } + updateChildNode() { + if (Array.isArray(this.props.children) === false) { + const currentNode = findDOMNode(this); + if (this.childNode !== currentNode) { + // if there's an existing observer disconnect it + if (this.observer != null) { + this.observer.disconnect(); + this.observer = null; + } - if (this.observer != null) { - this.observer.disconnect(); - this.observer = null; + this.childNode = currentNode; + if (this.childNode != null) { + this.observer = new MutationObserver(this.onMutation); + this.observer.observe(this.childNode, this.props.observerOptions); + } + } } + } - if (ref != null) { - const node = findDOMNode(ref); - this.observer = new MutationObserver(this.onMutation); - this.observer.observe(node, this.props.observerOptions); - } + componentDidUpdate() { + // in case the child element was changed + this.updateChildNode(); + } + + onMutation = (...args) => { + this.props.onMutation(...args); } render() { - const children = cloneElement( - this.props.children, - { - ...this.props.children.props, - ref: this.updateRef, - } - ); - return children; + const { children, ...rest } = this.props; + if (Array.isArray(children)) { + return React.Children.map( + children, + child => ( + + {child} + + ) + ); + } else { + return children; + } } } EuiMutationObserver.propTypes = { - children: PropTypes.element.isRequired, + children: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node) + ]).isRequired, observerOptions: PropTypes.shape({ // matches a [MutationObserverInit](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit) attributeFilter: PropTypes.arrayOf(PropTypes.string), attributeOldValue: PropTypes.bool, From 948acdc2c87ecee3bb9cac067d8f960f88ccc057 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 9 Jul 2018 13:48:41 -0600 Subject: [PATCH 06/17] Added full MutationObserver polyfill, added tests for EuiMutationObserver --- scripts/jest/config.json | 1 + scripts/jest/polyfills/mutation_observer.js | 541 ++++++++++++++++++ scripts/jest/setup/polyfills.js | 3 + src/components/accordion/accordion.test.js | 12 - .../mutation_observer.test.js | 88 +++ 5 files changed, 633 insertions(+), 12 deletions(-) create mode 100644 scripts/jest/polyfills/mutation_observer.js create mode 100644 scripts/jest/setup/polyfills.js create mode 100644 src/utils/mutation_observer/mutation_observer.test.js diff --git a/scripts/jest/config.json b/scripts/jest/config.json index 39cbcc92e58..c1cf159a266 100644 --- a/scripts/jest/config.json +++ b/scripts/jest/config.json @@ -17,6 +17,7 @@ "\\.(css|less|scss)$": "/scripts/jest/mocks/style_mock.js" }, "setupFiles": [ + "/scripts/jest/setup/polyfills.js", "/scripts/jest/setup/enzyme.js", "/scripts/jest/setup/throw_on_console_error.js" ], diff --git a/scripts/jest/polyfills/mutation_observer.js b/scripts/jest/polyfills/mutation_observer.js new file mode 100644 index 00000000000..577117aa50d --- /dev/null +++ b/scripts/jest/polyfills/mutation_observer.js @@ -0,0 +1,541 @@ +/* eslint-disable */ +// transpiled typescript->javascript from +// https://github.com/aurelia/pal-nodejs/blob/master/src/polyfills/mutation-observer.ts + +/* +The MIT License (MIT) + +Copyright (c) 2010 - 2018 Blue Spire Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +/* + * Based on Shim for MutationObserver interface + * Author: Graeme Yeates (github.com/megawac) + * Repository: https://github.com/megawac/MutationObserver.js + */ +import { EventEmitter } from 'events'; + +var __extends = (this && this.__extends) || (function () { + var extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); + +module.exports = {}; + +Object.defineProperty(module.exports, "__esModule", { value: true }); +var Util = /** @class */ (function () { + function Util() { + } + Util.clone = function ($target, config) { + var recurse = true; // set true so childList we'll always check the first level + return (function copy($target) { + var elestruct = { + /** @type {Node} */ + node: $target, + charData: null, + attr: null, + kids: null, + }; + // Store current character data of target text or comment node if the config requests + // those properties to be observed. + if (config.charData && ($target.nodeType === 3 || $target.nodeType === 8)) { + elestruct.charData = $target.nodeValue; + } + else { + // Add attr only if subtree is specified or top level and avoid if + // attributes is a document object (#13). + if (config.attr && recurse && $target.nodeType === 1) { + /** + * clone live attribute list to an object structure {name: val} + * @type {Object.} + */ + elestruct.attr = Util.reduce($target.attributes, function (memo, attr) { + if (!config.afilter || config.afilter[attr.name]) { + memo[attr.name] = attr.value; + } + return memo; + }, {}); + } + // whether we should iterate the children of $target node + if (recurse && ((config.kids || config.charData) || (config.attr && config.descendents))) { + /** @type {Array.} : Array of custom clone */ + elestruct.kids = Util.map($target.childNodes, copy); + } + recurse = config.descendents; + } + return elestruct; + })($target); + }; + /** + * indexOf an element in a collection of custom nodes + * + * @param {NodeList} set + * @param {!Object} $node : A custom cloned nodeg333 + * @param {number} idx : index to start the loop + * @return {number} + */ + Util.indexOfCustomNode = function (set, $node, idx) { + var JSCompiler_renameProperty = function (a) { return a; }; + return this.indexOf(set, $node, idx, JSCompiler_renameProperty('node')); + }; + /** + * Attempt to uniquely id an element for hashing. We could optimize this for legacy browsers but it hopefully wont be called enough to be a concern + * + * @param {Node} $ele + * @return {(string|number)} + */ + Util.getElementId = function ($ele) { + try { + return $ele.id || ($ele[this.expando] = $ele[this.expando] || this.counter++); + } + catch (e) { // ie <8 will throw if you set an unknown property on a text node + try { + return $ele.nodeValue; // naive + } + catch (shitie) { // when text node is removed: https://gist.github.com/megawac/8355978 :( + return this.counter++; + } + } + }; + /** + * **map** Apply a mapping function to each item of a set + * @param {Array|NodeList} set + * @param {Function} iterator + */ + Util.map = function (set, iterator) { + var results = []; + for (var index = 0; index < set.length; index++) { + results[index] = iterator(set[index], index, set); + } + return results; + }; + /** + * **Reduce** builds up a single result from a list of values + * @param {Array|NodeList|NamedNodeMap} set + * @param {Function} iterator + * @param {*} [memo] Initial value of the memo. + */ + Util.reduce = function (set, iterator, memo) { + for (var index = 0; index < set.length; index++) { + memo = iterator(memo, set[index], index, set); + } + return memo; + }; + /** + * **indexOf** find index of item in collection. + * @param {Array|NodeList} set + * @param {Object} item + * @param {number} idx + * @param {string} [prop] Property on set item to compare to item + */ + Util.indexOf = function (set, item, idx, prop) { + for ( /*idx = ~~idx*/; idx < set.length; idx++) { // start idx is always given as this is internal + if ((prop ? set[idx][prop] : set[idx]) === item) + return idx; + } + return -1; + }; + /** + * @param {Object} obj + * @param {(string|number)} prop + * @return {boolean} + */ + Util.has = function (obj, prop) { + return obj[prop] !== undefined; // will be nicely inlined by gcc + }; + Util.counter = 1; + Util.expando = 'mo_id'; + return Util; +}()); +module.exports.Util = Util; +var MutationObserver = /** @class */ (function () { + function MutationObserver(listener) { + var _this = this; + this._watched = []; + this._listener = null; + this._period = 30; + this._timeout = null; + this._disposed = false; + this._notifyListener = null; + this._watched = []; + this._listener = listener; + this._period = 30; + this._notifyListener = function () { _this.scheduleMutationCheck(_this); }; + } + MutationObserver.prototype.observe = function ($target, config) { + var settings = { + attr: !!(config.attributes || config.attributeFilter || config.attributeOldValue), + // some browsers enforce that subtree must be set with childList, attributes or characterData. + // We don't care as spec doesn't specify this rule. + kids: !!config.childList, + descendents: !!config.subtree, + charData: !!(config.characterData || config.characterDataOldValue), + afilter: null + }; + MutationNotifier.getInstance().on("changed", this._notifyListener); + var watched = this._watched; + // remove already observed target element from pool + for (var i = 0; i < watched.length; i++) { + if (watched[i].tar === $target) + watched.splice(i, 1); + } + if (config.attributeFilter) { + /** + * converts to a {key: true} dict for faster lookup + * @type {Object.} + */ + settings.afilter = Util.reduce(config.attributeFilter, function (a, b) { + a[b] = true; + return a; + }, {}); + } + watched.push({ + tar: $target, + fn: this.createMutationSearcher($target, settings) + }); + }; + MutationObserver.prototype.takeRecords = function () { + var mutations = []; + var watched = this._watched; + for (var i = 0; i < watched.length; i++) { + watched[i].fn(mutations); + } + return mutations; + }; + MutationObserver.prototype.disconnect = function () { + this._watched = []; // clear the stuff being observed + MutationNotifier.getInstance().removeListener("changed", this._notifyListener); + this._disposed = true; + clearTimeout(this._timeout); // ready for garbage collection + this._timeout = null; + }; + MutationObserver.prototype.createMutationSearcher = function ($target, config) { + var _this = this; + /** type {Elestuct} */ + var $oldstate = Util.clone($target, config); // create the cloned datastructure + /** + * consumes array of mutations we can push to + * + * @param {Array.} mutations + */ + return function (mutations) { + var olen = mutations.length; + var dirty; + if (config.charData && $target.nodeType === 3 && $target.nodeValue !== $oldstate.charData) { + mutations.push(new MutationRecord({ + type: 'characterData', + target: $target, + oldValue: $oldstate.charData + })); + } + // Alright we check base level changes in attributes... easy + if (config.attr && $oldstate.attr) { + _this.findAttributeMutations(mutations, $target, $oldstate.attr, config.afilter); + } + // check childlist or subtree for mutations + if (config.kids || config.descendents) { + dirty = _this.searchSubtree(mutations, $target, $oldstate, config); + } + // reclone data structure if theres changes + if (dirty || mutations.length !== olen) { + /** type {Elestuct} */ + $oldstate = Util.clone($target, config); + } + }; + }; + MutationObserver.prototype.scheduleMutationCheck = function (observer) { + var _this = this; + // Only schedule if there isn't already a timer. + if (!observer._timeout) { + observer._timeout = setTimeout(function () { return _this.mutationChecker(observer); }, this._period); + } + }; + MutationObserver.prototype.mutationChecker = function (observer) { + // allow scheduling a new timer. + observer._timeout = null; + var mutations = observer.takeRecords(); + if (mutations.length) { // fire away + // calling the listener with context is not spec but currently consistent with FF and WebKit + observer._listener(mutations, observer); + } + }; + MutationObserver.prototype.searchSubtree = function (mutations, $target, $oldstate, config) { + var _this = this; + // Track if the tree is dirty and has to be recomputed (#14). + var dirty; + /* + * Helper to identify node rearrangment and stuff... + * There is no gaurentee that the same node will be identified for both added and removed nodes + * if the positions have been shuffled. + * conflicts array will be emptied by end of operation + */ + var _resolveConflicts = function (conflicts, node, $kids, $oldkids, numAddedNodes) { + // the distance between the first conflicting node and the last + var distance = conflicts.length - 1; + // prevents same conflict being resolved twice consider when two nodes switch places. + // only one should be given a mutation event (note -~ is used as a math.ceil shorthand) + var counter = -~((distance - numAddedNodes) / 2); + var $cur; + var oldstruct; + var conflict; + while ((conflict = conflicts.pop())) { + $cur = $kids[conflict.i]; + oldstruct = $oldkids[conflict.j]; + // attempt to determine if there was node rearrangement... won't gaurentee all matches + // also handles case where added/removed nodes cause nodes to be identified as conflicts + if (config.kids && counter && Math.abs(conflict.i - conflict.j) >= distance) { + mutations.push(new MutationRecord({ + type: 'childList', + target: node, + addedNodes: [$cur], + removedNodes: [$cur], + // haha don't rely on this please + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling + })); + counter--; // found conflict + } + // Alright we found the resorted nodes now check for other types of mutations + if (config.attr && oldstruct.attr) + _this.findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + if (config.charData && $cur.nodeType === 3 && $cur.nodeValue !== oldstruct.charData) { + mutations.push(new MutationRecord({ + type: 'characterData', + target: $cur, + oldValue: oldstruct.charData + })); + } + // now look @ subtree + if (config.descendents) + _findMutations($cur, oldstruct); + } + }; + /** + * Main worker. Finds and adds mutations if there are any + * @param {Node} node + * @param {!Object} old : A cloned data structure using internal clone + */ + var _findMutations = function (node, old) { + var $kids = node.childNodes; + var $oldkids = old.kids; + var klen = $kids.length; + // $oldkids will be undefined for text and comment nodes + var olen = $oldkids ? $oldkids.length : 0; + // if (!olen && !klen) return; // both empty; clearly no changes + // we delay the intialization of these for marginal performance in the expected case (actually quite signficant on large subtrees when these would be otherwise unused) + // map of checked element of ids to prevent registering the same conflict twice + var map; + // array of potential conflicts (ie nodes that may have been re arranged) + var conflicts; + var id; // element id from getElementId helper + var idx; // index of a moved or inserted element + var oldstruct; + // current and old nodes + var $cur; + var $old; + // track the number of added nodes so we can resolve conflicts more accurately + var numAddedNodes = 0; + // iterate over both old and current child nodes at the same time + var i = 0; + var j = 0; + // while there is still anything left in $kids or $oldkids (same as i < $kids.length || j < $oldkids.length;) + while (i < klen || j < olen) { + // current and old nodes at the indexs + $cur = $kids[i]; + oldstruct = $oldkids[j]; + $old = oldstruct && oldstruct.node; + if ($cur === $old) { // expected case - optimized for this case + // check attributes as specified by config + if (config.attr && oldstruct.attr) { /* oldstruct.attr instead of textnode check */ + _this.findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + } + // check character data if node is a comment or textNode and it's being observed + if (config.charData && oldstruct.charData !== undefined && $cur.nodeValue !== oldstruct.charData) { + mutations.push(new MutationRecord({ + type: 'characterData', + target: $cur + })); + } + // resolve conflicts; it will be undefined if there are no conflicts - otherwise an array + if (conflicts) + _resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes); + // recurse on next level of children. Avoids the recursive call when there are no children left to iterate + if (config.descendents && ($cur.childNodes.length || oldstruct.kids && oldstruct.kids.length)) + _findMutations($cur, oldstruct); + i++; + j++; + } + else { // (uncommon case) lookahead until they are the same again or the end of children + dirty = true; + if (!map) { // delayed initalization (big perf benefit) + map = {}; + conflicts = []; + } + if ($cur) { + // check id is in the location map otherwise do a indexOf search + if (!(map[id = Util.getElementId($cur)])) { // to prevent double checking + // mark id as found + map[id] = true; + // custom indexOf using comparitor checking oldkids[i].node === $cur + if ((idx = Util.indexOfCustomNode($oldkids, $cur, j)) === -1) { + if (config.kids) { + mutations.push(new MutationRecord({ + type: 'childList', + target: node, + addedNodes: [$cur], + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling + })); + numAddedNodes++; + } + } + else { + conflicts.push({ + i: i, + j: idx + }); + } + } + i++; + } + if ($old && + // special case: the changes may have been resolved: i and j appear congurent so we can continue using the expected case + $old !== $kids[i]) { + if (!(map[id = Util.getElementId($old)])) { + map[id] = true; + if ((idx = Util.indexOf($kids, $old, i)) === -1) { + if (config.kids) { + mutations.push(new MutationRecord({ + type: 'childList', + target: old.node, + removedNodes: [$old], + nextSibling: $oldkids[j + 1], + previousSibling: $oldkids[j - 1] + })); + numAddedNodes--; + } + } + else { + conflicts.push({ + i: idx, + j: j + }); + } + } + j++; + } + } // end uncommon case + } // end loop + // resolve any remaining conflicts + if (conflicts) + _resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes); + }; + _findMutations($target, $oldstate); + return dirty; + }; + MutationObserver.prototype.findAttributeMutations = function (mutations, $target, $oldstate, filter) { + var checked = {}; + var attributes = $target.attributes; + var attr; + var name; + var i = attributes.length; + while (i--) { + attr = attributes[i]; + name = attr.name; + if (!filter || Util.has(filter, name)) { + if (attr.value !== $oldstate[name]) { + // The pushing is redundant but gzips very nicely + mutations.push(new MutationRecord({ + type: 'attributes', + target: $target, + attributeName: name, + oldValue: $oldstate[name], + attributeNamespace: attr.namespaceURI // in ie<8 it incorrectly will return undefined + })); + } + checked[name] = true; + } + } + for (name in $oldstate) { + if (!(checked[name])) { + mutations.push(new MutationRecord({ + target: $target, + type: 'attributes', + attributeName: name, + oldValue: $oldstate[name] + })); + } + } + }; + return MutationObserver; +}()); +module.exports.MutationObserver = MutationObserver; +var MutationRecord = /** @class */ (function () { + function MutationRecord(data) { + var settings = { + type: null, + target: null, + addedNodes: [], + removedNodes: [], + previousSibling: null, + nextSibling: null, + attributeName: null, + attributeNamespace: null, + oldValue: null + }; + for (var prop in data) { + if (Util.has(settings, prop) && data[prop] !== undefined) + settings[prop] = data[prop]; + } + return settings; + } + return MutationRecord; +}()); +module.exports.MutationRecord = MutationRecord; +var MutationNotifier = /** @class */ (function (_super) { + __extends(MutationNotifier, _super); + function MutationNotifier() { + var _this = _super.call(this) || this; + _this.setMaxListeners(100); + return _this; + } + MutationNotifier.getInstance = function () { + if (!MutationNotifier._instance) { + MutationNotifier._instance = new MutationNotifier(); + } + return MutationNotifier._instance; + }; + MutationNotifier.prototype.destruct = function () { + this.removeAllListeners("changed"); + }; + MutationNotifier.prototype.notifyChanged = function (node) { + this.emit("changed", node); + }; + MutationNotifier._instance = null; + return MutationNotifier; +}(EventEmitter)); +module.exports.MutationNotifier = MutationNotifier; diff --git a/scripts/jest/setup/polyfills.js b/scripts/jest/setup/polyfills.js new file mode 100644 index 00000000000..810feaefc82 --- /dev/null +++ b/scripts/jest/setup/polyfills.js @@ -0,0 +1,3 @@ +import { MutationObserver } from '../polyfills/mutation_observer'; + +Object.defineProperty(window, 'MutationObserver', { value: MutationObserver }); diff --git a/src/components/accordion/accordion.test.js b/src/components/accordion/accordion.test.js index 3d4aa26e2b2..2832314e9ba 100644 --- a/src/components/accordion/accordion.test.js +++ b/src/components/accordion/accordion.test.js @@ -81,18 +81,6 @@ describe('EuiAccordion', () => { }); describe('behavior', () => { - beforeAll(() => { - global.MutationObserver = class { - constructor() {} - disconnect() {} - observe() {} - }; - }); - - afterAll(() => { - delete global.MutationObserver; - }); - it('opens when clicked once', () => { const component = mount( { + setTimeout(resolve, duration); + }); +} + +/** + * Helper method to execute - and wait for - any mutation observers within a components's tree + * @param component {EnzymeComponent} mounted component to find and run observers in + * @returns {Promise} + */ +export async function runObserversOnComponent(component) { + const observerPromises = []; + + component.find('EuiMutationObserver').forEach( + mutationObserver => { + const observer = mutationObserver.instance().observer; + if (observer != null) { + // `observer` is an instance of a polyfill (polyfills/mutation_observer.js + // which has an internal method to force it to update + observer._notifyListener(); + observerPromises.push(sleep(observer._period)); + } + } + ); + + return Promise.all(observerPromises); +} + +describe('EuiMutationObserver', () => { + it('watches for a mutation', async () => { + expect.assertions(1); + const onMutation = jest.fn(); + + function Wrapper({ value }) { + return ( + + + + ); + } + function Child({ value }) { + return ( +
Hello World
+ ); + } + + const component = mount(); + + component.setProps({ value: 6 }); + + await runObserversOnComponent(component); + + expect(onMutation).toHaveBeenCalledTimes(1); + }); + + it('watches for mutation against multiple children', async () => { + expect.assertions(1); + const onMutation = jest.fn(); + + function Wrapper({ value }) { + return ( + + + + + + ); + } + function Child({ value }) { + return ( +
Hello World
+ ); + } + + const component = mount(); + + component.setProps({ value: 6 }); + + await runObserversOnComponent(component); + + expect(onMutation).toHaveBeenCalledTimes(1); + }); +}); From 12b01cc8b00e7e0e3f661e4034f7aafe6ae496fa Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Tue, 10 Jul 2018 15:10:05 -0600 Subject: [PATCH 07/17] Allow EuiPortal to be inserted at specific locations in the DOM --- src-docs/src/views/portal/portal_example.js | 22 +++++++++ src-docs/src/views/portal/portal_insert.js | 49 +++++++++++++++++++++ src/components/popover/popover.js | 6 +-- src/components/portal/portal.js | 31 +++++++++++-- 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src-docs/src/views/portal/portal_insert.js diff --git a/src-docs/src/views/portal/portal_example.js b/src-docs/src/views/portal/portal_example.js index d56ce50d40e..b720d56ed69 100644 --- a/src-docs/src/views/portal/portal_example.js +++ b/src-docs/src/views/portal/portal_example.js @@ -15,6 +15,10 @@ import { Portal } from './portal'; const portalSource = require('!!raw-loader!./portal'); const portalHtml = renderToHtml(Portal); +import { PortalInsert } from './portal_insert'; +const portalInsertSource = require('!!raw-loader!./portal_insert'); +const portalInsertHtml = renderToHtml(PortalInsert); + export const PortalExample = { title: 'Portal', sections: [{ @@ -35,5 +39,23 @@ export const PortalExample = { ), components: { EuiPortal }, demo: , + }, { + title: 'Inserting Portals', + source: [{ + type: GuideSectionTypes.JS, + code: portalInsertSource, + }, { + type: GuideSectionTypes.HTML, + code: portalInsertHtml, + }], + text: ( +

+ There is an optional insert prop that can specify the portal"s + location in the DOM. When used, it is important to consider how the location relates + to the component lifecycle, as it could be removed from the DOM by another component + update. +

+ ), + demo: , }], }; diff --git a/src-docs/src/views/portal/portal_insert.js b/src-docs/src/views/portal/portal_insert.js new file mode 100644 index 00000000000..06a8cd66bcb --- /dev/null +++ b/src-docs/src/views/portal/portal_insert.js @@ -0,0 +1,49 @@ +import React, { + Component, +} from 'react'; + +import { + EuiPortal, + EuiButton, +} from '../../../../src/components'; +import { EuiSpacer } from '../../../../src/components/spacer/spacer'; + +export class PortalInsert extends Component { + constructor(props) { + super(props); + + this.buttonRef = null; + + this.state = { + isPortalVisible: false, + }; + } + + setButtonRef = node => this.buttonRef = node + + togglePortal = () => { + this.setState(prevState => ({ isPortalVisible: !prevState.isPortalVisible })); + } + + render() { + + let portal; + + if (this.state.isPortalVisible) { + portal = ( + + +

This element is appended immediately after the button.

+
+ ); + } + return ( +
+ + Toggle portal + + {portal} +
+ ); + } +} diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index b12f4dfc20d..a621beab962 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -74,7 +74,7 @@ const DEFAULT_POPOVER_STYLES = { left: 50, }; -const GROUP_MUMERIC = /^([\d.]+)/; +const GROUP_NUMERIC = /^([\d.]+)/; export class EuiPopover extends Component { static getDerivedStateFromProps(nextProps, prevState) { @@ -186,11 +186,11 @@ export class EuiPopover extends Component { const computedStyle = window.getComputedStyle(record.target); const computedDuration = computedStyle.getPropertyValue('transition-duration'); - let durationMatch = computedDuration.match(GROUP_MUMERIC); + let durationMatch = computedDuration.match(GROUP_NUMERIC); durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0; const computedDelay = computedStyle.getPropertyValue('transition-delay'); - let delayMatch = computedDelay.match(GROUP_MUMERIC); + let delayMatch = computedDelay.match(GROUP_NUMERIC); delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0; waitDuration = Math.max(waitDuration, durationMatch + delayMatch); diff --git a/src/components/portal/portal.js b/src/components/portal/portal.js index 35b5143fcba..14d1b8da1dd 100644 --- a/src/components/portal/portal.js +++ b/src/components/portal/portal.js @@ -5,7 +5,14 @@ import { Component } from 'react'; import PropTypes from 'prop-types'; -import { createPortal } from 'react-dom'; +import { createPortal, findDOMNode } from 'react-dom'; + +export const insertPositions = { + 'after': 'afterend', + 'before': 'beforebegin', +}; + +export const INSERT_POSITIONS = Object.keys(insertPositions); export class EuiPortal extends Component { constructor(props) { @@ -13,14 +20,25 @@ export class EuiPortal extends Component { const { children, // eslint-disable-line no-unused-vars + insert, } = this.props; this.portalNode = document.createElement('div'); - document.body.appendChild(this.portalNode); + + if (insert == null) { + // no insertion defined, append to body + document.body.appendChild(this.portalNode); + } else { + // inserting before or after an element + findDOMNode(insert.sibling).insertAdjacentElement( + insertPositions[insert.position], + this.portalNode + ); + } } componentWillUnmount() { - document.body.removeChild(this.portalNode); + this.portalNode.parentNode.removeChild(this.portalNode); this.portalNode = null; } @@ -34,4 +52,11 @@ export class EuiPortal extends Component { EuiPortal.propTypes = { children: PropTypes.node, + insert: PropTypes.shape({ + sibling: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.instanceOf(HTMLElement) + ]).isRequired, + position: PropTypes.oneOf(INSERT_POSITIONS) + }) }; From 0649bed472e2b5133b5c0f3f7111d8362683d78f Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Tue, 10 Jul 2018 16:10:29 -0600 Subject: [PATCH 08/17] Added documentation for EuiMutationObserver --- src-docs/src/routes.js | 4 + .../mutation_observer/mutation_observer.js | 80 +++++++++++++++++++ .../mutation_observer_example.js | 39 +++++++++ src/components/accordion/accordion.js | 2 +- src/components/index.js | 4 + .../mutation_observer/index.js | 0 .../mutation_observer/mutation_observer.js | 0 .../mutation_observer.test.js | 0 src/components/popover/popover.js | 2 +- src/utils/index.js | 1 - 10 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src-docs/src/views/mutation_observer/mutation_observer.js create mode 100644 src-docs/src/views/mutation_observer/mutation_observer_example.js rename src/{utils => components}/mutation_observer/index.js (100%) rename src/{utils => components}/mutation_observer/mutation_observer.js (100%) rename src/{utils => components}/mutation_observer/mutation_observer.test.js (100%) diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index ecccf285268..d82e8706014 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -153,6 +153,9 @@ import { LoadingExample } import { ModalExample } from './views/modal/modal_example'; +import { MutationObserverExample } + from './views/mutation_observer/mutation_observer_example'; + import { OutsideClickDetectorExample } from './views/outside_click_detector/outside_click_detector_example'; @@ -383,6 +386,7 @@ const navigation = [{ PortalExample, ToggleExample, UtilityClassesExample, + MutationObserverExample, ].map(example => createExample(example)), }, { name: 'Package', diff --git a/src-docs/src/views/mutation_observer/mutation_observer.js b/src-docs/src/views/mutation_observer/mutation_observer.js new file mode 100644 index 00000000000..d51c470ad03 --- /dev/null +++ b/src-docs/src/views/mutation_observer/mutation_observer.js @@ -0,0 +1,80 @@ +import React, { + Component, +} from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiMutationObserver, + EuiPanel, + EuiSpacer, +} from '../../../../src/components'; + +export class MutationObserver extends Component { + constructor(props) { + super(props); + + this.state = { + lastMutation: 'no changes detected', + buttonColor: 'primary', + items: ['Item 1', 'Item 2', 'Item 3'], + }; + } + + toggleButtonColor = () => { + this.setState(({ buttonColor }) => ({ + buttonColor: buttonColor === 'primary' ? 'warning' : 'primary' + })); + } + + addItem = () => { + this.setState(({ items }) => ({ + items: [...items, `Item ${items.length + 1}`] + })); + } + + onMutation = ([{ type }]) => { + this.setState({ + lastMutation: type === 'attributes' + ? 'button class name changed' + : 'DOM tree changed' + }); + } + + render() { + return ( +
+

{this.state.lastMutation}

+ + + + + + + Toggle button color + + + + + + + +
    + {this.state.items.map(item =>
  • {item}
  • )} +
+ + add item +
+
+
+ +
+
+ ); + } +} diff --git a/src-docs/src/views/mutation_observer/mutation_observer_example.js b/src-docs/src/views/mutation_observer/mutation_observer_example.js new file mode 100644 index 00000000000..53177e9c851 --- /dev/null +++ b/src-docs/src/views/mutation_observer/mutation_observer_example.js @@ -0,0 +1,39 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiMutationObserver, +} from '../../../../src/components'; + +import { MutationObserver } from './mutation_observer'; +const mutationObserverSource = require('!!raw-loader!./mutation_observer'); +const mutationObserverHtml = renderToHtml(MutationObserver); + +export const MutationObserverExample = { + title: 'MutationObserver', + sections: [{ + title: 'MutationObserver', + source: [{ + type: GuideSectionTypes.JS, + code: mutationObserverSource, + }, { + type: GuideSectionTypes.HTML, + code: mutationObserverHtml, + }], + text: ( +

+ MutationObserver is a wrapper around the Mutation Observer API. + It takes the same configuration object describing what to watch for and fires the + callback when that mutation happens. +

+ ), + components: { EuiMutationObserver }, + demo: , + }], +}; diff --git a/src/components/accordion/accordion.js b/src/components/accordion/accordion.js index f5133a26703..624c1f7b5d5 100644 --- a/src/components/accordion/accordion.js +++ b/src/components/accordion/accordion.js @@ -15,7 +15,7 @@ import { import { EuiMutationObserver, -} from '../../utils/mutation_observer'; +} from '../mutation_observer'; const paddingSizeToClassNameMap = { none: null, diff --git a/src/components/index.js b/src/components/index.js index 3f98e19e887..9f7db1e09a0 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -195,6 +195,10 @@ export { EuiModalHeaderTitle, } from './modal'; +export { + EuiMutationObserver, +} from './mutation_observer'; + export { EuiOutsideClickDetector, } from './outside_click_detector'; diff --git a/src/utils/mutation_observer/index.js b/src/components/mutation_observer/index.js similarity index 100% rename from src/utils/mutation_observer/index.js rename to src/components/mutation_observer/index.js diff --git a/src/utils/mutation_observer/mutation_observer.js b/src/components/mutation_observer/mutation_observer.js similarity index 100% rename from src/utils/mutation_observer/mutation_observer.js rename to src/components/mutation_observer/mutation_observer.js diff --git a/src/utils/mutation_observer/mutation_observer.test.js b/src/components/mutation_observer/mutation_observer.test.js similarity index 100% rename from src/utils/mutation_observer/mutation_observer.test.js rename to src/components/mutation_observer/mutation_observer.test.js diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index a621beab962..023f56ca636 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -16,7 +16,7 @@ import { EuiPanel, SIZES } from '../panel'; import { EuiPortal } from '../portal'; -import { EuiMutationObserver } from '../../utils/mutation_observer'; +import { EuiMutationObserver } from '../mutation_observer'; import { findPopoverPosition, getElementZIndex } from '../../services/popover/popover_positioning'; diff --git a/src/utils/index.js b/src/utils/index.js index b57a93b5f79..f4a6a69a4cc 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,2 +1 @@ -export * from './mutation_observer'; export * from './prop_types'; From 7545fc247bb0eebcd69974e2a2dc0eff10947190 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Tue, 10 Jul 2018 16:51:04 -0600 Subject: [PATCH 09/17] EuiPopover now watches for text changes too --- src/components/popover/popover.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 023f56ca636..e155ff997d3 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -183,17 +183,21 @@ export class EuiPopover extends Component { onMutation = (records) => { const waitDuration = records.reduce( (waitDuration, record) => { - const computedStyle = window.getComputedStyle(record.target); + // only check for CSS transition values for ELEMENT nodes + if (record.nodeType === document.ELEMENT_NODE) { + const computedStyle = window.getComputedStyle(record.target); - const computedDuration = computedStyle.getPropertyValue('transition-duration'); - let durationMatch = computedDuration.match(GROUP_NUMERIC); - durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0; + const computedDuration = computedStyle.getPropertyValue('transition-duration'); + let durationMatch = computedDuration.match(GROUP_NUMERIC); + durationMatch = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0; - const computedDelay = computedStyle.getPropertyValue('transition-delay'); - let delayMatch = computedDelay.match(GROUP_NUMERIC); - delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0; + const computedDelay = computedStyle.getPropertyValue('transition-delay'); + let delayMatch = computedDelay.match(GROUP_NUMERIC); + delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0; + + waitDuration = Math.max(waitDuration, durationMatch + delayMatch); + } - waitDuration = Math.max(waitDuration, durationMatch + delayMatch); return waitDuration; }, 0 @@ -362,7 +366,12 @@ export class EuiPopover extends Component { children ? ( {children} From 08b86bc04d4066773e0c359e2bb0782b69a477fd Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Wed, 11 Jul 2018 11:43:18 -0600 Subject: [PATCH 10/17] Add EuiWrappingPopover to allow non-React elements to be used as popover anchors --- src-docs/src/views/popover/popover_example.js | 23 ++++++ .../popover/popover_htmlelement_anchor.js | 81 +++++++++++++++++++ src/components/index.js | 1 + src/components/popover/index.js | 1 + src/components/popover/popover.js | 7 +- src/components/popover/wrapping_popover.js | 57 +++++++++++++ src/components/portal/portal.js | 14 +++- 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src-docs/src/views/popover/popover_htmlelement_anchor.js create mode 100644 src/components/popover/wrapping_popover.js diff --git a/src-docs/src/views/popover/popover_example.js b/src-docs/src/views/popover/popover_example.js index 957d448db69..057b0c03849 100644 --- a/src-docs/src/views/popover/popover_example.js +++ b/src-docs/src/views/popover/popover_example.js @@ -36,6 +36,11 @@ import PopoverWithTitlePadding from './popover_with_title_padding'; const popoverWithTitlePaddingSource = require('!!raw-loader!./popover_with_title_padding'); const popoverWithTitlePaddingHtml = renderToHtml(PopoverWithTitlePadding); +import PopoverHTMLElementAnchor from './popover_htmlelement_anchor'; +const popoverHTMLElementAnchorSource = require('!!raw-loader!./popover_htmlelement_anchor'); +const popoverHTMLElementAnchorHtml = renderToHtml(PopoverHTMLElementAnchor); + + export const PopoverExample = { title: 'Popover', sections: [{ @@ -158,5 +163,23 @@ export const PopoverExample = {
), demo: , + }, { + title: 'Popover using an HTMLElement as the anchor', + source: [{ + type: GuideSectionTypes.JS, + code: popoverHTMLElementAnchorSource, + }, { + type: GuideSectionTypes.HTML, + code: popoverHTMLElementAnchorHtml, + }], + text: ( +
+

+ EuiWrappingPopover is an extra popover component that allows + any existing DOM element to be passed as the button prop. +

+
+ ), + demo: , }], }; diff --git a/src-docs/src/views/popover/popover_htmlelement_anchor.js b/src-docs/src/views/popover/popover_htmlelement_anchor.js new file mode 100644 index 00000000000..344782d46cc --- /dev/null +++ b/src-docs/src/views/popover/popover_htmlelement_anchor.js @@ -0,0 +1,81 @@ +/* eslint-disable react/no-multi-comp */ +import React, { + Component, +} from 'react'; + +import { findDOMNode, render, unmountComponentAtNode } from 'react-dom'; + +import { + EuiWrappingPopover, +} from '../../../../src/components'; + +class PopoverApp extends Component { + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + componentDidMount() { + this.props.anchor.addEventListener('click', this.onButtonClick); + } + + onButtonClick = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + } + + render() { + return ( + +
Normal JSX content populates the popover.
+
+ ); + } +} + +export default class extends Component { + componentDidMount() { + const thisNode = findDOMNode(this); + const thisAnchor = thisNode.querySelector('button'); + + // `container` can be created here or use an existing DOM element + // the popover DOM is positioned independently of where the container exists + this.container = document.createElement('div'); + document.body.appendChild(this.container); + + render( + , + this.container + ); + } + + componentWillUnmount() { + unmountComponentAtNode(this.container); + } + + render() { + return ( +
+ This is an HTML button + + ` }} + /> + ); + } +} diff --git a/src/components/index.js b/src/components/index.js index 9f7db1e09a0..692160038c2 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -231,6 +231,7 @@ export { export { EuiPopover, EuiPopoverTitle, + EuiWrappingPopover, } from './popover'; export { diff --git a/src/components/popover/index.js b/src/components/popover/index.js index 554cf3529b2..32e01e77191 100644 --- a/src/components/popover/index.js +++ b/src/components/popover/index.js @@ -1,2 +1,3 @@ export { EuiPopover } from './popover'; export { EuiPopoverTitle } from './popover_title'; +export { EuiWrappingPopover } from './wrapping_popover'; diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index e155ff997d3..a8321af08ba 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -395,7 +395,7 @@ export class EuiPopover extends Component { {...rest} >
- {button} + {button instanceof HTMLElement ? null : button}
{panel}
@@ -409,7 +409,10 @@ EuiPopover.propTypes = { ownFocus: PropTypes.bool, withTitle: PropTypes.bool, closePopover: PropTypes.func.isRequired, - button: PropTypes.node.isRequired, + button: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.instanceOf(HTMLElement), + ]).isRequired, children: PropTypes.node, anchorPosition: PropTypes.oneOf(ANCHOR_POSITIONS), panelClassName: PropTypes.string, diff --git a/src/components/popover/wrapping_popover.js b/src/components/popover/wrapping_popover.js new file mode 100644 index 00000000000..9f14f274ae1 --- /dev/null +++ b/src/components/popover/wrapping_popover.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { EuiPopover } from './popover'; +import { EuiPortal } from '../portal'; + +/** + * Injects the EuiPopover next to the button via EuiPortal + * then the button element is moved into the popover dom. + * On unmount, the button is moved back to its original location. + */ +export class EuiWrappingPopover extends Component { + constructor(...args) { + super(...args); + + this.portal = null; + this.contentParent = this.props.button.parentNode; + } + + componentDidMount() { + const thisDomNode = findDOMNode(this); + const placeholderAnchor = thisDomNode.querySelector('.euiWrappingPopover__anchor'); + + placeholderAnchor.insertAdjacentElement( + 'beforebegin', + this.props.button + ); + } + + componentWillUnmount() { + this.portal.insertAdjacentElement( + 'beforebegin', + this.props.button + ); + } + + setPortalRef = node => { + this.portal = node; + }; + + render() { + const { + button, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + return ( + + } + /> + + ); + } +} diff --git a/src/components/portal/portal.js b/src/components/portal/portal.js index 14d1b8da1dd..7dd75de5cf7 100644 --- a/src/components/portal/portal.js +++ b/src/components/portal/portal.js @@ -37,9 +37,20 @@ export class EuiPortal extends Component { } } + componentDidMount() { + this.updatePortalRef(); + } + componentWillUnmount() { this.portalNode.parentNode.removeChild(this.portalNode); this.portalNode = null; + this.updatePortalRef(); + } + + updatePortalRef() { + if (this.props.portalRef) { + this.props.portalRef(this.portalNode); + } } render() { @@ -57,6 +68,7 @@ EuiPortal.propTypes = { PropTypes.node, PropTypes.instanceOf(HTMLElement) ]).isRequired, - position: PropTypes.oneOf(INSERT_POSITIONS) + position: PropTypes.oneOf(INSERT_POSITIONS), + portalRef: PropTypes.func, }) }; From f736f8ac1730d32582c9735ed52edb876ce81d49 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Wed, 11 Jul 2018 12:26:17 -0600 Subject: [PATCH 11/17] changelog and some cleanup --- CHANGELOG.md | 5 +++++ src/components/accordion/accordion.js | 10 ---------- src/components/context_menu/context_menu.js | 1 - src/components/popover/popover.js | 10 ++-------- 4 files changed, 7 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6037a4d5a78..3ab3e5175ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added `EuiMutationObserver` to expose Mutation Observer API to React components ([#966](https://github.com/elastic/eui/pull/966)) +- Added `EuiWrappingPopover` which allows existing non-React elements to be popover anchors ([#966](https://github.com/elastic/eui/pull/966)) +- `EuiPortal` can inject content at arbitrary DOM locations, added `portalRef` prop ([#966](https://github.com/elastic/eui/pull/966)) + **Bug fixes** - `EuiInMemoryTable` no longer resets to the first page on prop update when `items` remains the same ([#1008](https://github.com/elastic/eui/pull/1008)) +- `EuiPopover` re-positions with dynamic content (including CSS height/width transitions) ([#966](https://github.com/elastic/eui/pull/966)) ## [`2.0.0`](https://github.com/elastic/eui/tree/v2.0.0) diff --git a/src/components/accordion/accordion.js b/src/components/accordion/accordion.js index 624c1f7b5d5..5e4bd73a8f5 100644 --- a/src/components/accordion/accordion.js +++ b/src/components/accordion/accordion.js @@ -62,16 +62,6 @@ export class EuiAccordion extends Component { setChildContentRef = (node) => { this.childContent = node; - // - // if (this.observer) { - // this.observer.disconnect(); - // this.observer = null; - // } - // - // if (node) { - // this.observer = new MutationObserver(this.setChildContentHeight); - // this.observer.observe(this.childContent, { childList: true, subtree: true }); - // } } render() { diff --git a/src/components/context_menu/context_menu.js b/src/components/context_menu/context_menu.js index 6a03e5b2961..f9472c49224 100644 --- a/src/components/context_menu/context_menu.js +++ b/src/components/context_menu/context_menu.js @@ -177,7 +177,6 @@ export class EuiContextMenu extends Component { if (height === prevHeight) { return null; } else { - // this.onTransitionStart(); return { height }; } }); diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index a8321af08ba..5a0afd77047 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -184,7 +184,7 @@ export class EuiPopover extends Component { const waitDuration = records.reduce( (waitDuration, record) => { // only check for CSS transition values for ELEMENT nodes - if (record.nodeType === document.ELEMENT_NODE) { + if (record.target.nodeType === document.ELEMENT_NODE) { const computedStyle = window.getComputedStyle(record.target); const computedDuration = computedStyle.getPropertyValue('transition-duration'); @@ -249,13 +249,7 @@ export class EuiPopover extends Component { const arrowStyles = arrow; const arrowPosition = position; - this.setState(({ popoverStyles: lastPosition }) => { - // only call setState if the top or left values have changed - if (lastPosition.top === popoverStyles.top && lastPosition.left === popoverStyles.left) { - return null; - } - return { popoverStyles, arrowStyles, arrowPosition }; - }); + this.setState({ popoverStyles, arrowStyles, arrowPosition }); } panelRef = node => { From ce8a6d220403aae5b38a9350eb46e8fc7cafe62a Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Wed, 11 Jul 2018 13:23:13 -0600 Subject: [PATCH 12/17] Add container prop to EuiPopover --- .../src/views/popover/popover_container.js | 69 +++++++++++++++++++ src-docs/src/views/popover/popover_example.js | 23 +++++++ src/components/popover/popover.js | 14 ++-- src/components/popover/wrapping_popover.js | 5 ++ 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 src-docs/src/views/popover/popover_container.js diff --git a/src-docs/src/views/popover/popover_container.js b/src-docs/src/views/popover/popover_container.js new file mode 100644 index 00000000000..39c0de0872a --- /dev/null +++ b/src-docs/src/views/popover/popover_container.js @@ -0,0 +1,69 @@ +import React, { + Component, +} from 'react'; + +import { + EuiButton, + EuiCode, + EuiPanel, + EuiPopover, + EuiSpacer, +} from '../../../../src/components'; + +export default class PopoverContainer extends Component { + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + onButtonClick = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + } + + setPanelRef = node => this.panel = node; + + render() { + const button = ( + + Show constrained popover + + ); + + return ( + + +
+ Popover is positioned downCenter but + constrained to fit within the panel. +
+
+ + {/* create adequate room for the popover */} + + +
+ ); + } +} diff --git a/src-docs/src/views/popover/popover_example.js b/src-docs/src/views/popover/popover_example.js index 057b0c03849..356ad0fee10 100644 --- a/src-docs/src/views/popover/popover_example.js +++ b/src-docs/src/views/popover/popover_example.js @@ -40,6 +40,10 @@ import PopoverHTMLElementAnchor from './popover_htmlelement_anchor'; const popoverHTMLElementAnchorSource = require('!!raw-loader!./popover_htmlelement_anchor'); const popoverHTMLElementAnchorHtml = renderToHtml(PopoverHTMLElementAnchor); +import PopoverContainer from './popover_container'; +const popoverContainerSource = require('!!raw-loader!./popover_htmlelement_anchor'); +const popoverContainerHtml = renderToHtml(PopoverHTMLElementAnchor); + export const PopoverExample = { title: 'Popover', @@ -163,6 +167,25 @@ export const PopoverExample = {
), demo: , + }, { + title: 'Constraining a popover inside a container', + source: [{ + type: GuideSectionTypes.JS, + code: popoverContainerSource, + }, { + type: GuideSectionTypes.HTML, + code: popoverContainerHtml, + }], + text: ( +
+

+ EuiPopover can accept a React or DOM element as + a container prop and restrict the popover from + overflowing that container. +

+
+ ), + demo: , }, { title: 'Popover using an HTMLElement as the anchor', source: [{ diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 5a0afd77047..1e9e3957a0f 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -224,6 +224,7 @@ export class EuiPopover extends Component { if (this.button == null || this.panel == null) return; const { top, left, position, arrow } = findPopoverPosition({ + container: this.props.container, position: getPopoverPositionFromAnchorPosition(this.props.anchorPosition), align: getPopoverAlignFromAnchorPosition(this.props.anchorPosition), anchor: this.button, @@ -235,7 +236,7 @@ export class EuiPopover extends Component { } }); - // the popver's z-index must inherit from the button + // the popover's z-index must inherit from the button // this keeps a button's popover under a flyout that would cover the button // but a popover triggered inside a flyout will appear over that flyout const zIndex = getElementZIndex(this.button, this.panel); @@ -403,15 +404,16 @@ EuiPopover.propTypes = { ownFocus: PropTypes.bool, withTitle: PropTypes.bool, closePopover: PropTypes.func.isRequired, - button: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.instanceOf(HTMLElement), - ]).isRequired, + button: PropTypes.node.isRequired, children: PropTypes.node, anchorPosition: PropTypes.oneOf(ANCHOR_POSITIONS), panelClassName: PropTypes.string, panelPaddingSize: PropTypes.oneOf(SIZES), - popoverRef: PropTypes.func + popoverRef: PropTypes.func, + container: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.instanceOf(HTMLElement) + ]), }; EuiPopover.defaultProps = { diff --git a/src/components/popover/wrapping_popover.js b/src/components/popover/wrapping_popover.js index 9f14f274ae1..542d779af56 100644 --- a/src/components/popover/wrapping_popover.js +++ b/src/components/popover/wrapping_popover.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { findDOMNode } from 'react-dom'; +import PropTypes from 'prop-types'; import { EuiPopover } from './popover'; import { EuiPortal } from '../portal'; @@ -55,3 +56,7 @@ export class EuiWrappingPopover extends Component { ); } } + +EuiWrappingPopover.propTypes = { + button: PropTypes.instanceOf(HTMLElement), +}; From 3193dc038bb4064d5c16f8709db1244649a7b6a3 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Wed, 11 Jul 2018 13:25:17 -0600 Subject: [PATCH 13/17] more changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab3e5175ac..6ea1194e2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Added `EuiMutationObserver` to expose Mutation Observer API to React components ([#966](https://github.com/elastic/eui/pull/966)) - Added `EuiWrappingPopover` which allows existing non-React elements to be popover anchors ([#966](https://github.com/elastic/eui/pull/966)) +- `EuiPopover` accepts a `container` prop to further restrict popover placement ([#966](https://github.com/elastic/eui/pull/966)) - `EuiPortal` can inject content at arbitrary DOM locations, added `portalRef` prop ([#966](https://github.com/elastic/eui/pull/966)) **Bug fixes** From 3c7415b7a34175fabac32780563e32d4b94d13ae Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 12 Jul 2018 12:00:16 -0600 Subject: [PATCH 14/17] PR feedback --- .../mutation_observer_example.js | 8 +++++-- src-docs/src/views/popover/popover_example.js | 4 ++-- src-docs/src/views/portal/portal_example.js | 21 +++++++++++++------ src-docs/src/views/portal/portal_insert.js | 4 ++-- src/components/portal/portal.js | 5 +++-- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src-docs/src/views/mutation_observer/mutation_observer_example.js b/src-docs/src/views/mutation_observer/mutation_observer_example.js index 53177e9c851..9357ea629fd 100644 --- a/src-docs/src/views/mutation_observer/mutation_observer_example.js +++ b/src-docs/src/views/mutation_observer/mutation_observer_example.js @@ -8,6 +8,7 @@ import { import { EuiCode, + EuiLink, EuiMutationObserver, } from '../../../../src/components'; @@ -28,8 +29,11 @@ export const MutationObserverExample = { }], text: (

- MutationObserver is a wrapper around the Mutation Observer API. - It takes the same configuration object describing what to watch for and fires the + MutationObserver is a wrapper around the + Mutation Observer API + which allows watching for DOM changes to elements and their children. + MutationObserver takes the same configuration object + as the browser API to describe what to watch for, and fires the callback when that mutation happens.

), diff --git a/src-docs/src/views/popover/popover_example.js b/src-docs/src/views/popover/popover_example.js index 356ad0fee10..4b86b9d15ed 100644 --- a/src-docs/src/views/popover/popover_example.js +++ b/src-docs/src/views/popover/popover_example.js @@ -41,8 +41,8 @@ const popoverHTMLElementAnchorSource = require('!!raw-loader!./popover_htmleleme const popoverHTMLElementAnchorHtml = renderToHtml(PopoverHTMLElementAnchor); import PopoverContainer from './popover_container'; -const popoverContainerSource = require('!!raw-loader!./popover_htmlelement_anchor'); -const popoverContainerHtml = renderToHtml(PopoverHTMLElementAnchor); +const popoverContainerSource = require('!!raw-loader!./popover_container'); +const popoverContainerHtml = renderToHtml(PopoverContainer); export const PopoverExample = { diff --git a/src-docs/src/views/portal/portal_example.js b/src-docs/src/views/portal/portal_example.js index b720d56ed69..35aa1ef714b 100644 --- a/src-docs/src/views/portal/portal_example.js +++ b/src-docs/src/views/portal/portal_example.js @@ -49,13 +49,22 @@ export const PortalExample = { code: portalInsertHtml, }], text: ( -

- There is an optional insert prop that can specify the portal"s - location in the DOM. When used, it is important to consider how the location relates - to the component lifecycle, as it could be removed from the DOM by another component - update. -

+ +

+ There is an optional insert prop that can specify the portal's + location in the DOM. When used, it is important to consider how the location relates + to the component lifecycle, as it could be removed from the DOM by another component + update. +

+

+ insert is an object with two key/values: sibling + and position. sibling is either the React node or HTMLElement + the portal should be inserted next to, and position specifies the portals relative + position, either before or after. +

+
), + props: { EuiPortal }, demo: , }], }; diff --git a/src-docs/src/views/portal/portal_insert.js b/src-docs/src/views/portal/portal_insert.js index 06a8cd66bcb..574f1969d4f 100644 --- a/src-docs/src/views/portal/portal_insert.js +++ b/src-docs/src/views/portal/portal_insert.js @@ -19,11 +19,11 @@ export class PortalInsert extends Component { }; } - setButtonRef = node => this.buttonRef = node + setButtonRef = node => this.buttonRef = node; togglePortal = () => { this.setState(prevState => ({ isPortalVisible: !prevState.isPortalVisible })); - } + }; render() { diff --git a/src/components/portal/portal.js b/src/components/portal/portal.js index 7dd75de5cf7..c7f3f8acc38 100644 --- a/src/components/portal/portal.js +++ b/src/components/portal/portal.js @@ -63,12 +63,13 @@ export class EuiPortal extends Component { EuiPortal.propTypes = { children: PropTypes.node, + /** `{sibling: ReactNode|HTMLElement, position: 'before'|'after'}` */ insert: PropTypes.shape({ sibling: PropTypes.oneOfType([ PropTypes.node, PropTypes.instanceOf(HTMLElement) ]).isRequired, position: PropTypes.oneOf(INSERT_POSITIONS), - portalRef: PropTypes.func, - }) + }), + portalRef: PropTypes.func, }; From 66cf140df54c114b1c8274577b85e538da411fa2 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 12 Jul 2018 16:13:27 -0600 Subject: [PATCH 15/17] re-worded portal docs --- src-docs/src/views/portal/portal_example.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src-docs/src/views/portal/portal_example.js b/src-docs/src/views/portal/portal_example.js index 35aa1ef714b..c168c7e0db0 100644 --- a/src-docs/src/views/portal/portal_example.js +++ b/src-docs/src/views/portal/portal_example.js @@ -57,10 +57,12 @@ export const PortalExample = { update.

- insert is an object with two key/values: sibling - and position. sibling is either the React node or HTMLElement - the portal should be inserted next to, and position specifies the portals relative - position, either before or after. + insert is an object with two key-value + pairs: sibling and position. + sibling is the React node or HTMLElement to + insert the portal next to, and position specifies + the portal's relative position, either before or + after.

), From e6bdd42638638c5c6f7b42b8e30bc5ed102459e7 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 16 Jul 2018 11:48:45 -0600 Subject: [PATCH 16/17] Allow EuiPopover to be created with isOpen=true --- .../__snapshots__/popover.test.js.snap | 159 ++++++++++-------- src/components/popover/popover.js | 9 +- src/components/popover/popover.test.js | 98 ++++++----- 3 files changed, 150 insertions(+), 116 deletions(-) diff --git a/src/components/popover/__snapshots__/popover.test.js.snap b/src/components/popover/__snapshots__/popover.test.js.snap index 076732a140b..8a7124bdaec 100644 --- a/src/components/popover/__snapshots__/popover.test.js.snap +++ b/src/components/popover/__snapshots__/popover.test.js.snap @@ -81,127 +81,142 @@ exports[`EuiPopover props isOpen defaults to false 1`] = ` `; exports[`EuiPopover props isOpen renders true 1`] = ` -
+
-
-
+
+
+ aria-live="assertive" + class="euiPanel euiPanel--paddingMedium euiPanel--shadow euiPopover__panel euiPopover__panel-bottom euiPopover__panel-isOpen" + style="top: 16px; left: -12px; z-index: 0;" + > +
+
`; exports[`EuiPopover props ownFocus defaults to false 1`] = ` -
+
-
-
+
+
+ aria-live="assertive" + class="euiPanel euiPanel--paddingMedium euiPanel--shadow euiPopover__panel euiPopover__panel-bottom euiPopover__panel-isOpen" + style="top: 16px; left: -12px; z-index: 0;" + > +
+
`; exports[`EuiPopover props ownFocus renders true 1`] = ` -
+
-
-
-
+
+
+
+ aria-live="off" + class="euiPanel euiPanel--paddingMedium euiPanel--shadow euiPopover__panel euiPopover__panel-bottom euiPopover__panel-isOpen" + style="top: 16px; left: -12px; z-index: 0;" + tabindex="0" + > +
+
`; exports[`EuiPopover props panelClassName is rendered 1`] = ` -
+
-
-
+
+
+ aria-live="assertive" + class="euiPanel euiPanel--paddingMedium euiPanel--shadow euiPopover__panel euiPopover__panel-bottom euiPopover__panel-isOpen test" + style="top: 16px; left: -12px; z-index: 0;" + > +
+
`; exports[`EuiPopover props panelPaddingSize is rendered 1`] = ` -
+
-
-
+
+
+ aria-live="assertive" + class="euiPanel euiPanel--paddingSmall euiPanel--shadow euiPopover__panel euiPopover__panel-bottom euiPopover__panel-isOpen" + style="top: 16px; left: -12px; z-index: 0;" + > +
+
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 1e9e3957a0f..32dfee14213 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -109,6 +109,7 @@ export class EuiPopover extends Component { prevProps: { isOpen: props.isOpen }, + suppressingPopover: this.props.isOpen, // only suppress if created with isOpen=true isClosing: false, isOpening: false, popoverStyles: DEFAULT_POPOVER_STYLES, @@ -146,6 +147,12 @@ export class EuiPopover extends Component { } componentDidMount() { + if (this.state.suppressingPopover) { + // component was created with isOpen=true; now that it's mounted + // stop suppressing and start opening + this.setState({ suppressingPopover: false, isOpening: true }); // eslint-disable-line react/no-did-mount-set-state + } + this.updateFocus(); } @@ -309,7 +316,7 @@ export class EuiPopover extends Component { let panel; - if (isOpen || this.state.isClosing) { + if (!this.state.suppressingPopover && (isOpen || this.state.isClosing)) { let tabIndex; let initialFocus; let ariaLive; diff --git a/src/components/popover/popover.test.js b/src/components/popover/popover.test.js index 729d532a20f..b572603e2a2 100644 --- a/src/components/popover/popover.test.js +++ b/src/components/popover/popover.test.js @@ -144,81 +144,93 @@ describe('EuiPopover', () => { }); test('renders true', () => { - const component = render( - } - closePopover={() => {}} - isOpen - /> + const component = mount( +
+ } + closePopover={() => {}} + isOpen + /> +
); - expect(component) + // console.log(component.debug()); + + expect(component.render()) .toMatchSnapshot(); }); }); describe('ownFocus', () => { test('defaults to false', () => { - const component = render( - } - closePopover={() => {}} - /> + const component = mount( +
+ } + closePopover={() => {}} + /> +
); - expect(component) + expect(component.render()) .toMatchSnapshot(); }); test('renders true', () => { - const component = render( - } - closePopover={() => {}} - /> + const component = mount( +
+ } + closePopover={() => {}} + /> +
); - expect(component) + expect(component.render()) .toMatchSnapshot(); }); }); describe('panelClassName', () => { test('is rendered', () => { - const component = render( - } - closePopover={() => {}} - panelClassName="test" - isOpen - /> + const component = mount( +
+ } + closePopover={() => {}} + panelClassName="test" + isOpen + /> +
); - expect(component) + expect(component.render()) .toMatchSnapshot(); }); }); describe('panelPaddingSize', () => { test('is rendered', () => { - const component = render( - } - closePopover={() => {}} - panelPaddingSize="s" - isOpen - /> + const component = mount( +
+ } + closePopover={() => {}} + panelPaddingSize="s" + isOpen + /> +
); - expect(component) + expect(component.render()) .toMatchSnapshot(); }); }); From 3db245c7a40d354df3d50b99f5be1aac57c284f9 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 16 Jul 2018 12:59:30 -0600 Subject: [PATCH 17/17] Update EuiPopover's arrow positioning --- .../__snapshots__/popover.test.js.snap | 20 +++++++++---------- src/components/popover/popover.js | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/popover/__snapshots__/popover.test.js.snap b/src/components/popover/__snapshots__/popover.test.js.snap index 8a7124bdaec..ce4b6885b54 100644 --- a/src/components/popover/__snapshots__/popover.test.js.snap +++ b/src/components/popover/__snapshots__/popover.test.js.snap @@ -95,11 +95,11 @@ exports[`EuiPopover props isOpen renders true 1`] = `
@@ -122,11 +122,11 @@ exports[`EuiPopover props ownFocus defaults to false 1`] = `
@@ -155,12 +155,12 @@ exports[`EuiPopover props ownFocus renders true 1`] = `
@@ -183,11 +183,11 @@ exports[`EuiPopover props panelClassName is rendered 1`] = `
@@ -210,11 +210,11 @@ exports[`EuiPopover props panelPaddingSize is rendered 1`] = `
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 32dfee14213..3dd0fd9e970 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -239,7 +239,7 @@ export class EuiPopover extends Component { offset: 16, arrowConfig: { arrowWidth: 24, - arrowBuffer: 0, + arrowBuffer: 10, } });