diff --git a/addons/web/static/src/core/utils/sortable.js b/addons/web/static/src/core/utils/sortable.js new file mode 100644 index 00000000000..e3c78a3f694 --- /dev/null +++ b/addons/web/static/src/core/utils/sortable.js @@ -0,0 +1,552 @@ +/** @odoo-module **/ + +import { clamp } from "@web/core/utils/numbers"; +import { debounce } from "@web/core/utils/timing"; + +const { useEffect, useEnv, useExternalListener, onWillUnmount } = owl; + +/** + * @typedef SortableParams + * + * MANDATORY + * + * @property {{ el: HTMLElement | null }} ref + * @property {string} elements defines sortable elements + * + * OPTIONAL + * + * @property {boolean | () => boolean} [enable] whether the sortable system should + * be enabled. + * @property {string | () => string} [groups] defines parent groups of sortable + * elements. This allows to add `onGroupEnter` and `onGroupLeave` callbacks to + * work on group elements during the dragging sequence. + * @property {string | () => string} [handle] additional selector for when the dragging + * sequence must be initiated when dragging on a certain part of the element. + * @property {boolean | () => boolean} [connectGroups] whether elements can be dragged + * accross different parent groups. Note that it requires a `groups` param to work. + * @property {string | () => string} [cursor] cursor style during the dragging sequence. + * + * HANDLERS (also optional) + * + * @property {(group: HTMLElement | null, element: HTMLElement) => any} [onStart] + * called when a dragging sequence is initiated. + * @property {(element: HTMLElement) => any} [onElementEnter] called when the cursor + * enters another sortable element. + * @property {(element: HTMLElement) => any} [onElementLeave] called when the cursor + * leaves another sortable element. + * @property {(group: HTMLElement) => any} [onGroupEnter] (if a `groups` is specified): + * will be called when the cursor enters another group element. + * @property {(group: HTMLElement) => any} [onGroupLeave] (if a `groups` is specified): + * will be called when the cursor leaves another group element. + * @property {(group: HTMLElement | null, element: HTMLElement) => any} [onStop] + * 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 + * callback will be given an object with any useful element regarding the new position + * of the dragged element (@see DropParams ). + */ + +/** + * @typedef DropParams + * @property {HTMLElement} element + * @property {HTMLElement | null} group + * @property {HTMLElement | null} previous + * @property {HTMLElement | null} next + * @property {HTMLElement | null} parent + */ + +const LEFT_CLICK = 0; +const MANDATORY_SORTABLE_PARAMS = ["ref", "elements"]; +const SORTABLE_PARAMS = { + enable: ["boolean", "function"], + ref: ["object"], + elements: ["string"], + groups: ["string", "function"], + handle: ["string", "function"], + connectGroups: ["boolean", "function"], + cursor: ["string"], +}; + +/** + * Cancels the default behavior and propagation of a given event. + * @param {Event} ev + */ +function cancelEvent(ev) { + ev.stopPropagation(); + ev.stopImmediatePropagation(); + ev.preventDefault(); +} + +/** + * @param {SortableParams} params + * @returns {[string, string | boolean][]} + */ +function computeParams(params) { + const computedParams = { enable: true }; + for (const prop in SORTABLE_PARAMS) { + if (prop in params) { + computedParams[prop] = params[prop]; + if (typeof params[prop] === "function") { + computedParams[prop] = computedParams[prop](); + } + } + } + return Object.entries(computedParams); +} + +/** + * Converts a CSS pixel value to a number, removing the 'px' part. + * @param {string} val + * @returns {number} + */ +function cssValueToNumber(val) { + return Number(val.slice(0, -2)); +} + +/** + * Basic error builder for the sortable hook. + * @param {string} reason + * @returns {Error} + */ +function sortableError(reason) { + return new Error(`Unable to use sortable feature: ${reason}.`); +} + +/** + * Sortable feature hook. + * + * This hook needs 2 things to work: + * + * 1) a `ref` object (@see owl.useRef) which will be used as the root element to + * calculate boundaries of dragged elements; + * + * 2) an `elements` selector string or function that will determine which elements + * are sortable in the reference element. + * + * All other parameters are optional and define the constraints of the dragged elements + * (and the appearance of the cursor during a dragging sequence), or the different + * available handlers triggered during the drag sequence. + * @see SortableParams + * + * @param {SortableParams} params + */ +export function useSortable(params) { + const env = useEnv(); + const { ref } = params; + /** @type {(() => any)[]} */ + const cleanups = []; + + // Basic error handling asserting that the parameters are valid. + for (const prop in SORTABLE_PARAMS) { + if (params[prop] && !SORTABLE_PARAMS[prop].includes(typeof params[prop])) { + throw sortableError(`invalid type for property "${prop}" in parameters`); + } else if (!params[prop] && MANDATORY_SORTABLE_PARAMS.includes(prop)) { + throw sortableError(`missing required property "${prop}" in parameters`); + } + } + + /** + * Stores the current element selector. + * @type {string | null} + */ + let groupSelector = null; + /** + * Stores the current group selector (optional). + * @type {string | null} + */ + let elementSelector = null; + /** + * Stores the full selector used to initiate a drag sequence. + * @type {string | null} + */ + let fullSelector = null; + + /** + * Stores the style of the cursor, if defined. + * @type {string | null} + */ + let cursor = null; + /** + * Stores whether the sortable elements can be dragged in different groups. + * @type {boolean} + */ + let connectGroups = false; + /** + * Stores the position and dimensions of the confining element (ref or + * parent). + * @type {DOMRect | null} + */ + let currentContainerRect = null; + + /** + * Stores the current dragged element. + * @type {HTMLElement | null} + */ + let currentElement = null; + /** + * Stores the dimensions and position of the dragged element. + * @type {DOMRect | null} + */ + let currentElementRect = null; + /** + * Stores the group in which the current element originated. + * @type {HTMLElement | null} + */ + let currentGroup = null; + /** + * Stores the ghost element taking place of the actual dragged element. + * @type {HTMLElement | null} + */ + let ghost = null; + + /** + * Stores whether a drag sequence can be initiated. + * This is determined by both the given ref being in the document and the + * `setup` function returning the required params (namely: `elements`). + * @type {boolean} + */ + let enabled = false; + /** + * Stores whether a drag sequence has been initiated. + * @type {boolean} + */ + let started = false; + + /** + * These 2 variables store the initial offset between the initial mousedown + * position and the top-left corner of the dragged element. + */ + /** @type {number} */ + let offsetX = 0; + /** @type {number} */ + let offsetY = 0; + + /** + * 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. + * @param {EventTarget} el + * @param {string} event + * @param {(...args: any[]) => any} callback + * @param {boolean | Record} [options] + */ + const addListener = (el, event, callback, options) => { + el.addEventListener(event, callback, options); + cleanups.push(() => el.removeEventListener(event, callback, options)); + }; + + /** + * Adds style to an element to be cleaned up after the next drag sequence has + * stopped. + * @param {HTMLElement} el + * @param {Record} style + */ + const addStyle = (el, style) => { + const originalStyle = el.getAttribute("style"); + cleanups.push(() => + originalStyle ? el.setAttribute("style", originalStyle) : el.removeAttribute("style") + ); + for (const key in style) { + el.style[key] = style[key]; + } + }; + + /** + * Safely executes a handler from the `params`, so that the drag sequence can + * be interrupted if an error occurs. + * @param {string} callbackName + * @param {...any} args + */ + const execHandler = (callbackName, ...args) => { + if (typeof params[callbackName] === "function") { + try { + params[callbackName](...args); + } catch (err) { + dragStop(true, true); + throw err; + } + } + }; + + /** + * Element "mouseenter" event handler. + * @param {MouseEvent} ev + */ + const onElementMouseenter = (ev) => { + const element = ev.currentTarget; + if (connectGroups || !groupSelector || currentGroup === element.closest(groupSelector)) { + const pos = ghost.compareDocumentPosition(element); + if (pos === 2 /* BEFORE */) { + element.before(ghost); + } else if (pos === 4 /* AFTER */) { + element.after(ghost); + } + } + execHandler("onElementEnter", element); + }; + + /** + * Element "mouseleave" event handler. + * @param {MouseEvent} ev + */ + const onElementMouseleave = (ev) => { + const element = ev.currentTarget; + execHandler("onElementLeave", element); + }; + + /** + * Group "mouseenter" event handler. + * @param {MouseEvent} ev + */ + const onGroupMouseenter = (ev) => { + const group = ev.currentTarget; + group.appendChild(ghost); + execHandler("onGroupEnter", group); + }; + + /** + * Group "mouseleave" event handler. + * @param {MouseEvent} ev + */ + const onGroupMouseleave = (ev) => { + const group = ev.currentTarget; + execHandler("onGroupLeave", group); + }; + + /** + * Window "keydown" event handler. + * @param {KeyboardEvent} ev + */ + const onKeydown = (ev) => { + if (!enabled || !started) { + return; + } + switch (ev.key) { + case "Escape": + case "Tab": { + cancelEvent(ev); + dragStop(true); + } + } + }; + + /** + * Global (= ref) "mousedown" event handler. + * @param {MouseEvent} ev + */ + const onMousedown = (ev) => { + // A drag sequence can still be in progress if the mouseup occurred + // outside of the window. + dragStop(true); + + if (ev.button !== LEFT_CLICK || !enabled || !ev.target.closest(fullSelector)) { + return; + } + + currentElement = ev.target.closest(elementSelector); + currentGroup = groupSelector && ev.target.closest(groupSelector); + offsetX = ev.clientX; + offsetY = ev.clientY; + }; + + /** + * Window "mousemove" event handler. + * @param {MouseEvent} ev + */ + const onMousemove = (ev) => { + if (!enabled || !currentElement) { + return; + } + if (started) { + // Updates the position of the dragged element. + currentElement.style.left = `${clamp( + ev.clientX - offsetX, + currentContainerRect.x, + currentContainerRect.x + currentContainerRect.width - currentElementRect.width + )}px`; + currentElement.style.top = `${clamp( + ev.clientY - offsetY, + currentContainerRect.y, + currentContainerRect.y + currentContainerRect.height - currentElementRect.height + )}px`; + } else { + dragStart(); + } + }; + + /** + * Window "mouseup" event handler. + */ + const onMouseup = () => dragStop(false); + + /** + * Main entry function to start a drag sequence. + */ + const dragStart = () => { + started = true; + + // Calculates the bounding rectangles of the current element, and of the + // container element (`parentElement` or `ref.el`). + const container = connectGroups || !groupSelector ? ref.el : currentGroup; + const containerStyle = getComputedStyle(container); + const [pleft, pright, ptop, pbottom] = [ + "padding-left", + "padding-right", + "padding-top", + "padding-bottom", + ].map((prop) => cssValueToNumber(containerStyle.getPropertyValue(prop))); + + currentElementRect = currentElement.getBoundingClientRect(); + currentContainerRect = container.getBoundingClientRect(); + const { x, y, width, height } = currentElementRect; + + // Reduces the container's dimensions according to its padding. + currentContainerRect.x += pleft; + currentContainerRect.width -= pleft + pright; + currentContainerRect.y += ptop; + currentContainerRect.height -= ptop + pbottom; + + // Prepares the ghost element + ghost = currentElement.cloneNode(true); + ghost.style.visibility = "hidden"; + cleanups.push(() => ghost.remove()); + + // Binds handlers on eligible groups, if the elements are not confined to + // 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); + addStyle(siblingGroup, { "pointer-events": "auto" }); + } + } + + // Binds handlers on eligible elements + for (const siblingElement of ref.el.querySelectorAll(elementSelector)) { + if (siblingElement !== currentElement && siblingElement !== ghost) { + addListener(siblingElement, "mouseenter", onElementMouseenter); + addListener(siblingElement, "mouseleave", onElementMouseleave); + addStyle(siblingElement, { "pointer-events": "auto" }); + } + } + + execHandler("onStart", currentGroup, currentElement); + + // Ghost is initially added right after the current element. + currentElement.after(ghost); + + // Adjusts the offset + offsetX -= x; + offsetY -= y; + + addStyle(currentElement, { + position: "fixed", + "pointer-events": "none", + "z-index": 1000, + width: `${width}px`, + height: `${height}px`, + left: `${x}px`, + top: `${y}px`, + }); + + const bodyStyle = { + "pointer-events": "none", + "user-select": "none", + }; + if (cursor) { + bodyStyle.cursor = cursor; + } + addStyle(document.body, bodyStyle); + }; + + /** + * Main exit function to stop a drag sequence. Note that it can be called + * even if a drag sequence did not start yet to perform a cleanup of all + * current context variables. + * @param {boolean} cancelled + * @param {boolean} [inErrorState] can be set to true when an error + * occurred to avoid falling into an infinite loop if the error + * originated from one of the handlers. + */ + const dragStop = (cancelled, inErrorState) => { + if (started) { + if (!inErrorState) { + execHandler("onStop", currentGroup, currentElement); + const previous = ghost.previousElementSibling; + const next = ghost.nextElementSibling; + if (!cancelled && previous !== currentElement && next !== currentElement) { + execHandler("onDrop", { + group: currentGroup, + element: currentElement, + previous, + next, + parent: groupSelector && ghost.closest(groupSelector), + }); + } + } + } + + // Performes all registered clean-ups. + while (cleanups.length) { + cleanups.pop()(); + } + + currentContainerRect = null; + + currentElement = null; + currentElementRect = null; + currentGroup = null; + ghost = null; + + started = false; + }; + + // OWL HOOKS + + // Effect depending on the params to update them. + useEffect( + (...deps) => { + const actualParams = Object.fromEntries(deps); + enabled = Boolean(ref.el && !env.isSmall && actualParams.enable); + if (!enabled) { + return; + } + + // Selectors + elementSelector = actualParams.elements; + groupSelector = actualParams.groups || null; + if (!elementSelector) { + throw sortableError(`no value found by "elements" selector: ${elementSelector}`); + } + const allSelectors = [elementSelector]; + cursor = actualParams.cursor; + if (groupSelector) { + allSelectors.unshift(groupSelector); + } + if (actualParams.handle) { + allSelectors.push(actualParams.handle); + } + fullSelector = allSelectors.join(" "); + + // Connection accross groups + connectGroups = actualParams.connectGroups; + }, + () => computeParams(params) + ); + // Effect depending on the `ref.el` to add triggering mouse events listener. + useEffect( + (el) => { + if (el) { + el.addEventListener("mousedown", onMousedown); + return () => el.removeEventListener("mousedown", onMousedown); + } + }, + () => [ref.el] + ); + // Other global mouse event listeners. + const debouncedMousemove = debounce(onMousemove, "animationFrame", true); + useExternalListener(window, "mousemove", debouncedMousemove); + useExternalListener(window, "mouseup", onMouseup); + useExternalListener(window, "keydown", onKeydown, true); + onWillUnmount(() => dragStop(true)); +} diff --git a/addons/web/static/src/core/utils/ui.js b/addons/web/static/src/core/utils/ui.js index 418d04295e2..79c7381ffe2 100644 --- a/addons/web/static/src/core/utils/ui.js +++ b/addons/web/static/src/core/utils/ui.js @@ -15,3 +15,45 @@ export function getVisibleElements(activeElement, selector) { } return visibleElements; } + +// ----------------------------------------------------------------------------- +// Get Tabable Elements +// ----------------------------------------------------------------------------- +// TODISCUSS: +// - leave the following in this file ? +// - redefine this selector in tests env with ":not(#qunit *)" ? + +// Following selector is based on this spec: https://html.spec.whatwg.org/multipage/interaction.html#dom-tabindex +let TABABLE_SELECTOR = "[tabindex], a, area, button, frame, iframe, input, object, select, textarea, details > summary:nth-child(1)," + .split(",") + .join(':not([tabindex="-1"]):not(:disabled),'); +TABABLE_SELECTOR = TABABLE_SELECTOR.slice(0, TABABLE_SELECTOR.length - 1); + +export function getTabableElements(container = document.body) { + const elements = container.querySelectorAll(TABABLE_SELECTOR); + const byTabIndex = {}; + for (const el of [...elements]) { + if (!byTabIndex[el.tabIndex]) { + byTabIndex[el.tabIndex] = []; + } + byTabIndex[el.tabIndex].push(el); + } + + const withTabIndexZero = byTabIndex[0] || []; + delete byTabIndex[0]; + return [...Object.values(byTabIndex).flat(), ...withTabIndexZero]; +} + +export function getNextTabableElement(container = document.body) { + const tabableElements = getTabableElements(container); + const index = tabableElements.indexOf(document.activeElement); + return index === -1 ? tabableElements[0] : tabableElements[index + 1] || null; +} + +export function getPreviousTabableElement(container = document.body) { + const tabableElements = getTabableElements(container); + const index = tabableElements.indexOf(document.activeElement); + return index === -1 + ? tabableElements[tabableElements.length - 1] + : tabableElements[index - 1] || null; +} diff --git a/addons/web/static/tests/core/utils/ui_tests.js b/addons/web/static/tests/core/utils/ui_tests.js new file mode 100644 index 00000000000..61e27c8a429 --- /dev/null +++ b/addons/web/static/tests/core/utils/ui_tests.js @@ -0,0 +1,277 @@ +/** @odoo-module **/ + +import { dragAndDrop, getFixture, mount, nextTick } from "@web/../tests/helpers/utils"; +import { useSortable } from "@web/core/utils/sortable"; + +const { Component, reactive, useRef, useState, xml } = owl; + +let target; +QUnit.module("UI", ({ beforeEach }) => { + beforeEach(() => (target = getFixture())); + + QUnit.module("Sortable hook"); + + QUnit.test("Parameters error handling", async (assert) => { + assert.expect(8); + + const mountListAndAssert = async (setupList, shouldThrow) => { + class List extends Component { + setup() { + setupList(); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + let err; + await mount(List, target).catch((e) => (err = e)); + + assert.ok( + shouldThrow ? err : !err, + `An error should${shouldThrow ? "" : "n't"} have been thrown when mounting.` + ); + }; + + // Incorrect params + await mountListAndAssert(() => { + useSortable({}); + }, true); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + elements: ".item", + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + elements: ".item", + groups: ".list", + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + setup: () => ({ elements: ".item" }), + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + elements: () => ".item", + }); + }, true); + + // Correct params + await mountListAndAssert(() => { + useSortable({ + ref: {}, + elements: ".item", + enable: false, + }); + }, false); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + elements: ".item", + connectGroups: () => true, + }); + }, false); + }); + + QUnit.test("Simple sorting in single group", async (assert) => { + assert.expect(19); + + class List extends Component { + setup() { + useSortable({ + ref: useRef("root"), + elements: ".item", + onStart(group, element) { + assert.step("start"); + assert.notOk(group); + assert.strictEqual(element.innerText, "1"); + }, + onElementEnter(element) { + assert.step("elemententer"); + assert.strictEqual(element.innerText, "2"); + }, + onStop(group, element) { + assert.step("stop"); + assert.notOk(group); + assert.strictEqual(element.innerText, "1"); + assert.containsN(target, ".item", 4); + }, + onDrop({ group, element, previous, next, parent }) { + assert.step("drop"); + assert.notOk(group); + assert.strictEqual(element.innerText, "1"); + assert.strictEqual(previous.innerText, "2"); + assert.strictEqual(next.innerText, "3"); + assert.notOk(parent); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + + assert.containsN(target, ".item", 3); + assert.verifySteps([]); + + // First item after 2nd item + await dragAndDrop(".item:first-child", ".item:nth-child(2)"); + + assert.containsN(target, ".item", 3); + assert.verifySteps(["start", "elemententer", "stop", "drop"]); + }); + + QUnit.test("Simple sorting in multiple groups", async (assert) => { + assert.expect(20); + + class List extends Component { + setup() { + useSortable({ + ref: useRef("root"), + elements: ".item", + groups: ".list", + connectGroups: true, + onStart(group, element) { + assert.step("start"); + assert.hasClass(group, "list2"); + assert.strictEqual(element.innerText, "2 1"); + }, + onGroupEnter(group) { + assert.step("groupenter"); + assert.hasClass(group, "list1"); + }, + onStop(group, element) { + assert.step("stop"); + assert.hasClass(group, "list2"); + assert.strictEqual(element.innerText, "2 1"); + }, + onDrop({ group, element, previous, next, parent }) { + assert.step("drop"); + assert.hasClass(group, "list2"); + assert.strictEqual(element.innerText, "2 1"); + assert.strictEqual(previous.innerText, "1 3"); + assert.notOk(next); + assert.hasClass(parent, "list1"); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + + assert.containsN(target, ".list", 3); + assert.containsN(target, ".item", 9); + assert.verifySteps([]); + + // First item of 2nd list appended to first list + await dragAndDrop(".list2 .item:first-child", ".list1"); + + assert.containsN(target, ".list", 3); + assert.containsN(target, ".item", 9); + assert.verifySteps(["start", "groupenter", "stop", "drop"]); + }); + + QUnit.test("Dynamically disable sortable feature", async (assert) => { + assert.expect(4); + + let state = reactive({ enableSortable: true }); + class List extends Component { + setup() { + this.state = useState(state); + useSortable({ + ref: useRef("root"), + elements: ".item", + enable: () => this.state.enableSortable, + onStart() { + assert.step("start"); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + + assert.verifySteps([]); + + // First item before last item + await dragAndDrop(".item:first-child", ".item:last-child"); + + // Drag should have occurred + assert.verifySteps(["start"]); + + state.enableSortable = false; + await nextTick(); + + // First item before last item + await dragAndDrop(".item:first-child", ".item:last-child"); + + // Drag shouldn't have occurred + assert.verifySteps([]); + }); + + QUnit.test("Disabled in small environment", async (assert) => { + assert.expect(2); + + class List extends Component { + setup() { + useSortable({ + ref: useRef("root"), + elements: ".item", + onStart() { + throw new Error("Shouldn't start the sortable feature."); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target, { env: { isSmall: true } }); + + assert.containsN(target, ".item", 3); + + // First item after 2nd item + await dragAndDrop(".item:first-child", ".item:nth-child(2)"); + + assert.ok(true, "No drag sequence should have been initiated"); + }); +});