1 | import { createElement, DOM as E, } from "react"
|
2 | import { PropTypes as T } from "prop-types"
|
3 | import createReactClass from "create-react-class"
|
4 | import { findDOMNode } from "react-dom"
|
5 | import Debug from "debug"
|
6 | import throttle from "lodash.throttle"
|
7 | import * as cssVendor from "css-vendor"
|
8 | import resizeEvent from "./on-resize"
|
9 | import Layout from "./layout"
|
10 | import ReactLayerMixin from "./react-layer-mixin"
|
11 | import { isServer, window, document } from "./platform"
|
12 | import { arrayify, clientOnly } from "./utils"
|
13 | import Tip from "./tip"
|
14 |
|
15 |
|
16 |
|
17 | const log = Debug("react-popover")
|
18 |
|
19 | const supportedCSSValue = clientOnly(cssVendor.supportedValue)
|
20 |
|
21 | const jsprefix = (x) => (
|
22 | `${cssVendor.prefix.js}${x}`
|
23 | )
|
24 |
|
25 | const cssprefix = (x) => (
|
26 | `${cssVendor.prefix.css}${x}`
|
27 | )
|
28 |
|
29 | const cssvalue = (prop, value) => (
|
30 | supportedCSSValue(prop, value) || cssprefix(value)
|
31 | )
|
32 |
|
33 | const coreStyle = {
|
34 | position: "absolute",
|
35 | top: 0,
|
36 | left: 0,
|
37 | display: cssvalue("display", "flex"),
|
38 | }
|
39 |
|
40 | const faces = {
|
41 | above: "down",
|
42 | right: "left",
|
43 | below: "up",
|
44 | left: "right",
|
45 | }
|
46 |
|
47 |
|
48 |
|
49 | const flowToTipTranslations = {
|
50 | row: "translateY",
|
51 | column: "translateX",
|
52 | }
|
53 |
|
54 | const flowToPopoverTranslations = {
|
55 | row: "translateX",
|
56 | column: "translateY",
|
57 | }
|
58 |
|
59 |
|
60 |
|
61 | const 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,
|
95 | exiting: false,
|
96 | toggle: this.props.isOpen || false,
|
97 | }
|
98 | },
|
99 | componentDidMount () {
|
100 | this.targetEl = findDOMNode(this)
|
101 | if (this.props.isOpen) this.enter()
|
102 | },
|
103 | componentWillReceiveProps (propsNext) {
|
104 |
|
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 |
|
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 | |
122 |
|
123 |
|
124 | if (this.hasTracked) this.untrackPopover()
|
125 | },
|
126 | resolvePopoverLayout () {
|
127 |
|
128 | |
129 |
|
130 |
|
131 | const pickerSettings = {
|
132 | preferPlace: this.props.preferPlace,
|
133 | place: this.props.place,
|
134 | }
|
135 |
|
136 | |
137 |
|
138 |
|
139 |
|
140 |
|
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
147 |
|
148 |
|
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 | |
170 |
|
171 |
|
172 | const pos = Layout.calcRelPos(zone, tb, this.size)
|
173 |
|
174 |
|
175 | pos[axis.main.start] += this.props.offset * zone.order
|
176 |
|
177 |
|
178 |
|
179 |
|
180 | |
181 |
|
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 | |
195 |
|
196 | if (pos.crossLength > frameCrossLength) {
|
197 | log("popoverCrossLength does not fit frame.")
|
198 | pos[axis.cross.start] = 0
|
199 |
|
200 | |
201 |
|
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 | |
207 |
|
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 | |
213 |
|
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 | |
228 |
|
229 |
|
230 | pos[axis.cross.start] += scrollSize.cross
|
231 | pos[axis.main.start] += scrollSize.main
|
232 |
|
233 | |
234 |
|
235 |
|
236 |
|
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 |
|
244 |
|
245 | log("pos", pos)
|
246 | this.containerEl.style.top = `${pos.y}px`
|
247 | this.containerEl.style.left = `${pos.x}px`
|
248 |
|
249 |
|
250 |
|
251 | let tipCrossPos = (
|
252 | |
253 |
|
254 |
|
255 | Layout.centerOfBoundsFromBounds(zone.flow, "cross", tb, pos)
|
256 |
|
257 | |
258 |
|
259 | + scrollSize.cross
|
260 |
|
261 | |
262 |
|
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 |
|
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 |
|
334 |
|
335 | this.containerEl.offsetHeight
|
336 |
|
337 |
|
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 |
|
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 | |
360 |
|
361 |
|
362 |
|
363 | this.frameEl = window
|
364 | this.hasTracked = true
|
365 |
|
366 | |
367 |
|
368 | if (this.props.refreshIntervalMs) {
|
369 | this.checkLayoutInterval = setInterval(this.checkTargetReposition, this.props.refreshIntervalMs)
|
370 | }
|
371 |
|
372 | |
373 |
|
374 |
|
375 |
|
376 |
|
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 | |
389 |
|
390 |
|
391 | document.addEventListener("mousedown", this.checkForOuterAction)
|
392 | document.addEventListener("touchstart", this.checkForOuterAction)
|
393 |
|
394 |
|
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 | |
456 |
|
457 |
|
458 |
|
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 |
|
477 |
|
478 | module.exports = Popover
|