[IMP] web: Support pointer events in draggable
This commit: - adds a "delay" parameter to the `draggable_hook_builder`, waiting for that delay on pointer down events before triggering the drag sequence (making it possible to use "long mousedown" events); - changes the event detection in `draggable_hook_builder` to listen for pointer events instead of mouse events, effectively supporting the drag and drop feature on touch devices; - adapts the test helpers (with some refactors to `triggerEvent` and other utility functions) to make them use pointer events instead for drags and drops; - adapts the tour utils to use a tone-downed version of the refactored test utils `dragAndDrop` function; - also changes events in list view which does not yet uses the draggable hook, but should still work with the same helpers in tests. Part-of: odoo/odoo#116005
This commit is contained in:
committed by
Julien Carion (juca)
parent
b053277ee7
commit
23e0ac2a1a
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<typeof makeCleanupManager>} CleanupManager
|
||||
*
|
||||
* @typedef DOMHelpers
|
||||
* @property {(el: HTMLElement, ...classNames: string[]) => void} addClass
|
||||
* @property {(el: EventTarget, event: string, callback: (...args: any[]) => any, options?: boolean | Record<string, boolean>) => void} addListener
|
||||
* @property {(el: HTMLElement, style: Record<string, string | number>) => 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<typeof makeDOMHelpers>} 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<string, boolean>} [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;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.o_draggable {
|
||||
-webkit-touch-callout: none; // for iOS
|
||||
}
|
||||
|
||||
.o_dragged {
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<th t-if="column.type === 'field'"
|
||||
t-att-data-name="column.name"
|
||||
t-att-class="getColumnClass(column) + ' opacity-trigger-hover'"
|
||||
t-on-mouseup="onColumnTitleMouseUp"
|
||||
t-on-pointerup="onColumnTitleMouseUp"
|
||||
t-on-click="() => this.onClickSortColumn(column)"
|
||||
t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)"
|
||||
t-att-data-tooltip-template="isDebugMode ? 'web.FieldTooltip' : false"
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<span t-if="!isEmpty"
|
||||
class="o_resize position-absolute top-0 end-0 bottom-0 ps-1 bg-black-25 opacity-0 opacity-50-hover z-index-1"
|
||||
t-on-mousedown.stop.prevent="onStartResize"/>
|
||||
t-on-pointerdown.stop.prevent="onStartResize"/>
|
||||
</t>
|
||||
</th>
|
||||
<th t-else="" t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev)" t-att-class="{o_list_button: column.type === 'button_group'}"/>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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]"));
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<GlobalEventHandlersEventMap[T]>}
|
||||
*/
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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])");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
</tree>`,
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<void>} 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user