From a6ea277bcdfc252223030bfe57e44c4bcd4a9638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Louren=C3=A7o?= Date: Tue, 28 Apr 2026 17:08:46 +0100 Subject: [PATCH 1/6] feat(item-sliding): added specific animations for ionic --- .../item-option/item-option.ionic.scss | 7 - .../components/item-sliding/item-sliding.tsx | 268 +++++++++++++++++- 2 files changed, 256 insertions(+), 19 deletions(-) diff --git a/core/src/components/item-option/item-option.ionic.scss b/core/src/components/item-option/item-option.ionic.scss index 20506623d29..7cccea4ad19 100644 --- a/core/src/components/item-option/item-option.ionic.scss +++ b/core/src/components/item-option/item-option.ionic.scss @@ -99,10 +99,3 @@ background: globals.current-color(base); color: globals.current-color(contrast); } - -// Item Expandable Animation -// -------------------------------------------------- - -:host(.item-option-expandable) { - transition-timing-function: globals.$ion-transition-curve-expressive; -} diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index 1d70cbc12d4..c7146f4a948 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_SNAP_OPEN_RATIO = 0.4; +const IONIC_EXPAND_TRIGGER = 80; +const IONIC_VELOCITY_THRESHOLD = 0.4; +const IONIC_ACTION_BASE_WIDTH = 64; +const IONIC_OPEN_TRANSITION = '250ms cubic-bezier(0.25, 1, 0.5, 1)'; +const IONIC_SNAPBACK_TRANSITION = '300ms cubic-bezier(0.34, 1.4, 0.64, 1)'; +const IONIC_CONFIRM_EASE_IN = '150ms ease-in'; +const IONIC_CONFIRM_SNAPBACK = '480ms cubic-bezier(0.34, 1.4, 0.64, 1)'; +const IONIC_CONFIRM_PAUSE = 900; +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 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.scss', + md: 'item-sliding.scss', + ionic: 'item-sliding.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); @@ -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(); @@ -395,6 +417,157 @@ export class ItemSliding implements ComponentInterface { } } + 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, animate: boolean, easing?: string) { + const expandableOption = this.getExpandableOption(direction); + if (!expandableOption) { + return; + } + + const style = expandableOption.style; + style.transition = animate ? `width ${easing ?? IONIC_OPEN_TRANSITION}` : 'none'; + const baseWidth = this.getExpandableBaseWidth(direction); + style.width = `${Math.max(baseWidth, width)}px`; + } + + private resetIonicExpandableOptions() { + [this.leftOptions, this.rightOptions].forEach((options) => { + const expandableOption = this.queryExpandableOption(options); + if (!expandableOption) { + return; + } + expandableOption.style.transition = ''; + expandableOption.style.width = ''; + }); + } + + 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.setIonicExpandableWidth( + previousDirection, + this.getExpandableBaseWidth(previousDirection), + isFinal, + IONIC_SNAPBACK_TRANSITION + ); + 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 easing = openAmount === 0 ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION; + + this.setIonicExpandableWidth(direction, targetWidth, isFinal, easing); + } + + 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.style.transition = `width ${IONIC_CONFIRM_EASE_IN}`; + expandableOption.style.width = `${expandableTargetWidth}px`; + + } + + this.item.style.transition = `transform ${IONIC_CONFIRM_EASE_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.style.transition = `width ${IONIC_CONFIRM_SNAPBACK}`; + expandableOption.style.width = `${baseWidth}px`; + } + + this.item.style.transition = `transform ${IONIC_CONFIRM_SNAPBACK}`; + this.item.style.transform = 'translate3d(0, 0, 0)'; + await this.delay(480, signal); + } catch { + // Animation was aborted. finally handles cleanup. + } finally { + this.animationAbortController = undefined; + + if (this.item) { + this.item.style.transition = ''; + this.item.style.transform = ''; + } + this.resetIonicExpandableOptions(); + 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'); @@ -512,11 +685,22 @@ export class ItemSliding implements ComponentInterface { } private onEnd(gesture: GestureDetail) { + 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 +745,65 @@ 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 wasRevealed = Math.abs(this.initialOpenAmount) >= optionsWidth; + + if ( + hasExpandable && + (extraWidth >= IONIC_EXPAND_TRIGGER || (wasRevealed && velocityX < Math.abs(IONIC_VELOCITY_THRESHOLD * 1000))) + ) { + this.animateIonicFullSwipe(activeDirection).catch(() => { + if (this.gesture) { + this.gesture.enable(!this.disabled); + } + }); + return; + } + + const closeDirection = + activeDirection === 'end' + ? velocityX > IONIC_VELOCITY_THRESHOLD * 1000 + : velocityX < -IONIC_VELOCITY_THRESHOLD * 1000; + if (closeDirection) { + this.setOpenAmount(0, true); + return; + } + + const openThreshold = optionsWidth * IONIC_SNAP_OPEN_RATIO; + const shouldSnapOpen = Math.abs(this.openAmount) > openThreshold; + const restingPoint = shouldSnapOpen + ? activeDirection === 'end' + ? this.optsWidthRightSide + : -this.optsWidthLeftSide + : 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 +811,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 = ''; } @@ -591,22 +834,23 @@ export class ItemSliding implements ComponentInterface { const { el } = this; const style = this.item.style; + const previousOpenAmount = this.openAmount; this.openAmount = openAmount; + if (this.isIonicTheme()) { + this.updateIonicExpandableFromOpenAmount(openAmount, isFinal, previousOpenAmount); + } + if (isFinal) { style.transition = ''; } 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 From 9675242ff8f0097aa4e6d3cc04cfbd44faa3e76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Louren=C3=A7o?= Date: Wed, 29 Apr 2026 11:41:46 +0100 Subject: [PATCH 2/6] feat(item-sliding): added specific swipe animations for ionic --- .../item-option/item-option.ionic.scss | 4 + ...-sliding.scss => item-sliding.common.scss} | 11 --- .../item-sliding/item-sliding.ionic.scss | 11 +++ .../item-sliding/item-sliding.ios.scss | 13 +++ .../item-sliding/item-sliding.md.scss | 13 +++ .../components/item-sliding/item-sliding.tsx | 94 ++++++++++++------- 6 files changed, 101 insertions(+), 45 deletions(-) rename core/src/components/item-sliding/{item-sliding.scss => item-sliding.common.scss} (81%) create mode 100644 core/src/components/item-sliding/item-sliding.ionic.scss create mode 100644 core/src/components/item-sliding/item-sliding.ios.scss create mode 100644 core/src/components/item-sliding/item-sliding.md.scss diff --git a/core/src/components/item-option/item-option.ionic.scss b/core/src/components/item-option/item-option.ionic.scss index 7cccea4ad19..35ff2e51e25 100644 --- a/core/src/components/item-option/item-option.ionic.scss +++ b/core/src/components/item-option/item-option.ionic.scss @@ -99,3 +99,7 @@ background: globals.current-color(base); color: globals.current-color(contrast); } + +:host(.item-option-expand-threshold) { + filter: brightness(0.92); +} \ No newline at end of file diff --git a/core/src/components/item-sliding/item-sliding.scss b/core/src/components/item-sliding/item-sliding.common.scss similarity index 81% rename from core/src/components/item-sliding/item-sliding.scss rename to core/src/components/item-sliding/item-sliding.common.scss index 8a342f57960..c8d62657f55 100644 --- a/core/src/components/item-sliding/item-sliding.scss +++ b/core/src/components/item-sliding/item-sliding.common.scss @@ -18,17 +18,6 @@ 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); - - opacity: 1; - z-index: $z-index-item-options + 1; - pointer-events: none; - will-change: transform; -} - .item-sliding-closing ion-item-options { pointer-events: none; } 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..7eb39403e7a --- /dev/null +++ b/core/src/components/item-sliding/item-sliding.ionic.scss @@ -0,0 +1,11 @@ +@use "./item-sliding.common"; +@import "../../themes/native/native.globals"; + +.item-sliding-active-slide .item { + position: relative; + + opacity: 1; + z-index: $z-index-item-options + 1; + pointer-events: none; + will-change: transform; +} \ No newline at end of file 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..4e4671244c1 --- /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; +} \ No newline at end of file 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..77b692579b1 --- /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; + } \ No newline at end of file diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index c7146f4a948..4f11609c9f0 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -11,6 +11,7 @@ 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 = 80; const IONIC_VELOCITY_THRESHOLD = 0.4; @@ -19,11 +20,12 @@ const IONIC_OPEN_TRANSITION = '250ms cubic-bezier(0.25, 1, 0.5, 1)'; const IONIC_SNAPBACK_TRANSITION = '300ms cubic-bezier(0.34, 1.4, 0.64, 1)'; const IONIC_CONFIRM_EASE_IN = '150ms ease-in'; const IONIC_CONFIRM_SNAPBACK = '480ms cubic-bezier(0.34, 1.4, 0.64, 1)'; -const IONIC_CONFIRM_PAUSE = 900; +const IONIC_CONFIRM_PAUSE = 300; 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, @@ -52,9 +54,9 @@ let openSlidingItem: HTMLIonItemSlidingElement | undefined; @Component({ tag: 'ion-item-sliding', styleUrls: { - ios: 'item-sliding.scss', - md: 'item-sliding.scss', - ionic: 'item-sliding.scss', + ios: 'item-sliding.ios.scss', + md: 'item-sliding.md.scss', + ionic: 'item-sliding.ionic.scss', }, }) export class ItemSliding implements ComponentInterface { @@ -457,6 +459,12 @@ export class ItemSliding implements ComponentInterface { 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; @@ -479,6 +487,10 @@ export class ItemSliding implements ComponentInterface { return; } + this.queryExpandableOption(previousDirection === 'end' ? this.rightOptions : this.leftOptions)?.classList.remove( + ITEM_OPTION_EXPAND_THRESHOLD_CLASS + ); + this.setIonicExpandableWidth( previousDirection, this.getExpandableBaseWidth(previousDirection), @@ -495,6 +507,15 @@ export class ItemSliding implements ComponentInterface { const targetWidth = baseWidth + resistedExtraWidth; const easing = openAmount === 0 ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION; + 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, isFinal, easing); } @@ -672,13 +693,23 @@ 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); @@ -759,9 +790,16 @@ export class ItemSliding implements ComponentInterface { const hasExpandable = this.hasExpandableOptions(activeDirection === 'end' ? this.rightOptions : this.leftOptions); const wasRevealed = Math.abs(this.initialOpenAmount) >= optionsWidth; + + const closeDirection = + activeDirection === 'end' + ? velocityX > IONIC_VELOCITY_THRESHOLD * 1000 + : velocityX < -IONIC_VELOCITY_THRESHOLD * 1000; + if ( + !closeDirection && hasExpandable && - (extraWidth >= IONIC_EXPAND_TRIGGER || (wasRevealed && velocityX < Math.abs(IONIC_VELOCITY_THRESHOLD * 1000))) + (extraWidth >= IONIC_EXPAND_TRIGGER || (extraWidth > 0 && (wasRevealed && velocityX < Math.abs(IONIC_VELOCITY_THRESHOLD * 1000)))) ) { this.animateIonicFullSwipe(activeDirection).catch(() => { if (this.gesture) { @@ -771,10 +809,7 @@ export class ItemSliding implements ComponentInterface { return; } - const closeDirection = - activeDirection === 'end' - ? velocityX > IONIC_VELOCITY_THRESHOLD * 1000 - : velocityX < -IONIC_VELOCITY_THRESHOLD * 1000; + if (closeDirection) { this.setOpenAmount(0, true); return; @@ -830,21 +865,23 @@ export class ItemSliding implements ComponentInterface { if (!this.item) { return; } - + const { el } = this; - const style = this.item.style; const previousOpenAmount = this.openAmount; this.openAmount = openAmount; - + if (this.isIonicTheme()) { this.updateIonicExpandableFromOpenAmount(openAmount, isFinal, previousOpenAmount); } - if (isFinal) { + if (this.isIonicTheme() && isFinal) { + const closing = Math.abs(openAmount) < Math.abs(previousOpenAmount); + style.transition = `transform ${closing ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION}`; + } else if (isFinal) { style.transition = ''; } - + if (openAmount > 0) { const fullSwipe = !this.isIonicTheme() && openAmount >= this.optsWidthRightSide + SWIPE_MARGIN; this.state = fullSwipe ? SlidingState.End | SlidingState.SwipeEnd : SlidingState.End; @@ -852,19 +889,7 @@ export class ItemSliding implements ComponentInterface { 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); } @@ -876,11 +901,12 @@ export class ItemSliding implements ComponentInterface { } el.classList.remove('item-sliding-closing'); }, 600); - + openSlidingItem = undefined; style.transform = ''; return; } + style.transform = `translate3d(${-openAmount}px,0,0)`; this.ionDrag.emit({ amount: openAmount, From 4ede42f04d0d6609db3c2b56c1a7c70766f2eb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Louren=C3=A7o?= Date: Wed, 29 Apr 2026 12:54:01 +0100 Subject: [PATCH 3/6] feat(item-sliding): added specific classes for swipe animations for ionic --- .../item-sliding/item-sliding.common.scss | 10 +++ .../item-sliding/item-sliding.ionic.scss | 29 ++++++++ .../components/item-sliding/item-sliding.tsx | 69 +++++++++++-------- 3 files changed, 81 insertions(+), 27 deletions(-) diff --git a/core/src/components/item-sliding/item-sliding.common.scss b/core/src/components/item-sliding/item-sliding.common.scss index c8d62657f55..2a2f21753fa 100644 --- a/core/src/components/item-sliding/item-sliding.common.scss +++ b/core/src/components/item-sliding/item-sliding.common.scss @@ -18,6 +18,16 @@ ion-item-sliding .item { user-select: none; } +// During drag on native (ios/md), disable transition — matches former inline `transition: none` +.item-sliding-active-slide.item-sliding-dragging .item { + transition: none; +} + +// 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 { pointer-events: none; } diff --git a/core/src/components/item-sliding/item-sliding.ionic.scss b/core/src/components/item-sliding/item-sliding.ionic.scss index 7eb39403e7a..36843c17d37 100644 --- a/core/src/components/item-sliding/item-sliding.ionic.scss +++ b/core/src/components/item-sliding/item-sliding.ionic.scss @@ -1,6 +1,35 @@ @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; diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index 4f11609c9f0..1d544686ee6 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -16,11 +16,8 @@ const IONIC_SNAP_OPEN_RATIO = 0.4; const IONIC_EXPAND_TRIGGER = 80; const IONIC_VELOCITY_THRESHOLD = 0.4; const IONIC_ACTION_BASE_WIDTH = 64; -const IONIC_OPEN_TRANSITION = '250ms cubic-bezier(0.25, 1, 0.5, 1)'; -const IONIC_SNAPBACK_TRANSITION = '300ms cubic-bezier(0.34, 1.4, 0.64, 1)'; -const IONIC_CONFIRM_EASE_IN = '150ms ease-in'; -const IONIC_CONFIRM_SNAPBACK = '480ms cubic-bezier(0.34, 1.4, 0.64, 1)'; 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). */ @@ -331,7 +328,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); @@ -384,7 +381,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) { @@ -395,15 +392,15 @@ 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.transition = ''; this.item.style.transform = ''; } this.openAmount = 0; @@ -445,14 +442,20 @@ export class ItemSliding implements ComponentInterface { return direction === 'end' ? this.rightExpandableBaseWidth : this.leftExpandableBaseWidth; } - private setIonicExpandableWidth(direction: 'start' | 'end', width: number, animate: boolean, easing?: string) { + private setIonicExpandableWidth(direction: 'start' | 'end', width: number, opening: boolean) { const expandableOption = this.getExpandableOption(direction); if (!expandableOption) { return; } const style = expandableOption.style; - style.transition = animate ? `width ${easing ?? IONIC_OPEN_TRANSITION}` : 'none'; + 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`; } @@ -469,8 +472,13 @@ export class ItemSliding implements ComponentInterface { if (!expandableOption) { return; } - expandableOption.style.transition = ''; expandableOption.style.width = ''; + expandableOption.classList.remove( + 'item-sliding-expandable-open', + 'item-sliding-expandable-snapback', + 'item-sliding-expandable-width-in', + 'item-sliding-expandable-width-back' + ); }); } @@ -489,13 +497,12 @@ export class ItemSliding implements ComponentInterface { this.queryExpandableOption(previousDirection === 'end' ? this.rightOptions : this.leftOptions)?.classList.remove( ITEM_OPTION_EXPAND_THRESHOLD_CLASS - ); + ); this.setIonicExpandableWidth( previousDirection, this.getExpandableBaseWidth(previousDirection), - isFinal, - IONIC_SNAPBACK_TRANSITION + false ); return; } @@ -505,7 +512,6 @@ export class ItemSliding implements ComponentInterface { const extraWidth = Math.max(0, Math.abs(openAmount) - optionsWidth); const resistedExtraWidth = isFinal ? extraWidth : extraWidth * IONIC_EXPAND_RESISTANCE_FACTOR; const targetWidth = baseWidth + resistedExtraWidth; - const easing = openAmount === 0 ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION; const expandableOption = this.getExpandableOption(direction); if (expandableOption) { @@ -516,7 +522,7 @@ export class ItemSliding implements ComponentInterface { } } - this.setIonicExpandableWidth(direction, targetWidth, isFinal, easing); + this.setIonicExpandableWidth(direction, targetWidth, true); } private async animateIonicFullSwipe(direction: 'start' | 'end') { @@ -546,12 +552,13 @@ export class ItemSliding implements ComponentInterface { const offScreenPosition = direction === 'end' ? itemWidth : -itemWidth; if (expandableOption) { - expandableOption.style.transition = `width ${IONIC_CONFIRM_EASE_IN}`; + expandableOption.classList.remove('item-sliding-expandable-width-back'); + expandableOption.classList.add('item-sliding-expandable-width-in'); expandableOption.style.width = `${expandableTargetWidth}px`; - } - this.item.style.transition = `transform ${IONIC_CONFIRM_EASE_IN}`; + 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); @@ -559,11 +566,13 @@ export class ItemSliding implements ComponentInterface { await this.delay(IONIC_CONFIRM_PAUSE, signal); if (expandableOption) { - expandableOption.style.transition = `width ${IONIC_CONFIRM_SNAPBACK}`; + expandableOption.classList.remove('item-sliding-expandable-width-in'); + expandableOption.classList.add('item-sliding-expandable-width-back'); expandableOption.style.width = `${baseWidth}px`; } - this.item.style.transition = `transform ${IONIC_CONFIRM_SNAPBACK}`; + 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 { @@ -571,8 +580,8 @@ export class ItemSliding implements ComponentInterface { } 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(); @@ -667,7 +676,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'); + } } } @@ -716,6 +729,7 @@ export class ItemSliding implements ComponentInterface { } private onEnd(gesture: GestureDetail) { + this.el.classList.remove('item-sliding-dragging'); this.restoreContentScrollAfterSlide(); if (this.isIonicTheme()) { this.onEndIonic(gesture); @@ -809,7 +823,6 @@ export class ItemSliding implements ComponentInterface { return; } - if (closeDirection) { this.setOpenAmount(0, true); return; @@ -877,9 +890,11 @@ export class ItemSliding implements ComponentInterface { if (this.isIonicTheme() && isFinal) { const closing = Math.abs(openAmount) < Math.abs(previousOpenAmount); - style.transition = `transform ${closing ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION}`; - } else if (isFinal) { - style.transition = ''; + if (closing) { + this.el.classList.add('item-sliding-transition-snapback'); + } else { + this.el.classList.add('item-sliding-transition-open'); + } } if (openAmount > 0) { From c0338abf19787ea0b2b1c4b4de39b4420b90b224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Louren=C3=A7o?= Date: Wed, 29 Apr 2026 16:59:08 +0100 Subject: [PATCH 4/6] feat(item-sliding): corrected ionic thresholds --- core/src/components/item-sliding/item-sliding.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index 1d544686ee6..08c164c4918 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -13,8 +13,8 @@ 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 = 80; -const IONIC_VELOCITY_THRESHOLD = 0.4; +const IONIC_EXPAND_TRIGGER = 40; +const IONIC_VELOCITY_THRESHOLD = 400; const IONIC_ACTION_BASE_WIDTH = 64; const IONIC_CONFIRM_PAUSE = 300; const FULL_SWIPE_TRANSITION_MS = 250; @@ -806,14 +806,12 @@ export class ItemSliding implements ComponentInterface { const closeDirection = - activeDirection === 'end' - ? velocityX > IONIC_VELOCITY_THRESHOLD * 1000 - : velocityX < -IONIC_VELOCITY_THRESHOLD * 1000; + activeDirection === 'end' ? velocityX > IONIC_VELOCITY_THRESHOLD : velocityX < -IONIC_VELOCITY_THRESHOLD; if ( !closeDirection && hasExpandable && - (extraWidth >= IONIC_EXPAND_TRIGGER || (extraWidth > 0 && (wasRevealed && velocityX < Math.abs(IONIC_VELOCITY_THRESHOLD * 1000)))) + (extraWidth >= IONIC_EXPAND_TRIGGER || (extraWidth > 0 && (wasRevealed && Math.abs(velocityX) > IONIC_VELOCITY_THRESHOLD))) ) { this.animateIonicFullSwipe(activeDirection).catch(() => { if (this.gesture) { From a247ac5867d6fb64a1dddd75f2f0b43bdca28a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Louren=C3=A7o?= Date: Thu, 30 Apr 2026 11:38:28 +0100 Subject: [PATCH 5/6] feat(item-sliding): added velocity validation for open options --- .../item-option/item-option.ionic.scss | 2 +- .../item-sliding/item-sliding.ionic.scss | 4 +- .../item-sliding/item-sliding.ios.scss | 2 +- .../item-sliding/item-sliding.md.scss | 18 +- .../components/item-sliding/item-sliding.tsx | 50 +++--- .../test/full-swipe/item-sliding.e2e.ts | 165 +++++++++++++++++- 6 files changed, 196 insertions(+), 45 deletions(-) diff --git a/core/src/components/item-option/item-option.ionic.scss b/core/src/components/item-option/item-option.ionic.scss index 35ff2e51e25..6ea452073db 100644 --- a/core/src/components/item-option/item-option.ionic.scss +++ b/core/src/components/item-option/item-option.ionic.scss @@ -102,4 +102,4 @@ :host(.item-option-expand-threshold) { filter: brightness(0.92); -} \ No newline at end of file +} diff --git a/core/src/components/item-sliding/item-sliding.ionic.scss b/core/src/components/item-sliding/item-sliding.ionic.scss index 36843c17d37..d3501b8ccd3 100644 --- a/core/src/components/item-sliding/item-sliding.ionic.scss +++ b/core/src/components/item-sliding/item-sliding.ionic.scss @@ -2,8 +2,6 @@ @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); @@ -37,4 +35,4 @@ ion-item-option.item-sliding-expandable-width-back { z-index: $z-index-item-options + 1; pointer-events: none; will-change: transform; -} \ No newline at end of file +} diff --git a/core/src/components/item-sliding/item-sliding.ios.scss b/core/src/components/item-sliding/item-sliding.ios.scss index 4e4671244c1..3e1d00a2196 100644 --- a/core/src/components/item-sliding/item-sliding.ios.scss +++ b/core/src/components/item-sliding/item-sliding.ios.scss @@ -10,4 +10,4 @@ z-index: $z-index-item-options + 1; pointer-events: none; will-change: transform; -} \ No newline at end of file +} diff --git a/core/src/components/item-sliding/item-sliding.md.scss b/core/src/components/item-sliding/item-sliding.md.scss index 77b692579b1..3e1d00a2196 100644 --- a/core/src/components/item-sliding/item-sliding.md.scss +++ b/core/src/components/item-sliding/item-sliding.md.scss @@ -2,12 +2,12 @@ @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; - } \ No newline at end of file + 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 08c164c4918..d1ebced0dad 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -14,7 +14,8 @@ 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_VELOCITY_THRESHOLD = 400; +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; @@ -499,11 +500,7 @@ export class ItemSliding implements ComponentInterface { ITEM_OPTION_EXPAND_THRESHOLD_CLASS ); - this.setIonicExpandableWidth( - previousDirection, - this.getExpandableBaseWidth(previousDirection), - false - ); + this.setIonicExpandableWidth(previousDirection, this.getExpandableBaseWidth(previousDirection), false); return; } @@ -802,16 +799,20 @@ export class ItemSliding implements ComponentInterface { 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 wasRevealed = Math.abs(this.initialOpenAmount) >= optionsWidth; - const closeDirection = - activeDirection === 'end' ? velocityX > IONIC_VELOCITY_THRESHOLD : velocityX < -IONIC_VELOCITY_THRESHOLD; + activeDirection === 'end' + ? velocityX > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD + : velocityX < -IONIC_FULL_SWIPE_VELOCITY_THRESHOLD; + + if (closeDirection) { + this.setOpenAmount(0, true); + return; + } if ( - !closeDirection && hasExpandable && - (extraWidth >= IONIC_EXPAND_TRIGGER || (extraWidth > 0 && (wasRevealed && Math.abs(velocityX) > IONIC_VELOCITY_THRESHOLD))) + (extraWidth >= IONIC_EXPAND_TRIGGER || Math.abs(velocityX) > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD) ) { this.animateIonicFullSwipe(activeDirection).catch(() => { if (this.gesture) { @@ -821,18 +822,15 @@ export class ItemSliding implements ComponentInterface { return; } - if (closeDirection) { - this.setOpenAmount(0, true); - 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 = Math.abs(this.openAmount) > openThreshold; - const restingPoint = shouldSnapOpen - ? activeDirection === 'end' - ? this.optsWidthRightSide - : -this.optsWidthLeftSide - : 0; + const shouldSnapOpen = flickOpen || Math.abs(this.openAmount) > openThreshold; + const restingPoint = shouldSnapOpen ? fullOpen : 0; this.setOpenAmount(restingPoint, true); } @@ -876,12 +874,12 @@ export class ItemSliding implements ComponentInterface { if (!this.item) { return; } - + const { el } = this; const style = this.item.style; const previousOpenAmount = this.openAmount; this.openAmount = openAmount; - + if (this.isIonicTheme()) { this.updateIonicExpandableFromOpenAmount(openAmount, isFinal, previousOpenAmount); } @@ -894,7 +892,7 @@ export class ItemSliding implements ComponentInterface { this.el.classList.add('item-sliding-transition-open'); } } - + if (openAmount > 0) { const fullSwipe = !this.isIonicTheme() && openAmount >= this.optsWidthRightSide + SWIPE_MARGIN; this.state = fullSwipe ? SlidingState.End | SlidingState.SwipeEnd : SlidingState.End; @@ -914,12 +912,12 @@ export class ItemSliding implements ComponentInterface { } el.classList.remove('item-sliding-closing'); }, 600); - + openSlidingItem = undefined; 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 From 7468b4cc28f9522365284408bd8dc953d4e111a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Louren=C3=A7o?= Date: Thu, 30 Apr 2026 12:45:48 +0100 Subject: [PATCH 6/6] feat(item-sliding): improve full swipe animation separation --- core/src/components/item-sliding/item-sliding.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index d1ebced0dad..279e9c3c442 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -812,7 +812,8 @@ export class ItemSliding implements ComponentInterface { if ( hasExpandable && - (extraWidth >= IONIC_EXPAND_TRIGGER || Math.abs(velocityX) > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD) + (extraWidth >= IONIC_EXPAND_TRIGGER || + (extraWidth > 0 && Math.abs(velocityX) > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD)) ) { this.animateIonicFullSwipe(activeDirection).catch(() => { if (this.gesture) {