diff --git a/packages/react-resizable-panels-website/playwright.config.ts b/packages/react-resizable-panels-website/playwright.config.ts index e72b6c69d..486245298 100644 --- a/packages/react-resizable-panels-website/playwright.config.ts +++ b/packages/react-resizable-panels-website/playwright.config.ts @@ -15,6 +15,7 @@ const config: PlaywrightTestConfig = { reuseExistingServer: true, url: "http://localhost:1234", }, + timeout: 60_000, }; if (process.env.DEBUG) { @@ -23,7 +24,7 @@ if (process.env.DEBUG) { headless: false, launchOptions: { - slowMo: DEBUG ? 50 : undefined, + // slowMo: DEBUG ? 250 : undefined, }, }; } diff --git a/packages/react-resizable-panels-website/src/routes/iframe/index.tsx b/packages/react-resizable-panels-website/src/routes/iframe/index.tsx index 581186780..9abe3367b 100644 --- a/packages/react-resizable-panels-website/src/routes/iframe/index.tsx +++ b/packages/react-resizable-panels-website/src/routes/iframe/index.tsx @@ -1,23 +1,32 @@ -import { useState } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import styles from "./styles.module.css"; export default function Page() { - const [url] = useState(() => { - const url = new URL( - typeof window !== undefined ? window.location.href : "" - ); + const urlString = useSyncExternalStore( + function subscribe(onChange) { + window.addEventListener("navigate", onChange); + return function unsubscribe() { + window.removeEventListener("navigate", onChange); + }; + }, + function read() { + return window.location.href; + } + ); - return `${url.origin}/__e2e/?urlPanelGroup=${url.searchParams.get( - "urlPanelGroup" - )}`; - }); + const url = useMemo(() => new URL(urlString), [urlString]); return (
); diff --git a/packages/react-resizable-panels-website/src/routes/iframe/styles.module.css b/packages/react-resizable-panels-website/src/routes/iframe/styles.module.css index 8f79b8511..dff040e86 100644 --- a/packages/react-resizable-panels-website/src/routes/iframe/styles.module.css +++ b/packages/react-resizable-panels-website/src/routes/iframe/styles.module.css @@ -8,7 +8,7 @@ } .IFrame { - width: 400px; + width: 300px; height: 200px; border: none; } diff --git a/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts b/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts index 75a56959a..dc13eded2 100644 --- a/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts +++ b/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts @@ -2,7 +2,8 @@ import { expect, test } from "@playwright/test"; import { createElement } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import { goToUrl } from "./utils/url"; +import { goToUrl, goToUrlWithIframe } from "./utils/url"; +import assert from "assert"; test.describe("Resize handle", () => { test("should set 'data-resize-handle-active' attribute when active", async ({ @@ -14,9 +15,9 @@ test.describe("Resize handle", () => { PanelGroup, { direction: "horizontal" }, createElement(Panel, { minSize: 10 }), - createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }), + createElement(PanelResizeHandle), createElement(Panel, { minSize: 10 }), - createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }), + createElement(PanelResizeHandle), createElement(Panel, { minSize: 10 }) ) ); @@ -70,4 +71,74 @@ test.describe("Resize handle", () => { await last.getAttribute("data-resize-handle-active") ).toBeNull(); }); + + test("should stop dragging if the mouse is released outside of the document/owner", async ({ + page, + }) => { + for (let sameOrigin of [true, false]) { + await goToUrlWithIframe( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { minSize: 10 }), + createElement(PanelResizeHandle), + createElement(Panel, { minSize: 10 }) + ), + sameOrigin + ); + + const iframe = page.locator("iframe").first(); + const iframeBounds = await iframe.boundingBox(); + assert(iframeBounds); + + const panel = page.frameLocator("#frame").locator("[data-panel]").first(); + await expect(await panel.getAttribute("data-panel-size")).toBe("50.0"); + + const handle = page + .frameLocator("#frame") + .locator("[data-panel-resize-handle-id]") + .first(); + const handleBounds = await handle.boundingBox(); + assert(handleBounds); + + // Mouse down + await page.mouse.move(handleBounds.x, handleBounds.y); + await page.mouse.down(); + + // Mouse move to iframe edge (and verify resize) + await page.mouse.move(iframeBounds.x, iframeBounds.y); + await expect(await panel.getAttribute("data-panel-size")).toBe("10.0"); + + // Mouse move outside of iframe (and verify no resize) + await page.mouse.move(iframeBounds.x - 10, iframeBounds.y - 10); + await expect(await panel.getAttribute("data-panel-size")).toBe("10.0"); + + // Mouse move within frame (and verify resize) + await page.mouse.move(iframeBounds.x, iframeBounds.y); + await page.mouse.move(handleBounds.x, handleBounds.y); + await expect(await panel.getAttribute("data-panel-size")).toBe("50.0"); + + // Mouse move to iframe edge + await page.mouse.move( + iframeBounds.x + iframeBounds.width, + iframeBounds.y + iframeBounds.height + ); + await expect(await panel.getAttribute("data-panel-size")).toBe("90.0"); + + // Mouse move outside of iframe and release + await page.mouse.move( + iframeBounds.x + iframeBounds.width + 10, + iframeBounds.y + iframeBounds.height + 10 + ); + await expect(await panel.getAttribute("data-panel-size")).toBe("90.0"); + await page.mouse.up(); + + // Mouse move within frame (and verify no resize) + await page.mouse.move(handleBounds.x, handleBounds.y); + await expect(await panel.getAttribute("data-panel-size")).toBe("90.0"); + await page.mouse.move(iframeBounds.x, iframeBounds.y); + await expect(await panel.getAttribute("data-panel-size")).toBe("90.0"); + } + }); }); diff --git a/packages/react-resizable-panels-website/tests/StackingOrder.spec.ts b/packages/react-resizable-panels-website/tests/StackingOrder.spec.ts index d6b560908..a369cfe9e 100644 --- a/packages/react-resizable-panels-website/tests/StackingOrder.spec.ts +++ b/packages/react-resizable-panels-website/tests/StackingOrder.spec.ts @@ -50,6 +50,8 @@ test.describe("stacking order", () => { const pageX = dragHandleRect.x + dragHandleRect.width / 2; const pageY = dragHandleRect.y + dragHandleRect.height / 2; + page.mouse.down(); + { page.mouse.move(pageX, pageY); diff --git a/packages/react-resizable-panels-website/tests/utils/url.ts b/packages/react-resizable-panels-website/tests/utils/url.ts index 8d5d237ba..18dcc4cf9 100644 --- a/packages/react-resizable-panels-website/tests/utils/url.ts +++ b/packages/react-resizable-panels-website/tests/utils/url.ts @@ -18,6 +18,25 @@ export async function goToUrl( await page.goto(url.toString()); } +export async function goToUrlWithIframe( + page: Page, + element: ReactElement, + sameOrigin: boolean +) { + const encodedString = UrlPanelGroupToEncodedString(element); + + const url = new URL("http://localhost:1234/__e2e/iframe"); + url.searchParams.set("urlPanelGroup", encodedString); + if (sameOrigin) { + url.searchParams.set("sameOrigin", ""); + } + + // Uncomment when testing for easier repros + // console.log(url.toString()); + + await page.goto(url.toString()); +} + export async function updateUrl( page: Page, element: ReactElement | null diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts index 48c203b01..f9d4ac7f0 100644 --- a/packages/react-resizable-panels/src/PanelGroup.ts +++ b/packages/react-resizable-panels/src/PanelGroup.ts @@ -638,9 +638,6 @@ function PanelGroupWithForwardedRef({ keyboardResizeBy, panelGroupElement ); - if (delta === 0) { - return; - } // Support RTL layouts const isHorizontal = direction === "horizontal"; diff --git a/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts b/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts index ae3068edf..f5844eb72 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts +++ b/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts @@ -87,7 +87,7 @@ export function registerResizeHandle( }; } -function handlePointerDown(event: ResizeEvent) { +function handlePointerDown(event: PointerEvent) { const { target } = event; const { x, y } = getResizeEventCoordinates(event); @@ -104,9 +104,17 @@ function handlePointerDown(event: ResizeEvent) { } } -function handlePointerMove(event: ResizeEvent) { +function handlePointerMove(event: PointerEvent) { const { x, y } = getResizeEventCoordinates(event); + // Edge case (see #340) + // Detect when the pointer has been released outside an iframe on a different domain + if (event.buttons === 0) { + isPointerDown = false; + + updateResizeHandlerStates("up", event); + } + if (!isPointerDown) { const { target } = event; diff --git a/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts b/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts index 93383aaff..b0123bef9 100644 --- a/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts +++ b/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts @@ -71,8 +71,8 @@ export function useWindowSplitterResizeHandlerBehavior({ ? index - 1 : handles.length - 1 : index + 1 < handles.length - ? index + 1 - : 0; + ? index + 1 + : 0; const nextHandle = handles[nextIndex] as HTMLElement; nextHandle.focus(); diff --git a/packages/react-resizable-panels/src/utils/test-utils.ts b/packages/react-resizable-panels/src/utils/test-utils.ts index 0082084b8..faa87b985 100644 --- a/packages/react-resizable-panels/src/utils/test-utils.ts +++ b/packages/react-resizable-panels/src/utils/test-utils.ts @@ -12,6 +12,7 @@ export function dispatchPointerEvent(type: string, target: HTMLElement) { bubbles: true, clientX, clientY, + buttons: 1, }); Object.defineProperties(event, { pageX: { diff --git a/tsconfig.json b/tsconfig.json index 195d7510a..4e1e9b8c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,8 +9,8 @@ "noUncheckedIndexedAccess": true, "strict": true, "typeRoots": ["node_modules/@types"], - "types": ["jest", "node"] + "types": ["jest", "node"], }, "exclude": ["node_modules"], - "include": ["declaration.d.ts", "packages/**/*.ts", "packages/**/*.tsx"] + "include": ["declaration.d.ts", "packages/**/*.ts", "packages/**/*.tsx"], }