+
+
+
diff --git a/addons/web/static/src/core/tooltip/tooltip_hook.js b/addons/web/static/src/core/tooltip/tooltip_hook.js
new file mode 100644
index 00000000000..96c6d0065cb
--- /dev/null
+++ b/addons/web/static/src/core/tooltip/tooltip_hook.js
@@ -0,0 +1,102 @@
+/** @odoo-module **/
+
+import { browser } from "@web/core/browser/browser";
+import { useListener } from "@web/core/utils/hooks";
+import { usePopover } from "@web/core/popover/popover_hook";
+import { Tooltip } from "./tooltip";
+import { registry } from "@web/core/registry";
+
+/**
+ * The useTooltip hook allows to display custom tooltips on every elements with
+ * a "data-tooltip" attribute inside the Component using the hook. This attribute
+ * can be set on elements for which we prefer a custom tooltip instead of the
+ * native one displaying the value of the "title" attribute.
+ *
+ * Usage (with a parent somewhere using the useTooltip hook):
+ *
+ *
+ * The ideal position of the tooltip can be specified thanks to the attribute
+ * "data-tooltip-position":
+ *
+ */
+const OPEN_DELAY = 400;
+const CLOSE_DELAY = 200;
+export function useTooltip() {
+ const popover = usePopover();
+ let openTooltipTimeout;
+ let closeTooltip;
+ let target = null;
+
+ /**
+ * Closes the currently opened tooltip if any, or prevent it from opening.
+ */
+ function cleanup() {
+ browser.clearTimeout(openTooltipTimeout);
+ if (closeTooltip) {
+ closeTooltip();
+ }
+ }
+
+ /**
+ * Opens the tooltip after a delay, if the event's target has a data-tooltip
+ * attribute.
+ *
+ * @param {MouseEvent} ev a "mouseenter" event
+ */
+ function onMouseEnter(ev) {
+ const dataset = ev.target.dataset;
+ const tooltip = dataset.tooltip;
+
+ let Component, props;
+ if ("tooltipComponent" in dataset) {
+ ({ Component, props } = JSON.parse(dataset["tooltipComponent"]));
+ Component = registry.category("tooltips").get(Component, null);
+ }
+ if (tooltip || Component) {
+ cleanup();
+ target = ev.target;
+ openTooltipTimeout = browser.setTimeout(() => {
+ const position = dataset.tooltipPosition;
+ closeTooltip = popover.add(
+ target,
+ Tooltip,
+ { tooltip, Component, props },
+ { position }
+ );
+ }, OPEN_DELAY);
+ }
+ }
+
+ // Track mouse position to be able to detect that we are no longer hovering
+ // the target, thus that we should close the tooltip
+ let positionX;
+ let positionY;
+ useListener("mousemove", (ev) => {
+ positionX = ev.x;
+ positionY = ev.y;
+ });
+
+ // Regularly check that the target is still in the DOM and we're still
+ // hovering it, because if not, we have to close the tooltip
+ browser.setInterval(() => {
+ if (!target) {
+ return;
+ }
+ if (!document.body.contains(target)) {
+ return cleanup(); // target is no longer in the DOM
+ }
+ const targetRect = target.getBoundingClientRect();
+ if (
+ positionX < targetRect.left ||
+ positionX > targetRect.right ||
+ positionY < targetRect.top ||
+ positionY > targetRect.bottom
+ ) {
+ return cleanup(); // mouse is no longer hovering the target
+ }
+ }, CLOSE_DELAY);
+
+ // Listen (using event delegation) to "mouseenter" events on all nodes with
+ // the "data-tooltip" attribute, to open the tooltip.
+ useListener("mouseenter", "[data-tooltip]", onMouseEnter, { capture: true });
+}
diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js
index fabcf18aeeb..c870d5e8659 100644
--- a/addons/web/static/src/core/utils/hooks.js
+++ b/addons/web/static/src/core/utils/hooks.js
@@ -130,6 +130,84 @@ export function useEffect(effect, computeDependencies = () => [NaN]) {
onWillUnmount(() => cleanup());
}
+// -----------------------------------------------------------------------------
+// useListener
+// -----------------------------------------------------------------------------
+
+/**
+ * The useListener hook offers an alternative to Owl's classical event
+ * registration mechanism (with attribute 't-on-eventName' in xml).
+ *
+ * It is especially useful for abstract components, meant to be extended by
+ * specific ones. If those abstract components need to define event handlers,
+ * but don't have any template (because the template completely depends on
+ * specific cases), then using the 't-on' mechanism isn't adequate, as the
+ * handlers would be lost by the template override. In this case, using this
+ * hook instead is more convenient.
+ *
+ * Usage: like all Owl hooks, this function has to be called in the
+ * constructor of an Owl component:
+ *
+ * useListener('click', () => { console.log('clicked'); });
+ *
+ * It also allows to do event delegation, by specifying a native query selector
+ * as second argument. In this case, the handler is only called if the event
+ * is triggered on an element matching the given selector.
+ *
+ * useListener('click', 'button', () => { console.log('clicked'); });
+ *
+ * Note: components that alter the event's target (e.g. Portal) are not
+ * expected to behave as expected with event delegation.
+ *
+ * @param {string} eventName the name of the event
+ * @param {string} [querySelector] a JS native selector for event delegation
+ * @param {function} handler the event handler (will be bound to the component)
+ * @param {Object} [options] to be passed to addEventListener as options.
+ * Useful for listening in the capture phase
+ */
+export function useListener(eventName, querySelector, handler, options = {}) {
+ if (typeof arguments[1] !== "string") {
+ querySelector = null;
+ handler = arguments[1];
+ options = arguments[2] || {};
+ }
+ if (typeof handler !== "function") {
+ throw new Error("The handler must be a function");
+ }
+
+ const comp = useComponent();
+ let boundHandler;
+ if (querySelector) {
+ boundHandler = function (ev) {
+ let el = ev.target;
+ let target;
+ while (el && !target) {
+ if (el.matches(querySelector)) {
+ target = el;
+ } else if (el === comp.el) {
+ el = null;
+ } else {
+ el = el.parentElement;
+ }
+ }
+ if (el) {
+ handler.call(comp, ev);
+ }
+ };
+ } else {
+ boundHandler = handler.bind(comp);
+ }
+ useEffect(
+ () => {
+ comp.el.addEventListener(eventName, boundHandler, options);
+ return () => {
+ comp.el.removeEventListener(eventName, boundHandler, options);
+ };
+ },
+ () => []
+ );
+}
+
// -----------------------------------------------------------------------------
// useService
// -----------------------------------------------------------------------------
diff --git a/addons/web/static/src/webclient/webclient.js b/addons/web/static/src/webclient/webclient.js
index 3bcd54e6fdb..0d15fbf5b79 100644
--- a/addons/web/static/src/webclient/webclient.js
+++ b/addons/web/static/src/webclient/webclient.js
@@ -3,6 +3,7 @@
import { ActionContainer } from "./actions/action_container";
import { NavBar } from "./navbar/navbar";
import { useBus, useEffect, useService } from "@web/core/utils/hooks";
+import { useTooltip } from "@web/core/tooltip/tooltip_hook";
import { NotUpdatable } from "../core/utils/components";
import { MainComponentsContainer } from "../core/main_components_container";
import { useOwnDebugContext } from "../core/debug/debug_context";
@@ -44,6 +45,7 @@ export class WebClient extends Component {
() => []
);
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
+ useTooltip();
}
mounted() {
diff --git a/addons/web/static/tests/core/popover/popover_hook.js b/addons/web/static/tests/core/popover/popover_hook_tests.js
similarity index 100%
rename from addons/web/static/tests/core/popover/popover_hook.js
rename to addons/web/static/tests/core/popover/popover_hook_tests.js
diff --git a/addons/web/static/tests/core/tooltip/tooltip_hook_tests.js b/addons/web/static/tests/core/tooltip/tooltip_hook_tests.js
new file mode 100644
index 00000000000..252fae3e06d
--- /dev/null
+++ b/addons/web/static/tests/core/tooltip/tooltip_hook_tests.js
@@ -0,0 +1,206 @@
+/** @odoo-module **/
+
+import { browser } from "@web/core/browser/browser";
+import { PopoverContainer } from "@web/core/popover/popover_container";
+import { popoverService } from "@web/core/popover/popover_service";
+import { useTooltip } from "@web/core/tooltip/tooltip_hook";
+import { registry } from "@web/core/registry";
+import { registerCleanup } from "../../helpers/cleanup";
+import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
+import { getFixture, nextTick, patchWithCleanup, triggerEvent } from "../../helpers/utils";
+
+const { Component, mount, useState } = owl;
+const { xml } = owl.tags;
+
+const mainComponents = registry.category("main_components");
+
+/**
+ * Creates and mounts a parent component that use the "useTooltip" hook.
+ *
+ * @param {Component} Child a child Component that contains nodes with "data-tooltip" attribute
+ * @param {Object} [options]
+ * @param {Object} options.mockSetTimeout the mocked setTimeout to use (by default, calls the
+ * callback directly)
+ * @returns {Promise}
+ */
+async function makeParent(Child, options = {}) {
+ const fixture = getFixture();
+
+ // add the popover service to the registry -> will add the PopoverContainer
+ // to the mainComponentRegistry
+ clearRegistryWithCleanup(mainComponents);
+ registry.category("services").add("popover", popoverService);
+ const env = await makeTestEnv();
+
+ class Parent extends Component {
+ setup() {
+ this.Components = mainComponents.getEntries();
+ useTooltip();
+ }
+ }
+ Parent.template = xml`
+