Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6017570
First checkpoint
OS-pedrolourenco Mar 23, 2026
994b969
Second checkpoint
OS-pedrolourenco Mar 24, 2026
a6f271f
Add automatic full expand animation to items
OS-pedrolourenco Mar 24, 2026
63274c0
Tweaks
OS-pedrolourenco Mar 24, 2026
bd8568f
Fix disabled option bug + add tests
OS-pedrolourenco Mar 25, 2026
87ddc89
Fix lint issues
OS-pedrolourenco Mar 25, 2026
e0736dc
Merge branch 'refs/heads/next' into ROU-12664
OS-pedrolourenco Mar 26, 2026
258e9df
Skip flaky tests
OS-pedrolourenco Mar 26, 2026
0e9abb2
Merge branch 'next' into ROU-12664
OS-pedrolourenco Mar 26, 2026
ed9bee5
fix(angular): forward generic type parameter on ModalOptions and Popo…
ShaneK Mar 19, 2026
08388ee
test(spinner): add transform test back (#31017)
thetaPC Mar 20, 2026
9aaecc9
v8.8.2
Ionitron Mar 25, 2026
24861e5
chore(): update package lock files
Ionitron Mar 25, 2026
9fc848a
fix(datetime): scroll failing for adjacent days on ios (#31033)
os-davidlourenco Mar 25, 2026
60df9d6
chore(deps): update playwright (#30810)
renovate[bot] Mar 25, 2026
bad2ac7
fix(input-otp): prevent deletion and paste when disabled or readonly …
KanhaiyaPandey Mar 25, 2026
f76e46e
chore(): add updated snapshots
Ionitron Mar 26, 2026
4cb32a1
chore(): add updated snapshots
Ionitron Mar 26, 2026
51db026
chore: sync next with main (#31040)
brandyscarney Mar 26, 2026
7762854
Merge branch 'next' into ROU-12664
brandyscarney Mar 26, 2026
55eec20
CR
OS-pedrolourenco Mar 27, 2026
004e33c
CR
OS-pedrolourenco Mar 27, 2026
f06c1ad
CR
OS-pedrolourenco Mar 27, 2026
cdbcdb7
CR
OS-pedrolourenco Mar 30, 2026
bfc4073
Merge branch 'next' into ROU-12664
OS-pedrolourenco Mar 30, 2026
c66de41
Revert mistaken button snapshots
OS-pedrolourenco Mar 30, 2026
9f5e28a
CR
OS-pedrolourenco Mar 30, 2026
754be74
Leverage tmr pattern
OS-pedrolourenco Mar 31, 2026
5a64eac
CR + fix lint issue
OS-pedrolourenco Mar 31, 2026
b63ddfc
CR
OS-pedrolourenco Mar 31, 2026
1130b7e
Indentation
OS-pedrolourenco Mar 31, 2026
9a58724
CR
OS-pedrolourenco Apr 1, 2026
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
137 changes: 137 additions & 0 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,20 @@ export class ItemSliding implements ComponentInterface {
this.gesture = undefined;
}

if (this.tmr !== undefined) {
clearTimeout(this.tmr);
this.tmr = 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 +263,111 @@ 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)`;

// tmr is shared with setOpenAmount's close timer; this is safe because
// animateFullSwipe only runs while the gesture is disabled.
this.tmr = setTimeout(() => {
this.tmr = undefined;
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The comment says 'slightly larger than SWIPE_MARGIN' but this is exactly SWIPE_MARGIN (both 30). Should this just reference the constant? return maxWidth + SWIPE_MARGIN; That way they stay in sync if the margin ever changes.

}

/**
* 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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You're disabling the gesture here to prevent interruption, but close() and open() are public @Method() calls that go through setOpenAmount and aren't gated. If someone calls close() programmatically during the animation, setOpenAmount clears this.tmr (which kills the current animation step), snaps the item, and schedules its own 600ms closing timer. The animation's promise hangs, finally never runs, and the component ends up with two competing timers. Should close()/open() check for AnimatingFullSwipe state and bail out?

}

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<void>((resolve) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If disconnectedCallback fires while we're awaiting one of these, clearTimeout(this.tmr) cancels the timeout but the Promise's resolve never gets called, so the await hangs and the finally block never executes. The cleanup in disconnectedCallback (lines 122-129) partially covers this, but now there are two separate cleanup paths that need to stay in sync. Could you use a cancellation pattern so clearing the timer also resolves/rejects the promise? That way finally runs deterministically and the duplicate cleanup in disconnectedCallback isn't needed.

this.tmr = setTimeout(() => {
this.tmr = undefined;
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<void>((resolve) => {
this.tmr = setTimeout(() => {
this.tmr = undefined;
resolve();
}, 300);
});

// Return to closed state
await this.animateToPosition(0, 250);
} finally {
// Reset state
if (this.item) {
this.item.style.transition = '';
}
this.openAmount = 0;
this.state = SlidingState.Disabled;
openSlidingItem = undefined;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This unconditionally clears openSlidingItem, but during the ~900ms animation a user could start swiping a different item. That new swipe sets openSlidingItem to the new element in onStart, and then this finally block blindly clears it, orphaning the newly-opened item. Should this use the same guard as disconnectedCallback? i.e. 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');

Expand Down Expand Up @@ -370,6 +490,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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is fire-and-forget with no .catch(). If the promise hangs (e.g., component disconnects mid-animation) or finally throws, the gesture stays permanently disabled from the enable(false) in animateFullSwipe. Worth adding a .catch() that re-enables the gesture as a safety net?

return;
}

const velocity = gesture.velocityX;

let restingPoint = this.openAmount > 0 ? this.optsWidthRightSide : -this.optsWidthLeftSide;
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
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
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
test.skip('should have padding on the right only', async ({ page }) => {
await page.setContent(
`
<style>
Expand Down
131 changes: 131 additions & 0 deletions core/src/components/item-sliding/test/full-swipe/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<!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>
<style>
h2 {
font-size: 12px;
font-weight: normal;

color: #6f7378;

margin-top: 10px;
margin-left: 5px;
}
</style>
</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">
<h2>Full Swipe - Expandable Options</h2>
</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">
<h2>Non-Expandable Options (No Full Swipe)</h2>
</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">
<h2>Mixed Scenarios</h2>
</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