diff --git a/addons/web/static/src/core/popover/popover.js b/addons/web/static/src/core/popover/popover.js index faab2cde600..aa0d7722cd7 100644 --- a/addons/web/static/src/core/popover/popover.js +++ b/addons/web/static/src/core/popover/popover.js @@ -1,9 +1,8 @@ /** @odoo-module **/ -import { usePosition } from "../position_hook"; - import { Component } from "@odoo/owl"; import { useForwardRefToParent } from "../utils/hooks"; +import { usePosition } from "@web/core/position_hook"; export class Popover extends Component { setup() { @@ -89,7 +88,14 @@ Popover.props = { type: Function, optional: true, }, - target: HTMLElement, + target: { + validate: (target) => { + // target may be inside an iframe, so get the Element constructor + // to test against from its owner document's default view + const Element = target?.ownerDocument?.defaultView.Element; + return Boolean(Element) && target instanceof Element; + }, + }, slots: { type: Object, optional: true, diff --git a/addons/web/static/src/core/position_hook.js b/addons/web/static/src/core/position_hook.js index ea0556800bc..a6033b85f4f 100644 --- a/addons/web/static/src/core/position_hook.js +++ b/addons/web/static/src/core/position_hook.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { useThrottleForAnimation } from "./utils/timing"; -import { useEffect, useExternalListener, useRef } from "@odoo/owl"; +import { useEffect, useRef } from "@odoo/owl"; import { localization } from "@web/core/l10n/localization"; /** @@ -72,6 +72,20 @@ const DEFAULTS = { position: "bottom", }; +/** + * @param {HTMLElement} el + * @returns {HTMLIFrameElement?} + */ +function getIFrame(el) { + const parentDocument = el.ownerDocument.defaultView.parent?.document; + if (!parentDocument || parentDocument === el.ownerDocument) { + return; + } + return [...parentDocument.getElementsByTagName("iframe")].find((iframe) => + iframe.contentDocument.contains(el) + ); +} + /** * Returns the best positioning solution staying in the container or falls back * to the requested position. @@ -82,19 +96,23 @@ const DEFAULTS = { * * @param {HTMLElement} target * @param {HTMLElement} popper + * @param {HTMLIFrameElement?} [iframe] * @param {Options} options * @returns {PositioningSolution} the best positioning solution, relative to * the containing block of the popper. * => can be applied to popper.style.(top|left) */ -function getBestPosition(target, popper, { container, margin, position }) { +function getBestPosition(target, popper, iframe, { container, margin, position }) { // Retrieve directions and variants const [directionKey, variantKey = "middle"] = position.split("-"); const directions = variantKey === "fit" ? FIT_FLIP_ORDER[directionKey] : DIRECTION_FLIP_ORDER[directionKey]; const variants = VARIANT_FLIP_ORDER[variantKey]; - if (typeof container === "function") { + // Retrieve container + if (!container) { + container = target.ownerDocument.documentElement; + } else if (typeof container === "function") { container = container(); } @@ -102,27 +120,28 @@ function getBestPosition(target, popper, { container, margin, position }) { const popBox = popper.getBoundingClientRect(); const targetBox = target.getBoundingClientRect(); const contBox = container.getBoundingClientRect(); + const iframeBox = iframe?.getBoundingClientRect() || { top: 0, left: 0 }; - const containerIsHTMLNode = container === document.firstElementChild; + const containerIsHTMLNode = container === container.ownerDocument.firstElementChild; // Compute positioning data /** @type {DirectionsData} */ const directionsData = { - t: targetBox.top - popBox.height - margin, - b: targetBox.bottom + margin, - r: targetBox.right + margin, - l: targetBox.left - popBox.width - margin, + t: iframeBox.top + targetBox.top - popBox.height - margin, + b: iframeBox.top + targetBox.bottom + margin, + r: iframeBox.left + targetBox.right + margin, + l: iframeBox.left + targetBox.left - popBox.width - margin, }; /** @type {VariantsData} */ const variantsData = { - vf: targetBox.left, - vs: targetBox.left, - vm: targetBox.left + targetBox.width / 2 + -popBox.width / 2, - ve: targetBox.right - popBox.width, - hf: targetBox.top, - hs: targetBox.top, - hm: targetBox.top + targetBox.height / 2 + -popBox.height / 2, - he: targetBox.bottom - popBox.height, + vf: iframeBox.left + targetBox.left, + vs: iframeBox.left + targetBox.left, + vm: iframeBox.left + targetBox.left + targetBox.width / 2 + -popBox.width / 2, + ve: iframeBox.left + targetBox.right - popBox.width, + hf: iframeBox.top + targetBox.top, + hs: iframeBox.top + targetBox.top, + hm: iframeBox.top + targetBox.top + targetBox.height / 2 + -popBox.height / 2, + he: iframeBox.top + targetBox.bottom - popBox.height, }; function getPositioningData(d = directions[0], v = variants[0], containerRestricted = false) { @@ -211,11 +230,10 @@ function getBestPosition(target, popper, { container, margin, position }) { * * @param {HTMLElement} target * @param {HTMLElement} popper + * @param {HTMLIFrameElement} [iframe] * @param {Options} options */ -export function reposition(target, popper, options) { - options = { ...DEFAULTS, container: document.documentElement, ...options }; - +export function reposition(target, popper, iframe, options) { let [directionKey, variantKey = "middle"] = options.position.split("-"); if (localization.direction === "rtl") { if (["bottom", "top"].includes(directionKey)) { @@ -234,7 +252,7 @@ export function reposition(target, popper, options) { popper.style.left = "0px"; // Get best positioning solution and apply it - const position = getBestPosition(target, popper, options); + const position = getBestPosition(target, popper, iframe, options); const { top, left, variant } = position; popper.style.top = `${top}px`; popper.style.left = `${left}px`; @@ -265,15 +283,31 @@ export function reposition(target, popper, options) { */ export function usePosition(target, options) { const popperRef = useRef(options.popper || DEFAULTS.popper); - const getTarget = target instanceof HTMLElement ? () => target : target; - const update = () => { - const targetEl = getTarget(); - if (popperRef.el && targetEl) { - reposition(targetEl, popperRef.el, options); - } - }; - useEffect(update); - const throttledUpdate = useThrottleForAnimation(update); - useExternalListener(document, "scroll", throttledUpdate, { capture: true }); - useExternalListener(window, "resize", throttledUpdate); + const getTarget = typeof target === "function" ? target : () => target; + const throttledReposition = useThrottleForAnimation(reposition); + useEffect( + (targetEl, popperEl) => { + if (!targetEl || !popperEl) { + return; + } + + // Prepare + const targetDocument = targetEl.ownerDocument; + const iframe = getIFrame(targetEl); + const currentOptions = { ...DEFAULTS, ...options }; + + // Reposition + reposition(targetEl, popperEl, iframe, currentOptions); + + // Attach listeners to keep the positioning up to date + const listener = () => throttledReposition(targetEl, popperEl, iframe, currentOptions); + targetDocument.addEventListener("scroll", listener, { capture: true }); + window.addEventListener("resize", listener); + return () => { + targetDocument.removeEventListener("scroll", listener, { capture: true }); + window.removeEventListener("resize", listener); + }; + }, + () => [getTarget(), popperRef.el] + ); } diff --git a/addons/web/static/src/views/fields/properties/properties_field.js b/addons/web/static/src/views/fields/properties/properties_field.js index 4a917c37825..e40c3693833 100644 --- a/addons/web/static/src/views/fields/properties/properties_field.js +++ b/addons/web/static/src/views/fields/properties/properties_field.js @@ -349,7 +349,7 @@ export class PropertiesField extends Component { `.o_property_field[property-name="${propertyName}"] .o_field_property_open_popover` ); - reposition(targetElement, popover, { position: "top", margin: 10 }); + reposition(targetElement, popover, null, { position: "top", margin: 10 }); const arrow = popover.querySelector(".popover-arrow"); if (arrow) { diff --git a/addons/web/static/tests/core/popover/popover_tests.js b/addons/web/static/tests/core/popover/popover_tests.js index 9dba9b82b77..35e730608fb 100644 --- a/addons/web/static/tests/core/popover/popover_tests.js +++ b/addons/web/static/tests/core/popover/popover_tests.js @@ -3,7 +3,7 @@ import { Popover } from "@web/core/popover/popover"; import { usePosition } from "@web/core/position_hook"; import { registerCleanup } from "../../helpers/cleanup"; -import { getFixture, mount, nextTick, triggerEvent } from "../../helpers/utils"; +import { getFixture, makeDeferred, mount, nextTick, triggerEvent } from "../../helpers/utils"; let fixture; let popoverTarget; @@ -159,3 +159,56 @@ QUnit.test("reposition popover should properly change classNames", async (assert ); assert.strictEqual(arrow.className, "popover-arrow top-auto"); }); + +QUnit.test("within iframe", async (assert) => { + const iframe = document.createElement("iframe"); + iframe.style.height = "200px"; + iframe.srcdoc = `
Within iframe
`; + const def = makeDeferred(); + iframe.onload = def.resolve; + fixture.appendChild(iframe); + await def; + + let popoverEl; + const TestPopover = class extends Popover { + onPositioned(el, { direction }) { + popoverEl = el; + assert.step(direction); + } + }; + + popoverTarget = iframe.contentDocument.getElementById("target"); + await mount(TestPopover, fixture, { + props: { target: popoverTarget }, + }); + assert.verifySteps(["bottom"]); + + // The popover should be rendered outside the iframe + assert.containsOnce(fixture, ".o_popover"); + assert.strictEqual( + iframe.contentDocument.documentElement.querySelectorAll(".o_popover").length, + 0 + ); + + // The popover should be rendered in the correct position + const { top: targetTop, left: targetLeft } = popoverTarget.getBoundingClientRect(); + const { top: iframeTop, left: iframeLeft } = iframe.getBoundingClientRect(); + let popoverBox = popoverEl.getBoundingClientRect(); + let expectedTop = iframeTop + targetTop + popoverTarget.offsetHeight; + let expectedLeft = + iframeLeft + targetLeft + popoverTarget.offsetWidth / 2 - popoverBox.width / 2; + assert.strictEqual(popoverBox.top, expectedTop); + assert.strictEqual(popoverBox.left, expectedLeft); + + // Scrolling inside the iframe should reposition the popover accordingly + const scrollOffset = 100; + const scrollable = popoverTarget.ownerDocument.documentElement; + scrollable.scrollTop = scrollOffset; + await nextTick(); + assert.verifySteps(["bottom"]); + popoverBox = popoverEl.getBoundingClientRect(); + expectedTop = iframeTop + targetTop + popoverTarget.offsetHeight - scrollOffset; + expectedLeft = iframeLeft + targetLeft + popoverTarget.offsetWidth / 2 - popoverBox.width / 2; + assert.strictEqual(popoverBox.top, expectedTop); + assert.strictEqual(popoverBox.left, expectedLeft); +}); diff --git a/addons/web/static/tests/core/position_hook_tests.js b/addons/web/static/tests/core/position_hook_tests.js index 0d5f6cecd1f..bf7353f7e07 100644 --- a/addons/web/static/tests/core/position_hook_tests.js +++ b/addons/web/static/tests/core/position_hook_tests.js @@ -165,7 +165,7 @@ QUnit.test("is positioned relative to its containing block", async (assert) => { const popBox1 = document.getElementById("popper").getBoundingClientRect(); destroy(popper); - document.getElementById("reference").remove(); + document.getElementById("target").remove(); // make container the containing block instead of the viewport container.style.contain = "layout"; diff --git a/addons/web_tour/static/src/tour_pointer/tour_pointer.js b/addons/web_tour/static/src/tour_pointer/tour_pointer.js index 940430653ec..f7a6731192f 100644 --- a/addons/web_tour/static/src/tour_pointer/tour_pointer.js +++ b/addons/web_tour/static/src/tour_pointer/tour_pointer.js @@ -94,7 +94,7 @@ export class TourPointer extends Component { const wouldOverflow = window.innerWidth - x - width / 2 < dimensions?.width; el.classList.toggle("o_expand_left", wouldOverflow); - reposition(anchor, el, { + reposition(anchor, el, null, { position: this.position, margin: 6, onPositioned: (popper, position) => {