diff options
| author | Damien Bouvy <dbo@odoo.com> | 2020-03-16 14:25:40 +0000 |
|---|---|---|
| committer | Damien Bouvy <dbo@odoo.com> | 2020-04-14 11:59:04 +0000 |
| commit | 256c75baf70bf05c2260f9091837f39293599f45 (patch) | |
| tree | ad54c843f8a985a46374d7f48e717355c1ef424e /addons/web/static/src/js/core | |
| parent | d599a05736dffb7222eeae7059dee19fd3c6f81c (diff) | |
[IMP] web: add a <Popover> component
Using bootstrap popover with owl is possible, but with a lot of
friction.
This commit adds a Popover element to ease developments that require
a popover. This component takes 2 props:
- position='bottom' (optional): 'top', 'bottom', 'left', 'right'
- title (optional)
Its usage requires setting an `opened` slot for the content of the
popover, whilst the 'trigger' of the popover uses the default slot.
E.g.
```xml
<Parent>
<Popover>
<t t-set="opened">
<p>You have opened the popover, congratulations!</p>
<button t-on-click='_handlerOnParent'>Dispatch cake</button>
</t>
<button class="btn btn-primary">Click me!</button>
</Popover>
</Parent>
```
Note that:
- only one popover can be opened at a time (opening a popover will
close any previously opened popover)
- `Popover` uses `Portal` under the hood, so events are propagated
correctly even though the popover is teleported elsewhere in the dom
(see example in this commit message with `_handlerOnParent`)
- `<Popover>` is registed as a global component in QWeb and does not
need to be added to a Component's subcomponents list
closes odoo/odoo#47710
Signed-off-by: Damien Bouvy (dbo) <dbo@odoo.com>
Diffstat (limited to 'addons/web/static/src/js/core')
| -rw-r--r-- | addons/web/static/src/js/core/popover.js | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/addons/web/static/src/js/core/popover.js b/addons/web/static/src/js/core/popover.js new file mode 100644 index 00000000000..96a899287f3 --- /dev/null +++ b/addons/web/static/src/js/core/popover.js @@ -0,0 +1,296 @@ +odoo.define('web.Popover', function () { + 'use strict'; + + const { Component, hooks, misc, QWeb } = owl; + const { Portal } = misc; + const { useRef, useState } = hooks; + + /** + * Popover + * + * Represents a bootstrap-style popover handled with pure JS. The popover + * will be visually bound to its `target` using an arrow-like '::before' + * CSS pseudo-element. + * @extends Component + **/ + class Popover extends Component { + /** + * @param {Object} props + * @param {String} props.position='bottom' 'top', 'bottom', 'left' or 'right' + * @param {String} [props.title] + */ + constructor() { + super(...arguments); + this.popoverRef = useRef('popover'); + this.orderedPositions = ['top', 'right', 'bottom', 'left']; + this.state = useState({ + displayed: false, + }); + } + + patched() { + super.patched(...arguments); + this.show(); + } + + /** + * Show the popover according to its props. This method will try to position the + * popover as requested (according to the `position` props). If the requested position + * does not fit the viewport, other positions will be tried in a clockwise order starting + * a the requested position (e.g. starting from left: top, right, bottom). If no position + * is found that fits the viewport, 'bottom' is used. + */ + show() { + if (!this.state.displayed) { + return; + } + // get the target from the dom + // we don't want to do this early, since the target might not + // be in the dom when the component is instanciated + if (!this.constructor._isInViewport(this.el)) { + // target is no longer in the viewport, close the popover + return this._close(); + } + + const positionIndex = this.orderedPositions.indexOf( + this.props.position + ); + + const positioningData = this.constructor._computePositioningData( + this.popoverRef.el, + this.el + ); + + // check if the requested position fits the viewport; if not, + // try all other positions and find one that does + const position = this.orderedPositions + .slice(positionIndex) + .concat(this.orderedPositions.slice(0, positionIndex)) + .map((pos) => positioningData[pos]) + .find((pos) => { + this.popoverRef.el.style.top = `${pos.top}px`; + this.popoverRef.el.style.left = `${pos.left}px`; + return this.constructor._isInViewport(this.popoverRef.el); + }); + + // remove all positioning classes + this.orderedPositions.forEach((pos) => { + this.popoverRef.el.classList.remove(`o_popover--${pos}`); + }); + + // apply the found position ('position' props or another position + // that fits the viewport) if it fits the viewport, otherwise + // fallback to 'bottom' if no position fits the viewport + if (position) { + this.popoverRef.el.classList.add(`o_popover--${position.name}`); + } else { + this.popoverRef.el.style.top = `${positioningData.bottom.top}px`; + this.popoverRef.el.style.left = `${positioningData.bottom.left}px`; + this.popoverRef.el.classList.add(`o_popover--bottom`); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _close() { + this.state.displayed = false; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Call the static method to display the popover, ensuring all others + * popover are closed first. This prevents having more than one popover + * open at a time. + * @param {DOMEvent} e + */ + openPopover(e) { + this.constructor.display(this); + } + + //-------------------------------------------------------------------------- + // Static + //-------------------------------------------------------------------------- + + /** + * Compute the expected positioning coordinates for each possible + * positioning based on the target and popover sizes. + * @private + * @static + * @param {HTMLElement} popoverElement The popover element + * @param {HTMLElement} targetElement The target element, to which + * the popover will be visually 'bound' + * @returns {Object} + */ + static _computePositioningData(popoverElement, targetElement) { + const boundingRectangle = targetElement.getBoundingClientRect(); + const targetTop = boundingRectangle.top; + const targetLeft = boundingRectangle.left; + const targetHeight = targetElement.offsetHeight; + const targetWidth = targetElement.offsetWidth; + const popoverHeight = popoverElement.offsetHeight; + const popoverWidth = popoverElement.offsetWidth; + return { + top: { + name: 'top', + top: targetTop - popoverHeight, + left: targetLeft - (popoverWidth - targetWidth) / 2, + }, + right: { + name: 'right', + top: targetTop - (popoverHeight - targetHeight) / 2, + left: targetLeft + targetWidth, + }, + bottom: { + name: 'bottom', + top: targetTop + targetHeight, + left: targetLeft - (popoverWidth - targetWidth) / 2, + }, + left: { + name: 'left', + top: targetTop - (popoverHeight - targetHeight) / 2, + left: targetLeft - popoverWidth, + }, + }; + } + + /** + * Check if the element is in the viewport. + * @private + * @static + * @param {HTMLElement} element + * @returns {Boolean} True if the element currently fits inside + * the viewport, false otherwise. + */ + static _isInViewport(element) { + const rect = element.getBoundingClientRect(); + const html = document.documentElement; + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || html.clientHeight) && + rect.right <= (window.innerWidth || html.clientWidth) + ); + } + + /** + * Hide any displayed popover, display the popover in argument and mark + * it as the one being displayed in the class attribute. Add an event + * listener on the document to detect any click outside the popover as a + * closing event. + * Note that the event listener is added for the capture phase, meaning + * that it will *always* run before the click listener set on a popover + * template. This means that there can be no race condition regarding + * clicking on another popover trigger: any existing popover will be + * removed during the capture phase, then a new listener will be added + * in the bubbling phase of the new popover's trigger click event. + * @static + * @param {Popover} popover The popover component to display. + */ + static display(popover) { + // this should never happen because of the way the events are handled, + // but in case an imaginative dev puts a popover inside another one, i'd + // prefer the first one to be hidden anyway, causing the second one to + // never display itself (since its target won't be in the viewport) + if (this.displayed) { + this.displayed._close(); + } + if (!this.isListening) { + document.addEventListener('click', documentClickHandler, { + capture: true, + }); + document.addEventListener('scroll', documentScrollHandler, { + capture: true, + }); + window.addEventListener('resize', windowResizeHandler); + this.isListening = true; + } + this.displayed = popover; + popover.state.displayed = true; + } + + /** + * Hide any displayed popover and remove event listeners if no popover is + * about to replace the current one. + * @static + * @param {Boolean} nextPopover: whether a new popover is about to be opened; if true, + * the document click listener will not be removed. + */ + static hide(nextPopover) { + const popover = this.displayed; + // only remove the listener if we are not about to open a new popover + if (this.isListening && !nextPopover) { + document.removeEventListener('click', documentClickHandler, { + capture: true, + }); + document.removeEventListener('scroll', documentScrollHandler, { + capture: true, + }); + window.removeEventListener('resize', windowResizeHandler); + this.isListening = false; + } + popover.state.displayed = false; + this.displayed = null; + } + } + + /** + * Global handler added on the document for when a popover is currently displayed. + * A click outside the popover will dismiss the current popover. + * @param {MouseEvent} e + */ + const documentClickHandler = function (e) { + const popover = Popover.displayed.popoverRef; + if (!popover.el.contains(e.target)) { + const nextPopover = !!e.target.closest('*[data-popover]'); + Popover.hide(nextPopover); + } + }; + + /** + * Reposition the currently displayed popover relative to its traget. + * @param {Event} e + */ + const _reposition = function (e) { + const popover = Popover.displayed; + popover.show(); + }; + + /** + * Global handler added on the document for when a popover is currently displayed. + * A scroll event will need to 'reposition' the popover close to its target. + */ + const documentScrollHandler = _.throttle(_reposition, 50); + + /** + * Global handler added on the document for when a popover is currently displayed. + * A resize event will need to 'reposition' the popover close to its target. + */ + const windowResizeHandler = _.debounce(_reposition, 250); + + Popover.displayed = null; + Popover.isListening = false; + Popover.components = { Portal }; + Popover.template = 'Popover'; + Popover.defaultProps = { + position: 'bottom', + }; + Popover.props = { + position: { + type: String, + validate: (p) => ['top', 'bottom', 'left', 'right'].includes(p), + }, + title: { type: String, optional: true }, + }; + + QWeb.registerComponent('Popover', Popover); + + return Popover; +}); |
