Skip to content

Commit a247ac5

Browse files
feat(item-sliding): added velocity validation for open options
1 parent c0338ab commit a247ac5

6 files changed

Lines changed: 196 additions & 45 deletions

File tree

core/src/components/item-option/item-option.ionic.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,4 @@
102102

103103
:host(.item-option-expand-threshold) {
104104
filter: brightness(0.92);
105-
}
105+
}

core/src/components/item-sliding/item-sliding.ionic.scss

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
@use "../../themes/ionic/ionic.globals.scss" as globals;
33
@import "../../themes/native/native.globals";
44

5-
6-
75
// Transition utility classes
86
.item-sliding-transition-open .item {
97
transition: transform 250ms cubic-bezier(0.25, 1, 0.5, 1);
@@ -37,4 +35,4 @@ ion-item-option.item-sliding-expandable-width-back {
3735
z-index: $z-index-item-options + 1;
3836
pointer-events: none;
3937
will-change: transform;
40-
}
38+
}

core/src/components/item-sliding/item-sliding.ios.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
z-index: $z-index-item-options + 1;
1111
pointer-events: none;
1212
will-change: transform;
13-
}
13+
}

core/src/components/item-sliding/item-sliding.md.scss

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
@import "../../themes/native/native.globals";
33

44
.item-sliding-active-slide .item {
5-
position: relative;
6-
7-
transition: transform 500ms cubic-bezier(0.36, 0.66, 0.04, 1);
8-
9-
opacity: 1;
10-
z-index: $z-index-item-options + 1;
11-
pointer-events: none;
12-
will-change: transform;
13-
}
5+
position: relative;
6+
7+
transition: transform 500ms cubic-bezier(0.36, 0.66, 0.04, 1);
8+
9+
opacity: 1;
10+
z-index: $z-index-item-options + 1;
11+
pointer-events: none;
12+
will-change: transform;
13+
}

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

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const ELASTIC_FACTOR = 0.55;
1414
const IONIC_ELASTIC_FACTOR = 0.15;
1515
const IONIC_SNAP_OPEN_RATIO = 0.4;
1616
const IONIC_EXPAND_TRIGGER = 40;
17-
const IONIC_VELOCITY_THRESHOLD = 400;
17+
const IONIC_FULL_SWIPE_VELOCITY_THRESHOLD = 400;
18+
const IONIC_OPEN_VELOCITY_THRESHOLD = 200;
1819
const IONIC_ACTION_BASE_WIDTH = 64;
1920
const IONIC_CONFIRM_PAUSE = 300;
2021
const FULL_SWIPE_TRANSITION_MS = 250;
@@ -499,11 +500,7 @@ export class ItemSliding implements ComponentInterface {
499500
ITEM_OPTION_EXPAND_THRESHOLD_CLASS
500501
);
501502

502-
this.setIonicExpandableWidth(
503-
previousDirection,
504-
this.getExpandableBaseWidth(previousDirection),
505-
false
506-
);
503+
this.setIonicExpandableWidth(previousDirection, this.getExpandableBaseWidth(previousDirection), false);
507504
return;
508505
}
509506

@@ -802,16 +799,20 @@ export class ItemSliding implements ComponentInterface {
802799
const optionsWidth = this.getOptionsWidthForDirection(activeDirection);
803800
const extraWidth = Math.max(0, Math.abs(this.openAmount) - optionsWidth);
804801
const hasExpandable = this.hasExpandableOptions(activeDirection === 'end' ? this.rightOptions : this.leftOptions);
805-
const wasRevealed = Math.abs(this.initialOpenAmount) >= optionsWidth;
806-
807802

808803
const closeDirection =
809-
activeDirection === 'end' ? velocityX > IONIC_VELOCITY_THRESHOLD : velocityX < -IONIC_VELOCITY_THRESHOLD;
804+
activeDirection === 'end'
805+
? velocityX > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD
806+
: velocityX < -IONIC_FULL_SWIPE_VELOCITY_THRESHOLD;
807+
808+
if (closeDirection) {
809+
this.setOpenAmount(0, true);
810+
return;
811+
}
810812

811813
if (
812-
!closeDirection &&
813814
hasExpandable &&
814-
(extraWidth >= IONIC_EXPAND_TRIGGER || (extraWidth > 0 && (wasRevealed && Math.abs(velocityX) > IONIC_VELOCITY_THRESHOLD)))
815+
(extraWidth >= IONIC_EXPAND_TRIGGER || Math.abs(velocityX) > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD)
815816
) {
816817
this.animateIonicFullSwipe(activeDirection).catch(() => {
817818
if (this.gesture) {
@@ -821,18 +822,15 @@ export class ItemSliding implements ComponentInterface {
821822
return;
822823
}
823824

824-
if (closeDirection) {
825-
this.setOpenAmount(0, true);
826-
return;
827-
}
825+
const flickOpen =
826+
activeDirection === 'end'
827+
? velocityX < -IONIC_OPEN_VELOCITY_THRESHOLD
828+
: velocityX > IONIC_OPEN_VELOCITY_THRESHOLD;
828829

830+
const fullOpen = activeDirection === 'end' ? this.optsWidthRightSide : -this.optsWidthLeftSide;
829831
const openThreshold = optionsWidth * IONIC_SNAP_OPEN_RATIO;
830-
const shouldSnapOpen = Math.abs(this.openAmount) > openThreshold;
831-
const restingPoint = shouldSnapOpen
832-
? activeDirection === 'end'
833-
? this.optsWidthRightSide
834-
: -this.optsWidthLeftSide
835-
: 0;
832+
const shouldSnapOpen = flickOpen || Math.abs(this.openAmount) > openThreshold;
833+
const restingPoint = shouldSnapOpen ? fullOpen : 0;
836834

837835
this.setOpenAmount(restingPoint, true);
838836
}
@@ -876,12 +874,12 @@ export class ItemSliding implements ComponentInterface {
876874
if (!this.item) {
877875
return;
878876
}
879-
877+
880878
const { el } = this;
881879
const style = this.item.style;
882880
const previousOpenAmount = this.openAmount;
883881
this.openAmount = openAmount;
884-
882+
885883
if (this.isIonicTheme()) {
886884
this.updateIonicExpandableFromOpenAmount(openAmount, isFinal, previousOpenAmount);
887885
}
@@ -894,7 +892,7 @@ export class ItemSliding implements ComponentInterface {
894892
this.el.classList.add('item-sliding-transition-open');
895893
}
896894
}
897-
895+
898896
if (openAmount > 0) {
899897
const fullSwipe = !this.isIonicTheme() && openAmount >= this.optsWidthRightSide + SWIPE_MARGIN;
900898
this.state = fullSwipe ? SlidingState.End | SlidingState.SwipeEnd : SlidingState.End;
@@ -914,12 +912,12 @@ export class ItemSliding implements ComponentInterface {
914912
}
915913
el.classList.remove('item-sliding-closing');
916914
}, 600);
917-
915+
918916
openSlidingItem = undefined;
919917
style.transform = '';
920918
return;
921919
}
922-
920+
923921
style.transform = `translate3d(${-openAmount}px,0,0)`;
924922
this.ionDrag.emit({
925923
amount: openAmount,

core/src/components/item-sliding/test/full-swipe/item-sliding.e2e.ts

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test';
22
import { configs, dragElementBy, test } from '@utils/test/playwright';
33

44
/**
5-
* Full swipe animation behavior is mode-independent but
6-
* child components (ion-item-options, ion-item-option) have
7-
* mode-specific styling, so we test across all modes.
8-
*
95
* When an item has at least one expandable option and the user swipes
106
* beyond the threshold (or with sufficient velocity), the item slides
117
* off-screen, fires ionSwipe, and returns to its closed position.
@@ -14,7 +10,7 @@ import { configs, dragElementBy, test } from '@utils/test/playwright';
1410
// Full animation cycle duration (100ms expand + 250ms off-screen + 300ms delay + 250ms return)
1511
const FULL_ANIMATION_MS = 1100;
1612

17-
configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => {
13+
configs({ modes: ['ios', 'md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => {
1814
test.describe(title('item-sliding: full swipe'), () => {
1915
test('should fire ionSwipe when expandable option is swiped fully (end side)', async ({ page }) => {
2016
await page.setContent(
@@ -58,6 +54,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
5854

5955
const ionSwipe = await page.spyOnEvent('ionSwipe');
6056
const item = page.locator('ion-item-sliding');
57+
await expect(item).toBeVisible();
58+
await page.waitForChanges();
6159
const dragByX = config.direction === 'rtl' ? -190 : 190;
6260

6361
await dragElementBy(item, page, dragByX);
@@ -82,6 +80,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
8280
);
8381

8482
const item = page.locator('ion-item-sliding');
83+
await expect(item).toBeVisible();
84+
await page.waitForChanges();
8585
const dragByX = config.direction === 'rtl' ? 190 : -190;
8686

8787
await dragElementBy(item, page, dragByX);
@@ -108,6 +108,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
108108

109109
const ionSwipe = await page.spyOnEvent('ionSwipe');
110110
const item = page.locator('ion-item-sliding');
111+
await expect(item).toBeVisible();
112+
await page.waitForChanges();
111113
const dragByX = config.direction === 'rtl' ? 180 : -180;
112114

113115
await dragElementBy(item, page, dragByX);
@@ -138,6 +140,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
138140

139141
const ionSwipe = await page.spyOnEvent('ionSwipe');
140142
const item = page.locator('ion-item-sliding');
143+
await expect(item).toBeVisible();
144+
await page.waitForChanges();
141145
const dragByX = config.direction === 'rtl' ? 190 : -190;
142146

143147
await dragElementBy(item, page, dragByX);
@@ -148,6 +152,155 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
148152
});
149153
});
150154

155+
/**
156+
* Test for Ionic theme that has a different full swipe animation behavior.
157+
*/
158+
configs({ modes: ['ionic-md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => {
159+
test.describe(title('item-sliding: full swipe'), () => {
160+
test('should fire ionSwipe when expandable option is swiped fully (end side)', async ({ page }) => {
161+
await page.setContent(
162+
`
163+
<ion-item-sliding>
164+
<ion-item>
165+
<ion-label>Expandable End (Swipe Left)</ion-label>
166+
</ion-item>
167+
<ion-item-options side="end">
168+
<ion-item-option expandable="true">Delete</ion-item-option>
169+
</ion-item-options>
170+
</ion-item-sliding>
171+
`,
172+
config
173+
);
174+
175+
const ionSwipe = await page.spyOnEvent('ionSwipe');
176+
const item = page.locator('ion-item-sliding');
177+
await expect(item).toBeVisible();
178+
await page.waitForChanges();
179+
const box = (await item.boundingBox())!;
180+
const y = box.y + box.height / 2;
181+
182+
// 1) Peek open (distance-based; moderate steps is fine)
183+
const peek = config.direction === 'rtl' ? 120 : -120;
184+
await dragElementBy(item, page, peek);
185+
186+
// 2) Fast flick in the same direction as “full swipe”
187+
const startX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40;
188+
const endX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40;
189+
await page.mouse.move(startX, y);
190+
await page.mouse.down();
191+
await page.mouse.move(endX, y, { steps: 2 }); // try 1–3; lower = faster
192+
await page.mouse.up();
193+
await ionSwipe.next();
194+
expect(ionSwipe).toHaveReceivedEventTimes(1);
195+
});
196+
197+
test('should fire ionSwipe when expandable option is swiped fully (start side)', async ({ page }) => {
198+
await page.setContent(
199+
`
200+
<ion-item-sliding>
201+
<ion-item>
202+
<ion-label>Expandable Start (Swipe Right)</ion-label>
203+
</ion-item>
204+
<ion-item-options side="start">
205+
<ion-item-option expandable="true">Archive</ion-item-option>
206+
</ion-item-options>
207+
</ion-item-sliding>
208+
`,
209+
config
210+
);
211+
212+
const ionSwipe = await page.spyOnEvent('ionSwipe');
213+
const item = page.locator('ion-item-sliding');
214+
await expect(item).toBeVisible();
215+
await page.waitForChanges();
216+
const box = (await item.boundingBox())!;
217+
const y = box.y + box.height / 2;
218+
219+
// 1) Peek open (distance-based; moderate steps is fine)
220+
const peek = config.direction === 'rtl' ? -120 : 120;
221+
await dragElementBy(item, page, peek);
222+
223+
// 2) Fast flick in the same direction as “full swipe”
224+
const startX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40;
225+
const endX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40;
226+
await page.mouse.move(startX, y);
227+
await page.mouse.down();
228+
await page.mouse.move(endX, y, { steps: 2 });
229+
await page.mouse.up();
230+
await ionSwipe.next();
231+
expect(ionSwipe).toHaveReceivedEventTimes(1);
232+
});
233+
234+
test('should return to closed state after full swipe animation completes', async ({ page }) => {
235+
await page.setContent(
236+
`
237+
<ion-item-sliding>
238+
<ion-item>
239+
<ion-label>Expandable End (Swipe Left)</ion-label>
240+
</ion-item>
241+
<ion-item-options side="end">
242+
<ion-item-option expandable="true">Delete</ion-item-option>
243+
</ion-item-options>
244+
</ion-item-sliding>
245+
`,
246+
config
247+
);
248+
249+
const item = page.locator('ion-item-sliding');
250+
await expect(item).toBeVisible();
251+
await page.waitForChanges();
252+
const box = (await item.boundingBox())!;
253+
const y = box.y + box.height / 2;
254+
255+
// 1) Peek open (distance-based; moderate steps is fine)
256+
const peek = config.direction === 'rtl' ? -120 : 120;
257+
await dragElementBy(item, page, peek);
258+
259+
// 2) Fast flick in the same direction as “full swipe”
260+
const startX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40;
261+
const endX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40;
262+
await page.mouse.move(startX, y);
263+
await page.mouse.down();
264+
await page.mouse.move(endX, y, { steps: 2 });
265+
await page.mouse.up();
266+
await page.waitForTimeout(FULL_ANIMATION_MS);
267+
268+
const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount());
269+
expect(openAmount).toBe(0);
270+
});
271+
272+
test('should NOT trigger full swipe animation for non-expandable options', async ({ page }) => {
273+
await page.setContent(
274+
`
275+
<ion-item-sliding>
276+
<ion-item>
277+
<ion-label>Non-Expandable (Should Show Options)</ion-label>
278+
</ion-item>
279+
<ion-item-options side="end">
280+
<ion-item-option>Edit</ion-item-option>
281+
</ion-item-options>
282+
</ion-item-sliding>
283+
`,
284+
config
285+
);
286+
const ionSwipe = await page.spyOnEvent('ionSwipe');
287+
const item = page.locator('ion-item-sliding');
288+
await expect(item).toBeVisible();
289+
await page.waitForChanges();
290+
const dragByX = config.direction === 'rtl' ? 180 : -180;
291+
292+
await dragElementBy(item, page, dragByX);
293+
await page.waitForChanges();
294+
295+
expect(ionSwipe).toHaveReceivedEventTimes(0);
296+
297+
const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount());
298+
299+
expect(Math.abs(openAmount)).toBeGreaterThan(0);
300+
});
301+
});
302+
});
303+
151304
/**
152305
* Velocity-based trigger: a fast short swipe should trigger the full animation
153306
* even if the raw distance alone wouldn't exceed the threshold.
@@ -172,6 +325,8 @@ configs({ modes: ['md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config
172325

173326
const ionSwipe = await page.spyOnEvent('ionSwipe');
174327
const item = page.locator('ion-item-sliding');
328+
await expect(item).toBeVisible();
329+
await page.waitForChanges();
175330
const box = (await item.boundingBox())!;
176331

177332
// Few steps = high velocity gesture

0 commit comments

Comments
 (0)