summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
authorDamien Bouvy <dbo@odoo.com>2020-03-16 14:25:40 +0000
committerDamien Bouvy <dbo@odoo.com>2020-04-14 11:59:04 +0000
commit256c75baf70bf05c2260f9091837f39293599f45 (patch)
treead54c843f8a985a46374d7f48e717355c1ef424e /addons/web/static/src
parentd599a05736dffb7222eeae7059dee19fd3c6f81c (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')
-rw-r--r--addons/web/static/src/js/core/popover.js296
-rw-r--r--addons/web/static/src/scss/popover.scss158
-rw-r--r--addons/web/static/src/xml/base.xml15
3 files changed, 469 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;
+});
diff --git a/addons/web/static/src/scss/popover.scss b/addons/web/static/src/scss/popover.scss
new file mode 100644
index 00000000000..efd45b436f2
--- /dev/null
+++ b/addons/web/static/src/scss/popover.scss
@@ -0,0 +1,158 @@
+@keyframes slide-top {
+ 0% {
+ opacity: 0;
+ transform: translateY(-5%);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes slide-right {
+ 0% {
+ opacity: 0;
+ transform: translateX(5%);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slide-bottom {
+ 0% {
+ opacity: 0;
+ transform: translateY(5%);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes slide-left {
+ 0% {
+ opacity: 0;
+ transform: translateX(-5%);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.o_popover {
+ $border-color: gray('300');
+ $bg-color: gray('200');
+ $arrow-pad: 8px;
+ $arrow-size: 8px;
+ $animation-time: 0.2s;
+ width: 280px;
+ max-width: 100vw;
+
+ position: fixed;
+ top: 0; // will be updated in js
+ left: 0; // but let's set it in the viewport in case of test/debug
+ border: 1px solid $border-color;
+ background-color: #fff;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
+
+ .o_popover_header {
+ background-color: gray('200');
+ font-size: 1.1rem;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid $border-color;
+ &:empty {
+ display: none;
+ }
+ }
+
+ &--top {
+ margin-top: -$arrow-size - $arrow-pad;
+ animation: $animation-time slide-top;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-left: -$arrow-size;
+ border: $arrow-size solid transparent;
+ border-top-color: $bg-color;
+ }
+
+ &::before {
+ margin-top: 1px;
+ border-top-color: $border-color;
+ }
+ }
+
+ &--right {
+ margin-left: $arrow-size + $arrow-pad;
+ animation: $animation-time slide-right;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ right: 100%;
+ margin-top: -$arrow-size;
+ border: $arrow-size solid transparent;
+ border-right-color: $bg-color;
+ }
+
+ &::before {
+ margin-right: 1px;
+ border-right-color: $border-color;
+ }
+ }
+
+ &--bottom {
+ margin-top: $arrow-size + $arrow-pad;
+ animation: $animation-time slide-bottom;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ margin-left: -$arrow-size;
+ border: $arrow-size solid transparent;
+ border-bottom-color: $bg-color;
+ }
+
+ &::before {
+ margin-bottom: 1px;
+ border-bottom-color: $border-color;
+ }
+ }
+
+ &--left {
+ margin-left: -$arrow-size - $arrow-pad;
+ animation: $animation-time slide-left;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 100%;
+ margin-top: -$arrow-size;
+ border: $arrow-size solid transparent;
+ border-left-color: $bg-color;
+ }
+
+ &::before {
+ margin-left: 1px;
+ border-left-color: $border-color;
+ }
+ }
+}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index db1e893c168..99b4588c450 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -505,6 +505,21 @@
</Portal>
</t>
+<t t-name="Popover" owl="1">
+ <div t-on-click="openPopover" data-popover="1">
+ <t t-slot="default"/>
+ <Portal t-if="state.displayed" target="'body'">
+ <div role="tooltip" class="o_popover" t-ref="popover">
+ <div class="arrow"/>
+ <h3 t-if="props.title" class="o_popover_header"><t t-esc="props.title"/></h3>
+ <div class="popover-body">
+ <t t-slot="opened"/>
+ </div>
+ </div>
+ </Portal>
+ </div>
+</t>
+
<t t-name="web.Pager" owl="1">
<nav class="o_pager" aria-label="Pager">
<span class="o_pager_counter" t-on-click.stop="">