diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index d2bce0e0dfd..d3272a620ae 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -408,6 +408,11 @@ export class ItemSliding implements ComponentInterface { if (this.tmr !== undefined) { clearTimeout(this.tmr); this.tmr = undefined; + // Reopening cancels the close animation, including its interaction cleanup. + this.el.classList.remove('item-sliding-closing'); + if (this.gesture) { + this.gesture.enable(!this.disabled); + } } if (!this.item) { return; diff --git a/core/src/components/item-sliding/test/open/index.html b/core/src/components/item-sliding/test/open/index.html new file mode 100644 index 00000000000..32e42bc234e --- /dev/null +++ b/core/src/components/item-sliding/test/open/index.html @@ -0,0 +1,40 @@ + + + + + Item Sliding - Switching Sides + + + + + + + + + + +

+ Open one side, then the other. The new option should remain clickable and the item should still swipe closed. +

+ + + + + Sliding item + + Start + + + End + + + + +
+
+ + diff --git a/core/src/components/item-sliding/test/open/item-sliding.e2e.ts b/core/src/components/item-sliding/test/open/item-sliding.e2e.ts new file mode 100644 index 00000000000..0fa02f9e5c8 --- /dev/null +++ b/core/src/components/item-sliding/test/open/item-sliding.e2e.ts @@ -0,0 +1,129 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import { configs, dragElementBy, test } from '@utils/test/playwright'; + +const openSide = async (item: Locator, side: 'start' | 'end', direction: 'ltr' | 'rtl') => { + await item.evaluate((el: HTMLIonItemSlidingElement, side) => el.open(side), side); + const ratio = (side === 'end' ? 1 : -1) * (direction === 'rtl' ? -1 : 1); + await expect.poll(() => item.evaluate((el: HTMLIonItemSlidingElement) => el.getSlidingRatio())).toBe(ratio); + const itemElement = await item.locator('ion-item').elementHandle(); + await itemElement!.waitForElementState('stable'); +}; + +const slidingItem = ` + + Sliding item + + Start + + + End + + +`; + +// Programmatic open() in RTL is tracked separately in FW-3711. +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('item-sliding: switching open sides'), () => { + test.describe('single item', () => { + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + ${slidingItem} + + + `, + config + ); + }); + + for (const side of ['start', 'end'] as const) { + const previousSide = side === 'start' ? 'end' : 'start'; + + test(`should allow option clicks after opening ${side} from ${previousSide}`, async ({ page }) => { + const item = page.locator('ion-item-sliding'); + await openSide(item, previousSide, config.direction); + await openSide(item, side, config.direction); + + await page.locator(`ion-item-options[side="${side}"] ion-item-option`).click(); + await expect(page.locator('output')).toHaveText(side); + }); + + test(`should allow swiping closed after opening ${side} from ${previousSide}`, async ({ page }) => { + const item = page.locator('ion-item-sliding'); + await openSide(item, previousSide, config.direction); + await openSide(item, side, config.direction); + + await dragElementBy( + item, + page, + (await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount())) * 0.75 + ); + await expect.poll(() => item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount())).toBe(0); + }); + + test(`should preserve disabled gestures when switching to ${side}`, async ({ page }) => { + const item = page.locator('ion-item-sliding'); + await openSide(item, previousSide, config.direction); + await item.evaluate((el: HTMLIonItemSlidingElement) => (el.disabled = true)); + await page.waitForChanges(); + await openSide(item, side, config.direction); + + await dragElementBy( + item, + page, + (await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount())) * 0.75 + ); + expect( + await item.evaluate(async (el: HTMLIonItemSlidingElement) => Math.abs(await el.getSlidingRatio())) + ).toBe(1); + + await item.evaluate((el: HTMLIonItemSlidingElement) => (el.disabled = false)); + await page.waitForChanges(); + await dragElementBy( + item, + page, + (await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount())) * 0.75 + ); + await expect.poll(() => item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount())).toBe(0); + }); + } + + test('should keep options clickable after repeated side changes', async ({ page }) => { + const item = page.locator('ion-item-sliding'); + for (const side of ['start', 'end', 'start', 'end'] as const) { + await openSide(item, side, config.direction); + } + + await page.locator('ion-item-options[side="end"] ion-item-option').click(); + await expect(page.locator('output')).toHaveText('end'); + }); + + test('should still finish closing before allowing a new swipe', async ({ page }) => { + const item = page.locator('ion-item-sliding'); + await openSide(item, 'end', config.direction); + await openSide(item, 'start', config.direction); + await item.evaluate((el: HTMLIonItemSlidingElement) => el.close()); + await expect(item).not.toHaveClass(/item-sliding-active-slide/); + + await dragElementBy(item, page, config.direction === 'rtl' ? 150 : -150); + await expect + .poll(() => item.evaluate((el: HTMLIonItemSlidingElement) => el.getSlidingRatio())) + .toBe(config.direction === 'rtl' ? -1 : 1); + }); + }); + + test('should still close the first item when another item opens', async ({ page }) => { + await page.setContent(`${slidingItem}${slidingItem}`, config); + const first = page.locator('ion-item-sliding').first(); + const second = page.locator('ion-item-sliding').last(); + await openSide(first, 'end', config.direction); + await openSide(second, 'start', config.direction); + + await expect(first).not.toHaveClass(/item-sliding-active-slide/); + await second.locator('ion-item-options[side="start"] ion-item-option').click(); + await expect(page.locator('output')).toHaveText('start'); + }); + }); +});