diff --git a/.github/workflows/masonry-e2e.yml b/.github/workflows/masonry-e2e.yml new file mode 100644 index 000000000..07f6cdf9e --- /dev/null +++ b/.github/workflows/masonry-e2e.yml @@ -0,0 +1,36 @@ +name: Masonry E2E + +on: + pull_request: + push: + branches: [main, feat/masonry-list] + +permissions: + contents: read + +jobs: + masonry: + name: Chromium and WebKit masonry regressions + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.6 + - run: bun install --frozen-lockfile + - run: bun install --frozen-lockfile + working-directory: example-web + - run: bun test __tests__/components/MasonryLegendList.test.tsx + - run: bunx playwright install --with-deps chromium webkit + working-directory: example-web + - run: bun run typecheck:e2e + working-directory: example-web + - run: bun run test:e2e + working-directory: example-web + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: masonry-browser-failures + path: example-web/test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 9fbf18364..7f941a42c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ example/android example/ios legendapp-list.tgz tsconfig.tsbuildinfo + +example-web/test-results/ +example-web/playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a91b8613..0fdc448bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Feat: Add optional `MasonryLegendList` for vertical, variable-height columns. +- Fix: Preserve pending column reflow across React render replay. + ## 3.3.11 - Fix: `scrollToEnd` and `maintainScrollAtEnd` keep reaching the end as content changes, while scrolling away or requesting another position cancels automatic following. diff --git a/README.md b/README.md index 75363db74..eae368a2f 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,26 @@ export default LegendListExample ``` +### Masonry layout + +Import `MasonryLegendList` from the optional masonry entrypoint to place each item in the shortest available column without adding the masonry implementation to the core bundle. + +```tsx +import { MasonryLegendList } from "@legendapp/list/masonry" + + photo.id} + numColumns={2} + recycleItems + renderItem={({ item }) => } +/> +``` + +Masonry lists are vertical and support dynamically measured or fixed-size items. Column spans and `overrideItemLayout` are not supported. + --- ## How to Build @@ -123,6 +143,29 @@ export default LegendListExample 2. `bun i` 3. `bun run ios` +## Masonry regression checks + +From the repository root, run `bun install --frozen-lockfile` and `bun test`. +For browser E2E checks: + +```sh +cd example-web +bun install --frozen-lockfile +bunx playwright install chromium webkit +bun run test:e2e +``` + +The runner starts and stops its own fixture server. Chromium and WebKit checks cover +recycled-card identity, prepend anchoring, append, actual column placement, dynamic +tall-card visibility during reverse scrolling, and reset at two viewport widths. +Failures retain screenshots and traces in `example-web/test-results/`. The Masonry E2E +workflow runs these checks for pull requests. + +For native interaction checks, run `bun run ios:fixtures` or `bun run android:fixtures` +in `example`, then open Masonry. Scroll before tapping a card and check the Selected +identity; exercise Prepend, Append, Columns, Toggle tall card, Reset, and Back. These +fixture checks do not establish release performance or app-specific acceptance. + ## PRs gladly accepted! There's not a ton of code so hopefully it's easy to contribute. If you want to add a missing feature or fix a bug please post an issue to see if development is already in progress so we can make sure to not duplicate work 😀. @@ -132,7 +175,7 @@ There's not a ton of code so hopefully it's easy to contribute. If you want to a - [] Column spans - [] overrideItemLayout - [] Sticky headers -- [] Masonry layout +- [x] Masonry layout - [] getItemType - [] React DOM implementation diff --git a/__tests__/components/MasonryLegendList.test.tsx b/__tests__/components/MasonryLegendList.test.tsx new file mode 100644 index 000000000..bf07156eb --- /dev/null +++ b/__tests__/components/MasonryLegendList.test.tsx @@ -0,0 +1,394 @@ +import * as React from "react"; + +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { ScrollAdjustHandler } from "../../src/core/ScrollAdjustHandler"; +import type { StateContext } from "../../src/state/state"; +import type { LegendListRef } from "../../src/types.base"; +import TestRenderer, { act } from "../helpers/testRenderer"; +import { registerBaseModuleMocks } from "../setup"; + +const handlerInstances: ScrollAdjustHandler[] = []; +let lastListProps: any; + +function registerMasonryListMocks() { + mock.module("@/components/ListComponent", () => ({ + ListComponent: (props: any) => { + lastListProps = props; + return null; + }, + })); + + mock.module("@/core/ScrollAdjustHandler", () => ({ + ScrollAdjustHandler: class { + context: StateContext; + + constructor(ctx: StateContext) { + this.context = ctx; + handlerInstances.push(this as any); + } + + requestAdjust() {} + setMounted() {} + getAdjust() { + return 0; + } + commitPendingAdjust() {} + }, + })); +} + +beforeEach(() => { + mock.restore(); + registerBaseModuleMocks(); + registerMasonryListMocks(); + handlerInstances.length = 0; + lastListProps = undefined; +}); + +describe("MasonryLegendList", () => { + it("places each item in the shortest column", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-shortest-column-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?shortest-column"); + const ref = React.createRef(); + const data = [ + { height: 100, id: "a" }, + { height: 200, id: "b" }, + { height: 50, id: "c" }, + { height: 60, id: "d" }, + ]; + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + item.height} + keyExtractor={(item) => item.id} + numColumns={2} + recycleItems={false} + ref={ref} + renderItem={() => null} + />, + ); + }); + + const state = ref.current?.getState(); + expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 100, 150]); + expect(state?.contentLength).toBe(210); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it("reflows downstream items when an estimated item is measured", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-dynamic-size-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?dynamic-size"); + const ref = React.createRef(); + const data = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + item.id} + numColumns={2} + recycleItems={false} + ref={ref} + renderItem={() => null} + />, + ); + }); + + expect([0, 1, 2, 3].map((index) => ref.current?.getState().positionAtIndex(index))).toEqual([0, 0, 100, 100]); + + await act(async () => { + lastListProps?.onLayout?.({ + nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } }, + }); + }); + const internalState = (handlerInstances.at(-1) as any).context.state; + internalState.didContainersLayout = true; + internalState.startBuffered = 0; + internalState.endBuffered = 3; + + await act(async () => { + ref.current?.setItemSize("a", { height: 200, width: 160 }); + }); + + const state = ref.current?.getState(); + expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 200, 200]); + expect(state?.contentLength).toBe(400); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it("falls back to one column when numColumns is not finite", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-invalid-columns-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?invalid-columns"); + const ref = React.createRef(); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + item.id} + numColumns={Number.NaN} + recycleItems={false} + ref={ref} + renderItem={() => null} + />, + ); + }); + + expect([0, 1].map((index) => ref.current?.getState().positionAtIndex(index))).toEqual([0, 100]); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it("uses the scroll-axis gap when balancing columns", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-gap-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?gap"); + const ref = React.createRef(); + const data = [ + { height: 100, id: "a" }, + { height: 50, id: "b" }, + { height: 100, id: "c" }, + ]; + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + item.height} + keyExtractor={(item) => item.id} + numColumns={2} + recycleItems={false} + ref={ref} + renderItem={() => null} + />, + ); + }); + + const state = ref.current?.getState(); + expect([0, 1, 2].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 60]); + // Content metrics exclude the trailing row gap, matching the current core. + expect(state?.contentLength).toBe(160); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it("rebalances when data is appended", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-append-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?append"); + const ref = React.createRef(); + const initialData = [ + { height: 100, id: "a" }, + { height: 200, id: "b" }, + { height: 50, id: "c" }, + ]; + const renderList = (data: typeof initialData) => ( + item.height} + keyExtractor={(item) => item.id} + numColumns={2} + recycleItems={false} + ref={ref} + renderItem={() => null} + /> + ); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create(renderList(initialData)); + }); + await act(async () => { + lastListProps?.onLayout?.({ + nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } }, + }); + }); + + await act(async () => { + renderer?.update(renderList([...initialData, { height: 60, id: "d" }])); + }); + + const state = ref.current?.getState(); + expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 100, 150]); + expect(state?.contentLength).toBe(210); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it("rebalances when numColumns changes", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-column-change-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?column-change"); + const ref = React.createRef(); + const data = [ + { height: 100, id: "a" }, + { height: 200, id: "b" }, + { height: 50, id: "c" }, + { height: 60, id: "d" }, + ]; + const renderList = (numColumns: number) => ( + item.height} + keyExtractor={(item) => item.id} + numColumns={numColumns} + recycleItems={false} + ref={ref} + renderItem={() => null} + /> + ); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create(renderList(2)); + }); + await act(async () => { + lastListProps?.onLayout?.({ + nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } }, + }); + }); + + await act(async () => { + renderer?.update(renderList(3)); + }); + + const state = ref.current?.getState(); + expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 0, 50]); + expect(state?.contentLength).toBe(200); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it("balances a large fixed-size dataset in one positioning pass", async () => { + const { LegendList } = await import("../../src/components/LegendList?masonry-large-dataset-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?large-dataset"); + const ref = React.createRef(); + const data = Array.from({ length: 10_000 }, (_, index) => ({ + height: 40 + ((index * 37) % 200), + id: String(index), + })); + const getFixedItemSize = mock((item: (typeof data)[number]) => item.height); + const expectedPositions: number[] = []; + const expectedColumns: number[] = []; + const columnHeights = [0, 0, 0]; + + for (let index = 0; index < data.length; index++) { + let shortestColumn = 0; + for (let column = 1; column < columnHeights.length; column++) { + if (columnHeights[column] < columnHeights[shortestColumn]) { + shortestColumn = column; + } + } + expectedPositions.push(columnHeights[shortestColumn]); + expectedColumns.push(shortestColumn + 1); + columnHeights[shortestColumn] += data[index].height; + } + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + item.id} + numColumns={3} + recycleItems + ref={ref} + renderItem={() => null} + />, + ); + }); + + const state = ref.current?.getState(); + const internalState = (handlerInstances.at(-1) as any).context.state; + expect(data.map((_, index) => state?.positionAtIndex(index))).toEqual(expectedPositions); + expect(internalState.columns).toEqual(expectedColumns); + expect(state?.contentLength).toBe(Math.max(...columnHeights)); + expect(getFixedItemSize).toHaveBeenCalledTimes(data.length); + + await act(async () => { + renderer?.unmount(); + }); + }); + it.each([ + [2, 1], + [3, 2], + [4, 3], + ])("keeps a tall card visible on reverse scroll with %i columns", async (columns, tallIndex) => { + const { LegendList } = await import("../../src/components/LegendList?masonry-tall-core"); + mock.module("@legendapp/list/react-native", () => ({ LegendList })); + const { MasonryLegendList } = await import("../../src/integrations/masonry?tall-reverse"); + const { calculateItemsInView } = await import("../../src/core/calculateItemsInView"); + const ref = React.createRef(); + const data = Array.from({ length: 100 }, (_, index) => ({ + height: index === tallIndex ? 2000 : 100, + id: String(index), + })); + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + item.height} + keyExtractor={(item) => item.id} + numColumns={columns} + recycleItems + ref={ref} + renderItem={() => null} + />, + ); + }); + await act(async () => { + lastListProps?.onLayout?.({ nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } } }); + }); + const ctx = (handlerInstances.at(-1) as any).context as StateContext; + ctx.state.didContainersLayout = true; + for (const scroll of [2500, 1500, 1400, 1600, 1450]) { + await act(async () => { + ctx.state.scroll = scroll; + ctx.state.scrollHistory.length = 0; + calculateItemsInView(ctx); + }); + for (let index = 0; index < data.length; index++) { + const top = ctx.state.positions[index]!; + if (top + data[index].height > scroll && top <= scroll + 300) { + expect(ctx.state.containerItemKeys.has(data[index].id)).toBe(true); + } + } + } + expect(ctx.state.startNoBuffer).toBe(tallIndex); + expect(ctx.state.containerItemKeys.has(String(tallIndex))).toBe(true); + expect(ctx.values.get("numContainers")).toBeLessThanOrEqual(32); + await act(async () => { + renderer?.unmount(); + }); + }); +}); diff --git a/example-web/bun.lock b/example-web/bun.lock index 357318a5d..d19a96b3b 100644 --- a/example-web/bun.lock +++ b/example-web/bun.lock @@ -15,6 +15,7 @@ "virtua": "^0.41.5", }, "devDependencies": { + "@playwright/test": "1.63.0", "@tailwindcss/vite": "^4.1.14", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", @@ -129,6 +130,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.38", "", {}, "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], @@ -367,6 +370,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], diff --git a/example-web/e2e/masonry.e2e.ts b/example-web/e2e/masonry.e2e.ts new file mode 100644 index 000000000..2d4c9ab03 --- /dev/null +++ b/example-web/e2e/masonry.e2e.ts @@ -0,0 +1,159 @@ +import { expect, type Page, test } from "@playwright/test"; + +async function readLayout(page: Page) { + return page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + const scroller = Array.from(document.querySelectorAll("div")).find( + (element) => + element.scrollHeight > element.clientHeight + 300 && getComputedStyle(element).overflowY === "auto", + ); + if (!scroller) throw new Error("Missing masonry scroll container"); + const viewport = scroller.getBoundingClientRect(); + const cards = Array.from(document.querySelectorAll("[data-card-id]")) + .map((element) => { + const bounds = element.getBoundingClientRect(); + return { + height: bounds.height, + id: element.dataset.cardId!, + width: bounds.width, + x: bounds.x, + y: bounds.y, + }; + }) + .filter((card) => card.y + card.height > viewport.y && card.y < viewport.bottom); + const overlaps: string[][] = []; + for (let i = 0; i < cards.length; i++) { + for (let j = i + 1; j < cards.length; j++) { + const a = cards[i]; + const b = cards[j]; + if ( + Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 1 && + Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 1 + ) + overlaps.push([a.id, b.id]); + } + } + return { + cards, + overlaps, + scroll: scroller.scrollTop, + viewportBottom: viewport.bottom, + viewportTop: viewport.y, + }; + }); +} + +async function scrollTo(page: Page, offset: number) { + for (let attempt = 0; attempt < 3; attempt++) { + const target = await page.evaluate(() => { + const scroller = Array.from(document.querySelectorAll("div")).find( + (element) => + element.scrollHeight > element.clientHeight + 300 && getComputedStyle(element).overflowY === "auto", + ); + if (!scroller) throw new Error("Missing masonry scroll container"); + const bounds = scroller.getBoundingClientRect(); + return { offset: scroller.scrollTop, x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; + }); + const delta = offset - target.offset; + if (Math.abs(delta) < 2) break; + await page.mouse.move(target.x, target.y); + await page.mouse.wheel(0, delta); + // A reverse gesture must actually move backward, even while sizes settle. + await expect + .poll(async () => Math.sign(delta) * ((await readLayout(page)).scroll - target.offset)) + .toBeGreaterThan(0); + let previous = ""; + let stableSamples = 0; + await expect + .poll( + async () => { + const layout = await readLayout(page); + const signature = JSON.stringify(layout); + stableSamples = signature === previous ? stableSamples + 1 : 0; + previous = signature; + return stableSamples; + }, + { intervals: [100] }, + ) + .toBeGreaterThanOrEqual(3); + expect(Math.sign(delta) * ((await readLayout(page)).scroll - target.offset)).toBeGreaterThan(0); + // Dynamic measurement may preserve an anchor a few pixels away from the + // requested wheel delta. Correct only after layout settles, as a user can. + } + expect(Math.abs((await readLayout(page)).scroll - offset)).toBeLessThan(2); + expect((await readLayout(page)).cards.length).toBeGreaterThan(0); + expect((await readLayout(page)).overlaps).toEqual([]); +} + +for (const width of [1280, 768]) { + test.describe(`${width}px viewport`, () => { + test.use({ viewport: { height: 900, width } }); + test.beforeEach(async ({ page }) => { + await page.goto("/masonry"); + await expect(page.getByRole("button", { exact: true, name: "Card 0" })).toBeVisible(); + }); + + test("deep and reverse scroll preserve recycled card identity", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + for (const offset of [1600, 3200, 900, 1400]) await scrollTo(page, offset); + const layout = await readLayout(page); + const card = layout.cards.find( + (item) => item.y > layout.viewportTop + 10 && item.y + item.height < layout.viewportBottom, + ); + expect(card).toBeDefined(); + await page.getByRole("button", { exact: true, name: `Card ${card!.id}` }).click(); + await expect(page.getByRole("status")).toHaveText(`Selected: ${card!.id} / Items: 80`); + await scrollTo(page, 0); + expect(errors).toEqual([]); + }); + + test("prepend preserves the visible anchor and append preserves selection", async ({ page }) => { + await scrollTo(page, 1400); + const before = await readLayout(page); + // MVCP anchors the first visible data index, not DOM recycler order. + const anchor = before.cards + .sort((a, b) => Number(a.id) - Number(b.id)) + .find((card) => card.y >= before.viewportTop - 10)!; + expect(anchor).toBeDefined(); + await page.getByRole("button", { exact: true, name: `Card ${anchor.id}` }).click(); + await page.getByRole("button", { exact: true, name: "Prepend" }).click(); + await expect(page.getByRole("status")).toHaveText(`Selected: ${anchor.id} / Items: 81`); + await expect + .poll(async () => { + const after = (await readLayout(page)).cards.find((card) => card.id === anchor.id); + return after ? Math.abs(after.y - anchor.y) : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(2); + await page.getByRole("button", { exact: true, name: "Append" }).click(); + await expect(page.getByRole("status")).toHaveText(`Selected: ${anchor.id} / Items: 82`); + expect((await readLayout(page)).overlaps).toEqual([]); + }); + + test("column changes and tall-card reverse scrolling retain visible cards", async ({ page }) => { + for (const columns of [4, 2, 3]) { + await page.getByRole("button", { name: /^Columns:/ }).click(); + await expect(page.getByRole("button", { exact: true, name: `Columns: ${columns}` })).toBeVisible(); + await expect + .poll(async () => new Set((await readLayout(page)).cards.map((card) => Math.round(card.x))).size) + .toBe(columns); + expect((await readLayout(page)).overlaps).toEqual([]); + } + await page.getByRole("button", { exact: true, name: "Toggle tall card" }).click(); + await expect + .poll(async () => (await readLayout(page)).cards.find((card) => card.id === "1")?.height) + .toBe(2000); + await scrollTo(page, 2800); + for (const offset of [1500, 1400, 1600, 1450]) { + await scrollTo(page, offset); + expect((await readLayout(page)).cards.some((card) => card.id === "1")).toBe(true); + } + await page.getByRole("button", { exact: true, name: "Reset" }).click(); + await expect(page.getByRole("status")).toHaveText("Selected: none / Items: 80"); + await scrollTo(page, 0); + await expect + .poll(async () => (await readLayout(page)).cards.find((card) => card.id === "1")?.height) + .toBe(143); + }); + }); +} diff --git a/example-web/package.json b/example-web/package.json index 0f478bc66..1f2de821b 100644 --- a/example-web/package.json +++ b/example-web/package.json @@ -10,7 +10,9 @@ "build": "VITE_LEGEND_LIST_MODE=examples tsc && vite build", "build:fixtures": "VITE_LEGEND_LIST_MODE=fixtures tsc && vite build", "build:ignore-errors": "vite build --mode development", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "typecheck:e2e": "tsc --project tsconfig.e2e.json" }, "dependencies": { "@tanstack/react-router": "^1.59.0", @@ -23,6 +25,7 @@ "virtua": "^0.41.5" }, "devDependencies": { + "@playwright/test": "1.63.0", "@tailwindcss/vite": "^4.1.14", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", diff --git a/example-web/playwright.config.ts b/example-web/playwright.config.ts new file mode 100644 index 000000000..61126371f --- /dev/null +++ b/example-web/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + forbidOnly: !!process.env.CI, + fullyParallel: false, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "webkit", use: { browserName: "webkit" } }, + ], + retries: 0, + testDir: "./e2e", + testMatch: "**/*.e2e.ts", + use: { + baseURL: "http://127.0.0.1:5197", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: "bun run dev:fixtures --host 127.0.0.1 --port 5197 --strictPort", + reuseExistingServer: false, + url: "http://127.0.0.1:5197/masonry", + }, + workers: 1, +}); diff --git a/example-web/src/catalogMeta.ts b/example-web/src/catalogMeta.ts index d4d3a11d4..1a2bc90b7 100644 --- a/example-web/src/catalogMeta.ts +++ b/example-web/src/catalogMeta.ts @@ -64,6 +64,11 @@ export const FIXTURE_SECTIONS: CatalogSection[] = [ slug: "columns", title: "Columns", }, + { + description: "Balances dynamically sized cards into the shortest available column.", + slug: "masonry", + title: "Masonry", + }, { description: "Forces external state updates through visible cells.", slug: "extra-data", diff --git a/example-web/src/fixtures/MasonryExample.tsx b/example-web/src/fixtures/MasonryExample.tsx new file mode 100644 index 000000000..8443ad7d8 --- /dev/null +++ b/example-web/src/fixtures/MasonryExample.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import { MasonryLegendList } from "@legendapp/list/masonry"; + +const COLORS = ["#7c3aed", "#2563eb", "#089669", "#059669", "#ca8a04", "#dc2626"]; +const makeItem = (id: number) => ({ + color: COLORS[Math.abs(id) % COLORS.length], + height: 96 + ((Math.abs(id) * 47) % 180), + id: String(id), +}); +const DATA = Array.from({ length: 80 }, (_, index) => makeItem(index)); + +export default function MasonryExample() { + const [data, setData] = useState(DATA); + const [columns, setColumns] = useState(3); + const [selected, setSelected] = useState(null); + const [tall, setTall] = useState(false); + return ( +
+
+ + + + + + + Selected: {selected ?? "none"} / Items: {data.length} + +
+ item.id} + maintainVisibleContentPosition + numColumns={columns} + recycleItems + renderItem={({ item }) => ( + + )} + style={{ flex: 1, minHeight: 0 }} + /> +
+ ); +} diff --git a/example-web/src/fixtures/routes.tsx b/example-web/src/fixtures/routes.tsx index 1434dabeb..387f8f12e 100644 --- a/example-web/src/fixtures/routes.tsx +++ b/example-web/src/fixtures/routes.tsx @@ -19,6 +19,7 @@ import HeaderMvcpExample from "./HeaderMvcpExample"; import InitialScrollAtEndExample from "./InitialScrollAtEndExample"; import InitialScrollIndexExample from "./InitialScrollIndexExample"; import LazyListExample from "./LazyListExample"; +import MasonryExample from "./MasonryExample"; import MutableCellsExample from "./MutableCellsExample"; import MVCPTestExample from "./MVCPTestExample"; import PrependLargeItemsJumpExample from "./PrependLargeItemsJumpExample"; @@ -84,6 +85,13 @@ export const FIXTURE_ROUTES: FixtureRoute[] = [ path: "columns", title: "Columns", }, + { + description: "Balances dynamically sized cards into the shortest available column.", + element: () => , + group: "Data & Layout", + path: "masonry", + title: "Masonry", + }, { description: "Searchable directory with dynamic filtering.", element: () => , diff --git a/example-web/tsconfig.e2e.json b/example-web/tsconfig.e2e.json new file mode 100644 index 000000000..41cb2b758 --- /dev/null +++ b/example-web/tsconfig.e2e.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "e2e", + "playwright.config.ts" + ], + "references": [] +} diff --git a/example-web/tsconfig.json b/example-web/tsconfig.json index 336ac0864..aaecd5f10 100644 --- a/example-web/tsconfig.json +++ b/example-web/tsconfig.json @@ -33,6 +33,12 @@ "@legendapp/list/react": [ "../src/react.ts" ], + "@legendapp/list/react-native": [ + "../src/react.ts" + ], + "@legendapp/list/masonry": [ + "../src/integrations/masonry.tsx" + ], "react": [ "./node_modules/@types/react" ], diff --git a/example-web/vite.config.ts b/example-web/vite.config.ts index 261572eca..9b80c6766 100644 --- a/example-web/vite.config.ts +++ b/example-web/vite.config.ts @@ -28,7 +28,9 @@ export default defineConfig(({ command, mode }) => { alias: { "@": path.resolve(__dirname, "../src"), "@examples": path.resolve(__dirname, "../examples-shared"), + "@legendapp/list/masonry": path.resolve(__dirname, "../src/integrations/masonry.tsx"), "@legendapp/list/react": path.resolve(__dirname, "../src/react.ts"), + "@legendapp/list/react-native": path.resolve(__dirname, "../src/react.ts"), }, // Deduplicate React to avoid multiple copies dedupe: ["react", "react-dom"], diff --git a/example/metro.config.js b/example/metro.config.js index ccf8e2b29..6db2249f0 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -16,6 +16,7 @@ config.resolver.nodeModulesPaths = [path.resolve(projectRoot, 'node_modules'), p const defaultResolveRequest = config.resolver.resolveRequest; const listEntrypoints = { '@legendapp/list/keyboard': path.join(listRoot, 'integrations/keyboard'), + '@legendapp/list/masonry': path.join(listRoot, 'integrations/masonry'), '@legendapp/list/react': path.join(listRoot, 'react'), '@legendapp/list/react-native': path.join(listRoot, 'react-native'), '@legendapp/list/reanimated': path.join(listRoot, 'integrations/reanimated'), diff --git a/example/screens/fixtures/masonry.tsx b/example/screens/fixtures/masonry.tsx new file mode 100644 index 000000000..d0e3a79aa --- /dev/null +++ b/example/screens/fixtures/masonry.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { Button, Pressable, StyleSheet, Text, View } from "react-native"; + +import { MasonryLegendList } from "@legendapp/list/masonry"; + +const COLORS = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#ca8a04", "#dc2626"]; +const makeItem = (index: number) => ({ + color: COLORS[Math.abs(index) % COLORS.length], + height: 96 + ((Math.abs(index) * 47) % 180), + id: String(index), +}); +const DATA = Array.from({ length: 80 }, (_, index) => makeItem(index)); + +export default function Masonry() { + const [data, setData] = useState(DATA); + const [columns, setColumns] = useState(2); + const [selected, setSelected] = useState(null); + const [tall, setTall] = useState(false); + return ( + + +