[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 **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
import { usePosition } from "../position_hook";
|
|
||||||
|
|
||||||
import { Component } from "@odoo/owl";
|
import { Component } from "@odoo/owl";
|
||||||
import { useForwardRefToParent } from "../utils/hooks";
|
import { useForwardRefToParent } from "../utils/hooks";
|
||||||
|
import { usePosition } from "@web/core/position_hook";
|
||||||
|
|
||||||
export class Popover extends Component {
|
export class Popover extends Component {
|
||||||
setup() {
|
setup() {
|
||||||
@@ -89,7 +88,14 @@ Popover.props = {
|
|||||||
type: Function,
|
type: Function,
|
||||||
optional: true,
|
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: {
|
slots: {
|
||||||
type: Object,
|
type: Object,
|
||||||
optional: true,
|
optional: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @odoo-module */
|
/** @odoo-module */
|
||||||
|
|
||||||
import { useThrottleForAnimation } from "./utils/timing";
|
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";
|
import { localization } from "@web/core/l10n/localization";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +72,20 @@ const DEFAULTS = {
|
|||||||
position: "bottom",
|
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
|
* Returns the best positioning solution staying in the container or falls back
|
||||||
* to the requested position.
|
* to the requested position.
|
||||||
@@ -82,19 +96,23 @@ const DEFAULTS = {
|
|||||||
*
|
*
|
||||||
* @param {HTMLElement} target
|
* @param {HTMLElement} target
|
||||||
* @param {HTMLElement} popper
|
* @param {HTMLElement} popper
|
||||||
|
* @param {HTMLIFrameElement?} [iframe]
|
||||||
* @param {Options} options
|
* @param {Options} options
|
||||||
* @returns {PositioningSolution} the best positioning solution, relative to
|
* @returns {PositioningSolution} the best positioning solution, relative to
|
||||||
* the containing block of the popper.
|
* the containing block of the popper.
|
||||||
* => can be applied to popper.style.(top|left)
|
* => 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
|
// Retrieve directions and variants
|
||||||
const [directionKey, variantKey = "middle"] = position.split("-");
|
const [directionKey, variantKey = "middle"] = position.split("-");
|
||||||
const directions =
|
const directions =
|
||||||
variantKey === "fit" ? FIT_FLIP_ORDER[directionKey] : DIRECTION_FLIP_ORDER[directionKey];
|
variantKey === "fit" ? FIT_FLIP_ORDER[directionKey] : DIRECTION_FLIP_ORDER[directionKey];
|
||||||
const variants = VARIANT_FLIP_ORDER[variantKey];
|
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();
|
container = container();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,27 +120,28 @@ function getBestPosition(target, popper, { container, margin, position }) {
|
|||||||
const popBox = popper.getBoundingClientRect();
|
const popBox = popper.getBoundingClientRect();
|
||||||
const targetBox = target.getBoundingClientRect();
|
const targetBox = target.getBoundingClientRect();
|
||||||
const contBox = container.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
|
// Compute positioning data
|
||||||
/** @type {DirectionsData} */
|
/** @type {DirectionsData} */
|
||||||
const directionsData = {
|
const directionsData = {
|
||||||
t: targetBox.top - popBox.height - margin,
|
t: iframeBox.top + targetBox.top - popBox.height - margin,
|
||||||
b: targetBox.bottom + margin,
|
b: iframeBox.top + targetBox.bottom + margin,
|
||||||
r: targetBox.right + margin,
|
r: iframeBox.left + targetBox.right + margin,
|
||||||
l: targetBox.left - popBox.width - margin,
|
l: iframeBox.left + targetBox.left - popBox.width - margin,
|
||||||
};
|
};
|
||||||
/** @type {VariantsData} */
|
/** @type {VariantsData} */
|
||||||
const variantsData = {
|
const variantsData = {
|
||||||
vf: targetBox.left,
|
vf: iframeBox.left + targetBox.left,
|
||||||
vs: targetBox.left,
|
vs: iframeBox.left + targetBox.left,
|
||||||
vm: targetBox.left + targetBox.width / 2 + -popBox.width / 2,
|
vm: iframeBox.left + targetBox.left + targetBox.width / 2 + -popBox.width / 2,
|
||||||
ve: targetBox.right - popBox.width,
|
ve: iframeBox.left + targetBox.right - popBox.width,
|
||||||
hf: targetBox.top,
|
hf: iframeBox.top + targetBox.top,
|
||||||
hs: targetBox.top,
|
hs: iframeBox.top + targetBox.top,
|
||||||
hm: targetBox.top + targetBox.height / 2 + -popBox.height / 2,
|
hm: iframeBox.top + targetBox.top + targetBox.height / 2 + -popBox.height / 2,
|
||||||
he: targetBox.bottom - popBox.height,
|
he: iframeBox.top + targetBox.bottom - popBox.height,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPositioningData(d = directions[0], v = variants[0], containerRestricted = false) {
|
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} target
|
||||||
* @param {HTMLElement} popper
|
* @param {HTMLElement} popper
|
||||||
|
* @param {HTMLIFrameElement} [iframe]
|
||||||
* @param {Options} options
|
* @param {Options} options
|
||||||
*/
|
*/
|
||||||
export function reposition(target, popper, options) {
|
export function reposition(target, popper, iframe, options) {
|
||||||
options = { ...DEFAULTS, container: document.documentElement, ...options };
|
|
||||||
|
|
||||||
let [directionKey, variantKey = "middle"] = options.position.split("-");
|
let [directionKey, variantKey = "middle"] = options.position.split("-");
|
||||||
if (localization.direction === "rtl") {
|
if (localization.direction === "rtl") {
|
||||||
if (["bottom", "top"].includes(directionKey)) {
|
if (["bottom", "top"].includes(directionKey)) {
|
||||||
@@ -234,7 +252,7 @@ export function reposition(target, popper, options) {
|
|||||||
popper.style.left = "0px";
|
popper.style.left = "0px";
|
||||||
|
|
||||||
// Get best positioning solution and apply it
|
// 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;
|
const { top, left, variant } = position;
|
||||||
popper.style.top = `${top}px`;
|
popper.style.top = `${top}px`;
|
||||||
popper.style.left = `${left}px`;
|
popper.style.left = `${left}px`;
|
||||||
@@ -265,15 +283,31 @@ export function reposition(target, popper, options) {
|
|||||||
*/
|
*/
|
||||||
export function usePosition(target, options) {
|
export function usePosition(target, options) {
|
||||||
const popperRef = useRef(options.popper || DEFAULTS.popper);
|
const popperRef = useRef(options.popper || DEFAULTS.popper);
|
||||||
const getTarget = target instanceof HTMLElement ? () => target : target;
|
const getTarget = typeof target === "function" ? target : () => target;
|
||||||
const update = () => {
|
const throttledReposition = useThrottleForAnimation(reposition);
|
||||||
const targetEl = getTarget();
|
useEffect(
|
||||||
if (popperRef.el && targetEl) {
|
(targetEl, popperEl) => {
|
||||||
reposition(targetEl, popperRef.el, options);
|
if (!targetEl || !popperEl) {
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
useEffect(update);
|
|
||||||
const throttledUpdate = useThrottleForAnimation(update);
|
// Prepare
|
||||||
useExternalListener(document, "scroll", throttledUpdate, { capture: true });
|
const targetDocument = targetEl.ownerDocument;
|
||||||
useExternalListener(window, "resize", throttledUpdate);
|
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`
|
`.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");
|
const arrow = popover.querySelector(".popover-arrow");
|
||||||
if (arrow) {
|
if (arrow) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Popover } from "@web/core/popover/popover";
|
import { Popover } from "@web/core/popover/popover";
|
||||||
import { usePosition } from "@web/core/position_hook";
|
import { usePosition } from "@web/core/position_hook";
|
||||||
import { registerCleanup } from "../../helpers/cleanup";
|
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 fixture;
|
||||||
let popoverTarget;
|
let popoverTarget;
|
||||||
@@ -159,3 +159,56 @@ QUnit.test("reposition popover should properly change classNames", async (assert
|
|||||||
);
|
);
|
||||||
assert.strictEqual(arrow.className, "popover-arrow top-auto");
|
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();
|
const popBox1 = document.getElementById("popper").getBoundingClientRect();
|
||||||
destroy(popper);
|
destroy(popper);
|
||||||
document.getElementById("reference").remove();
|
document.getElementById("target").remove();
|
||||||
|
|
||||||
// make container the containing block instead of the viewport
|
// make container the containing block instead of the viewport
|
||||||
container.style.contain = "layout";
|
container.style.contain = "layout";
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class TourPointer extends Component {
|
|||||||
const wouldOverflow =
|
const wouldOverflow =
|
||||||
window.innerWidth - x - width / 2 < dimensions?.width;
|
window.innerWidth - x - width / 2 < dimensions?.width;
|
||||||
el.classList.toggle("o_expand_left", wouldOverflow);
|
el.classList.toggle("o_expand_left", wouldOverflow);
|
||||||
reposition(anchor, el, {
|
reposition(anchor, el, null, {
|
||||||
position: this.position,
|
position: this.position,
|
||||||
margin: 6,
|
margin: 6,
|
||||||
onPositioned: (popper, position) => {
|
onPositioned: (popper, position) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user