diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx b/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx index 60cf81852..256036921 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx +++ b/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx @@ -3,7 +3,11 @@ import { act } from "react-dom/test-utils"; import { Panel, PanelGroup, PanelResizeHandle } from "."; import { assert } from "./utils/assert"; import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement"; -import { dispatchPointerEvent } from "./utils/test-utils"; +import { + dispatchPointerEvent, + mockBoundingClientRect, +} from "./utils/test-utils"; +import type { PanelResizeHandleProps } from "react-resizable-panels"; describe("PanelResizeHandle", () => { let expectedWarnings: string[] = []; @@ -67,47 +71,204 @@ describe("PanelResizeHandle", () => { expect(element.title).toBe("bar"); }); + function setupMockedGroup({ + leftProps = {}, + rightProps = {}, + }: { + leftProps?: Partial; + rightProps?: Partial; + } = {}) { + act(() => { + root.render( + + + + + + + + ); + }); + + const leftElement = container.querySelector( + '[data-panel-resize-handle-id="handle-left"]' + ) as HTMLElement; + + const rightElement = container.querySelector( + '[data-panel-resize-handle-id="handle-right"]' + ) as HTMLElement; + + // JSDom doesn't properly handle bounding rects + mockBoundingClientRect(leftElement, { + x: 50, + y: 0, + height: 50, + width: 2, + }); + mockBoundingClientRect(rightElement, { + x: 100, + y: 0, + height: 50, + width: 2, + }); + + return { + leftElement, + rightElement, + }; + } + describe("callbacks", () => { describe("onDragging", () => { - it("should fire when dragging starts/stops", async () => { + it("should fire when dragging starts/stops", () => { const onDragging = jest.fn(); - act(() => { - root.render( - - - - - - ); + const { leftElement } = setupMockedGroup({ + leftProps: { onDragging }, }); - const handleElement = container.querySelector( - '[data-panel-resize-handle-id="handle"]' - ) as HTMLElement; - act(() => { - dispatchPointerEvent("mouseover", handleElement); + dispatchPointerEvent("mousemove", leftElement); }); expect(onDragging).not.toHaveBeenCalled(); act(() => { - dispatchPointerEvent("mousedown", handleElement); + dispatchPointerEvent("mousedown", leftElement); }); expect(onDragging).toHaveBeenCalledTimes(1); expect(onDragging).toHaveBeenCalledWith(true); act(() => { - dispatchPointerEvent("mouseup", handleElement); + dispatchPointerEvent("mouseup", leftElement); }); expect(onDragging).toHaveBeenCalledTimes(2); expect(onDragging).toHaveBeenCalledWith(false); }); + + it("should only fire for the handle that has been dragged", () => { + const onDraggingLeft = jest.fn(); + const onDraggingRight = jest.fn(); + + const { leftElement } = setupMockedGroup({ + leftProps: { onDragging: onDraggingLeft }, + rightProps: { onDragging: onDraggingRight }, + }); + + act(() => { + dispatchPointerEvent("mousemove", leftElement); + }); + expect(onDraggingLeft).not.toHaveBeenCalled(); + expect(onDraggingRight).not.toHaveBeenCalled(); + + act(() => { + dispatchPointerEvent("mousedown", leftElement); + }); + expect(onDraggingLeft).toHaveBeenCalledTimes(1); + expect(onDraggingLeft).toHaveBeenCalledWith(true); + expect(onDraggingRight).not.toHaveBeenCalled(); + + act(() => { + dispatchPointerEvent("mouseup", leftElement); + }); + expect(onDraggingLeft).toHaveBeenCalledTimes(2); + expect(onDraggingLeft).toHaveBeenCalledWith(false); + expect(onDraggingRight).not.toHaveBeenCalled(); + }); + }); + }); + + describe("data attributes", () => { + function verifyAttribute( + element: HTMLElement, + attributeName: string, + expectedValue: string | null + ) { + const actualValue = element.getAttribute(attributeName); + expect(actualValue).toBe(expectedValue); + } + + it("should initialize with the correct props based attributes", () => { + const { leftElement, rightElement } = setupMockedGroup(); + + verifyAttribute(leftElement, "data-panel-group-id", "test-group"); + verifyAttribute(leftElement, "data-resize-handle", ""); + verifyAttribute(leftElement, "data-panel-group-direction", "horizontal"); + verifyAttribute(leftElement, "data-panel-resize-handle-enabled", "true"); + verifyAttribute( + leftElement, + "data-panel-resize-handle-id", + "handle-left" + ); + + verifyAttribute(rightElement, "data-panel-group-id", "test-group"); + verifyAttribute(rightElement, "data-resize-handle", ""); + verifyAttribute(rightElement, "data-panel-group-direction", "horizontal"); + verifyAttribute(rightElement, "data-panel-resize-handle-enabled", "true"); + verifyAttribute( + rightElement, + "data-panel-resize-handle-id", + "handle-right" + ); + }); + + it("should update data-resize-handle-active and data-resize-handle-state when dragging starts/stops", () => { + const { leftElement, rightElement } = setupMockedGroup(); + verifyAttribute(leftElement, "data-resize-handle-active", null); + verifyAttribute(rightElement, "data-resize-handle-active", null); + verifyAttribute(leftElement, "data-resize-handle-state", "inactive"); + verifyAttribute(rightElement, "data-resize-handle-state", "inactive"); + + act(() => { + dispatchPointerEvent("mousemove", leftElement); + }); + verifyAttribute(leftElement, "data-resize-handle-active", null); + verifyAttribute(rightElement, "data-resize-handle-active", null); + verifyAttribute(leftElement, "data-resize-handle-state", "hover"); + verifyAttribute(rightElement, "data-resize-handle-state", "inactive"); + + act(() => { + dispatchPointerEvent("mousedown", leftElement); + }); + verifyAttribute(leftElement, "data-resize-handle-active", "pointer"); + verifyAttribute(rightElement, "data-resize-handle-active", null); + verifyAttribute(leftElement, "data-resize-handle-state", "drag"); + verifyAttribute(rightElement, "data-resize-handle-state", "inactive"); + + act(() => { + dispatchPointerEvent("mouseup", leftElement); + }); + verifyAttribute(leftElement, "data-resize-handle-active", null); + verifyAttribute(rightElement, "data-resize-handle-active", null); + verifyAttribute(leftElement, "data-resize-handle-state", "hover"); + verifyAttribute(rightElement, "data-resize-handle-state", "inactive"); + + act(() => { + dispatchPointerEvent("mousemove", rightElement); + }); + verifyAttribute(leftElement, "data-resize-handle-active", null); + verifyAttribute(rightElement, "data-resize-handle-active", null); + verifyAttribute(leftElement, "data-resize-handle-state", "inactive"); + verifyAttribute(rightElement, "data-resize-handle-state", "hover"); + }); + + it("should update data-resize-handle-active when focused", () => { + const { leftElement, rightElement } = setupMockedGroup(); + verifyAttribute(leftElement, "data-resize-handle-active", null); + verifyAttribute(rightElement, "data-resize-handle-active", null); + + act(() => { + leftElement.focus(); + }); + expect(document.activeElement).toBe(leftElement); + verifyAttribute(leftElement, "data-resize-handle-active", "keyboard"); + verifyAttribute(rightElement, "data-resize-handle-active", null); + + act(() => { + leftElement.blur(); + }); + expect(document.activeElement).not.toBe(leftElement); + verifyAttribute(leftElement, "data-resize-handle-active", null); + verifyAttribute(rightElement, "data-resize-handle-active", null); }); }); }); diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.ts b/packages/react-resizable-panels/src/PanelResizeHandle.ts index b6255704b..2f4450dd5 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandle.ts +++ b/packages/react-resizable-panels/src/PanelResizeHandle.ts @@ -21,11 +21,11 @@ import { PointerHitAreaMargins, registerResizeHandle, ResizeHandlerAction, - ResizeHandlerState, } from "./PanelResizeHandleRegistry"; import { assert } from "./utils/assert"; export type PanelResizeHandleOnDragging = (isDragging: boolean) => void; +export type ResizeHandlerState = "drag" | "hover" | "inactive"; export type PanelResizeHandleProps = Omit< HTMLAttributes, @@ -109,37 +109,42 @@ export function PanelResizeHandle({ const setResizeHandlerState = ( action: ResizeHandlerAction, - state: ResizeHandlerState, + isActive: boolean, event: ResizeEvent ) => { - setState(state); - - switch (action) { - case "down": { - startDragging(resizeHandleId, event); - - const { onDragging } = callbacksRef.current; - if (onDragging) { - onDragging(true); + if (isActive) { + switch (action) { + case "down": { + setState("drag"); + + startDragging(resizeHandleId, event); + + const { onDragging } = callbacksRef.current; + if (onDragging) { + onDragging(true); + } + break; } - break; - } - case "up": { - stopDragging(); + case "move": { + setState("hover"); - const { onDragging } = callbacksRef.current; - if (onDragging) { - onDragging(false); + resizeHandler(event); + break; } - break; - } - } + case "up": { + setState("hover"); + + stopDragging(); - switch (state) { - case "drag": { - resizeHandler(event); - break; + const { onDragging } = callbacksRef.current; + if (onDragging) { + onDragging(false); + } + break; + } } + } else { + setState("inactive"); } }; diff --git a/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts b/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts index a7b14d1a8..37811b7a2 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts +++ b/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts @@ -4,10 +4,9 @@ import { getResizeEventCoordinates } from "./utils/events/getResizeEventCoordina import { getInputType } from "./utils/getInputType"; export type ResizeHandlerAction = "down" | "move" | "up"; -export type ResizeHandlerState = "drag" | "hover" | "inactive"; export type SetResizeHandlerState = ( action: ResizeHandlerAction, - state: ResizeHandlerState, + isActive: boolean, event: ResizeEvent ) => void; @@ -93,21 +92,18 @@ function handlePointerDown(event: ResizeEvent) { function handlePointerMove(event: ResizeEvent) { const { x, y } = getResizeEventCoordinates(event); - if (isPointerDown) { - intersectingHandles.forEach((data) => { - const { setResizeHandlerState } = data; - - setResizeHandlerState("move", "drag", event); - }); - - // Update cursor based on return value(s) from active handles - updateCursor(); - } else { + if (!isPointerDown) { + // Recalculate intersecting handles whenever the pointer moves, except if it has already been pressed + // at that point, the handles may not move with the pointer (depending on constraints) + // but the same set of active handles should be locked until the pointer is released recalculateIntersectingHandles({ x, y }); - updateResizeHandlerStates("move", event); - updateCursor(); } + updateResizeHandlerStates("move", event); + + // Update cursor based on return value(s) from active handles + updateCursor(); + if (intersectingHandles.length > 0) { event.preventDefault(); } @@ -250,14 +246,8 @@ function updateResizeHandlerStates( registeredResizeHandlers.forEach((data) => { const { setResizeHandlerState } = data; - if (intersectingHandles.includes(data)) { - if (isPointerDown) { - setResizeHandlerState(action, "drag", event); - } else { - setResizeHandlerState(action, "hover", event); - } - } else { - setResizeHandlerState(action, "inactive", event); - } + const isActive = intersectingHandles.includes(data); + + setResizeHandlerState(action, isActive, event); }); } diff --git a/packages/react-resizable-panels/src/utils/test-utils.ts b/packages/react-resizable-panels/src/utils/test-utils.ts index 4a787e3fc..5b0d8c6cf 100644 --- a/packages/react-resizable-panels/src/utils/test-utils.ts +++ b/packages/react-resizable-panels/src/utils/test-utils.ts @@ -47,6 +47,36 @@ export function expectToBeCloseToArray( } } +export function mockBoundingClientRect( + element: HTMLElement, + rect: { + height: number; + width: number; + x: number; + y: number; + } +) { + const { height, width, x, y } = rect; + + Object.defineProperty(element, "getBoundingClientRect", { + configurable: true, + value: () => + ({ + bottom: y + height, + height, + left: x, + right: x + width, + toJSON() { + return ""; + }, + top: y, + width, + x, + y, + }) satisfies DOMRect, + }); +} + export function mockPanelGroupOffsetWidthAndHeight( mockWidth = 1_000, mockHeight = 1_000