Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 4.0.9

- [#542](https://github.com/bvaughn/react-resizable-panels/pull/542): Clicks on higher `z-index` elements (e.g. modals) should not trigger separators behind them

## 4.0.8

- [#541](https://github.com/bvaughn/react-resizable-panels/pull/541): Don't set invalid layouts when Group is hidden or has a width/height of 0
Expand Down
4 changes: 3 additions & 1 deletion integrations/vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
import "./index.css";
import { Decoder } from "./routes/Decoder";
import { Visibility } from "./routes/Visibility";
import { Edges } from "./routes/Edges";
import { Encoder } from "./routes/Encoder";
import { Home } from "./routes/Home";
import { StackingOrder } from "./routes/StackingOrder";
import { Visibility } from "./routes/Visibility";

createRoot(document.getElementById("root")!).render(
<StrictMode>
Expand All @@ -20,6 +21,7 @@ createRoot(document.getElementById("root")!).render(
path="/e2e/visibility/:mode/:default/:encoded"
element={<Visibility />}
/>
<Route path="/e2e/stacking-order" element={<StackingOrder />} />
</Routes>
</BrowserRouter>
</StrictMode>
Expand Down
49 changes: 49 additions & 0 deletions integrations/vite/src/routes/StackingOrder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";
import type { Layout } from "react-resizable-panels";
import { DebugData } from "../components/DebugData";
import { Group } from "../components/Group";
import { Panel } from "../components/Panel";
import { Separator } from "../components/Separator";

export function StackingOrder() {
const [state, setState] = useState<{
onLayoutCount: number;
layout: Layout;
}>({
layout: {},
onLayoutCount: 0
});

return (
<div className="px-2 py-10 flex flex-col gap-2 h-full">
<Group
className="w-25 h-25 min-h-25"
onLayoutChange={(layout) => {
setState((prev) => ({
onLayoutCount: prev.onLayoutCount + 1,
layout
}));
}}
>
<Panel>id: left</Panel>
<Separator id="separator" />
<Panel>id: center</Panel>
<Panel>id: right</Panel>
</Group>
<div className="absolute left-[33%] ml-[-50px] w-[100px] top-5 bg-slate-300/65 text-slate-800 p-2 rounded text-center text-xs">
top-left
</div>
<div className="absolute left-[66%] ml-[-50px] w-[100px] top-5 bg-slate-300/65 text-slate-800 p-2 rounded text-center text-xs">
top-right
</div>
<div className="absolute left-[33%] ml-[-50px] w-[100px] top-35 bg-slate-300/65 text-slate-800 p-2 rounded text-center text-xs">
bottom-left
</div>
<div className="absolute left-[66%] ml-[-50px] w-[100px] top-35 bg-slate-300/65 text-slate-800 p-2 rounded text-center text-xs">
bottom-right
</div>

<DebugData data={state} />
</div>
);
}
56 changes: 56 additions & 0 deletions integrations/vite/tests/stacking-order.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
import { calculateHitArea } from "./utils/calculateHitArea";

test.describe("stacking order", () => {
test("should ignore pointer events that target overlapping higher z-index targets", async ({
page
}) => {
await page.goto("http://localhost:3012/e2e/stacking-order");

await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();

const box = (await page.getByRole("separator").boundingBox())!;

await page.mouse.move(box.x, box.y);
await page.mouse.down();
await page.mouse.move(0, 0);
await page.mouse.up();

await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();

const hitAreaBox = await calculateHitArea(page, ["center", "right"]);

await page.mouse.move(hitAreaBox.x, hitAreaBox.y);
await page.mouse.down();
await page.mouse.move(1000, 0);
await page.mouse.up();

await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();
});

test("should allow pointer events that target nearby but not overlapping higher z-index targets", async ({
page
}) => {
await page.goto("http://localhost:3012/e2e/stacking-order");

await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();

const box = (await page.getByRole("separator").boundingBox())!;

await page.mouse.move(box.x, box.y + box.height);
await page.mouse.down();
await page.mouse.move(0, 0);
await page.mouse.up();

await expect(page.getByText('"onLayoutCount": 2')).toBeVisible();

const hitAreaBox = await calculateHitArea(page, ["center", "right"]);

await page.mouse.move(hitAreaBox.x, hitAreaBox.y + hitAreaBox.height);
await page.mouse.down();
await page.mouse.move(1000, 0);
await page.mouse.up();

await expect(page.getByText('"onLayoutCount": 3')).toBeVisible();
});
});
97 changes: 97 additions & 0 deletions lib/global/utils/doRectsIntersect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import type { Rect } from "../../types";
import { doRectsIntersect } from "./doRectsIntersect";

const emptyRect = { x: 0, y: 0, width: 0, height: 0 };
const rect = { x: 25, y: 25, width: 50, height: 50 };

function forkRect(partial: Partial<Rect>) {
return { ...rect, ...partial };
}

describe("doRectsIntersect", () => {
function verify(rectOne: Rect, rectTwo: Rect, expected: boolean) {
const actual = doRectsIntersect(rectOne, rectTwo);

try {
expect(actual).toBe(expected);
} catch (thrown) {
console.log(
"Expected",
rectOne,
"to",
expected ? "intersect" : "not intersect",
rectTwo
);

throw thrown;
}
}

test("should handle empty rects", () => {
verify(emptyRect, emptyRect, false);
});

test("should support fully overlapping rects", () => {
verify(rect, rect, true);

verify(rect, forkRect({ x: 35, width: 30 }), true);
verify(rect, forkRect({ y: 35, height: 30 }), true);
verify(
rect,
forkRect({
x: 35,
y: 35,
width: 30,
height: 30
}),
true
);

verify(rect, forkRect({ x: 10, width: 100 }), true);
verify(rect, forkRect({ y: 10, height: 100 }), true);
verify(
rect,
forkRect({
x: 10,
y: 10,
width: 100,
height: 100
}),
true
);
});

test.each([[{ x: 0 }], [{ y: 0 }]])(
"should support partially overlapping rects: %o",
(partial) => {
verify(forkRect(partial), rect, true);
}
);

test.each([
[{ x: 100 }],
[{ x: -100 }],
[{ y: 100 }],
[{ y: -100 }],
[{ x: -100, y: -100 }],
[{ x: 100, y: 100 }],
[{ x: -25 }],
[{ x: 75 }],
[{ y: -25 }],
[{ y: 75 }],
[{ x: -25, y: -25 }],
[{ x: 75, y: 75 }]
])("should support non-overlapping rects: %o", (partial) => {
verify(forkRect(partial), rect, false);
});

test("should support all negative coordinates", () => {
expect(
doRectsIntersect(
{ x: -100, y: -100, width: 50, height: 50 },
{ x: -110, y: -90, width: 50, height: 50 }
)
).toBe(true);
});
});
10 changes: 10 additions & 0 deletions lib/global/utils/doRectsIntersect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Rect } from "../../types";

export function doRectsIntersect(a: Rect, b: Rect): boolean {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
8 changes: 7 additions & 1 deletion lib/global/utils/findMatchingHitRegions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "../dom/calculateHitRegions";
import { findClosetHitRegion } from "./findClosetHitRegion";
import { isCoarsePointer } from "./isCoarsePointer";
import { isViableHitTarget } from "./isViableHitTarget";

export function findMatchingHitRegions(
event: PointerEvent,
Expand All @@ -31,7 +32,12 @@ export function findMatchingHitRegions(
if (
match &&
match.distance.x <= maxDistance &&
match.distance.y <= maxDistance
match.distance.y <= maxDistance &&
isViableHitTarget({
groupElement: groupData.element,
hitRegion: match.hitRegion.rect,
pointerEventTarget: event.target
})
) {
matchingHitRegions.push(match.hitRegion);
}
Expand Down
54 changes: 54 additions & 0 deletions lib/global/utils/isViableHitTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { compare } from "../../vendor/stacking-order";
import { doRectsIntersect } from "./doRectsIntersect";

// This library adds pointer event handlers to the Window for two reasons:
// 1. It allows detecting when the pointer is "near" to a panel border or separator element,
// (which can be particularly helpful on touch devices)
// 2. It allows detecting pointer interactions that apply to multiple, nearby panels/separators
// (in the event of e.g. nested groups)
//
// Because events are handled at the Window, it's important to detect when another element is "above" a separator (e.g. a modal)
// as this should prevent the separator element from being clicked.
// This function does that determination.
export function isViableHitTarget({
groupElement,
hitRegion,
pointerEventTarget
}: {
groupElement: HTMLElement;
hitRegion: DOMRect;
pointerEventTarget: EventTarget | null;
}) {
if (
!(pointerEventTarget instanceof HTMLElement) ||
pointerEventTarget.contains(groupElement) ||
groupElement.contains(pointerEventTarget)
) {
// Calculating stacking order has a cost;
// If either group or element contain the other, the click is safe and we can skip calculating the indices
return true;
}

if (compare(pointerEventTarget, groupElement) > 0) {
// If the pointer target is above the separator, check for overlap
// If they are near each other, but not overlapping, then the separator is still a viable target
//
// Note that it's not sufficient to compare only the target
// The target might be a small element inside of a larger container
// (For example, a SPAN or a DIV inside of a larger modal dialog)
let currentElement: HTMLElement | SVGElement | null = pointerEventTarget;
while (currentElement) {
if (currentElement.contains(groupElement)) {
return true;
} else if (
doRectsIntersect(currentElement.getBoundingClientRect(), hitRegion)
) {
return false;
}

currentElement = currentElement.parentElement;
}
}

return true;
}
2 changes: 2 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ export type PointerPrecision = {
coarse: number;
precise: number;
};

export type Rect = Dimensions & Point;
4 changes: 2 additions & 2 deletions lib/vendor/stacking-order.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Forked from NPM stacking-order@2.0.0
// Background at https://github.com/Rich-Harris/stacking-order/issues/3
// Background at https://github.com/Rich-Harris/stacking-order/issues/6
// - github.com/Rich-Harris/stacking-order/issues/3
// - github.com/Rich-Harris/stacking-order/issues/6

import { assert } from "../utils/assert";

Expand Down
Loading