diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index fc4d4ce2644..1d70cbc12d4 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; @@ -47,6 +48,7 @@ export class ItemSliding implements ComponentInterface { private optsWidthLeftSide = 0; private sides = ItemSide.None; private tmr?: ReturnType; + private animationAbortController?: AbortController; private leftOptions?: HTMLIonItemOptionsElement; private rightOptions?: HTMLIonItemOptionsElement; private optsDirty = true; @@ -113,6 +115,15 @@ export class ItemSliding implements ComponentInterface { this.gesture = undefined; } + if (this.tmr !== undefined) { + clearTimeout(this.tmr); + this.tmr = undefined; + } + + // Abort any in-progress animation. The abort handler rejects the pending + // promise, causing animateFullSwipe's finally block to run cleanup. + this.animationAbortController?.abort(); + this.item = null; this.leftOptions = this.rightOptions = undefined; @@ -153,6 +164,10 @@ export class ItemSliding implements ComponentInterface { */ @Method() async open(side: Side | undefined) { + if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) { + return; + } + /** * It is possible for the item to be added to the DOM * after the item-sliding component was created. As a result, @@ -216,6 +231,9 @@ export class ItemSliding implements ComponentInterface { */ @Method() async close() { + if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) { + return; + } this.setOpenAmount(0, true); } @@ -248,6 +266,135 @@ export class ItemSliding implements ComponentInterface { } } + /** + * Check if the given item options element contains at least one expandable, non-disabled 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 && !option.disabled; + }); + } + + /** + * Returns a Promise that resolves after `ms` milliseconds, or rejects if the + * given AbortSignal is fired before the timer expires. + */ + private delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const id = setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + clearTimeout(id); + reject(new DOMException('Animation cancelled', 'AbortError')); + }, + { once: true } + ); + }); + } + + /** + * Animate the item to a specific position using CSS transitions. + * Returns a Promise that resolves when the animation completes, or rejects if + * the given AbortSignal is fired. + */ + private animateToPosition(position: number, duration: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (!this.item) { + return resolve(); + } + + this.item.style.transition = `transform ${duration}ms ease-out`; + this.item.style.transform = `translate3d(${-position}px, 0, 0)`; + + const id = setTimeout(resolve, duration); + signal.addEventListener( + 'abort', + () => { + clearTimeout(id); + reject(new DOMException('Animation cancelled', 'AbortError')); + }, + { once: true } + ); + }); + } + + /** + * 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(direction: 'start' | 'end'): number { + const maxWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; + return maxWidth + SWIPE_MARGIN; + } + + /** + * 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') { + const abortController = new AbortController(); + this.animationAbortController = abortController; + const { signal } = abortController; + + // Prevent interruption during animation + if (this.gesture) { + this.gesture.enable(false); + } + + try { + const options = direction === 'end' ? this.rightOptions : this.leftOptions; + + // 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 this.delay(100, signal); + + // Animate off-screen while maintaining the expanded state + const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth; + await this.animateToPosition(offScreenDistance, 250, signal); + + // Trigger action + if (options) { + options.fireSwipeEvent(); + } + + // Small delay before returning + await this.delay(300, signal); + + // Return to closed state + await this.animateToPosition(0, 250, signal); + } catch { + // Animation was aborted (e.g. component disconnected). finally handles cleanup. + } finally { + this.animationAbortController = undefined; + + // Reset state + if (this.item) { + this.item.style.transition = ''; + this.item.style.transform = ''; + } + this.openAmount = 0; + this.state = SlidingState.Disabled; + + if (openSlidingItem === this.el) { + openSlidingItem = undefined; + } + + if (this.gesture) { + this.gesture.enable(!this.disabled); + } + } + } + private async updateOptions() { const options = this.el.querySelectorAll('ion-item-options'); @@ -370,6 +517,27 @@ 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(direction) || + (Math.abs(gesture.velocityX) > 0.5 && + rawSwipeDistance > (direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide) * 0.5)); + + if (shouldTriggerFullSwipe) { + this.animateFullSwipe(direction).catch(() => { + if (this.gesture) { + this.gesture.enable(!this.disabled); + } + }); + return; + } + const velocity = gesture.velocityX; let restingPoint = this.openAmount > 0 ? this.optsWidthRightSide : -this.optsWidthLeftSide; diff --git a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts index 794dff39a5b..f6bcee20c27 100644 --- a/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts +++ b/core/src/components/item-sliding/test/basic/item-sliding.e2e.ts @@ -14,7 +14,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf await page.goto(`/src/components/item-sliding/test/basic`, config); }); test.describe('start options', () => { - test('should not have visual regressions', async ({ page }) => { + // TODO(FW-7184): remove skip once issue is resolved + test.skip('should not have visual regressions', async ({ page }) => { const item = page.locator('#item2'); /** @@ -108,7 +109,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, config }) => { test.describe(title('item-sliding: basic'), () => { test.describe('safe area left', () => { - test('should have padding on the left only', async ({ page }) => { + // TODO(FW-7184): remove skip once issue is resolved + test.skip('should have padding on the left only', async ({ page }) => { await page.setContent( ` + + + + + + + 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 + + + +
+
+ + + diff --git a/core/src/components/item-sliding/test/full-swipe/item-sliding.e2e.ts b/core/src/components/item-sliding/test/full-swipe/item-sliding.e2e.ts new file mode 100644 index 00000000000..1ba692bf5a3 --- /dev/null +++ b/core/src/components/item-sliding/test/full-swipe/item-sliding.e2e.ts @@ -0,0 +1,191 @@ +import { expect } from '@playwright/test'; +import { configs, dragElementBy, test } from '@utils/test/playwright'; + +/** + * Full swipe animation behavior is mode-independent but + * child components (ion-item-options, ion-item-option) have + * mode-specific styling, so we test across all modes. + * + * When an item has at least one expandable option and the user swipes + * beyond the threshold (or with sufficient velocity), the item slides + * off-screen, fires ionSwipe, and returns to its closed position. + */ + +// Full animation cycle duration (100ms expand + 250ms off-screen + 300ms delay + 250ms return) +const FULL_ANIMATION_MS = 1100; + +configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => { + test.describe(title('item-sliding: full swipe'), () => { + test('should fire ionSwipe when expandable option is swiped fully (end side)', async ({ page }) => { + await page.setContent( + ` + + + Expandable End (Swipe Left) + + + Delete + + + `, + config + ); + + const ionSwipe = await page.spyOnEvent('ionSwipe'); + const item = page.locator('ion-item-sliding'); + const dragByX = config.direction === 'rtl' ? 190 : -190; + + await dragElementBy(item, page, dragByX); + await ionSwipe.next(); + + expect(ionSwipe).toHaveReceivedEventTimes(1); + }); + + test('should fire ionSwipe when expandable option is swiped fully (start side)', async ({ page }) => { + await page.setContent( + ` + + + Expandable Start (Swipe Right) + + + Archive + + + `, + config + ); + + const ionSwipe = await page.spyOnEvent('ionSwipe'); + const item = page.locator('ion-item-sliding'); + const dragByX = config.direction === 'rtl' ? -190 : 190; + + await dragElementBy(item, page, dragByX); + await ionSwipe.next(); + + expect(ionSwipe).toHaveReceivedEventTimes(1); + }); + + test('should return to closed state after full swipe animation completes', async ({ page }) => { + await page.setContent( + ` + + + Expandable End (Swipe Left) + + + Delete + + + `, + config + ); + + const item = page.locator('ion-item-sliding'); + const dragByX = config.direction === 'rtl' ? 190 : -190; + + await dragElementBy(item, page, dragByX); + await page.waitForTimeout(FULL_ANIMATION_MS); + + const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount()); + expect(openAmount).toBe(0); + }); + + test('should NOT trigger full swipe animation for non-expandable options', async ({ page }) => { + await page.setContent( + ` + + + Non-Expandable (Should Show Options) + + + Edit + + + `, + config + ); + + const ionSwipe = await page.spyOnEvent('ionSwipe'); + const item = page.locator('ion-item-sliding'); + const dragByX = config.direction === 'rtl' ? 180 : -180; + + await dragElementBy(item, page, dragByX); + + await ionSwipe.next(); + await page.waitForChanges(); + + // The full swipe animation closes the item (openAmount === 0) after completing. + // For a non-expandable item, no animation runs and the item stays open at optsWidth. + const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount()); + expect(Math.abs(openAmount)).toBeGreaterThan(0); + }); + + test('should fire ionSwipe when non-expandable options are swiped past the threshold', async ({ page }) => { + await page.setContent( + ` + + + Non-Expandable (Should Show Options) + + + Edit + + + `, + config + ); + + const ionSwipe = await page.spyOnEvent('ionSwipe'); + const item = page.locator('ion-item-sliding'); + const dragByX = config.direction === 'rtl' ? 190 : -190; + + await dragElementBy(item, page, dragByX); + await ionSwipe.next(); + + expect(ionSwipe).toHaveReceivedEventTimes(1); + }); + }); +}); + +/** + * Velocity-based trigger: a fast short swipe should trigger the full animation + * even if the raw distance alone wouldn't exceed the threshold. + * This behavior does not vary across modes. + */ +configs({ modes: ['md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => { + test.describe(title('item-sliding: full swipe velocity'), () => { + test('should trigger full swipe animation with fast velocity', async ({ page }) => { + await page.setContent( + ` + + + Expandable End (Swipe Left) + + + Delete + + + `, + config + ); + + const ionSwipe = await page.spyOnEvent('ionSwipe'); + const item = page.locator('ion-item-sliding'); + const box = (await item.boundingBox())!; + + // Few steps = high velocity gesture + const startX = config.direction === 'rtl' ? box.x + 30 : box.x + box.width - 10; + const endX = config.direction === 'rtl' ? box.x + box.width - 10 : box.x + 30; + const startY = box.y + box.height / 2; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, startY, { steps: 3 }); + await page.mouse.up(); + await ionSwipe.next(); + + expect(ionSwipe).toHaveReceivedEventTimes(1); + }); + }); +});