@@ -27,6 +27,7 @@ const enum SlidingState {
2727
2828 SwipeEnd = 1 << 5 ,
2929 SwipeStart = 1 << 6 ,
30+ AnimatingFullSwipe = 1 << 7 ,
3031}
3132
3233let openSlidingItem : HTMLIonItemSlidingElement | undefined ;
@@ -47,6 +48,7 @@ export class ItemSliding implements ComponentInterface {
4748 private optsWidthLeftSide = 0 ;
4849 private sides = ItemSide . None ;
4950 private tmr ?: ReturnType < typeof setTimeout > ;
51+ private animationAbortController ?: AbortController ;
5052 private leftOptions ?: HTMLIonItemOptionsElement ;
5153 private rightOptions ?: HTMLIonItemOptionsElement ;
5254 private optsDirty = true ;
@@ -113,6 +115,15 @@ export class ItemSliding implements ComponentInterface {
113115 this . gesture = undefined ;
114116 }
115117
118+ if ( this . tmr !== undefined ) {
119+ clearTimeout ( this . tmr ) ;
120+ this . tmr = undefined ;
121+ }
122+
123+ // Abort any in-progress animation. The abort handler rejects the pending
124+ // promise, causing animateFullSwipe's finally block to run cleanup.
125+ this . animationAbortController ?. abort ( ) ;
126+
116127 this . item = null ;
117128 this . leftOptions = this . rightOptions = undefined ;
118129
@@ -153,6 +164,10 @@ export class ItemSliding implements ComponentInterface {
153164 */
154165 @Method ( )
155166 async open ( side : Side | undefined ) {
167+ if ( ( this . state & SlidingState . AnimatingFullSwipe ) !== 0 ) {
168+ return ;
169+ }
170+
156171 /**
157172 * It is possible for the item to be added to the DOM
158173 * after the item-sliding component was created. As a result,
@@ -216,6 +231,9 @@ export class ItemSliding implements ComponentInterface {
216231 */
217232 @Method ( )
218233 async close ( ) {
234+ if ( ( this . state & SlidingState . AnimatingFullSwipe ) !== 0 ) {
235+ return ;
236+ }
219237 this . setOpenAmount ( 0 , true ) ;
220238 }
221239
@@ -248,6 +266,135 @@ export class ItemSliding implements ComponentInterface {
248266 }
249267 }
250268
269+ /**
270+ * Check if the given item options element contains at least one expandable, non-disabled option.
271+ */
272+ private hasExpandableOptions ( options : HTMLIonItemOptionsElement | undefined ) : boolean {
273+ if ( ! options ) return false ;
274+
275+ const optionElements = options . querySelectorAll ( 'ion-item-option' ) ;
276+ return Array . from ( optionElements ) . some ( ( option : any ) => {
277+ return option . expandable === true && ! option . disabled ;
278+ } ) ;
279+ }
280+
281+ /**
282+ * Returns a Promise that resolves after `ms` milliseconds, or rejects if the
283+ * given AbortSignal is fired before the timer expires.
284+ */
285+ private delay ( ms : number , signal : AbortSignal ) : Promise < void > {
286+ return new Promise < void > ( ( resolve , reject ) => {
287+ const id = setTimeout ( resolve , ms ) ;
288+ signal . addEventListener (
289+ 'abort' ,
290+ ( ) => {
291+ clearTimeout ( id ) ;
292+ reject ( new DOMException ( 'Animation cancelled' , 'AbortError' ) ) ;
293+ } ,
294+ { once : true }
295+ ) ;
296+ } ) ;
297+ }
298+
299+ /**
300+ * Animate the item to a specific position using CSS transitions.
301+ * Returns a Promise that resolves when the animation completes, or rejects if
302+ * the given AbortSignal is fired.
303+ */
304+ private animateToPosition ( position : number , duration : number , signal : AbortSignal ) : Promise < void > {
305+ return new Promise < void > ( ( resolve , reject ) => {
306+ if ( ! this . item ) {
307+ return resolve ( ) ;
308+ }
309+
310+ this . item . style . transition = `transform ${ duration } ms ease-out` ;
311+ this . item . style . transform = `translate3d(${ - position } px, 0, 0)` ;
312+
313+ const id = setTimeout ( resolve , duration ) ;
314+ signal . addEventListener (
315+ 'abort' ,
316+ ( ) => {
317+ clearTimeout ( id ) ;
318+ reject ( new DOMException ( 'Animation cancelled' , 'AbortError' ) ) ;
319+ } ,
320+ { once : true }
321+ ) ;
322+ } ) ;
323+ }
324+
325+ /**
326+ * Calculate the swipe threshold distance required to trigger a full swipe animation.
327+ * Returns the maximum options width plus a margin to ensure it's achievable.
328+ */
329+ private getSwipeThreshold ( direction : 'start' | 'end' ) : number {
330+ const maxWidth = direction === 'end' ? this . optsWidthRightSide : this . optsWidthLeftSide ;
331+ return maxWidth + SWIPE_MARGIN ;
332+ }
333+
334+ /**
335+ * Animate the item through a full swipe sequence: off-screen → trigger action → return.
336+ * This is used when an expandable option is swiped beyond the threshold.
337+ */
338+ private async animateFullSwipe ( direction : 'start' | 'end' ) {
339+ const abortController = new AbortController ( ) ;
340+ this . animationAbortController = abortController ;
341+ const { signal } = abortController ;
342+
343+ // Prevent interruption during animation
344+ if ( this . gesture ) {
345+ this . gesture . enable ( false ) ;
346+ }
347+
348+ try {
349+ const options = direction === 'end' ? this . rightOptions : this . leftOptions ;
350+
351+ // Trigger expandable state without moving the item
352+ // Set state directly so expandable option fills its container, starting from
353+ // the exact position where the user released, without any visual snap.
354+ this . state =
355+ direction === 'end'
356+ ? SlidingState . End | SlidingState . SwipeEnd | SlidingState . AnimatingFullSwipe
357+ : SlidingState . Start | SlidingState . SwipeStart | SlidingState . AnimatingFullSwipe ;
358+
359+ await this . delay ( 100 , signal ) ;
360+
361+ // Animate off-screen while maintaining the expanded state
362+ const offScreenDistance = direction === 'end' ? window . innerWidth : - window . innerWidth ;
363+ await this . animateToPosition ( offScreenDistance , 250 , signal ) ;
364+
365+ // Trigger action
366+ if ( options ) {
367+ options . fireSwipeEvent ( ) ;
368+ }
369+
370+ // Small delay before returning
371+ await this . delay ( 300 , signal ) ;
372+
373+ // Return to closed state
374+ await this . animateToPosition ( 0 , 250 , signal ) ;
375+ } catch {
376+ // Animation was aborted (e.g. component disconnected). finally handles cleanup.
377+ } finally {
378+ this . animationAbortController = undefined ;
379+
380+ // Reset state
381+ if ( this . item ) {
382+ this . item . style . transition = '' ;
383+ this . item . style . transform = '' ;
384+ }
385+ this . openAmount = 0 ;
386+ this . state = SlidingState . Disabled ;
387+
388+ if ( openSlidingItem === this . el ) {
389+ openSlidingItem = undefined ;
390+ }
391+
392+ if ( this . gesture ) {
393+ this . gesture . enable ( ! this . disabled ) ;
394+ }
395+ }
396+ }
397+
251398 private async updateOptions ( ) {
252399 const options = this . el . querySelectorAll ( 'ion-item-options' ) ;
253400
@@ -370,6 +517,27 @@ export class ItemSliding implements ComponentInterface {
370517 resetContentScrollY ( contentEl , initialContentScrollY ) ;
371518 }
372519
520+ // Check for full swipe conditions with expandable options
521+ const rawSwipeDistance = Math . abs ( gesture . deltaX ) ;
522+ const direction = gesture . deltaX < 0 ? 'end' : 'start' ;
523+ const options = direction === 'end' ? this . rightOptions : this . leftOptions ;
524+ const hasExpandable = this . hasExpandableOptions ( options ) ;
525+
526+ const shouldTriggerFullSwipe =
527+ hasExpandable &&
528+ ( rawSwipeDistance > this . getSwipeThreshold ( direction ) ||
529+ ( Math . abs ( gesture . velocityX ) > 0.5 &&
530+ rawSwipeDistance > ( direction === 'end' ? this . optsWidthRightSide : this . optsWidthLeftSide ) * 0.5 ) ) ;
531+
532+ if ( shouldTriggerFullSwipe ) {
533+ this . animateFullSwipe ( direction ) . catch ( ( ) => {
534+ if ( this . gesture ) {
535+ this . gesture . enable ( ! this . disabled ) ;
536+ }
537+ } ) ;
538+ return ;
539+ }
540+
373541 const velocity = gesture . velocityX ;
374542
375543 let restingPoint = this . openAmount > 0 ? this . optsWidthRightSide : - this . optsWidthLeftSide ;
0 commit comments