[IMP] web: can add popover in iframes

Before this commit:
It is not possible to add a popover in an iframe (with the popover
service of an app from outside the iframe).

After this commit:
The position hook now finds out the possible target's iframe element,
from which it can compute an offset so it could position properly the
popper element.

Known limitations:
The popover element is not attached inside the iframe's DOM, so there
may be some cases where the popover is shown outside of the iframe
(depends on scrolling).

closes odoo/odoo#116499

Taskid: 3184061
Related: odoo/enterprise#36168
Signed-off-by: Arnaud Joset <arj@odoo.com>
Co-authored-by: Leonardo Pavan Rocha <lpr@odoo.com>
This commit is contained in:
Bruno Boi
2023-04-12 18:58:29 +02:00
committed by Leonardo Pavan Rocha
co-authored by Leonardo Pavan Rocha
parent 0613af8fa7
commit d6afa9f325
6 changed files with 131 additions and 38 deletions
@@ -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,
+65 -31
View File
@@ -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]
);
}
@@ -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) {
@@ -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 = `<div id="target" style="height:400px;">Within iframe</div>`;
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);
});
@@ -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";
@@ -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) => {