From c3759f7f7fe721b79b36704969ddb2899e761be8 Mon Sep 17 00:00:00 2001 From: Chandler Date: Wed, 18 Jul 2018 11:04:59 -0600 Subject: [PATCH] Update popover position when children contents change (#966) * create EuiMutationObserver util; implement it on accordion and popover * updated EuiContextMenu to trigger popover's mutation observer during height transition * another approach to the popover conundrum * change popover to call requestAnimationFrame while a transition may be active * refactor EuiMutationObserver to support multiple children * Added full MutationObserver polyfill, added tests for EuiMutationObserver * Allow EuiPortal to be inserted at specific locations in the DOM * Added documentation for EuiMutationObserver * EuiPopover now watches for text changes too * Add EuiWrappingPopover to allow non-React elements to be used as popover anchors * changelog and some cleanup * Add container prop to EuiPopover * more changelog * PR feedback * re-worded portal docs * Allow EuiPopover to be created with isOpen=true * Update EuiPopover's arrow positioning --- CHANGELOG.md | 9 +- scripts/jest/config.json | 1 + scripts/jest/polyfills/mutation_observer.js | 541 ++++++++++++++++++ scripts/jest/setup/polyfills.js | 3 + src-docs/src/routes.js | 4 + .../mutation_observer/mutation_observer.js | 80 +++ .../mutation_observer_example.js | 43 ++ .../src/views/popover/popover_container.js | 69 +++ src-docs/src/views/popover/popover_example.js | 46 ++ .../popover/popover_htmlelement_anchor.js | 81 +++ src-docs/src/views/portal/portal_example.js | 33 ++ src-docs/src/views/portal/portal_insert.js | 49 ++ .../__snapshots__/accordion.test.js.snap | 40 +- src/components/accordion/accordion.js | 27 +- src/components/accordion/accordion.test.js | 12 - src/components/context_menu/context_menu.js | 9 +- src/components/index.js | 5 + src/components/mutation_observer/index.js | 1 + .../mutation_observer/mutation_observer.js | 108 ++++ .../mutation_observer.test.js | 88 +++ .../__snapshots__/popover.test.js.snap | 159 ++--- src/components/popover/index.js | 1 + src/components/popover/popover.js | 87 ++- src/components/popover/popover.test.js | 98 ++-- src/components/popover/wrapping_popover.js | 62 ++ src/components/portal/portal.js | 44 +- 26 files changed, 1536 insertions(+), 164 deletions(-) create mode 100644 scripts/jest/polyfills/mutation_observer.js create mode 100644 scripts/jest/setup/polyfills.js 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 create mode 100644 src-docs/src/views/popover/popover_container.js create mode 100644 src-docs/src/views/popover/popover_htmlelement_anchor.js create mode 100644 src-docs/src/views/portal/portal_insert.js create mode 100644 src/components/mutation_observer/index.js create mode 100644 src/components/mutation_observer/mutation_observer.js create mode 100644 src/components/mutation_observer/mutation_observer.test.js create mode 100644 src/components/popover/wrapping_popover.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e725b54d66..71565fa13c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ ## [`master`](https://github.com/elastic/eui/tree/master) -No public interface changes since `3.0.2`. +- 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** + +- `EuiPopover` re-positions with dynamic content (including CSS height/width transitions) ([#966](https://github.com/elastic/eui/pull/966)) ## [`3.0.2`](https://github.com/elastic/eui/tree/v3.0.2) 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-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..9357ea629fd --- /dev/null +++ b/src-docs/src/views/mutation_observer/mutation_observer_example.js @@ -0,0 +1,43 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiLink, + 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 + 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. +

+ ), + components: { EuiMutationObserver }, + demo: , + }], +}; 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 957d448db69..4b86b9d15ed 100644 --- a/src-docs/src/views/popover/popover_example.js +++ b/src-docs/src/views/popover/popover_example.js @@ -36,6 +36,15 @@ 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); + +import PopoverContainer from './popover_container'; +const popoverContainerSource = require('!!raw-loader!./popover_container'); +const popoverContainerHtml = renderToHtml(PopoverContainer); + + export const PopoverExample = { title: 'Popover', sections: [{ @@ -158,5 +167,42 @@ 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: [{ + 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-docs/src/views/portal/portal_example.js b/src-docs/src/views/portal/portal_example.js index d56ce50d40e..c168c7e0db0 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,34 @@ 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. +

+

+ 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. +

+
+ ), + props: { EuiPortal }, + 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..574f1969d4f --- /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/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..5e4bd73a8f5 100644 --- a/src/components/accordion/accordion.js +++ b/src/components/accordion/accordion.js @@ -13,6 +13,10 @@ import { EuiFlexItem, } from '../flex'; +import { + EuiMutationObserver, +} from '../mutation_observer'; + const paddingSizeToClassNameMap = { none: null, xs: 'euiAccordion__padding--xs', @@ -58,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() { @@ -154,11 +148,16 @@ export class EuiAccordion extends Component { ref={node => { this.childWrapper = node; }} id={id} > -
-
- {children} + +
+
+ {children} +
-
+
); 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( { - this.setState({ - height, + this.setState(({ height: prevHeight }) => { + if (height === prevHeight) { + return null; + } else { + return { height }; + } }); }; @@ -299,7 +303,6 @@ export class EuiContextMenu extends Component { return (
{ this.menu = node; }} className={classes} style={{ height: this.state.height }} {...rest} diff --git a/src/components/index.js b/src/components/index.js index 37e04b4414f..4148d1ab284 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -196,6 +196,10 @@ export { EuiModalHeaderTitle, } from './modal'; +export { + EuiMutationObserver, +} from './mutation_observer'; + export { EuiOutsideClickDetector, } from './outside_click_detector'; @@ -228,6 +232,7 @@ export { export { EuiPopover, EuiPopoverTitle, + EuiWrappingPopover, } from './popover'; export { diff --git a/src/components/mutation_observer/index.js b/src/components/mutation_observer/index.js new file mode 100644 index 00000000000..fb7dde9bf34 --- /dev/null +++ b/src/components/mutation_observer/index.js @@ -0,0 +1 @@ +export { EuiMutationObserver } from './mutation_observer'; diff --git a/src/components/mutation_observer/mutation_observer.js b/src/components/mutation_observer/mutation_observer.js new file mode 100644 index 00000000000..46c49d61dad --- /dev/null +++ b/src/components/mutation_observer/mutation_observer.js @@ -0,0 +1,108 @@ +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.childNode = null; + this.observer = null; + } + + componentDidMount() { + this.updateChildNode(); + } + + 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; + } + + this.childNode = currentNode; + if (this.childNode != null) { + this.observer = new MutationObserver(this.onMutation); + this.observer.observe(this.childNode, this.props.observerOptions); + } + } + } + } + + componentDidUpdate() { + // in case the child element was changed + this.updateChildNode(); + } + + onMutation = (...args) => { + this.props.onMutation(...args); + } + + render() { + const { children, ...rest } = this.props; + if (Array.isArray(children)) { + return React.Children.map( + children, + child => ( + + {child} + + ) + ); + } else { + return children; + } + } +} + +EuiMutationObserver.propTypes = { + 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, + attributes: PropTypes.bool, + characterData: PropTypes.bool, + characterDataOldValue: PropTypes.bool, + childList: PropTypes.bool, + subtree: PropTypes.bool, + }).isRequired, + onMutation: PropTypes.func.isRequired, +}; + +export { EuiMutationObserver }; diff --git a/src/components/mutation_observer/mutation_observer.test.js b/src/components/mutation_observer/mutation_observer.test.js new file mode 100644 index 00000000000..f6b39876c03 --- /dev/null +++ b/src/components/mutation_observer/mutation_observer.test.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiMutationObserver } from './mutation_observer'; + +async function sleep(duration) { + return new Promise(resolve => { + 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); + }); +}); diff --git a/src/components/popover/__snapshots__/popover.test.js.snap b/src/components/popover/__snapshots__/popover.test.js.snap index 076732a140b..ce4b6885b54 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: -22px; 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: -22px; 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: -22px; 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: -22px; 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: -22px; z-index: 0;" + > +
+
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 81612557181..3dd0fd9e970 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 '../mutation_observer'; + import { findPopoverPosition, getElementZIndex } from '../../services/popover/popover_positioning'; const anchorPositionToPopoverPositionMap = { @@ -72,6 +74,8 @@ const DEFAULT_POPOVER_STYLES = { left: 50, }; +const GROUP_NUMERIC = /^([\d.]+)/; + export class EuiPopover extends Component { static getDerivedStateFromProps(nextProps, prevState) { if (prevState.prevProps.isOpen && !nextProps.isOpen) { @@ -105,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, @@ -142,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(); } @@ -176,8 +187,51 @@ export class EuiPopover extends Component { clearTimeout(this.closingTransitionTimeout); } + onMutation = (records) => { + const waitDuration = records.reduce( + (waitDuration, record) => { + // only check for CSS transition values for ELEMENT nodes + if (record.target.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 computedDelay = computedStyle.getPropertyValue('transition-delay'); + let delayMatch = computedDelay.match(GROUP_NUMERIC); + delayMatch = delayMatch ? parseFloat(delayMatch[1]) * 1000 : 0; + + waitDuration = Math.max(waitDuration, durationMatch + delayMatch); + } + + return waitDuration; + }, + 0 + ); + this.positionPopover(); + + if (waitDuration > 0) { + const startTime = Date.now(); + const endTime = startTime + waitDuration; + + const onFrame = () => { + this.positionPopover(); + + if (endTime > Date.now()) { + requestAnimationFrame(onFrame); + } + }; + + requestAnimationFrame(onFrame); + } + } + positionPopover = () => { + 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, @@ -185,11 +239,11 @@ export class EuiPopover extends Component { offset: 16, arrowConfig: { arrowWidth: 24, - arrowBuffer: 0, + arrowBuffer: 10, } }); - // 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); @@ -262,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; @@ -310,7 +364,24 @@ export class EuiPopover extends Component { style={this.state.popoverStyles} >
- {children} + { + children + ? ( + + {children} + + ) + : null + + } @@ -326,7 +397,7 @@ export class EuiPopover extends Component { {...rest} >
- {button} + {button instanceof HTMLElement ? null : button}
{panel}
@@ -345,7 +416,11 @@ EuiPopover.propTypes = { 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/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(); }); }); diff --git a/src/components/popover/wrapping_popover.js b/src/components/popover/wrapping_popover.js new file mode 100644 index 00000000000..542d779af56 --- /dev/null +++ b/src/components/popover/wrapping_popover.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import PropTypes from 'prop-types'; +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 ( + + } + /> + + ); + } +} + +EuiWrappingPopover.propTypes = { + button: PropTypes.instanceOf(HTMLElement), +}; diff --git a/src/components/portal/portal.js b/src/components/portal/portal.js index 35b5143fcba..c7f3f8acc38 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,15 +20,37 @@ 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 + ); + } + } + + componentDidMount() { + this.updatePortalRef(); } componentWillUnmount() { - document.body.removeChild(this.portalNode); + this.portalNode.parentNode.removeChild(this.portalNode); this.portalNode = null; + this.updatePortalRef(); + } + + updatePortalRef() { + if (this.props.portalRef) { + this.props.portalRef(this.portalNode); + } } render() { @@ -34,4 +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, };