[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:
committed by
Leonardo Pavan Rocha
co-authored by
Leonardo Pavan Rocha
parent
0613af8fa7
commit
d6afa9f325
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user