diff --git a/addons/web/static/src/core/tooltip/tooltip.js b/addons/web/static/src/core/tooltip/tooltip.js new file mode 100644 index 00000000000..f35de09b408 --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +const { Component } = owl; + +export class Tooltip extends Component {} +Tooltip.template = "web.Tooltip"; +Tooltip.props = { + tooltip: String, + Component: { optional: true }, + props: { optional: true }, +}; diff --git a/addons/web/static/src/core/tooltip/tooltip.scss b/addons/web/static/src/core/tooltip/tooltip.scss new file mode 100644 index 00000000000..98933b8ad56 --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.scss @@ -0,0 +1,25 @@ +.o-tooltip { + font-size: smaller; + + .o-tooltip--string { + background-color: $o-tooltip-title-background-color; + font-weight: bold; + padding: 5px 8px; + } + + .o-tooltip--help { + white-space: pre-line; + padding: 8px; + margin-bottom: 0px; + } + + .o-tooltip--technical { + padding: 8px; + margin: 0 0 0 15px; + list-style-type: circle; + + .o-tooltip--technical--title { + font-weight: bold; + } + } +} diff --git a/addons/web/static/src/core/tooltip/tooltip.xml b/addons/web/static/src/core/tooltip/tooltip.xml new file mode 100644 index 00000000000..d9c74413ede --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.xml @@ -0,0 +1,11 @@ + + + + +
+ + +
+
+ +
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` +
+ +
+ + + +
+
`; + Parent.components = { PopoverContainer, Child }; + + patchWithCleanup(browser, { + setTimeout: options.mockSetTimeout || ((fn) => fn()), + setInterval: options.mockSetInterval || ((fn) => fn()), + }); + + const comp = await mount(Parent, { env, target: fixture }); + registerCleanup(() => comp.destroy()); + return comp; +} + +QUnit.module("Tooltip hook", () => { + QUnit.test("basic rendering", async (assert) => { + class MyComponent extends Component {} + MyComponent.template = xml``; + let simulateTimeout; + const mockSetTimeout = (fn) => { + simulateTimeout = fn; + }; + let simulateInterval; + const mockSetInterval = (fn) => { + simulateInterval = fn; + }; + const parent = await makeParent(MyComponent, { mockSetTimeout, mockSetInterval }); + + assert.containsNone(parent, ".o_popover_container .o_popover"); + parent.el.querySelector("button").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsNone(parent, ".o_popover_container .o_popover"); + + simulateTimeout(); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual( + parent.el.querySelector(".o_popover_container .o_popover").innerText, + "hello" + ); + + const buttonRect = parent.el.querySelector("button").getBoundingClientRect(); + const x = buttonRect.right + 10; + const y = buttonRect.bottom + 10; + await triggerEvent(parent.el, "button", "mousemove", { + pageX: x, + layerX: x, + screenX: x, + pageY: y, + layerY: y, + screenY: y, + }); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + simulateInterval(); + await nextTick(); + assert.containsNone(parent, ".o_popover_container .o_popover"); + }); + + QUnit.test("remove element with opened tooltip", async (assert) => { + let compState; + class MyComponent extends Component { + setup() { + this.state = useState({ visible: true }); + compState = this.state; + } + } + MyComponent.template = xml` +
+ +
`; + let simulateInterval; + const mockSetInterval = (fn) => { + simulateInterval = fn; + }; + const parent = await makeParent(MyComponent, { mockSetInterval }); + + assert.containsOnce(parent, "button"); + assert.containsNone(parent, ".o_popover_container .o_popover"); + parent.el.querySelector("button").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + + compState.visible = false; + await nextTick(); + assert.containsNone(parent, "button"); + simulateInterval(); + await nextTick(); + assert.containsNone(parent, ".o_popover_container .o_popover"); + }); + + QUnit.test("rendering with several tooltips", async (assert) => { + class MyComponent extends Component {} + MyComponent.template = xml` +
+ + +
`; + const parent = await makeParent(MyComponent); + + assert.containsNone(parent, ".o_popover_container .o_popover"); + parent.el.querySelector("button.button_1").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "tooltip 1"); + parent.el.querySelector("button.button_1").dispatchEvent(new Event("mouseleave")); + parent.el.querySelector("button.button_2").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "tooltip 2"); + }); + + QUnit.test("positionning", async (assert) => { + class MyComponent extends Component {} + MyComponent.template = xml` +
+ + + + + +
`; + const parent = await makeParent(MyComponent); + + // default + parent.el.querySelector("button.default").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "default"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--bm"); + + // top + parent.el.querySelector("button.top").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "top"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--tm"); + + // right + parent.el.querySelector("button.right").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "right"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--rm"); + + // bottom + parent.el.querySelector("button.bottom").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "bottom"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--bm"); + + // left + parent.el.querySelector("button.left").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "left"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--lm"); + }); +}); diff --git a/addons/web/static/tests/core/utils/hooks_tests.js b/addons/web/static/tests/core/utils/hooks_tests.js index 901ea18fa80..632b35ed537 100644 --- a/addons/web/static/tests/core/utils/hooks_tests.js +++ b/addons/web/static/tests/core/utils/hooks_tests.js @@ -1,9 +1,10 @@ /** @odoo-module **/ -import { useBus, useEffect, useService } from "@web/core/utils/hooks"; +import { useBus, useEffect, useListener, useService } from "@web/core/utils/hooks"; import { registry } from "@web/core/registry"; import { makeTestEnv } from "../../helpers/mock_env"; -import { getFixture, nextTick } from "../../helpers/utils"; +import { click, getFixture, nextTick } from "../../helpers/utils"; +import { registerCleanup } from "../../helpers/cleanup"; const { Component, mount, tags, useState } = owl; const serviceRegistry = registry.category("services"); @@ -230,6 +231,93 @@ QUnit.module("utils", () => { } ); + QUnit.module("useListener"); + + QUnit.test("useListener: simple usecase", async function (assert) { + class MyComponent extends Component { + setup() { + useListener("click", () => assert.step("click")); + } + } + MyComponent.template = tags.xml``; + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, { env, target }); + registerCleanup(() => { + comp.destroy(); + }); + + await click(comp.el); + assert.verifySteps(["click"]); + }); + + QUnit.test("useListener: event delegation", async function (assert) { + class MyComponent extends Component { + setup() { + this.flag = true; + useListener("click", "button", () => assert.step("click")); + } + } + MyComponent.template = tags.xml` +
+ + +
`; + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, { env, target }); + registerCleanup(() => { + comp.destroy(); + }); + + await click(comp.el); + assert.verifySteps([]); + await click(comp.el.querySelector("button")); + assert.verifySteps(["click"]); + + comp.flag = false; + await comp.render(); + await click(comp.el.querySelector("button span")); + assert.verifySteps(["click"]); + }); + + QUnit.test("useListener: event delegation with capture option", async function (assert) { + class MyComponent extends Component { + setup() { + this.flag = false; + useListener("click", "button", () => assert.step("click"), { capture: true }); + } + } + MyComponent.template = tags.xml` +
+ + +
`; + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, { env, target }); + registerCleanup(() => { + comp.destroy(); + }); + + await click(comp.el); + assert.verifySteps([]); + await click(comp.el.querySelector("button")); + assert.verifySteps(["click"]); + + comp.flag = false; + await comp.render(); + await click(comp.el.querySelector("button span")); + assert.verifySteps(["click"]); + }); + QUnit.module("useService"); QUnit.test("useService: unavailable service", async function (assert) { diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index f76e813a438..95736b80790 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -149,7 +149,7 @@ TRANSLATED_ELEMENTS = { TRANSLATED_ATTRS = dict.fromkeys({ 'string', 'add-label', 'help', 'sum', 'avg', 'confirm', 'placeholder', 'alt', 'title', 'aria-label', 'aria-keyshortcuts', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', - 'value_label', + 'value_label', 'data-tooltip', }, lambda e: True) def translate_attrib_value(node):