diff --git a/addons/web/static/src/core/popover/popover.js b/addons/web/static/src/core/popover/popover.js
index 92b46b1553e..2104b612dd6 100644
--- a/addons/web/static/src/core/popover/popover.js
+++ b/addons/web/static/src/core/popover/popover.js
@@ -1,80 +1,15 @@
/** @odoo-module **/
-import { debounce, throttle } from "../utils/timing";
+import { usePosition } from "../position/position_hook";
const { Component } = owl;
-const { useExternalListener, useRef } = owl.hooks;
export class Popover extends Component {
setup() {
- this.popoverRef = useRef("popover");
-
- useExternalListener(document, "scroll", throttle(this.compute, 50), { capture: true });
- useExternalListener(window, "resize", debounce(this.compute, 250));
- }
-
- mounted() {
- this.compute();
- }
- patched() {
- this.compute();
- }
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Computes 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.
- *
- * @private
- */
- compute() {
- const positioningData = this.constructor.computePositioningData(
- this.popoverRef.el,
- this.props.target
- );
-
- const ORDERED_POSITIONS = ["top", "bottom", "left", "right"];
- // copy the default ordered position to avoid updating them in place
- const positionIndex = ORDERED_POSITIONS.indexOf(this.props.position);
- // check if the requested position fits the viewport; if not,
- // try all other positions and find one that does
- const position = ORDERED_POSITIONS.slice(positionIndex)
- .concat(ORDERED_POSITIONS.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`;
- const rect = this.popoverRef.el.getBoundingClientRect();
- const html = document.documentElement;
- return (
- rect.top >= 0 &&
- rect.left >= 0 &&
- rect.bottom <= (window.innerHeight || html.clientHeight) &&
- rect.right <= (window.innerWidth || html.clientWidth)
- );
- });
-
- // remove all existing positioning classes
- for (const pos of ORDERED_POSITIONS) {
- this.popoverRef.el.classList.remove(`o_popover_${pos}`);
- }
-
- if (position) {
- // apply the preferred found position that fits the viewport
- this.popoverRef.el.classList.add(`o_popover_${position.name}`);
- } else {
- // use the given `position` props because no position fits
- this.popoverRef.el.style.top = `${positioningData[this.props.position].top}px`;
- this.popoverRef.el.style.left = `${positioningData[this.props.position].left}px`;
- this.popoverRef.el.classList.add(`o_popover_${this.props.position}`);
- }
+ usePosition(this.props.target, {
+ margin: 16,
+ position: this.props.position,
+ });
}
}
@@ -93,66 +28,3 @@ Popover.props = {
},
target: HTMLElement,
};
-
-/**
- * Compute the expected positioning coordinates for each possible
- * positioning based on the target and popover sizes.
- * In particular the popover must not overflow the viewport in any
- * direction, it should actually stay at `margin` distance from the
- * border to look good.
- *
- * @param {HTMLElement} popoverElement The popover element
- * @param {HTMLElement} targetElement The target element, to which
- * the popover will be visually "bound"
- * @param {number} [margin=16] Minimal accepted margin from the border
- * of the viewport.
- * @returns {Object}
- */
-Popover.computePositioningData = function (popoverElement, targetElement, margin = 16) {
- // set target position, possible position
- 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;
- const windowWidth = window.innerWidth || document.documentElement.clientWidth;
- const windowHeight = window.innerHeight || document.documentElement.clientHeight;
- const leftOffsetForVertical = Math.max(
- margin,
- Math.min(
- Math.round(targetLeft - (popoverWidth - targetWidth) / 2),
- windowWidth - popoverWidth - margin
- )
- );
- const topOffsetForHorizontal = Math.max(
- margin,
- Math.min(
- Math.round(targetTop - (popoverHeight - targetHeight) / 2),
- windowHeight - popoverHeight - margin
- )
- );
- return {
- top: {
- name: "top",
- top: Math.round(targetTop - popoverHeight),
- left: leftOffsetForVertical,
- },
- right: {
- name: "right",
- top: topOffsetForHorizontal,
- left: Math.round(targetLeft + targetWidth),
- },
- bottom: {
- name: "bottom",
- top: Math.round(targetTop + targetHeight),
- left: leftOffsetForVertical,
- },
- left: {
- name: "left",
- top: topOffsetForHorizontal,
- left: Math.round(targetLeft - popoverWidth),
- },
- };
-};
diff --git a/addons/web/static/src/core/popover/popover.scss b/addons/web/static/src/core/popover/popover.scss
index 7f65b53cd66..3a5f355d18a 100644
--- a/addons/web/static/src/core/popover/popover.scss
+++ b/addons/web/static/src/core/popover/popover.scss
@@ -54,99 +54,98 @@
$animation-time: 0.2s;
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
z-index: 1060;
border: 1px solid $border-color;
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
- &_top {
- animation: $animation-time slide-top;
- margin-top: -$arrow-size;
-
- &::before,
- &::after {
- pointer-events: none;
- 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 {
- animation: $animation-time slide-right;
- margin-left: $arrow-size;
-
- &::before,
- &::after {
- pointer-events: none;
- content: "";
- position: absolute;
- top: 50%;
- right: 100%;
+ &.o-popper-position {
+ &--tm {
+ animation: $animation-time slide-top;
margin-top: -$arrow-size;
- border: $arrow-size solid transparent;
- border-right-color: $bg-color;
+
+ &::before,
+ &::after {
+ pointer-events: none;
+ 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;
+ }
}
- &::before {
- margin-right: 1px;
- border-right-color: $border-color;
+ &--rm {
+ animation: $animation-time slide-right;
+ margin-left: $arrow-size;
+
+ &::before,
+ &::after {
+ pointer-events: none;
+ 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 {
- animation: $animation-time slide-bottom;
- margin-top: $arrow-size;
+ &--bm {
+ animation: $animation-time slide-bottom;
+ margin-top: $arrow-size;
- &::before,
- &::after {
- pointer-events: none;
- content: "";
- position: absolute;
- bottom: 100%;
- left: 50%;
+ &::before,
+ &::after {
+ pointer-events: none;
+ 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;
+ }
+ }
+
+ &--lm {
+ animation: $animation-time slide-left;
margin-left: -$arrow-size;
- border: $arrow-size solid transparent;
- border-bottom-color: $bg-color;
- }
- &::before {
- margin-bottom: 1px;
- border-bottom-color: $border-color;
- }
- }
+ &::before,
+ &::after {
+ pointer-events: none;
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 100%;
+ margin-top: -$arrow-size;
+ border: $arrow-size solid transparent;
+ border-left-color: $bg-color;
+ }
- &_left {
- animation: $animation-time slide-left;
- margin-left: -$arrow-size;
-
- &::before,
- &::after {
- pointer-events: none;
- 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;
+ &::before {
+ margin-left: 1px;
+ border-left-color: $border-color;
+ }
}
}
}
diff --git a/addons/web/static/src/core/popover/popover.xml b/addons/web/static/src/core/popover/popover.xml
index 7ec48f8082d..d734e1279e9 100644
--- a/addons/web/static/src/core/popover/popover.xml
+++ b/addons/web/static/src/core/popover/popover.xml
@@ -5,7 +5,6 @@
diff --git a/addons/web/static/src/core/position/position.scss b/addons/web/static/src/core/position/position.scss
new file mode 100644
index 00000000000..c96e9f2401d
--- /dev/null
+++ b/addons/web/static/src/core/position/position.scss
@@ -0,0 +1,5 @@
+.o-popper-position {
+ position: fixed;
+ top: 0;
+ left: 0;
+}
diff --git a/addons/web/static/src/core/position/position_hook.js b/addons/web/static/src/core/position/position_hook.js
new file mode 100644
index 00000000000..b1dee994655
--- /dev/null
+++ b/addons/web/static/src/core/position/position_hook.js
@@ -0,0 +1,229 @@
+/** @odoo-module */
+
+import { useEffect } from "@web/core/utils/hooks";
+import { debounce, throttle } from "../utils/timing";
+
+/**
+ * @typedef {{
+ * popper?: string;
+ * container?: HTMLElement;
+ * margin?: number;
+ * position?: Position;
+ * }} Options
+ *
+ * @typedef {{
+ * directions: (DirectionsDataKey)[];
+ * variants: (VariantsDataKey)[];
+ * get: (d?: DirectionsDataKey, v?: VariantsDataKey, containerRestricted?: boolean) => PositioningSolution | null;
+ * }} Positioning
+ *
+ * @typedef {keyof DirectionsData} DirectionsDataKey
+ * @typedef {{
+ * t: number;
+ * b: number;
+ * l: number;
+ * r: number;
+ * }} DirectionsData
+ *
+ * @typedef {keyof VariantsData} VariantsDataKey
+ * @typedef {{
+ * vs: number;
+ * vm: number;
+ * ve: number;
+ * hs: number;
+ * hm: number;
+ * he: number;
+ * }} VariantsData
+ *
+ * @typedef {"top" | "left" | "bottom" | "right"} Direction
+ * @typedef {Direction
+ * | "top-start" | "top-middle" | "top-end"
+ * | "left-start" | "left-middle" | "left-end"
+ * | "right-start" | "right-middle" | "right-end"
+ * | "bottom-start" | "bottom-middle" | "bottom-end"} Position
+ *
+ * @typedef {{ className: string, top: number, left: number }} PositioningSolution
+ */
+
+const { hooks } = owl;
+const { useComponent, useExternalListener, useRef } = hooks;
+
+const POPPER_CLASS = "o-popper-position";
+const DIRECTION_FLIP_ORDER = { top: "tbrl", right: "rltb", bottom: "btrl", left: "lrbt" };
+const VARIANT_FLIP_ORDER = { start: "sme", middle: "mse", end: "ems" };
+
+/** @type {Options} */
+export const DEFAULTS = {
+ margin: 0,
+ position: "bottom",
+};
+
+/**
+ * Computes positioning data used to determine each possible position
+ * based on the reference, popper, and container sizes.
+ * Particularly, a popper must not overflow the container in any direction,
+ * it should actually stay at `margin` distance from the border to look good.
+ *
+ * @param {HTMLElement} reference
+ * @param {HTMLElement} popper
+ * @param {Options} options
+ * @returns {Positioning} a positioning object containing:
+ * - ascendingly sorted directions and variants
+ * - a method returning style to apply to the popper for a given direction and variant
+ */
+export function computePositioning(reference, popper, options) {
+ const { container, margin, position } = options;
+
+ // Retrieve directions and variants
+ const [directionKey, variantKey = "middle"] = position.split("-");
+ const directions = DIRECTION_FLIP_ORDER[directionKey];
+ const variants = VARIANT_FLIP_ORDER[variantKey];
+
+ // Boxes
+ const popBox = popper.getBoundingClientRect();
+ const refBox = reference.getBoundingClientRect();
+ const contBox = container.getBoundingClientRect();
+
+ // Compute positioning data
+ /** @type {DirectionsData} */
+ const directionsData = {
+ t: refBox.top - popBox.height - margin,
+ b: refBox.bottom + margin,
+ r: refBox.right + margin,
+ l: refBox.left - popBox.width - margin,
+ };
+ /** @type {VariantsData} */
+ const variantsData = {
+ vs: refBox.left,
+ vm: refBox.left + refBox.width / 2 + -popBox.width / 2,
+ ve: refBox.right - popBox.width,
+ hs: refBox.top,
+ hm: refBox.top + refBox.height / 2 + -popBox.height / 2,
+ he: refBox.bottom - popBox.height,
+ };
+
+ function get(d = directions[0], v = variants[0], containerRestricted = false) {
+ const vertical = ["t", "b"].includes(d);
+ const variantPrefix = vertical ? "v" : "h";
+ const directionValue = directionsData[d];
+ const variantValue = variantsData[variantPrefix + v];
+
+ if (containerRestricted) {
+ const [directionSize, variantSize] = vertical
+ ? [popBox.height + margin, popBox.width]
+ : [popBox.width, popBox.height + margin];
+ const [directionMin, directionMax] = vertical
+ ? [contBox.top, contBox.bottom]
+ : [contBox.left, contBox.right];
+ const [variantMin, variantMax] = vertical
+ ? [contBox.left, contBox.right]
+ : [contBox.top, contBox.bottom];
+
+ // Abort if outside container boundaries
+ const directionOverflow =
+ directionValue < directionMin || directionValue + directionSize > directionMax;
+ const variantOverflow =
+ variantValue < variantMin || variantValue + variantSize > variantMax;
+ if (directionOverflow || variantOverflow) {
+ return null;
+ }
+ }
+
+ const positioning = vertical
+ ? {
+ top: directionValue,
+ left: variantValue,
+ }
+ : {
+ top: variantValue,
+ left: directionValue,
+ };
+ return { ...positioning, className: `${POPPER_CLASS}--${d}${v}` };
+ }
+
+ return {
+ directions,
+ variants,
+ get,
+ };
+}
+
+/**
+ * This method will try to position the popper as requested (according to options).
+ * If the requested position does not fit the container, other positions will be
+ * tried in different direction and variant flip orders (depending on the requested position).
+ * If no position is found that fits the container, the requested position stays used.
+ *
+ * When the final position is applied, a corresponding CSS class is also added to the popper.
+ * This could be used to further styling.
+ *
+ * @param {HTMLElement} reference
+ * @param {HTMLElement} popper
+ * @param {Options} options
+ */
+function reposition(reference, popper, options) {
+ options = {
+ container: document.documentElement,
+ ...DEFAULTS,
+ ...options,
+ };
+
+ // Reset all existing popper classes and only leave it as standalone
+ for (const popperClass of popper.classList) {
+ if (popperClass.startsWith(POPPER_CLASS)) {
+ popper.classList.remove(popperClass);
+ }
+ }
+ popper.classList.add(POPPER_CLASS);
+
+ // Compute positioning and find first match
+ const positioning = computePositioning(reference, popper, options);
+ for (const d of positioning.directions) {
+ for (const v of positioning.variants) {
+ const posData = positioning.get(d, v, true);
+ if (!posData) {
+ continue;
+ }
+
+ // From now, a position match have been found.
+ // Apply styles
+ const { className, top, left } = posData;
+ popper.classList.add(className);
+ popper.style.top = `${top}px`;
+ popper.style.left = `${left}px`;
+ return;
+ }
+ }
+
+ // use the given `position` because no position fits
+ const { className, top, left } = positioning.get();
+ popper.classList.add(className);
+ popper.style.top = `${top}px`;
+ popper.style.left = `${left}px`;
+}
+
+/**
+ * Makes sure that the `popper` element is always
+ * placed at `position` from the `reference` element.
+ * If doing so the `popper` element is clipped off `container`,
+ * sensible fallback positions are tried.
+ * If all of fallback positions are also clipped off `container`,
+ * the original position is used.
+ *
+ * @param {HTMLElement | (()=>HTMLElement)} reference
+ * @param {Options} options
+ */
+export function usePosition(reference, options) {
+ options = { ...DEFAULTS, ...options };
+ const { popper } = options;
+ const popperRef = popper ? useRef(popper) : useComponent();
+ const getReference = reference instanceof HTMLElement ? () => reference : reference;
+ const update = () => {
+ if (popperRef.el) {
+ reposition(getReference(), popperRef.el, options);
+ }
+ };
+ useEffect(update);
+ useExternalListener(document, "scroll", throttle(update, 50), { capture: true });
+ useExternalListener(window, "resize", debounce(update, 250));
+}
diff --git a/addons/web/static/tests/core/popover/popover_tests.js b/addons/web/static/tests/core/popover/popover_tests.js
index 3ca7f47d009..921703a6c5c 100644
--- a/addons/web/static/tests/core/popover/popover_tests.js
+++ b/addons/web/static/tests/core/popover/popover_tests.js
@@ -2,31 +2,23 @@
import { Popover } from "@web/core/popover/popover";
import { registerCleanup } from "../../helpers/cleanup";
-import { getFixture, patchWithCleanup } from "../../helpers/utils";
+import { getFixture } from "../../helpers/utils";
const { mount } = owl;
let fixture;
let popoverTarget;
-function computePositioningDataTest(popover, target) {
- const rect = target.getBoundingClientRect();
- const top = Math.floor(rect.top);
- const left = Math.floor(rect.left);
- return {
- top: { name: "top", top, left },
- bottom: { name: "bottom", top: top + 10, left },
- left: { name: "left", top: top + 10, left },
- right: { name: "right", top: top + 10, left: left + 10 },
- };
-}
+const positionClassMap = {
+ top: "o-popper-position--tm",
+ right: "o-popper-position--rm",
+ bottom: "o-popper-position--bm",
+ left: "o-popper-position--lm",
+};
-function pointsTo(popover, element, position) {
- const hasCorrectClass = popover.classList.contains(`o_popover_${position}`);
- const expectedPosition = Popover.computePositioningData(popover, element)[position];
- const correctLeft = parseFloat(popover.style.left) === expectedPosition.left;
- const correctTop = parseFloat(popover.style.top) === expectedPosition.top;
- return hasCorrectClass && correctLeft && correctTop;
+function pointsTo(popover, position) {
+ const hasCorrectClass = popover.classList.contains(positionClassMap[position]);
+ return hasCorrectClass;
}
QUnit.module("Popover", {
@@ -40,10 +32,6 @@ QUnit.module("Popover", {
registerCleanup(() => {
fixture.removeChild(popoverTarget);
});
-
- patchWithCleanup(Popover, {
- computePositioningData: computePositioningDataTest,
- });
},
});
@@ -64,7 +52,7 @@ QUnit.test("popover is rendered nearby target (default)", async (assert) => {
});
const popoverEl = fixture.querySelector(".o_popover");
- assert.ok(pointsTo(popoverEl, popoverTarget, "bottom"));
+ assert.ok(pointsTo(popoverEl, "bottom"));
popover.destroy();
});
@@ -75,7 +63,7 @@ QUnit.test("popover is rendered nearby target (bottom)", async (assert) => {
});
const popoverEl = fixture.querySelector(".o_popover");
- assert.ok(pointsTo(popoverEl, popoverTarget, "bottom"));
+ assert.ok(pointsTo(popoverEl, "bottom"));
popover.destroy();
});
@@ -86,7 +74,7 @@ QUnit.test("popover is rendered nearby target (top)", async (assert) => {
});
const popoverEl = fixture.querySelector(".o_popover");
- assert.ok(pointsTo(popoverEl, popoverTarget, "top"));
+ assert.ok(pointsTo(popoverEl, "top"));
popover.destroy();
});
@@ -97,7 +85,7 @@ QUnit.test("popover is rendered nearby target (left)", async (assert) => {
});
const popoverEl = fixture.querySelector(".o_popover");
- assert.ok(pointsTo(popoverEl, popoverTarget, "left"));
+ assert.ok(pointsTo(popoverEl, "left"));
popover.destroy();
});
@@ -108,6 +96,6 @@ QUnit.test("popover is rendered nearby target (right)", async (assert) => {
});
const popoverEl = fixture.querySelector(".o_popover");
- assert.ok(pointsTo(popoverEl, popoverTarget, "right"));
+ assert.ok(pointsTo(popoverEl, "right"));
popover.destroy();
});
diff --git a/addons/web/static/tests/core/position_hook_tests.js b/addons/web/static/tests/core/position_hook_tests.js
new file mode 100644
index 00000000000..202f2cf3d4e
--- /dev/null
+++ b/addons/web/static/tests/core/position_hook_tests.js
@@ -0,0 +1,754 @@
+/** @odoo-module */
+
+import { browser } from "@web/core/browser/browser";
+import { computePositioning, DEFAULTS, usePosition } from "@web/core/position/position_hook";
+import { registerCleanup } from "../helpers/cleanup";
+import { getFixture, nextTick, patchWithCleanup, triggerEvent } from "../helpers/utils";
+
+const { Component, mount, tags } = owl;
+const { css, xml } = tags;
+let container;
+let reference;
+
+/**
+ * @param {HTMLElement} popper
+ * @param {import("@web/core/position/position_hook").Position} [position="bottom"]
+ * @returns {boolean}
+ */
+function isWellPositioned(popper, position = "bottom") {
+ /** @type {import("@web/core/position/position_hook").Options} */
+ const options = {
+ ...DEFAULTS,
+ container,
+ position,
+ };
+ const [direction, variant = "middle"] = position.split("-");
+ const d = /** @type {import("@web/core/position/position_hook").DirectionsDataKey} */ (direction[0]);
+ const v = /** @type {import("@web/core/position/position_hook").VariantsDataKey} */ (variant[0]);
+ const posSolution = computePositioning(reference, popper, options).get(d, v);
+ const hasCorrectClass = popper.classList.contains(posSolution.className);
+ const correctLeft = parseFloat(popper.style.left) === posSolution.left;
+ const correctTop = parseFloat(popper.style.top) === posSolution.top;
+ return hasCorrectClass && correctLeft && correctTop;
+}
+
+class TestComp extends Component {
+ setup() {
+ usePosition(reference, this.constructor.popperOptions);
+ }
+}
+TestComp.style = css`
+ #popper {
+ background-color: cyan;
+ height: 100px;
+ width: 100px;
+ }
+`;
+TestComp.template = xml``;
+/** @type {import("@web/core/position/position_hook").Options} */
+TestComp.popperOptions = {};
+
+QUnit.module("usePosition Hook", {
+ async beforeEach() {
+ reference = document.createElement("div");
+ reference.id = "reference";
+ reference.style.backgroundColor = "yellow";
+ reference.style.height = "50px";
+ reference.style.width = "50px";
+
+ // Force container style, to make these tests independent of screen size
+ container = document.createElement("div");
+ container.id = "container";
+ container.style.backgroundColor = "pink";
+ container.style.height = "450px";
+ container.style.width = "450px";
+ container.style.display = "flex";
+ container.style.alignItems = "center";
+ container.style.justifyContent = "center";
+ container.appendChild(reference);
+ getFixture().prepend(container);
+ registerCleanup(() => {
+ getFixture().removeChild(container);
+ });
+ TestComp.popperOptions = { container };
+
+ patchWithCleanup(browser, { setTimeout: (func) => func() });
+ },
+});
+
+QUnit.test("default position is bottom", async (assert) => {
+ const popper = await mount(TestComp, { target: container });
+ assert.ok(isWellPositioned(popper.el, "bottom"));
+});
+
+QUnit.test("can add margin", async (assert) => {
+ let margin = 0;
+ Object.assign(TestComp.popperOptions, { margin });
+ let popper = await mount(TestComp, { target: container });
+ const popBox1 = popper.el.getBoundingClientRect();
+ popper.destroy();
+
+ margin = 20;
+ Object.assign(TestComp.popperOptions, { margin });
+ popper = await mount(TestComp, { target: container });
+ const popBox2 = popper.el.getBoundingClientRect();
+ popper.destroy();
+
+ assert.strictEqual(popBox1.top + margin, popBox2.top);
+});
+
+QUnit.test("can use a t-ref as popper", async (assert) => {
+ patchWithCleanup(TestComp, {
+ popperOptions: { ...TestComp.popperOptions, popper: "popperRef" },
+ template: xml`
+
+ `,
+ });
+ const comp = await mount(TestComp, { target: container });
+ assert.notOk(isWellPositioned(comp.el));
+ assert.ok(isWellPositioned(comp.el.querySelector("#popper")));
+});
+
+const getPositionTest = (position, positionToCheck) => {
+ return async (assert) => {
+ Object.assign(TestComp.popperOptions, { position });
+ const popper = await mount(TestComp, { target: container });
+ positionToCheck = positionToCheck || position;
+ assert.ok(isWellPositioned(popper.el, positionToCheck));
+ };
+};
+QUnit.test("position top", getPositionTest("top"));
+QUnit.test("position left", getPositionTest("left"));
+QUnit.test("position bottom", getPositionTest("bottom"));
+QUnit.test("position right", getPositionTest("right"));
+QUnit.test("position top-start", getPositionTest("top-start"));
+QUnit.test("position top-middle", getPositionTest("top-middle"));
+QUnit.test("position top-end", getPositionTest("top-end"));
+QUnit.test("position left-start", getPositionTest("left-start"));
+QUnit.test("position left-middle", getPositionTest("left-middle"));
+QUnit.test("position left-end", getPositionTest("left-end"));
+QUnit.test("position bottom-start", getPositionTest("bottom-start"));
+QUnit.test("position bottom-middle", getPositionTest("bottom-middle"));
+QUnit.test("position bottom-end", getPositionTest("bottom-end"));
+QUnit.test("position right-start", getPositionTest("right-start"));
+QUnit.test("position right-middle", getPositionTest("right-middle"));
+QUnit.test("position right-end", getPositionTest("right-end"));
+QUnit.test("position top === top-middle", getPositionTest("top", "top-middle"));
+QUnit.test("position left === left-middle", getPositionTest("left", "left-middle"));
+QUnit.test("position bottom === bottom-middle", getPositionTest("bottom", "bottom-middle"));
+QUnit.test("position right === right-middle", getPositionTest("right", "right-middle"));
+
+const CONTAINER_STYLE_MAP = {
+ top: { alignItems: "flex-start" },
+ bottom: { alignItems: "flex-end" },
+ left: { justifyContent: "flex-start" },
+ right: { justifyContent: "flex-end" },
+ slimfit: { height: "100px", width: "100px" }, // height and width of popper
+ h125: { height: "125px" }, // height of popper + 1/2 reference
+ w125: { width: "125px" }, // width of popper + 1/2 reference
+};
+const getRepositionTest = (from, to, containerStyleChanges) => {
+ return async (assert) => {
+ Object.assign(TestComp.popperOptions, { position: from });
+ const popper = await mount(TestComp, { target: container });
+ assert.ok(isWellPositioned(popper.el, from), `points to ${from} direction`);
+
+ // Change container style and force update
+ for (const styleToApply of containerStyleChanges.split(" ")) {
+ Object.assign(container.style, CONTAINER_STYLE_MAP[styleToApply]);
+ }
+ triggerEvent(document, null, "scroll");
+ await nextTick();
+ assert.ok(isWellPositioned(popper.el, to), `points to ${to} direction`);
+ };
+};
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from top-start to bottom-start",
+ getRepositionTest("top-start", "bottom-start", "top")
+);
+QUnit.test(
+ "reposition from top-start to bottom-middle",
+ getRepositionTest("top-start", "bottom-middle", "top w125")
+);
+QUnit.test(
+ "reposition from top-start to bottom-end",
+ getRepositionTest("top-start", "bottom-end", "top right")
+);
+QUnit.test(
+ "reposition from top-start to right-start",
+ getRepositionTest("top-start", "right-start", "h125 top")
+);
+QUnit.test(
+ "reposition from top-start to right-middle",
+ getRepositionTest("top-start", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from top-start to right-end",
+ getRepositionTest("top-start", "right-end", "h125 bottom")
+);
+QUnit.test(
+ "reposition from top-start to left-start",
+ getRepositionTest("top-start", "left-start", "h125 right top")
+);
+QUnit.test(
+ "reposition from top-start to left-middle",
+ getRepositionTest("top-start", "left-middle", "h125 right")
+);
+QUnit.test(
+ "reposition from top-start to left-end",
+ getRepositionTest("top-start", "left-end", "h125 right bottom")
+);
+QUnit.test(
+ "reposition from top-start to top-start",
+ getRepositionTest("top-start", "top-start", "slimfit")
+);
+QUnit.test(
+ "reposition from top-start to top-middle",
+ getRepositionTest("top-start", "top-middle", "w125")
+);
+QUnit.test(
+ "reposition from top-start to top-end",
+ getRepositionTest("top-start", "top-end", "right")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from top-middle to bottom-start",
+ getRepositionTest("top-middle", "bottom-start", "top left")
+);
+QUnit.test(
+ "reposition from top-middle to bottom-middle",
+ getRepositionTest("top-middle", "bottom-middle", "top")
+);
+QUnit.test(
+ "reposition from top-middle to bottom-end",
+ getRepositionTest("top-middle", "bottom-end", "top right")
+);
+QUnit.test(
+ "reposition from top-middle to right-start",
+ getRepositionTest("top-middle", "right-start", "h125 top")
+);
+QUnit.test(
+ "reposition from top-middle to right-middle",
+ getRepositionTest("top-middle", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from top-middle to right-end",
+ getRepositionTest("top-middle", "right-end", "h125 bottom")
+);
+QUnit.test(
+ "reposition from top-middle to left-start",
+ getRepositionTest("top-middle", "left-start", "h125 right top")
+);
+QUnit.test(
+ "reposition from top-middle to left-middle",
+ getRepositionTest("top-middle", "left-middle", "h125 right")
+);
+QUnit.test(
+ "reposition from top-middle to left-end",
+ getRepositionTest("top-middle", "left-end", "h125 right bottom")
+);
+QUnit.test(
+ "reposition from top-middle to top-start",
+ getRepositionTest("top-middle", "top-start", "left")
+);
+QUnit.test(
+ "reposition from top-middle to top-middle",
+ getRepositionTest("top-middle", "top-middle", "slimfit")
+);
+QUnit.test(
+ "reposition from top-middle to top-end",
+ getRepositionTest("top-middle", "top-end", "right")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from top-end to bottom-start",
+ getRepositionTest("top-end", "bottom-start", "top left")
+);
+QUnit.test(
+ "reposition from top-end to bottom-middle",
+ getRepositionTest("top-end", "bottom-middle", "top w125")
+);
+QUnit.test(
+ "reposition from top-end to bottom-end",
+ getRepositionTest("top-end", "bottom-end", "top")
+);
+QUnit.test(
+ "reposition from top-end to right-start",
+ getRepositionTest("top-end", "right-start", "h125 top")
+);
+QUnit.test(
+ "reposition from top-end to right-middle",
+ getRepositionTest("top-end", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from top-end to right-end",
+ getRepositionTest("top-end", "right-end", "h125 bottom")
+);
+QUnit.test(
+ "reposition from top-end to left-start",
+ getRepositionTest("top-end", "left-start", "h125 right top")
+);
+QUnit.test(
+ "reposition from top-end to left-middle",
+ getRepositionTest("top-end", "left-middle", "h125 right")
+);
+QUnit.test(
+ "reposition from top-end to left-end",
+ getRepositionTest("top-end", "left-end", "h125 right bottom")
+);
+QUnit.test(
+ "reposition from top-end to top-start",
+ getRepositionTest("top-end", "top-start", "left")
+);
+QUnit.test(
+ "reposition from top-end to top-middle",
+ getRepositionTest("top-end", "top-middle", "w125")
+);
+QUnit.test(
+ "reposition from top-end to top-end",
+ getRepositionTest("top-end", "top-end", "slimfit")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from left-start to bottom-start",
+ getRepositionTest("left-start", "bottom-start", "w125 left")
+);
+QUnit.test(
+ "reposition from left-start to bottom-middle",
+ getRepositionTest("left-start", "bottom-middle", "w125")
+);
+QUnit.test(
+ "reposition from left-start to bottom-end",
+ getRepositionTest("left-start", "bottom-end", "w125 right")
+);
+QUnit.test(
+ "reposition from left-start to right-start",
+ getRepositionTest("left-start", "right-start", "left")
+);
+QUnit.test(
+ "reposition from left-start to right-middle",
+ getRepositionTest("left-start", "right-middle", "left h125")
+);
+QUnit.test(
+ "reposition from left-start to right-end",
+ getRepositionTest("left-start", "right-end", "left bottom")
+);
+QUnit.test(
+ "reposition from left-start to left-start",
+ getRepositionTest("left-start", "left-start", "slimfit")
+);
+QUnit.test(
+ "reposition from left-start to left-middle",
+ getRepositionTest("left-start", "left-middle", "h125")
+);
+QUnit.test(
+ "reposition from left-start to left-end",
+ getRepositionTest("left-start", "left-end", "bottom")
+);
+QUnit.test(
+ "reposition from left-start to top-start",
+ getRepositionTest("left-start", "top-start", "w125 bottom left")
+);
+QUnit.test(
+ "reposition from left-start to top-middle",
+ getRepositionTest("left-start", "top-middle", "w125 bottom")
+);
+QUnit.test(
+ "reposition from left-start to top-end",
+ getRepositionTest("left-start", "top-end", "w125 bottom right")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from left-middle to bottom-start",
+ getRepositionTest("left-middle", "bottom-start", "w125 left")
+);
+QUnit.test(
+ "reposition from left-middle to bottom-middle",
+ getRepositionTest("left-middle", "bottom-middle", "w125")
+);
+QUnit.test(
+ "reposition from left-middle to bottom-end",
+ getRepositionTest("left-middle", "bottom-end", "w125 right")
+);
+QUnit.test(
+ "reposition from left-middle to right-start",
+ getRepositionTest("left-middle", "right-start", "left top")
+);
+QUnit.test(
+ "reposition from left-middle to right-middle",
+ getRepositionTest("left-middle", "right-middle", "left")
+);
+QUnit.test(
+ "reposition from left-middle to right-end",
+ getRepositionTest("left-middle", "right-end", "left bottom")
+);
+QUnit.test(
+ "reposition from left-middle to top-start",
+ getRepositionTest("left-middle", "top-start", "w125 bottom left")
+);
+QUnit.test(
+ "reposition from left-middle to top-middle",
+ getRepositionTest("left-middle", "top-middle", "w125 bottom")
+);
+QUnit.test(
+ "reposition from left-middle to top-end",
+ getRepositionTest("left-middle", "top-end", "w125 bottom right")
+);
+QUnit.test(
+ "reposition from left-middle to left-start",
+ getRepositionTest("left-middle", "left-start", "top")
+);
+QUnit.test(
+ "reposition from left-middle to left-middle",
+ getRepositionTest("left-middle", "left-middle", "slimfit")
+);
+QUnit.test(
+ "reposition from left-middle to left-end",
+ getRepositionTest("left-middle", "left-end", "bottom")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from left-end to bottom-start",
+ getRepositionTest("left-end", "bottom-start", "w125 left")
+);
+QUnit.test(
+ "reposition from left-end to bottom-middle",
+ getRepositionTest("left-end", "bottom-middle", "w125")
+);
+QUnit.test(
+ "reposition from left-end to bottom-end",
+ getRepositionTest("left-end", "bottom-end", "w125 right")
+);
+QUnit.test(
+ "reposition from left-end to right-start",
+ getRepositionTest("left-end", "right-start", "left top")
+);
+QUnit.test(
+ "reposition from left-end to right-middle",
+ getRepositionTest("left-end", "right-middle", "left h125")
+);
+QUnit.test(
+ "reposition from left-end to right-end",
+ getRepositionTest("left-end", "right-end", "left")
+);
+QUnit.test(
+ "reposition from left-end to top-start",
+ getRepositionTest("left-end", "top-start", "w125 bottom left")
+);
+QUnit.test(
+ "reposition from left-end to top-middle",
+ getRepositionTest("left-end", "top-middle", "w125 bottom")
+);
+QUnit.test(
+ "reposition from left-end to top-end",
+ getRepositionTest("left-end", "top-end", "w125 bottom right")
+);
+QUnit.test(
+ "reposition from left-end to left-start",
+ getRepositionTest("left-end", "left-start", "top")
+);
+QUnit.test(
+ "reposition from left-end to left-middle",
+ getRepositionTest("left-end", "left-middle", "h125")
+);
+QUnit.test(
+ "reposition from left-end to left-end",
+ getRepositionTest("left-end", "left-end", "slimfit")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from bottom-start to bottom-start",
+ getRepositionTest("bottom-start", "bottom-start", "slimfit")
+);
+QUnit.test(
+ "reposition from bottom-start to bottom-middle",
+ getRepositionTest("bottom-start", "bottom-middle", "w125")
+);
+QUnit.test(
+ "reposition from bottom-start to bottom-end",
+ getRepositionTest("bottom-start", "bottom-end", "right")
+);
+QUnit.test(
+ "reposition from bottom-start to right-start",
+ getRepositionTest("bottom-start", "right-start", "h125 top")
+);
+QUnit.test(
+ "reposition from bottom-start to right-middle",
+ getRepositionTest("bottom-start", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from bottom-start to right-end",
+ getRepositionTest("bottom-start", "right-end", "h125 bottom")
+);
+QUnit.test(
+ "reposition from bottom-start to top-start",
+ getRepositionTest("bottom-start", "top-start", "bottom")
+);
+QUnit.test(
+ "reposition from bottom-start to top-middle",
+ getRepositionTest("bottom-start", "top-middle", "bottom w125")
+);
+QUnit.test(
+ "reposition from bottom-start to top-end",
+ getRepositionTest("bottom-start", "top-end", "bottom right")
+);
+QUnit.test(
+ "reposition from bottom-start to left-start",
+ getRepositionTest("bottom-start", "left-start", "h125 right top")
+);
+QUnit.test(
+ "reposition from bottom-start to left-middle",
+ getRepositionTest("bottom-start", "left-middle", "h125 right")
+);
+QUnit.test(
+ "reposition from bottom-start to left-end",
+ getRepositionTest("bottom-start", "left-end", "h125 right bottom")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from bottom-middle to bottom-start",
+ getRepositionTest("bottom-middle", "bottom-start", "left")
+);
+QUnit.test(
+ "reposition from bottom-middle to bottom-middle",
+ getRepositionTest("bottom-middle", "bottom-middle", "slimfit")
+);
+QUnit.test(
+ "reposition from bottom-middle to bottom-end",
+ getRepositionTest("bottom-middle", "bottom-end", "right")
+);
+QUnit.test(
+ "reposition from bottom-middle to right-start",
+ getRepositionTest("bottom-middle", "right-start", "h125 top")
+);
+QUnit.test(
+ "reposition from bottom-middle to right-middle",
+ getRepositionTest("bottom-middle", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from bottom-middle to right-end",
+ getRepositionTest("bottom-middle", "right-end", "h125 bottom")
+);
+QUnit.test(
+ "reposition from bottom-middle to top-start",
+ getRepositionTest("bottom-middle", "top-start", "bottom left")
+);
+QUnit.test(
+ "reposition from bottom-middle to top-middle",
+ getRepositionTest("bottom-middle", "top-middle", "bottom")
+);
+QUnit.test(
+ "reposition from bottom-middle to top-end",
+ getRepositionTest("bottom-middle", "top-end", "bottom right")
+);
+QUnit.test(
+ "reposition from bottom-middle to left-start",
+ getRepositionTest("bottom-middle", "left-start", "h125 right top")
+);
+QUnit.test(
+ "reposition from bottom-middle to left-middle",
+ getRepositionTest("bottom-middle", "left-middle", "h125 right")
+);
+QUnit.test(
+ "reposition from bottom-middle to left-end",
+ getRepositionTest("bottom-middle", "left-end", "h125 right bottom")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from bottom-end to bottom-start",
+ getRepositionTest("bottom-end", "bottom-start", "left")
+);
+QUnit.test(
+ "reposition from bottom-end to bottom-middle",
+ getRepositionTest("bottom-end", "bottom-middle", "w125")
+);
+QUnit.test(
+ "reposition from bottom-end to bottom-end",
+ getRepositionTest("bottom-end", "bottom-end", "slimfit")
+);
+QUnit.test(
+ "reposition from bottom-end to right-start",
+ getRepositionTest("bottom-end", "right-start", "h125 top")
+);
+QUnit.test(
+ "reposition from bottom-end to right-middle",
+ getRepositionTest("bottom-end", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from bottom-end to right-end",
+ getRepositionTest("bottom-end", "right-end", "h125 bottom")
+);
+QUnit.test(
+ "reposition from bottom-end to top-start",
+ getRepositionTest("bottom-end", "top-start", "bottom left")
+);
+QUnit.test(
+ "reposition from bottom-end to top-middle",
+ getRepositionTest("bottom-end", "top-middle", "bottom w125")
+);
+QUnit.test(
+ "reposition from bottom-end to top-end",
+ getRepositionTest("bottom-end", "top-end", "bottom")
+);
+QUnit.test(
+ "reposition from bottom-end to left-start",
+ getRepositionTest("bottom-end", "left-start", "h125 right top")
+);
+QUnit.test(
+ "reposition from bottom-end to left-middle",
+ getRepositionTest("bottom-end", "left-middle", "h125 right")
+);
+QUnit.test(
+ "reposition from bottom-end to left-end",
+ getRepositionTest("bottom-end", "left-end", "h125 right bottom")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from right-start to bottom-start",
+ getRepositionTest("right-start", "bottom-start", "w125 top left")
+);
+QUnit.test(
+ "reposition from right-start to bottom-middle",
+ getRepositionTest("right-start", "bottom-middle", "w125 top")
+);
+QUnit.test(
+ "reposition from right-start to bottom-end",
+ getRepositionTest("right-start", "bottom-end", "w125 top right")
+);
+QUnit.test(
+ "reposition from right-start to right-start",
+ getRepositionTest("right-start", "right-start", "slimfit")
+);
+QUnit.test(
+ "reposition from right-start to right-middle",
+ getRepositionTest("right-start", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from right-start to right-end",
+ getRepositionTest("right-start", "right-end", "bottom")
+);
+QUnit.test(
+ "reposition from right-start to top-start",
+ getRepositionTest("right-start", "top-start", "w125 left")
+);
+QUnit.test(
+ "reposition from right-start to top-middle",
+ getRepositionTest("right-start", "top-middle", "w125")
+);
+QUnit.test(
+ "reposition from right-start to top-end",
+ getRepositionTest("right-start", "top-end", "w125 right")
+);
+QUnit.test(
+ "reposition from right-start to left-start",
+ getRepositionTest("right-start", "left-start", "right")
+);
+QUnit.test(
+ "reposition from right-start to left-middle",
+ getRepositionTest("right-start", "left-middle", "right h125")
+);
+QUnit.test(
+ "reposition from right-start to left-end",
+ getRepositionTest("right-start", "left-end", "right bottom")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from right-middle to bottom-start",
+ getRepositionTest("right-middle", "bottom-start", "w125 top left")
+);
+QUnit.test(
+ "reposition from right-middle to bottom-middle",
+ getRepositionTest("right-middle", "bottom-middle", "w125 top")
+);
+QUnit.test(
+ "reposition from right-middle to bottom-end",
+ getRepositionTest("right-middle", "bottom-end", "w125 top right")
+);
+QUnit.test(
+ "reposition from right-middle to right-start",
+ getRepositionTest("right-middle", "right-start", "top")
+);
+QUnit.test(
+ "reposition from right-middle to right-middle",
+ getRepositionTest("right-middle", "right-middle", "slimfit")
+);
+QUnit.test(
+ "reposition from right-middle to right-end",
+ getRepositionTest("right-middle", "right-end", "bottom")
+);
+QUnit.test(
+ "reposition from right-middle to left-start",
+ getRepositionTest("right-middle", "left-start", "right top")
+);
+QUnit.test(
+ "reposition from right-middle to left-middle",
+ getRepositionTest("right-middle", "left-middle", "right")
+);
+QUnit.test(
+ "reposition from right-middle to left-end",
+ getRepositionTest("right-middle", "left-end", "right bottom")
+);
+QUnit.test(
+ "reposition from right-middle to top-start",
+ getRepositionTest("right-middle", "top-start", "w125 left")
+);
+QUnit.test(
+ "reposition from right-middle to top-middle",
+ getRepositionTest("right-middle", "top-middle", "w125")
+);
+QUnit.test(
+ "reposition from right-middle to top-end",
+ getRepositionTest("right-middle", "top-end", "w125 right")
+);
+// -----------------------------------------------------------------------------
+QUnit.test(
+ "reposition from right-end to bottom-start",
+ getRepositionTest("right-end", "bottom-start", "w125 top left")
+);
+QUnit.test(
+ "reposition from right-end to bottom-middle",
+ getRepositionTest("right-end", "bottom-middle", "w125 top")
+);
+QUnit.test(
+ "reposition from right-end to bottom-end",
+ getRepositionTest("right-end", "bottom-end", "w125 top right")
+);
+QUnit.test(
+ "reposition from right-end to right-start",
+ getRepositionTest("right-end", "right-start", "top")
+);
+QUnit.test(
+ "reposition from right-end to right-middle",
+ getRepositionTest("right-end", "right-middle", "h125")
+);
+QUnit.test(
+ "reposition from right-end to right-end",
+ getRepositionTest("right-end", "right-end", "slimfit")
+);
+QUnit.test(
+ "reposition from right-end to left-start",
+ getRepositionTest("right-end", "left-start", "right top")
+);
+QUnit.test(
+ "reposition from right-end to left-middle",
+ getRepositionTest("right-end", "left-middle", "right h125")
+);
+QUnit.test(
+ "reposition from right-end to left-end",
+ getRepositionTest("right-end", "left-end", "right")
+);
+QUnit.test(
+ "reposition from right-end to top-start",
+ getRepositionTest("right-end", "top-start", "w125 left")
+);
+QUnit.test(
+ "reposition from right-end to top-middle",
+ getRepositionTest("right-end", "top-middle", "w125")
+);
+QUnit.test(
+ "reposition from right-end to top-end",
+ getRepositionTest("right-end", "top-end", "w125 right")
+);
diff --git a/addons/web/static/tests/helpers/utils.js b/addons/web/static/tests/helpers/utils.js
index 4b130a11578..cf45b4c9d1e 100644
--- a/addons/web/static/tests/helpers/utils.js
+++ b/addons/web/static/tests/helpers/utils.js
@@ -46,6 +46,9 @@ export function patchWithCleanup(obj, patchValue, options) {
});
}
+/**
+ * @returns {HTMLElement}
+ */
export function getFixture() {
if (QUnit.config.debug) {
return document.body;