diff --git a/README.md b/README.md index 5adb5b1be..37d5ba0bd 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,26 @@ Supported input methods include mouse, touch, and keyboard (via [Window Splitter No. Pixel-based constraints [added significant complexity](https://github.com/bvaughn/react-resizable-panels/pull/176) to the initialization and validation logic and so I've decided not to support them. You may be able to implement a version of this yourself following [a pattern like this](https://github.com/bvaughn/react-resizable-panels/issues/46#issuecomment-1368108416) but it is not officially supported by this library. +### How can I fix layout/sizing problems with conditionally rendered panels? + +The `Panel` API doesn't _require_ `id` and `order` props because they aren't necessary for static layouts. When panels are conditionally rendered though, it's best to supply these values. + +```tsx + + {renderSideBar && ( + <> + + + + + + )} + +
+ + +``` + ### Can a attach a ref to the DOM elements? No. I think exposing two refs (one for the component's imperative API and one for a DOM element) would be awkward. This library does export several utility methods for accessing the underlying DOM elements though. For example: @@ -74,31 +94,11 @@ This likely means that you haven't applied any CSS to style the resize handles. ``` -### How can I fix layout/sizing problems with conditionally rendered panels? - -The `Panel` API doesn't _require_ `id` and `order` props because they aren't necessary for static layouts. When panels are conditionally rendered though, it's best to supply these values. - -```tsx - - {renderSideBar && ( - <> - - - - - - )} - -
- - -``` - ### How can I use persistent layouts with SSR? By default, this library uses `localStorage` to persist layouts. With server rendering, this can cause a flicker when the default layout (rendered on the server) is replaced with the persisted layout (in `localStorage`). The way to avoid this flicker is to also persist the layout with a cookie like so: -##### Server component +#### Server component ```tsx import ResizablePanels from "@/app/ResizablePanels"; @@ -116,7 +116,7 @@ export function ServerComponent() { } ``` -##### Client component +#### Client component ```tsx "use client"; diff --git a/packages/react-resizable-panels/CHANGELOG.md b/packages/react-resizable-panels/CHANGELOG.md index c73dd60a8..fdcee9e2c 100644 --- a/packages/react-resizable-panels/CHANGELOG.md +++ b/packages/react-resizable-panels/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.0.0 + +- Support resizing multiple (intersecting) panels at once (#274) +This behavior can be customized using a new `hitAreaMargins` prop; defaults to a 15 pixel margin for _coarse_ inputs and a 5 pixel margin for _fine_ inputs. + ## 1.0.10 - Fixed edge case constraints check bug that could cause a collapsed panel to re-expand unnecessarily (#273) diff --git a/packages/react-resizable-panels/README.md b/packages/react-resizable-panels/README.md index 176f5c946..528fef17c 100644 --- a/packages/react-resizable-panels/README.md +++ b/packages/react-resizable-panels/README.md @@ -20,7 +20,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; ; ``` -### If you like this project, 🎉 [become a sponsor](https://github.com/sponsors/bvaughn/) or ☕ [buy me a coffee](http://givebrian.coffee/) +## If you like this project, 🎉 [become a sponsor](https://github.com/sponsors/bvaughn/) or ☕ [buy me a coffee](http://givebrian.coffee/) ## Props @@ -44,11 +44,11 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; - `setItem: (name: string, value: string) => void` `PanelGroup` components also expose an imperative API for manual resizing: -| method | description -| :-------------------------------- | :--- -| `getId(): string` | Gets the panel group's ID. -| `getLayout(): number[]` | Gets the panel group's current _layout_ (`[1 - 100, ...]`). -| `setLayout(layout: number[])` | Resize panel group to the specified _layout_ (`[1 - 100, ...]`). +| method | description | +| :---------------------------- | :--------------------------------------------------------------- | +| `getId(): string` | Gets the panel group's ID. | +| `getLayout(): number[]` | Gets the panel group's current _layout_ (`[1 - 100, ...]`). | +| `setLayout(layout: number[])` | Resize panel group to the specified _layout_ (`[1 - 100, ...]`). | ### `Panel` @@ -63,7 +63,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; | `maxSize` | `?number = 100` | Maximum allowable size of panel (numeric value between 1-100); defaults to `100` | | `minSize` | `?number = 10` | Minimum allowable size of panel (numeric value between 1-100); defaults to `10` | | `onCollapse` | `?() => void` | Called when panel is collapsed | -| `onExpand` | `?() => void` | Called when panel is expanded | +| `onExpand` | `?() => void` | Called when panel is expanded | | `onResize` | `?(size: number) => void` | Called when panel is resized; `size` parameter is a numeric value between 1-100. 1 | | `order` | `?number` | Order of panel within group; required for groups with conditionally rendered panels | | `style` | `?CSSProperties` | CSS style to attach to root element | @@ -72,28 +72,29 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 1: If any `Panel` has an `onResize` callback, the `order` prop should be provided for all `Panel`s. `Panel` components also expose an imperative API for manual resizing: -| method | description -| :--------------------------- | :--- -| `collapse()` | If panel is `collapsible`, collapse it fully. -| `expand()` | If panel is currently _collapsed_, expand it to its most recent size. -| `getId(): string` | Gets the ID of the panel. -| `getSize(): number` | Gets the current size of the panel as a percentage (`1 - 100`). -| `isCollapsed(): boolean` | Returns `true` if the panel is currently _collapsed_ (`size === 0`). -| `isExpanded(): boolean` | Returns `true` if the panel is currently _not collapsed_ (`!isCollapsed()`). -| `getSize(): number` | Returns the most recently commited size of the panel as a percentage (`1 - 100`). -| `resize(size: number)` | Resize panel to the specified _percentage_ (`1 - 100`). +| method | description | +| :----------------------- | :--------------------------------------------------------------------------------- | +| `collapse()`v | If panel is `collapsible`, collapse it fully. | +| `expand()` | If panel is currently _collapsed_, expand it to its most recent size. | +| `getId(): string` | Gets the ID of the panel. | +| `getSize(): number` | Gets the current size of the panel as a percentage (`1 - 100`). | +| `isCollapsed(): boolean` | Returns `true` if the panel is currently _collapsed_ (`size === 0`). | +| `isExpanded(): boolean` | Returns `true` if the panel is currently _not collapsed_ (`!isCollapsed()`). | +| `getSize(): number` | Returns the most recently committed size of the panel as a percentage (`1 - 100`). | +| `resize(size: 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 | -| `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 | +| prop | type | description | +| :--------------- | :-------------------------------------------- | :------------------------------------------------------------------------------ | +| `children` | `?ReactNode` | Custom drag UI; can be any arbitrary React element(s) | +| `className` | `?string` | Class name to attach to root element | +| `hitAreaMargins` | `?{ coarse: number = 15; fine: number = 5; }` | Allow this much margin when determining resizable handle hit detection | +| `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 | --- @@ -123,62 +124,6 @@ The `Panel` API doesn't _require_ `id` and `order` props because they aren't nec ``` -### How can I use persistent layouts with SSR? - -By default, this library uses `localStorage` to persist layouts. With server rendering, this can cause a flicker when the default layout (rendered on the server) is replaced with the persisted layout (in `localStorage`). The way to avoid this flicker is to also persist the layout with a cookie like so: - -##### Server component - -```tsx -import ResizablePanels from "@/app/ResizablePanels"; -import { cookies } from "next/headers"; - -export function ServerComponent() { - const layout = cookies().get("react-resizable-panels:layout"); - - let defaultLayout; - if (layout) { - defaultLayout = JSON.parse(layout.value); - } - - return ; -} -``` - -##### Client component - -```tsx -"use client"; - -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; - -export function ClientComponent({ - defaultLayout = [33, 67], -}: { - defaultLayout: number[] | undefined; -}) { - const onLayout = (sizes: number[]) => { - document.cookie = `react-resizable-panels:layout=${JSON.stringify(sizes)}`; - }; - - return ( - - {/* ... */} - - {/* ... */} - - ); -} -``` - ---- - -## FAQ - -### Can panel sizes be specified in pixels? - -No. Pixel-based constraints [added significant complexity](https://github.com/bvaughn/react-resizable-panels/pull/176) to the initialization and validation logic and so I've decided not to support them. You may be able to implement a version of this yourself following [a pattern like this](https://github.com/bvaughn/react-resizable-panels/issues/46#issuecomment-1368108416) but it is not officially supported by this library. - ### Can a attach a ref to the DOM elements? No. I think exposing two refs (one for the component's imperative API and one for a DOM element) would be awkward. This library does export several utility methods for accessing the underlying DOM elements though. For example: @@ -230,31 +175,11 @@ This likely means that you haven't applied any CSS to style the resize handles. ``` -### How can I fix layout/sizing problems with conditionally rendered panels? - -The `Panel` API doesn't _require_ `id` and `order` props because they aren't necessary for static layouts. When panels are conditionally rendered though, it's best to supply these values. - -```tsx - - {renderSideBar && ( - <> - - - - - - )} - -
- - -``` - ### How can I use persistent layouts with SSR? By default, this library uses `localStorage` to persist layouts. With server rendering, this can cause a flicker when the default layout (rendered on the server) is replaced with the persisted layout (in `localStorage`). The way to avoid this flicker is to also persist the layout with a cookie like so: -##### Server component +#### Server component ```tsx import ResizablePanels from "@/app/ResizablePanels"; @@ -272,7 +197,7 @@ export function ServerComponent() { } ``` -##### Client component +#### Client component ```tsx "use client"; diff --git a/packages/react-resizable-panels/src/PanelResizeHandle.ts b/packages/react-resizable-panels/src/PanelResizeHandle.ts index c57937c59..9f0229cde 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandle.ts +++ b/packages/react-resizable-panels/src/PanelResizeHandle.ts @@ -18,6 +18,7 @@ import { ResizeHandler, } from "./PanelGroupContext"; import { + PointerHitAreaMargins, registerResizeHandle, ResizeHandlerAction, ResizeHandlerState, @@ -33,7 +34,7 @@ export type PanelResizeHandleProps = Omit< PropsWithChildren<{ className?: string; disabled?: boolean; - gutter?: number; + hitAreaMargins?: PointerHitAreaMargins; id?: string | null; onDragging?: PanelResizeHandleOnDragging; style?: CSSProperties; @@ -45,7 +46,7 @@ export function PanelResizeHandle({ children = null, className: classNameFromProps = "", disabled = false, - gutter = 5, + hitAreaMargins, id: idFromProps, onDragging, style: styleFromProps = {}, @@ -136,13 +137,18 @@ export function PanelResizeHandle({ resizeHandleId, element, direction, - gutter, + { + // Coarse inputs (e.g. finger/touch) + coarse: hitAreaMargins?.coarse ?? 15, + // Fine inputs (e.g. mouse) + fine: hitAreaMargins?.fine ?? 5, + }, setResizeHandlerState ); }, [ direction, disabled, - gutter, + hitAreaMargins, registerResizeHandleWithParentGroup, resizeHandleId, resizeHandler, diff --git a/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts b/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts index 3be54951b..efe64c4ec 100644 --- a/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts +++ b/packages/react-resizable-panels/src/PanelResizeHandleRegistry.ts @@ -1,6 +1,7 @@ import { Direction, ResizeEvent } from "./types"; import { resetGlobalCursorStyle, setGlobalCursorStyle } from "./utils/cursor"; import { getResizeEventCoordinates } from "./utils/events/getResizeEventCoordinates"; +import { getInputType } from "./utils/getInputType"; export type ResizeHandlerAction = "down" | "move" | "up"; export type ResizeHandlerState = "drag" | "hover" | "inactive"; @@ -10,10 +11,15 @@ export type SetResizeHandlerState = ( event: ResizeEvent ) => void; +export type PointerHitAreaMargins = { + coarse: number; + fine: number; +}; + export type ResizeHandlerData = { direction: Direction; element: HTMLElement; - gutter: number; + hitAreaMargins: PointerHitAreaMargins; setResizeHandlerState: SetResizeHandlerState; }; @@ -22,6 +28,8 @@ export const EXCEEDED_HORIZONTAL_MAX = 0b0010; export const EXCEEDED_VERTICAL_MIN = 0b0100; export const EXCEEDED_VERTICAL_MAX = 0b1000; +const isCoarsePointer = getInputType() === "coarse"; + let intersectingHandles: ResizeHandlerData[] = []; let isPointerDown = false; let ownerDocumentCounts: Map = new Map(); @@ -33,7 +41,7 @@ export function registerResizeHandle( resizeHandleId: string, element: HTMLElement, direction: Direction, - gutter: number, + hitAreaMargins: PointerHitAreaMargins, setResizeHandlerState: SetResizeHandlerState ) { const { ownerDocument } = element; @@ -41,7 +49,7 @@ export function registerResizeHandle( const data: ResizeHandlerData = { direction, element, - gutter, + hitAreaMargins, setResizeHandlerState, }; @@ -122,31 +130,24 @@ function handlePointerUp(event: ResizeEvent) { updateListeners(); } -function intersects({ - data, - x, - y, -}: { - data: ResizeHandlerData; - x: number; - y: number; -}) { - const { element, gutter } = data; - const { bottom, left, right, top } = element.getBoundingClientRect(); - - return ( - x >= left - gutter && - x <= right + gutter && - y >= top - gutter && - y <= bottom + gutter - ); -} - function recalculateIntersectingHandles({ x, y }: { x: number; y: number }) { intersectingHandles.splice(0); registeredResizeHandlers.forEach((data) => { - if (intersects({ data, x, y })) { + const { element, hitAreaMargins } = data; + const { bottom, left, right, top } = element.getBoundingClientRect(); + + const margin = isCoarsePointer + ? hitAreaMargins.coarse + : hitAreaMargins.fine; + + const intersects = + x >= left - margin && + x <= right + margin && + y >= top - margin && + y <= bottom + margin; + + if (intersects) { intersectingHandles.push(data); } }); diff --git a/packages/react-resizable-panels/src/utils/getInputType.ts b/packages/react-resizable-panels/src/utils/getInputType.ts new file mode 100644 index 000000000..4e539fb19 --- /dev/null +++ b/packages/react-resizable-panels/src/utils/getInputType.ts @@ -0,0 +1,5 @@ +export function getInputType(): "coarse" | "fine" | undefined { + if (typeof matchMedia === "function") { + return matchMedia("(pointer:coarse)").matches ? "coarse" : "fine"; + } +}