From 85d7851251ec8f1b788ad754182d98f508336dc7 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 24 Jan 2026 07:43:00 -0500 Subject: [PATCH 1/3] Exploration: Double-clicking a Separator resets Panel to default size (#615) https://github.com/user-attachments/assets/f19f6c5e-d290-455e-9bad-20e5038c3508 --- CHANGELOG.md | 6 +++ .../tests/tests/pointer-interactions.spec.tsx | 28 +++++++++++++ .../event-handlers/onDocumentDoubleClick.ts | 40 +++++++++++++++++++ lib/global/mountGroup.ts | 7 ++++ lib/global/utils/findMatchingHitRegions.ts | 8 +++- .../examples/LayoutBasicsSeparator.json | 2 +- src/routes/LayoutBasicsRoute.tsx | 5 ++- src/routes/examples/LayoutBasicsSeparator.tsx | 2 +- 8 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 lib/global/event-handlers/onDocumentDoubleClick.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11244ecf4..03d4a8c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- [615](https://github.com/bvaughn/react-resizable-panels/pull/615): Double-clicking on a `Separator` resets its associated `Panel` to its default-size (if there is one). + +https://github.com/user-attachments/assets/f19f6c5e-d290-455e-9bad-20e5038c3508 + ## 4.4.2 - [610](https://github.com/bvaughn/react-resizable-panels/pull/610): Fix calculated cursor style when `"pointermove"` event is has low-precision/rounded `clientX` and `clientY` values diff --git a/integrations/tests/tests/pointer-interactions.spec.tsx b/integrations/tests/tests/pointer-interactions.spec.tsx index a5f5d0aea..f33129c07 100644 --- a/integrations/tests/tests/pointer-interactions.spec.tsx +++ b/integrations/tests/tests/pointer-interactions.spec.tsx @@ -366,6 +366,34 @@ test.describe("pointer interactions", () => { await expect(mainPage.getByText('"right": 33')).toBeVisible(); }); + test("double-clicking a separator resets panel to default size", async ({ + page: mainPage + }) => { + const page = await goToUrl( + mainPage, + + + + + , + { usePopUpWindow } + ); + + await assertLayoutChangeCounts(mainPage, 1); + await expect(mainPage.getByText('"left": 30')).toBeVisible(); + + await resizeHelper(page, ["left", "right"], 100, 0); + + await assertLayoutChangeCounts(mainPage, 2); + await expect(mainPage.getByText('"left": 40')).toBeVisible(); + + const separator = page.getByTestId("separator"); + await separator.dblclick(); + + await assertLayoutChangeCounts(mainPage, 3); + await expect(mainPage.getByText('"left": 30')).toBeVisible(); + }); + test.describe("focus", () => { test("should update focus to the nearest separator", async ({ page: mainPage diff --git a/lib/global/event-handlers/onDocumentDoubleClick.ts b/lib/global/event-handlers/onDocumentDoubleClick.ts new file mode 100644 index 000000000..ea590aee3 --- /dev/null +++ b/lib/global/event-handlers/onDocumentDoubleClick.ts @@ -0,0 +1,40 @@ +import type { RegisteredGroup } from "../../components/group/types"; +import type { RegisteredPanel } from "../../components/panel/types"; +import { read } from "../mutableState"; +import { findMatchingHitRegions } from "../utils/findMatchingHitRegions"; +import { getImperativePanelMethods } from "../utils/getImperativePanelMethods"; + +export function onDocumentDoubleClick(event: MouseEvent) { + if (event.defaultPrevented) { + return; + } + + const { mountedGroups } = read(); + + const hitRegions = findMatchingHitRegions(event, mountedGroups); + + const groups = new Set(); + const panels = new Set(); + + hitRegions.forEach((current) => { + groups.add(current.group); + current.panels.forEach((panel) => { + panels.add(panel); + }); + + if (current.separator) { + const primaryPanel = current.panels[0]; + if (primaryPanel.panelConstraints.defaultSize !== undefined) { + const api = getImperativePanelMethods({ + groupId: current.group.id, + panelId: primaryPanel.id + }); + if (api) { + api.resize(primaryPanel.panelConstraints.defaultSize); + + event.preventDefault(); + } + } + } + }); +} diff --git a/lib/global/mountGroup.ts b/lib/global/mountGroup.ts index a09f6fc12..d16fb8075 100644 --- a/lib/global/mountGroup.ts +++ b/lib/global/mountGroup.ts @@ -3,6 +3,7 @@ import { assert } from "../utils/assert"; import { calculateAvailableGroupSize } from "./dom/calculateAvailableGroupSize"; import { calculateHitRegions } from "./dom/calculateHitRegions"; import { calculatePanelConstraints } from "./dom/calculatePanelConstraints"; +import { onDocumentDoubleClick } from "./event-handlers/onDocumentDoubleClick"; import { onDocumentKeyDown } from "./event-handlers/onDocumentKeyDown"; import { onDocumentPointerDown } from "./event-handlers/onDocumentPointerDown"; import { onDocumentPointerLeave } from "./event-handlers/onDocumentPointerLeave"; @@ -170,6 +171,7 @@ export function mountGroup(group: RegisteredGroup) { // If this is the first group to be mounted, initialize event handlers if (ownerDocumentReferenceCounts.get(ownerDocument) === 1) { + ownerDocument.addEventListener("dblclick", onDocumentDoubleClick, true); ownerDocument.addEventListener("pointerdown", onDocumentPointerDown, true); ownerDocument.addEventListener("pointerleave", onDocumentPointerLeave); ownerDocument.addEventListener("pointermove", onDocumentPointerMove); @@ -197,6 +199,11 @@ export function mountGroup(group: RegisteredGroup) { // If this was the last group to be mounted, tear down event handlers if (!ownerDocumentReferenceCounts.get(ownerDocument)) { + ownerDocument.removeEventListener( + "dblclick", + onDocumentDoubleClick, + true + ); ownerDocument.removeEventListener( "pointerdown", onDocumentPointerDown, diff --git a/lib/global/utils/findMatchingHitRegions.ts b/lib/global/utils/findMatchingHitRegions.ts index ea10d3b52..8894eb5e3 100644 --- a/lib/global/utils/findMatchingHitRegions.ts +++ b/lib/global/utils/findMatchingHitRegions.ts @@ -1,15 +1,19 @@ import { DEFAULT_POINTER_PRECISION } from "../../constants"; -import type { MountedGroupMap } from "../mutableState"; import { calculateHitRegions, type HitRegion } from "../dom/calculateHitRegions"; +import type { MountedGroupMap } from "../mutableState"; import { findClosetHitRegion } from "./findClosetHitRegion"; import { isCoarsePointer } from "./isCoarsePointer"; import { isViableHitTarget } from "./isViableHitTarget"; export function findMatchingHitRegions( - event: PointerEvent, + event: { + clientX: number; + clientY: number; + target: EventTarget | null; + }, mountedGroups: MountedGroupMap ): HitRegion[] { const matchingHitRegions: HitRegion[] = []; diff --git a/public/generated/examples/LayoutBasicsSeparator.json b/public/generated/examples/LayoutBasicsSeparator.json index bc497c803..24a645049 100644 --- a/public/generated/examples/LayoutBasicsSeparator.json +++ b/public/generated/examples/LayoutBasicsSeparator.json @@ -1,3 +1,3 @@ { - "html": "
<Group>
\n
<Panel>left</Panel>
\n
<Separator />
\n
<Panel>right</Panel>
\n
</Group>
" + "html": "
<Group>
\n
<Panel defaultSize=\"50%\">left</Panel>
\n
<Separator />
\n
<Panel>right</Panel>
\n
</Group>
" } \ No newline at end of file diff --git a/src/routes/LayoutBasicsRoute.tsx b/src/routes/LayoutBasicsRoute.tsx index 18a98c974..c8b0e539f 100644 --- a/src/routes/LayoutBasicsRoute.tsx +++ b/src/routes/LayoutBasicsRoute.tsx @@ -42,10 +42,11 @@ export default function LayoutBasicsRoute() {
Panels can be resized by clicking on their borders but explicit - separators can be rendered to improve UX. + separators can be rendered to improve UX. Separators provide another + benefit: double-clicking on one resets a panel to its default size.
- left + left right diff --git a/src/routes/examples/LayoutBasicsSeparator.tsx b/src/routes/examples/LayoutBasicsSeparator.tsx index 89b1919be..a557c4126 100644 --- a/src/routes/examples/LayoutBasicsSeparator.tsx +++ b/src/routes/examples/LayoutBasicsSeparator.tsx @@ -4,7 +4,7 @@ import { Group, Panel, Separator } from "react-resizable-panels"; /* prettier-ignore */ - left + left right From 678e4effac17c958a8e562d8d0208906e035286b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 24 Jan 2026 07:45:59 -0500 Subject: [PATCH 2/3] Exploration: Replace hit-area padding with minimum size threshold (#616) Rather than applying an invisible "padding" to `Separator` elements, measure them and adjust their hit target only if it's too small to be interacted with easily. [Apple interface guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility) suggest 20pt (27) on desktops and 28pt (37px) for touch devices, so for a starting point that's what I've implemented here as a starting point. Resolves #611 --- CHANGELOG.md | 1 + lib/components/group/Group.test.tsx | 14 +++ lib/constants.ts | 6 - lib/global/dom/calculateHitRegions.test.ts | 116 +++++++++--------- lib/global/dom/calculateHitRegions.ts | 34 ++++- ...etHitRegion.ts => findClosestHitRegion.ts} | 2 +- .../utils/findMatchingHitRegions.test.ts | 63 +++++++--- lib/global/utils/findMatchingHitRegions.ts | 15 +-- lib/global/utils/isCoarsePointer.ts | 3 + lib/index.ts | 2 + 10 files changed, 156 insertions(+), 100 deletions(-) rename lib/global/utils/{findClosetHitRegion.ts => findClosestHitRegion.ts} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d4a8c04..6a7751f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- [616](https://github.com/bvaughn/react-resizable-panels/pull/616): Replace `Separator` and `Panel` edge hit-area padding with a minimum size threshold based on [Apple's user interface guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility). Separators that are large enough will no longer be padded; separators that are too small (or panels without separators) will more or less function like before. This should not have much of a user-facing impact other than an increase in the click target area. (Previously I was not padding enough, as per Apple's guidelines.) - [615](https://github.com/bvaughn/react-resizable-panels/pull/615): Double-clicking on a `Separator` resets its associated `Panel` to its default-size (if there is one). https://github.com/user-attachments/assets/f19f6c5e-d290-455e-9bad-20e5038c3508 diff --git a/lib/components/group/Group.test.tsx b/lib/components/group/Group.test.tsx index 3a9f3dbbc..b4b084f0e 100644 --- a/lib/components/group/Group.test.tsx +++ b/lib/components/group/Group.test.tsx @@ -459,6 +459,20 @@ describe("Group", () => { }); test("should be called once per layout change", async () => { + setElementBoundsFunction((element) => { + switch (element.id) { + case "a": { + return new DOMRect(0, 0, 50, 50); + } + case "b": { + return new DOMRect(50, 0, 10, 50); + } + case "c": { + return new DOMRect(60, 0, 50, 50); + } + } + }); + const onLayoutChange = vi.fn(); const onLayoutChanged = vi.fn(); diff --git a/lib/constants.ts b/lib/constants.ts index daa105868..976fde438 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -24,9 +24,3 @@ export const CURSOR_FLAG_VERTICAL_MIN = 0b0100; export const CURSOR_FLAG_VERTICAL_MAX = 0b1000; export const CURSOR_FLAGS_HORIZONTAL = 0b0011; export const CURSOR_FLAGS_VERTICAL = 0b1100; - -// Misc. shared values -export const DEFAULT_POINTER_PRECISION = { - coarse: 10, - precise: 5 -}; diff --git a/lib/global/dom/calculateHitRegions.test.ts b/lib/global/dom/calculateHitRegions.test.ts index ba064ea4e..ea8318307 100644 --- a/lib/global/dom/calculateHitRegions.test.ts +++ b/lib/global/dom/calculateHitRegions.test.ts @@ -30,9 +30,9 @@ describe("calculateHitRegions", () => { }); test("two panels", () => { - const group = mockGroup(new DOMRect(0, 0, 20, 50)); - group.addPanel(new DOMRect(0, 0, 10, 50), "left"); - group.addPanel(new DOMRect(10, 0, 10, 50), "right"); + const group = mockGroup(new DOMRect(0, 0, 100, 50)); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); + group.addPanel(new DOMRect(50, 0, 50, 50), "right"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -41,17 +41,17 @@ describe("calculateHitRegions", () => { "group-1-left", "group-1-right" ], - "rect": "10,0 0 x 50" + "rect": "36.5,0 27 x 50" } ]" `); }); test("three panels", () => { - const group = mockGroup(new DOMRect(0, 0, 30, 50)); - group.addPanel(new DOMRect(0, 0, 10, 50), "left"); - group.addPanel(new DOMRect(10, 0, 10, 50), "center"); - group.addPanel(new DOMRect(20, 0, 10, 50), "right"); + const group = mockGroup(new DOMRect(0, 0, 120, 50)); + group.addPanel(new DOMRect(0, 0, 40, 50), "left"); + group.addPanel(new DOMRect(40, 0, 40, 50), "center"); + group.addPanel(new DOMRect(80, 0, 40, 50), "right"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -60,26 +60,26 @@ describe("calculateHitRegions", () => { "group-1-left", "group-1-center" ], - "rect": "10,0 0 x 50" + "rect": "26.5,0 27 x 50" }, { "panels": [ "group-1-center", "group-1-right" ], - "rect": "20,0 0 x 50" + "rect": "66.5,0 27 x 50" } ]" `); }); test("panels and explicit separators", () => { - const group = mockGroup(new DOMRect(0, 0, 50, 50)); - group.addPanel(new DOMRect(0, 0, 10, 50), "left"); - group.addSeparator(new DOMRect(10, 0, 10, 50), "left"); - group.addPanel(new DOMRect(20, 0, 10, 50), "center"); - group.addSeparator(new DOMRect(30, 0, 10, 50), "right"); - group.addPanel(new DOMRect(40, 0, 10, 50), "right"); + const group = mockGroup(new DOMRect(0, 0, 140, 50)); + group.addPanel(new DOMRect(0, 0, 40, 50), "left"); + group.addSeparator(new DOMRect(40, 0, 10, 50), "left"); + group.addPanel(new DOMRect(50, 0, 40, 50), "center"); + group.addSeparator(new DOMRect(90, 0, 10, 50), "right"); + group.addPanel(new DOMRect(100, 0, 40, 50), "right"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -88,7 +88,7 @@ describe("calculateHitRegions", () => { "group-1-left", "group-1-center" ], - "rect": "10,0 10 x 50", + "rect": "31.5,0 27 x 50", "separator": "group-1-left" }, { @@ -96,7 +96,7 @@ describe("calculateHitRegions", () => { "group-1-center", "group-1-right" ], - "rect": "30,0 10 x 50", + "rect": "81.5,0 27 x 50", "separator": "group-1-right" } ]" @@ -104,11 +104,11 @@ describe("calculateHitRegions", () => { }); test("panels and some explicit separators", () => { - const group = mockGroup(new DOMRect(0, 0, 60, 50)); - group.addPanel(new DOMRect(0, 0, 20, 50), "a"); - group.addPanel(new DOMRect(20, 0, 20, 50), "b"); - group.addSeparator(new DOMRect(40, 0, 5, 50), "separator"); - group.addPanel(new DOMRect(40, 0, 40, 50), "c"); + const group = mockGroup(new DOMRect(0, 0, 125, 50)); + group.addPanel(new DOMRect(0, 0, 40, 50), "a"); + group.addPanel(new DOMRect(40, 0, 40, 50), "b"); + group.addSeparator(new DOMRect(80, 0, 5, 50), "separator"); + group.addPanel(new DOMRect(85, 0, 40, 50), "c"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -117,14 +117,14 @@ describe("calculateHitRegions", () => { "group-1-a", "group-1-b" ], - "rect": "20,0 0 x 50" + "rect": "26.5,0 27 x 50" }, { "panels": [ "group-1-b", "group-1-c" ], - "rect": "40,0 5 x 50", + "rect": "69,0 27 x 50", "separator": "group-1-separator" } ]" @@ -132,14 +132,14 @@ describe("calculateHitRegions", () => { }); test("mixed panels and non-panel children", () => { - const group = mockGroup(new DOMRect(0, 0, 70, 50)); + const group = mockGroup(new DOMRect(0, 0, 230, 50)); group.addHTMLElement(new DOMRect(0, 0, 10, 50)); - group.addPanel(new DOMRect(10, 0, 10, 50), "a"); - group.addPanel(new DOMRect(20, 0, 10, 50), "b"); - group.addHTMLElement(new DOMRect(30, 0, 10, 50)); - group.addPanel(new DOMRect(40, 0, 10, 50), "c"); - group.addPanel(new DOMRect(50, 0, 10, 50), "d"); - group.addHTMLElement(new DOMRect(60, 0, 10, 50)); + group.addPanel(new DOMRect(10, 0, 50, 50), "a"); + group.addPanel(new DOMRect(60, 0, 50, 50), "b"); + group.addHTMLElement(new DOMRect(110, 0, 10, 50)); + group.addPanel(new DOMRect(120, 0, 50, 50), "c"); + group.addPanel(new DOMRect(170, 0, 50, 50), "d"); + group.addHTMLElement(new DOMRect(220, 0, 10, 50)); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -148,38 +148,38 @@ describe("calculateHitRegions", () => { "group-1-a", "group-1-b" ], - "rect": "20,0 0 x 50" + "rect": "46.5,0 27 x 50" }, { "panels": [ "group-1-b", "group-1-c" ], - "rect": "30,0 0 x 50" + "rect": "96.5,0 27 x 50" }, { "panels": [ "group-1-b", "group-1-c" ], - "rect": "40,0 0 x 50" + "rect": "106.5,0 27 x 50" }, { "panels": [ "group-1-c", "group-1-d" ], - "rect": "50,0 0 x 50" + "rect": "156.5,0 27 x 50" } ]" `); }); test("CSS styles (e.g. padding and flex gap)", () => { - const group = mockGroup(new DOMRect(0, 0, 50, 50)); - group.addPanel(new DOMRect(5, 5, 10, 40), "left"); - group.addPanel(new DOMRect(20, 5, 10, 40), "center"); - group.addPanel(new DOMRect(35, 5, 10, 40), "right"); + const group = mockGroup(new DOMRect(0, 0, 155, 50)); + group.addPanel(new DOMRect(5, 5, 45, 40), "left"); + group.addPanel(new DOMRect(55, 5, 45, 40), "center"); + group.addPanel(new DOMRect(105, 5, 45, 40), "right"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -188,26 +188,26 @@ describe("calculateHitRegions", () => { "group-1-left", "group-1-center" ], - "rect": "15,5 5 x 40" + "rect": "39,5 27 x 40" }, { "panels": [ "group-1-center", "group-1-right" ], - "rect": "30,5 5 x 40" + "rect": "89,5 27 x 40" } ]" `); }); test("out of order children (e.g. dynamic rendering)", () => { - const group = mockGroup(new DOMRect(0, 0, 30, 50)); - group.addPanel(new DOMRect(0, 0, 10, 50), "left"); - group.addPanel(new DOMRect(20, 0, 10, 50), "right"); + const group = mockGroup(new DOMRect(0, 0, 150, 50)); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); + group.addPanel(new DOMRect(100, 0, 50, 50), "right"); // Simulate conditionally rendering a new middle panel - group.addPanel(new DOMRect(10, 0, 10, 50), "center"); + group.addPanel(new DOMRect(50, 0, 50, 50), "center"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -216,14 +216,14 @@ describe("calculateHitRegions", () => { "group-1-left", "group-1-center" ], - "rect": "10,0 0 x 50" + "rect": "36.5,0 27 x 50" }, { "panels": [ "group-1-center", "group-1-right" ], - "rect": "20,0 0 x 50" + "rect": "86.5,0 27 x 50" } ]" `); @@ -231,13 +231,13 @@ describe("calculateHitRegions", () => { // Test covers conditionally rendered panels and separators test("should sort elements and separators by offset", () => { - const group = mockGroup(new DOMRect(0, 0, 50, 50)); - group.addPanel(new DOMRect(40, 0, 10, 50), "d"); - group.addPanel(new DOMRect(15, 0, 10, 50), "b"); - group.addPanel(new DOMRect(0, 0, 10, 50), "a"); - group.addPanel(new DOMRect(25, 0, 10, 50), "c"); - group.addSeparator(new DOMRect(35, 0, 5, 50), "right"); - group.addSeparator(new DOMRect(10, 0, 5, 50), "left"); + const group = mockGroup(new DOMRect(0, 0, 270, 50)); + group.addPanel(new DOMRect(205, 0, 65, 50), "d"); + group.addPanel(new DOMRect(70, 0, 65, 50), "b"); + group.addPanel(new DOMRect(0, 0, 65, 50), "a"); + group.addPanel(new DOMRect(135, 0, 65, 50), "c"); + group.addSeparator(new DOMRect(200, 0, 5, 50), "right"); + group.addSeparator(new DOMRect(65, 0, 5, 50), "left"); expect(serialize(group)).toMatchInlineSnapshot(` "[ @@ -246,7 +246,7 @@ describe("calculateHitRegions", () => { "group-1-a", "group-1-b" ], - "rect": "10,0 5 x 50", + "rect": "54,0 27 x 50", "separator": "group-1-left" }, { @@ -254,14 +254,14 @@ describe("calculateHitRegions", () => { "group-1-b", "group-1-c" ], - "rect": "25,0 0 x 50" + "rect": "121.5,0 27 x 50" }, { "panels": [ "group-1-c", "group-1-d" ], - "rect": "35,0 5 x 50", + "rect": "189,0 27 x 50", "separator": "group-1-right" } ]" diff --git a/lib/global/dom/calculateHitRegions.ts b/lib/global/dom/calculateHitRegions.ts index 1a435a495..dc0b367d9 100644 --- a/lib/global/dom/calculateHitRegions.ts +++ b/lib/global/dom/calculateHitRegions.ts @@ -4,6 +4,7 @@ import type { RegisteredPanel } from "../../components/panel/types"; import type { RegisteredSeparator } from "../../components/separator/types"; import { isHTMLElement } from "../../utils/isHTMLElement"; import { findClosestRect } from "../utils/findClosestRect"; +import { isCoarsePointer } from "../utils/isCoarsePointer"; import { calculateAvailableGroupSize } from "./calculateAvailableGroupSize"; type PanelsTuple = [panel: RegisteredPanel, panel: RegisteredPanel]; @@ -123,16 +124,41 @@ export function calculateHitRegions(group: RegisteredGroup) { } for (const rectOrSeparator of pendingRectsOrSeparators) { + let rect = + "width" in rectOrSeparator + ? rectOrSeparator + : rectOrSeparator.element.getBoundingClientRect(); + + // Ensure that Separators or Panel "edges" have large enough hit areas to be interacted with easily + // Apple interface guidelines suggest 20pt (27) on desktops and 28pt (37px) for touch devices + // https://developer.apple.com/design/human-interface-guidelines/accessibility + const minHitTargetSize = isCoarsePointer() ? 37 : 27; + if (rect.width < minHitTargetSize) { + const delta = minHitTargetSize - rect.width; + rect = new DOMRect( + rect.x - delta / 2, + rect.y, + rect.width + delta, + rect.height + ); + } + if (rect.height < minHitTargetSize) { + const delta = minHitTargetSize - rect.height; + rect = new DOMRect( + rect.x, + rect.y - delta / 2, + rect.width, + rect.height + delta + ); + } + hitRegions.push({ group, groupSize: calculateAvailableGroupSize({ group }), panels: [prevPanel, panelData], separator: "width" in rectOrSeparator ? undefined : rectOrSeparator, - rect: - "width" in rectOrSeparator - ? rectOrSeparator - : rectOrSeparator.element.getBoundingClientRect() + rect }); } } diff --git a/lib/global/utils/findClosetHitRegion.ts b/lib/global/utils/findClosestHitRegion.ts similarity index 96% rename from lib/global/utils/findClosetHitRegion.ts rename to lib/global/utils/findClosestHitRegion.ts index 720691892..5ec66196b 100644 --- a/lib/global/utils/findClosetHitRegion.ts +++ b/lib/global/utils/findClosestHitRegion.ts @@ -3,7 +3,7 @@ import type { Point } from "../../types"; import type { HitRegion } from "../dom/calculateHitRegions"; import { getDistanceBetweenPointAndRect } from "./getDistanceBetweenPointAndRect"; -export function findClosetHitRegion( +export function findClosestHitRegion( orientation: Orientation, hitRegions: HitRegion[], point: Point diff --git a/lib/global/utils/findMatchingHitRegions.test.ts b/lib/global/utils/findMatchingHitRegions.test.ts index 8edaa1a9f..24c46a3ff 100644 --- a/lib/global/utils/findMatchingHitRegions.test.ts +++ b/lib/global/utils/findMatchingHitRegions.test.ts @@ -12,7 +12,8 @@ describe("findMatchingHitRegions", () => { return JSON.stringify( hitRegions.map((region) => ({ panels: region.panels.map((panel) => panel.id), - rect: `${region.rect.x},${region.rect.y} ${region.rect.width} x ${region.rect.height}` + rect: `${region.rect.x},${region.rect.y} ${region.rect.width} x ${region.rect.height}`, + separator: region.separator?.id })), null, 2 @@ -27,13 +28,13 @@ describe("findMatchingHitRegions", () => { ).toMatchInlineSnapshot(`"[]"`); }); - test("group", () => { - const group = mockGroup(new DOMRect(0, 0, 20, 50)); - group.addPanel(new DOMRect(0, 0, 10, 50), "left"); - group.addPanel(new DOMRect(10, 0, 10, 50), "right"); + test("group with no separator", () => { + const group = mockGroup(new DOMRect(0, 0, 100, 50)); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); + group.addPanel(new DOMRect(50, 0, 50, 50), "right"); mountGroup(group); - expect(serialize(mockPointerEvent({ clientX: 10 }), read().mountedGroups)) + expect(serialize(mockPointerEvent({ clientX: 50 }), read().mountedGroups)) .toMatchInlineSnapshot(` "[ { @@ -41,26 +42,48 @@ describe("findMatchingHitRegions", () => { "group-1-left", "group-1-right" ], - "rect": "10,0 0 x 50" + "rect": "36.5,0 27 x 50" + } + ]" + `); + }); + + test("group with separator", () => { + const group = mockGroup(new DOMRect(0, 0, 1200, 50)); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); + group.addSeparator(new DOMRect(50, 0, 20, 50), "separator"); + group.addPanel(new DOMRect(70, 0, 50, 50), "right"); + mountGroup(group); + + expect(serialize(mockPointerEvent({ clientX: 60 }), read().mountedGroups)) + .toMatchInlineSnapshot(` + "[ + { + "panels": [ + "group-1-left", + "group-1-right" + ], + "rect": "46.5,0 27 x 50", + "separator": "group-1-separator" } ]" `); }); test("nested groups", () => { - const outerGroup = mockGroup(new DOMRect(0, 0, 20, 50)); - outerGroup.addPanel(new DOMRect(0, 0, 10, 50), "left"); - outerGroup.addPanel(new DOMRect(10, 0, 10, 50), "right"); + const outerGroup = mockGroup(new DOMRect(0, 0, 100, 50)); + outerGroup.addPanel(new DOMRect(0, 0, 50, 50), "left"); + outerGroup.addPanel(new DOMRect(50, 0, 50, 50), "right"); mountGroup(outerGroup); - const innerGroup = mockGroup(new DOMRect(0, 0, 10, 50), "vertical"); - innerGroup.addPanel(new DOMRect(0, 0, 10, 25), "top"); - innerGroup.addPanel(new DOMRect(0, 25, 10, 25), "bottom"); + const innerGroup = mockGroup(new DOMRect(0, 0, 50, 50), "vertical"); + innerGroup.addPanel(new DOMRect(0, 0, 50, 25), "top"); + innerGroup.addPanel(new DOMRect(0, 25, 50, 25), "bottom"); mountGroup(innerGroup); expect( serialize( - mockPointerEvent({ clientX: 10, clientY: 25 }), + mockPointerEvent({ clientX: 50, clientY: 25 }), read().mountedGroups ) ).toMatchInlineSnapshot(` @@ -70,27 +93,27 @@ describe("findMatchingHitRegions", () => { "group-1-left", "group-1-right" ], - "rect": "10,0 0 x 50" + "rect": "36.5,0 27 x 50" }, { "panels": [ "group-2-top", "group-2-bottom" ], - "rect": "0,25 10 x 0" + "rect": "0,11.5 50 x 27" } ]" `); }); test("should skip disabled groups", () => { - const group = mockGroup(new DOMRect(0, 0, 20, 50)); + const group = mockGroup(new DOMRect(0, 0, 100, 50)); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); + group.addPanel(new DOMRect(50, 0, 50, 50), "right"); group.disabled = true; - group.addPanel(new DOMRect(0, 0, 10, 50), "left"); - group.addPanel(new DOMRect(10, 0, 10, 50), "right"); mountGroup(group); - expect(serialize(mockPointerEvent({ clientX: 10 }), read().mountedGroups)) + expect(serialize(mockPointerEvent({ clientX: 50 }), read().mountedGroups)) .toMatchInlineSnapshot(` "[]" `); diff --git a/lib/global/utils/findMatchingHitRegions.ts b/lib/global/utils/findMatchingHitRegions.ts index 8894eb5e3..159dfcf9e 100644 --- a/lib/global/utils/findMatchingHitRegions.ts +++ b/lib/global/utils/findMatchingHitRegions.ts @@ -1,11 +1,9 @@ -import { DEFAULT_POINTER_PRECISION } from "../../constants"; import { calculateHitRegions, type HitRegion } from "../dom/calculateHitRegions"; import type { MountedGroupMap } from "../mutableState"; -import { findClosetHitRegion } from "./findClosetHitRegion"; -import { isCoarsePointer } from "./isCoarsePointer"; +import { findClosestHitRegion } from "./findClosestHitRegion"; import { isViableHitTarget } from "./isViableHitTarget"; export function findMatchingHitRegions( @@ -23,20 +21,15 @@ export function findMatchingHitRegions( return; } - const maxDistance = isCoarsePointer() - ? DEFAULT_POINTER_PRECISION.coarse - : DEFAULT_POINTER_PRECISION.precise; - const hitRegions = calculateHitRegions(groupData); - const match = findClosetHitRegion(groupData.orientation, hitRegions, { + const match = findClosestHitRegion(groupData.orientation, hitRegions, { x: event.clientX, y: event.clientY }); - if ( match && - match.distance.x <= maxDistance && - match.distance.y <= maxDistance && + match.distance.x <= 0 && + match.distance.y <= 0 && isViableHitTarget({ groupElement: groupData.element, hitRegion: match.hitRegion.rect, diff --git a/lib/global/utils/isCoarsePointer.ts b/lib/global/utils/isCoarsePointer.ts index a495ab693..5b4bd5191 100644 --- a/lib/global/utils/isCoarsePointer.ts +++ b/lib/global/utils/isCoarsePointer.ts @@ -1,5 +1,8 @@ let cached: boolean | undefined = undefined; +/** + * Caches and returns matchMedia()'s computed value for "pointer:coarse" + */ export function isCoarsePointer(): boolean { if (cached === undefined) { if (typeof matchMedia === "function") { diff --git a/lib/index.ts b/lib/index.ts index a50c6f476..7ee713e6c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -7,6 +7,8 @@ export { usePanelCallbackRef } from "./components/panel/usePanelCallbackRef"; export { usePanelRef } from "./components/panel/usePanelRef"; export { Separator } from "./components/separator/Separator"; +export { isCoarsePointer } from "./global/utils/isCoarsePointer"; + export type { GroupImperativeHandle, GroupProps, From 3fb215c207289dfbb4284d18cbdca692abf7c2ed Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 24 Jan 2026 07:54:50 -0500 Subject: [PATCH 3/3] Bugfix: Don't override `adoptedStyleSheets` (#618) Resolves #617 --- CHANGELOG.md | 3 ++- lib/global/cursor/updateCursorStyle.ts | 2 +- vitest.setup.ts | 9 ++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7751f43..8de0a5785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## Unreleased - [616](https://github.com/bvaughn/react-resizable-panels/pull/616): Replace `Separator` and `Panel` edge hit-area padding with a minimum size threshold based on [Apple's user interface guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility). Separators that are large enough will no longer be padded; separators that are too small (or panels without separators) will more or less function like before. This should not have much of a user-facing impact other than an increase in the click target area. (Previously I was not padding enough, as per Apple's guidelines.) -- [615](https://github.com/bvaughn/react-resizable-panels/pull/615): Double-clicking on a `Separator` resets its associated `Panel` to its default-size (if there is one). +- [615](https://github.com/bvaughn/react-resizable-panels/pull/615): Double-clicking on a `Separator` resets its associated `Panel` to its default-size (see video below); double-click will have no impact on panels without default sizes +- [618](https://github.com/bvaughn/react-resizable-panels/pull/618): Bugfix: Don't override `adoptedStyleSheets` https://github.com/user-attachments/assets/f19f6c5e-d290-455e-9bad-20e5038c3508 diff --git a/lib/global/cursor/updateCursorStyle.ts b/lib/global/cursor/updateCursorStyle.ts index ff0b91f78..84c8fc7d8 100644 --- a/lib/global/cursor/updateCursorStyle.ts +++ b/lib/global/cursor/updateCursorStyle.ts @@ -24,7 +24,7 @@ export function updateCursorStyle(ownerDocument: Document) { if (styleSheet === undefined) { styleSheet = new ownerDocument.defaultView.CSSStyleSheet(); - ownerDocument.adoptedStyleSheets = [styleSheet]; + ownerDocument.adoptedStyleSheets.push(styleSheet); } const { cursorFlags, interactionState } = read(); diff --git a/vitest.setup.ts b/vitest.setup.ts index d726517f7..b16b61ae7 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -63,7 +63,14 @@ beforeAll(() => { }); vi.spyOn(console, "warn").mockImplementation(() => { - throw Error("Unexpectec console warning"); + throw Error("Unexpected console warning"); + }); + + Object.defineProperty(Document.prototype, "adoptedStyleSheets", { + value: [] + }); + Object.defineProperty(ShadowRoot.prototype, "adoptedStyleSheets", { + value: [] }); });