From 440c72f2aa6571bddf830fb3e7bbe981a26b50ad Mon Sep 17 00:00:00 2001 From: luvi Date: Mon, 31 Jul 2023 16:14:49 +0000 Subject: [PATCH] [FIX] web: fix draggable with touch input This commit fixes some isues with touch behaviors in the drag and drop feature. Since commit (1), pointer events are used instead of mouse events in the draggable hook. This makes the drag and drop available with touch, but some issues appeared. The following issues have been adressed by the commit: - Scrolling in a kanban view has become difficult, since items are dragged, and columns are moved. A simple scroll in the view to show other items was nearly impossible without interacting with elements. A small delay has now be introduced in the draggable_hook_builder by default for touch interactions. The user has to do a long press (500ms) to instanciate the drag sequence. - on Windows laptops, the hasTouch helper would not return true, even on PCs and tablets with a touchscreen, because 'ontouchstart' is not present in the window object. It meant that any touch feature was not working properly (e.g. moving icons from the home screen). This commit adds a mediaQuery check right after the current check on the window object, to also consider those laptops as touch devices. Tests have been added to check the behavior of the touch drag, and the presence of a bouncing animation when initiated. The corresponding drag helper now has the possibility to set a custom pointerType. task-3436591 closes odoo/odoo#132373 X-original-commit: 4c5cf0962f91d05085fea75e9c26ecdca9c0eb13 Signed-off-by: Julien Mougenot (jum) --- .../src/core/browser/feature_detection.js | 2 +- .../src/core/utils/draggable_hook_builder.js | 11 +- .../core/utils/draggable_hook_builder.scss | 17 +++ .../tests/core/utils/draggable_tests.js | 100 +++++++++++++++++- addons/web/static/tests/helpers/utils.js | 3 +- 5 files changed, 128 insertions(+), 5 deletions(-) diff --git a/addons/web/static/src/core/browser/feature_detection.js b/addons/web/static/src/core/browser/feature_detection.js index 85740a1d88b..21c6294b1b0 100644 --- a/addons/web/static/src/core/browser/feature_detection.js +++ b/addons/web/static/src/core/browser/feature_detection.js @@ -62,7 +62,7 @@ export function isDisplayStandalone() { } export function hasTouch() { - return browser.ontouchstart !== undefined; + return browser.ontouchstart !== undefined || browser.matchMedia("(pointer:coarse)").matches; } export function maxTouchPoints() { diff --git a/addons/web/static/src/core/utils/draggable_hook_builder.js b/addons/web/static/src/core/utils/draggable_hook_builder.js index 4f883ea48de..df0bd02724b 100644 --- a/addons/web/static/src/core/utils/draggable_hook_builder.js +++ b/addons/web/static/src/core/utils/draggable_hook_builder.js @@ -95,6 +95,7 @@ const DEFAULT_DEFAULT_PARAMS = { }, delay: 0, tolerance: 10, + touch_delay: 300, }; const LEFT_CLICK = 0; const MANDATORY_PARAMS = ["ref"]; @@ -651,6 +652,8 @@ export function makeDraggableHook(hookParams) { const onPointerDown = (ev) => { updatePointerPosition(ev); + const initiationDelay = ev.pointerType === "touch" ? ctx.touch_delay : ctx.delay; + // A drag sequence can still be in progress if the pointerup occurred // outside of the window. dragEnd(null); @@ -679,8 +682,11 @@ export function makeDraggableHook(hookParams) { target.releasePointerCapture(pointerId); } - if (ctx.delay) { + if (initiationDelay) { if (hasTouch()) { + if (ev.pointerType === "touch") { + dom.addClass(target.closest(ctx.elementSelector), "o_touch_bounce"); + } if (isBrowserFirefox()) { // On Firefox mobile, long-touch events trigger an unpreventable // context menu to appear. To prevent this, all linkes are removed @@ -714,7 +720,7 @@ export function makeDraggableHook(hookParams) { // Note that the timeout is cleared in dragEnd dragEnd(null); } - }, ctx.delay); + }, initiationDelay); cleanup.add(() => browser.clearTimeout(ctx.current.timeout)); } else { willStartDrag(target); @@ -927,6 +933,7 @@ export function makeDraggableHook(hookParams) { // Delay & tolerance ctx.delay = actualParams.delay; + ctx.touch_delay = actualParams.delay || actualParams.touch_delay; ctx.tolerance = actualParams.tolerance; callBuildHandler("onComputeParams", { params: actualParams }); diff --git a/addons/web/static/src/core/utils/draggable_hook_builder.scss b/addons/web/static/src/core/utils/draggable_hook_builder.scss index 07e4f4c20ad..6af67c52dfa 100644 --- a/addons/web/static/src/core/utils/draggable_hook_builder.scss +++ b/addons/web/static/src/core/utils/draggable_hook_builder.scss @@ -1,3 +1,15 @@ +@keyframes bounce { + 0% { + transform: scale(1); + } + 60% { + transform: scale(.95); + } + 100% { + transform: scale(1); + } +} + .o_draggable { -webkit-touch-callout: none; // for iOS } @@ -6,3 +18,8 @@ z-index: 1000; pointer-events: none; } + +.o_touch_bounce { + animation: bounce .4s forwards; + user-select: none; +} diff --git a/addons/web/static/tests/core/utils/draggable_tests.js b/addons/web/static/tests/core/utils/draggable_tests.js index 4622acaf865..6b82c994852 100644 --- a/addons/web/static/tests/core/utils/draggable_tests.js +++ b/addons/web/static/tests/core/utils/draggable_tests.js @@ -1,7 +1,8 @@ /** @odoo-module **/ -import { drag, dragAndDrop, getFixture, mount, nextTick } from "@web/../tests/helpers/utils"; +import { drag, dragAndDrop, getFixture, mount, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; import { useDraggable } from "@web/core/utils/draggable"; +import { browser } from "@web/core/browser/browser"; import { Component, reactive, useRef, useState, xml } from "@odoo/owl"; @@ -213,4 +214,101 @@ QUnit.module("Draggable", ({ beforeEach }) => { assert.verifySteps([]); }); + + QUnit.test("Dragging element with touch event", async (assert) => { + assert.expect(10); + + patchWithCleanup(browser, { + matchMedia: (media) => { + if (media === "(pointer:coarse)") { + return { matches: true }; + } else { + this._super(); + } + }, + setTimeout: (fn, delay) => { + assert.strictEqual(delay, 300, "touch drag has a default 300ms initiation delay"); + fn(); + } + }); + + class List extends Component { + setup() { + useDraggable({ + ref: useRef("root"), + elements: ".item", + onDragStart({ element }) { + assert.step("start"); + assert.hasClass(element, "o_touch_bounce", "element has the animation class applied"); + }, + onDrag() { + assert.step("drag"); + }, + onDragEnd() { + assert.step("end"); + }, + async onDrop({ element }) { + assert.step("drop"); + await nextTick(); + assert.doesNotHaveClass(element, "o_touch_bounce", "element no longer has the animation class applied"); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + assert.verifySteps([]); + + const { drop, moveTo } = await drag(".item:first-child", "touch"); + await moveTo(".item:nth-child(2)"); + assert.hasClass(target.querySelector(".item"), "o_dragged"); + + await drop(); + assert.verifySteps(["start", "drag", "drop", "end"]); + }); + + QUnit.test("Dragging element with touch event: initiation delay can be overrided", async (assert) => { + patchWithCleanup(browser, { + matchMedia: (media) => { + if (media === "(pointer:coarse)") { + return { matches: true }; + } else { + this._super(); + } + }, + setTimeout: (fn, delay) => { + assert.strictEqual(delay, 1000, "touch drag has the custom initiation delay"); + fn(); + } + }); + + class List extends Component { + setup() { + useDraggable({ + ref: useRef("root"), + delay: 1000, + elements: ".item", + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + const { drop, moveTo } = await drag(".item:first-child", "touch"); + await moveTo(".item:nth-child(2)"); + await drop(); + }); }); diff --git a/addons/web/static/tests/helpers/utils.js b/addons/web/static/tests/helpers/utils.js index f799bcea230..b9a30c739c9 100644 --- a/addons/web/static/tests/helpers/utils.js +++ b/addons/web/static/tests/helpers/utils.js @@ -848,7 +848,7 @@ export async function dragAndDrop(from, to, position) { * * @param {Element | string} from */ -export async function drag(from) { +export async function drag(from, pointerType = "mouse") { const assertIsDragging = (fn, endDrag) => { return { async [fn.name](...args) { @@ -961,6 +961,7 @@ export async function drag(from) { // Pointer down on main target await triggerEvent(source, null, "pointerdown", { + pointerType, clientX: sourceRect.x + sourceRect.width / 2, clientY: sourceRect.y + sourceRect.height / 2, });