Repository: bvaughn/react-resizable-panels (ts)

Issue:
Detect click events inside hit area
First, thank you for the library! Everything is very quick to set up and get working.

I wanted to get a similar functionality to VSCode where dblclick on the handle expands and collapses the panels. This works, except that, because the click events are only being detected if I click right on the actual handle it becomes very annoying. Would it be possible / much work to make so that these events are fired if user clicks anywhere inside the hit area?

Pull request description:
Add onPointerDown, onPointerUp, and onClick callbacks to PanelResizeHandle
Resolves #438

Patch:
diff --git a/packages/react-resizable-panels-website/src/components/ResizeHandle.tsx b/packages/react-resizable-panels-website/src/components/ResizeHandle.tsx
index 38984b5..cedea53 100644
--- a/packages/react-resizable-panels-website/src/components/ResizeHandle.tsx
+++ b/packages/react-resizable-panels-website/src/components/ResizeHandle.tsx
@@ -1,11 +1,15 @@
-import { PanelResizeHandle } from "react-resizable-panels";
+import {
+  PanelResizeHandle,
+  PanelResizeHandleProps,
+} from "react-resizable-panels";
 
 import styles from "./ResizeHandle.module.css";
 
 export function ResizeHandle({
   className = "",
   id,
-}: {
+  ...rest
+}: PanelResizeHandleProps & {
   className?: string;
   id?: string;
 }) {
@@ -13,6 +17,7 @@ export function ResizeHandle({
     <PanelResizeHandle
       className={[styles.ResizeHandle, className].join(" ")}
       id={id}
+      {...rest}
     />
   );
 }
diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.ts b/packages/react-resizable-panels/src/PanelResizeHandle.ts
index e636e15..26b1bd3 100644
--- a/packages/react-resizable-panels/src/PanelResizeHandle.ts
+++ b/packages/react-resizable-panels/src/PanelResizeHandle.ts
@@ -29,7 +29,7 @@ export type ResizeHandlerState = "drag" | "hover" | "inactive";
 
 export type PanelResizeHandleProps = Omit<
   HTMLAttributes<keyof HTMLElementTagNameMap>,
-  "id" | "onBlur" | "onFocus"
+  "id" | "onBlur" | "onClick" | "onFocus" | "onPointerDown" | "onPointerUp"
 > &
   PropsWithChildren<{
     className?: string;
@@ -37,8 +37,11 @@ export type PanelResizeHandleProps = Omit<
     hitAreaMargins?: PointerHitAreaMargins;
     id?: string | null;
     onBlur?: () => void;
+    onClick?: () => void;
     onDragging?: PanelResizeHandleOnDragging;
     onFocus?: () => void;
+    onPointerDown?: () => void;
+    onPointerUp?: () => void;
     style?: CSSProperties;
     tabIndex?: number;
     tagName?: keyof HTMLElementTagNameMap;
@@ -51,8 +54,11 @@ export function PanelResizeHandle({
   hitAreaMargins,
   id: idFromProps,
   onBlur,
+  onClick,
   onDragging,
   onFocus,
+  onPointerDown,
+  onPointerUp,
   style: styleFromProps = {},
   tabIndex = 0,
   tagName: Type = "div",
@@ -62,10 +68,16 @@ export function PanelResizeHandle({
 
   // Use a ref to guard against users passing inline props
   const callbacksRef = useRef<{
+    onClick: (() => void) | undefined;
     onDragging: PanelResizeHandleOnDragging | undefined;
-  }>({ onDragging });
+    onPointerDown: (() => void) | undefined;
+    onPointerUp: (() => void) | undefined;
+  }>({ onClick, onDragging, onPointerDown, onPointerUp });
   useEffect(() => {
+    callbacksRef.current.onClick = onClick;
     callbacksRef.current.onDragging = onDragging;
+    callbacksRef.current.onPointerDown = onPointerDown;
+    callbacksRef.current.onPointerUp = onPointerUp;
   });
 
   const panelGroupContext = useContext(PanelGroupContext);
@@ -126,6 +138,8 @@ export function PanelResizeHandle({
     const element = elementRef.current;
     assert(element, "Element ref not attached");
 
+    let didMove = false;
+
     const setResizeHandlerState = (
       action: ResizeHandlerAction,
       isActive: boolean,
@@ -136,19 +150,22 @@ export function PanelResizeHandle({
           case "down": {
             setState("drag");
 
+            didMove = false;
+
             assert(event, 'Expected event to be defined for "down" action');
 
             startDragging(resizeHandleId, event);
 
-            const { onDragging } = callbacksRef.current;
-            if (onDragging) {
-              onDragging(true);
-            }
+            const { onDragging, onPointerDown } = callbacksRef.current;
+            onDragging?.(true);
+            onPointerDown?.();
             break;
           }
           case "move": {
             const { state } = committedValuesRef.current;
 
+            didMove = true;
+
             if (state !== "drag") {
               setState("hover");
             }
@@ -163,9 +180,12 @@ export function PanelResizeHandle({
 
             stopDragging();
 
-            const { onDragging } = callbacksRef.current;
-            if (onDragging) {
-              onDragging(false);
+            const { onClick, onDragging, onPointerUp } = callbacksRef.current;
+            onDragging?.(false);
+            onPointerUp?.();
+
+            if (!didMove) {
+              onClick?.();
             }
             break;
           }


Test patch:
diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx b/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx
index e7c3e71..fb0548d 100644
--- a/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx
+++ b/packages/react-resizable-panels/src/PanelResizeHandle.test.tsx
@@ -129,61 +129,131 @@ describe("PanelResizeHandle", () => {
   }
 
   describe("callbacks", () => {
-    describe("onDragging", () => {
-      it("should fire when dragging starts/stops", () => {
-        const onDragging = jest.fn();
-
-        const { leftElement } = setupMockedGroup({
-          leftProps: { onDragging },
-        });
-
-        act(() => {
-          dispatchPointerEvent("pointermove", leftElement);
-        });
-        expect(onDragging).not.toHaveBeenCalled();
-
-        act(() => {
-          dispatchPointerEvent("pointerdown", leftElement);
-        });
-        expect(onDragging).toHaveBeenCalledTimes(1);
-        expect(onDragging).toHaveBeenCalledWith(true);
-
-        act(() => {
-          dispatchPointerEvent("pointerup", leftElement);
-        });
-        expect(onDragging).toHaveBeenCalledTimes(2);
-        expect(onDragging).toHaveBeenCalledWith(false);
+    it("should fire when dragging starts/stops", () => {
+      const onDragging = jest.fn();
+      const onPointerDown = jest.fn();
+      const onPointerUp = jest.fn();
+
+      const { leftElement } = setupMockedGroup({
+        leftProps: { onDragging, onPointerDown, onPointerUp },
+      });
+
+      act(() => {
+        dispatchPointerEvent("pointermove", leftElement);
+      });
+      expect(onPointerDown).not.toHaveBeenCalled();
+      expect(onDragging).not.toHaveBeenCalled();
+      expect(onPointerUp).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerdown", leftElement);
+      });
+      expect(onPointerDown).toHaveBeenCalledTimes(1);
+      expect(onDragging).toHaveBeenCalledTimes(1);
+      expect(onDragging).toHaveBeenCalledWith(true);
+      expect(onPointerUp).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerup", leftElement);
+      });
+      expect(onPointerDown).toHaveBeenCalledTimes(1);
+      expect(onDragging).toHaveBeenCalledTimes(2);
+      expect(onDragging).toHaveBeenCalledWith(false);
+      expect(onPointerUp).toHaveBeenCalledTimes(1);
+    });
+
+    it("should fire on-click if not dragged", () => {
+      const onClick = jest.fn();
+      const onDragging = jest.fn();
+
+      const { leftElement } = setupMockedGroup({
+        leftProps: { onClick, onDragging },
+      });
+
+      act(() => {
+        dispatchPointerEvent("pointermove", leftElement);
+      });
+      expect(onDragging).not.toHaveBeenCalled();
+      expect(onClick).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerdown", leftElement);
+      });
+      expect(onDragging).toHaveBeenCalledTimes(1);
+      expect(onDragging).toHaveBeenCalledWith(true);
+      expect(onClick).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerup", leftElement);
       });
+      expect(onDragging).toHaveBeenCalledTimes(2);
+      expect(onDragging).toHaveBeenCalledWith(false);
+      expect(onClick).toHaveBeenCalledTimes(1);
+    });
 
-      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("pointermove", leftElement);
-        });
-        expect(onDraggingLeft).not.toHaveBeenCalled();
-        expect(onDraggingRight).not.toHaveBeenCalled();
-
-        act(() => {
-          dispatchPointerEvent("pointerdown", leftElement);
-        });
-        expect(onDraggingLeft).toHaveBeenCalledTimes(1);
-        expect(onDraggingLeft).toHaveBeenCalledWith(true);
-        expect(onDraggingRight).not.toHaveBeenCalled();
-
-        act(() => {
-          dispatchPointerEvent("pointerup", leftElement);
-        });
-        expect(onDraggingLeft).toHaveBeenCalledTimes(2);
-        expect(onDraggingLeft).toHaveBeenCalledWith(false);
-        expect(onDraggingRight).not.toHaveBeenCalled();
+    it("should not fire on-click if dragged", () => {
+      const onClick = jest.fn();
+      const onDragging = jest.fn();
+
+      const { leftElement } = setupMockedGroup({
+        leftProps: { onClick, onDragging },
+      });
+
+      act(() => {
+        dispatchPointerEvent("pointermove", leftElement);
+      });
+      expect(onDragging).not.toHaveBeenCalled();
+      expect(onClick).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerdown", leftElement);
+      });
+      expect(onDragging).toHaveBeenCalledTimes(1);
+      expect(onDragging).toHaveBeenCalledWith(true);
+      expect(onClick).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointermove", leftElement);
+      });
+      expect(onDragging).toHaveBeenCalledTimes(1);
+      expect(onClick).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerup", leftElement);
+      });
+      expect(onDragging).toHaveBeenCalledTimes(2);
+      expect(onDragging).toHaveBeenCalledWith(false);
+      expect(onClick).not.toHaveBeenCalled();
+    });
+
+    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("pointermove", leftElement);
+      });
+      expect(onDraggingLeft).not.toHaveBeenCalled();
+      expect(onDraggingRight).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerdown", leftElement);
+      });
+      expect(onDraggingLeft).toHaveBeenCalledTimes(1);
+      expect(onDraggingLeft).toHaveBeenCalledWith(true);
+      expect(onDraggingRight).not.toHaveBeenCalled();
+
+      act(() => {
+        dispatchPointerEvent("pointerup", leftElement);
       });
+      expect(onDraggingLeft).toHaveBeenCalledTimes(2);
+      expect(onDraggingLeft).toHaveBeenCalledWith(false);
+      expect(onDraggingRight).not.toHaveBeenCalled();
     });
   });