Skip to content

Commit f0b45d3

Browse files
committed
Merge branch 'next' of github.com:ionic-team/ionic-framework into FW-7137
2 parents 8f3e623 + 32201b7 commit f0b45d3

6 files changed

Lines changed: 501 additions & 11 deletions

File tree

core/src/components/item-sliding/item-sliding.tsx

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const enum SlidingState {
2727

2828
SwipeEnd = 1 << 5,
2929
SwipeStart = 1 << 6,
30+
AnimatingFullSwipe = 1 << 7,
3031
}
3132

3233
let 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;

core/src/components/item-sliding/test/basic/item-sliding.e2e.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
2323
* Positive dragByX value to drag element from the left to the right
2424
* to reveal the options on the left side.
2525
*/
26-
const dragByX = config.direction === 'rtl' ? -150 : 150;
26+
const dragByX = config.direction === 'rtl' ? -100 : 100;
2727

28-
await dragElementBy(item, page, dragByX);
28+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
2929
await page.waitForChanges();
3030

3131
await expect(item).toHaveScreenshot(screenshot('item-sliding-start'));
@@ -44,7 +44,7 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
4444
*/
4545
const dragByX = config.direction === 'rtl' ? 150 : -150;
4646

47-
await dragElementBy(item, page, dragByX);
47+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
4848

4949
await expect(item).toHaveScreenshot(screenshot('item-sliding-end'));
5050
});
@@ -61,7 +61,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
6161
await page.goto(`/src/components/item-sliding/test/basic`, config);
6262
const item = page.locator('#item2');
6363

64-
await dragElementBy(item, page, -150);
64+
await dragElementBy(item, page, -150, 0, undefined, undefined, true, 20);
6565
await page.waitForChanges();
6666

6767
// item-sliding doesn't have an easy way to tell whether it's fully open so just screenshot it
@@ -141,7 +141,7 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
141141
const item = page.locator('ion-item-sliding');
142142

143143
const dragByX = direction == 'rtl' ? -150 : 150;
144-
await dragElementBy(item, page, dragByX);
144+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
145145
await page.waitForChanges();
146146

147147
await expect(item).toHaveScreenshot(screenshot(`item-sliding-safe-area-left`));
@@ -182,7 +182,7 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
182182
const item = page.locator('ion-item-sliding');
183183

184184
const dragByX = direction == 'rtl' ? 150 : -150;
185-
await dragElementBy(item, page, dragByX);
185+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
186186
await page.waitForChanges();
187187

188188
await expect(item).toHaveScreenshot(screenshot(`item-sliding-safe-area-right`));
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Item Sliding - Full Swipe</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
h2 {
17+
font-size: 12px;
18+
font-weight: normal;
19+
20+
color: #6f7378;
21+
22+
margin-top: 10px;
23+
margin-left: 5px;
24+
}
25+
</style>
26+
</head>
27+
28+
<body>
29+
<ion-app>
30+
<ion-header>
31+
<ion-toolbar>
32+
<ion-title>Item Sliding - Full Swipe</ion-title>
33+
</ion-toolbar>
34+
</ion-header>
35+
36+
<ion-content>
37+
<div class="ion-padding-start" style="padding-top: 30px">
38+
<h2>Full Swipe - Expandable Options</h2>
39+
</div>
40+
<ion-list>
41+
<!-- Expandable option on end side -->
42+
<ion-item-sliding id="expandable-end">
43+
<ion-item>
44+
<ion-label>Expandable End (Swipe Left)</ion-label>
45+
</ion-item>
46+
<ion-item-options side="end">
47+
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
48+
</ion-item-options>
49+
</ion-item-sliding>
50+
51+
<!-- Expandable on start side -->
52+
<ion-item-sliding id="expandable-start">
53+
<ion-item>
54+
<ion-label>Expandable Start (Swipe Right)</ion-label>
55+
</ion-item>
56+
<ion-item-options side="start">
57+
<ion-item-option expandable="true" color="success">Archive</ion-item-option>
58+
</ion-item-options>
59+
</ion-item-sliding>
60+
61+
<!-- Both sides with expandable -->
62+
<ion-item-sliding id="expandable-both">
63+
<ion-item>
64+
<ion-label>Expandable Both Sides</ion-label>
65+
</ion-item>
66+
<ion-item-options side="start">
67+
<ion-item-option expandable="true" color="success">Archive</ion-item-option>
68+
</ion-item-options>
69+
<ion-item-options side="end">
70+
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
71+
</ion-item-options>
72+
</ion-item-sliding>
73+
</ion-list>
74+
75+
<div class="ion-padding-start" style="padding-top: 30px">
76+
<h2>Non-Expandable Options (No Full Swipe)</h2>
77+
</div>
78+
<ion-list>
79+
<!-- Non-expandable option -->
80+
<ion-item-sliding id="non-expandable">
81+
<ion-item>
82+
<ion-label>Non-Expandable (Should Show Options)</ion-label>
83+
</ion-item>
84+
<ion-item-options side="end">
85+
<ion-item-option color="primary">Edit</ion-item-option>
86+
</ion-item-options>
87+
</ion-item-sliding>
88+
89+
<!-- Multiple non-expandable options -->
90+
<ion-item-sliding id="non-expandable-multiple">
91+
<ion-item>
92+
<ion-label>Multiple Non-Expandable Options</ion-label>
93+
</ion-item>
94+
<ion-item-options side="end">
95+
<ion-item-option color="primary">Edit</ion-item-option>
96+
<ion-item-option color="secondary">Share</ion-item-option>
97+
<ion-item-option color="danger">Delete</ion-item-option>
98+
</ion-item-options>
99+
</ion-item-sliding>
100+
</ion-list>
101+
102+
<div class="ion-padding-start" style="padding-top: 30px">
103+
<h2>Mixed Scenarios</h2>
104+
</div>
105+
<ion-list>
106+
<!-- Expandable with multiple options -->
107+
<ion-item-sliding id="expandable-with-others">
108+
<ion-item>
109+
<ion-label>Expandable + Other Options</ion-label>
110+
</ion-item>
111+
<ion-item-options side="end">
112+
<ion-item-option color="primary">Edit</ion-item-option>
113+
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
114+
</ion-item-options>
115+
</ion-item-sliding>
116+
</ion-list>
117+
</ion-content>
118+
</ion-app>
119+
<script>
120+
// Log swipe events for debugging
121+
document.querySelectorAll('ion-item-sliding').forEach((item) => {
122+
const id = item.getAttribute('id');
123+
item.querySelectorAll('ion-item-options').forEach((options) => {
124+
options.addEventListener('ionSwipe', () => {
125+
console.log(`[${id}] ionSwipe fired on ${options.getAttribute('side')} side`);
126+
});
127+
});
128+
});
129+
</script>
130+
</body>
131+
</html>

0 commit comments

Comments
 (0)