UNPKG

21 kBJavaScriptView Raw
1"use strict";
2
3var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
4
5var _react = require("react");
6
7var _propTypes = require("prop-types");
8
9var _createReactClass = require("create-react-class");
10
11var _createReactClass2 = _interopRequireDefault(_createReactClass);
12
13var _reactDom = require("react-dom");
14
15var _debug = require("debug");
16
17var _debug2 = _interopRequireDefault(_debug);
18
19var _lodash = require("lodash.throttle");
20
21var _lodash2 = _interopRequireDefault(_lodash);
22
23var _cssVendor = require("css-vendor");
24
25var cssVendor = _interopRequireWildcard(_cssVendor);
26
27var _onResize = require("./on-resize");
28
29var _onResize2 = _interopRequireDefault(_onResize);
30
31var _layout = require("./layout");
32
33var _layout2 = _interopRequireDefault(_layout);
34
35var _reactLayerMixin = require("./react-layer-mixin");
36
37var _reactLayerMixin2 = _interopRequireDefault(_reactLayerMixin);
38
39var _platform = require("./platform");
40
41var _utils = require("./utils");
42
43var _tip = require("./tip");
44
45var _tip2 = _interopRequireDefault(_tip);
46
47function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
48
49function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
50
51function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
52
53var log = (0, _debug2.default)("react-popover");
54
55var supportedCSSValue = (0, _utils.clientOnly)(cssVendor.supportedValue);
56
57var jsprefix = function jsprefix(x) {
58 return "" + cssVendor.prefix.js + x;
59};
60
61var cssprefix = function cssprefix(x) {
62 return "" + cssVendor.prefix.css + x;
63};
64
65var cssvalue = function cssvalue(prop, value) {
66 return supportedCSSValue(prop, value) || cssprefix(value);
67};
68
69var coreStyle = {
70 position: "absolute",
71 top: 0,
72 left: 0,
73 display: cssvalue("display", "flex")
74};
75
76var faces = {
77 above: "down",
78 right: "left",
79 below: "up",
80 left: "right"
81};
82
83/* Flow mappings. Each map maps the flow domain to another domain. */
84
85var flowToTipTranslations = {
86 row: "translateY",
87 column: "translateX"
88};
89
90var flowToPopoverTranslations = {
91 row: "translateX",
92 column: "translateY"
93};
94
95var Popover = (0, _createReactClass2.default)({
96 displayName: "popover",
97 propTypes: {
98 body: _propTypes.PropTypes.node.isRequired,
99 children: _propTypes.PropTypes.element.isRequired,
100 className: _propTypes.PropTypes.string,
101 enterExitTransitionDurationMs: _propTypes.PropTypes.number,
102 isOpen: _propTypes.PropTypes.bool,
103 offset: _propTypes.PropTypes.number,
104 place: _propTypes.PropTypes.oneOf(_layout2.default.validTypeValues),
105 preferPlace: _propTypes.PropTypes.oneOf(_layout2.default.validTypeValues),
106 refreshIntervalMs: _propTypes.PropTypes.oneOfType([_propTypes.PropTypes.number, _propTypes.PropTypes.bool]),
107 style: _propTypes.PropTypes.object,
108 tipSize: _propTypes.PropTypes.number,
109 onOuterAction: _propTypes.PropTypes.func
110 },
111 mixins: [(0, _reactLayerMixin2.default)()],
112 getDefaultProps: function getDefaultProps() {
113 return {
114 tipSize: 7,
115 preferPlace: null,
116 place: null,
117 offset: 4,
118 isOpen: false,
119 onOuterAction: function noOperation() {},
120 enterExitTransitionDurationMs: 500,
121 children: null,
122 refreshIntervalMs: 200
123 };
124 },
125 getInitialState: function getInitialState() {
126 return {
127 standing: "above",
128 exited: !this.props.isOpen, // for animation-dependent rendering, should popover close/open?
129 exiting: false, // for tracking in-progress animations
130 toggle: this.props.isOpen || false };
131 },
132 componentDidMount: function componentDidMount() {
133 this.targetEl = (0, _reactDom.findDOMNode)(this);
134 if (this.props.isOpen) this.enter();
135 },
136 componentWillReceiveProps: function componentWillReceiveProps(propsNext) {
137 //log(`Component received props!`, propsNext)
138 var willOpen = !this.props.isOpen && propsNext.isOpen;
139 var willClose = this.props.isOpen && !propsNext.isOpen;
140
141 if (willOpen) this.open();else if (willClose) this.close();
142 },
143 componentDidUpdate: function componentDidUpdate(propsPrev, statePrev) {
144 //log(`Component did update!`)
145 var didOpen = !statePrev.toggle && this.state.toggle;
146 var didClose = statePrev.toggle && !this.state.toggle;
147
148 if (didOpen) this.enter();else if (didClose) this.exit();
149 },
150 componentWillUnmount: function componentWillUnmount() {
151 /* If the Popover was never opened then then tracking
152 initialization never took place and so calling untrack
153 would be an error. Also see issue 55. */
154 if (this.hasTracked) this.untrackPopover();
155 },
156 resolvePopoverLayout: function resolvePopoverLayout() {
157
158 /* Find the optimal zone to position self. Measure the size of each zone and use the one with
159 the greatest area. */
160
161 var pickerSettings = {
162 preferPlace: this.props.preferPlace,
163 place: this.props.place
164 };
165
166 /* This is a kludge that solves a general problem very specifically for Popover.
167 The problem is subtle. When Popover positioning changes such that it resolves at
168 a different orientation, its Size will change because the Tip will toggle between
169 extending Height or Width. The general problem of course is that calculating
170 zone positioning based on current size is non-trivial if the Size can change once
171 resolved to a different zone. Infinite recursion can be triggered as we noted here:
172 https://github.com/littlebits/react-popover/issues/18. As an example of how this
173 could happen in another way: Imagine the user changes the CSS styling of the popover
174 based on whether it was `row` or `column` flow. TODO: Find a solution to generally
175 solve this problem so that the user is free to change the Popover styles in any
176 way at any time for any arbitrary trigger. There may be value in investigating the
177 http://overconstrained.io community for its general layout system via the
178 constraint-solver Cassowary. */
179 if (this.zone) this.size[this.zone.flow === "row" ? "h" : "w"] += this.props.tipSize;
180 var zone = _layout2.default.pickZone(pickerSettings, this.frameBounds, this.targetBounds, this.size);
181 if (this.zone) this.size[this.zone.flow === "row" ? "h" : "w"] -= this.props.tipSize;
182
183 var tb = this.targetBounds;
184 this.zone = zone;
185 log("zone", zone);
186
187 this.setState({
188 standing: zone.standing
189 });
190
191 var axis = _layout2.default.axes[zone.flow];
192 log("axes", axis);
193
194 var dockingEdgeBufferLength = Math.round(getComputedStyle(this.bodyEl).borderRadius.slice(0, -2)) || 0;
195 var scrollSize = _layout2.default.El.calcScrollSize(this.frameEl);
196 scrollSize.main = scrollSize[axis.main.size];
197 scrollSize.cross = scrollSize[axis.cross.size];
198
199 /* When positioning self on the cross-axis do not exceed frame bounds. The strategy to achieve
200 this is thus: First position cross-axis self to the cross-axis-center of the the target. Then,
201 offset self by the amount that self is past the boundaries of frame. */
202 var pos = _layout2.default.calcRelPos(zone, tb, this.size);
203
204 /* Offset allows users to control the distance betweent the tip and the target. */
205 pos[axis.main.start] += this.props.offset * zone.order;
206
207 /* Constrain containerEl Position within frameEl. Try not to penetrate a visually-pleasing buffer from
208 frameEl. `frameBuffer` length is based on tipSize and its offset. */
209
210 var frameBuffer = this.props.tipSize + this.props.offset;
211 var hangingBufferLength = dockingEdgeBufferLength * 2 + this.props.tipSize * 2 + frameBuffer;
212 var frameCrossStart = this.frameBounds[axis.cross.start];
213 var frameCrossEnd = this.frameBounds[axis.cross.end];
214 var frameCrossLength = this.frameBounds[axis.cross.size];
215 var frameCrossInnerLength = frameCrossLength - frameBuffer * 2;
216 var frameCrossInnerStart = frameCrossStart + frameBuffer;
217 var frameCrossInnerEnd = frameCrossEnd - frameBuffer;
218 var popoverCrossStart = pos[axis.cross.start];
219 var popoverCrossEnd = pos[axis.cross.end];
220
221 /* If the popover dose not fit into frameCrossLength then just position it to the `frameCrossStart`.
222 popoverCrossLength` will now be forced to overflow into the `Frame` */
223 if (pos.crossLength > frameCrossLength) {
224 log("popoverCrossLength does not fit frame.");
225 pos[axis.cross.start] = 0;
226
227 /* If the `popoverCrossStart` is forced beyond some threshold of `targetCrossLength` then bound
228 it (`popoverCrossStart`). */
229 } else if (tb[axis.cross.end] < hangingBufferLength) {
230 log("popoverCrossStart cannot hang any further without losing target.");
231 pos[axis.cross.start] = tb[axis.cross.end] - hangingBufferLength;
232
233 /* checking if the cross start of the target area is within the frame and it makes sense
234 to try fitting popover into the frame. */
235 } else if (tb[axis.cross.start] > frameCrossInnerEnd) {
236 log("popoverCrossStart cannot hang any further without losing target.");
237 pos[axis.cross.start] = tb[axis.cross.start] - this.size[axis.cross.size];
238
239 /* If the `popoverCrossStart` does not fit within the inner frame (honouring buffers) then
240 just center the popover in the remaining `frameCrossLength`. */
241 } else if (pos.crossLength > frameCrossInnerLength) {
242 log("popoverCrossLength does not fit within buffered frame.");
243 pos[axis.cross.start] = (frameCrossLength - pos.crossLength) / 2;
244 } else if (popoverCrossStart < frameCrossInnerStart) {
245 log("popoverCrossStart cannot reverse without exceeding frame.");
246 pos[axis.cross.start] = frameCrossInnerStart;
247 } else if (popoverCrossEnd > frameCrossInnerEnd) {
248 log("popoverCrossEnd cannot travel without exceeding frame.");
249 pos[axis.cross.start] = pos[axis.cross.start] - (pos[axis.cross.end] - frameCrossInnerEnd);
250 }
251
252 /* So far the link position has been calculated relative to the target. To calculate the absolute
253 position we need to factor the `Frame``s scroll position */
254
255 pos[axis.cross.start] += scrollSize.cross;
256 pos[axis.main.start] += scrollSize.main;
257
258 /* Apply `flow` and `order` styles. This can impact subsequent measurements of height and width
259 of the container. When tip changes orientation position due to changes from/to `row`/`column`
260 width`/`height` will be impacted. Our layout monitoring will catch these cases and automatically
261 recalculate layout. */
262
263 this.containerEl.style.flexFlow = zone.flow;
264 this.containerEl.style[jsprefix("FlexFlow")] = this.containerEl.style.flexFlow;
265 this.bodyEl.style.order = zone.order;
266 this.bodyEl.style[jsprefix("Order")] = this.bodyEl.style.order;
267
268 /* Apply Absolute Positioning. */
269
270 log("pos", pos);
271 this.containerEl.style.top = pos.y + "px";
272 this.containerEl.style.left = pos.x + "px";
273
274 /* Calculate Tip Position */
275
276 var tipCrossPos =
277 /* Get the absolute tipCrossCenter. Tip is positioned relative to containerEl
278 but it aims at targetCenter which is positioned relative to frameEl... we
279 need to cancel the containerEl positioning so as to hit our intended position. */
280 _layout2.default.centerOfBoundsFromBounds(zone.flow, "cross", tb, pos)
281
282 /* centerOfBounds does not account for scroll so we need to manually add that
283 here. */
284 + scrollSize.cross
285
286 /* Center tip relative to self. We do not have to calcualte half-of-tip-size since tip-size
287 specifies the length from base to tip which is half of total length already. */
288 - this.props.tipSize;
289
290 if (tipCrossPos < dockingEdgeBufferLength) tipCrossPos = dockingEdgeBufferLength;else if (tipCrossPos > pos.crossLength - dockingEdgeBufferLength - this.props.tipSize * 2) {
291 tipCrossPos = pos.crossLength - dockingEdgeBufferLength - this.props.tipSize * 2;
292 }
293
294 this.tipEl.style.transform = flowToTipTranslations[zone.flow] + "(" + tipCrossPos + "px)";
295 this.tipEl.style[jsprefix("Transform")] = this.tipEl.style.transform;
296 },
297 checkTargetReposition: function checkTargetReposition() {
298 if (this.measureTargetBounds()) this.resolvePopoverLayout();
299 },
300 measurePopoverSize: function measurePopoverSize() {
301 this.size = _layout2.default.El.calcSize(this.containerEl);
302 },
303 measureTargetBounds: function measureTargetBounds() {
304 var newTargetBounds = _layout2.default.El.calcBounds(this.targetEl);
305
306 if (this.targetBounds && _layout2.default.equalCoords(this.targetBounds, newTargetBounds)) {
307 return false;
308 }
309
310 this.targetBounds = newTargetBounds;
311 return true;
312 },
313 open: function open() {
314 if (this.state.exiting) this.animateExitStop();
315 this.setState({ toggle: true, exited: false });
316 },
317 close: function close() {
318 this.setState({ toggle: false });
319 },
320 enter: function enter() {
321 if (_platform.isServer) return;
322 log("enter!");
323 this.trackPopover();
324 this.animateEnter();
325 },
326 exit: function exit() {
327 log("exit!");
328 this.animateExit();
329 this.untrackPopover();
330 },
331 animateExitStop: function animateExitStop() {
332 clearTimeout(this.exitingAnimationTimer1);
333 clearTimeout(this.exitingAnimationTimer2);
334 this.setState({ exiting: false });
335 },
336 animateExit: function animateExit() {
337 var _this = this;
338
339 this.setState({ exiting: true });
340 this.exitingAnimationTimer2 = setTimeout(function () {
341 setTimeout(function () {
342 _this.containerEl.style.transform = flowToPopoverTranslations[_this.zone.flow] + "(" + _this.zone.order * 50 + "px)";
343 _this.containerEl.style.opacity = "0";
344 }, 0);
345 }, 0);
346
347 this.exitingAnimationTimer1 = setTimeout(function () {
348 _this.setState({ exited: true, exiting: false });
349 }, this.props.enterExitTransitionDurationMs);
350 },
351 animateEnter: function animateEnter() {
352 /* Prepare `entering` style so that we can then animate it toward `entered`. */
353
354 this.containerEl.style.transform = flowToPopoverTranslations[this.zone.flow] + "(" + this.zone.order * 50 + "px)";
355 this.containerEl.style[jsprefix("Transform")] = this.containerEl.style.transform;
356 this.containerEl.style.opacity = "0";
357
358 /* After initial layout apply transition animations. */
359 /* Hack: http://stackoverflow.com/questions/3485365/how-can-i-force-webkit-to-redraw-repaint-to-propagate-style-changes */
360 this.containerEl.offsetHeight;
361
362 /* If enterExitTransitionDurationMs is falsy, tip animation should be also disabled */
363 if (this.props.enterExitTransitionDurationMs) {
364 this.tipEl.style.transition = "transform 150ms ease-in";
365 this.tipEl.style[jsprefix("Transition")] = cssprefix("transform") + " 150ms ease-in";
366 }
367 this.containerEl.style.transitionProperty = "top, left, opacity, transform";
368 this.containerEl.style.transitionDuration = this.props.enterExitTransitionDurationMs + " ms";
369 this.containerEl.style.transitionTimingFunction = "cubic-bezier(0.230, 1.000, 0.320, 1.000)";
370 this.containerEl.style.opacity = "1";
371 this.containerEl.style.transform = "translateY(0)";
372 this.containerEl.style[jsprefix("Transform")] = this.containerEl.style.transform;
373 },
374 trackPopover: function trackPopover() {
375 var minScrollRefreshIntervalMs = 200;
376 var minResizeRefreshIntervalMs = 200;
377
378 /* Get references to DOM elements. */
379
380 this.containerEl = (0, _reactDom.findDOMNode)(this.layerReactComponent);
381 this.bodyEl = this.containerEl.querySelector(".Popover-body");
382 this.tipEl = this.containerEl.querySelector(".Popover-tip");
383
384 /* Note: frame is hardcoded to window now but we think it will
385 be a nice feature in the future to allow other frames to be used
386 such as local elements that further constrain the popover`s world. */
387
388 this.frameEl = _platform.window;
389 this.hasTracked = true;
390
391 /* Set a general interval for checking if target position changed. There is no way
392 to know this information without polling. */
393 if (this.props.refreshIntervalMs) {
394 this.checkLayoutInterval = setInterval(this.checkTargetReposition, this.props.refreshIntervalMs);
395 }
396
397 /* Watch for boundary changes in all deps, and when one of them changes, recalculate layout.
398 This layout monitoring must be bound immediately because a layout recalculation can recursively
399 cause a change in boundaries. So if we did a one-time force-layout before watching boundaries
400 our final position calculations could be wrong. See comments in resolver function for details
401 about which parts can trigger recursive recalculation. */
402
403 this.onFrameScroll = (0, _lodash2.default)(this.onFrameScroll, minScrollRefreshIntervalMs);
404 this.onFrameResize = (0, _lodash2.default)(this.onFrameResize, minResizeRefreshIntervalMs);
405 this.onPopoverResize = (0, _lodash2.default)(this.onPopoverResize, minResizeRefreshIntervalMs);
406 this.onTargetResize = (0, _lodash2.default)(this.onTargetResize, minResizeRefreshIntervalMs);
407
408 this.frameEl.addEventListener("scroll", this.onFrameScroll);
409 _onResize2.default.on(this.frameEl, this.onFrameResize);
410 _onResize2.default.on(this.containerEl, this.onPopoverResize);
411 _onResize2.default.on(this.targetEl, this.onTargetResize);
412
413 /* Track user actions on the page. Anything that occurs _outside_ the Popover boundaries
414 should close the Popover. */
415
416 _platform.document.addEventListener("mousedown", this.checkForOuterAction);
417 _platform.document.addEventListener("touchstart", this.checkForOuterAction);
418
419 /* Kickstart layout at first boot. */
420
421 this.measurePopoverSize();
422 this.measureFrameBounds();
423 this.measureTargetBounds();
424 this.resolvePopoverLayout();
425 },
426 checkForOuterAction: function checkForOuterAction(event) {
427 var isOuterAction = !this.containerEl.contains(event.target) && !this.targetEl.contains(event.target);
428 if (isOuterAction) this.props.onOuterAction(event);
429 },
430 untrackPopover: function untrackPopover() {
431 clearInterval(this.checkLayoutInterval);
432 this.frameEl.removeEventListener("scroll", this.onFrameScroll);
433 _onResize2.default.off(this.frameEl, this.onFrameResize);
434 _onResize2.default.off(this.containerEl, this.onPopoverResize);
435 _onResize2.default.off(this.targetEl, this.onTargetResize);
436 _platform.document.removeEventListener("mousedown", this.checkForOuterAction);
437 _platform.document.removeEventListener("touchstart", this.checkForOuterAction);
438 },
439 onTargetResize: function onTargetResize() {
440 log("Recalculating layout because _target_ resized!");
441 this.measureTargetBounds();
442 this.resolvePopoverLayout();
443 },
444 onPopoverResize: function onPopoverResize() {
445 log("Recalculating layout because _popover_ resized!");
446 this.measurePopoverSize();
447 this.resolvePopoverLayout();
448 },
449 onFrameScroll: function onFrameScroll() {
450 log("Recalculating layout because _frame_ scrolled!");
451 this.measureTargetBounds();
452 this.resolvePopoverLayout();
453 },
454 onFrameResize: function onFrameResize() {
455 log("Recalculating layout because _frame_ resized!");
456 this.measureFrameBounds();
457 this.resolvePopoverLayout();
458 },
459 measureFrameBounds: function measureFrameBounds() {
460 this.frameBounds = _layout2.default.El.calcBounds(this.frameEl);
461 },
462 renderLayer: function renderLayer() {
463 if (this.state.exited) return null;
464
465 var _props = this.props,
466 _props$className = _props.className,
467 className = _props$className === undefined ? "" : _props$className,
468 _props$style = _props.style,
469 style = _props$style === undefined ? {} : _props$style;
470
471
472 var popoverProps = {
473 className: "Popover " + className,
474 style: _extends({}, coreStyle, style)
475 };
476
477 var tipProps = {
478 direction: faces[this.state.standing],
479 size: this.props.tipSize
480 };
481
482 /* If we pass array of nodes to component children React will complain that each
483 item should have a key prop. This is not a valid requirement in our case. Users
484 should be able to give an array of elements applied as if they were just normal
485 children of the body component (note solution is to spread array items as args). */
486
487 var popoverBody = (0, _utils.arrayify)(this.props.body);
488
489 return _react.DOM.div(popoverProps, _react.DOM.div.apply(_react.DOM, [{ className: "Popover-body" }].concat(_toConsumableArray(popoverBody))), (0, _react.createElement)(_tip2.default, tipProps));
490 },
491 render: function render() {
492 return this.props.children;
493 }
494});
495
496// Support for CJS
497// http://stackoverflow.com/questions/33505992/babel-6-changes-how-it-exports-default
498module.exports = Popover;
\No newline at end of file