@@ -11,6 +11,19 @@ import type { Side } from '../menu/menu-interface';
1111
1212const SWIPE_MARGIN = 30 ;
1313const ELASTIC_FACTOR = 0.55 ;
14+ const IONIC_SNAP_OPEN_RATIO = 0.4 ;
15+ const IONIC_EXPAND_TRIGGER = 80 ;
16+ const IONIC_VELOCITY_THRESHOLD = 0.4 ;
17+ const IONIC_ACTION_BASE_WIDTH = 64 ;
18+ const IONIC_OPEN_TRANSITION = '250ms cubic-bezier(0.25, 1, 0.5, 1)' ;
19+ const IONIC_SNAPBACK_TRANSITION = '300ms cubic-bezier(0.34, 1.4, 0.64, 1)' ;
20+ const IONIC_CONFIRM_EASE_IN = '150ms ease-in' ;
21+ const IONIC_CONFIRM_SNAPBACK = '480ms cubic-bezier(0.34, 1.4, 0.64, 1)' ;
22+ const IONIC_CONFIRM_PAUSE = 900 ;
23+ const IONIC_EXPAND_RESISTANCE_FACTOR = 0.95 ;
24+
25+ /** Expandable, non-disabled option (matches item-option expandable class). */
26+ const EXPANDABLE_OPTION_SELECTOR = 'ion-item-option.item-option-expandable:not(.item-option-disabled)' ;
1427
1528const enum ItemSide {
1629 None = 0 ,
@@ -38,7 +51,11 @@ let openSlidingItem: HTMLIonItemSlidingElement | undefined;
3851 */
3952@Component ( {
4053 tag : 'ion-item-sliding' ,
41- styleUrl : 'item-sliding.scss' ,
54+ styleUrls : {
55+ ios : 'item-sliding.scss' ,
56+ md : 'item-sliding.scss' ,
57+ ionic : 'item-sliding.scss' ,
58+ } ,
4259} )
4360export class ItemSliding implements ComponentInterface {
4461 private item : HTMLIonItemElement | null = null ;
@@ -56,6 +73,12 @@ export class ItemSliding implements ComponentInterface {
5673 private contentEl : HTMLElement | null = null ;
5774 private initialContentScrollY = true ;
5875 private mutationObserver ?: MutationObserver ;
76+ private leftExpandableBaseWidth = IONIC_ACTION_BASE_WIDTH ;
77+ private rightExpandableBaseWidth = IONIC_ACTION_BASE_WIDTH ;
78+
79+ private isIonicTheme ( ) : boolean {
80+ return getIonTheme ( this ) === 'ionic' ;
81+ }
5982
6083 @Element ( ) el ! : HTMLIonItemSlidingElement ;
6184
@@ -79,7 +102,6 @@ export class ItemSliding implements ComponentInterface {
79102
80103 async connectedCallback ( ) {
81104 const { el } = this ;
82-
83105 this . item = el . querySelector ( 'ion-item' ) ;
84106 this . contentEl = findClosestIonContent ( el ) ;
85107
@@ -332,8 +354,8 @@ export class ItemSliding implements ComponentInterface {
332354 }
333355
334356 /**
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 .
357+ * Native (ios/md) full swipe: off-screen → fire swipe → return.
358+ * Ionic theme uses `animateIonicFullSwipe` instead (see `onEndIonic`) .
337359 */
338360 private async animateFullSwipe ( direction : 'start' | 'end' ) {
339361 const abortController = new AbortController ( ) ;
@@ -395,6 +417,157 @@ export class ItemSliding implements ComponentInterface {
395417 }
396418 }
397419
420+ private queryExpandableOption ( options ?: HTMLIonItemOptionsElement ) : HTMLIonItemOptionElement | undefined {
421+ return options ?. querySelector < HTMLIonItemOptionElement > ( EXPANDABLE_OPTION_SELECTOR ) ?? undefined ;
422+ }
423+
424+ private getExpandableOption ( direction : 'start' | 'end' ) : HTMLIonItemOptionElement | undefined {
425+ return this . queryExpandableOption ( direction === 'end' ? this . rightOptions : this . leftOptions ) ;
426+ }
427+
428+ private getOpenDirectionFromAmount ( openAmount : number ) : 'start' | 'end' | undefined {
429+ if ( openAmount > 0 ) {
430+ return 'end' ;
431+ }
432+ if ( openAmount < 0 ) {
433+ return 'start' ;
434+ }
435+ return undefined ;
436+ }
437+
438+ private getOptionsWidthForDirection ( direction : 'start' | 'end' ) : number {
439+ return direction === 'end' ? this . optsWidthRightSide : this . optsWidthLeftSide ;
440+ }
441+
442+ private getExpandableBaseWidth ( direction : 'start' | 'end' ) : number {
443+ return direction === 'end' ? this . rightExpandableBaseWidth : this . leftExpandableBaseWidth ;
444+ }
445+
446+ private setIonicExpandableWidth ( direction : 'start' | 'end' , width : number , animate : boolean , easing ?: string ) {
447+ const expandableOption = this . getExpandableOption ( direction ) ;
448+ if ( ! expandableOption ) {
449+ return ;
450+ }
451+
452+ const style = expandableOption . style ;
453+ style . transition = animate ? `width ${ easing ?? IONIC_OPEN_TRANSITION } ` : 'none' ;
454+ const baseWidth = this . getExpandableBaseWidth ( direction ) ;
455+ style . width = `${ Math . max ( baseWidth , width ) } px` ;
456+ }
457+
458+ private resetIonicExpandableOptions ( ) {
459+ [ this . leftOptions , this . rightOptions ] . forEach ( ( options ) => {
460+ const expandableOption = this . queryExpandableOption ( options ) ;
461+ if ( ! expandableOption ) {
462+ return ;
463+ }
464+ expandableOption . style . transition = '' ;
465+ expandableOption . style . width = '' ;
466+ } ) ;
467+ }
468+
469+ private updateIonicExpandableFromOpenAmount ( openAmount : number , isFinal : boolean , previousOpenAmount : number ) {
470+ if ( ( this . state & SlidingState . AnimatingFullSwipe ) !== 0 ) {
471+ return ;
472+ }
473+
474+ const direction = this . getOpenDirectionFromAmount ( openAmount ) ;
475+ if ( direction === undefined ) {
476+ const previousDirection = this . getOpenDirectionFromAmount ( previousOpenAmount ) ;
477+ if ( previousDirection === undefined ) {
478+ this . resetIonicExpandableOptions ( ) ;
479+ return ;
480+ }
481+
482+ this . setIonicExpandableWidth (
483+ previousDirection ,
484+ this . getExpandableBaseWidth ( previousDirection ) ,
485+ isFinal ,
486+ IONIC_SNAPBACK_TRANSITION
487+ ) ;
488+ return ;
489+ }
490+
491+ const baseWidth = this . getExpandableBaseWidth ( direction ) ;
492+ const optionsWidth = this . getOptionsWidthForDirection ( direction ) ;
493+ const extraWidth = Math . max ( 0 , Math . abs ( openAmount ) - optionsWidth ) ;
494+ const resistedExtraWidth = isFinal ? extraWidth : extraWidth * IONIC_EXPAND_RESISTANCE_FACTOR ;
495+ const targetWidth = baseWidth + resistedExtraWidth ;
496+ const easing = openAmount === 0 ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION ;
497+
498+ this . setIonicExpandableWidth ( direction , targetWidth , isFinal , easing ) ;
499+ }
500+
501+ private async animateIonicFullSwipe ( direction : 'start' | 'end' ) {
502+ const abortController = new AbortController ( ) ;
503+ this . animationAbortController = abortController ;
504+ const { signal } = abortController ;
505+ const expandableOption = this . getExpandableOption ( direction ) ;
506+ const options = direction === 'end' ? this . rightOptions : this . leftOptions ;
507+
508+ if ( this . gesture ) {
509+ this . gesture . enable ( false ) ;
510+ }
511+
512+ try {
513+ this . state =
514+ direction === 'end'
515+ ? SlidingState . End | SlidingState . AnimatingFullSwipe
516+ : SlidingState . Start | SlidingState . AnimatingFullSwipe ;
517+
518+ if ( ! this . item ) {
519+ return ;
520+ }
521+
522+ const itemWidth = this . el . offsetWidth || window . innerWidth ;
523+ const baseWidth = this . getExpandableBaseWidth ( direction ) ;
524+ const expandableTargetWidth = Math . max ( baseWidth , itemWidth - 16 ) ;
525+ const offScreenPosition = direction === 'end' ? itemWidth : - itemWidth ;
526+
527+ if ( expandableOption ) {
528+ expandableOption . style . transition = `width ${ IONIC_CONFIRM_EASE_IN } ` ;
529+ expandableOption . style . width = `${ expandableTargetWidth } px` ;
530+
531+ }
532+
533+ this . item . style . transition = `transform ${ IONIC_CONFIRM_EASE_IN } ` ;
534+ this . item . style . transform = `translate3d(${ - offScreenPosition } px, 0, 0)` ;
535+ await this . delay ( 150 , signal ) ;
536+
537+ options ?. fireSwipeEvent ( ) ;
538+ await this . delay ( IONIC_CONFIRM_PAUSE , signal ) ;
539+
540+ if ( expandableOption ) {
541+ expandableOption . style . transition = `width ${ IONIC_CONFIRM_SNAPBACK } ` ;
542+ expandableOption . style . width = `${ baseWidth } px` ;
543+ }
544+
545+ this . item . style . transition = `transform ${ IONIC_CONFIRM_SNAPBACK } ` ;
546+ this . item . style . transform = 'translate3d(0, 0, 0)' ;
547+ await this . delay ( 480 , signal ) ;
548+ } catch {
549+ // Animation was aborted. finally handles cleanup.
550+ } finally {
551+ this . animationAbortController = undefined ;
552+
553+ if ( this . item ) {
554+ this . item . style . transition = '' ;
555+ this . item . style . transform = '' ;
556+ }
557+ this . resetIonicExpandableOptions ( ) ;
558+ this . openAmount = 0 ;
559+ this . state = SlidingState . Disabled ;
560+
561+ if ( openSlidingItem === this . el ) {
562+ openSlidingItem = undefined ;
563+ }
564+
565+ if ( this . gesture ) {
566+ this . gesture . enable ( ! this . disabled ) ;
567+ }
568+ }
569+ }
570+
398571 private async updateOptions ( ) {
399572 const options = this . el . querySelectorAll ( 'ion-item-options' ) ;
400573
@@ -512,11 +685,22 @@ export class ItemSliding implements ComponentInterface {
512685 }
513686
514687 private onEnd ( gesture : GestureDetail ) {
688+ this . restoreContentScrollAfterSlide ( ) ;
689+ if ( this . isIonicTheme ( ) ) {
690+ this . onEndIonic ( gesture ) ;
691+ } else {
692+ this . onEndNative ( gesture ) ;
693+ }
694+ }
695+
696+ private restoreContentScrollAfterSlide ( ) {
515697 const { contentEl, initialContentScrollY } = this ;
516698 if ( contentEl ) {
517699 resetContentScrollY ( contentEl , initialContentScrollY ) ;
518700 }
701+ }
519702
703+ private onEndNative ( gesture : GestureDetail ) {
520704 // Check for full swipe conditions with expandable options
521705 const rawSwipeDistance = Math . abs ( gesture . deltaX ) ;
522706 const direction = gesture . deltaX < 0 ? 'end' : 'start' ;
@@ -561,18 +745,77 @@ export class ItemSliding implements ComponentInterface {
561745 }
562746 }
563747
748+ private onEndIonic ( gesture : GestureDetail ) {
749+ const velocity = gesture . velocityX ;
750+ const velocityX = velocity * 1000 ;
751+ const activeDirection = this . getOpenDirectionFromAmount ( this . openAmount ) ;
752+ if ( activeDirection === undefined ) {
753+ this . setOpenAmount ( 0 , true ) ;
754+ return ;
755+ }
756+
757+ const optionsWidth = this . getOptionsWidthForDirection ( activeDirection ) ;
758+ const extraWidth = Math . max ( 0 , Math . abs ( this . openAmount ) - optionsWidth ) ;
759+ const hasExpandable = this . hasExpandableOptions ( activeDirection === 'end' ? this . rightOptions : this . leftOptions ) ;
760+ const wasRevealed = Math . abs ( this . initialOpenAmount ) >= optionsWidth ;
761+
762+ if (
763+ hasExpandable &&
764+ ( extraWidth >= IONIC_EXPAND_TRIGGER || ( wasRevealed && velocityX < Math . abs ( IONIC_VELOCITY_THRESHOLD * 1000 ) ) )
765+ ) {
766+ this . animateIonicFullSwipe ( activeDirection ) . catch ( ( ) => {
767+ if ( this . gesture ) {
768+ this . gesture . enable ( ! this . disabled ) ;
769+ }
770+ } ) ;
771+ return ;
772+ }
773+
774+ const closeDirection =
775+ activeDirection === 'end'
776+ ? velocityX > IONIC_VELOCITY_THRESHOLD * 1000
777+ : velocityX < - IONIC_VELOCITY_THRESHOLD * 1000 ;
778+ if ( closeDirection ) {
779+ this . setOpenAmount ( 0 , true ) ;
780+ return ;
781+ }
782+
783+ const openThreshold = optionsWidth * IONIC_SNAP_OPEN_RATIO ;
784+ const shouldSnapOpen = Math . abs ( this . openAmount ) > openThreshold ;
785+ const restingPoint = shouldSnapOpen
786+ ? activeDirection === 'end'
787+ ? this . optsWidthRightSide
788+ : - this . optsWidthLeftSide
789+ : 0 ;
790+
791+ this . setOpenAmount ( restingPoint , true ) ;
792+ }
793+
564794 private calculateOptsWidth ( ) {
565795 this . optsWidthRightSide = 0 ;
566796 if ( this . rightOptions ) {
567797 this . rightOptions . style . display = 'flex' ;
568798 this . optsWidthRightSide = this . rightOptions . offsetWidth ;
799+ const rightExpandable = this . queryExpandableOption ( this . rightOptions ) ;
800+ if ( rightExpandable ) {
801+ rightExpandable . style . width = '' ;
802+ this . rightExpandableBaseWidth = Math . max (
803+ IONIC_ACTION_BASE_WIDTH ,
804+ rightExpandable . getBoundingClientRect ( ) . width
805+ ) ;
806+ }
569807 this . rightOptions . style . display = '' ;
570808 }
571809
572810 this . optsWidthLeftSide = 0 ;
573811 if ( this . leftOptions ) {
574812 this . leftOptions . style . display = 'flex' ;
575813 this . optsWidthLeftSide = this . leftOptions . offsetWidth ;
814+ const leftExpandable = this . queryExpandableOption ( this . leftOptions ) ;
815+ if ( leftExpandable ) {
816+ leftExpandable . style . width = '' ;
817+ this . leftExpandableBaseWidth = Math . max ( IONIC_ACTION_BASE_WIDTH , leftExpandable . getBoundingClientRect ( ) . width ) ;
818+ }
576819 this . leftOptions . style . display = '' ;
577820 }
578821
@@ -591,22 +834,23 @@ export class ItemSliding implements ComponentInterface {
591834 const { el } = this ;
592835
593836 const style = this . item . style ;
837+ const previousOpenAmount = this . openAmount ;
594838 this . openAmount = openAmount ;
595839
840+ if ( this . isIonicTheme ( ) ) {
841+ this . updateIonicExpandableFromOpenAmount ( openAmount , isFinal , previousOpenAmount ) ;
842+ }
843+
596844 if ( isFinal ) {
597845 style . transition = '' ;
598846 }
599847
600848 if ( openAmount > 0 ) {
601- this . state =
602- openAmount >= this . optsWidthRightSide + SWIPE_MARGIN
603- ? SlidingState . End | SlidingState . SwipeEnd
604- : SlidingState . End ;
849+ const fullSwipe = ! this . isIonicTheme ( ) && openAmount >= this . optsWidthRightSide + SWIPE_MARGIN ;
850+ this . state = fullSwipe ? SlidingState . End | SlidingState . SwipeEnd : SlidingState . End ;
605851 } else if ( openAmount < 0 ) {
606- this . state =
607- openAmount <= - this . optsWidthLeftSide - SWIPE_MARGIN
608- ? SlidingState . Start | SlidingState . SwipeStart
609- : SlidingState . Start ;
852+ const fullSwipe = ! this . isIonicTheme ( ) && openAmount <= - this . optsWidthLeftSide - SWIPE_MARGIN ;
853+ this . state = fullSwipe ? SlidingState . Start | SlidingState . SwipeStart : SlidingState . Start ;
610854 } else {
611855 /**
612856 * The sliding options should not be
0 commit comments