[IMP] web: Tooltip UX on touch-enabled devices

Port of legacy's BasicRenderer for WOWL views.

In a nutshell: Tooltips are problematic in touch devices as they are
meant to appear on hover... which doesn't happen with touch events.

This commit implements a "tap-to-show" behavior which let the user see
the Tooltip by pressing the element, and letting it disappear when the
pressure ends.

Part-of: odoo/odoo#95924
This commit is contained in:
Pierre Paridans
2022-07-19 11:50:51 +02:00
parent 69f047c26d
commit f5ef77839f
2 changed files with 83 additions and 8 deletions
@@ -3,6 +3,7 @@
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { Tooltip } from "./tooltip";
import { hasTouch } from "@web/core/browser/feature_detection";
const { whenReady } = owl;
@@ -47,6 +48,7 @@ export const tooltipService = {
let target = null;
let positionX;
let positionY;
let touchPressed;
const elementsWithTooltips = new Map();
/**
@@ -70,6 +72,9 @@ export const tooltipService = {
if (!document.body.contains(target)) {
return true; // target is no longer in the DOM
}
if (hasTouch()) {
return !touchPressed;
}
const targetRect = target.getBoundingClientRect();
if (
positionX < targetRect.left ||
@@ -120,18 +125,13 @@ export const tooltipService = {
}
/**
* Checks whether there is a tooltip registered on the event target, and
* Checks whether there is a tooltip registered on the element, and
* if there is, creates a timeout to open the corresponding tooltip
* after a delay.
*
* @param {MouseEvent} ev a "mouseenter" event
* @param {HTMLElement} el
*/
function onMouseenter(ev) {
// set mouse position in case the target contains disabled elements which won't trigger mousemove
positionX = ev.x;
positionY = ev.y;
/** @type {HTMLElement} */
const el = ev.target;
function openElementsTooltip(el) {
if (elementsWithTooltips.has(el)) {
openTooltip(el, elementsWithTooltips.get(el));
} else if (el.matches("[data-tooltip], [data-tooltip-template]")) {
@@ -148,6 +148,32 @@ export const tooltipService = {
}
}
/**
* Checks whether there is a tooltip registered on the event target, and
* if there is, creates a timeout to open the corresponding tooltip
* after a delay.
*
* @param {MouseEvent} ev a "mouseenter" event
*/
function onMouseenter(ev) {
// set mouse position in case the target contains disabled elements which won't trigger mousemove
positionX = ev.x;
positionY = ev.y;
openElementsTooltip(ev.target);
}
/**
* Checks whether there is a tooltip registered on the event target, and
* if there is, creates a timeout to open the corresponding tooltip
* after a delay.
*
* @param {TouchEvent} ev a "touchstart" event
*/
function onTouchStart(ev) {
touchPressed = true;
openElementsTooltip(ev.target);
}
whenReady(() => {
// Regularly check that the target is still in the DOM and we're still
// hovering it, because if not, we have to close the tooltipd
@@ -157,6 +183,20 @@ export const tooltipService = {
}
}, CLOSE_DELAY);
if (hasTouch()) {
document.body.addEventListener("touchstart", onTouchStart);
document.body.addEventListener("touchend", () => {
touchPressed = false;
});
document.body.addEventListener("touchcancel", () => {
touchPressed = false;
});
return;
}
// Track mouse position to be able to detect that we are no longer hovering
// the target, thus that we should close the tooltip
document.body.addEventListener("mousemove", (ev) => {
@@ -41,6 +41,7 @@ export async function makeParent(Child, options = {}) {
clearTimeout: options.mockClearTimeout || (() => {}),
setInterval: options.mockSetInterval || ((fn) => fn()),
clearInterval: options.mockClearInterval || (() => {}),
ontouchstart: options.mockOnTouchStart || undefined,
});
registry.category("services").add("popover", popoverService);
@@ -375,4 +376,38 @@ QUnit.module("Tooltip service", (hooks) => {
target.querySelector("button").dispatchEvent(new Event("mouseenter"));
await nextTick();
});
QUnit.test("touch 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 mockOnTouchStart = () => {};
await makeParent(MyComponent, { mockSetTimeout, mockSetInterval, mockOnTouchStart });
assert.containsNone(target, ".o_popover_container .o_popover");
await triggerEvent(target, "button", "touchstart");
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
simulateTimeout();
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.strictEqual(
target.querySelector(".o_popover_container .o_popover").innerText,
"hello"
);
await triggerEvent(target, "button", "touchend");
assert.containsOnce(target, ".o_popover_container .o_popover");
simulateInterval();
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
});
});