UNPKG

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