diff --git a/addons/web/static/src/core/popover/popover_controller.js b/addons/web/static/src/core/popover/popover_controller.js index b82c1fc35ce..41ecddf0ce5 100644 --- a/addons/web/static/src/core/popover/popover_controller.js +++ b/addons/web/static/src/core/popover/popover_controller.js @@ -1,10 +1,12 @@ /** @odoo-module **/ -import { Component, onWillDestroy, useExternalListener, xml } from "@odoo/owl"; +import { Component, onWillDestroy, useExternalListener, useSubEnv, xml } from "@odoo/owl"; import { useHotkey } from "../hotkeys/hotkey_hook"; import { useChildRef } from "../utils/hooks"; import { Popover } from "./popover"; +export const POPOVER_SYMBOL = Symbol("popover"); + export class PopoverController extends Component { static template = xml` @@ -19,28 +21,40 @@ export class PopoverController extends Component { "component", "componentProps", "popoverProps", + "subPopovers?", ]; setup() { + this.props.subPopovers?.add(this); + + this.subPopovers = new Set(); + useSubEnv({ [POPOVER_SYMBOL]: this.subPopovers }); + if (this.props.target.isConnected) { this.popoverRef = useChildRef(); useExternalListener(window, "pointerdown", this.onClickAway, { capture: true }); useHotkey("escape", () => this.props.close()); const targetObserver = new MutationObserver(this.onTargetMutate.bind(this)); targetObserver.observe(this.props.target.parentElement, { childList: true }); - onWillDestroy(() => targetObserver.disconnect()); + onWillDestroy(() => { + targetObserver.disconnect(); + this.props.subPopovers?.delete(this); + }); } else { this.props.close(); } } + isInside(target) { + if (this.props.target.contains(target) || this.popoverRef.el.contains(target)) { + return true; + } + return [...this.subPopovers].some((p) => p.isInside(target)); + } + onClickAway(ev) { const target = ev.composedPath()[0]; - if ( - this.props.closeOnClickAway(target) && - !this.props.target.contains(target) && - !this.popoverRef.el.contains(target) - ) { + if (this.props.closeOnClickAway(target) && !this.isInside(target)) { this.props.close(); ev.preventDefault(); } diff --git a/addons/web/static/src/core/popover/popover_hook.js b/addons/web/static/src/core/popover/popover_hook.js index bc51ba77696..fbaf61d4ea9 100644 --- a/addons/web/static/src/core/popover/popover_hook.js +++ b/addons/web/static/src/core/popover/popover_hook.js @@ -2,7 +2,8 @@ import { useService } from "@web/core/utils/hooks"; -import { onWillUnmount, status, useComponent } from "@odoo/owl"; +import { onWillUnmount, status, useComponent, useEnv } from "@odoo/owl"; +import { POPOVER_SYMBOL } from "./popover_controller"; /** * @typedef PopoverHookReturnType @@ -45,9 +46,11 @@ export function makePopover(popoverService, component, options) { * @returns {PopoverHookReturnType} */ export function usePopover(component, options = {}) { + const env = useEnv(); const popoverService = useService("popover"); const owner = useComponent(); const newOptions = Object.create(options); + newOptions[POPOVER_SYMBOL] = env[POPOVER_SYMBOL]; newOptions.onClose = () => { if (status(owner) !== "destroyed") { options.onClose?.(); diff --git a/addons/web/static/src/core/popover/popover_service.js b/addons/web/static/src/core/popover/popover_service.js index 6608afc5245..63fc779e31d 100644 --- a/addons/web/static/src/core/popover/popover_service.js +++ b/addons/web/static/src/core/popover/popover_service.js @@ -2,7 +2,7 @@ import { markRaw } from "@odoo/owl"; import { registry } from "../registry"; -import { PopoverController } from "./popover_controller"; +import { POPOVER_SYMBOL, PopoverController } from "./popover_controller"; /** * @typedef {{ @@ -40,6 +40,7 @@ export const popoverService = { target, close: () => remove(), closeOnClickAway, + subPopovers: options[POPOVER_SYMBOL], component, componentProps: markRaw(props), popoverProps: { diff --git a/addons/web/static/tests/core/popover/popover_hook_tests.js b/addons/web/static/tests/core/popover/popover_hook_tests.js index d84deae28c0..57875c4ad0f 100644 --- a/addons/web/static/tests/core/popover/popover_hook_tests.js +++ b/addons/web/static/tests/core/popover/popover_hook_tests.js @@ -4,7 +4,7 @@ import { usePopover } from "@web/core/popover/popover_hook"; import { popoverService } from "@web/core/popover/popover_service"; import { registry } from "@web/core/registry"; import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env"; -import { destroy, getFixture, mount, nextTick } from "../../helpers/utils"; +import { click, destroy, getFixture, mount, nextTick } from "../../helpers/utils"; import { Component, xml } from "@odoo/owl"; import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; @@ -85,3 +85,54 @@ QUnit.test("close popover when component is unmounted", async (assert) => { assert.containsNone(target, ".o_popover #comp1"); assert.containsNone(target, ".o_popover #comp2"); }); + +QUnit.test("popover opened from another", async (assert) => { + class Comp extends Component { + static id = 0; + static template = xml` +
+ +
+ `; + setup() { + this.popover = usePopover(Comp, { + popoverClass: `popover-${++Comp.id}`, + }); + } + } + + await mount(Comp, target, { env }); + + await click(target, ".pop-open"); + assert.containsOnce(target, ".popover-1", "open first popover"); + + await click(target, ".popover-1 .pop-open"); + assert.containsN(target, ".o_popover", 2, "open second popover from the first one"); + assert.containsOnce(target, ".popover-1"); + assert.containsOnce(target, ".popover-2"); + + await click(target, ".popover-2 .pop-open"); + assert.containsN(target, ".o_popover", 3, "open third popover from the second one"); + assert.containsOnce(target, ".popover-1"); + assert.containsOnce(target, ".popover-2"); + assert.containsOnce(target, ".popover-3"); + + await click(target, ".popover-3"); + assert.containsN(target, ".o_popover", 3, "clicking inside third popover closes nothing"); + assert.containsOnce(target, ".popover-1"); + assert.containsOnce(target, ".popover-2"); + assert.containsOnce(target, ".popover-3"); + + await click(target, ".popover-2"); + assert.containsN( + target, + ".o_popover", + 2, + "clicking inside second popover closes third popover" + ); + assert.containsOnce(target, ".popover-1"); + assert.containsOnce(target, ".popover-2"); + + await click(target, "#close"); + assert.containsNone(target, ".o_popover", "clicking out of any popover closes them all"); +});