[IMP] web: introduce useTooltip hook

This hook automatically generates custom tooltips (instead of
native ones) for all elements with the "data-tooltip" attribute
set, inside the component using it. The optional attribute
"data-tooltip-position" can be used to enforce a specific tooltip
position.

This commit also makes the WebClient use this hook.

Part-of: odoo/odoo#73311
Co-authored-by: Lucas Perais (lpe) <lpe@odoo.com>
This commit is contained in:
Aaron Bohy
2021-09-07 10:04:32 +00:00
co-authored by Lucas Perais
parent 6cfe05ae4b
commit 1fd54eeae7
10 changed files with 526 additions and 3 deletions
@@ -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 },
};
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Tooltip" owl="1">
<div class="o-tooltip px-2 py-1">
<t t-if="props.Component" t-component="props.Component" t-props="props.props" />
<small t-else="" t-esc="props.tooltip"/>
</div>
</t>
</templates>
@@ -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):
* <button data-tooltip="This is a tooltip">Do something</button>
*
* The ideal position of the tooltip can be specified thanks to the attribute
* "data-tooltip-position":
* <button data-tooltip="This is a tooltip" data-tooltip-position="left">Do something</button>
*/
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 });
}
+78
View File
@@ -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
// -----------------------------------------------------------------------------
@@ -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() {
@@ -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<Component>}
*/
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`
<div>
<Child/>
<div>
<t t-foreach="Components" t-as="Component" t-key="Component[0]">
<t t-component="Component[1].Component" t-props="Component[1].props"/>
</t>
</div>
</div>`;
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`<button data-tooltip="hello">Action</button>`;
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`
<div>
<button t-if="state.visible" data-tooltip="hello">Action</button>
</div>`;
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`
<div>
<button class="button_1" data-tooltip="tooltip 1">Action 1</button>
<button class="button_2" data-tooltip="tooltip 2">Action 2</button>
</div>`;
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`
<div style="height: 400px; padding: 40px">
<button class="default" data-tooltip="default">Default</button>
<button class="top" data-tooltip="top" data-tooltip-position="top">Top</button>
<button class="right" data-tooltip="right" data-tooltip-position="right">Right</button>
<button class="bottom" data-tooltip="bottom" data-tooltip-position="bottom">Bottom</button>
<button class="left" data-tooltip="left" data-tooltip-position="left">Left</button>
</div>`;
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");
});
});
@@ -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`<button>Click Me</button>`;
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`
<div>
<button t-if="flag">Click Here</button>
<button t-else="">
<span>or Here</span>
</button>
</div>`;
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`
<div>
<button t-if="flag">Click Here</button>
<button t-else="">
<span>or Here</span>
</button>
</div>`;
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) {
+1 -1
View File
@@ -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):