From a9822bee0660ad8a999a9c89effe69e3b4cfd191 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 18 Mar 2023 13:07:47 -0400 Subject: [PATCH 1/3] Add onDragging prop to PanelResizeHandle --- .../src/routes/examples/types.ts | 12 +++- .../src/utils/UrlData.ts | 23 ++++++- .../tests/ResizeHandle-OnDragging.spec.ts | 66 +++++++++++++++++++ packages/react-resizable-panels/CHANGELOG.md | 3 + packages/react-resizable-panels/README.md | 25 +++---- .../src/PanelResizeHandle.ts | 41 ++++++++++-- packages/react-resizable-panels/src/types.ts | 1 + 7 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts diff --git a/packages/react-resizable-panels-website/src/routes/examples/types.ts b/packages/react-resizable-panels-website/src/routes/examples/types.ts index ca6957d36..801305af0 100644 --- a/packages/react-resizable-panels-website/src/routes/examples/types.ts +++ b/packages/react-resizable-panels-website/src/routes/examples/types.ts @@ -1,5 +1,6 @@ export type PanelCollapseLogEntryType = "onCollapse"; export type PanelGroupLayoutLogEntryType = "onLayout"; +export type PanelResizeHandleDraggingLogEntryType = "onDragging"; export type PanelResizeLogEntryType = "onResize"; export type PanelCollapseLogEntry = { @@ -7,23 +8,30 @@ export type PanelCollapseLogEntry = { panelId: string; type: PanelCollapseLogEntryType; }; +export type PanelResizeHandleDraggingLogEntry = { + isDragging: boolean; + resizeHandleId: string; + type: PanelResizeHandleDraggingLogEntryType; +}; export type PanelGroupLayoutLogEntry = { groupId: string; - type: PanelGroupLayoutLogEntryType; sizes: number[]; + type: PanelGroupLayoutLogEntryType; }; export type PanelResizeLogEntry = { panelId: string; - type: PanelResizeLogEntryType; size: number; + type: PanelResizeLogEntryType; }; export type LogEntryType = | PanelCollapseLogEntryType + | PanelResizeHandleDraggingLogEntryType | PanelGroupLayoutLogEntryType | PanelResizeLogEntryType; export type LogEntry = | PanelCollapseLogEntry + | PanelResizeHandleDraggingLogEntry | PanelGroupLayoutLogEntry | PanelResizeLogEntry; diff --git a/packages/react-resizable-panels-website/src/utils/UrlData.ts b/packages/react-resizable-panels-website/src/utils/UrlData.ts index 28a11da5a..2d412663d 100644 --- a/packages/react-resizable-panels-website/src/utils/UrlData.ts +++ b/packages/react-resizable-panels-website/src/utils/UrlData.ts @@ -16,6 +16,7 @@ import { PanelOnResize, PanelProps, PanelResizeHandle, + PanelResizeHandleOnDragging, PanelResizeHandleProps, } from "react-resizable-panels"; import { ImperativeDebugLogHandle } from "../routes/examples/DebugLog"; @@ -241,7 +242,11 @@ export function urlPanelGroupToPanelGroup( if (isUrlPanel(child)) { return urlPanelToPanel(child, debugLogRef, idToPanelMapRef, index); } else if (isUrlPanelResizeHandle(child)) { - return urlPanelResizeHandleToPanelResizeHandle(child, index); + return urlPanelResizeHandleToPanelResizeHandle( + child, + debugLogRef, + index + ); } else { throw Error("Invalid child"); } @@ -251,13 +256,29 @@ export function urlPanelGroupToPanelGroup( function urlPanelResizeHandleToPanelResizeHandle( urlPanelResizeHandle: UrlPanelResizeHandle, + debugLogRef: RefObject, key?: any ): ReactElement { + let onDragging: PanelResizeHandleOnDragging | undefined = undefined; + if (urlPanelResizeHandle.id) { + onDragging = (isDragging: boolean) => { + const debugLog = debugLogRef.current; + if (debugLog) { + debugLog.log({ + isDragging, + resizeHandleId: urlPanelResizeHandle.id, + type: "onDragging", + }); + } + }; + } + return createElement(PanelResizeHandle, { className: "PanelResizeHandle", disabled: urlPanelResizeHandle.disabled, id: urlPanelResizeHandle.id, key, + onDragging, style: urlPanelResizeHandle.style, }); } diff --git a/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts b/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts new file mode 100644 index 000000000..d6b5b912a --- /dev/null +++ b/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts @@ -0,0 +1,66 @@ +import { expect, Page, test } from "@playwright/test"; +import { createElement } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; + +import { PanelResizeHandleDraggingLogEntry } from "../src/routes/examples/types"; + +import { clearLogEntries, getLogEntries } from "./utils/debug"; +import { goToUrl } from "./utils/url"; + +async function openPage(page: Page) { + const panelGroup = createElement( + PanelGroup, + { direction: "horizontal", id: "group" }, + createElement(Panel, { collapsible: true, defaultSize: 20, order: 1 }), + createElement(PanelResizeHandle, { id: "handle" }), + createElement(Panel, { collapsible: true, defaultSize: 20, order: 3 }) + ); + + await goToUrl(page, panelGroup); +} + +async function verifyEntries(page: Page, expectedIsDragging: boolean[]) { + const logEntries = await getLogEntries( + page, + "onDragging" + ); + + expect(logEntries.length).toEqual(expectedIsDragging.length); + + for (let index = 0; index < expectedIsDragging.length; index++) { + const actual = logEntries[index].isDragging; + const expected = expectedIsDragging[index]; + expect(actual).toEqual(expected); + } +} + +test.describe("PanelResizeHandle onDragging prop", () => { + test.beforeEach(async ({ page }) => { + await openPage(page); + }); + + test("should not be called on-mount", async ({ page }) => { + await verifyEntries(page, []); + }); + + test("should be called when the panel ResizeHandle starts or stops resizing", async ({ + page, + }) => { + const handle = page.locator('[data-panel-resize-handle-id="handle"]'); + + await clearLogEntries(page, "onDragging"); + + const bounds = await handle.boundingBox(); + await page.mouse.move(bounds.x, bounds.y); + await page.mouse.down(); + await page.mouse.move(5, 0); + await page.mouse.move(10, 0); + await page.mouse.move(15, 0); + + await verifyEntries(page, [true]); + + await page.mouse.up(); + + await verifyEntries(page, [true, false]); + }); +}); diff --git a/packages/react-resizable-panels/CHANGELOG.md b/packages/react-resizable-panels/CHANGELOG.md index 6ff79e432..e50fbd19e 100644 --- a/packages/react-resizable-panels/CHANGELOG.md +++ b/packages/react-resizable-panels/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.0.37 +* [#94](https://github.com/bvaughn/react-resizable-panels/issues/94): Add `onDragging` prop to `PanelResizeHandle` to be notified of when dragging starts/stops. + ## 0.0.36 * [#96](https://github.com/bvaughn/react-resizable-panels/issues/96): No longer disable `pointer-events` during resize by default. This behavior can be re-enabled using the newly added `PanelGroup` prop `disablePointerEventsDuringResize`. diff --git a/packages/react-resizable-panels/README.md b/packages/react-resizable-panels/README.md index bff7002c1..35c6c7029 100644 --- a/packages/react-resizable-panels/README.md +++ b/packages/react-resizable-panels/README.md @@ -22,18 +22,19 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; ## Props ### `PanelGroup` -| prop | type | description -| :--------------------------------- | :-------------------------- | :--- -| `autoSaveId` | `?string` | Unique id used to auto-save group arrangement via `localStorage` -| `children` | `ReactNode` | Arbitrary React element(s) -| `className` | `?string` | Class name to attach to root element -| `direction` | `"horizontal" \| "vertical"` | Group orientation -| `disablePointerEventsDuringResize` | `?boolean = false` | Disable pointer events inside `Panel`s during resize 2 -| `id` | `?string` | Group id; falls back to `useId` when not provided -| `onLayout` | `?(sizes: number[]) => void` | Called when group layout changes -| `storage` | `?PanelGroupStorage` | Custom storage API; defaults to `localStorage` 1 -| `style` | `?CSSProperties` | CSS style to attach to root element -| `tagName` | `?string = "div"` | HTML element tag name for root element +| prop | type | description +| :--------------------------------- | :----------------------------- | :--- +| `autoSaveId` | `?string` | Unique id used to auto-save group arrangement via `localStorage` +| `children` | `ReactNode` | Arbitrary React element(s) +| `className` | `?string` | Class name to attach to root element +| `direction` | `"horizontal" \| "vertical"` | Group orientation +| `disablePointerEventsDuringResize` | `?boolean = false` | Disable pointer events inside `Panel`s during resize 2 +| `id` | `?string` | Group id; falls back to `useId` when not provided +| `onDragging` | `?(isDragging: boolean) => void` | Called when group layout changes +| `onLayout` | `?(sizes: number[]) => void` | Called when group layout changes +| `storage` | `?PanelGroupStorage` | Custom storage API; defaults to `localStorage` 1 +| `style` | `?CSSProperties` | CSS style to attach to root element +| `tagName` | `?string = "div"` | HTML element tag name for root element 1: Storage API must define the following _synchronous_ methods: * `getItem: (name:string) => string` diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.ts b/packages/react-resizable-panels/src/PanelResizeHandle.ts index d3aeb4022..122438857 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandle.ts +++ b/packages/react-resizable-panels/src/PanelResizeHandle.ts @@ -15,7 +15,11 @@ import useUniqueId from "./hooks/useUniqueId"; import { useWindowSplitterResizeHandlerBehavior } from "./hooks/useWindowSplitterBehavior"; import { PanelGroupContext } from "./PanelContexts"; -import type { ResizeHandler, ResizeEvent } from "./types"; +import type { + ResizeHandler, + ResizeEvent, + PanelResizeHandleOnDragging, +} from "./types"; import { getCursorStyle } from "./utils/cursor"; export type PanelResizeHandleProps = { @@ -23,6 +27,7 @@ export type PanelResizeHandleProps = { className?: string; disabled?: boolean; id?: string | null; + onDragging?: PanelResizeHandleOnDragging; style?: CSSProperties; tagName?: ElementType; }; @@ -32,11 +37,20 @@ export function PanelResizeHandle({ className: classNameFromProps = "", disabled = false, id: idFromProps = null, + onDragging = null, style: styleFromProps = {}, tagName: Type = "div", }: PanelResizeHandleProps) { const divElementRef = useRef(null); + // Use a ref to guard against users passing inline props + const callbacksRef = useRef<{ + onDragging: PanelResizeHandleOnDragging | null; + }>({ onDragging }); + useEffect(() => { + callbacksRef.current.onDragging = onDragging; + }); + const panelGroupContext = useContext(PanelGroupContext); if (panelGroupContext === null) { throw Error( @@ -69,6 +83,11 @@ export function PanelResizeHandle({ div.blur(); stopDragging(); + + const { onDragging } = callbacksRef.current; + if (onDragging) { + onDragging(false); + } }, [stopDragging]); useEffect(() => { @@ -130,13 +149,25 @@ export function PanelResizeHandle({ "data-panel-resize-handle-id": resizeHandleId, onBlur: () => setIsFocused(false), onFocus: () => setIsFocused(true), - onMouseDown: (event: MouseEvent) => - startDragging(resizeHandleId, event.nativeEvent), + onMouseDown: (event: MouseEvent) => { + startDragging(resizeHandleId, event.nativeEvent); + + const { onDragging } = callbacksRef.current; + if (onDragging) { + onDragging(true); + } + }, onMouseUp: stopDraggingAndBlur, onTouchCancel: stopDraggingAndBlur, onTouchEnd: stopDraggingAndBlur, - onTouchStart: (event: TouchEvent) => - startDragging(resizeHandleId, event.nativeEvent), + onTouchStart: (event: TouchEvent) => { + startDragging(resizeHandleId, event.nativeEvent); + + const { onDragging } = callbacksRef.current; + if (onDragging) { + onDragging(true); + } + }, ref: divElementRef, role: "separator", style: { diff --git a/packages/react-resizable-panels/src/types.ts b/packages/react-resizable-panels/src/types.ts index d9bebe444..869bd8772 100644 --- a/packages/react-resizable-panels/src/types.ts +++ b/packages/react-resizable-panels/src/types.ts @@ -10,6 +10,7 @@ export type PanelGroupStorage = { export type PanelGroupOnLayout = (sizes: number[]) => void; export type PanelOnCollapse = (collapsed: boolean) => void; export type PanelOnResize = (size: number) => void; +export type PanelResizeHandleOnDragging = (isDragging: boolean) => void; export type PanelData = { callbacksRef: RefObject<{ From d886c819195403eb80a412235650ff48819432c2 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 18 Mar 2023 13:12:43 -0400 Subject: [PATCH 2/3] Beef up e2e test --- .../tests/ResizeHandle-OnDragging.spec.ts | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts b/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts index d6b5b912a..991418fad 100644 --- a/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts +++ b/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts @@ -12,25 +12,32 @@ async function openPage(page: Page) { PanelGroup, { direction: "horizontal", id: "group" }, createElement(Panel, { collapsible: true, defaultSize: 20, order: 1 }), - createElement(PanelResizeHandle, { id: "handle" }), + createElement(PanelResizeHandle, { id: "left-handle" }), + createElement(Panel, { defaultSize: 60, order: 2 }), + createElement(PanelResizeHandle, { id: "right-handle" }), createElement(Panel, { collapsible: true, defaultSize: 20, order: 3 }) ); await goToUrl(page, panelGroup); } -async function verifyEntries(page: Page, expectedIsDragging: boolean[]) { +async function verifyEntries( + page: Page, + expected: Array<[handleId: string, isDragging: boolean]> +) { const logEntries = await getLogEntries( page, "onDragging" ); - expect(logEntries.length).toEqual(expectedIsDragging.length); + expect(logEntries.length).toEqual(expected.length); - for (let index = 0; index < expectedIsDragging.length; index++) { - const actual = logEntries[index].isDragging; - const expected = expectedIsDragging[index]; - expect(actual).toEqual(expected); + for (let index = 0; index < expected.length; index++) { + const { isDragging: isDraggingActual, resizeHandleId: handleIdActual } = + logEntries[index]; + const [handleIdExpected, isDraggingExpected] = expected[index]; + expect(handleIdExpected).toEqual(handleIdActual); + expect(isDraggingExpected).toEqual(isDraggingActual); } } @@ -46,21 +53,41 @@ test.describe("PanelResizeHandle onDragging prop", () => { test("should be called when the panel ResizeHandle starts or stops resizing", async ({ page, }) => { - const handle = page.locator('[data-panel-resize-handle-id="handle"]'); + const leftHandle = page.locator( + '[data-panel-resize-handle-id="left-handle"]' + ); + const rightHandle = page.locator( + '[data-panel-resize-handle-id="right-handle"]' + ); await clearLogEntries(page, "onDragging"); - const bounds = await handle.boundingBox(); + let bounds = await leftHandle.boundingBox(); await page.mouse.move(bounds.x, bounds.y); await page.mouse.down(); await page.mouse.move(5, 0); await page.mouse.move(10, 0); await page.mouse.move(15, 0); - - await verifyEntries(page, [true]); + await verifyEntries(page, [["left-handle", true]]); await page.mouse.up(); + await verifyEntries(page, [ + ["left-handle", true], + ["left-handle", false], + ]); + + await clearLogEntries(page, "onDragging"); - await verifyEntries(page, [true, false]); + bounds = await rightHandle.boundingBox(); + await page.mouse.move(bounds.x, bounds.y); + await page.mouse.down(); + await page.mouse.move(25, 0); + await verifyEntries(page, [["right-handle", true]]); + + await page.mouse.up(); + await verifyEntries(page, [ + ["right-handle", true], + ["right-handle", false], + ]); }); }); From a3aea0cc9d6b6850ad947980e0f93ad8af128a5d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 18 Mar 2023 13:14:01 -0400 Subject: [PATCH 3/3] README fix --- packages/react-resizable-panels/README.md | 42 +++++++++++------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/react-resizable-panels/README.md b/packages/react-resizable-panels/README.md index 35c6c7029..3ad5f6b45 100644 --- a/packages/react-resizable-panels/README.md +++ b/packages/react-resizable-panels/README.md @@ -22,19 +22,18 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; ## Props ### `PanelGroup` -| prop | type | description -| :--------------------------------- | :----------------------------- | :--- -| `autoSaveId` | `?string` | Unique id used to auto-save group arrangement via `localStorage` -| `children` | `ReactNode` | Arbitrary React element(s) -| `className` | `?string` | Class name to attach to root element -| `direction` | `"horizontal" \| "vertical"` | Group orientation -| `disablePointerEventsDuringResize` | `?boolean = false` | Disable pointer events inside `Panel`s during resize 2 -| `id` | `?string` | Group id; falls back to `useId` when not provided -| `onDragging` | `?(isDragging: boolean) => void` | Called when group layout changes -| `onLayout` | `?(sizes: number[]) => void` | Called when group layout changes -| `storage` | `?PanelGroupStorage` | Custom storage API; defaults to `localStorage` 1 -| `style` | `?CSSProperties` | CSS style to attach to root element -| `tagName` | `?string = "div"` | HTML element tag name for root element +| prop | type | description +| :--------------------------------- | :--------------------------- | :--- +| `autoSaveId` | `?string` | Unique id used to auto-save group arrangement via `localStorage` +| `children` | `ReactNode` | Arbitrary React element(s) +| `className` | `?string` | Class name to attach to root element +| `direction` | `"horizontal" \| "vertical"` | Group orientation +| `disablePointerEventsDuringResize` | `?boolean = false` | Disable pointer events inside `Panel`s during resize 2 +| `id` | `?string` | Group id; falls back to `useId` when not provided +| `onLayout` | `?(sizes: number[]) => void` | Called when group layout changes +| `storage` | `?PanelGroupStorage` | Custom storage API; defaults to `localStorage` 1 +| `style` | `?CSSProperties` | CSS style to attach to root element +| `tagName` | `?string = "div"` | HTML element tag name for root element 1: Storage API must define the following _synchronous_ methods: * `getItem: (name:string) => string` @@ -70,14 +69,15 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; | `resize(percentage: number)` | Resize panel to the specified _percentage_ (`1 - 100`). ### `PanelResizeHandle` -| prop | type | description -| :------------ | :---------------- | :--- -| `children` | `?ReactNode` | Custom drag UI; can be any arbitrary React element(s) -| `className` | `?string` | Class name to attach to root element -| `disabled` | `?boolean` | Disable drag handle -| `id` | `?string` | Resize handle id (unique within group); falls back to `useId` when not provided -| `style` | `?CSSProperties` | CSS style to attach to root element -| `tagName` | `?string = "div"` | HTML element tag name for root element +| prop | type | description +| :------------ | :------------------------------- | :--- +| `children` | `?ReactNode` | Custom drag UI; can be any arbitrary React element(s) +| `className` | `?string` | Class name to attach to root element +| `disabled` | `?boolean` | Disable drag handle +| `id` | `?string` | Resize handle id (unique within group); falls back to `useId` when not provided +| `onDragging` | `?(isDragging: boolean) => void` | Called when group layout changes +| `style` | `?CSSProperties` | CSS style to attach to root element +| `tagName` | `?string = "div"` | HTML element tag name for root element --- #### If you like this project, [buy me a coffee](http://givebrian.coffee/). \ No newline at end of file