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`
+
+ `;
+ 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");
+}