Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
168 changes: 168 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 All @@ -47,6 +48,7 @@ export class ItemSliding implements ComponentInterface {
private optsWidthLeftSide = 0;
private sides = ItemSide.None;
private tmr?: ReturnType<typeof setTimeout>;
private animationAbortController?: AbortController;
private leftOptions?: HTMLIonItemOptionsElement;
private rightOptions?: HTMLIonItemOptionsElement;
private optsDirty = true;
Expand Down Expand Up @@ -113,6 +115,15 @@ export class ItemSliding implements ComponentInterface {
this.gesture = undefined;
}

if (this.tmr !== undefined) {
clearTimeout(this.tmr);
this.tmr = undefined;
}

// Abort any in-progress animation. The abort handler rejects the pending
// promise, causing animateFullSwipe's finally block to run cleanup.
this.animationAbortController?.abort();

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

Expand Down Expand Up @@ -153,6 +164,10 @@ export class ItemSliding implements ComponentInterface {
*/
@Method()
async open(side: Side | undefined) {
if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) {
return;
}

/**
* It is possible for the item to be added to the DOM
* after the item-sliding component was created. As a result,
Expand Down Expand Up @@ -216,6 +231,9 @@ export class ItemSliding implements ComponentInterface {
*/
@Method()
async close() {
if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) {
return;
}
this.setOpenAmount(0, true);
}

Expand Down Expand Up @@ -248,6 +266,135 @@ 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;
});
}

/**
* Returns a Promise that resolves after `ms` milliseconds, or rejects if the
* given AbortSignal is fired before the timer expires.
*/
private delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(id);
reject(new DOMException('Animation cancelled', 'AbortError'));
},
{ once: true }
);
});
}

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

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

const id = setTimeout(resolve, duration);
signal.addEventListener(
'abort',
() => {
clearTimeout(id);
reject(new DOMException('Animation cancelled', 'AbortError'));
},
{ once: true }
);
});
}

/**
* 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 + SWIPE_MARGIN;
}

/**
* 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') {
const abortController = new AbortController();
this.animationAbortController = abortController;
const { signal } = abortController;

// Prevent interruption during animation
if (this.gesture) {
this.gesture.enable(false);
}

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 this.delay(100, signal);

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

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

// Small delay before returning
await this.delay(300, signal);

// Return to closed state
await this.animateToPosition(0, 250, signal);
} catch {
// Animation was aborted (e.g. component disconnected). finally handles cleanup.
} finally {
this.animationAbortController = undefined;

// Reset state
if (this.item) {
this.item.style.transition = '';
this.item.style.transform = '';
}
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');

Expand Down Expand Up @@ -370,6 +517,27 @@ 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).catch(() => {
if (this.gesture) {
this.gesture.enable(!this.disabled);
}
});
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