From 67b1fdcd39dfc60c7b141bef554078b8691a4cd7 Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Tue, 13 Jul 2021 12:28:03 +0000 Subject: [PATCH] [IMP] web: introduce position hook This commit encapsulates the positioning logic of the Popover component into a reusable hook. It also improves this logic by adding more positioning directions and functionalities. Before: - only 4 directions (top, bottom, right, left : all middle aligned) - the whole window viewport serves as a direction toggler Now: - 12 positions are available and are composed by 4 directions and 3 variants: {top,bottom,right,left}-{start,middle,end} - the positioned element container is configurable. Default: window Description: When you use this hook, you make sure that your `popper` element (the one you would like to position) is always placed at the given `position` from the `reference` element. If positioning it so the `popper` element is clipped off the `container` element, sensible fallback positions are tried. If none is fully visible without being clipped off `container`, the original position is used. Usage: usePosition( reference, // [mandatory] HTMLElement or function giving HTMLElement options // [optional] configure behaviour through options object ) Options: - popper (string): a t-ref reference. If none, component.el is used - container (HTMLElement) - margin (number) - position (string): {top,bottom,right,left}-{start,middle,end} Taskid-2578926 --- addons/web/static/src/core/popover/popover.js | 138 +--- .../web/static/src/core/popover/popover.scss | 157 ++-- .../web/static/src/core/popover/popover.xml | 1 - .../static/src/core/position/position.scss | 5 + .../static/src/core/position/position_hook.js | 229 ++++++ .../tests/core/popover/popover_tests.js | 42 +- .../static/tests/core/position_hook_tests.js | 754 ++++++++++++++++++ addons/web/static/tests/helpers/utils.js | 3 + 8 files changed, 1089 insertions(+), 240 deletions(-) create mode 100644 addons/web/static/src/core/position/position.scss create mode 100644 addons/web/static/src/core/position/position_hook.js create mode 100644 addons/web/static/tests/core/position_hook_tests.js 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;