diff --git a/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js b/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js index 9ac8b9276e1..ccb7a98bcc1 100644 --- a/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js +++ b/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js @@ -114,42 +114,8 @@ registry.category("web_tour.tours").add("im_livechat_chatbot_steps_sequence_with run: () => {}, }, { - trigger: 'tr:contains("Step 5") .o_row_handle', - run: () => { - // move 'step 5' between 'step 1' and 'step 2' - const from = document.querySelector( - 'div[name="script_step_ids"] tr:nth-child(5) .o_row_handle' - ); - const fromPosition = from.getBoundingClientRect(); - fromPosition.x += from.offsetWidth / 2; - fromPosition.y += from.offsetHeight / 2; - - const to = document.querySelector( - 'div[name="script_step_ids"] tr:nth-child(2) .o_row_handle' - ); - from.dispatchEvent(new Event("mouseenter", { bubbles: true })); - from.dispatchEvent( - new MouseEvent("mousedown", { - bubbles: true, - which: 1, - button: 0, - clientX: fromPosition.x, - clientY: fromPosition.y, - }) - ); - from.dispatchEvent( - new MouseEvent("mousemove", { - bubbles: true, - which: 1, - button: 0, - // dragging is only enabled when the mouse have moved from at least 10 pixels from the original position - clientX: fromPosition.x + 20, - clientY: fromPosition.y + 20, - }) - ); - to.dispatchEvent(new Event("mouseenter", { bubbles: true })); - from.dispatchEvent(new Event("mouseup", { bubbles: true })); - }, + trigger: 'div[name="script_step_ids"] tr:nth-child(5) .o_row_handle', + run: 'drag_and_drop_native div[name="script_step_ids"] tr:nth-child(2)', }, { trigger: 'div[name="script_step_ids"] .o_field_x2many_list_row_add a', diff --git a/addons/spreadsheet/__manifest__.py b/addons/spreadsheet/__manifest__.py index 507b5f2a4f0..bc20e758889 100644 --- a/addons/spreadsheet/__manifest__.py +++ b/addons/spreadsheet/__manifest__.py @@ -44,6 +44,7 @@ 'web/static/lib/owl/odoo_module.js', 'web/static/src/core/utils/**/*.js', 'web/static/src/core/browser/browser.js', + 'web/static/src/core/browser/feature_detection.js', 'web/static/src/core/registry.js', 'web/static/src/core/assets.js', 'web/static/src/session.js', diff --git a/addons/web/static/src/core/browser/feature_detection.js b/addons/web/static/src/core/browser/feature_detection.js index 4cde423741b..d0795e58736 100644 --- a/addons/web/static/src/core/browser/feature_detection.js +++ b/addons/web/static/src/core/browser/feature_detection.js @@ -7,12 +7,14 @@ import { browser } from "./browser"; // ----------------------------------------------------------------------------- /** - * true if the browser is based on Chromium (Google Chrome, Opera, Edge) - * - * @returns {boolean} + * True if the browser is based on Chromium (Google Chrome, Opera, Edge). */ export function isBrowserChrome() { - return browser.navigator.userAgent.includes("Chrome"); + return /Chrome/i.test(browser.navigator.userAgent); +} + +export function isBrowserFirefox() { + return /Firefox/i.test(browser.navigator.userAgent); } export function isAndroid() { @@ -31,7 +33,7 @@ export function isOtherMobileOS() { } export function isMacOS() { - return Boolean(browser.navigator.userAgent.match(/Mac/i)); + return /Mac/i.test(browser.navigator.userAgent); } export function isMobileOS() { 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 8820b58666c..4f883ea48de 100644 --- a/addons/web/static/src/core/utils/draggable_hook_builder.js +++ b/addons/web/static/src/core/utils/draggable_hook_builder.js @@ -3,19 +3,13 @@ import { onWillUnmount, reactive, useEffect, useExternalListener } from "@odoo/owl"; import { clamp } from "@web/core/utils/numbers"; import { setRecurringAnimationFrame, useThrottleForAnimation } from "@web/core/utils/timing"; +import { browser } from "../browser/browser"; +import { hasTouch, isBrowserFirefox, isIOS } from "../browser/feature_detection"; /** - * @typedef CleanupManager - * @property {(cleanupFn: Function) => void} add - * @property {() => void} cleanup + * @typedef {ReturnType} CleanupManager * - * @typedef DOMHelpers - * @property {(el: HTMLElement, ...classNames: string[]) => void} addClass - * @property {(el: EventTarget, event: string, callback: (...args: any[]) => any, options?: boolean | Record) => void} addListener - * @property {(el: HTMLElement, style: Record) => void} addStyle - * @property {(el: HTMLElement, options?: { adjust?: boolean }) => DOMRect} getRect - * @property {(el: HTMLElement, ...classNames: string[]) => void} removeClass - * @property {(el: HTMLElement, properties: string[]) => void} removeStyle + * @typedef {ReturnType} DOMHelpers * * @typedef DraggableBuilderParams * Hook params @@ -40,8 +34,9 @@ import { setRecurringAnimationFrame, useThrottleForAnimation } from "@web/core/u * @property {boolean} [followCursor=true] * @property {string | null} [cursor=null] * @property {() => boolean} [enable=() => false] - * @property {Position} [mouse={ x: 0, y: 0 }] + * @property {Position} [pointer={ x: 0, y: 0 }] * @property {EdgeScrollingOptions} [edgeScrolling] + * @property {number} [delay] * @property {number} [tolerance] * @property {DraggableHookCurrentContext} current * @@ -54,6 +49,7 @@ import { setRecurringAnimationFrame, useThrottleForAnimation } from "@web/core/u * @property {DOMRect | null} [current.scrollParentXRect] * @property {HTMLElement | null} [current.scrollParentY] * @property {DOMRect | null} [current.scrollParentYRect] + * @property {number} [timeout] * @property {Position} [initialPosition] * @property {Position} [offset={ x: 0, y: 0 }] * @@ -76,6 +72,9 @@ import { setRecurringAnimationFrame, useThrottleForAnimation } from "@web/core/u * @typedef {DOMHelpers & Position & { element: HTMLElement }} DraggableHandlerParams */ +const DRAGGABLE_CLASS = "o_draggable"; +const DRAGGED_CLASS = "o_dragged"; + const DEFAULT_ACCEPTED_PARAMS = { enable: [Boolean, Function], ref: [Object], @@ -84,19 +83,21 @@ const DEFAULT_ACCEPTED_PARAMS = { ignore: [String, Function], cursor: [String], edgeScrolling: [Object, Function], + delay: [Number], tolerance: [Number], }; const DEFAULT_DEFAULT_PARAMS = { + elements: `.${DRAGGABLE_CLASS}`, enable: true, edgeScrolling: { speed: 10, threshold: 30, }, + delay: 0, tolerance: 10, }; -const DRAGGED_CLASS = "o_dragged"; const LEFT_CLICK = 0; -const MANDATORY_PARAMS = ["ref", "elements"]; +const MANDATORY_PARAMS = ["ref"]; const WHITE_LISTED_KEYS = ["Alt", "Control", "Meta", "Shift"]; /** @@ -181,13 +182,12 @@ function getScrollParentY(el) { } /** - * @param {Function} [defaultCleanupFn] - * @returns {CleanupManager} + * @param {() => any} [defaultCleanupFn] */ function makeCleanupManager(defaultCleanupFn) { /** * Registers the given cleanup function to be called when cleaning up hooks. - * @param {Function} [cleanupFn] + * @param {() => any} [cleanupFn] */ const add = (cleanupFn) => typeof cleanupFn === "function" && cleanups.push(cleanupFn); @@ -210,7 +210,6 @@ function makeCleanupManager(defaultCleanupFn) { /** * @param {CleanupManager} cleanup - * @returns {DOMHelpers} */ function makeDOMHelpers(cleanup) { /** @@ -227,20 +226,21 @@ function makeDOMHelpers(cleanup) { /** * Adds an event listener to be cleaned up after the next drag sequence - * has stopped. An additionnal `timeout` param allows the handler to be - * delayed after a timeout. + * has stopped. * @param {EventTarget} el * @param {string} event * @param {(...args: any[]) => any} callback - * @param {boolean | Record} [options] + * @param {AddEventListenerOptions & { noAddedStyle?: boolean }} [options] */ - const addListener = (el, event, callback, options) => { + const addListener = (el, event, callback, options = {}) => { if (!el || !event || !callback) { return; } + const { noAddedStyle } = options; + delete options.noAddedStyle; el.addEventListener(event, callback, options); - if (/pointer|mouse/.test(event)) { - // Restore pointer events on elements listening on mouse/pointer events. + if (!noAddedStyle && /mouse|pointer|touch/.test(event)) { + // Restore pointer events on elements listening on mouse/pointer/touch events. addStyle(el, { pointerEvents: "auto" }); } cleanup.add(() => el.removeEventListener(event, callback, options)); @@ -295,10 +295,22 @@ function makeDOMHelpers(cleanup) { /** * @param {HTMLElement} el - * @param {...string} classNames + * @param {string} attribute + */ + const removeAttribute = (el, attribute) => { + if (!el || !attribute) { + return; + } + cleanup.add(saveAttribute(el, attribute)); + el.removeAttribute(attribute); + }; + + /** + * @param {HTMLElement} el + * @param {...string} classNames */ const removeClass = (el, ...classNames) => { - if (!el || !classNames) { + if (!el || !classNames.length) { return; } cleanup.add(saveAttribute(el, "class")); @@ -312,7 +324,7 @@ function makeDOMHelpers(cleanup) { * @param {...string} properties */ const removeStyle = (el, ...properties) => { - if (!el || !properties?.length) { + if (!el || !properties.length) { return; } cleanup.add(saveAttribute(el, "style")); @@ -321,7 +333,29 @@ function makeDOMHelpers(cleanup) { } }; - return { addClass, addListener, addStyle, getRect, removeClass, removeStyle }; + /** + * @param {HTMLElement} el + * @param {string} attribute + * @param {any} value + */ + const setAttribute = (el, attribute, value) => { + if (!el || !attribute) { + return; + } + cleanup.add(saveAttribute(el, attribute)); + el.setAttribute(attribute, String(value)); + }; + + return { + addClass, + addListener, + addStyle, + getRect, + removeAttribute, + removeClass, + removeStyle, + setAttribute, + }; } /** @@ -333,10 +367,23 @@ function pixelValueToNumber(val) { return Number(val.endsWith("px") ? val.slice(0, -2) : val); } +/** + * @param {Event} ev + * @param {{ stop?: boolean }} params + */ +function safePrevent(ev, { stop } = {}) { + if (ev.cancelable) { + ev.preventDefault(); + if (stop) { + ev.stopPropagation(); + } + } +} + function saveAttribute(el, attribute) { const restoreAttribute = () => { cache.delete(el); - if (originalValue) { + if (hasAttribute) { el.setAttribute(attribute, originalValue); } else { el.removeAttribute(attribute); @@ -353,6 +400,7 @@ function saveAttribute(el, attribute) { } cache.add(el); + const hasAttribute = el.hasAttribute(attribute); const originalValue = el.getAttribute(attribute); return restoreAttribute; @@ -437,7 +485,7 @@ export function makeDraggableHook(hookParams) { return; } try { - params[handlerName]({ ...dom, ...ctx.mouse, ...arg }); + params[handlerName]({ ...dom, ...ctx.pointer, ...arg }); } catch (err) { dragEnd(null, true); throw err; @@ -446,16 +494,16 @@ export function makeDraggableHook(hookParams) { /** * Returns whether the user has moved from at least the number of pixels - * that are tolerated from the initial mouse position. + * that are tolerated from the initial pointer position. */ const canStartDrag = () => { const { - mouse, + pointer, current: { initialPosition }, } = ctx; return ( !ctx.tolerance || - Math.hypot(mouse.x - initialPosition.x, mouse.y - initialPosition.y) >= + Math.hypot(pointer.x - initialPosition.x, pointer.y - initialPosition.y) >= ctx.tolerance ); }; @@ -491,10 +539,7 @@ export function makeDraggableHook(hookParams) { updateElementPosition(); } - dom.addStyle(document.body, { - pointerEvents: "none", - userSelect: "none", - }); + dom.addClass(document.body, "pe-none", "user-select-none"); if (ctx.cursor) { dom.addStyle(document.body, { cursor: ctx.cursor }); } @@ -585,8 +630,7 @@ export function makeDraggableHook(hookParams) { return; } if (!WHITE_LISTED_KEYS.includes(ev.key)) { - ev.stopImmediatePropagation(); - ev.preventDefault(); + safePrevent(ev, { stop: true }); // Cancels drag sequences on every non-whitelisted key down event. dragEnd(null); @@ -594,13 +638,20 @@ export function makeDraggableHook(hookParams) { }; /** - * Global (= ref) "mousedown" event handler. - * @param {MouseEvent} ev + * Global (= ref) "pointercancel" event handler. */ - const onMouseDown = (ev) => { - updateMousePosition(ev); + const onPointerCancel = () => { + dragEnd(null); + }; - // A drag sequence can still be in progress if the mouseup occurred + /** + * Global (= ref) "pointerdown" event handler. + * @param {PointerEvent} ev + */ + const onPointerDown = (ev) => { + updatePointerPosition(ev); + + // A drag sequence can still be in progress if the pointerup occurred // outside of the window. dragEnd(null); @@ -616,29 +667,72 @@ export function makeDraggableHook(hookParams) { // In FireFox: elements with `overflow: hidden` will prevent mouseenter and mouseleave // events from firing on elements underneath them. This is the case when dragging a card // by the `.o_kanban_record_headings` element. In such cases, we can prevent the default - // action on the mousedown event to allow mouse events to fire properly. + // action on the pointerdown event to allow pointer events to fire properly. // https://bugzilla.mozilla.org/show_bug.cgi?id=1352061 // https://bugzilla.mozilla.org/show_bug.cgi?id=339293 - ev.preventDefault(); + safePrevent(ev); - const { target } = ev; - ctx.current.initialPosition = { ...ctx.mouse }; + const { currentTarget, pointerId, target } = ev; + ctx.current.initialPosition = { ...ctx.pointer }; - willStartDrag(target); + if (target.hasPointerCapture(pointerId)) { + target.releasePointerCapture(pointerId); + } + + if (ctx.delay) { + if (hasTouch()) { + if (isBrowserFirefox()) { + // On Firefox mobile, long-touch events trigger an unpreventable + // context menu to appear. To prevent this, all linkes are removed + // from the dragged elements during the drag sequence. + const links = [...currentTarget.querySelectorAll("[href")]; + if (currentTarget.hasAttribute("href")) { + links.unshift(currentTarget); + } + for (const link of links) { + dom.removeAttribute(link, "href"); + } + } + if (isIOS()) { + // On Safari mobile, any image can be dragged regardless + // of the 'user-select' property. + for (const image of currentTarget.getElementsByTagName("img")) { + dom.setAttribute(image, "draggable", false); + } + } + } + + ctx.current.timeout = browser.setTimeout(() => { + ctx.current.initialPosition = { ...ctx.pointer }; + + willStartDrag(target); + + const { x: px, y: py } = ctx.pointer; + const { x, y, width, height } = dom.getRect(ctx.current.element); + if (px < x || x + width < px || py < y || y + height < py) { + // Pointer left the target + // Note that the timeout is cleared in dragEnd + dragEnd(null); + } + }, ctx.delay); + cleanup.add(() => browser.clearTimeout(ctx.current.timeout)); + } else { + willStartDrag(target); + } }; /** - * Window "mousemove" event handler. - * @param {MouseEvent} ev + * Window "pointermove" event handler. + * @param {PointerEvent} ev */ - const onMouseMove = (ev) => { - updateMousePosition(ev); + const onPointerMove = (ev) => { + updatePointerPosition(ev); if (!ctx.current.element || !ctx.enable()) { return; } - ev.preventDefault(); + safePrevent(ev); if (!state.dragging) { if (!canStartDrag()) { @@ -655,17 +749,17 @@ export function makeDraggableHook(hookParams) { }; /** - * Window "mouseup" event handler. - * @param {MouseEvent} ev + * Window "pointerup" event handler. + * @param {PointerEvent} ev */ - const onMouseUp = (ev) => { - updateMousePosition(ev); + const onPointerUp = (ev) => { + updatePointerPosition(ev); dragEnd(ev.target); }; /** * Updates the position of the current dragged element according to - * the current mouse position. + * the current pointer position. */ const updateElementPosition = () => { const { containerRect, element, elementRect, offset } = ctx.current; @@ -674,18 +768,18 @@ export function makeDraggableHook(hookParams) { // Updates the position of the dragged element. dom.addStyle(element, { - left: `${clamp(ctx.mouse.x - offset.x, cx, cx + cw - ew)}px`, - top: `${clamp(ctx.mouse.y - offset.y, cy, cy + ch)}px`, + left: `${clamp(ctx.pointer.x - offset.x, cx, cx + cw - ew)}px`, + top: `${clamp(ctx.pointer.y - offset.y, cy, cy + ch)}px`, }); }; /** - * Updates the current mouse position from a given event. - * @param {MouseEvent} ev + * Updates the current pointer position from a given event. + * @param {PointerEvent} ev */ - const updateMousePosition = (ev) => { - ctx.mouse.x = ev.clientX; - ctx.mouse.y = ev.clientY; + const updatePointerPosition = (ev) => { + ctx.pointer.x = ev.clientX; + ctx.pointer.y = ev.clientY; }; const updateRects = () => { @@ -741,6 +835,14 @@ export function makeDraggableHook(hookParams) { cleanup.add(() => (ctx.current = {})); callBuildHandler("onWillStartDrag"); + + if (hasTouch()) { + // Prevents panning/zooming after a long press + dom.addListener(window, "touchmove", safePrevent, { + passive: false, + noAddedStyle: true, + }); + } }; // Initialize helpers @@ -783,7 +885,7 @@ export function makeDraggableHook(hookParams) { fullSelector: null, followCursor: true, cursor: null, - mouse: { x: 0, y: 0 }, + pointer: { x: 0, y: 0 }, edgeScrolling: { enabled: true }, get dragging() { return state.dragging; @@ -824,6 +926,7 @@ export function makeDraggableHook(hookParams) { Object.assign(ctx.edgeScrolling, actualParams.edgeScrolling); // Delay & tolerance + ctx.delay = actualParams.delay; ctx.tolerance = actualParams.tolerance; callBuildHandler("onComputeParams", { params: actualParams }); @@ -833,21 +936,37 @@ export function makeDraggableHook(hookParams) { }, () => computeParams(params) ); - // Effect depending on the `ref.el` to add triggering mouse events listener. + // Effect depending on the `ref.el` to add triggering pointer events listener. useEffect( (el) => { if (el) { - el.addEventListener("mousedown", onMouseDown); - return () => el.removeEventListener("mousedown", onMouseDown); + const { add, cleanup } = makeCleanupManager(); + const { addListener } = makeDOMHelpers({ add }); + addListener(el, "pointerdown", onPointerDown, { noAddedStyle: true }); + if (hasTouch()) { + addListener(el, "contextmenu", safePrevent); + // Adds a non-passive listener on touchstart: this allows + // the subsequent "touchmove" events to be cancelable + // and thus prevent parasitic "touchcancel" events to + // be fired. Note that we DO NOT want to prevent touchstart + // events since they're responsible of the native swipe + // scrolling. + addListener(el, "touchstart", () => {}, { + passive: false, + noAddedStyle: true, + }); + } + return cleanup; } }, () => [ctx.ref.el] ); // Other global event listeners. - const throttledOnMouseMove = useThrottleForAnimation(onMouseMove); - useExternalListener(window, "mousemove", throttledOnMouseMove); - useExternalListener(window, "mouseup", onMouseUp); - useExternalListener(window, "keydown", onKeyDown, true); + const throttledOnPointerMove = useThrottleForAnimation(onPointerMove); + useExternalListener(window, "pointermove", throttledOnPointerMove, { passive: false }); + useExternalListener(window, "pointerup", onPointerUp); + useExternalListener(window, "pointercancel", onPointerCancel); + useExternalListener(window, "keydown", onKeyDown, { capture: true }); onWillUnmount(() => dragEnd(null)); return state; 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 a6099c3cf9e..07e4f4c20ad 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,7 @@ +.o_draggable { + -webkit-touch-callout: none; // for iOS +} + .o_dragged { z-index: 1000; pointer-events: none; diff --git a/addons/web/static/src/core/utils/sortable.js b/addons/web/static/src/core/utils/sortable.js index c4f5e9e6431..88b2551da25 100644 --- a/addons/web/static/src/core/utils/sortable.js +++ b/addons/web/static/src/core/utils/sortable.js @@ -44,7 +44,7 @@ import { pick } from "@web/core/utils/objects"; * @property {(params: SortableHandlerParams) => any} [onDragEnd] * called when the dragging sequence ends, regardless of the reason. * @property {(params: DropParams) => any} [onDrop] called when the dragging sequence - * ends on a mouseup action AND the dragged element has been moved elsewhere. The + * ends on a pointerup action AND the dragged element has been moved elsewhere. The * callback will be given an object with any useful element regarding the new position * of the dragged element (@see DropParams ). */ @@ -91,10 +91,10 @@ export const useSortable = makeDraggableHook({ // Runtime steps onDragStart({ ctx, addListener, addStyle, callHandler }) { /** - * Element "mouseenter" event handler. - * @param {MouseEvent} ev + * Element "pointerenter" event handler. + * @param {PointerEvent} ev */ - const onElementMouseenter = (ev) => { + const onElementPointerEnter = (ev) => { const element = ev.currentTarget; if ( connectGroups || @@ -112,29 +112,29 @@ export const useSortable = makeDraggableHook({ }; /** - * Element "mouseleave" event handler. - * @param {MouseEvent} ev + * Element "pointerleave" event handler. + * @param {PointerEvent} ev */ - const onElementMouseleave = (ev) => { + const onElementPointerLeave = (ev) => { const element = ev.currentTarget; callHandler("onElementLeave", { element }); }; /** - * Group "mouseenter" event handler. - * @param {MouseEvent} ev + * Group "pointerenter" event handler. + * @param {PointerEvent} ev */ - const onGroupMouseenter = (ev) => { + const onGroupPointerEnter = (ev) => { const group = ev.currentTarget; group.appendChild(current.placeHolder); callHandler("onGroupEnter", { group }); }; /** - * Group "mouseleave" event handler. - * @param {MouseEvent} ev + * Group "pointerleave" event handler. + * @param {PointerEvent} ev */ - const onGroupMouseleave = (ev) => { + const onGroupPointerLeave = (ev) => { const group = ev.currentTarget; callHandler("onGroupLeave", { group }); }; @@ -154,16 +154,16 @@ export const useSortable = makeDraggableHook({ // their parents and a 'groupSelector' has been provided. if (connectGroups && groupSelector) { for (const siblingGroup of ref.el.querySelectorAll(groupSelector)) { - addListener(siblingGroup, "mouseenter", onGroupMouseenter); - addListener(siblingGroup, "mouseleave", onGroupMouseleave); + addListener(siblingGroup, "pointerenter", onGroupPointerEnter); + addListener(siblingGroup, "pointerleave", onGroupPointerLeave); } } // Binds handlers on eligible elements for (const siblingEl of ref.el.querySelectorAll(elementSelector)) { if (siblingEl !== current.element && siblingEl !== current.placeHolder) { - addListener(siblingEl, "mouseenter", onElementMouseenter); - addListener(siblingEl, "mouseleave", onElementMouseleave); + addListener(siblingEl, "pointerenter", onElementPointerEnter); + addListener(siblingEl, "pointerleave", onElementPointerLeave); } } @@ -202,5 +202,7 @@ export const useSortable = makeDraggableHook({ current.placeHolder = current.element.cloneNode(false); addCleanup(() => current.placeHolder.remove()); + + return pick(current, "element", "group"); }, }); diff --git a/addons/web/static/src/scss/bootstrap_overridden.scss b/addons/web/static/src/scss/bootstrap_overridden.scss index 79b537cd190..b996457a569 100644 --- a/addons/web/static/src/scss/bootstrap_overridden.scss +++ b/addons/web/static/src/scss/bootstrap_overridden.scss @@ -294,10 +294,14 @@ $badge-color: inherit !default; $badge-padding-y: 0.25em !default; $badge-padding-x: 0.82em !default; -// Placeholder color +// Placeholder color $input-placeholder-color: $gray-300 !default; // Card $card-spacer-y: $spacer !default; // BS Default $card-cap-padding-y: $card-spacer-y !default; + +.user-select-none { + -webkit-user-select: none !important; // not included in BS by default +} diff --git a/addons/web/static/src/views/list/list_renderer.js b/addons/web/static/src/views/list/list_renderer.js index ab85dd879ce..c177c9aa1f5 100644 --- a/addons/web/static/src/views/list/list_renderer.js +++ b/addons/web/static/src/views/list/list_renderer.js @@ -97,7 +97,7 @@ export class ListRenderer extends Component { * will be triggered on the column title which will reorder the column. * Column resize that triggers a reorder is not a good UX and we prevent this * using the following state variables: `resizing` and `preventReorder` which - * are set during the column's click (onClickSortColumn), mouseup + * are set during the column's click (onClickSortColumn), pointerup * (onColumnTitleMouseUp) and onStartResize events. */ this.resizing = false; @@ -1865,7 +1865,7 @@ export class ListRenderer extends Component { const initialX = ev.clientX; const initialWidth = th.getBoundingClientRect().width; const initialTableWidth = table.getBoundingClientRect().width; - const resizeStoppingEvents = ["keydown", "mousedown", "mouseup"]; + const resizeStoppingEvents = ["keydown", "pointerdown", "pointerup"]; // fix the width so that if the resize overflows, it doesn't affect the layout of the parent if (!this.rootRef.el.style.width) { @@ -1892,7 +1892,7 @@ export class ListRenderer extends Component { th.style.maxWidth = `${Math.floor(newWidth)}px`; table.style.width = `${Math.floor(initialTableWidth + tableDelta)}px`; }; - window.addEventListener("mousemove", resizeHeader); + window.addEventListener("pointermove", resizeHeader); // Mouse or keyboard events : stop resize const stopResize = (ev) => { @@ -1900,7 +1900,7 @@ export class ListRenderer extends Component { // freeze column size after resizing this.keepColumnWidths = true; // Ignores the 'left mouse button down' event as it used to start resizing - if (ev.type === "mousedown" && ev.which === 1) { + if (ev.type === "pointerdown" && ev.button === 0) { return; } ev.preventDefault(); @@ -1913,7 +1913,7 @@ export class ListRenderer extends Component { handler.classList.add("bg-black-25", "opacity-50-hover"); } - window.removeEventListener("mousemove", resizeHeader); + window.removeEventListener("pointermove", resizeHeader); for (const eventType of resizeStoppingEvents) { window.removeEventListener(eventType, stopResize); } @@ -1924,8 +1924,8 @@ export class ListRenderer extends Component { document.activeElement.blur(); }; // We have to listen to several events to properly stop the resizing function. Those are: - // - mousedown (e.g. pressing right click) - // - mouseup : logical flow of the resizing feature (drag & drop) + // - pointerdown (e.g. pressing right click) + // - pointerup : logical flow of the resizing feature (drag & drop) // - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key) for (const eventType of resizeStoppingEvents) { window.addEventListener(eventType, stopResize); diff --git a/addons/web/static/src/views/list/list_renderer.xml b/addons/web/static/src/views/list/list_renderer.xml index 1b15a4d0bbd..5e72f5f3ca5 100644 --- a/addons/web/static/src/views/list/list_renderer.xml +++ b/addons/web/static/src/views/list/list_renderer.xml @@ -20,7 +20,7 @@ + t-on-pointerdown.stop.prevent="onStartResize"/> diff --git a/addons/web/static/tests/core/checkbox_tests.js b/addons/web/static/tests/core/checkbox_tests.js index 4ab8352542d..d3b967b5c89 100644 --- a/addons/web/static/tests/core/checkbox_tests.js +++ b/addons/web/static/tests/core/checkbox_tests.js @@ -160,18 +160,11 @@ QUnit.module("Components", (hooks) => { // Pressing Space when focus is on the input is a standard behavior // So we simulate it and verify that it will have its standard behavior. assert.strictEqual(document.activeElement, target.querySelector(".o-checkbox input")); - const event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Space" }, - { fast: true } - ); + const event = await triggerEvent(document.activeElement, null, "keydown", { key: "Space" }); assert.ok(!event.defaultPrevented); target.querySelector(".o-checkbox input").checked = true; assert.verifySteps([]); - triggerEvent(target, ".o-checkbox input", "change", {}, { fast: true }); - await nextTick(); + await triggerEvent(target, ".o-checkbox input", "change"); assert.ok(target.querySelector(".o-checkbox input").checked); assert.verifySteps(["true"]); }); diff --git a/addons/web/static/tests/core/ui_service_tests.js b/addons/web/static/tests/core/ui_service_tests.js index 0ca4e015131..d0ae28ff375 100644 --- a/addons/web/static/tests/core/ui_service_tests.js +++ b/addons/web/static/tests/core/ui_service_tests.js @@ -119,30 +119,19 @@ QUnit.test("UI active element: trap focus", async (assert) => { ); // Pressing 'Tab' - let event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab" }, - { fast: true } - ); + let event = await triggerEvent(document.activeElement, null, "keydown", { key: "Tab" }); assert.strictEqual(event.defaultPrevented, true); - await nextTick(); assert.strictEqual( document.activeElement, target.querySelector("input[placeholder=withFocus]") ); // Pressing 'Shift + Tab' - event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab", shiftKey: true }, - { fast: true } - ); + event = await triggerEvent(document.activeElement, null, "keydown", { + key: "Tab", + shiftKey: true, + }); assert.strictEqual(event.defaultPrevented, true); - await nextTick(); assert.strictEqual( document.activeElement, target.querySelector("input[placeholder=withFocus]") @@ -177,43 +166,29 @@ QUnit.test("UI active element: trap focus - default focus with autofocus", async ); // Pressing 'Tab' - let event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab" }, - { fast: true } - ); + let event = await triggerEvent(document.activeElement, null, "keydown", { key: "Tab" }); assert.strictEqual(event.defaultPrevented, true); - await nextTick(); assert.strictEqual( document.activeElement, target.querySelector("input[placeholder=withoutFocus]") ); // Pressing 'Shift + Tab' - event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab", shiftKey: true }, - { fast: true } - ); + event = await triggerEvent(document.activeElement, null, "keydown", { + key: "Tab", + shiftKey: true, + }); assert.strictEqual(event.defaultPrevented, true); - await nextTick(); assert.strictEqual( document.activeElement, target.querySelector("input[placeholder=withAutoFocus]") ); // Pressing 'Shift + Tab' (default) - event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab", shiftKey: true }, - { fast: true } - ); + event = await triggerEvent(document.activeElement, null, "keydown", { + key: "Tab", + shiftKey: true, + }); assert.strictEqual(event.defaultPrevented, false); }); @@ -244,26 +219,15 @@ QUnit.test("UI active element: trap focus - no focus element", async (assert) => "when there is not other element, the focus is on the UI active element itself" ); // Pressing 'Tab' - let event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab" }, - { fast: true } - ); + let event = await triggerEvent(document.activeElement, null, "keydown", { key: "Tab" }); assert.strictEqual(event.defaultPrevented, true); - await nextTick(); assert.strictEqual(document.activeElement, target.querySelector("div[id=idActiveElement]")); // Pressing 'Shift + Tab' - event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Tab", shiftKey: true }, - { fast: true } - ); + event = await triggerEvent(document.activeElement, null, "keydown", { + key: "Tab", + shiftKey: true, + }); assert.strictEqual(event.defaultPrevented, true); - await nextTick(); assert.strictEqual(document.activeElement, target.querySelector("div[id=idActiveElement]")); }); diff --git a/addons/web/static/tests/core/utils/draggable_tests.js b/addons/web/static/tests/core/utils/draggable_tests.js index 0362daf170a..4622acaf865 100644 --- a/addons/web/static/tests/core/utils/draggable_tests.js +++ b/addons/web/static/tests/core/utils/draggable_tests.js @@ -41,11 +41,6 @@ QUnit.module("Draggable", ({ beforeEach }) => { await mountListAndAssert(() => { useDraggable({}); }, true); - await mountListAndAssert(() => { - useDraggable({ - ref: useRef("root"), - }); - }, true); await mountListAndAssert(() => { useDraggable({ elements: ".item", @@ -53,6 +48,11 @@ QUnit.module("Draggable", ({ beforeEach }) => { }, true); // Correct params + await mountListAndAssert(() => { + useDraggable({ + ref: useRef("root"), + }); + }, false); await mountListAndAssert(() => { useDraggable({ ref: {}, diff --git a/addons/web/static/tests/core/utils/sortable_tests.js b/addons/web/static/tests/core/utils/sortable_tests.js index 874d4d72c28..a1acc6fa7e6 100644 --- a/addons/web/static/tests/core/utils/sortable_tests.js +++ b/addons/web/static/tests/core/utils/sortable_tests.js @@ -48,11 +48,6 @@ QUnit.module("Draggable", ({ beforeEach }) => { await mountListAndAssert(() => { useSortable({}); }, true); - await mountListAndAssert(() => { - useSortable({ - ref: useRef("root"), - }); - }, true); await mountListAndAssert(() => { useSortable({ elements: ".item", @@ -66,6 +61,11 @@ QUnit.module("Draggable", ({ beforeEach }) => { }, true); // Correct params + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + }); + }, false); await mountListAndAssert(() => { useSortable({ ref: {}, @@ -435,11 +435,17 @@ QUnit.module("Draggable", ({ beforeEach }) => { // Move the element from only 5 pixels const listItem = target.querySelector(".item:first-child"); - await dragAndDrop(listItem, listItem, { x: listItem.getBoundingClientRect().width/2, y: listItem.getBoundingClientRect().height/2 + 5 }); + await dragAndDrop(listItem, listItem, { + x: listItem.getBoundingClientRect().width / 2, + y: listItem.getBoundingClientRect().height / 2 + 5, + }); assert.verifySteps([], "No drag sequence should have been initiated"); // Move the element from more than 10 pixels - await dragAndDrop(".item:first-child", ".item:first-child", { x: listItem.getBoundingClientRect().width/2 + 10, y: listItem.getBoundingClientRect().height/2 + 10 }); + await dragAndDrop(".item:first-child", ".item:first-child", { + x: listItem.getBoundingClientRect().width / 2 + 10, + y: listItem.getBoundingClientRect().height / 2 + 10, + }); assert.verifySteps( ["Initiation of the drag sequence"], "A drag sequence should have been initiated" diff --git a/addons/web/static/tests/helpers/utils.js b/addons/web/static/tests/helpers/utils.js index 84ee4e3a048..6d091e15605 100644 --- a/addons/web/static/tests/helpers/utils.js +++ b/addons/web/static/tests/helpers/utils.js @@ -11,6 +11,18 @@ import { registerCleanup } from "./cleanup"; import { App, onMounted, onPatched, useComponent } from "@odoo/owl"; +/** + * @typedef {keyof HTMLElementEventMap | keyof WindowEventMap} EventType + * + * @typedef {Side | `${Side}-${Side}` | { x?: number, y?: number }} Position + * + * @typedef {"bottom" | "left" | "right" | "top"} Side + * + * @typedef TriggerEventOptions + * @property {boolean} [skipVisibilityCheck=false] + * @property {boolean} [sync=false] + */ + /** * Patch the native Date object * @@ -168,155 +180,218 @@ export function findElement(el, selector) { return target; } -function keyboardEventBubble(args) { - return Object.assign({}, args, { bubbles: true, keyCode: args.which, cancelable: true }); -} +//----------------------------------------------------------------------------- +// Event init attributes mappers +//----------------------------------------------------------------------------- -function pointerEventMapping(args) { - return { - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - pointerType: "mouse", - ...args, - bubbles: true, - cancelable: true, - view: window, - }; -} +/** @param {EventInit} [args] */ +const mapBubblingEvent = (args) => ({ ...args, bubbles: true }); -function mouseEventMapping(args) { - return { - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - ...args, - bubbles: true, - cancelable: true, - view: window, - }; -} +/** @param {EventInit} [args] */ +const mapNonBubblingEvent = (args) => ({ ...args, bubbles: false }); -function mouseEventNoBubble(args) { - return { - clientX: args ? args.pageX : undefined, - clientY: args ? args.pageY : undefined, - ...args, - bubbles: false, - cancelable: false, - view: window, - }; -} +/** @param {EventInit} [args={}] */ +const mapBubblingPointerEvent = (args = {}) => ({ + clientX: args.pageX, + clientY: args.pageY, + ...args, + bubbles: true, + cancelable: true, + view: window, +}); -function touchEventMapping(args) { - return { - ...args, - cancelable: true, - bubbles: true, - composed: true, - view: window, - rotation: 0.0, - zoom: 1.0, - touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined, - }; -} +/** @param {EventInit} [args] */ +const mapNonBubblingPointerEvent = (args) => ({ + ...mapBubblingPointerEvent(args), + bubbles: false, + cancelable: false, +}); -function touchEventCancelMapping(args) { - return { - ...touchEventMapping(args), - cancelable: false, - }; -} +/** @param {EventInit} [args={}] */ +const mapCancelableTouchEvent = (args = {}) => ({ + ...args, + bubbles: true, + cancelable: true, + composed: true, + rotation: 0.0, + touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined, + view: window, + zoom: 1.0, +}); -function noBubble(args) { - return Object.assign({}, args, { bubbles: false }); -} +/** @param {EventInit} [args] */ +const mapNonCancelableTouchEvent = (args) => ({ + ...mapCancelableTouchEvent(args), + cancelable: false, +}); -function onlyBubble(args) { - return Object.assign({}, args, { bubbles: true }); -} +/** @param {EventInit} [args] */ +const mapKeyboardEvent = (args) => ({ + ...args, + bubbles: true, + cancelable: true, + keyCode: args.which, +}); -// TriggerEvent constructor/args processor mapping -const EVENT_TYPES = { - auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, - click: { constructor: MouseEvent, processParameters: mouseEventMapping }, - contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping }, - dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble }, - mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble }, - mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping }, - mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping }, - pointerdown: { constructor: PointerEvent, processParameters: pointerEventMapping }, - pointerup: { constructor: PointerEvent, processParameters: pointerEventMapping }, - focus: { constructor: FocusEvent, processParameters: noBubble }, - focusin: { constructor: FocusEvent, processParameters: onlyBubble }, - blur: { constructor: FocusEvent, processParameters: noBubble }, - cut: { constructor: ClipboardEvent, processParameters: onlyBubble }, - copy: { constructor: ClipboardEvent, processParameters: onlyBubble }, - paste: { constructor: ClipboardEvent, processParameters: onlyBubble }, - keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, - keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, - keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble }, - drag: { constructor: DragEvent, processParameters: onlyBubble }, - dragend: { constructor: DragEvent, processParameters: onlyBubble }, - dragenter: { constructor: DragEvent, processParameters: onlyBubble }, - dragstart: { constructor: DragEvent, processParameters: onlyBubble }, - dragleave: { constructor: DragEvent, processParameters: onlyBubble }, - dragover: { constructor: DragEvent, processParameters: onlyBubble }, - drop: { constructor: DragEvent, processParameters: onlyBubble }, - input: { constructor: InputEvent, processParameters: onlyBubble }, - compositionstart: { constructor: CompositionEvent, processParameters: onlyBubble }, - compositionend: { constructor: CompositionEvent, processParameters: onlyBubble }, +/** + * @template {typeof Event} T + * @param {EventType} eventType + * @returns {[T, (attrs: EventInit) => EventInit]} + */ +const getEventConstructor = (eventType) => { + switch (eventType) { + // Mouse events + case "auxclick": + case "click": + case "contextmenu": + case "dblclick": + case "mousedown": + case "mouseup": + case "mousemove": + case "mouseover": + case "mouseout": { + return [MouseEvent, mapBubblingPointerEvent]; + } + case "mouseenter": + case "mouseleave": { + return [MouseEvent, mapNonBubblingPointerEvent]; + } + // Pointer events + case "pointerdown": + case "pointerup": + case "pointermove": + case "pointerover": + case "pointerout": { + return [PointerEvent, mapBubblingPointerEvent]; + } + case "pointerenter": + case "pointerleave": { + return [PointerEvent, mapNonBubblingPointerEvent]; + } + // Focus events + case "focusin": { + return [FocusEvent, mapBubblingEvent]; + } + case "focus": + case "blur": { + return [FocusEvent, mapNonBubblingEvent]; + } + // Clipboard events + case "cut": + case "copy": + case "paste": { + return [ClipboardEvent, mapBubblingEvent]; + } + // Keyboard events + case "keydown": + case "keypress": + case "keyup": { + return [KeyboardEvent, mapKeyboardEvent]; + } + // Drag events + case "drag": + case "dragend": + case "dragenter": + case "dragstart": + case "dragleave": + case "dragover": + case "drop": { + return [DragEvent, mapBubblingEvent]; + } + // Input events + case "input": { + return [InputEvent, mapBubblingEvent]; + } + // Composition events + case "compositionstart": + case "compositionend": { + return [CompositionEvent, mapBubblingEvent]; + } + // UI events + case "scroll": { + return [UIEvent, mapNonBubblingEvent]; + } + // Touch events + case "touchstart": + case "touchend": + case "touchmove": { + return [TouchEvent, mapCancelableTouchEvent]; + } + case "touchcancel": { + return [TouchEvent, mapNonCancelableTouchEvent]; + } + // Default: base Event constructor + default: { + return [Event, mapBubblingEvent]; + } + } }; -if (typeof TouchEvent === "function") { - Object.assign(EVENT_TYPES, { - touchstart: { constructor: TouchEvent, processParameters: touchEventMapping }, - touchend: { constructor: TouchEvent, processParameters: touchEventMapping }, - touchmove: { constructor: TouchEvent, processParameters: touchEventMapping }, - touchcancel: { constructor: TouchEvent, processParameters: touchEventCancelMapping }, - }); -} - -function _makeEvent(eventType, eventAttrs) { - let event; - if (eventType in EVENT_TYPES) { - const { constructor, processParameters } = EVENT_TYPES[eventType]; - event = new constructor(eventType, processParameters(eventAttrs)); - } else { - event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true })); - } - return event; -} - -export function triggerEvent(el, selector, eventType, eventAttrs = {}, options = {}) { - const event = _makeEvent(eventType, eventAttrs); +/** + * @template {EventType} T + * @param {Element} el + * @param {string | null | undefined | false} selector + * @param {T} eventType + * @param {EventInit} [eventInit] + * @param {TriggerEventOptions} [options={}] + * @returns {GlobalEventHandlersEventMap[T] | Promise} + */ +export function triggerEvent(el, selector, eventType, eventInit, options = {}) { + const errors = []; const target = findElement(el, selector); + + // Error handling + if (typeof eventType !== "string") { + errors.push("event type must be a string"); + } if (!target) { - throw new Error(`Can't find a target to trigger ${eventType} event`); + errors.push("cannot find target"); + } else if (!options.skipVisibilityCheck && !isVisible(target)) { + errors.push("target is not visible"); } - if (!options.skipVisibilityCheck) { - if (!isVisible(target)) { - throw new Error(`Called triggerEvent ${eventType} on invisible target`); - } + if (errors.length) { + throw new Error( + `Cannot trigger event${eventType ? ` "${eventType}"` : ""}${ + selector ? ` (with selector "${selector}")` : "" + }: ${errors.join(" and ")}` + ); } + + // Actual dispatch + const [Constructor, processParams] = getEventConstructor(eventType); + const event = new Constructor(eventType, processParams(eventInit)); target.dispatchEvent(event); - if (!options.fast) { + + if (QUnit.config.debug) { + const group = `%c[${event.type.toUpperCase()}]`; + console.groupCollapsed(group, "color: #b52c9b"); + console.log(target, event); + console.groupEnd(group, "color: #b52c9b"); + } + + if (options.sync) { + return event; + } else { return nextTick().then(() => event); } - return event; } -export async function triggerEvents(el, querySelector, events, options) { - for (let e = 0; e < events.length; e++) { - if (Array.isArray(events[e])) { - triggerEvent(el, querySelector, events[e][0], events[e][1], options); - } else { - triggerEvent(el, querySelector, events[e], {}, options); - } +/** + * @param {Element} el + * @param {string | null | undefined | false} selector + * @param {(EventType | [EventType, EventInit])[]} [eventDefs] + * @param {TriggerEventOptions} [options={}] + */ +export function triggerEvents(el, selector, eventDefs, options = {}) { + const events = [...eventDefs].map((eventDef) => { + const [eventType, eventInit] = Array.isArray(eventDef) ? eventDef : [eventDef, {}]; + return triggerEvent(el, selector, eventType, eventInit, options); + }); + if (options.sync) { + return events; + } else { + return nextTick().then(() => events); } - await nextTick(); } /** @@ -328,9 +403,9 @@ export async function triggerEvents(el, querySelector, events, options) { * * @param {Element} target target of the scroll event * @param {Object} coordinates - * @param {Number} coordinates[left] coordinates to scroll horizontally - * @param {Number} coordinates[top] coordinates to scroll vertically - * @param {Boolean} canPropagate states if the scroll can propagate to a scrollable parent + * @param {number} coordinates.left coordinates to scroll horizontally + * @param {number} coordinates.top coordinates to scroll vertically + * @param {boolean} canPropagate states if the scroll can propagate to a scrollable parent */ export async function triggerScroll( target, @@ -362,15 +437,14 @@ export async function triggerScroll( } }); target.scrollTo(scrollCoordinates); - target.dispatchEvent(new UIEvent("scroll")); - await nextTick(); + await triggerEvent(target, null, "scroll"); if (!canPropagate || !Object.entries(coordinates).length) { return; } } target.parentElement ? triggerScroll(target.parentElement, coordinates) - : window.dispatchEvent(new UIEvent("scroll")); + : triggerEvent(window, null, "scroll"); await nextTick(); } @@ -552,11 +626,16 @@ export async function triggerHotkey(hotkey, addOverlayModParts = false, eventAtt eventAttrs.bubbles = true; } - const keydownEvent = new KeyboardEvent("keydown", eventAttrs); - const keyupEvent = new KeyboardEvent("keyup", eventAttrs); - document.activeElement.dispatchEvent(keydownEvent); - document.activeElement.dispatchEvent(keyupEvent); - await nextTick(); + const [keydownEvent, keyupEvent] = await triggerEvents( + document.activeElement, + null, + [ + ["keydown", eventAttrs], + ["keyup", eventAttrs], + ], + { skipVisibilityCheck: true } + ); + return { keydownEvent, keyupEvent }; } @@ -757,7 +836,7 @@ function getDifferentParents(n1, n2) { * * @param {Element | string} from * @param {Element | string} to - * @param {"top" | "bottom" | "left" | "right"} [position] + * @param {Position} [position] */ export async function dragAndDrop(from, to, position) { const { drop } = await drag(from); @@ -799,13 +878,13 @@ export async function drag(from) { /** * @param {Element | string} [to] - * @param {"top" | "bottom" | "left" | "right"} [position] + * @param {Position} [position] */ const drop = assertIsDragging(async function drop(to, position) { if (to) { await moveTo(to, position); } - await triggerEvent(target || source, null, "mouseup", targetPosition); + await triggerEvent(target || source, null, "pointerup", targetPosition); }, true); /** @@ -815,36 +894,37 @@ export async function drag(from) { selector instanceof Element ? selector : fixture.querySelector(selector); /** - * @param {"top" | "bottom" | "left" | "right" | { x?: number, y?: number }} [position] + * @param {Position} [position] */ const getTargetPosition = (position) => { const tRect = target.getBoundingClientRect(); - const tPos = { clientX: tRect.x, clientY: tRect.y }; + const tPos = { + clientX: Math.floor(tRect.x), + clientY: Math.floor(tRect.y), + }; if (position && typeof position === "object") { // x and y coordinates start from the element's initial coordinates tPos.clientX += position.x || 0; tPos.clientY += position.y || 0; } else { - // relative positionning starts from the center of the element - tPos.clientX += tRect.width / 2; - tPos.clientY += tRect.height / 2; - switch (position) { - case "top": { - tPos.clientY = tRect.y - 1; - break; - } - case "bottom": { - tPos.clientY = tRect.y + tRect.height + 1; - break; - } - case "left": { - tPos.clientX = tRect.x - 1; - break; - } - case "right": { - tPos.clientX = tRect.x + tRect.width + 1; - break; - } + const positions = typeof position === "string" ? position.split("-") : []; + + // X position + if (positions.includes("left")) { + tPos.clientX -= 1; + } else if (positions.includes("right")) { + tPos.clientX += Math.ceil(tRect.width) + 1; + } else { + tPos.clientX += Math.floor(tRect.width / 2); + } + + // Y position + if (positions.includes("top")) { + tPos.clientY -= 1; + } else if (positions.includes("bottom")) { + tPos.clientY += Math.ceil(tRect.height) + 1; + } else { + tPos.clientY += Math.floor(tRect.height / 2); } } return tPos; @@ -852,7 +932,7 @@ export async function drag(from) { /** * @param {Element | string} [to] - * @param {"top" | "bottom" | "left" | "right"} [position] + * @param {Position} [position] */ const moveTo = assertIsDragging(async function moveTo(to, position) { target = getEl(to); @@ -864,12 +944,12 @@ export async function drag(from) { targetPosition = getTargetPosition(position); // Move, enter and drop the element on the target - await triggerEvent(window, null, "mousemove", targetPosition); + await triggerEvent(source, null, "pointermove", targetPosition); - // "mouseenter" is fired on every parent of `target` that do not contain + // "pointerenter" is fired on every parent of `target` that do not contain // `from` (typically: different parent lists). for (const parent of getDifferentParents(source, target)) { - triggerEvent(parent, null, "mouseenter", targetPosition); + triggerEvent(parent, null, "pointerenter", targetPosition); } await nextTick(); @@ -886,8 +966,8 @@ export async function drag(from) { let target; let targetPosition; - // Mouse down on main target - await triggerEvent(source, null, "mousedown", { + // Pointer down on main target + await triggerEvent(source, null, "pointerdown", { clientX: sourceRect.x + sourceRect.width / 2, clientY: sourceRect.y + sourceRect.height / 2, }); diff --git a/addons/web/static/tests/views/fields/one2many_field_tests.js b/addons/web/static/tests/views/fields/one2many_field_tests.js index 5e34ade8946..32a05b5be07 100644 --- a/addons/web/static/tests/views/fields/one2many_field_tests.js +++ b/addons/web/static/tests/views/fields/one2many_field_tests.js @@ -11914,7 +11914,7 @@ QUnit.module("Fields", (hooks) => { null, "keydown", { key: "Tab" }, - { fast: true } + { sync: true } ); assert.strictEqual(getNextTabableElement(target), nextInput); assert.ok(!event.defaultPrevented); @@ -11992,7 +11992,7 @@ QUnit.module("Fields", (hooks) => { null, "keydown", { key: "Tab" }, - { fast: true } + { sync: true } ); assert.strictEqual(getNextTabableElement(target), nextInput); assert.ok(!event.defaultPrevented); @@ -12155,7 +12155,7 @@ QUnit.module("Fields", (hooks) => { null, "keydown", { key: "Tab" }, - { fast: true } + { sync: true } ); assert.strictEqual(getNextTabableElement(target), firstCreateActionLink); assert.ok(!event.defaultPrevented); @@ -12193,7 +12193,7 @@ QUnit.module("Fields", (hooks) => { null, "keydown", { key: "Tab" }, - { fast: true } + { sync: true } ); assert.strictEqual(getNextTabableElement(target), secondCreateActionLink); assert.ok(!event.defaultPrevented); @@ -12207,7 +12207,7 @@ QUnit.module("Fields", (hooks) => { null, "keydown", { key: "Tab" }, - { fast: true } + { sync: true } ); assert.strictEqual(getNextTabableElement(target), nextInput); assert.ok(!event.defaultPrevented); diff --git a/addons/web/static/tests/views/fields/signature_field_tests.js b/addons/web/static/tests/views/fields/signature_field_tests.js index 4e74ef40190..d2891c4d3a4 100644 --- a/addons/web/static/tests/views/fields/signature_field_tests.js +++ b/addons/web/static/tests/views/fields/signature_field_tests.js @@ -1,11 +1,11 @@ /** @odoo-module **/ import { click, - dragAndDrop, getFixture, makeDeferred, nextTick, patchWithCleanup, + triggerEvents, } from "@web/../tests/helpers/utils"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; import { NameAndSignature } from "@web/core/signature/name_and_signature"; @@ -55,7 +55,7 @@ QUnit.module("Fields", (hooks) => { QUnit.module("Signature Field"); - QUnit.test("signature can be drawned", async function (assert) { + QUnit.test("signature can be drawn", async function (assert) { await makeView({ type: "form", resModel: "partner", @@ -85,9 +85,16 @@ QUnit.module("Fields", (hooks) => { // Use a drag&drop simulation to draw a signature const def = makeDeferred(); - const $jSignatureEl = $(target.querySelector(".modal .o_web_sign_signature")); - $jSignatureEl.on("change", def.resolve); - await dragAndDrop("canvas.jSignature", "canvas.jSignature"); + const jSignatureEl = target.querySelector(".modal .o_web_sign_signature"); + $(jSignatureEl).on("change", def.resolve); + const { x, y, width, height } = target + .querySelector("canvas.jSignature") + .getBoundingClientRect(); + await triggerEvents(jSignatureEl, "canvas.jSignature", [ + ["mousedown", { clientX: x + 1, clientY: y + 1 }], + ["mousemove", { clientX: x + width - 1, clientY: height + height - 1 }], + ["mouseup", { clientX: x + width - 1, clientY: height + height - 1 }], + ]); await def; // makes sure the signature stroke is taken into account by jSignature await nextTick(); // await owl rendering assert.containsOnce(target, ".modal .btn.btn-primary:not([disabled])"); diff --git a/addons/web/static/tests/views/kanban/kanban_view_tests.js b/addons/web/static/tests/views/kanban/kanban_view_tests.js index b5ac4b2ec32..3259a96c8f3 100644 --- a/addons/web/static/tests/views/kanban/kanban_view_tests.js +++ b/addons/web/static/tests/views/kanban/kanban_view_tests.js @@ -10764,7 +10764,7 @@ QUnit.module("Views", (hooks) => { nameInput.focus(); const addButton = target.querySelector(".o_kanban_add"); - const event = triggerEvent(nameInput, null, "keydown", { key: "Tab" }, { fast: true }); + const event = triggerEvent(nameInput, null, "keydown", { key: "Tab" }, { sync: true }); assert.strictEqual(getNextTabableElement(target), addButton); assert.ok(!event.defaultPrevented); addButton.focus(); @@ -13102,7 +13102,7 @@ QUnit.module("Views", (hooks) => { assert.strictEqual(content.scrollLeft, 0); // Cancel drag: click outside - await triggerEvent(content, ".o_kanban_renderer", "mousedown"); + await triggerEvent(content, ".o_kanban_renderer", "pointerdown"); assert.containsNone(target, ".o_kanban_record.o_dragged"); }); diff --git a/addons/web/static/tests/views/list_view_tests.js b/addons/web/static/tests/views/list_view_tests.js index 02f3c24ddfc..eeb98f53114 100644 --- a/addons/web/static/tests/views/list_view_tests.js +++ b/addons/web/static/tests/views/list_view_tests.js @@ -9693,7 +9693,7 @@ QUnit.module("Views", (hooks) => { null, "keydown", { key: "Tab" }, - { fast: true } + { sync: true } ); assert.strictEqual(getNextTabableElement(target), nextInput); assert.ok(!event.defaultPrevented); @@ -12013,7 +12013,7 @@ QUnit.module("Views", (hooks) => { ); assert.strictEqual(document.activeElement, checkbox); assert.ok(!checkbox.checked); - let event = triggerEvent(checkbox, null, "keydown", { key: "Space" }, { fast: true }); + let event = triggerEvent(checkbox, null, "keydown", { key: "Space" }, { sync: true }); assert.ok(!event.defaultPrevented); checkbox.checked = true; await nextTick(); @@ -12032,7 +12032,7 @@ QUnit.module("Views", (hooks) => { checkbox = target.querySelector(".o_data_row:nth-child(4) .o_list_record_selector input"); assert.strictEqual(document.activeElement, checkbox); assert.ok(!checkbox.checked); - event = triggerEvent(checkbox, null, "keydown", { key: "Space" }, { fast: true }); + event = triggerEvent(checkbox, null, "keydown", { key: "Space" }, { sync: true }); assert.ok(!event.defaultPrevented); checkbox.checked = true; await nextTick(); @@ -14918,13 +14918,8 @@ QUnit.module("Views", (hooks) => { target.querySelectorAll(".o_group_field_row_add a")[1] ); - const event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Enter" }, - { fast: true } - ); // default Enter on a A tag + // default Enter on a A tag + const event = await triggerEvent(document.activeElement, null, "keydown", { key: "Enter" }); assert.ok(!event.defaultPrevented); await click(target.querySelectorAll(".o_group_field_row_add a")[1]); @@ -15256,13 +15251,9 @@ QUnit.module("Views", (hooks) => { document.activeElement, target.querySelector(".o_group_field_row_add a") ); - const event = triggerEvent( - document.activeElement, - null, - "keydown", - { key: "Enter" }, - { fast: true } - ); + const event = await triggerEvent(document.activeElement, null, "keydown", { + key: "Enter", + }); assert.ok(!event.defaultPrevented); // Simulate "enter" keydown await click(target, ".o_group_field_row_add a"); @@ -16112,17 +16103,16 @@ QUnit.module("Views", (hooks) => { `, }); - const originalWidths = [...target.querySelectorAll(".o_list_table th")].map( - (th) => th.offsetWidth + const originalWidths = [...target.querySelectorAll(".o_list_table th")].map((th) => + Math.floor(th.offsetWidth) ); const th = target.querySelector("th:nth-child(2)"); const resizeHandle = th.querySelector(".o_resize"); - const expectedWidth = - Math.round(originalWidths[1] / 2) + Math.round(resizeHandle.offsetWidth / 2); + const expectedWidth = Math.floor(originalWidths[1] / 2 + resizeHandle.offsetWidth / 2); await dragAndDrop(resizeHandle, th); - const finalWidths = [...target.querySelectorAll(".o_list_table th")].map( - (th) => th.offsetWidth + const finalWidths = [...target.querySelectorAll(".o_list_table th")].map((th) => + Math.floor(th.offsetWidth) ); assert.strictEqual(finalWidths[0], originalWidths[0]); assert.ok(Math.abs(finalWidths[1] - expectedWidth) <= 1); // rounding @@ -16172,6 +16162,10 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("resize column with several x2many lists in form group", async function (assert) { + /** @param {number} index */ + const getTableWidth = (index) => + Math.floor(target.querySelectorAll(".o_field_x2many_list table")[index].offsetWidth); + serverData.models.bar.fields.text = { string: "Text field", type: "char" }; serverData.models.foo.records[0].o2m = [1, 2]; @@ -16201,37 +16195,24 @@ QUnit.module("Views", (hooks) => { const th = target.querySelector("th"); const resizeHandle = th.querySelector(".o_resize"); - const firstTableInitialWidth = target - .querySelectorAll(".o_field_x2many_list table")[0] - .getBoundingClientRect().width; - const secondTableInititalWidth = target - .querySelectorAll(".o_field_x2many_list table")[1] - .getBoundingClientRect().width; + const initialWidths = [getTableWidth(0), getTableWidth(1)]; assert.strictEqual( - Math.floor(firstTableInitialWidth), - Math.floor(secondTableInititalWidth), + initialWidths[0], + initialWidths[1], "both table columns have same width" ); - await dragAndDrop(resizeHandle, target.getElementsByTagName("th")[1], { - position: "right", - }); + await dragAndDrop(resizeHandle, target.getElementsByTagName("th")[1], "right"); assert.notEqual( - Math.floor(firstTableInitialWidth), - Math.floor( - target.querySelectorAll(".o_field_x2many_list table")[0].getBoundingClientRect() - .width - ), + initialWidths[0], + getTableWidth(0), "first o2m table is resized and width of table has changed" ); assert.strictEqual( - Math.floor(secondTableInititalWidth), - Math.floor( - target.querySelectorAll(".o_field_x2many_list table")[1].getBoundingClientRect() - .width - ), + initialWidths[1], + getTableWidth(1), "second o2m table should not be impacted on first o2m in group resized" ); }); diff --git a/addons/web_tour/static/src/tour_service/tour_utils.js b/addons/web_tour/static/src/tour_service/tour_utils.js index 4cff6fccf46..58e247dac12 100644 --- a/addons/web_tour/static/src/tour_service/tour_utils.js +++ b/addons/web_tour/static/src/tour_service/tour_utils.js @@ -2,8 +2,8 @@ import { markup } from "@odoo/owl"; import { _t } from "@web/core/l10n/translation"; -import { _legacyIsVisible } from "@web/core/utils/ui"; import { utils } from "@web/core/ui/ui_service"; +import { _legacyIsVisible } from "@web/core/utils/ui"; /** * @typedef {string | (actions: RunningTourActionHelper) => void | Promise} RunCommand @@ -124,7 +124,7 @@ export function getConsumeEventType(element, runCommand) { (/^drag_and_drop_native/.test(runCommand) && classList.contains("o_draggable")) || element.closest(".o_draggable") ) { - return "mousedown"; + return "pointerdown"; } } @@ -132,6 +132,25 @@ export function getConsumeEventType(element, runCommand) { return "click"; } +/** + * ! This function is a copy-paste of its namesake in web/static/tests/helpers/utils.js + * TODO: Unify utils for tests and tours since they're doing the exact same thing + * @param {Node} n1 + * @param {Node} n2 + * @returns {Node[]} + */ +function getDifferentParents(n1, n2) { + const parents = [n2]; + while (parents[0].parentNode) { + const parent = parents[0].parentNode; + if (parent.contains(n1)) { + break; + } + parents.unshift(parent); + } + return parents; +} + /** * @param {HTMLElement} element * @returns {HTMLElement | null} @@ -347,37 +366,60 @@ export class RunningTourActionHelper { $.Event("mouseup", { which: 1, pageX: toCenter.left, pageY: toCenter.top }) ); } - _drag_and_drop(element, to) { - const elementCenter = this._calculateCenter($(element)); - const toCenter = this._calculateCenter($(to)); - element.dispatchEvent(new Event("mouseenter")); - element.dispatchEvent( - new MouseEvent("mousedown", { + /** + * ! This function is a reduced version of "drag" in web/static/tests/helpers/utils.js + * TODO: Unify utils for tests and tours since they're doing the exact same thing + * @param {HTMLElement} source + * @param {HTMLElement} target + */ + _drag_and_drop(source, target) { + const sourceRect = source.getBoundingClientRect(); + const sourcePosition = { + clientX: sourceRect.x + sourceRect.width / 2, + clientY: sourceRect.y + sourceRect.height / 2, + }; + + const targetRect = target.getBoundingClientRect(); + const targetPosition = { + clientX: targetRect.x + targetRect.width / 2, + clientY: targetRect.y + targetRect.height / 2, + }; + + source.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0, which: 1, - clientX: elementCenter.left, - clientY: elementCenter.top, + ...sourcePosition, }) ); - element.dispatchEvent( - new MouseEvent("mousemove", { + + source.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, cancelable: true, - clientX: toCenter.left, - clientY: toCenter.top, + ...targetPosition, }) ); - to.dispatchEvent( - new Event("mouseenter", { clientX: toCenter.left, clientY: toCenter.top }) - ); - element.dispatchEvent( - new Event("mouseup", { + + for (const parent of getDifferentParents(source, target)) { + parent.dispatchEvent( + new PointerEvent("pointerenter", { + bubbles: false, + cancelable: false, + ...targetPosition, + }) + ); + } + + target.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, cancelable: true, button: 0, which: 1, + ...targetPosition, }) ); }