diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index 28acfdbb7..6aaf656fc 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -2,6 +2,19 @@ if (import.meta.env.STORYBOOK_RTL) { document.documentElement.dir = "rtl"; } +// for e2e +if (import.meta.env.STORYBOOK_E2E) { + const style = document.createElement("style"); + // elements with `pointer-events: none` can't be found by document.elementFromPoint() + style.appendChild( + document.createTextNode( + `* { + pointer-events: auto !important; + }` + ) + ); + document.head.appendChild(style); +} export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, diff --git a/e2e/VList.ios.spec.ts b/e2e/VList.ios.spec.ts new file mode 100644 index 000000000..09c547cb2 --- /dev/null +++ b/e2e/VList.ios.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from "@playwright/test"; +import { + storyUrl, + scrollableSelector, + getLastItem, + scrollWithTouch, + getScrollTop, + getFirstItem, +} from "./utils"; + +test.describe("check if scroll jump compensation in emulated iOS WebKit works", () => { + test("scroll with touch", async ({ page }) => { + await page.goto(storyUrl("basics-vlist--default")); + + const component = await page.waitForSelector(scrollableSelector); + await component.waitForElementState("stable"); + + // check if first is displayed + const last = await getFirstItem(component); + await expect(last.text).toEqual("0"); + await expect(last.top).toBeLessThanOrEqual(0); + + await component.tap(); + + const [w, h] = await page.evaluate(() => [ + window.outerWidth, + window.outerHeight, + ]); + const centerX = w / 2; + const centerY = h / 2; + + let top: number = await getScrollTop(component); + for (let i = 0; i < 5; i++) { + await scrollWithTouch(component, { + fromX: centerX, + fromY: centerY + h / 3, + toX: centerX, + toY: centerY - h / 3, + }); + + // check if item position is preserved during flush + const [/*nextTopBeforeFlush,*/ nextLastItemBeforeFlush] = + await Promise.all([ + // getScrollTop(component), + getFirstItem(component), + ]); + await page.waitForTimeout(500); + const [nextTop, nextLastItem] = await Promise.all([ + getScrollTop(component), + getFirstItem(component), + ]); + + expect(nextTop).toBeGreaterThan(top); + // expect(nextTop).not.toBe(nextTopBeforeFlush); + expect(nextLastItem.text).toEqual(nextLastItemBeforeFlush.text); + expect( + Math.abs(nextLastItem.top - nextLastItemBeforeFlush.top) // FIXME: may not be 0 in Safari + ).toBeLessThanOrEqual(1); + + top = nextTop; + } + }); + + test("reverse scroll with touch", async ({ page }) => { + await page.goto(storyUrl("basics-vlist--reverse")); + + const component = await page.waitForSelector(scrollableSelector); + await component.waitForElementState("stable"); + + // FIXME this offset is needed only in ci for unknown reason + const opts = { y: 60 } as const; + + // check if last is displayed + const last = await getLastItem(component, opts); + await expect(last.text).toEqual("999"); + await expect(last.bottom).toBeLessThanOrEqual(1); // FIXME: may not be 0 in Safari + + await component.tap(); + + const [w, h] = await page.evaluate(() => [ + window.outerWidth, + window.outerHeight, + ]); + const centerX = w / 2; + const centerY = h / 2; + + let top: number = await getScrollTop(component); + for (let i = 0; i < 5; i++) { + await scrollWithTouch(component, { + fromX: centerX, + fromY: centerY - h / 3, + toX: centerX, + toY: centerY + h / 3, + }); + + // check if item position is preserved during flush + const [nextTopBeforeFlush, nextLastItemBeforeFlush] = await Promise.all([ + getScrollTop(component), + getLastItem(component, opts), + ]); + await page.waitForTimeout(500); + const [nextTop, nextLastItem] = await Promise.all([ + getScrollTop(component), + getLastItem(component, opts), + ]); + + expect(nextTop).toBeLessThan(top); + expect(nextTop).not.toBe(nextTopBeforeFlush); + expect(nextLastItem.text).toEqual(nextLastItemBeforeFlush.text); + expect( + Math.abs(nextLastItem.bottom - nextLastItemBeforeFlush.bottom) // FIXME: may not be 0 in Safari + ).toBeLessThanOrEqual(1); + + top = nextTop; + } + }); + + test("reverse scroll with momentum scroll", async ({ page }) => { + await page.goto(storyUrl("basics-vlist--reverse")); + + const component = await page.waitForSelector(scrollableSelector); + await component.waitForElementState("stable"); + + // FIXME this offset is needed only in ci for unknown reason + const opts = { y: 60 } as const; + + // check if last is displayed + const last = await getLastItem(component, opts); + await expect(last.text).toEqual("999"); + await expect(last.bottom).toBeLessThanOrEqual(1); // FIXME: may not be 0 in Safari + + await component.tap(); + + const [w, h] = await page.evaluate(() => [ + window.outerWidth, + window.outerHeight, + ]); + const centerX = w / 2; + const centerY = h / 2; + + let top: number = await getScrollTop(component); + for (let i = 0; i < 5; i++) { + await scrollWithTouch(component, { + fromX: centerX, + fromY: centerY - h / 3, + toX: centerX, + toY: centerY + h / 3, + momentumScroll: true, + }); + + // check if item position is preserved during flush + const [nextTopBeforeFlush, nextLastItemBeforeFlush] = await Promise.all([ + getScrollTop(component), + getLastItem(component, opts), + ]); + await page.waitForTimeout(500); + const [nextTop, nextLastItem] = await Promise.all([ + getScrollTop(component), + getLastItem(component, opts), + ]); + + expect(nextTop).toBeLessThan(top); + expect(nextTop).not.toBe(nextTopBeforeFlush); + expect(nextLastItem.text).toEqual(nextLastItemBeforeFlush.text); + expect( + Math.abs(nextLastItem.bottom - nextLastItemBeforeFlush.bottom) // FIXME: may not be 0 in Safari + ).toBeLessThanOrEqual(1); + + top = nextTop; + } + }); + + // TODO shift/unshift + + // TODO display none +}); diff --git a/e2e/utils.ts b/e2e/utils.ts index fb15d74b6..7eedc1c6c 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -26,16 +26,20 @@ export const getFirstItem = ( }; export const getLastItem = ( - scrollable: ElementHandle + scrollable: ElementHandle, + offset: { x?: number; y?: number } = {} ) => { - return scrollable.evaluate((s) => { + return scrollable.evaluate((s, { x: offsetX = 2, y: offsetY = 2 }) => { const rect = s.getBoundingClientRect(); - const el = document.elementFromPoint(rect.left + 2, rect.bottom - 2)!; + const el = document.elementFromPoint( + rect.left + offsetX, + rect.bottom - offsetY + )!; return { text: el.textContent!, bottom: el.getBoundingClientRect().bottom - rect.bottom, }; - }); + }, offset); }; // export const getFirstItemRtl = ( @@ -184,3 +188,114 @@ export const windowScrollToRight = async ( }); await scrollable.waitForElementState("stable"); }; + +export const scrollWithTouch = ( + scrollable: ElementHandle, + target: { + fromX: number; + toX: number; + fromY: number; + toY: number; + momentumScroll?: boolean; + } +): Promise => { + return scrollable.evaluate( + async ( + e, + { fromX, toX, fromY, toY, momentumScroll: isMomentumScrolling = false } + ) => { + const diffY = fromY - toY; + const diffX = fromX - toX; + + let count = 1; + const MAX_COUNT = 60; + + const createTouchEvent = ( + name: "touchstart" | "touchmove" | "touchend" + ): TouchEvent => { + // const touchObj = new Touch({ + // identifier: Date.now(), + // target: e, + // clientX: fromX, + // clientY: fromY, + // radiusX: 2.5, + // radiusY: 2.5, + // rotationAngle: 10, + // force: 0.5, + // }); + return new TouchEvent(name, { + bubbles: true, + cancelable: true, + // touches: [touchObj], + // targetTouches: [], + // changedTouches: [], + }); + }; + + const touchStart = () => { + e.dispatchEvent(createTouchEvent("touchstart")); + }; + const touchEnd = () => { + e.dispatchEvent(createTouchEvent("touchend")); + }; + const touchMove = () => { + setTimeout(() => { + e.dispatchEvent(createTouchEvent("touchmove")); + if (count > MAX_COUNT) { + // NOP + } else { + if (diffY) { + e.scrollTop += diffY / MAX_COUNT; + } + if (diffX) { + e.scrollLeft += diffX / MAX_COUNT; + } + } + + count++; + }, 500 / MAX_COUNT); + }; + + e.addEventListener( + "touchstart", + () => { + touchMove(); + }, + { once: true, passive: true } + ); + + let resolve: () => void; + let isScrollStarted = false; + let isScrollEnded = false; + const onScroll = () => { + if (isScrollEnded) return; + + if (isMomentumScrolling && !isScrollStarted) { + touchEnd(); + } + isScrollStarted = true; + + if (count >= MAX_COUNT) { + isScrollEnded = true; + e.removeEventListener("scroll", onScroll); + + if (isMomentumScrolling) { + resolve(); + } else { + setTimeout(() => { + touchEnd(); + resolve(); + }, 500); + } + } else { + touchMove(); + } + }; + e.addEventListener("scroll", onScroll, { passive: true }); + + touchStart(); + return new Promise((r) => (resolve = r)); + }, + target + ); +}; diff --git a/playwright.config.ts b/playwright.config.ts index 0a1d15a36..a4e42be3d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,6 +6,8 @@ import { defineConfig, devices } from "@playwright/test"; */ // require('dotenv').config(); +const IOS_SPECS = /.ios.spec.ts$/; + /** * See https://playwright.dev/docs/test-configuration. */ @@ -28,6 +30,8 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", + + // video: "on", }, /* Configure projects for major browsers */ @@ -35,16 +39,19 @@ export default defineConfig({ { name: "chromium", use: { ...devices["Desktop Chrome"] }, + testIgnore: IOS_SPECS, }, { name: "firefox", use: { ...devices["Desktop Firefox"] }, + testIgnore: IOS_SPECS, }, { name: "webkit", use: { ...devices["Desktop Safari"] }, + testIgnore: IOS_SPECS, }, /* Test against mobile viewports. */ @@ -52,10 +59,11 @@ export default defineConfig({ // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 13"] }, + testMatch: IOS_SPECS, + }, /* Test against branded browsers. */ // { @@ -70,7 +78,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "npm run storybook", + command: "STORYBOOK_E2E=1 npm run storybook", url: "http://127.0.0.1:6006", reuseExistingServer: !process.env.CI, },