From 156d41dbda72ade4da13a6fcf384ba2164988bbc Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Thu, 22 Sep 2022 08:44:43 +0000 Subject: [PATCH] [MOV] web: transfer ActionSwiper to community This commit adapts codebase following the move and integration of the touch device detection and customization (through the o_touch_device class) in community. X-original-commit: 5ae86ad473dd69559820b1f599ce389484cdebbc Part-of: odoo/odoo#101185 Co-authored-by: Jorge Pinna Puissant --- .../src/core/action_swiper/action_swiper.js | 219 +++++ .../src/core/action_swiper/action_swiper.scss | 20 + .../src/core/action_swiper/action_swiper.xml | 27 + .../tests/mobile/action_swiper_tests.js | 866 ++++++++++++++++++ addons/web/static/tests/mobile/helpers.js | 58 ++ 5 files changed, 1190 insertions(+) create mode 100644 addons/web/static/src/core/action_swiper/action_swiper.js create mode 100644 addons/web/static/src/core/action_swiper/action_swiper.scss create mode 100644 addons/web/static/src/core/action_swiper/action_swiper.xml create mode 100644 addons/web/static/tests/mobile/action_swiper_tests.js create mode 100644 addons/web/static/tests/mobile/helpers.js diff --git a/addons/web/static/src/core/action_swiper/action_swiper.js b/addons/web/static/src/core/action_swiper/action_swiper.js new file mode 100644 index 00000000000..fd0502e699a --- /dev/null +++ b/addons/web/static/src/core/action_swiper/action_swiper.js @@ -0,0 +1,219 @@ +/** @odoo-module **/ +import { browser } from "@web/core/browser/browser"; +import { localization } from "@web/core/l10n/localization"; +import { clamp } from "@web/core/utils/numbers"; + +const { Component, onMounted, onWillUnmount, useRef, useState } = owl; + +const isScrollSwipable = (scrollables) => { + return { + left: !scrollables.filter((e) => e.scrollLeft !== 0).length, + right: !scrollables.filter( + (e) => e.scrollLeft + Math.round(e.getBoundingClientRect().width) !== e.scrollWidth + ).length, + }; +}; + +/** + * Action Swiper + * + * This component is intended to perform action once a user has completed a touch swipe. + * You can choose the direction allowed for such behavior (left, right or both). + * The action to perform must be passed as a props. It is possible to define a condition + * to allow the swipe interaction conditionnally. + * @extends Component + */ +export class ActionSwiper extends Component { + setup() { + this.actionTimeoutId = null; + this.resetTimeoutId = null; + this.defaultState = { + containerStyle: "", + isSwiping: false, + width: undefined, + }; + this.root = useRef("root"); + this.targetContainer = useRef("targetContainer"); + this.state = useState({ ...this.defaultState }); + this.scrollables = undefined; + this.startX = undefined; + this.swipedDistance = 0; + this.isScrollValidated = false; + onMounted(() => { + if (this.targetContainer.el) { + this.state.width = this.targetContainer.el.getBoundingClientRect().width; + } + // Forward classes set on component to slot, as we only want to wrap an + // existing component without altering the DOM structure any more than + // strictly necessary + if (this.props.onLeftSwipe || this.props.onRightSwipe) { + const classes = new Set(this.root.el.classList); + classes.delete("o_actionswiper"); + for (const className of classes) { + this.targetContainer.el.firstChild.classList.add(className); + this.root.el.classList.remove(className); + } + } + }); + onWillUnmount(() => { + browser.clearTimeout(this.actionTimeoutId); + browser.clearTimeout(this.resetTimeoutId); + }); + } + get localizedProps() { + return { + onLeftSwipe: + localization.direction === "rtl" ? this.props.onRightSwipe : this.props.onLeftSwipe, + onRightSwipe: + localization.direction === "rtl" ? this.props.onLeftSwipe : this.props.onRightSwipe, + }; + } + + /** + * @private + * @param {TouchEvent} ev + */ + _onTouchEndSwipe() { + if (this.state.isSwiping) { + this.state.isSwiping = false; + if ( + this.localizedProps.onRightSwipe && + this.swipedDistance > this.state.width / this.props.swipeDistanceRatio + ) { + this.swipedDistance = this.state.width; + this.handleSwipe(this.localizedProps.onRightSwipe.action); + } else if ( + this.localizedProps.onLeftSwipe && + this.swipedDistance < -this.state.width / this.props.swipeDistanceRatio + ) { + this.swipedDistance = -this.state.width; + this.handleSwipe(this.localizedProps.onLeftSwipe.action); + } else { + this.state.containerStyle = ""; + } + } + } + /** + * @private + * @param {TouchEvent} ev + */ + _onTouchMoveSwipe(ev) { + if (this.state.isSwiping) { + const { onLeftSwipe, onRightSwipe } = this.localizedProps; + this.swipedDistance = clamp( + ev.touches[0].clientX - this.startX, + onLeftSwipe ? -this.state.width : 0, + onRightSwipe ? this.state.width : 0 + ); + // Prevent the browser to navigate back/forward when using swipe + // gestures while still allowing to scroll vertically. + if (Math.abs(this.swipedDistance) > 40) { + ev.preventDefault(); + } + // If there are scrollable elements under touch pressure, + // they must be at their limits to allow swiping. + if ( + !this.isScrollValidated && + this.scrollables && + !isScrollSwipable(this.scrollables)[this.swipedDistance > 0 ? "left" : "right"] + ) { + return this._reset(); + } + this.isScrollValidated = true; + + if (this.props.animationOnMove) { + this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`; + } + } + } + /** + * @private + * @param {TouchEvent} ev + */ + _onTouchStartSwipe(ev) { + this.scrollables = ev + .composedPath() + .filter( + (e) => + e.nodeType === 1 && + this.targetContainer.el.contains(e) && + e.scrollWidth > e.getBoundingClientRect().width && + ["auto", "scroll"].includes(window.getComputedStyle(e)["overflow-x"]) + ); + if (!this.state.width) { + this.state.width = + this.targetContainer && this.targetContainer.el.getBoundingClientRect().width; + } + this.state.isSwiping = true; + this.isScrollValidated = false; + this.startX = ev.touches[0].clientX; + } + + /** + * @private + */ + _reset() { + Object.assign(this.state, { ...this.defaultState }); + this.scrollables = undefined; + this.startX = undefined; + this.swipedDistance = 0; + this.isScrollValidated = false; + } + + handleSwipe(action) { + if (this.props.animationType === "bounce") { + this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`; + this.actionTimeoutId = browser.setTimeout(() => { + action(); + this._reset(); + }, 500); + } else if (this.props.animationType === "forwards") { + this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`; + this.actionTimeoutId = browser.setTimeout(() => { + action(); + this.state.isSwiping = true; + this.state.containerStyle = `transform: translateX(${-this.swipedDistance}px)`; + this.resetTimeoutId = browser.setTimeout(() => { + this._reset(); + }, 100); + }, 100); + } else { + return action(); + } + } +} + +ActionSwiper.props = { + onLeftSwipe: { + type: Object, + args: { + action: Function, + icon: String, + bgColor: String, + }, + optional: true, + }, + onRightSwipe: { + type: Object, + args: { + action: Function, + icon: String, + bgColor: String, + }, + optional: true, + }, + slots: Object, + animationOnMove: { type: Boolean, optional: true }, + animationType: { type: String, optional: true }, + swipeDistanceRatio: { type: Number, optional: true }, +}; + +ActionSwiper.defaultProps = { + onLeftSwipe: undefined, + onRightSwipe: undefined, + animationOnMove: true, + animationType: "bounce", + swipeDistanceRatio: 2, +}; + +ActionSwiper.template = "web.ActionSwiper"; diff --git a/addons/web/static/src/core/action_swiper/action_swiper.scss b/addons/web/static/src/core/action_swiper/action_swiper.scss new file mode 100644 index 00000000000..b681dc39fe5 --- /dev/null +++ b/addons/web/static/src/core/action_swiper/action_swiper.scss @@ -0,0 +1,20 @@ +.o_actionswiper { + position: relative; + touch-action: pan-y; +} +.o_actionswiper_target_container { + transition: transform 0.4s; +} +.o_actionswiper_swiping { + transition: none; +} +.o_actionswiper_right_swipe_area { + /*rtl:ignore*/ + transform: translateX(-100%); + inset: 0 auto auto 0; +} +.o_actionswiper_left_swipe_area { + /*rtl:ignore*/ + transform: translateX(100%); + inset: 0 0 auto auto; +} diff --git a/addons/web/static/src/core/action_swiper/action_swiper.xml b/addons/web/static/src/core/action_swiper/action_swiper.xml new file mode 100644 index 00000000000..5d5d6d86ea3 --- /dev/null +++ b/addons/web/static/src/core/action_swiper/action_swiper.xml @@ -0,0 +1,27 @@ + + + + +
+
+
+ + +
+ +
+
+ +
+ +
+
+
+
+
+
+ + + + +
diff --git a/addons/web/static/tests/mobile/action_swiper_tests.js b/addons/web/static/tests/mobile/action_swiper_tests.js new file mode 100644 index 00000000000..78f5bc82edd --- /dev/null +++ b/addons/web/static/tests/mobile/action_swiper_tests.js @@ -0,0 +1,866 @@ +/** @odoo-module **/ + +import { ActionSwiper } from "@web/core/action_swiper/action_swiper"; +import { registry } from "@web/core/registry"; +import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; + +import { + mount, + nextTick, + triggerEvent, + getFixture, + mockTimeout, +} from "@web/../tests/helpers/utils"; + +const { Component, xml } = owl; +const serviceRegistry = registry.category("services"); + +let env; +let target; + +QUnit.module("ActionSwiper", ({ beforeEach }) => { + beforeEach(async () => { + env = await makeTestEnv(); + target = getFixture(); + }); + + // Tests marked as [REQUIRE TOUCHEVENT] will fail on browsers that don't support + // TouchEvent by default. It might be an option to activate on some browser. + + QUnit.test("render only its target if no props is given", async (assert) => { + class Parent extends Component {} + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
+ +
+ `; + await mount(Parent, target, { env }); + assert.containsNone(target, "div.o_actionswiper"); + assert.containsOnce(target, "div.target-component"); + }); + + QUnit.test("only render the necessary divs", async (assert) => { + await mount(ActionSwiper, target, { + env, + props: { + onRightSwipe: { + action: () => {}, + icon: "fa-circle", + bgColor: "bg-warning", + }, + slots: {}, + }, + }); + assert.containsOnce(target, "div.o_actionswiper_right_swipe_area"); + assert.containsNone(target, "div.o_actionswiper_left_swipe_area"); + await mount(ActionSwiper, target, { + env, + props: { + onLeftSwipe: { + action: () => {}, + icon: "fa-circle", + bgColor: "bg-warning", + }, + slots: {}, + }, + }); + assert.containsOnce(target, "div.o_actionswiper_right_swipe_area"); + assert.containsOnce(target, "div.o_actionswiper_left_swipe_area"); + }); + + QUnit.test("render with the height of its content", async (assert) => { + assert.expect(2); + class Parent extends Component { + onRightSwipe() { + assert.step("onRightSwipe"); + } + } + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
This element is very high and + the o-container element must have a scrollbar
+
+
+ `; + await mount(Parent, target, { env }); + assert.ok( + target.querySelector(".o_actionswiper").scrollHeight === + target.querySelector(".target-component").scrollHeight, + "the swiper has the height of its content" + ); + assert.ok( + target.querySelector(".o_actionswiper").scrollHeight > + target.querySelector(".o_actionswiper").clientHeight, + "the height of the swiper must make the parent div scrollable" + ); + }); + + QUnit.test( + "can perform actions by swiping to the right [REQUIRE TOUCHEVENT]", + async (assert) => { + assert.expect(5); + const { execRegisteredTimeouts } = mockTimeout(); + class Parent extends Component { + onRightSwipe() { + assert.step("onRightSwipe"); + } + } + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
Test
+
+
+ `; + await mount(Parent, target, { env }); + const swiper = target.querySelector(".o_actionswiper"); + const targetContainer = target.querySelector(".o_actionswiper_target_container"); + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: 0, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: (3 * swiper.clientWidth) / 4, + clientY: 0, + target: target, + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "target has translateX" + ); + // Touch ends before the half of the distance has been reached + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth / 2 - 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "target does not have a translate value" + ); + // Touch ends once the half of the distance has been crossed + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth / 2, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth + 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + // The action is performed AND the component is reset + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "target doesn't have translateX after action is performed" + ); + assert.verifySteps(["onRightSwipe"]); + } + ); + + QUnit.test( + "can perform actions by swiping in both directions [REQUIRE TOUCHEVENT]", + async (assert) => { + assert.expect(7); + const { execRegisteredTimeouts } = mockTimeout(); + class Parent extends Component { + onRightSwipe() { + assert.step("onRightSwipe"); + } + onLeftSwipe() { + assert.step("onLeftSwipe"); + } + } + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
Swipe in both directions
+
+
+ `; + await mount(Parent, target, { env }); + const swiper = target.querySelector(".o_actionswiper"); + const targetContainer = target.querySelector(".o_actionswiper_target_container"); + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: 0, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: (3 * swiper.clientWidth) / 4, + clientY: 0, + target: target, + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "target has translateX" + ); + // Touch ends before the half of the distance has been reached to the left + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: -swiper.clientWidth / 2 + 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "target does not have a translate value" + ); + // Touch ends once the half of the distance has been crossed to the left + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth / 2, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: -swiper.clientWidth - 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.verifySteps(["onLeftSwipe"], "the onLeftSwipe props action has been performed"); + // Touch ends once the half of the distance has been crossed to the right + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth / 2, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth + 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "target doesn't have translateX after all actions are performed" + ); + assert.verifySteps( + ["onRightSwipe"], + "the onRightSwipe props action has been performed" + ); + } + ); + + QUnit.test( + "invert the direction of swipes when language is rtl [REQUIRE TOUCHEVENT]", + async (assert) => { + assert.expect(7); + const { execRegisteredTimeouts } = mockTimeout(); + class Parent extends Component { + onRightSwipe() { + assert.step("onRightSwipe"); + } + onLeftSwipe() { + assert.step("onLeftSwipe"); + } + } + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
Swipe in both directions
+
+
+ `; + serviceRegistry.add("localization", makeFakeLocalizationService({ direction: "rtl" })); + await mount(Parent, target, { env }); + const swiper = target.querySelector(".o_actionswiper"); + const targetContainer = target.querySelector(".o_actionswiper_target_container"); + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: 0, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: (3 * swiper.clientWidth) / 4, + clientY: 0, + target: target, + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "target has translateX" + ); + // Touch ends before the half of the distance has been reached to the left + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: -swiper.clientWidth / 2 + 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "target does not have a translate value" + ); + // Touch ends once the half of the distance has been crossed to the left + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth / 2, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: -swiper.clientWidth - 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + // In rtl languages, actions are permuted + assert.verifySteps( + ["onRightSwipe"], + "the onRightSwipe props action has been performed" + ); + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth / 2, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: swiper.clientWidth + 1, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "target doesn't have translateX after all actions are performed" + ); + // In rtl languages, actions are permuted + assert.verifySteps(["onLeftSwipe"], "the onLeftSwipe props action has been performed"); + } + ); + + QUnit.test( + "swiping when the swiper contains scrollable areas [REQUIRE TOUCHEVENT]", + async (assert) => { + assert.expect(9); + const { execRegisteredTimeouts } = mockTimeout(); + class Parent extends Component { + onRightSwipe() { + assert.step("onRightSwipe"); + } + onLeftSwipe() { + assert.step("onLeftSwipe"); + } + } + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
+

Test about swiping and scrolling

+
+

This div contains a larger element that will make it scrollable

+

This element is so large it needs to be scrollable

+
+
+
+
+ `; + await mount(Parent, target, { env }); + const swiper = target.querySelector(".o_actionswiper"); + const targetContainer = target.querySelector(".o_actionswiper_target_container"); + const scrollable = target.querySelector(".large-content"); + // The scrollable element is set as scrollable + scrollable.scrollLeft = 100; + await triggerEvent(target, ".o_actionswiper", "touchstart", { + touches: [ + { + identifier: 0, + clientX: 0, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: (3 * swiper.clientWidth) / 4, + clientY: 0, + target: target, + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "the swiper can swipe if the scrollable area is not under touch pressure" + ); + await triggerEvent(target, ".o_actionswiper", "touchmove", { + touches: [ + { + identifier: 0, + clientX: 0, + clientY: 0, + target: target, + }, + ], + }); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "the swiper has not swiped to the right because the scrollable element was scrollable to the left" + ); + // The scrollable element is set at its left limit + scrollable.scrollLeft = 0; + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "the swiper has swiped to the right because the scrollable element couldn't scroll anymore to the left" + ); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.verifySteps( + ["onRightSwipe"], + "the onRightSwipe props action has been performed" + ); + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "the swiper has not swiped to the left because the scrollable element was scrollable to the right" + ); + // The scrollable element is set at its right limit + scrollable.scrollLeft = + scrollable.scrollWidth - scrollable.getBoundingClientRect().right; + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "the swiper has swiped to the left because the scrollable element couldn't scroll anymore to the right" + ); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + assert.verifySteps(["onLeftSwipe"], "the onLeftSwipe props action has been performed"); + } + ); + + QUnit.test( + "preventing swipe on scrollable areas when language is rtl [REQUIRE TOUCHEVENT]", + async (assert) => { + assert.expect(8); + const { execRegisteredTimeouts } = mockTimeout(); + class Parent extends Component { + onRightSwipe() { + assert.step("onRightSwipe"); + } + onLeftSwipe() { + assert.step("onLeftSwipe"); + } + } + Parent.components = { ActionSwiper }; + Parent.template = xml` +
+ +
+

Test about swiping and scrolling for rtl

+
+

elballorcs ti ekam lliw taht tnemele regral a sniatnoc vid sihT

+

elballorcs eb ot sdeen ti egral os si tnemele sihT

+
+
+
+
+ `; + serviceRegistry.add("localization", makeFakeLocalizationService({ direction: "rtl" })); + await mount(Parent, target, { env }); + const targetContainer = target.querySelector(".o_actionswiper_target_container"); + const scrollable = target.querySelector(".large-content"); + // The scrollable element is set as scrollable + scrollable.scrollLeft = 100; + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "the swiper has not swiped to the right because the scrollable element was scrollable to the left" + ); + // The scrollable element is set at its left limit + scrollable.scrollLeft = 0; + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "the swiper has swiped to the right because the scrollable element couldn't scroll anymore to the left" + ); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + // In rtl languages, actions are permuted + assert.verifySteps(["onLeftSwipe"], "the onLeftSwipe props action has been performed"); + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + !targetContainer.style.transform.includes("translateX"), + "the swiper has not swiped to the left because the scrollable element was scrollable to the right" + ); + // The scrollable element is set at its right limit + scrollable.scrollLeft = + scrollable.scrollWidth - scrollable.getBoundingClientRect().right; + await triggerEvent(scrollable, ".large-text", "touchstart", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientWidth, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + await triggerEvent(scrollable, ".large-text", "touchmove", { + touches: [ + { + identifier: 0, + clientX: scrollable.clientLeft, + clientY: + scrollable.getBoundingClientRect().top + + scrollable.getBoundingClientRect().height / 2, + target: scrollable.querySelector(".large-text"), + }, + ], + }); + assert.ok( + targetContainer.style.transform.includes("translateX"), + "the swiper has swiped to the left because the scrollable element couldn't scroll anymore to the right" + ); + await triggerEvent(target, ".o_actionswiper", "touchend", {}); + execRegisteredTimeouts(); + await nextTick(); + // In rtl languages, actions are permuted + assert.verifySteps( + ["onRightSwipe"], + "the onRightSwipe props action has been performed" + ); + } + ); +}); diff --git a/addons/web/static/tests/mobile/helpers.js b/addons/web/static/tests/mobile/helpers.js new file mode 100644 index 00000000000..8ec398d5b52 --- /dev/null +++ b/addons/web/static/tests/mobile/helpers.js @@ -0,0 +1,58 @@ +/** @odoo-module **/ + +import { findElement, triggerEvent } from "../helpers/utils"; + +async function swipe(target, selector, direction) { + const touchTarget = findElement(target, selector); + if (direction === "left") { + // The scrollable element is set at its right limit + touchTarget.scrollLeft = touchTarget.scrollWidth - touchTarget.offsetWidth; + } else { + // The scrollable element is set at its left limit + touchTarget.scrollLeft = 0; + } + + await triggerEvent(target, selector, "touchstart", { + touches: [ + { + identifier: 0, + clientX: 0, + clientY: 0, + target: touchTarget, + }, + ], + }); + await triggerEvent(target, selector, "touchmove", { + touches: [ + { + identifier: 0, + clientX: (direction === "left" ? -1 : 1) * touchTarget.clientWidth, + clientY: 0, + target: touchTarget, + }, + ], + }); + await triggerEvent(target, selector, "touchend", {}); +} + +/** + * Will simulate a swipe right on the target element with the given selector. + * + * @param {HTMLElement} target + * @param {DOMSelector} [selector] + * @returns {Promise} + */ +export async function swipeRight(target, selector) { + return swipe(target, selector, "right"); +} + +/** + * Will simulate a swipe left on the target element with the given selector. + * + * @param {HTMLElement} target + * @param {DOMSelector} [selector] + * @returns {Promise} + */ +export async function swipeLeft(target, selector) { + return swipe(target, selector, "left"); +}