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"],
}