diff --git a/core/src/components/item-option/item-option.ionic.scss b/core/src/components/item-option/item-option.ionic.scss index 20506623d29..6ea452073db 100644 --- a/core/src/components/item-option/item-option.ionic.scss +++ b/core/src/components/item-option/item-option.ionic.scss @@ -100,9 +100,6 @@ color: globals.current-color(contrast); } -// Item Expandable Animation -// -------------------------------------------------- - -:host(.item-option-expandable) { - transition-timing-function: globals.$ion-transition-curve-expressive; +:host(.item-option-expand-threshold) { + filter: brightness(0.92); } diff --git a/core/src/components/item-sliding/item-sliding.scss b/core/src/components/item-sliding/item-sliding.common.scss similarity index 73% rename from core/src/components/item-sliding/item-sliding.scss rename to core/src/components/item-sliding/item-sliding.common.scss index 8a342f57960..2a2f21753fa 100644 --- a/core/src/components/item-sliding/item-sliding.scss +++ b/core/src/components/item-sliding/item-sliding.common.scss @@ -18,15 +18,14 @@ ion-item-sliding .item { user-select: none; } -.item-sliding-active-slide .item { - position: relative; - - transition: transform 500ms cubic-bezier(0.36, 0.66, 0.04, 1); +// During drag on native (ios/md), disable transition — matches former inline `transition: none` +.item-sliding-active-slide.item-sliding-dragging .item { + transition: none; +} - opacity: 1; - z-index: $z-index-item-options + 1; - pointer-events: none; - will-change: transform; +// Native full-swipe animation (250ms ease-out; replaces inline styles on `ion-item`) +.item-sliding-active-slide.item-sliding-full-swipe-transition .item { + transition: transform 250ms ease-out; } .item-sliding-closing ion-item-options { diff --git a/core/src/components/item-sliding/item-sliding.ionic.scss b/core/src/components/item-sliding/item-sliding.ionic.scss new file mode 100644 index 00000000000..d3501b8ccd3 --- /dev/null +++ b/core/src/components/item-sliding/item-sliding.ionic.scss @@ -0,0 +1,38 @@ +@use "./item-sliding.common"; +@use "../../themes/ionic/ionic.globals.scss" as globals; +@import "../../themes/native/native.globals"; + +// Transition utility classes +.item-sliding-transition-open .item { + transition: transform 250ms cubic-bezier(0.25, 1, 0.5, 1); +} + +.item-sliding-transition-snapback .item { + transition: transform globals.$ion-transition-time-300 globals.$ion-transition-curve-bounce; +} + +// Ionic full-swipe confirm sequence (replaces inline `transition` on item / expandable width) +.item-sliding-ionic-confirm-item-in .item { + transition: transform globals.$ion-transition-time-150 globals.$ion-transition-curve-base; +} + +.item-sliding-ionic-confirm-item-back .item { + transition: transform globals.$ion-transition-time-500 globals.$ion-transition-curve-bounce; +} + +ion-item-option.item-sliding-expandable-width-in { + transition: width globals.$ion-transition-time-150 globals.$ion-transition-curve-base; +} + +ion-item-option.item-sliding-expandable-width-back { + transition: width globals.$ion-transition-time-500 globals.$ion-transition-curve-bounce; +} + +.item-sliding-active-slide .item { + position: relative; + + opacity: 1; + z-index: $z-index-item-options + 1; + pointer-events: none; + will-change: transform; +} diff --git a/core/src/components/item-sliding/item-sliding.ios.scss b/core/src/components/item-sliding/item-sliding.ios.scss new file mode 100644 index 00000000000..3e1d00a2196 --- /dev/null +++ b/core/src/components/item-sliding/item-sliding.ios.scss @@ -0,0 +1,13 @@ +@use "./item-sliding.common"; +@import "../../themes/native/native.globals"; + +.item-sliding-active-slide .item { + position: relative; + + transition: transform 500ms cubic-bezier(0.36, 0.66, 0.04, 1); + + opacity: 1; + z-index: $z-index-item-options + 1; + pointer-events: none; + will-change: transform; +} diff --git a/core/src/components/item-sliding/item-sliding.md.scss b/core/src/components/item-sliding/item-sliding.md.scss new file mode 100644 index 00000000000..3e1d00a2196 --- /dev/null +++ b/core/src/components/item-sliding/item-sliding.md.scss @@ -0,0 +1,13 @@ +@use "./item-sliding.common"; +@import "../../themes/native/native.globals"; + +.item-sliding-active-slide .item { + position: relative; + + transition: transform 500ms cubic-bezier(0.36, 0.66, 0.04, 1); + + opacity: 1; + z-index: $z-index-item-options + 1; + pointer-events: none; + will-change: transform; +} diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index 1d70cbc12d4..279e9c3c442 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -11,6 +11,19 @@ import type { Side } from '../menu/menu-interface'; const SWIPE_MARGIN = 30; const ELASTIC_FACTOR = 0.55; +const IONIC_ELASTIC_FACTOR = 0.15; +const IONIC_SNAP_OPEN_RATIO = 0.4; +const IONIC_EXPAND_TRIGGER = 40; +const IONIC_FULL_SWIPE_VELOCITY_THRESHOLD = 400; +const IONIC_OPEN_VELOCITY_THRESHOLD = 200; +const IONIC_ACTION_BASE_WIDTH = 64; +const IONIC_CONFIRM_PAUSE = 300; +const FULL_SWIPE_TRANSITION_MS = 250; +const IONIC_EXPAND_RESISTANCE_FACTOR = 0.95; + +/** Expandable, non-disabled option (matches item-option expandable class). */ +const EXPANDABLE_OPTION_SELECTOR = 'ion-item-option.item-option-expandable:not(.item-option-disabled)'; +const ITEM_OPTION_EXPAND_THRESHOLD_CLASS = 'item-option-expand-threshold'; const enum ItemSide { None = 0, @@ -38,7 +51,11 @@ let openSlidingItem: HTMLIonItemSlidingElement | undefined; */ @Component({ tag: 'ion-item-sliding', - styleUrl: 'item-sliding.scss', + styleUrls: { + ios: 'item-sliding.ios.scss', + md: 'item-sliding.md.scss', + ionic: 'item-sliding.ionic.scss', + }, }) export class ItemSliding implements ComponentInterface { private item: HTMLIonItemElement | null = null; @@ -56,6 +73,12 @@ export class ItemSliding implements ComponentInterface { private contentEl: HTMLElement | null = null; private initialContentScrollY = true; private mutationObserver?: MutationObserver; + private leftExpandableBaseWidth = IONIC_ACTION_BASE_WIDTH; + private rightExpandableBaseWidth = IONIC_ACTION_BASE_WIDTH; + + private isIonicTheme(): boolean { + return getIonTheme(this) === 'ionic'; + } @Element() el!: HTMLIonItemSlidingElement; @@ -79,7 +102,6 @@ export class ItemSliding implements ComponentInterface { async connectedCallback() { const { el } = this; - this.item = el.querySelector('ion-item'); this.contentEl = findClosestIonContent(el); @@ -307,7 +329,7 @@ export class ItemSliding implements ComponentInterface { return resolve(); } - this.item.style.transition = `transform ${duration}ms ease-out`; + this.el.classList.add('item-sliding-full-swipe-transition'); this.item.style.transform = `translate3d(${-position}px, 0, 0)`; const id = setTimeout(resolve, duration); @@ -332,8 +354,8 @@ export class ItemSliding implements ComponentInterface { } /** - * 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. + * Native (ios/md) full swipe: off-screen → fire swipe → return. + * Ionic theme uses `animateIonicFullSwipe` instead (see `onEndIonic`). */ private async animateFullSwipe(direction: 'start' | 'end') { const abortController = new AbortController(); @@ -360,7 +382,7 @@ export class ItemSliding implements ComponentInterface { // Animate off-screen while maintaining the expanded state const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth; - await this.animateToPosition(offScreenDistance, 250, signal); + await this.animateToPosition(offScreenDistance, FULL_SWIPE_TRANSITION_MS, signal); // Trigger action if (options) { @@ -371,17 +393,195 @@ export class ItemSliding implements ComponentInterface { await this.delay(300, signal); // Return to closed state - await this.animateToPosition(0, 250, signal); + await this.animateToPosition(0, FULL_SWIPE_TRANSITION_MS, signal); } catch { // Animation was aborted (e.g. component disconnected). finally handles cleanup. } finally { this.animationAbortController = undefined; // Reset state + this.el.classList.remove('item-sliding-full-swipe-transition'); + if (this.item) { + 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 queryExpandableOption(options?: HTMLIonItemOptionsElement): HTMLIonItemOptionElement | undefined { + return options?.querySelector(EXPANDABLE_OPTION_SELECTOR) ?? undefined; + } + + private getExpandableOption(direction: 'start' | 'end'): HTMLIonItemOptionElement | undefined { + return this.queryExpandableOption(direction === 'end' ? this.rightOptions : this.leftOptions); + } + + private getOpenDirectionFromAmount(openAmount: number): 'start' | 'end' | undefined { + if (openAmount > 0) { + return 'end'; + } + if (openAmount < 0) { + return 'start'; + } + return undefined; + } + + private getOptionsWidthForDirection(direction: 'start' | 'end'): number { + return direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; + } + + private getExpandableBaseWidth(direction: 'start' | 'end'): number { + return direction === 'end' ? this.rightExpandableBaseWidth : this.leftExpandableBaseWidth; + } + + private setIonicExpandableWidth(direction: 'start' | 'end', width: number, opening: boolean) { + const expandableOption = this.getExpandableOption(direction); + if (!expandableOption) { + return; + } + + const style = expandableOption.style; + if (opening) { + expandableOption.classList.remove('item-sliding-expandable-snapback'); + expandableOption.classList.add('item-sliding-expandable-open'); + } else { + expandableOption.classList.remove('item-sliding-expandable-open'); + expandableOption.classList.add('item-sliding-expandable-snapback'); + } + const baseWidth = this.getExpandableBaseWidth(direction); + style.width = `${Math.max(baseWidth, width)}px`; + } + + private resetIonicExpandableOptions() { + [this.leftOptions, this.rightOptions].forEach((options) => { + if (!options) { + return; + } + options.querySelectorAll(EXPANDABLE_OPTION_SELECTOR).forEach((node) => { + node.classList.remove(ITEM_OPTION_EXPAND_THRESHOLD_CLASS); + }); + const expandableOption = this.queryExpandableOption(options); + if (!expandableOption) { + return; + } + expandableOption.style.width = ''; + expandableOption.classList.remove( + 'item-sliding-expandable-open', + 'item-sliding-expandable-snapback', + 'item-sliding-expandable-width-in', + 'item-sliding-expandable-width-back' + ); + }); + } + + private updateIonicExpandableFromOpenAmount(openAmount: number, isFinal: boolean, previousOpenAmount: number) { + if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) { + return; + } + + const direction = this.getOpenDirectionFromAmount(openAmount); + if (direction === undefined) { + const previousDirection = this.getOpenDirectionFromAmount(previousOpenAmount); + if (previousDirection === undefined) { + this.resetIonicExpandableOptions(); + return; + } + + this.queryExpandableOption(previousDirection === 'end' ? this.rightOptions : this.leftOptions)?.classList.remove( + ITEM_OPTION_EXPAND_THRESHOLD_CLASS + ); + + this.setIonicExpandableWidth(previousDirection, this.getExpandableBaseWidth(previousDirection), false); + return; + } + + const baseWidth = this.getExpandableBaseWidth(direction); + const optionsWidth = this.getOptionsWidthForDirection(direction); + const extraWidth = Math.max(0, Math.abs(openAmount) - optionsWidth); + const resistedExtraWidth = isFinal ? extraWidth : extraWidth * IONIC_EXPAND_RESISTANCE_FACTOR; + const targetWidth = baseWidth + resistedExtraWidth; + + const expandableOption = this.getExpandableOption(direction); + if (expandableOption) { + if (!isFinal && extraWidth >= IONIC_EXPAND_TRIGGER) { + expandableOption.classList.add(ITEM_OPTION_EXPAND_THRESHOLD_CLASS); + } else { + expandableOption.classList.remove(ITEM_OPTION_EXPAND_THRESHOLD_CLASS); + } + } + + this.setIonicExpandableWidth(direction, targetWidth, true); + } + + private async animateIonicFullSwipe(direction: 'start' | 'end') { + const abortController = new AbortController(); + this.animationAbortController = abortController; + const { signal } = abortController; + const expandableOption = this.getExpandableOption(direction); + const options = direction === 'end' ? this.rightOptions : this.leftOptions; + + if (this.gesture) { + this.gesture.enable(false); + } + + try { + this.state = + direction === 'end' + ? SlidingState.End | SlidingState.AnimatingFullSwipe + : SlidingState.Start | SlidingState.AnimatingFullSwipe; + + if (!this.item) { + return; + } + + const itemWidth = this.el.offsetWidth || window.innerWidth; + const baseWidth = this.getExpandableBaseWidth(direction); + const expandableTargetWidth = Math.max(baseWidth, itemWidth - 16); + const offScreenPosition = direction === 'end' ? itemWidth : -itemWidth; + + if (expandableOption) { + expandableOption.classList.remove('item-sliding-expandable-width-back'); + expandableOption.classList.add('item-sliding-expandable-width-in'); + expandableOption.style.width = `${expandableTargetWidth}px`; + } + + this.el.classList.remove('item-sliding-ionic-confirm-item-back'); + this.el.classList.add('item-sliding-ionic-confirm-item-in'); + this.item.style.transform = `translate3d(${-offScreenPosition}px, 0, 0)`; + await this.delay(150, signal); + + options?.fireSwipeEvent(); + await this.delay(IONIC_CONFIRM_PAUSE, signal); + + if (expandableOption) { + expandableOption.classList.remove('item-sliding-expandable-width-in'); + expandableOption.classList.add('item-sliding-expandable-width-back'); + expandableOption.style.width = `${baseWidth}px`; + } + + this.el.classList.remove('item-sliding-ionic-confirm-item-in'); + this.el.classList.add('item-sliding-ionic-confirm-item-back'); + this.item.style.transform = 'translate3d(0, 0, 0)'; + await this.delay(480, signal); + } catch { + // Animation was aborted. finally handles cleanup. + } finally { + this.animationAbortController = undefined; + + this.el.classList.remove('item-sliding-ionic-confirm-item-in', 'item-sliding-ionic-confirm-item-back'); if (this.item) { - this.item.style.transition = ''; this.item.style.transform = ''; } + this.resetIonicExpandableOptions(); this.openAmount = 0; this.state = SlidingState.Disabled; @@ -473,7 +673,11 @@ export class ItemSliding implements ComponentInterface { } this.initialOpenAmount = this.openAmount; if (this.item) { - this.item.style.transition = 'none'; + if (this.isIonicTheme()) { + this.el.classList.remove('item-sliding-transition-open', 'item-sliding-transition-snapback'); + } else { + this.el.classList.add('item-sliding-dragging'); + } } } @@ -499,24 +703,46 @@ export class ItemSliding implements ComponentInterface { break; } - let optsWidth; - if (openAmount > this.optsWidthRightSide) { - optsWidth = this.optsWidthRightSide; - openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR; - } else if (openAmount < -this.optsWidthLeftSide) { - optsWidth = -this.optsWidthLeftSide; - openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR; + if (this.isIonicTheme()) { + if (openAmount > this.optsWidthRightSide) { + const overDrag = openAmount - this.optsWidthRightSide; + openAmount = this.optsWidthRightSide + overDrag * IONIC_ELASTIC_FACTOR; + } else if (openAmount < -this.optsWidthLeftSide) { + const overDrag = openAmount + this.optsWidthLeftSide; + openAmount = -this.optsWidthLeftSide + overDrag * IONIC_ELASTIC_FACTOR; + } + } else { + let optsWidth: number; + if (openAmount > this.optsWidthRightSide) { + optsWidth = this.optsWidthRightSide; + openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR; + } else if (openAmount < -this.optsWidthLeftSide) { + optsWidth = -this.optsWidthLeftSide; + openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR; + } } this.setOpenAmount(openAmount, false); } private onEnd(gesture: GestureDetail) { + this.el.classList.remove('item-sliding-dragging'); + this.restoreContentScrollAfterSlide(); + if (this.isIonicTheme()) { + this.onEndIonic(gesture); + } else { + this.onEndNative(gesture); + } + } + + private restoreContentScrollAfterSlide() { const { contentEl, initialContentScrollY } = this; if (contentEl) { resetContentScrollY(contentEl, initialContentScrollY); } + } + private onEndNative(gesture: GestureDetail) { // Check for full swipe conditions with expandable options const rawSwipeDistance = Math.abs(gesture.deltaX); const direction = gesture.deltaX < 0 ? 'end' : 'start'; @@ -561,11 +787,68 @@ export class ItemSliding implements ComponentInterface { } } + private onEndIonic(gesture: GestureDetail) { + const velocity = gesture.velocityX; + const velocityX = velocity * 1000; + const activeDirection = this.getOpenDirectionFromAmount(this.openAmount); + if (activeDirection === undefined) { + this.setOpenAmount(0, true); + return; + } + + const optionsWidth = this.getOptionsWidthForDirection(activeDirection); + const extraWidth = Math.max(0, Math.abs(this.openAmount) - optionsWidth); + const hasExpandable = this.hasExpandableOptions(activeDirection === 'end' ? this.rightOptions : this.leftOptions); + + const closeDirection = + activeDirection === 'end' + ? velocityX > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD + : velocityX < -IONIC_FULL_SWIPE_VELOCITY_THRESHOLD; + + if (closeDirection) { + this.setOpenAmount(0, true); + return; + } + + if ( + hasExpandable && + (extraWidth >= IONIC_EXPAND_TRIGGER || + (extraWidth > 0 && Math.abs(velocityX) > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD)) + ) { + this.animateIonicFullSwipe(activeDirection).catch(() => { + if (this.gesture) { + this.gesture.enable(!this.disabled); + } + }); + return; + } + + const flickOpen = + activeDirection === 'end' + ? velocityX < -IONIC_OPEN_VELOCITY_THRESHOLD + : velocityX > IONIC_OPEN_VELOCITY_THRESHOLD; + + const fullOpen = activeDirection === 'end' ? this.optsWidthRightSide : -this.optsWidthLeftSide; + const openThreshold = optionsWidth * IONIC_SNAP_OPEN_RATIO; + const shouldSnapOpen = flickOpen || Math.abs(this.openAmount) > openThreshold; + const restingPoint = shouldSnapOpen ? fullOpen : 0; + + this.setOpenAmount(restingPoint, true); + } + private calculateOptsWidth() { this.optsWidthRightSide = 0; if (this.rightOptions) { this.rightOptions.style.display = 'flex'; this.optsWidthRightSide = this.rightOptions.offsetWidth; + const rightExpandable = this.queryExpandableOption(this.rightOptions); + if (rightExpandable) { + rightExpandable.style.width = ''; + this.rightExpandableBaseWidth = Math.max( + IONIC_ACTION_BASE_WIDTH, + rightExpandable.getBoundingClientRect().width + ); + } this.rightOptions.style.display = ''; } @@ -573,6 +856,11 @@ export class ItemSliding implements ComponentInterface { if (this.leftOptions) { this.leftOptions.style.display = 'flex'; this.optsWidthLeftSide = this.leftOptions.offsetWidth; + const leftExpandable = this.queryExpandableOption(this.leftOptions); + if (leftExpandable) { + leftExpandable.style.width = ''; + this.leftExpandableBaseWidth = Math.max(IONIC_ACTION_BASE_WIDTH, leftExpandable.getBoundingClientRect().width); + } this.leftOptions.style.display = ''; } @@ -589,38 +877,31 @@ export class ItemSliding implements ComponentInterface { } const { el } = this; - const style = this.item.style; + const previousOpenAmount = this.openAmount; this.openAmount = openAmount; - if (isFinal) { - style.transition = ''; + if (this.isIonicTheme()) { + this.updateIonicExpandableFromOpenAmount(openAmount, isFinal, previousOpenAmount); + } + + if (this.isIonicTheme() && isFinal) { + const closing = Math.abs(openAmount) < Math.abs(previousOpenAmount); + if (closing) { + this.el.classList.add('item-sliding-transition-snapback'); + } else { + this.el.classList.add('item-sliding-transition-open'); + } } if (openAmount > 0) { - this.state = - openAmount >= this.optsWidthRightSide + SWIPE_MARGIN - ? SlidingState.End | SlidingState.SwipeEnd - : SlidingState.End; + const fullSwipe = !this.isIonicTheme() && openAmount >= this.optsWidthRightSide + SWIPE_MARGIN; + this.state = fullSwipe ? SlidingState.End | SlidingState.SwipeEnd : SlidingState.End; } else if (openAmount < 0) { - this.state = - openAmount <= -this.optsWidthLeftSide - SWIPE_MARGIN - ? SlidingState.Start | SlidingState.SwipeStart - : SlidingState.Start; + const fullSwipe = !this.isIonicTheme() && openAmount <= -this.optsWidthLeftSide - SWIPE_MARGIN; + this.state = fullSwipe ? SlidingState.Start | SlidingState.SwipeStart : SlidingState.Start; } else { - /** - * The sliding options should not be - * clickable while the item is closing. - */ el.classList.add('item-sliding-closing'); - - /** - * Item sliding cannot be interrupted - * while closing the item. If it did, - * it would allow the item to get into an - * inconsistent state where multiple - * items are then open at the same time. - */ if (this.gesture) { this.gesture.enable(false); } @@ -637,6 +918,7 @@ export class ItemSliding implements ComponentInterface { style.transform = ''; return; } + style.transform = `translate3d(${-openAmount}px,0,0)`; this.ionDrag.emit({ amount: openAmount, 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 index 1ba692bf5a3..e95744ae651 100644 --- 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 @@ -2,10 +2,6 @@ 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. @@ -14,7 +10,7 @@ import { configs, dragElementBy, test } from '@utils/test/playwright'; // 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 }) => { +configs({ modes: ['ios', '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( @@ -58,6 +54,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac const ionSwipe = await page.spyOnEvent('ionSwipe'); const item = page.locator('ion-item-sliding'); + await expect(item).toBeVisible(); + await page.waitForChanges(); const dragByX = config.direction === 'rtl' ? -190 : 190; await dragElementBy(item, page, dragByX); @@ -82,6 +80,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac ); const item = page.locator('ion-item-sliding'); + await expect(item).toBeVisible(); + await page.waitForChanges(); const dragByX = config.direction === 'rtl' ? 190 : -190; await dragElementBy(item, page, dragByX); @@ -108,6 +108,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac const ionSwipe = await page.spyOnEvent('ionSwipe'); const item = page.locator('ion-item-sliding'); + await expect(item).toBeVisible(); + await page.waitForChanges(); const dragByX = config.direction === 'rtl' ? 180 : -180; await dragElementBy(item, page, dragByX); @@ -138,6 +140,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac const ionSwipe = await page.spyOnEvent('ionSwipe'); const item = page.locator('ion-item-sliding'); + await expect(item).toBeVisible(); + await page.waitForChanges(); const dragByX = config.direction === 'rtl' ? 190 : -190; await dragElementBy(item, page, dragByX); @@ -148,6 +152,155 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac }); }); +/** + * Test for Ionic theme that has a different full swipe animation behavior. + */ +configs({ modes: ['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'); + await expect(item).toBeVisible(); + await page.waitForChanges(); + const box = (await item.boundingBox())!; + const y = box.y + box.height / 2; + + // 1) Peek open (distance-based; moderate steps is fine) + const peek = config.direction === 'rtl' ? 120 : -120; + await dragElementBy(item, page, peek); + + // 2) Fast flick in the same direction as “full swipe” + const startX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40; + const endX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40; + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 2 }); // try 1–3; lower = faster + await page.mouse.up(); + 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'); + await expect(item).toBeVisible(); + await page.waitForChanges(); + const box = (await item.boundingBox())!; + const y = box.y + box.height / 2; + + // 1) Peek open (distance-based; moderate steps is fine) + const peek = config.direction === 'rtl' ? -120 : 120; + await dragElementBy(item, page, peek); + + // 2) Fast flick in the same direction as “full swipe” + const startX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40; + const endX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40; + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 2 }); + await page.mouse.up(); + 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'); + await expect(item).toBeVisible(); + await page.waitForChanges(); + const box = (await item.boundingBox())!; + const y = box.y + box.height / 2; + + // 1) Peek open (distance-based; moderate steps is fine) + const peek = config.direction === 'rtl' ? -120 : 120; + await dragElementBy(item, page, peek); + + // 2) Fast flick in the same direction as “full swipe” + const startX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40; + const endX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40; + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 2 }); + await page.mouse.up(); + 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'); + await expect(item).toBeVisible(); + await page.waitForChanges(); + const dragByX = config.direction === 'rtl' ? 180 : -180; + + await dragElementBy(item, page, dragByX); + await page.waitForChanges(); + + expect(ionSwipe).toHaveReceivedEventTimes(0); + + const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount()); + + expect(Math.abs(openAmount)).toBeGreaterThan(0); + }); + }); +}); + /** * Velocity-based trigger: a fast short swipe should trigger the full animation * even if the raw distance alone wouldn't exceed the threshold. @@ -172,6 +325,8 @@ configs({ modes: ['md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config const ionSwipe = await page.spyOnEvent('ionSwipe'); const item = page.locator('ion-item-sliding'); + await expect(item).toBeVisible(); + await page.waitForChanges(); const box = (await item.boundingBox())!; // Few steps = high velocity gesture