From 601757093a1538ce32e28be443bbcc1ace3a0970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Louren=C3=A7o?= Date: Mon, 23 Mar 2026 18:39:25 +0000 Subject: [PATCH 1/7] First checkpoint --- .../components/item-sliding/item-sliding.tsx | 121 ++++++++++++++++++ .../item-sliding/test/full-swipe/index.html | 108 ++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 core/src/components/item-sliding/test/full-swipe/index.html diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index fc4d4ce2644..e13e2abae7e 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -27,6 +27,7 @@ const enum SlidingState { SwipeEnd = 1 << 5, SwipeStart = 1 << 6, + AnimatingFullSwipe = 1 << 7, } let openSlidingItem: HTMLIonItemSlidingElement | undefined; @@ -113,6 +114,15 @@ export class ItemSliding implements ComponentInterface { this.gesture = undefined; } + // Cancel animation if in progress + if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) { + if (this.item) { + this.item.style.transition = ''; + this.item.style.transform = ''; + } + this.state = SlidingState.Disabled; + } + this.item = null; this.leftOptions = this.rightOptions = undefined; @@ -248,6 +258,100 @@ export class ItemSliding implements ComponentInterface { } } + /** + * Check if the given item options element contains at least one expandable option. + */ + private hasExpandableOptions(options: HTMLIonItemOptionsElement | undefined): boolean { + if (!options) return false; + + const optionElements = options.querySelectorAll('ion-item-option'); + return Array.from(optionElements).some((option: any) => { + return option.expandable === true; + }); + } + + /** + * Animate the item to a specific position using CSS transitions. + * Returns a Promise that resolves when the animation completes. + */ + private animateToPosition(position: number, duration: number): Promise { + return new Promise((resolve) => { + if (!this.item) { + return resolve(); + } + + this.item.style.transition = `transform ${duration}ms ease-out`; + this.item.style.transform = `translate3d(${-position}px, 0, 0)`; + + setTimeout(() => { + resolve(); + }, duration); + }); + } + + /** + * Calculate the swipe threshold distance required to trigger a full swipe animation. + * Returns the maximum options width plus a margin to ensure it's achievable. + */ + private getSwipeThreshold(): number { + const maxWidth = Math.max(this.optsWidthRightSide, this.optsWidthLeftSide); + return maxWidth + 2000; // Slightly larger than SWIPE_MARGIN to be achievable + } + + /** + * Animate the item through a full swipe sequence: off-screen → trigger action → return. + * This is used when an expandable option is swiped beyond the threshold. + */ + private async animateFullSwipe(direction: 'start' | 'end') { + // Prevent interruption during animation + this.state = SlidingState.AnimatingFullSwipe; + if (this.gesture) { + this.gesture.enable(false); + } + + const { el } = this; + el.classList.add('item-sliding-full-swipe'); + + try { + // Calculate which options we're using + const options = direction === 'end' ? this.rightOptions : this.leftOptions; + const optsWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; + + // Phase 1: First reveal the options fully (so expandable option fills space) + const initialOpenAmount = direction === 'end' ? optsWidth : -optsWidth; + this.setOpenAmount(initialOpenAmount, false); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Phase 2: Animate off-screen from current position + const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth; + await this.animateToPosition(offScreenDistance, 250); + + // Phase 3: Trigger action + if (options) { + options.fireSwipeEvent(); + } + + // Phase 4: Small delay before returning + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Phase 5: Return to closed state + await this.animateToPosition(0, 250); + + // Phase 6: Reset state + if (this.item) { + this.item.style.transition = ''; + } + this.openAmount = 0; + this.state = SlidingState.Disabled; + openSlidingItem = undefined; + } finally { + el.classList.remove('item-sliding-full-swipe'); + if (this.gesture) { + this.gesture.enable(!this.disabled); + } + } + } + private async updateOptions() { const options = this.el.querySelectorAll('ion-item-options'); @@ -370,6 +474,23 @@ export class ItemSliding implements ComponentInterface { resetContentScrollY(contentEl, initialContentScrollY); } + // Check for full swipe conditions with expandable options + const rawSwipeDistance = Math.abs(gesture.deltaX); + const direction = gesture.deltaX < 0 ? 'end' : 'start'; + const options = direction === 'end' ? this.rightOptions : this.leftOptions; + const hasExpandable = this.hasExpandableOptions(options); + + const shouldTriggerFullSwipe = + hasExpandable && + (rawSwipeDistance > this.getSwipeThreshold() || + (Math.abs(gesture.velocityX) > 0.5 && + rawSwipeDistance > Math.max(this.optsWidthRightSide, this.optsWidthLeftSide) * 0.5)); + + if (shouldTriggerFullSwipe) { + this.animateFullSwipe(direction); + return; + } + const velocity = gesture.velocityX; let restingPoint = this.openAmount > 0 ? this.optsWidthRightSide : -this.optsWidthLeftSide; diff --git a/core/src/components/item-sliding/test/full-swipe/index.html b/core/src/components/item-sliding/test/full-swipe/index.html new file mode 100644 index 00000000000..d406593c76d --- /dev/null +++ b/core/src/components/item-sliding/test/full-swipe/index.html @@ -0,0 +1,108 @@ + + + + + Item Sliding - Full Swipe + + + + + + + + + + +

Full Swipe - Expandable Options

+ + + + + Expandable End (Swipe Left) + + + Delete + + + + + + + Expandable Start (Swipe Right) + + + Archive + + + + + + + Expandable Both Sides + + + Archive + + + Delete + + + + +

Non-Expandable Options (No Full Swipe)

+ + + + + Non-Expandable (Should Show Options) + + + Edit + + + + + + + Multiple Non-Expandable Options + + + Edit + Share + Delete + + + + +

Mixed Scenarios

+ + + + + Expandable + Other Options + + + Edit + Delete + + + +
+ + + + From 994b9695e43930493c604c3d8c97c55a8bc758b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Louren=C3=A7o?= Date: Tue, 24 Mar 2026 12:15:40 +0000 Subject: [PATCH 2/7] Second checkpoint --- .../components/item-sliding/item-sliding.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index e13e2abae7e..44e242f4759 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -295,7 +295,7 @@ export class ItemSliding implements ComponentInterface { */ private getSwipeThreshold(): number { const maxWidth = Math.max(this.optsWidthRightSide, this.optsWidthLeftSide); - return maxWidth + 2000; // Slightly larger than SWIPE_MARGIN to be achievable + return maxWidth + 100; // Slightly larger than SWIPE_MARGIN to be achievable } /** @@ -304,7 +304,6 @@ export class ItemSliding implements ComponentInterface { */ private async animateFullSwipe(direction: 'start' | 'end') { // Prevent interruption during animation - this.state = SlidingState.AnimatingFullSwipe; if (this.gesture) { this.gesture.enable(false); } @@ -317,12 +316,17 @@ export class ItemSliding implements ComponentInterface { const options = direction === 'end' ? this.rightOptions : this.leftOptions; const optsWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; - // Phase 1: First reveal the options fully (so expandable option fills space) - const initialOpenAmount = direction === 'end' ? optsWidth : -optsWidth; - this.setOpenAmount(initialOpenAmount, false); - await new Promise((resolve) => setTimeout(resolve, 50)); + // Phase 1: Reveal options beyond threshold to trigger expandable state + // This sets the SwipeEnd or SwipeStart state which expands the expandable option + const thresholdAmount = direction === 'end' ? optsWidth + SWIPE_MARGIN : -(optsWidth + SWIPE_MARGIN); + this.setOpenAmount(thresholdAmount, false); - // Phase 2: Animate off-screen from current position + // Add AnimatingFullSwipe flag while preserving the SwipeEnd/SwipeStart state + this.state = this.state | SlidingState.AnimatingFullSwipe; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Phase 2: Animate off-screen while maintaining the expanded state const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth; await this.animateToPosition(offScreenDistance, 250); @@ -332,7 +336,7 @@ export class ItemSliding implements ComponentInterface { } // Phase 4: Small delay before returning - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 300)); // Phase 5: Return to closed state await this.animateToPosition(0, 250); From a6f271f203c3adf5119d50946959306569447b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Louren=C3=A7o?= Date: Tue, 24 Mar 2026 13:37:55 +0000 Subject: [PATCH 3/7] Add automatic full expand animation to items --- .../components/item-sliding/item-sliding.tsx | 25 +++++++++---------- .../item-sliding/test/full-swipe/index.html | 7 +++--- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index 44e242f4759..aed860b48a6 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -314,34 +314,33 @@ export class ItemSliding implements ComponentInterface { try { // Calculate which options we're using const options = direction === 'end' ? this.rightOptions : this.leftOptions; - const optsWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; - // Phase 1: Reveal options beyond threshold to trigger expandable state - // This sets the SwipeEnd or SwipeStart state which expands the expandable option - const thresholdAmount = direction === 'end' ? optsWidth + SWIPE_MARGIN : -(optsWidth + SWIPE_MARGIN); - this.setOpenAmount(thresholdAmount, false); - - // Add AnimatingFullSwipe flag while preserving the SwipeEnd/SwipeStart state - this.state = this.state | SlidingState.AnimatingFullSwipe; + // Trigger expandable state without moving the item + // Set state directly so expandable option fills its container, starting from + // the exact position where the user released, without any visual snap. + this.state = + direction === 'end' + ? SlidingState.End | SlidingState.SwipeEnd | SlidingState.AnimatingFullSwipe + : SlidingState.Start | SlidingState.SwipeStart | SlidingState.AnimatingFullSwipe; await new Promise((resolve) => setTimeout(resolve, 100)); - // Phase 2: Animate off-screen while maintaining the expanded state + // Animate off-screen while maintaining the expanded state const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth; await this.animateToPosition(offScreenDistance, 250); - // Phase 3: Trigger action + // Trigger action if (options) { options.fireSwipeEvent(); } - // Phase 4: Small delay before returning + // Small delay before returning await new Promise((resolve) => setTimeout(resolve, 300)); - // Phase 5: Return to closed state + // Return to closed state await this.animateToPosition(0, 250); - // Phase 6: Reset state + // Reset state if (this.item) { this.item.style.transition = ''; } diff --git a/core/src/components/item-sliding/test/full-swipe/index.html b/core/src/components/item-sliding/test/full-swipe/index.html index d406593c76d..05b7e215f3c 100644 --- a/core/src/components/item-sliding/test/full-swipe/index.html +++ b/core/src/components/item-sliding/test/full-swipe/index.html @@ -96,10 +96,9 @@

Mixed Scenarios

// Log swipe events for debugging document.querySelectorAll('ion-item-sliding').forEach((item) => { const id = item.getAttribute('id'); - const options = item.querySelectorAll('ion-item-option'); - options.forEach((option) => { - option.addEventListener('ionSwipe', () => { - console.log(`[${id}] ionSwipe event fired for:`, option.textContent); + item.querySelectorAll('ion-item-options').forEach((options) => { + options.addEventListener('ionSwipe', () => { + console.log(`[${id}] ionSwipe fired on ${options.getAttribute('side')} side`); }); }); }); From 63274c0b57e0470efa40b1e93b665c333416add3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Louren=C3=A7o?= Date: Tue, 24 Mar 2026 18:54:37 +0000 Subject: [PATCH 4/7] Tweaks --- .../components/item-sliding/item-sliding.tsx | 18 +- .../item-sliding/test/full-swipe/index.html | 155 ++++++++++-------- 2 files changed, 89 insertions(+), 84 deletions(-) diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index aed860b48a6..3481bbcce06 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -293,9 +293,9 @@ export class ItemSliding implements ComponentInterface { * Calculate the swipe threshold distance required to trigger a full swipe animation. * Returns the maximum options width plus a margin to ensure it's achievable. */ - private getSwipeThreshold(): number { - const maxWidth = Math.max(this.optsWidthRightSide, this.optsWidthLeftSide); - return maxWidth + 100; // Slightly larger than SWIPE_MARGIN to be achievable + private getSwipeThreshold(direction: 'start' | 'end'): number { + const maxWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; + return maxWidth + 30; // Slightly larger than SWIPE_MARGIN to be achievable } /** @@ -312,7 +312,6 @@ export class ItemSliding implements ComponentInterface { el.classList.add('item-sliding-full-swipe'); try { - // Calculate which options we're using const options = direction === 'end' ? this.rightOptions : this.leftOptions; // Trigger expandable state without moving the item @@ -485,9 +484,9 @@ export class ItemSliding implements ComponentInterface { const shouldTriggerFullSwipe = hasExpandable && - (rawSwipeDistance > this.getSwipeThreshold() || + (rawSwipeDistance > this.getSwipeThreshold(direction) || (Math.abs(gesture.velocityX) > 0.5 && - rawSwipeDistance > Math.max(this.optsWidthRightSide, this.optsWidthLeftSide) * 0.5)); + rawSwipeDistance > (direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide) * 0.5)); if (shouldTriggerFullSwipe) { this.animateFullSwipe(direction); @@ -507,14 +506,7 @@ export class ItemSliding implements ComponentInterface { restingPoint = 0; } - const state = this.state; this.setOpenAmount(restingPoint, true); - - if ((state & SlidingState.SwipeEnd) !== 0 && this.rightOptions) { - this.rightOptions.fireSwipeEvent(); - } else if ((state & SlidingState.SwipeStart) !== 0 && this.leftOptions) { - this.leftOptions.fireSwipeEvent(); - } } private calculateOptsWidth() { diff --git a/core/src/components/item-sliding/test/full-swipe/index.html b/core/src/components/item-sliding/test/full-swipe/index.html index 05b7e215f3c..4f94d461cb6 100644 --- a/core/src/components/item-sliding/test/full-swipe/index.html +++ b/core/src/components/item-sliding/test/full-swipe/index.html @@ -15,83 +15,96 @@ - -

Full Swipe - Expandable Options

- - - - - Expandable End (Swipe Left) - - - Delete - - + + + + Item Sliding - Full Swipe + + - - - - Expandable Start (Swipe Right) - - - Archive - - + +
+ Full Swipe - Expandable Options +
+ + + + + Expandable End (Swipe Left) + + + Delete + + - - - - Expandable Both Sides - - - Archive - - - Delete - - - + + + + Expandable Start (Swipe Right) + + + Archive + + -

Non-Expandable Options (No Full Swipe)

- - - - - Non-Expandable (Should Show Options) - - - Edit - - + + + + Expandable Both Sides + + + Archive + + + Delete + + + - - - - Multiple Non-Expandable Options - - - Edit - Share - Delete - - -
+
+ Non-Expandable Options (No Full Swipe) +
+ + + + + Non-Expandable (Should Show Options) + + + Edit + + -

Mixed Scenarios

- - - - - Expandable + Other Options - - - Edit - Delete - - - -
+ + + + Multiple Non-Expandable Options + + + Edit + Share + Delete + + + +
+ Mixed Scenarios +
+ + + + + Expandable + Other Options + + + Edit + Delete + + + + +