Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 123 additions & 7 deletions core/src/components/item-sliding/item-sliding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const enum SlidingState {

SwipeEnd = 1 << 5,
SwipeStart = 1 << 6,
AnimatingFullSwipe = 1 << 7,
}

let openSlidingItem: HTMLIonItemSlidingElement | undefined;
Expand Down Expand Up @@ -113,6 +114,15 @@ export class ItemSliding implements ComponentInterface {
this.gesture = undefined;
}

// Cancel animation if in progress
if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) {
if (this.item) {
this.item.style.transition = '';
this.item.style.transform = '';
}
this.state = SlidingState.Disabled;
}

this.item = null;
this.leftOptions = this.rightOptions = undefined;

Expand Down Expand Up @@ -248,6 +258,102 @@ export class ItemSliding implements ComponentInterface {
}
}

/**
* Check if the given item options element contains at least one expandable, non-disabled option.
*/
private hasExpandableOptions(options: HTMLIonItemOptionsElement | undefined): boolean {
if (!options) return false;

const optionElements = options.querySelectorAll('ion-item-option');
return Array.from(optionElements).some((option: any) => {
return option.expandable === true && !option.disabled;
});
}

/**
* Animate the item to a specific position using CSS transitions.
* Returns a Promise that resolves when the animation completes.
*/
private animateToPosition(position: number, duration: number): Promise<void> {
return new Promise((resolve) => {
if (!this.item) {
return resolve();
}

this.item.style.transition = `transform ${duration}ms ease-out`;
this.item.style.transform = `translate3d(${-position}px, 0, 0)`;

setTimeout(() => {
resolve();
}, duration);
});
}

/**
* Calculate the swipe threshold distance required to trigger a full swipe animation.
* Returns the maximum options width plus a margin to ensure it's achievable.
*/
private getSwipeThreshold(direction: 'start' | 'end'): number {
const maxWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide;
return maxWidth + 30; // Slightly larger than SWIPE_MARGIN to be achievable
}

/**
* Animate the item through a full swipe sequence: off-screen → trigger action → return.
* This is used when an expandable option is swiped beyond the threshold.
*/
private async animateFullSwipe(direction: 'start' | 'end') {
// Prevent interruption during animation
if (this.gesture) {
this.gesture.enable(false);
}

const { el } = this;
el.classList.add('item-sliding-full-swipe');

try {
const options = direction === 'end' ? this.rightOptions : this.leftOptions;

// Trigger expandable state without moving the item
// Set state directly so expandable option fills its container, starting from
// the exact position where the user released, without any visual snap.
this.state =
direction === 'end'
? SlidingState.End | SlidingState.SwipeEnd | SlidingState.AnimatingFullSwipe
: SlidingState.Start | SlidingState.SwipeStart | SlidingState.AnimatingFullSwipe;

await new Promise((resolve) => setTimeout(resolve, 100));

// Animate off-screen while maintaining the expanded state
const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth;
await this.animateToPosition(offScreenDistance, 250);

// Trigger action
if (options) {
options.fireSwipeEvent();
}

// Small delay before returning
await new Promise((resolve) => setTimeout(resolve, 300));

// Return to closed state
await this.animateToPosition(0, 250);

// Reset state
if (this.item) {
this.item.style.transition = '';
}
this.openAmount = 0;
this.state = SlidingState.Disabled;
openSlidingItem = undefined;
} finally {
el.classList.remove('item-sliding-full-swipe');
if (this.gesture) {
this.gesture.enable(!this.disabled);
}
}
}

private async updateOptions() {
const options = this.el.querySelectorAll('ion-item-options');

Expand Down Expand Up @@ -370,6 +476,23 @@ export class ItemSliding implements ComponentInterface {
resetContentScrollY(contentEl, initialContentScrollY);
}

// Check for full swipe conditions with expandable options
const rawSwipeDistance = Math.abs(gesture.deltaX);
const direction = gesture.deltaX < 0 ? 'end' : 'start';
const options = direction === 'end' ? this.rightOptions : this.leftOptions;
const hasExpandable = this.hasExpandableOptions(options);

const shouldTriggerFullSwipe =
hasExpandable &&
(rawSwipeDistance > this.getSwipeThreshold(direction) ||
(Math.abs(gesture.velocityX) > 0.5 &&
rawSwipeDistance > (direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide) * 0.5));

if (shouldTriggerFullSwipe) {
this.animateFullSwipe(direction);
return;
}

const velocity = gesture.velocityX;

let restingPoint = this.openAmount > 0 ? this.optsWidthRightSide : -this.optsWidthLeftSide;
Expand All @@ -383,14 +506,7 @@ export class ItemSliding implements ComponentInterface {
restingPoint = 0;
}

const state = this.state;
this.setOpenAmount(restingPoint, true);

if ((state & SlidingState.SwipeEnd) !== 0 && this.rightOptions) {
this.rightOptions.fireSwipeEvent();
} else if ((state & SlidingState.SwipeStart) !== 0 && this.leftOptions) {
this.leftOptions.fireSwipeEvent();
}
}

private calculateOptsWidth() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
await page.goto(`/src/components/item-sliding/test/basic`, config);
});
test.describe('start options', () => {
test('should not have visual regressions', async ({ page }) => {
//TODO(FW-7184): remove skip once issue is resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//TODO(FW-7184): remove skip once issue is resolved
// TODO(FW-7184): remove skip once issue is resolved

test.skip('should not have visual regressions', async ({ page }) => {
const item = page.locator('#item2');

/**
Expand Down Expand Up @@ -108,7 +109,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('item-sliding: basic'), () => {
test.describe('safe area left', () => {
test('should have padding on the left only', async ({ page }) => {
//TODO(FW-7184): remove skip once issue is resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//TODO(FW-7184): remove skip once issue is resolved
// TODO(FW-7184): remove skip once issue is resolved

test.skip('should have padding on the left only', async ({ page }) => {
await page.setContent(
`
<style>
Expand Down Expand Up @@ -149,7 +151,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
});

test.describe('safe area right', () => {
test('should have padding on the right only', async ({ page }) => {
//TODO(FW-7184): remove skip once issue is resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//TODO(FW-7184): remove skip once issue is resolved
// TODO(FW-7184): remove skip once issue is resolved

test.skip('should have padding on the right only', async ({ page }) => {
await page.setContent(
`
<style>
Expand Down
120 changes: 120 additions & 0 deletions core/src/components/item-sliding/test/full-swipe/index.html
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is some weirdness going on with the headings in ios:

Image

Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Item Sliding - Full Swipe</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Item Sliding - Full Swipe</ion-title>
</ion-toolbar>
</ion-header>

<ion-content>
<div class="ion-padding-start" style="padding-top: 30px">
<ion-title>Full Swipe - Expandable Options</ion-title>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't use ion-title outside of a toolbar, this is what's causing ios to look strange. Either use an h2 like this:

<h2>Full Swipe - Expandable Options</h2>

<style>
  h2 {
    font-size: 12px;
    font-weight: normal;

    color: #6f7378;

    margin-top: 10px;
    margin-left: 5px;
  }
</style>

Or use an ion-list-header at the top of the list.

</div>
<ion-list>
<!-- Expandable option on end side -->
<ion-item-sliding id="expandable-end">
<ion-item>
<ion-label>Expandable End (Swipe Left)</ion-label>
</ion-item>
<ion-item-options side="end">
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
</ion-item-options>
</ion-item-sliding>

<!-- Expandable on start side -->
<ion-item-sliding id="expandable-start">
<ion-item>
<ion-label>Expandable Start (Swipe Right)</ion-label>
</ion-item>
<ion-item-options side="start">
<ion-item-option expandable="true" color="success">Archive</ion-item-option>
</ion-item-options>
</ion-item-sliding>

<!-- Both sides with expandable -->
<ion-item-sliding id="expandable-both">
<ion-item>
<ion-label>Expandable Both Sides</ion-label>
</ion-item>
<ion-item-options side="start">
<ion-item-option expandable="true" color="success">Archive</ion-item-option>
</ion-item-options>
<ion-item-options side="end">
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>

<div class="ion-padding-start" style="padding-top: 30px">
<ion-title>Non-Expandable Options (No Full Swipe)</ion-title>
</div>
<ion-list>
<!-- Non-expandable option -->
<ion-item-sliding id="non-expandable">
<ion-item>
<ion-label>Non-Expandable (Should Show Options)</ion-label>
</ion-item>
<ion-item-options side="end">
<ion-item-option color="primary">Edit</ion-item-option>
</ion-item-options>
</ion-item-sliding>

<!-- Multiple non-expandable options -->
<ion-item-sliding id="non-expandable-multiple">
<ion-item>
<ion-label>Multiple Non-Expandable Options</ion-label>
</ion-item>
<ion-item-options side="end">
<ion-item-option color="primary">Edit</ion-item-option>
<ion-item-option color="secondary">Share</ion-item-option>
<ion-item-option color="danger">Delete</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>

<div class="ion-padding-start" style="padding-top: 30px">
<ion-title>Mixed Scenarios</ion-title>
</div>
<ion-list>
<!-- Expandable with multiple options -->
<ion-item-sliding id="expandable-with-others">
<ion-item>
<ion-label>Expandable + Other Options</ion-label>
</ion-item>
<ion-item-options side="end">
<ion-item-option color="primary">Edit</ion-item-option>
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
</ion-content>
</ion-app>
<script>
// Log swipe events for debugging
document.querySelectorAll('ion-item-sliding').forEach((item) => {
const id = item.getAttribute('id');
item.querySelectorAll('ion-item-options').forEach((options) => {
options.addEventListener('ionSwipe', () => {
console.log(`[${id}] ionSwipe fired on ${options.getAttribute('side')} side`);
});
});
});
</script>
</body>
</html>
Loading
Loading