From 9876053e62b5e87bd2e5fb24282b47d3bbabc9ac Mon Sep 17 00:00:00 2001 From: Munawar Shah <93397215+mwrshah@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:39:02 +0500 Subject: [PATCH 1/3] Add declarative separator resize previews to Group --- README.md | 8 +++ lib/components/group/Group.tsx | 6 ++ lib/components/group/ResizePreview.tsx | 61 +++++++++++++++++++ lib/components/group/types.ts | 11 ++++ lib/components/separator/Separator.tsx | 16 ++++- lib/components/separator/types.ts | 5 +- lib/global/dom/calculateHitRegions.ts | 1 + .../event-handlers/onDocumentContextMenu.ts | 2 +- .../event-handlers/onDocumentPointerDown.ts | 27 ++++++++ .../event-handlers/onDocumentPointerLeave.ts | 1 + .../event-handlers/onDocumentPointerMove.ts | 12 ++++ .../event-handlers/onDocumentPointerUp.ts | 5 +- lib/global/mutable-state/interactions.ts | 10 ++- lib/global/mutable-state/types.ts | 7 +++ lib/global/test/mockGroup.ts | 1 + .../utils/completeActivePointerResize.ts | 24 +++++++- lib/global/utils/updateActiveHitRegion.ts | 29 ++++++++- public/generated/docs/Group.json | 10 +++ public/generated/examples/ResizePreview.json | 3 + public/generated/site-map.json | 8 +-- src/App.tsx | 2 +- src/routes/GroupResizeBehaviorRoute.tsx | 28 ++++++++- src/routes/LayoutBasicsRoute.tsx | 6 ++ src/routes/examples/ResizePreview.tsx | 10 +++ 24 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 lib/components/group/ResizePreview.tsx create mode 100644 public/generated/examples/ResizePreview.json create mode 100644 src/routes/examples/ResizePreview.tsx diff --git a/README.md b/README.md index 1a98f832b..c6e4e61e8 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,14 @@ This method is recommended when saving layouts to some storage api.

The isUserInteraction attribute signals whether the resize was caused by direct user input. It is true for resizes caused by pointer or keyboard input and false for other triggers (e.g. imperative API calls, initial mount, etc.)

+ + + + resizePreviewMode +

Controls whether pointer dragging resizes Panels immediately or only moves +a preview separator element until the pointer is released. +Defaults to "panel" (immediate resizing); "separator" defers resizing until release. +A visible preview requires an explicit Separator.

diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx index aef2a5d64..67fea0a6f 100644 --- a/lib/components/group/Group.tsx +++ b/lib/components/group/Group.tsx @@ -21,6 +21,7 @@ import { useStableObject } from "../../hooks/useStableObject"; import type { RegisteredPanel } from "../panel/types"; import type { RegisteredSeparator } from "../separator/types"; import { GroupContext } from "./GroupContext"; +import { ResizePreview } from "./ResizePreview"; import { sortByElementOffset } from "./sortByElementOffset"; import type { GroupProps, @@ -54,6 +55,7 @@ export function Group({ onLayoutChange: onLayoutChangeUnstable, onLayoutChanged: onLayoutChangedUnstable, orientation = "horizontal", + resizePreviewMode = "panel", resizeTargetMinimumSize = { coarse: 20, fine: 10 @@ -266,6 +268,7 @@ export function Group({ }, orientation, panels: inMemoryValues.panels, + resizePreviewMode, resizeTargetMinimumSize: inMemoryValues.resizeTargetMinimumSize, separators: inMemoryValues.separators }; @@ -343,6 +346,7 @@ export function Group({ onLayoutChangeStable, orientation, panelOrSeparatorChangeSigil, + resizePreviewMode, stableProps ]); @@ -369,6 +373,7 @@ export function Group({ height: "100%", width: "100%", overflow: "hidden", + position: resizePreviewMode === "separator" ? "relative" : undefined, ...style, @@ -384,6 +389,7 @@ export function Group({ }} > {children} + {resizePreviewMode === "separator" && } ); diff --git a/lib/components/group/ResizePreview.tsx b/lib/components/group/ResizePreview.tsx new file mode 100644 index 000000000..ff9774baf --- /dev/null +++ b/lib/components/group/ResizePreview.tsx @@ -0,0 +1,61 @@ +import { useSyncExternalStore } from "react"; +import { + getInteractionState, + subscribeToInteractionState +} from "../../global/mutable-state/interactions"; + +export function ResizePreview({ groupId }: { groupId: string }) { + const read = () => { + const interaction = getInteractionState(); + return interaction.state === "active" && + interaction.preview?.hitRegion.group.id === groupId + ? interaction.preview + : undefined; + }; + const preview = useSyncExternalStore( + subscribeToInteractionState, + read, + () => undefined + ); + if (!preview) { + return null; + } + + const { hitRegion, rect, offset } = preview; + const { group, separator } = hitRegion; + const horizontal = group.orientation === "horizontal"; + const { children, className, style } = separator ?? {}; + + return ( + + ); +} diff --git a/lib/components/group/types.ts b/lib/components/group/types.ts index 580a90573..1b86ddebd 100644 --- a/lib/components/group/types.ts +++ b/lib/components/group/types.ts @@ -38,6 +38,8 @@ export type DragState = { separatorId: string | undefined; }; +export type ResizePreviewMode = "panel" | "separator"; + export type ResizeTargetMinimumSize = { coarse: number; fine: number; @@ -59,6 +61,7 @@ export type RegisteredGroup = Readonly<{ }; orientation: Orientation; panels: RegisteredPanel[]; + resizePreviewMode: ResizePreviewMode; resizeTargetMinimumSize: ResizeTargetMinimumSize; separators: RegisteredSeparator[]; }>; @@ -183,6 +186,14 @@ export type GroupProps = HTMLAttributes & { */ onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void; + /** + * Controls whether pointer dragging resizes Panels immediately or only moves + * a preview separator element until the pointer is released. + * Defaults to `"panel"` (immediate resizing); `"separator"` defers resizing until release. + * A visible preview requires an explicit Separator. + */ + resizePreviewMode?: ResizePreviewMode | undefined; + /** * Minimum size of the resizable hit target area (either `Separator` or `Panel` edge) * This threshold ensures are large enough to avoid mis-clicks. diff --git a/lib/components/separator/Separator.tsx b/lib/components/separator/Separator.tsx index 518b1125b..a2b991c65 100644 --- a/lib/components/separator/Separator.tsx +++ b/lib/components/separator/Separator.tsx @@ -42,7 +42,10 @@ export function Separator({ const stableProps = useStableObject({ disabled, - disableDoubleClick + disableDoubleClick, + children, + className, + style }); const [aria, setAria] = useState<{ @@ -80,7 +83,16 @@ export function Separator({ disabled: stableProps.disabled, disableDoubleClick: stableProps.disableDoubleClick, element, - id + id, + get children() { + return stableProps.children; + }, + get className() { + return stableProps.className; + }, + get style() { + return stableProps.style; + } }; const unregisterSeparator = registerSeparator(separator); diff --git a/lib/components/separator/types.ts b/lib/components/separator/types.ts index 5844b411a..72c054027 100644 --- a/lib/components/separator/types.ts +++ b/lib/components/separator/types.ts @@ -1,6 +1,9 @@ -import type { CSSProperties, HTMLAttributes, Ref } from "react"; +import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from "react"; export type RegisteredSeparator = { + children?: ReactNode; + className?: string | undefined; + style?: CSSProperties | undefined; disabled?: boolean | undefined; disableDoubleClick?: boolean | undefined; element: HTMLDivElement; diff --git a/lib/global/dom/calculateHitRegions.ts b/lib/global/dom/calculateHitRegions.ts index 49dfe067c..628228d68 100644 --- a/lib/global/dom/calculateHitRegions.ts +++ b/lib/global/dom/calculateHitRegions.ts @@ -32,6 +32,7 @@ export function calculateHitRegions(group: RegisteredGroup) { orientation, Array.from(groupElement.children) .filter(isHTMLElement) + .filter((element) => !element.hasAttribute("data-resize-preview")) .map((element) => ({ element: element as HTMLElement })) ).map(({ element }) => element); diff --git a/lib/global/event-handlers/onDocumentContextMenu.ts b/lib/global/event-handlers/onDocumentContextMenu.ts index 5d5dd27a1..31a08d280 100644 --- a/lib/global/event-handlers/onDocumentContextMenu.ts +++ b/lib/global/event-handlers/onDocumentContextMenu.ts @@ -5,5 +5,5 @@ export function onDocumentContextMenu(event: MouseEvent) { return; } - completeActivePointerResize(event.currentTarget as Document); + completeActivePointerResize(event.currentTarget as Document, event); } diff --git a/lib/global/event-handlers/onDocumentPointerDown.ts b/lib/global/event-handlers/onDocumentPointerDown.ts index 7bc92952a..a656fffb7 100644 --- a/lib/global/event-handlers/onDocumentPointerDown.ts +++ b/lib/global/event-handlers/onDocumentPointerDown.ts @@ -15,6 +15,12 @@ export function onDocumentPointerDown(event: PointerEvent) { const hitRegions = findMatchingHitRegions(event, mountedGroups); const initialLayoutMap = new Map(); + const previewHitRegion = + hitRegions.find( + ({ separator }) => + event.target instanceof Node && + separator?.element.contains(event.target) + ) ?? hitRegions[0]; let didChangeFocus = false; @@ -42,11 +48,32 @@ export function onDocumentPointerDown(event: PointerEvent) { } }); + let preview; + if ( + previewHitRegion?.group.resizePreviewMode === "separator" && + previewHitRegion.separator + ) { + const { element } = previewHitRegion.group; + const groupRect = element.getBoundingClientRect(); + const rect = previewHitRegion.separator.element.getBoundingClientRect(); + preview = { + hitRegion: previewHitRegion, + rect: new DOMRect( + rect.left - groupRect.left - element.clientLeft + element.scrollLeft, + rect.top - groupRect.top - element.clientTop + element.scrollTop, + rect.width, + rect.height + ), + offset: 0 + }; + } + updateInteractionState({ cursorFlags: 0, hitRegions, initialLayoutMap, pointerDownAtPoint: { x: event.clientX, y: event.clientY }, + preview, state: "active" }); diff --git a/lib/global/event-handlers/onDocumentPointerLeave.ts b/lib/global/event-handlers/onDocumentPointerLeave.ts index 8a53cddee..30ef85f3b 100644 --- a/lib/global/event-handlers/onDocumentPointerLeave.ts +++ b/lib/global/event-handlers/onDocumentPointerLeave.ts @@ -9,6 +9,7 @@ export function onDocumentPointerLeave(event: PointerEvent) { switch (interactionState.state) { case "active": { updateActiveHitRegions({ + commit: false, document: event.currentTarget as Document, event, hitRegions: interactionState.hitRegions, diff --git a/lib/global/event-handlers/onDocumentPointerMove.ts b/lib/global/event-handlers/onDocumentPointerMove.ts index 41d9b12e9..375d9c386 100644 --- a/lib/global/event-handlers/onDocumentPointerMove.ts +++ b/lib/global/event-handlers/onDocumentPointerMove.ts @@ -27,6 +27,17 @@ export function onDocumentPointerMove(event: PointerEvent) { // Skip this check for "pointerleave" events, else Firefox triggers a false positive (see #514) event.buttons === 0 ) { + updateActiveHitRegions({ + commit: true, + document: event.currentTarget as Document, + event, + hitRegions: interactionState.hitRegions, + initialLayoutMap: interactionState.initialLayoutMap, + mountedGroups, + pointerDownAtPoint: interactionState.pointerDownAtPoint, + prevCursorFlags: interactionState.cursorFlags + }); + updateInteractionState({ cursorFlags: 0, state: "inactive" @@ -64,6 +75,7 @@ export function onDocumentPointerMove(event: PointerEvent) { } updateActiveHitRegions({ + commit: false, document: event.currentTarget as Document, event, hitRegions: interactionState.hitRegions, diff --git a/lib/global/event-handlers/onDocumentPointerUp.ts b/lib/global/event-handlers/onDocumentPointerUp.ts index 37157984b..999c7b9b6 100644 --- a/lib/global/event-handlers/onDocumentPointerUp.ts +++ b/lib/global/event-handlers/onDocumentPointerUp.ts @@ -7,7 +7,10 @@ export function onDocumentPointerUp(event: PointerEvent) { return; } - const matched = completeActivePointerResize(event.currentTarget as Document); + const matched = completeActivePointerResize( + event.currentTarget as Document, + event + ); if (matched) { event.preventDefault(); } diff --git a/lib/global/mutable-state/interactions.ts b/lib/global/mutable-state/interactions.ts index 4c479c541..a62601939 100644 --- a/lib/global/mutable-state/interactions.ts +++ b/lib/global/mutable-state/interactions.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "../../utils/EventEmitter"; -import type { InteractionState } from "./types"; +import type { InteractionState, ResizePreview } from "./types"; let state: InteractionState = { cursorFlags: 0, @@ -25,11 +25,17 @@ export function subscribeToInteractionState( return eventEmitter.addListener("change", callback); } -export function updateCursorFlags(cursorFlags: number) { +export function updateCursorFlags( + cursorFlags: number, + preview?: ResizePreview +) { const prev = state; const next = { ...state }; next.cursorFlags = cursorFlags; + if (next.state === "active") { + next.preview = preview; + } state = next; diff --git a/lib/global/mutable-state/types.ts b/lib/global/mutable-state/types.ts index 034f27c74..a8f2ef6f5 100644 --- a/lib/global/mutable-state/types.ts +++ b/lib/global/mutable-state/types.ts @@ -15,11 +15,18 @@ export type InteractionHover = { state: "hover"; }; +export type ResizePreview = { + hitRegion: HitRegion; + rect: DOMRect; + offset: number; +}; + export type InteractionActive = { cursorFlags: number; hitRegions: HitRegion[]; initialLayoutMap: Map; pointerDownAtPoint: Point; + preview: ResizePreview | undefined; state: "active"; }; diff --git a/lib/global/test/mockGroup.ts b/lib/global/test/mockGroup.ts index 9f66fb0bc..27ff68fe8 100644 --- a/lib/global/test/mockGroup.ts +++ b/lib/global/test/mockGroup.ts @@ -62,6 +62,7 @@ export function mockGroup( layouts: {} }, orientation: "horizontal" as Orientation, + resizePreviewMode: "panel" as const, resizeTargetMinimumSize: { coarse: 20, fine: 10 diff --git a/lib/global/utils/completeActivePointerResize.ts b/lib/global/utils/completeActivePointerResize.ts index 38bde1e06..db6b067cc 100644 --- a/lib/global/utils/completeActivePointerResize.ts +++ b/lib/global/utils/completeActivePointerResize.ts @@ -1,4 +1,5 @@ import { updateCursorStyle } from "../cursor/updateCursorStyle.ts"; +import { updateActiveHitRegions } from "./updateActiveHitRegion"; import { getMountedGroups, getMountedGroupState, @@ -9,7 +10,15 @@ import { updateInteractionState } from "../mutable-state/interactions.ts"; -export function completeActivePointerResize(document: Document) { +export function completeActivePointerResize( + document: Document, + event?: { + clientX: number; + clientY: number; + movementX: number; + movementY: number; + } +) { const interactionState = getInteractionState(); const mountedGroups = getMountedGroups(); @@ -17,6 +26,19 @@ export function completeActivePointerResize(document: Document) { switch (interactionState.state) { case "active": { + if (event) { + updateActiveHitRegions({ + commit: true, + document, + event, + hitRegions: interactionState.hitRegions, + initialLayoutMap: interactionState.initialLayoutMap, + mountedGroups, + pointerDownAtPoint: interactionState.pointerDownAtPoint, + prevCursorFlags: interactionState.cursorFlags + }); + } + updateInteractionState({ cursorFlags: 0, state: "inactive" diff --git a/lib/global/utils/updateActiveHitRegion.ts b/lib/global/utils/updateActiveHitRegion.ts index dcc2ca5a0..4ccfef4ec 100644 --- a/lib/global/utils/updateActiveHitRegion.ts +++ b/lib/global/utils/updateActiveHitRegion.ts @@ -14,11 +14,15 @@ import { updateMountedGroup, type MountedGroups } from "../mutable-state/groups"; -import { updateCursorFlags } from "../mutable-state/interactions"; +import { + getInteractionState, + updateCursorFlags +} from "../mutable-state/interactions"; import { adjustLayoutByDelta } from "./adjustLayoutByDelta"; import { layoutsEqual } from "./layoutsEqual"; export function updateActiveHitRegions({ + commit, document, event, hitRegions, @@ -27,6 +31,7 @@ export function updateActiveHitRegions({ pointerDownAtPoint, prevCursorFlags }: { + commit: boolean; document: Document; event: { clientX: number; @@ -41,12 +46,18 @@ export function updateActiveHitRegions({ prevCursorFlags: number; }) { let nextCursorFlags = 0; + const interaction = getInteractionState(); + let preview = + interaction.state === "active" ? interaction.preview : undefined; // Note that HitRegions are frozen once a drag has started // Modify the Group layouts for all matching HitRegions though hitRegions.forEach((current) => { const { group, groupSize } = current; const { orientation, panels } = group; + if (commit && group.resizePreviewMode !== "separator") { + return; + } const { disableCursor } = group.mutableState; let deltaAsPercentage = 0; @@ -89,7 +100,19 @@ export function updateActiveHitRegions({ trigger: "mouse-or-touch" }); - if (layoutsEqual(nextLayout, prevLayout)) { + // The preview implementation hinges on this block: consume resizePreviewMode and use commit to defer the Group layout update until the pointer is released. + if (group.resizePreviewMode === "separator" && !commit) { + const pivotIndex = panels.indexOf(current.panels[0]); + const offset = + panels.slice(0, pivotIndex + 1).reduce((total, panel) => { + return total + nextLayout[panel.id] - initialLayout[panel.id]; + }, 0) * + (groupSize / 100); + + if (preview?.hitRegion === current) { + preview = { ...preview, offset }; + } + } else if (layoutsEqual(nextLayout, prevLayout)) { if (deltaAsPercentage !== 0 && !disableCursor) { // An unchanged means the cursor has exceeded the allowed bounds switch (orientation) { @@ -136,6 +159,6 @@ export function updateActiveHitRegions({ cursorFlags |= nextCursorFlags & CURSOR_FLAGS_VERTICAL; } - updateCursorFlags(cursorFlags); + updateCursorFlags(cursorFlags, preview); updateCursorStyle(document); } diff --git a/public/generated/docs/Group.json b/public/generated/docs/Group.json index a024db92a..b28f63517 100644 --- a/public/generated/docs/Group.json +++ b/public/generated/docs/Group.json @@ -159,6 +159,16 @@ "name": "onLayoutChanged", "required": false }, + "resizePreviewMode": { + "description": [ + { + "content": "

Controls whether pointer dragging resizes Panels immediately or only moves\na preview separator element until the pointer is released.\nDefaults to "panel" (immediate resizing); "separator" defers resizing until release.\nA visible preview requires an explicit Separator.

\n" + } + ], + "html": "
resizePreviewMode?: ResizePreviewMode = \"panel\"
", + "name": "resizePreviewMode", + "required": false + }, "resizeTargetMinimumSize": { "description": [ { diff --git a/public/generated/examples/ResizePreview.json b/public/generated/examples/ResizePreview.json new file mode 100644 index 000000000..9a52c1e9b --- /dev/null +++ b/public/generated/examples/ResizePreview.json @@ -0,0 +1,3 @@ +{ + "html": "
<Group resizePreviewMode=\"separator\">
\n
<Panel defaultSize=\"50%\" minSize=\"20%\">left</Panel>
\n
<Separator />
\n
<Panel minSize=\"20%\">right</Panel>
\n
</Group>
" +} \ No newline at end of file diff --git a/public/generated/site-map.json b/public/generated/site-map.json index 041f47e29..6112e127b 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -12,7 +12,7 @@ { "path": "/examples/the-basics", "section": "Examples", - "text": " The simplest resizable panel configuration is two panels within a group. import { Group, Panel } from \"react-resizable-panels\";\n \n\n left\n right\n Panel groups use a flexbox layout with a default orientation of horizontal but the orientation prop can be used to specify a vertical layout. \n top\n bottom\n Vertical groups may benefit from an explicit height or min-height (read more). Panels can be resized by clicking on their borders but explicit separators can be rendered to improve UX. Separators provide another benefit: double-clicking on one resets a panel to its default size. \n left\n \n right\n Separators improve keyboard accessibility by providing a tab-focusable window splitter element. ", + "text": " The simplest resizable panel configuration is two panels within a group. import { Group, Panel } from \"react-resizable-panels\";\n \n\n left\n right\n Panel groups use a flexbox layout with a default orientation of horizontal but the orientation prop can be used to specify a vertical layout. \n top\n bottom\n Vertical groups may benefit from an explicit height or min-height (read more). Panels can be resized by clicking on their borders but explicit separators can be rendered to improve UX. Separators provide another benefit: double-clicking on one resets a panel to its default size. \n left\n \n right\n For panels that are expensive to resize, you can use to defer panel resizing until release. See Resize behaviors. Separators improve keyboard accessibility by providing a tab-focusable window splitter element. ", "title": "The basics" }, { @@ -78,8 +78,8 @@ { "path": "/examples/group-resize-behavior", "section": "Examples", - "text": " Resizing a group typically affects the size of panels within the group. The groupResizeBehavior prop can be used override this behavior and freeze specific panels (in terms of their pixels sizes) while the group is resized. For an example of this, resize the browser window while keeping an eye on the left panel below. \n \n left\n \n \n main\n Minor pixel changes in the panel above are due to precision/rounding. Groups are required to contain at least one panel without groupResizeBehavior=\"preserve-pixel-size\". ", - "title": "Group resize behavior" + "text": " Separator preview Set resizePreviewMode=\"separator\" to preview a resize without changing panel sizes until release. The preview respects panel size constraints and reuses the clicked Separator’s class, inline styles, and children. The default, \"panel\", resizes panels while dragging. \n left\n \n right\n Render an explicit Separator for a visible preview. The preview is rendered inside the Group and reuses the Separator’s presentation. Panel-edge drags without a Separator still defer resizing until release, but do not show a preview. Group resize behavior Resizing a group typically affects the size of panels within the group. The groupResizeBehavior prop can be used override this behavior and freeze specific panels (in terms of their pixels sizes) while the group is resized. For an example of this, resize the browser window while keeping an eye on the left panel below. \n \n left\n \n \n main\n Minor pixel changes in the panel above are due to precision/rounding. Groups are required to contain at least one panel without groupResizeBehavior=\"preserve-pixel-size\". ", + "title": "Resize behaviors" }, { "path": "/examples/overflow", @@ -96,7 +96,7 @@ { "path": "/props/group", "section": "Props", - "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument contains meta information about the layout change.\nThe isUserInteraction attribute signals whether the resize was caused by direct user input.\nIt is true for resizes caused by pointer or keyboard input\nand false for other triggers (e.g. imperative API calls, initial mount, etc.)\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ", + "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument contains meta information about the layout change.\nThe isUserInteraction attribute signals whether the resize was caused by direct user input.\nIt is true for resizes caused by pointer or keyboard input\nand false for other triggers (e.g. imperative API calls, initial mount, etc.)\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizePreviewMode?: ResizePreviewMode = \"panel\" Controls whether pointer dragging resizes Panels immediately or only moves\na preview separator element until the pointer is released.\nDefaults to \"panel\" (immediate resizing); \"separator\" defers resizing until release.\nA visible preview requires an explicit Separator.\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ", "title": "Group component props" }, { diff --git a/src/App.tsx b/src/App.tsx index a37ca5122..8abb9c910 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,7 +55,7 @@ export default function App() { Disabled panels - Group resize behavior + Resize behaviors Overflow diff --git a/src/routes/GroupResizeBehaviorRoute.tsx b/src/routes/GroupResizeBehaviorRoute.tsx index 150b8f07e..7345110e7 100644 --- a/src/routes/GroupResizeBehaviorRoute.tsx +++ b/src/routes/GroupResizeBehaviorRoute.tsx @@ -1,5 +1,6 @@ import { Box, Callout, Code, Header } from "react-lib-tools"; import { html as ExampleHTML } from "../../public/generated/examples/GroupResizeBehavior.json"; +import { html as PreviewHTML } from "../../public/generated/examples/ResizePreview.json"; import { Group } from "../components/styled-panels/Group"; import { Panel } from "../components/styled-panels/Panel"; import { Separator } from "../components/styled-panels/Separator"; @@ -7,7 +8,32 @@ import { Separator } from "../components/styled-panels/Separator"; export default function GroupResizeBehaviorRoute() { return ( -
+
+

Separator preview

+
+ Set resizePreviewMode="separator" to preview a resize + without changing panel sizes until release. The preview respects panel + size constraints and reuses the clicked Separator’s class, inline + styles, and children. The default, "panel", resizes panels + while dragging. +
+ + + + left + + + + right + + + + Render an explicit Separator for a visible preview. The + preview is rendered inside the Group and reuses the Separator’s + presentation. Panel-edge drags without a Separator still defer resizing + until release, but do not show a preview. + +

Group resize behavior

Resizing a group typically affects the size of panels within the group. The groupResizeBehavior prop can be used override this diff --git a/src/routes/LayoutBasicsRoute.tsx b/src/routes/LayoutBasicsRoute.tsx index 8081e416e..582484696 100644 --- a/src/routes/LayoutBasicsRoute.tsx +++ b/src/routes/LayoutBasicsRoute.tsx @@ -51,6 +51,12 @@ export default function LayoutBasicsRoute() { right +
+ For panels that are expensive to resize, you can use{" "} + {''} to defer panel + resizing until release. See{" "} + Resize behaviors. +
Separators improve keyboard accessibility by providing a tab-focusable{" "} diff --git a/src/routes/examples/ResizePreview.tsx b/src/routes/examples/ResizePreview.tsx new file mode 100644 index 000000000..9a3726666 --- /dev/null +++ b/src/routes/examples/ResizePreview.tsx @@ -0,0 +1,10 @@ +import { Group, Panel, Separator } from "react-resizable-panels"; + +// + +/* prettier-ignore */ + + left + + right + From a586a6033ff987f9ef92c311e140edde6e0061ab Mon Sep 17 00:00:00 2001 From: Munawar Shah <93397215+mwrshah@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:12:03 +0500 Subject: [PATCH 2/3] Require event when completing pointer resize --- .../utils/completeActivePointerResize.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/global/utils/completeActivePointerResize.ts b/lib/global/utils/completeActivePointerResize.ts index db6b067cc..efb3bb56c 100644 --- a/lib/global/utils/completeActivePointerResize.ts +++ b/lib/global/utils/completeActivePointerResize.ts @@ -12,7 +12,7 @@ import { export function completeActivePointerResize( document: Document, - event?: { + event: { clientX: number; clientY: number; movementX: number; @@ -26,18 +26,16 @@ export function completeActivePointerResize( switch (interactionState.state) { case "active": { - if (event) { - updateActiveHitRegions({ - commit: true, - document, - event, - hitRegions: interactionState.hitRegions, - initialLayoutMap: interactionState.initialLayoutMap, - mountedGroups, - pointerDownAtPoint: interactionState.pointerDownAtPoint, - prevCursorFlags: interactionState.cursorFlags - }); - } + updateActiveHitRegions({ + commit: true, + document, + event, + hitRegions: interactionState.hitRegions, + initialLayoutMap: interactionState.initialLayoutMap, + mountedGroups, + pointerDownAtPoint: interactionState.pointerDownAtPoint, + prevCursorFlags: interactionState.cursorFlags + }); updateInteractionState({ cursorFlags: 0, From 820f2d719cf67ea8938810a35903b91c805f0b9d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 20 Sep 2026 12:21:27 -0400 Subject: [PATCH 3/3] Iterate on API and docs --- README.md | 13 +- lib/components/group/Group.tsx | 36 +- lib/components/group/ResizePreview.test.tsx | 484 ++++++++++++++++++ lib/components/group/ResizePreview.tsx | 87 ++-- lib/components/group/types.ts | 10 +- lib/components/group/useResizePreviews.ts | 66 +++ lib/components/separator/Separator.tsx | 22 +- lib/components/separator/SeparatorClone.tsx | 67 +++ lib/components/separator/SeparatorOverlay.tsx | 37 ++ .../separator/SeparatorOverlayRenderer.tsx | 45 ++ lib/components/separator/types.ts | 10 +- lib/global/dom/calculateHitRegions.test.ts | 2 +- lib/global/dom/calculateHitRegions.ts | 22 +- .../event-handlers/onDocumentPointerDown.ts | 35 +- .../event-handlers/onDocumentPointerMove.ts | 23 +- lib/global/mountGroup.ts | 7 +- lib/global/mutable-state/interactions.test.ts | 64 +++ lib/global/mutable-state/interactions.ts | 74 ++- lib/global/mutable-state/types.ts | 11 +- .../utils/calculateResizePreviews.test.ts | 62 +++ lib/global/utils/calculateResizePreviews.ts | 73 +++ lib/global/utils/findMatchingHitRegions.ts | 2 +- .../utils/updateActiveHitRegion.test.ts | 204 ++++++++ lib/global/utils/updateActiveHitRegion.ts | 61 ++- lib/index.ts | 8 +- public/generated/docs/Group.json | 8 +- public/generated/docs/Separator.json | 10 + public/generated/examples/ResizePreview.json | 3 - .../generated/examples/ResizePreviewMode.json | 3 + .../ResizePreviewWithImplicitSeparator.json | 3 + .../ResizePreviewWithSeparatorOverlay.json | 3 + public/generated/site-map.json | 16 +- src/App.tsx | 5 +- src/routes.ts | 3 + src/routes/GroupResizeBehaviorRoute.tsx | 28 +- src/routes/LayoutBasicsRoute.tsx | 6 - src/routes/PanelResizeBehaviorRoute.tsx | 79 +++ ...esizePreview.tsx => ResizePreviewMode.tsx} | 4 +- .../ResizePreviewWithImplicitSeparator.tsx | 15 + .../ResizePreviewWithSeparatorOverlay.tsx | 21 + 40 files changed, 1562 insertions(+), 170 deletions(-) create mode 100644 lib/components/group/ResizePreview.test.tsx create mode 100644 lib/components/group/useResizePreviews.ts create mode 100644 lib/components/separator/SeparatorClone.tsx create mode 100644 lib/components/separator/SeparatorOverlay.tsx create mode 100644 lib/components/separator/SeparatorOverlayRenderer.tsx create mode 100644 lib/global/mutable-state/interactions.test.ts create mode 100644 lib/global/utils/calculateResizePreviews.test.ts create mode 100644 lib/global/utils/calculateResizePreviews.ts create mode 100644 lib/global/utils/updateActiveHitRegion.test.ts delete mode 100644 public/generated/examples/ResizePreview.json create mode 100644 public/generated/examples/ResizePreviewMode.json create mode 100644 public/generated/examples/ResizePreviewWithImplicitSeparator.json create mode 100644 public/generated/examples/ResizePreviewWithSeparatorOverlay.json create mode 100644 src/routes/PanelResizeBehaviorRoute.tsx rename src/routes/examples/{ResizePreview.tsx => ResizePreviewMode.tsx} (64%) create mode 100644 src/routes/examples/ResizePreviewWithImplicitSeparator.tsx create mode 100644 src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx diff --git a/README.md b/README.md index c6e4e61e8..0d9c98ab0 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,10 @@ and false for other triggers (e.g. imperative API calls, initial mount, etc.)

resizePreviewMode -

Controls whether pointer dragging resizes Panels immediately or only moves -a preview separator element until the pointer is released. -Defaults to "panel" (immediate resizing); "separator" defers resizing until release. -A visible preview requires an explicit Separator.

+

Controls whether pointer dragging updates Panels sizes immediately, +or renders overlay separator previews until the pointer is released.

+

Defaults to "panel" (immediate resizing); "separator" defers resizing until release.

+

Customize previews using the SeparatorOverlay component.

@@ -400,6 +400,11 @@ To prevent a panel from being resized at all, it needs to also be disabled.

elementRef

Ref attached to the root HTMLDivElement.

+ + + + preview +

Overrides the Group default preview for this Separator when resizePreviewMode is "separator".

diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx index 67fea0a6f..6831c83c4 100644 --- a/lib/components/group/Group.tsx +++ b/lib/components/group/Group.tsx @@ -1,6 +1,12 @@ "use client"; -import { useEffect, useMemo, useRef, type CSSProperties } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties +} from "react"; import { calculatePanelConstraints } from "../../global/dom/calculatePanelConstraints"; import { mountGroup } from "../../global/mountGroup"; import { @@ -19,7 +25,10 @@ import { useMergedRefs } from "../../hooks/useMergedRefs"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useStableObject } from "../../hooks/useStableObject"; import type { RegisteredPanel } from "../panel/types"; -import type { RegisteredSeparator } from "../separator/types"; +import type { + RegisteredSeparator, + SeparatorOverlayProps +} from "../separator/types"; import { GroupContext } from "./GroupContext"; import { ResizePreview } from "./ResizePreview"; import { sortByElementOffset } from "./sortByElementOffset"; @@ -30,6 +39,7 @@ import type { ResizeTargetMinimumSize } from "./types"; import { useGroupImperativeHandle } from "./useGroupImperativeHandle"; +import { useResizePreviews } from "./useResizePreviews"; /** * A Group wraps a set of resizable Panel components. @@ -95,6 +105,13 @@ export function Group({ const id = useId(idProp); + const [overlay, setOverlay] = useState(); + + const previews = useResizePreviews({ + groupId: id, + resizePreviewMode + }); + const elementRef = useRef(null); const [panelOrSeparatorChangeSigil, forceUpdate] = useForceUpdate(); @@ -166,6 +183,13 @@ export function Group({ forceUpdate(); }; }, + registerOverlay: (props: SeparatorOverlayProps) => { + setOverlay(props); + + return () => { + setOverlay(undefined); + }; + }, registerSeparator: (separator: RegisteredSeparator) => { const inMemoryValues = inMemoryValuesRef.current; inMemoryValues.separators = sortByElementOffset(orientation, [ @@ -389,7 +413,13 @@ export function Group({ }} > {children} - {resizePreviewMode === "separator" && } + {previews.map((preview) => ( + + ))}
); diff --git a/lib/components/group/ResizePreview.test.tsx b/lib/components/group/ResizePreview.test.tsx new file mode 100644 index 000000000..cd5daa3b6 --- /dev/null +++ b/lib/components/group/ResizePreview.test.tsx @@ -0,0 +1,484 @@ +import { act, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRef, Profiler } from "react"; +import { createPortal } from "react-dom"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getRegisteredGroup } from "../../global/mutable-state/groups"; +import { + getInteractionState, + updateInteractionState +} from "../../global/mutable-state/interactions"; +import type { InteractionActive } from "../../global/mutable-state/types"; +import { mockGroup } from "../../global/test/mockGroup"; +import { setElementBoundsFunction } from "../../utils/test/mockBoundingClientRect"; +import { Panel } from "../panel/Panel"; +import { Separator } from "../separator/Separator"; +import { SeparatorOverlay } from "../separator/SeparatorOverlay"; +import { Group } from "./Group"; +import { ResizePreview } from "./ResizePreview"; +import type { GroupImperativeHandle } from "./types"; + +describe("separator previews", () => { + afterEach(() => + updateInteractionState({ state: "inactive", cursorFlags: 0 }) + ); + + describe("preview lifecycle", () => { + function setBounds() { + setElementBoundsFunction((element) => { + switch (element.id) { + case "group": { + return new DOMRect(0, 0, 210, 100); + } + case "a": { + return new DOMRect(0, 0, 100, 100); + } + case "separator": { + return new DOMRect(100, 0, 10, 100); + } + case "b": { + return new DOMRect(110, 0, 100, 100); + } + } + }); + } + + function ui(label: string, key = "group", disabled = false) { + return ( + + default + + {label} : undefined + } + /> + + + ); + } + + test("updates and removes a custom preview without moving the pointer", async () => { + setBounds(); + const { container, rerender } = render(ui("old")); + const user = userEvent.setup(); + + await user.pointer({ + keys: "[MouseLeft>]", + coords: { clientX: 105, clientY: 50 } + }); + const group = getRegisteredGroup("group", true); + expect( + container.querySelector("[data-resize-preview]") + ).toHaveTextContent("old"); + + rerender(ui("new")); + expect( + container.querySelector("[data-resize-preview]") + ).toHaveTextContent("new"); + expect(getRegisteredGroup("group", true)).toBe(group); + expect(getInteractionState().state).toBe("active"); + + rerender(ui("")); + expect( + container.querySelector("[data-resize-preview]") + ).toHaveTextContent("default"); + await user.pointer({ keys: "[/MouseLeft]" }); + }); + + for (const replacement of ["remount", "disable"] as const) { + test(`clears previews on group ${replacement}`, async () => { + setBounds(); + const { container, rerender } = render(ui("old")); + const user = userEvent.setup(); + + await user.pointer({ + keys: "[MouseLeft>]", + coords: { clientX: 105, clientY: 50 } + }); + expect( + container.querySelector("[data-resize-preview]") + ).toHaveTextContent("old"); + + rerender( + ui( + "new", + replacement === "remount" ? "new" : "group", + replacement === "disable" + ) + ); + expect(container.querySelector("[data-resize-preview]")).toBeNull(); + expect(getInteractionState().state).toBe("inactive"); + + await user.pointer({ keys: "[/MouseLeft]" }); + }); + } + }); + + test("copies computed HTML and SVG styles inside an iframe", () => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + + const ownerDocument = iframe.contentDocument!; + const ownerWindow = ownerDocument.defaultView!; + ownerDocument.body.innerHTML = ` + +
+ `; + const element = ownerDocument.querySelector(".handle")!; + const group = mockGroup(new DOMRect(0, 0, 200, 100)); + const { unmount } = render( + createPortal( + , + ownerDocument.body + ) + ); + + try { + const clone = ownerDocument.querySelector( + "[data-resize-preview] .handle" + )!; + expect(ownerWindow.getComputedStyle(clone).backgroundColor).toBe( + "rgb(255, 0, 0)" + ); + expect( + ownerWindow.getComputedStyle(clone.querySelector("svg")!).fill + ).toBe("rgb(0, 0, 255)"); + } finally { + unmount(); + iframe.remove(); + } + }); + + test("only renders when this group's visible previews change", () => { + const onRender = vi.fn(); + + const ui = (resizePreviewMode: "panel" | "separator" = "separator") => ( + + + + ); + + const { container, rerender, unmount } = render(ui()); + const group = getRegisteredGroup("group", true); + + const preview = { + group, + panelIndex: 0, + key: "active", + active: true, + rect: new DOMRect(50, 0, 0, 100), + offset: 0 + }; + const indirect = { + ...preview, + key: "indirect", + active: false + }; + const unrelated = { + ...preview, + group: { + ...group, + id: "other" + } + }; + + let interaction: InteractionActive = { + state: "active", + cursorFlags: 0, + hitRegions: [], + initialLayoutMap: new Map(), + previewLayoutMap: new Map(), + pointerDownAtPoint: { + x: 0, + y: 0 + }, + previews: [preview, indirect, unrelated] + }; + + act(() => updateInteractionState(interaction)); + expect(container.querySelectorAll("[data-resize-preview]")).toHaveLength(1); + + onRender.mockClear(); + + interaction = { + ...interaction, + cursorFlags: 1, + previews: [preview, { ...indirect }, { ...unrelated, offset: 10 }] + }; + + act(() => updateInteractionState(interaction)); + expect(onRender).not.toHaveBeenCalled(); + + interaction = { + ...interaction, + previews: [preview, { ...indirect, offset: 20 }] + }; + + act(() => updateInteractionState(interaction)); + expect(container.querySelectorAll("[data-resize-preview]")).toHaveLength(2); + + rerender(ui("panel")); + expect(container.querySelectorAll("[data-resize-preview]")).toHaveLength(0); + + onRender.mockClear(); + + act(() => updateInteractionState({ ...interaction, cursorFlags: 0 })); + expect(onRender).not.toHaveBeenCalled(); + + // Switching modes replaces the registration; stale snapshots must not return. + rerender(ui()); + expect(container.querySelectorAll("[data-resize-preview]")).toHaveLength(0); + + act(() => updateInteractionState({ state: "inactive", cursorFlags: 0 })); + expect(container.querySelectorAll("[data-resize-preview]")).toHaveLength(0); + + onRender.mockClear(); + + act(() => + updateInteractionState({ state: "hover", cursorFlags: 0, hitRegions: [] }) + ); + expect(onRender).not.toHaveBeenCalled(); + + unmount(); + }); + + for (const orientation of ["horizontal", "vertical"] as const) { + for (const explicit of [true, false]) { + test(`${orientation}, ${explicit ? "explicit" : "implicit"} separators preview all moved boundaries and commit on release`, async () => { + const horizontal = orientation === "horizontal"; + const bounds = (start: number, size: number) => + horizontal + ? new DOMRect(start, 0, size, 100) + : new DOMRect(0, start, 100, size); + + setElementBoundsFunction((element) => { + switch (element.id) { + case "group": + return bounds(0, explicit ? 320 : 300); + case "a": + return bounds(0, 100); + case "ab": + return bounds(100, 10); + case "b": + return bounds(explicit ? 110 : 100, 100); + case "bc": + return bounds(210, 10); + case "c": + return bounds(explicit ? 220 : 200, 100); + } + }); + + const groupRef = createRef(); + + const { container } = render( + + + {explicit && ( + + handle + + )} + + {explicit && } + + {!explicit && ( + + )} + + ); + + const initial = groupRef.current!.getLayout(); + const coords = (position: number) => + horizontal + ? { clientX: position, clientY: 50 } + : { clientX: 50, clientY: position }; + const start = explicit ? 105 : 100; + const user = userEvent.setup(); + + await user.pointer({ keys: "[MouseLeft>]", coords: coords(start) }); + expect( + container.querySelectorAll("[data-resize-preview]") + ).toHaveLength(1); + + await user.pointer({ coords: coords(start + 50) }); + + const previews = container.querySelectorAll( + "[data-resize-preview]" + ); + expect(previews).toHaveLength(2); + + const axis = horizontal ? "X" : "Y"; + expect( + parseFloat(previews[0].style.transform.split("(")[1]) + ).toBeCloseTo(50); + expect(previews[0].style.transform).toContain(`translate${axis}`); + expect( + parseFloat(previews[1].style.transform.split("(")[1]) + ).toBeCloseTo(30); + expect(groupRef.current!.getLayout()).toEqual(initial); + + if (explicit) { + expect(previews[0].textContent).toBe("handle"); + expect(previews[0].firstElementChild).toHaveStyle({ + opacity: "0.65" + }); + expect(container.querySelectorAll("#ab")).toHaveLength(1); + expect(previews[0].querySelector("[data-separator]")).toHaveStyle({ + backgroundColor: "rgb(255, 0, 0)" + }); + expect(previews[0]).toHaveAttribute("inert"); + expect(previews[0]).toHaveAttribute("aria-hidden", "true"); + } else { + expect(previews[0].firstElementChild).toHaveAttribute( + "data-separator-overlay", + "active" + ); + expect(previews[1].firstElementChild).toHaveAttribute( + "data-separator-overlay", + "inactive" + ); + expect(previews[0].firstElementChild).toHaveStyle({ + backgroundColor: "rgb(0, 0, 255)", + [horizontal ? "width" : "height"]: "1rem" + }); + } + + await user.pointer({ coords: coords(start + 10) }); + expect( + container.querySelectorAll("[data-resize-preview]") + ).toHaveLength(1); + + await user.pointer({ + keys: "[/MouseLeft]", + coords: coords(start + 50) + }); + expect( + container.querySelectorAll("[data-resize-preview]") + ).toHaveLength(0); + expect(groupRef.current!.getLayout().a).toBeCloseTo(50); + }); + } + } + + test("per-separator overlays override the group default and update during a drag", async () => { + setElementBoundsFunction((element) => { + switch (element.id) { + case "group": + return new DOMRect(0, 0, 320, 100); + case "a": + return new DOMRect(0, 0, 100, 100); + case "ab": + return new DOMRect(100, 0, 10, 100); + case "b": + return new DOMRect(110, 0, 100, 100); + case "bc": + return new DOMRect(210, 0, 10, 100); + case "c": + return new DOMRect(220, 0, 100, 100); + } + }); + + const ui = (label: string, show = true) => ( + + {show && ( + + {label} + + )} + + + custom + + } + /> + + + + + ); + + const { container, rerender } = render(ui("default")); + expect(container.querySelector(".default-overlay")).toBeNull(); + + const user = userEvent.setup(); + + await user.pointer([ + { + keys: "[MouseLeft>]", + coords: { + clientX: 105, + clientY: 50 + } + }, + { + coords: { + clientX: 155, + clientY: 50 + } + } + ]); + expect(container.querySelector(".custom-overlay")).toHaveTextContent( + "custom" + ); + expect(container.querySelector(".default-overlay")).toHaveTextContent( + "default" + ); + expect(container.querySelector(".custom-overlay")).toHaveAttribute( + "data-separator-overlay", + "active" + ); + expect(container.querySelector(".default-overlay")).toHaveAttribute( + "data-separator-overlay", + "inactive" + ); + + rerender(ui("updated")); + expect(container.querySelector(".default-overlay")).toHaveTextContent( + "updated" + ); + + rerender(ui("updated", false)); + expect(container.querySelector(".default-overlay")).toBeNull(); + expect(container.querySelectorAll("[data-resize-preview]")).toHaveLength(2); + expect( + container.querySelectorAll( + '[data-resize-preview] [data-separator="disabled"]' + ) + ).toHaveLength(1); + + await user.pointer({ keys: "[/MouseLeft]" }); + }); +}); diff --git a/lib/components/group/ResizePreview.tsx b/lib/components/group/ResizePreview.tsx index ff9774baf..9572c547c 100644 --- a/lib/components/group/ResizePreview.tsx +++ b/lib/components/group/ResizePreview.tsx @@ -1,61 +1,64 @@ -import { useSyncExternalStore } from "react"; -import { - getInteractionState, - subscribeToInteractionState -} from "../../global/mutable-state/interactions"; +import { isValidElement } from "react"; +import type { ResizePreview as ResizePreviewState } from "../../global/mutable-state/types"; +import { SeparatorClone } from "../separator/SeparatorClone"; +import { SeparatorOverlay } from "../separator/SeparatorOverlay"; +import { SeparatorOverlayRenderer } from "../separator/SeparatorOverlayRenderer"; +import type { SeparatorOverlayProps } from "../separator/types"; -export function ResizePreview({ groupId }: { groupId: string }) { - const read = () => { - const interaction = getInteractionState(); - return interaction.state === "active" && - interaction.preview?.hitRegion.group.id === groupId - ? interaction.preview - : undefined; - }; - const preview = useSyncExternalStore( - subscribeToInteractionState, - read, - () => undefined - ); - if (!preview) { - return null; - } +export function ResizePreview({ + overlay, + preview +}: { + overlay?: SeparatorOverlayProps | undefined; + preview: ResizePreviewState; +}) { + const { group, offset, rect, separator } = preview; - const { hitRegion, rect, offset } = preview; - const { group, separator } = hitRegion; const horizontal = group.orientation === "horizontal"; - const { children, className, style } = separator ?? {}; + + let rendered = separator?.preview; + let overlayProps = overlay; + + if ( + isValidElement(rendered) && + rendered.type === SeparatorOverlay + ) { + overlayProps = rendered.props; + rendered = undefined; + } + + if (rendered == null) { + if (overlayProps) { + rendered = ( + + ); + } else if (separator) { + rendered = ; + } + } return ( ); } diff --git a/lib/components/group/types.ts b/lib/components/group/types.ts index 1b86ddebd..7cf1465f5 100644 --- a/lib/components/group/types.ts +++ b/lib/components/group/types.ts @@ -1,5 +1,6 @@ import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from "react"; import type { RegisteredPanel } from "../panel/types"; +import type { SeparatorOverlayProps } from "../separator/types"; import type { RegisteredSeparator } from "../separator/types"; /** @@ -74,6 +75,7 @@ export type GroupContextType = { ) => CSSProperties | undefined; id: string; orientation: Orientation; + registerOverlay: (props: SeparatorOverlayProps) => () => void; registerPanel: (panel: RegisteredPanel) => () => void; registerSeparator: (separator: RegisteredSeparator) => () => void; updatePanelProps: ( @@ -187,10 +189,12 @@ export type GroupProps = HTMLAttributes & { onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void; /** - * Controls whether pointer dragging resizes Panels immediately or only moves - * a preview separator element until the pointer is released. + * Controls whether pointer dragging updates `Panel`s sizes immediately, + * or renders overlay separator previews until the pointer is released. + * * Defaults to `"panel"` (immediate resizing); `"separator"` defers resizing until release. - * A visible preview requires an explicit Separator. + * + * Customize previews using the `SeparatorOverlay` component. */ resizePreviewMode?: ResizePreviewMode | undefined; diff --git a/lib/components/group/useResizePreviews.ts b/lib/components/group/useResizePreviews.ts new file mode 100644 index 000000000..3d07b7124 --- /dev/null +++ b/lib/components/group/useResizePreviews.ts @@ -0,0 +1,66 @@ +import { useRef, useState } from "react"; +import { getRegisteredGroup } from "../../global/mutable-state/groups"; +import { + getInteractionState, + subscribeToInteractionState +} from "../../global/mutable-state/interactions"; +import type { + InteractionState, + ResizePreview as ResizePreviewState +} from "../../global/mutable-state/types"; +import { layoutNumbersEqual } from "../../global/utils/layoutNumbersEqual"; +import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; +import type { ResizePreviewMode } from "./types"; + +export function useResizePreviews({ + groupId, + resizePreviewMode +}: { + groupId: string; + resizePreviewMode: ResizePreviewMode; +}) { + const [previews, setPreviews] = useState([]); + + const previewsRef = useRef(previews); + + useIsomorphicLayoutEffect(() => { + const updatePreviews = (interaction: InteractionState) => { + const group = getRegisteredGroup(groupId); + const nextPreviews = + resizePreviewMode === "separator" && interaction.state === "active" + ? interaction.previews.filter( + (preview) => + preview.group === group && + (preview.active || !layoutNumbersEqual(preview.offset, 0)) + ) + : []; + const previousPreviews = previewsRef.current; + if ( + previousPreviews.length === nextPreviews.length && + nextPreviews.every( + (preview, index) => preview === previousPreviews[index] + ) + ) { + return; + } + + previewsRef.current = nextPreviews; + setPreviews(nextPreviews); + }; + + if (resizePreviewMode !== "separator") { + updatePreviews(getInteractionState()); + return; + } + + const unsubscribe = subscribeToInteractionState(({ next }) => + updatePreviews(next) + ); + + updatePreviews(getInteractionState()); + + return unsubscribe; + }, [groupId, resizePreviewMode]); + + return previews; +} diff --git a/lib/components/separator/Separator.tsx b/lib/components/separator/Separator.tsx index a2b991c65..9b05587d9 100644 --- a/lib/components/separator/Separator.tsx +++ b/lib/components/separator/Separator.tsx @@ -3,7 +3,10 @@ import type { Properties } from "csstype"; import { useEffect, useRef, useState } from "react"; import { subscribeToMountedGroup } from "../../global/mutable-state/groups"; -import { subscribeToInteractionState } from "../../global/mutable-state/interactions"; +import { + notifySeparatorPreviewChanged, + subscribeToInteractionState +} from "../../global/mutable-state/interactions"; import type { InteractionState } from "../../global/mutable-state/types"; import { calculateSeparatorAriaValues } from "../../global/utils/calculateSeparatorAriaValues"; import { useId } from "../../hooks/useId"; @@ -35,6 +38,7 @@ export function Separator({ disableDoubleClick, elementRef: elementRefProp, id: idProp, + preview, style, ...rest }: SeparatorProps) { @@ -45,6 +49,7 @@ export function Separator({ disableDoubleClick, children, className, + preview, style }); @@ -60,6 +65,7 @@ export function Separator({ const [isFocused, setIsFocused] = useState(false); const elementRef = useRef(null); + const registeredSeparatorRef = useRef(null); const mergedRef = useMergedRefs(elementRef, elementRefProp); @@ -90,11 +96,16 @@ export function Separator({ get className() { return stableProps.className; }, + get preview() { + return stableProps.preview; + }, get style() { return stableProps.style; } }; + registeredSeparatorRef.current = separator; + const unregisterSeparator = registerSeparator(separator); const removeInteractionStateChangeListener = subscribeToInteractionState( @@ -140,6 +151,8 @@ export function Separator({ ); return () => { + registeredSeparatorRef.current = null; + removeInteractionStateChangeListener(); removeMountedGroupsChangeListener(); unregisterSeparator(); @@ -147,6 +160,13 @@ export function Separator({ } }, [groupId, id, registerSeparator, stableProps]); + useIsomorphicLayoutEffect(() => { + const separator = registeredSeparatorRef.current; + if (separator) { + notifySeparatorPreviewChanged(separator); + } + }, [preview]); + // Not all props require re-registering the separator; useEffect(() => { updateSeparatorProps(id, { disabled, disableDoubleClick }); diff --git a/lib/components/separator/SeparatorClone.tsx b/lib/components/separator/SeparatorClone.tsx new file mode 100644 index 000000000..acd571afb --- /dev/null +++ b/lib/components/separator/SeparatorClone.tsx @@ -0,0 +1,67 @@ +import { useRef } from "react"; +import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; +import type { RegisteredSeparator } from "./types"; + +export function SeparatorClone({ + separator +}: { + separator: RegisteredSeparator; +}) { + const { element } = separator; + + const ref = useRef(null); + + useIsomorphicLayoutEffect(() => { + const clone = element.cloneNode(true) as HTMLDivElement; + const sources = [element, ...element.querySelectorAll("*")]; + const targets = [clone, ...clone.querySelectorAll("*")]; + const ownerWindow = element.ownerDocument.defaultView!; + + sources.forEach((source, index) => { + const target = targets[index]; + + if ( + target instanceof ownerWindow.HTMLElement || + target instanceof ownerWindow.SVGElement + ) { + const computed = ownerWindow.getComputedStyle(source); + + for (let index = 0; index < computed.length; index++) { + const property = computed.item(index); + target.style.setProperty( + property, + computed.getPropertyValue(property) + ); + } + } + + target.removeAttribute("data-testid"); + target.removeAttribute("id"); + }); + + Object.assign(clone.style, { + boxSizing: "border-box", + height: "100%", + margin: "0", + position: "static", + transform: "none", + width: "100%" + }); + + ref.current!.appendChild(clone); + + return () => clone.remove(); + }, [element]); + + return ( +
+ ); +} diff --git a/lib/components/separator/SeparatorOverlay.tsx b/lib/components/separator/SeparatorOverlay.tsx new file mode 100644 index 000000000..30627ae78 --- /dev/null +++ b/lib/components/separator/SeparatorOverlay.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRef } from "react"; +import { objectsEqual } from "../../global/utils/objectsEqual"; +import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; +import { useGroupContext } from "../group/useGroupContext"; +import type { SeparatorOverlayProps } from "./types"; + +/** + * Customizes separator previews when resizePreviewMode is "separator". + * Render within a `Group` to style all overlay previews, or pass to an individual `Separator` to style its overlay preview. + * + * Use `data-separator-overlay="active"` to style the separator being dragged, + * or `data-separator-overlay="inactive"` for separators moved indirectly. + * + * Set `width` (horizontal groups) or `height` (vertical groups) using `style` or `className`. + */ +export function SeparatorOverlay(props: SeparatorOverlayProps) { + const { registerOverlay } = useGroupContext(); + + const propsRef = useRef(props); + + if (!objectsEqual(propsRef.current, props)) { + propsRef.current = props; + } + + const stableProps = propsRef.current; + + useIsomorphicLayoutEffect( + () => registerOverlay(stableProps), + [registerOverlay, stableProps] + ); + + return null; +} + +SeparatorOverlay.displayName = "SeparatorOverlay"; diff --git a/lib/components/separator/SeparatorOverlayRenderer.tsx b/lib/components/separator/SeparatorOverlayRenderer.tsx new file mode 100644 index 000000000..a2d39c76e --- /dev/null +++ b/lib/components/separator/SeparatorOverlayRenderer.tsx @@ -0,0 +1,45 @@ +import type { CSSProperties } from "react"; +import type { Orientation } from "../group/types"; +import type { SeparatorOverlayProps } from "./types"; + +/** + * Renders a visible overlay preview + */ +export function SeparatorOverlayRenderer({ + active, + orientation, + style, + ...rest +}: SeparatorOverlayProps & { active: boolean; orientation: Orientation }) { + let styleConstraints: CSSProperties; + + switch (orientation) { + case "horizontal": { + styleConstraints = { + height: "100%", + minWidth: "1px" + }; + break; + } + case "vertical": { + styleConstraints = { + minHeight: "1px", + width: "100%" + }; + break; + } + } + + return ( +
+ ); +} diff --git a/lib/components/separator/types.ts b/lib/components/separator/types.ts index 72c054027..559b81cff 100644 --- a/lib/components/separator/types.ts +++ b/lib/components/separator/types.ts @@ -3,11 +3,12 @@ import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from "react"; export type RegisteredSeparator = { children?: ReactNode; className?: string | undefined; - style?: CSSProperties | undefined; disabled?: boolean | undefined; disableDoubleClick?: boolean | undefined; element: HTMLDivElement; id: string; + preview?: ReactNode; + style?: CSSProperties | undefined; }; type BaseSeparatorAttributes = Omit< @@ -51,6 +52,11 @@ export type SeparatorProps = BaseSeparatorAttributes & { */ id?: string | number | undefined; + /** + * Overrides the `Group` default preview for this `Separator` when `resizePreviewMode` is "separator". + */ + preview?: ReactNode; + /** * CSS properties. * @@ -60,3 +66,5 @@ export type SeparatorProps = BaseSeparatorAttributes & { */ style?: CSSProperties | undefined; }; + +export type SeparatorOverlayProps = HTMLAttributes; diff --git a/lib/global/dom/calculateHitRegions.test.ts b/lib/global/dom/calculateHitRegions.test.ts index 11b2abb8c..85eed14c7 100644 --- a/lib/global/dom/calculateHitRegions.test.ts +++ b/lib/global/dom/calculateHitRegions.test.ts @@ -4,7 +4,7 @@ import { calculateHitRegions } from "./calculateHitRegions"; describe("calculateHitRegions", () => { function serialize(group: MockGroup) { - const hitRegions = calculateHitRegions(group); + const hitRegions = calculateHitRegions({ group }); return JSON.stringify( hitRegions.map((region) => ({ diff --git a/lib/global/dom/calculateHitRegions.ts b/lib/global/dom/calculateHitRegions.ts index 628228d68..f79e74218 100644 --- a/lib/global/dom/calculateHitRegions.ts +++ b/lib/global/dom/calculateHitRegions.ts @@ -24,7 +24,15 @@ export type HitRegion = { * * This method determines bounding rects of all regions for the particular group. */ -export function calculateHitRegions(group: RegisteredGroup) { +export function calculateHitRegions({ + expandHitTargets = true, + group, + includeDisabled = false +}: { + expandHitTargets?: boolean; + group: RegisteredGroup; + includeDisabled?: boolean; +}) { const { element: groupElement, orientation, panels, separators } = group; // Sort elements by offset before traversing @@ -67,7 +75,7 @@ export function calculateHitRegions(group: RegisteredGroup) { } // If all (or all but one) of the Panels are disabled, there can be no resize interactions. - if (numEnabledPanels > 1) { + if (includeDisabled || numEnabledPanels > 1) { let currentPanelIndex = -1; for (const childElement of sortedChildElements) { @@ -165,9 +173,11 @@ export function calculateHitRegions(group: RegisteredGroup) { ? rectOrSeparator : rectOrSeparator.element.getBoundingClientRect(); - const minHitTargetSize = isCoarsePointer() - ? group.resizeTargetMinimumSize.coarse - : group.resizeTargetMinimumSize.fine; + const minHitTargetSize = expandHitTargets + ? isCoarsePointer() + ? group.resizeTargetMinimumSize.coarse + : group.resizeTargetMinimumSize.fine + : 0; if (rect.width < minHitTargetSize) { const delta = minHitTargetSize - rect.width; rect = new DOMRect( @@ -191,7 +201,7 @@ export function calculateHitRegions(group: RegisteredGroup) { currentPanelIndex <= firstEnabledPanelIndex || currentPanelIndex > lastEnabledPanelIndex; - if (!disabledSeparator && !skip) { + if (includeDisabled || (!disabledSeparator && !skip)) { hitRegions.push({ group, groupSize: calculateAvailableGroupSize({ group }), diff --git a/lib/global/event-handlers/onDocumentPointerDown.ts b/lib/global/event-handlers/onDocumentPointerDown.ts index a656fffb7..3ae12b914 100644 --- a/lib/global/event-handlers/onDocumentPointerDown.ts +++ b/lib/global/event-handlers/onDocumentPointerDown.ts @@ -1,3 +1,4 @@ +import { calculateResizePreviews } from "../utils/calculateResizePreviews"; import type { Layout, RegisteredGroup } from "../../components/group/types"; import { getMountedGroups } from "../mutable-state/groups"; import { updateInteractionState } from "../mutable-state/interactions"; @@ -15,13 +16,6 @@ export function onDocumentPointerDown(event: PointerEvent) { const hitRegions = findMatchingHitRegions(event, mountedGroups); const initialLayoutMap = new Map(); - const previewHitRegion = - hitRegions.find( - ({ separator }) => - event.target instanceof Node && - separator?.element.contains(event.target) - ) ?? hitRegions[0]; - let didChangeFocus = false; hitRegions.forEach((current) => { @@ -48,32 +42,19 @@ export function onDocumentPointerDown(event: PointerEvent) { } }); - let preview; - if ( - previewHitRegion?.group.resizePreviewMode === "separator" && - previewHitRegion.separator - ) { - const { element } = previewHitRegion.group; - const groupRect = element.getBoundingClientRect(); - const rect = previewHitRegion.separator.element.getBoundingClientRect(); - preview = { - hitRegion: previewHitRegion, - rect: new DOMRect( - rect.left - groupRect.left - element.clientLeft + element.scrollLeft, - rect.top - groupRect.top - element.clientTop + element.scrollTop, - rect.width, - rect.height - ), - offset: 0 - }; - } + const previews = Array.from(initialLayoutMap.keys()).flatMap((group) => + group.resizePreviewMode === "separator" + ? calculateResizePreviews(group, hitRegions) + : [] + ); updateInteractionState({ cursorFlags: 0, hitRegions, initialLayoutMap, pointerDownAtPoint: { x: event.clientX, y: event.clientY }, - preview, + previewLayoutMap: new Map(initialLayoutMap), + previews, state: "active" }); diff --git a/lib/global/event-handlers/onDocumentPointerMove.ts b/lib/global/event-handlers/onDocumentPointerMove.ts index 375d9c386..c57e8836a 100644 --- a/lib/global/event-handlers/onDocumentPointerMove.ts +++ b/lib/global/event-handlers/onDocumentPointerMove.ts @@ -8,6 +8,7 @@ import { getInteractionState, updateInteractionState } from "../mutable-state/interactions"; +import { layoutsEqual } from "../utils/layoutsEqual"; import { findMatchingHitRegions } from "../utils/findMatchingHitRegions"; import { updateActiveHitRegions } from "../utils/updateActiveHitRegion"; @@ -27,15 +28,17 @@ export function onDocumentPointerMove(event: PointerEvent) { // Skip this check for "pointerleave" events, else Firefox triggers a false positive (see #514) event.buttons === 0 ) { - updateActiveHitRegions({ - commit: true, - document: event.currentTarget as Document, - event, - hitRegions: interactionState.hitRegions, - initialLayoutMap: interactionState.initialLayoutMap, - mountedGroups, - pointerDownAtPoint: interactionState.pointerDownAtPoint, - prevCursorFlags: interactionState.cursorFlags + // This event is a later hover, not the release position. + // Commit the last preview without incorporating movement after the button was released. + interactionState.previewLayoutMap.forEach((layout, group) => { + const groupState = mountedGroups.get(group); + if ( + group.resizePreviewMode === "separator" && + groupState && + !layoutsEqual(layout, groupState.layout) + ) { + updateMountedGroup(group, { ...groupState, layout }); + } }); updateInteractionState({ @@ -59,6 +62,8 @@ export function onDocumentPointerMove(event: PointerEvent) { }); }); + updateCursorStyle(event.currentTarget as Document); + return; } diff --git a/lib/global/mountGroup.ts b/lib/global/mountGroup.ts index c895df233..3439a1027 100644 --- a/lib/global/mountGroup.ts +++ b/lib/global/mountGroup.ts @@ -1,4 +1,6 @@ import type { Layout, RegisteredGroup } from "../components/group/types"; +import { updateCursorStyle } from "./cursor/updateCursorStyle"; +import { removeGroupFromInteraction } from "./mutable-state/interactions"; import { assert } from "../utils/assert"; import { calculateAvailableGroupSize } from "./dom/calculateAvailableGroupSize"; import { calculateHitRegions } from "./dom/calculateHitRegions"; @@ -152,7 +154,7 @@ export function mountGroup(group: RegisteredGroup) { ); const separatorToPanels: SeparatorToPanelsMap = new Map(); - const hitRegions = calculateHitRegions(group); + const hitRegions = calculateHitRegions({ group }); hitRegions.forEach((hitRegion) => { if (hitRegion.separator) { separatorToPanels.set(hitRegion.separator, hitRegion.panels); @@ -198,6 +200,9 @@ export function mountGroup(group: RegisteredGroup) { ); deleteMutableGroup(group); + if (removeGroupFromInteraction(group)) { + updateCursorStyle(ownerDocument); + } group.separators.forEach((separator) => { separator.element.removeEventListener("keydown", onDocumentKeyDown); diff --git a/lib/global/mutable-state/interactions.test.ts b/lib/global/mutable-state/interactions.test.ts new file mode 100644 index 000000000..b39191d9a --- /dev/null +++ b/lib/global/mutable-state/interactions.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, test } from "vitest"; +import { calculateHitRegions } from "../dom/calculateHitRegions"; +import { mockGroup } from "../test/mockGroup"; +import { calculateResizePreviews } from "../utils/calculateResizePreviews"; +import { + getInteractionState, + removeGroupFromInteraction, + updateInteractionState +} from "./interactions"; + +afterEach(() => { + updateInteractionState({ cursorFlags: 0, state: "inactive" }); +}); + +test("removing a group preserves the other group in a shared drag", () => { + const groups = [0, 100].map((top) => { + const group = mockGroup(new DOMRect(0, top, 200, 100)); + group.addPanel(new DOMRect(0, 0, 100, 100)); + group.addPanel(new DOMRect(100, 0, 100, 100)); + return group; + }); + const hitRegions = groups.flatMap((group) => calculateHitRegions({ group })); + const initialLayoutMap = new Map( + groups.map((group) => [ + group, + { + [group.panels[0].id]: 50, + [group.panels[1].id]: 50 + } + ]) + ); + const previews = groups.flatMap((group) => + calculateResizePreviews(group, hitRegions) + ); + + updateInteractionState({ + cursorFlags: 0, + hitRegions, + initialLayoutMap, + pointerDownAtPoint: { x: 100, y: 100 }, + previewLayoutMap: new Map(initialLayoutMap), + previews, + state: "active" + }); + + removeGroupFromInteraction(groups[0]); + + const interaction = getInteractionState(); + expect(interaction.state).toBe("active"); + if (interaction.state !== "active") { + throw Error("Expected active interaction"); + } + expect(interaction.hitRegions).toEqual([hitRegions[1]]); + expect(interaction.previews).toEqual([previews[1]]); + expect(interaction.previews[0]).toBe(previews[1]); + expect(interaction.initialLayoutMap.has(groups[0])).toBe(false); + expect(interaction.previewLayoutMap.has(groups[0])).toBe(false); + expect(interaction.initialLayoutMap.get(groups[1])).toBe( + initialLayoutMap.get(groups[1]) + ); + + removeGroupFromInteraction(groups[1]); + expect(getInteractionState().state).toBe("inactive"); +}); diff --git a/lib/global/mutable-state/interactions.ts b/lib/global/mutable-state/interactions.ts index a62601939..66c013252 100644 --- a/lib/global/mutable-state/interactions.ts +++ b/lib/global/mutable-state/interactions.ts @@ -1,5 +1,11 @@ +import type { RegisteredGroup } from "../../components/group/types"; +import type { RegisteredSeparator } from "../../components/separator/types"; import { EventEmitter } from "../../utils/EventEmitter"; -import type { InteractionState, ResizePreview } from "./types"; +import type { + InteractionActive, + InteractionState, + ResizePreview +} from "./types"; let state: InteractionState = { cursorFlags: 0, @@ -27,14 +33,18 @@ export function subscribeToInteractionState( export function updateCursorFlags( cursorFlags: number, - preview?: ResizePreview + previews: ResizePreview[] = [], + previewLayoutMap?: InteractionActive["previewLayoutMap"] ) { const prev = state; const next = { ...state }; next.cursorFlags = cursorFlags; if (next.state === "active") { - next.preview = preview; + next.previews = previews; + if (previewLayoutMap) { + next.previewLayoutMap = previewLayoutMap; + } } state = next; @@ -55,3 +65,61 @@ export function updateInteractionState(next: InteractionState) { next }); } + +/** + * The preview prop is read through a registered separator getter. + * Notify after its layout effect has updated that getter, without re-registering the group. + */ +export function notifySeparatorPreviewChanged(separator: RegisteredSeparator) { + if ( + state.state !== "active" || + !state.previews.some((preview) => preview.separator === separator) + ) { + return; + } + + updateInteractionState({ + ...state, + previews: state.previews.map((preview) => + preview.separator === separator ? { ...preview } : preview + ) + }); +} + +export function removeGroupFromInteraction(group: RegisteredGroup) { + if (state.state === "inactive") { + return false; + } + + const hitRegions = state.hitRegions.filter( + (region) => region.group !== group + ); + const hasPreviews = + state.state === "active" && + state.previews.some((preview) => preview.group === group); + if (hitRegions.length === state.hitRegions.length && !hasPreviews) { + return false; + } + + if (hitRegions.length === 0) { + updateInteractionState({ cursorFlags: 0, state: "inactive" }); + } else if (state.state === "active") { + const initialLayoutMap = new Map(state.initialLayoutMap); + const previewLayoutMap = new Map(state.previewLayoutMap); + initialLayoutMap.delete(group); + previewLayoutMap.delete(group); + + updateInteractionState({ + ...state, + cursorFlags: 0, + hitRegions, + initialLayoutMap, + previewLayoutMap, + previews: state.previews.filter((preview) => preview.group !== group) + }); + } else { + updateInteractionState({ ...state, hitRegions }); + } + + return true; +} diff --git a/lib/global/mutable-state/types.ts b/lib/global/mutable-state/types.ts index a8f2ef6f5..d695f7f15 100644 --- a/lib/global/mutable-state/types.ts +++ b/lib/global/mutable-state/types.ts @@ -16,9 +16,13 @@ export type InteractionHover = { }; export type ResizePreview = { - hitRegion: HitRegion; - rect: DOMRect; + active: boolean; + group: RegisteredGroup; + key: string; offset: number; + panelIndex: number; + rect: DOMRect; + separator?: RegisteredSeparator | undefined; }; export type InteractionActive = { @@ -26,7 +30,8 @@ export type InteractionActive = { hitRegions: HitRegion[]; initialLayoutMap: Map; pointerDownAtPoint: Point; - preview: ResizePreview | undefined; + previewLayoutMap: Map; + previews: ResizePreview[]; state: "active"; }; diff --git a/lib/global/utils/calculateResizePreviews.test.ts b/lib/global/utils/calculateResizePreviews.test.ts new file mode 100644 index 000000000..74ea2de9f --- /dev/null +++ b/lib/global/utils/calculateResizePreviews.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { calculateHitRegions } from "../dom/calculateHitRegions"; +import { mockGroup } from "../test/mockGroup"; +import { calculateResizePreviews } from "./calculateResizePreviews"; + +describe("calculateResizePreviews", () => { + for (const orientation of ["horizontal", "vertical"] as const) { + for (const explicit of [false, true]) { + for (const activeIndex of [0, 1]) { + test(`${orientation}, explicit=${explicit}, active edge=${activeIndex}`, () => { + const horizontal = orientation === "horizontal"; + const rect = (start: number, size: number) => + horizontal + ? new DOMRect(start, 0, size, 100) + : new DOMRect(0, start, 100, size); + + const group = mockGroup(rect(0, 300), { orientation }); + group.addPanel(rect(0, 100)); + if (explicit) { + group.addSeparator(rect(100, 10)); + } + group.addHTMLElement(rect(explicit ? 110 : 100, explicit ? 90 : 100)); + group.addPanel(rect(200, 100)); + + const hitRegions = calculateHitRegions({ group }); + const previews = calculateResizePreviews(group, [ + hitRegions[activeIndex] + ]); + + expect(previews).toHaveLength(2); + expect(previews.map(({ active }) => active)).toEqual([ + activeIndex === 0, + activeIndex === 1 + ]); + expect( + previews.map(({ rect }) => (horizontal ? rect.left : rect.top)) + ).toEqual([100, 200]); + expect( + previews.map(({ rect }) => (horizontal ? rect.width : rect.height)) + ).toEqual([explicit ? 10 : 0, 0]); + expect(new Set(previews.map(({ key }) => key)).size).toBe(2); + expect(previews[0].separator).toBe(group.separators[0]); + expect(previews[1].separator).toBeUndefined(); + }); + } + } + } + + test("includes disabled separators that can move indirectly", () => { + const group = mockGroup(new DOMRect(0, 0, 210, 100)); + group.addPanel(new DOMRect(0, 0, 100, 100)); + group.addSeparator(new DOMRect(100, 0, 10, 100), "separator", true); + group.addPanel(new DOMRect(110, 0, 100, 100)); + + expect(calculateHitRegions({ group })).toHaveLength(0); + + const previews = calculateResizePreviews(group, []); + expect(previews).toHaveLength(1); + expect(previews[0].active).toBe(false); + expect(previews[0].separator).toBe(group.separators[0]); + }); +}); diff --git a/lib/global/utils/calculateResizePreviews.ts b/lib/global/utils/calculateResizePreviews.ts new file mode 100644 index 000000000..4244b1eda --- /dev/null +++ b/lib/global/utils/calculateResizePreviews.ts @@ -0,0 +1,73 @@ +import type { RegisteredGroup } from "../../components/group/types"; +import { + calculateHitRegions, + type HitRegion +} from "../dom/calculateHitRegions"; +import type { ResizePreview } from "../mutable-state/types"; +import { layoutNumbersEqual } from "./layoutNumbersEqual"; + +export function calculateResizePreviews( + group: RegisteredGroup, + hitRegions: HitRegion[] +): ResizePreview[] { + const { element, orientation, panels } = group; + + const groupRect = element.getBoundingClientRect(); + const horizontal = orientation === "horizontal"; + + // Use the same boundaries as hit testing, including separate edges around + // static content. Disabled boundaries can still move indirectly during a drag. + const boundaries = calculateHitRegions({ + expandHitTargets: false, + group, + includeDisabled: true + }); + + return boundaries.map( + ({ panels: boundaryPanels, rect, separator }, index) => { + const panelIndex = panels.indexOf(boundaryPanels[0]); + const center = horizontal + ? rect.left + rect.width / 2 + : rect.top + rect.height / 2; + + const active = hitRegions.some((region) => { + if (region.group !== group || region.panels[0] !== boundaryPanels[0]) { + return false; + } + + if (separator || region.separator) { + return region.separator === separator; + } + + const regionCenter = horizontal + ? region.rect.left + region.rect.width / 2 + : region.rect.top + region.rect.height / 2; + + return layoutNumbersEqual(center, regionCenter); + }); + + return { + active, + group, + key: separator + ? `separator-${separator.id}` + : `panel-${boundaryPanels[0].id}-${index}`, + offset: 0, + panelIndex, + rect: new DOMRect( + (horizontal && !separator ? center : rect.left) - + groupRect.left - + element.clientLeft + + element.scrollLeft, + (!horizontal && !separator ? center : rect.top) - + groupRect.top - + element.clientTop + + element.scrollTop, + horizontal && !separator ? 0 : rect.width, + !horizontal && !separator ? 0 : rect.height + ), + separator + }; + } + ); +} diff --git a/lib/global/utils/findMatchingHitRegions.ts b/lib/global/utils/findMatchingHitRegions.ts index ed06e9825..44794e667 100644 --- a/lib/global/utils/findMatchingHitRegions.ts +++ b/lib/global/utils/findMatchingHitRegions.ts @@ -21,7 +21,7 @@ export function findMatchingHitRegions( return; } - const hitRegions = calculateHitRegions(groupData); + const hitRegions = calculateHitRegions({ group: groupData }); const match = findClosestHitRegion(groupData.orientation, hitRegions, { x: event.clientX, y: event.clientY diff --git a/lib/global/utils/updateActiveHitRegion.test.ts b/lib/global/utils/updateActiveHitRegion.test.ts new file mode 100644 index 000000000..1ea6aafa7 --- /dev/null +++ b/lib/global/utils/updateActiveHitRegion.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { + CURSOR_FLAG_HORIZONTAL_MAX, + CURSOR_FLAG_HORIZONTAL_MIN, + CURSOR_FLAG_VERTICAL_MAX, + CURSOR_FLAG_VERTICAL_MIN +} from "../../constants"; +import { calculateHitRegions } from "../dom/calculateHitRegions"; +import { onDocumentPointerMove } from "../event-handlers/onDocumentPointerMove"; +import { mountGroup } from "../mountGroup"; +import { + getMountedGroups, + getMountedGroupState +} from "../mutable-state/groups"; +import { + getInteractionState, + updateInteractionState +} from "../mutable-state/interactions"; +import { mockGroup } from "../test/mockGroup"; +import { calculateResizePreviews } from "./calculateResizePreviews"; +import { updateActiveHitRegions } from "./updateActiveHitRegion"; + +describe("updateActiveHitRegions preview bounds", () => { + let unmount: (() => void) | undefined; + + afterEach(() => { + unmount?.(); + + updateInteractionState({ + state: "inactive", + cursorFlags: 0 + }); + }); + + test("missed pointer-up commits the last preview rather than the later hover position", () => { + const group = mockGroup(new DOMRect(0, 0, 200, 100), { + resizePreviewMode: "separator" + }); + group.addPanel(new DOMRect(0, 0, 100, 100)); + group.addPanel(new DOMRect(100, 0, 100, 100)); + unmount = mountGroup(group); + + const hitRegions = calculateHitRegions({ group }); + const initialLayoutMap = new Map([ + [group, getMountedGroupState(group.id, true).layout] + ]); + const pointerDownAtPoint = { x: 100, y: 50 }; + + updateInteractionState({ + cursorFlags: 0, + hitRegions, + initialLayoutMap, + pointerDownAtPoint, + previewLayoutMap: new Map(initialLayoutMap), + previews: calculateResizePreviews(group, hitRegions), + state: "active" + }); + + updateActiveHitRegions({ + commit: false, + document, + event: { clientX: 140, clientY: 50, movementX: 40, movementY: 0 }, + hitRegions, + initialLayoutMap, + mountedGroups: getMountedGroups(), + pointerDownAtPoint, + prevCursorFlags: 0 + }); + expect( + getMountedGroupState(group.id, true).layout[group.panels[0].id] + ).toBe(50); + + onDocumentPointerMove({ + buttons: 0, + clientX: 100, + clientY: 50, + currentTarget: document, + defaultPrevented: false, + movementX: -40, + movementY: 0 + } as unknown as PointerEvent); + + expect(getInteractionState().state).toBe("inactive"); + expect( + getMountedGroupState(group.id, true).layout[group.panels[0].id] + ).toBe(70); + }); + + for (const orientation of ["horizontal", "vertical"] as const) { + for (const direction of [-1, 1]) { + for (const disableCursor of [false, true]) { + test(`${orientation}, direction ${direction}, disableCursor=${disableCursor}`, () => { + const horizontal = orientation === "horizontal"; + const rect = (start: number, size: number) => + horizontal + ? new DOMRect(start, 0, size, 100) + : new DOMRect(0, start, 100, size); + + const group = mockGroup(rect(0, 200), { + orientation, + resizePreviewMode: "separator" + }); + group.mutableState.disableCursor = disableCursor; + group.addPanel(rect(0, 100), "a", { + minSize: "25%" + }); + group.addPanel(rect(100, 100), "b", { + minSize: "25%" + }); + + unmount = mountGroup(group); + + const initialLayout = getMountedGroupState(group.id, true).layout; + const hitRegions = calculateHitRegions({ group }); + const initialLayoutMap = new Map([[group, initialLayout]]); + const pointerDownAtPoint = { + x: 100, + y: 100 + }; + + updateInteractionState({ + state: "active", + cursorFlags: 0, + hitRegions, + initialLayoutMap, + pointerDownAtPoint, + previewLayoutMap: new Map(initialLayoutMap), + previews: calculateResizePreviews(group, hitRegions) + }); + + let previousDelta = 0; + const move = (delta: number, commit = false) => { + const movement = delta - previousDelta; + previousDelta = delta; + + updateActiveHitRegions({ + commit, + document, + hitRegions, + initialLayoutMap, + mountedGroups: getMountedGroups(), + pointerDownAtPoint, + prevCursorFlags: getInteractionState().cursorFlags, + event: { + clientX: 100 + (horizontal ? delta : 0), + clientY: 100 + (horizontal ? 0 : delta), + movementX: horizontal ? movement : 0, + movementY: horizontal ? 0 : movement + } + }); + }; + + const expectedFlag = disableCursor + ? 0 + : horizontal + ? direction < 0 + ? CURSOR_FLAG_HORIZONTAL_MIN + : CURSOR_FLAG_HORIZONTAL_MAX + : direction < 0 + ? CURSOR_FLAG_VERTICAL_MIN + : CURSOR_FLAG_VERTICAL_MAX; + + move(direction * 60); + const previousState = getInteractionState(); + + move(direction * 70); + expect(getInteractionState().cursorFlags).toBe(expectedFlag); + expect(getMountedGroupState(group.id, true).layout).toEqual( + initialLayout + ); + + const state = getInteractionState(); + if (state.state !== "active") { + throw Error("Expected active interaction"); + } + + expect(state.previews[0].offset).toBe(direction * 50); + if (previousState.state !== "active") { + throw Error("Expected active interaction"); + } + expect(state.previews).toBe(previousState.previews); + expect(state.previews[0]).toBe(previousState.previews[0]); + + // A rounded pointer event must preserve the bounds cursor. + move(direction * 70); + expect(getInteractionState().cursorFlags).toBe(expectedFlag); + + // Returning to the allowed range clears the bounds cursor. + move(direction * 20); + expect(getInteractionState().cursorFlags).toBe(0); + + move(direction * 60); + move(direction * 70); + + // Committing the same preview still updates the mounted layout. + move(direction * 70, true); + expect( + getMountedGroupState(group.id, true).layout[group.panels[0].id] + ).toBe(50 + direction * 25); + }); + } + } + } +}); diff --git a/lib/global/utils/updateActiveHitRegion.ts b/lib/global/utils/updateActiveHitRegion.ts index 4ccfef4ec..69df15d87 100644 --- a/lib/global/utils/updateActiveHitRegion.ts +++ b/lib/global/utils/updateActiveHitRegion.ts @@ -47,8 +47,10 @@ export function updateActiveHitRegions({ }) { let nextCursorFlags = 0; const interaction = getInteractionState(); - let preview = - interaction.state === "active" ? interaction.preview : undefined; + let previews = interaction.state === "active" ? interaction.previews : []; + const previewLayoutMap = new Map( + interaction.state === "active" ? interaction.previewLayoutMap : undefined + ); // Note that HitRegions are frozen once a drag has started // Modify the Group layouts for all matching HitRegions though @@ -87,10 +89,14 @@ export function updateActiveHitRegions({ defaultLayoutDeferred, derivedPanelConstraints, groupSize: mountedGroupSize, - layout: prevLayout, + layout: mountedLayout, separatorToPanels } = groupState; - if (derivedPanelConstraints && prevLayout && separatorToPanels) { + if (derivedPanelConstraints && mountedLayout && separatorToPanels) { + const prevLayout = + group.resizePreviewMode === "separator" + ? (previewLayoutMap.get(group) ?? mountedLayout) + : mountedLayout; const nextLayout = adjustLayoutByDelta({ delta: deltaAsPercentage, initialLayout, @@ -100,21 +106,33 @@ export function updateActiveHitRegions({ trigger: "mouse-or-touch" }); - // The preview implementation hinges on this block: consume resizePreviewMode and use commit to defer the Group layout update until the pointer is released. - if (group.resizePreviewMode === "separator" && !commit) { - const pivotIndex = panels.indexOf(current.panels[0]); - const offset = - panels.slice(0, pivotIndex + 1).reduce((total, panel) => { - return total + nextLayout[panel.id] - initialLayout[panel.id]; - }, 0) * - (groupSize / 100); + // Preview every moved boundary, deferring the layout update until release. + if ( + group.resizePreviewMode === "separator" && + !commit && + !layoutsEqual(nextLayout, prevLayout) + ) { + previewLayoutMap.set(group, nextLayout); - if (preview?.hitRegion === current) { - preview = { ...preview, offset }; - } - } else if (layoutsEqual(nextLayout, prevLayout)) { + let total = 0; + const offsets = panels.map((panel) => { + total += nextLayout[panel.id] - initialLayout[panel.id]; + return total * (groupSize / 100); + }); + + previews = previews.map((preview) => { + if (preview.group !== group) { + return preview; + } + + const offset = offsets[preview.panelIndex]; + return offset === preview.offset ? preview : { ...preview, offset }; + }); + } + + if (layoutsEqual(nextLayout, prevLayout)) { if (deltaAsPercentage !== 0 && !disableCursor) { - // An unchanged means the cursor has exceeded the allowed bounds + // An unchanged layout means the cursor has exceeded the allowed bounds switch (orientation) { case "horizontal": { nextCursorFlags |= @@ -132,7 +150,12 @@ export function updateActiveHitRegions({ } } } - } else { + } + + if ( + (group.resizePreviewMode !== "separator" || commit) && + !layoutsEqual(nextLayout, mountedLayout) + ) { updateMountedGroup(current.group, { defaultLayoutDeferred, derivedPanelConstraints: derivedPanelConstraints, @@ -159,6 +182,6 @@ export function updateActiveHitRegions({ cursorFlags |= nextCursorFlags & CURSOR_FLAGS_VERTICAL; } - updateCursorFlags(cursorFlags, preview); + updateCursorFlags(cursorFlags, previews, previewLayoutMap); updateCursorStyle(document); } diff --git a/lib/index.ts b/lib/index.ts index 584e4bcdc..e67890598 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,6 +6,7 @@ export { Panel } from "./components/panel/Panel"; export { usePanelCallbackRef } from "./components/panel/usePanelCallbackRef"; export { usePanelRef } from "./components/panel/usePanelRef"; export { Separator } from "./components/separator/Separator"; +export { SeparatorOverlay } from "./components/separator/SeparatorOverlay"; export { isCoarsePointer } from "./global/utils/isCoarsePointer"; @@ -18,6 +19,7 @@ export type { OnGroupLayoutChange, Orientation } from "./components/group/types"; + export type { OnPanelResize, PanelImperativeHandle, @@ -25,4 +27,8 @@ export type { PanelSize, SizeUnit } from "./components/panel/types"; -export type { SeparatorProps } from "./components/separator/types"; + +export type { + SeparatorProps, + SeparatorOverlayProps +} from "./components/separator/types"; diff --git a/public/generated/docs/Group.json b/public/generated/docs/Group.json index b28f63517..4f352646e 100644 --- a/public/generated/docs/Group.json +++ b/public/generated/docs/Group.json @@ -162,7 +162,13 @@ "resizePreviewMode": { "description": [ { - "content": "

Controls whether pointer dragging resizes Panels immediately or only moves\na preview separator element until the pointer is released.\nDefaults to "panel" (immediate resizing); "separator" defers resizing until release.\nA visible preview requires an explicit Separator.

\n" + "content": "

Controls whether pointer dragging updates Panels sizes immediately,\nor renders overlay separator previews until the pointer is released.

\n" + }, + { + "content": "

Defaults to "panel" (immediate resizing); "separator" defers resizing until release.

\n" + }, + { + "content": "

Customize previews using the SeparatorOverlay component.

\n" } ], "html": "
resizePreviewMode?: ResizePreviewMode = \"panel\"
", diff --git a/public/generated/docs/Separator.json b/public/generated/docs/Separator.json index d923e84ce..f25cae91b 100644 --- a/public/generated/docs/Separator.json +++ b/public/generated/docs/Separator.json @@ -108,6 +108,16 @@ "html": "
elementRef?: Ref<HTMLDivElement>
", "name": "elementRef", "required": false + }, + "preview": { + "description": [ + { + "content": "

Overrides the Group default preview for this Separator when resizePreviewMode is "separator".

\n" + } + ], + "html": "
preview?: ReactNode
", + "name": "preview", + "required": false } } } \ No newline at end of file diff --git a/public/generated/examples/ResizePreview.json b/public/generated/examples/ResizePreview.json deleted file mode 100644 index 9a52c1e9b..000000000 --- a/public/generated/examples/ResizePreview.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "html": "
<Group resizePreviewMode=\"separator\">
\n
<Panel defaultSize=\"50%\" minSize=\"20%\">left</Panel>
\n
<Separator />
\n
<Panel minSize=\"20%\">right</Panel>
\n
</Group>
" -} \ No newline at end of file diff --git a/public/generated/examples/ResizePreviewMode.json b/public/generated/examples/ResizePreviewMode.json new file mode 100644 index 000000000..191745a86 --- /dev/null +++ b/public/generated/examples/ResizePreviewMode.json @@ -0,0 +1,3 @@ +{ + "html": "
<Group resizePreviewMode=\"separator\">
\n
<Panel minSize=\"10%\">left</Panel>
\n
<Separator />
\n
<Panel minSize=\"10%\">right</Panel>
\n
</Group>
" +} \ No newline at end of file diff --git a/public/generated/examples/ResizePreviewWithImplicitSeparator.json b/public/generated/examples/ResizePreviewWithImplicitSeparator.json new file mode 100644 index 000000000..0d8ed1d51 --- /dev/null +++ b/public/generated/examples/ResizePreviewWithImplicitSeparator.json @@ -0,0 +1,3 @@ +{ + "html": "
<Group resizePreviewMode=\"separator\">
\n
<Panel minSize=\"10%\">left</Panel>
\n
<Panel minSize=\"10%\">center</Panel>
\n
<Panel minSize=\"10%\">right</Panel>
\n
<Panel minSize=\"10%\">right</Panel>
\n
 
\n
<SeparatorOverlay
\n
className=\"w-2 bg-sky-500 data-[separator-overlay=inactive]:bg-sky-700 opacity-80\"
\n
/>
\n
</Group>
" +} \ No newline at end of file diff --git a/public/generated/examples/ResizePreviewWithSeparatorOverlay.json b/public/generated/examples/ResizePreviewWithSeparatorOverlay.json new file mode 100644 index 000000000..246e78633 --- /dev/null +++ b/public/generated/examples/ResizePreviewWithSeparatorOverlay.json @@ -0,0 +1,3 @@ +{ + "html": "
<Group resizePreviewMode=\"separator\">
\n
<Panel minSize=\"10%\">left</Panel>
\n
<Separator />
\n
<Panel minSize=\"10%\">center</Panel>
\n
<Separator />
\n
<Panel minSize=\"10%\">right</Panel>
\n
 
\n
<SeparatorOverlay
\n
className=\"w-2 bg-sky-500 data-[separator-overlay=inactive]:bg-sky-700 opacity-80\"
\n
/>
\n
</Group>
" +} \ No newline at end of file diff --git a/public/generated/site-map.json b/public/generated/site-map.json index 6112e127b..28285d93d 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -12,7 +12,7 @@ { "path": "/examples/the-basics", "section": "Examples", - "text": " The simplest resizable panel configuration is two panels within a group. import { Group, Panel } from \"react-resizable-panels\";\n \n\n left\n right\n Panel groups use a flexbox layout with a default orientation of horizontal but the orientation prop can be used to specify a vertical layout. \n top\n bottom\n Vertical groups may benefit from an explicit height or min-height (read more). Panels can be resized by clicking on their borders but explicit separators can be rendered to improve UX. Separators provide another benefit: double-clicking on one resets a panel to its default size. \n left\n \n right\n For panels that are expensive to resize, you can use to defer panel resizing until release. See Resize behaviors. Separators improve keyboard accessibility by providing a tab-focusable window splitter element. ", + "text": " The simplest resizable panel configuration is two panels within a group. import { Group, Panel } from \"react-resizable-panels\";\n \n\n left\n right\n Panel groups use a flexbox layout with a default orientation of horizontal but the orientation prop can be used to specify a vertical layout. \n top\n bottom\n Vertical groups may benefit from an explicit height or min-height (read more). Panels can be resized by clicking on their borders but explicit separators can be rendered to improve UX. Separators provide another benefit: double-clicking on one resets a panel to its default size. \n left\n \n right\n Separators improve keyboard accessibility by providing a tab-focusable window splitter element. ", "title": "The basics" }, { @@ -75,11 +75,17 @@ "text": " Panel and Separator components can be disabled to disable or limit resize behavior. Below are a few examples of how this can be used to implement different types of UIs. In groups with only two panels, disabling a separator is sufficient to completely prevent resizing. \n left\n \n right\n The same applies to disabling one or both panels when there is no explicit separator element. Note this is functionally the same as disabling the entire Group component. \n left\n right\n In groups with three or more panels, disabling a separator does not completely prevent a panel from being resized. In the example below, resizing the center panel can indirectly cause the left panel to be resized as well. Disabling a panel prevents it from being resized, though its separator can still be used to resize other panels. \n left\n \n center (disabled)\n \n right\n When there is no separator, a disabled panel's edges can also be used to resize other panels. You can also disable both a panel and its separator to completely prevent them from being resized or interacted with in any way. \n left (disabled\n \n center\n \n right\n Note that a disabled Panel can still be resized using the imperative API. ", "title": "Disabling interactions" }, + { + "path": "/examples/panel-resize-behavior", + "section": "Examples", + "text": " Dragging a resize separator causes panels to re-render with updated sizes. In most cases, this is what you want, but if re-rendering the contents of a panel is too slow, the resizePreviewMode prop can be used to defer the re-render until the resize is finished. \n left\n \n right\n In place of a panel update, an overlay separator will be rendered instead, as shown in the group below. By default, this overlay separator is just a partially transparent copy of the separator element being dragged. The `SeparatorOverlay` component allows users to customize the overlay. \n left\n \n center\n \n right\n \n \n Resize preview mode works even for groups with implicit separators. \n left\n center\n right\n right\n \n \n The data-separator-overlay attribute can be used to differentiate between a separator that's being active dragged and one that's being moved as a result of min/max size constraints. ", + "title": "Panel resize behavior" + }, { "path": "/examples/group-resize-behavior", "section": "Examples", - "text": " Separator preview Set resizePreviewMode=\"separator\" to preview a resize without changing panel sizes until release. The preview respects panel size constraints and reuses the clicked Separator’s class, inline styles, and children. The default, \"panel\", resizes panels while dragging. \n left\n \n right\n Render an explicit Separator for a visible preview. The preview is rendered inside the Group and reuses the Separator’s presentation. Panel-edge drags without a Separator still defer resizing until release, but do not show a preview. Group resize behavior Resizing a group typically affects the size of panels within the group. The groupResizeBehavior prop can be used override this behavior and freeze specific panels (in terms of their pixels sizes) while the group is resized. For an example of this, resize the browser window while keeping an eye on the left panel below. \n \n left\n \n \n main\n Minor pixel changes in the panel above are due to precision/rounding. Groups are required to contain at least one panel without groupResizeBehavior=\"preserve-pixel-size\". ", - "title": "Resize behaviors" + "text": " Resizing a group typically affects the size of panels within the group. The groupResizeBehavior prop can be used override this behavior and freeze specific panels (in terms of their pixels sizes) while the group is resized. For an example of this, resize the browser window while keeping an eye on the left panel below. \n \n left\n \n \n main\n Minor pixel changes in the panel above are due to precision/rounding. Groups are required to contain at least one panel without groupResizeBehavior=\"preserve-pixel-size\". ", + "title": "Group resize behavior" }, { "path": "/examples/overflow", @@ -96,7 +102,7 @@ { "path": "/props/group", "section": "Props", - "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument contains meta information about the layout change.\nThe isUserInteraction attribute signals whether the resize was caused by direct user input.\nIt is true for resizes caused by pointer or keyboard input\nand false for other triggers (e.g. imperative API calls, initial mount, etc.)\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizePreviewMode?: ResizePreviewMode = \"panel\" Controls whether pointer dragging resizes Panels immediately or only moves\na preview separator element until the pointer is released.\nDefaults to \"panel\" (immediate resizing); \"separator\" defers resizing until release.\nA visible preview requires an explicit Separator.\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ", + "text": " A Group wraps a set of resizable Panel components.\nGroup content can be resized horizontally or vertically.\n Group elements always include the following attributes:\n < div data-group data-testid = \"group-id-prop\" id = \"group-id-prop\" > Test id can be used to narrow selection when unit testing.\n Optional props children?: ReactNode Panel and Separator components that comprise this group.\n className?: string CSS class name.\n defaultLayout?: Layout Default layout for the Group.\n This value allows layouts to be remembered between page reloads.\n Slight layout shift may occur when server-rendering panels with percentage-based default sizes.\nRefer to the documentation for suggestions on how to minimize the impact of this.\n disableCursor?: boolean This library sets custom mouse cursor styles to indicate drag state.\nUse this prop to disable that behavior for Panels and Separators in this group.\n disabled?: boolean Disable resize functionality.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n groupRef?: Ref Exposes the following imperative API:\n\n getLayout(): Layout \n setLayout(layout: Layout): void \n\n The useGroupRef and useGroupCallbackRef hooks are exported for convenience use in TypeScript projects.\n id?: string | number Uniquely identifies this group within an application.\nFalls back to useId when not provided.\n This value will also be assigned to the data-group attribute.\n onLayoutChange?: (layout: Layout) => void Called when the Group's layout is changing.\n For layout changes caused by pointer events, this method is called each time the pointer is moved.\nFor most cases, it is recommended to use the onLayoutChanged callback instead.\n onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void Called after the Group's layout has been changed.\n For layout changes caused by pointer events, this method is not called until the pointer has been released.\nThis method is recommended when saving layouts to some storage api.\n The second argument contains meta information about the layout change.\nThe isUserInteraction attribute signals whether the resize was caused by direct user input.\nIt is true for resizes caused by pointer or keyboard input\nand false for other triggers (e.g. imperative API calls, initial mount, etc.)\n orientation?: \"horizontal\" | \"vertical\" = \"horizontal\" Specifies the resizable orientation (\"horizontal\" or \"vertical\"); defaults to \"horizontal\"\n resizePreviewMode?: ResizePreviewMode = \"panel\" Controls whether pointer dragging updates Panels sizes immediately,\nor renders overlay separator previews until the pointer is released.\n Defaults to \"panel\" (immediate resizing); \"separator\" defers resizing until release.\n Customize previews using the SeparatorOverlay component.\n resizeTargetMinimumSize?: { coarse: number; fine: number; } = {\n coarse: 20,\n fine: 10\n } Minimum size of the resizable hit target area (either Separator or Panel edge)\nThis threshold ensures are large enough to avoid mis-clicks.\n \nCoarse inputs (typically a finger on a touchscreen) have reduced accuracy;\nto ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks.\nFine inputs (typically a mouse) can be smaller\n\n Apple interface guidelines suggest 20pt (27px) on desktops and 28pt (37px) for touch devices\nIn practice this seems to be much larger than many of their own applications use though.\n style?: CSSProperties CSS properties.\n The default inline styles cannot be overridden, except for overflow .\n ", "title": "Group component props" }, { @@ -108,7 +114,7 @@ { "path": "/props/separator", "section": "Props", - "text": " Separators are not required but they are recommended as they improve keyboard accessibility.\n Separator elements must be direct DOM children of their parent Group elements.\n Separator elements always include the following attributes:\n < div data-separator data-testid = \"separator-id-prop\" id = \"separator-id-prop\" role = \"separator\" > Test id can be used to narrow selection when unit testing.\n In addition to the attributes shown above, separator also renders all required WAI-ARIA properties.\n Optional props className?: string CSS class name.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n disabled?: boolean When disabled, the separator cannot be used to resize its neighboring panels.\n The panels may still be resized indirectly (while other panels are being resized).\nTo prevent a panel from being resized at all, it needs to also be disabled.\n disableDoubleClick?: boolean When true, double-clicking this Separator will not reset its Panel to its default size.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n id?: string | number Uniquely identifies the separator within the parent group.\nFalls back to useId when not provided.\n This value will also be assigned to the data-separator attribute.\n style?: CSSProperties CSS properties.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n ", + "text": " Separators are not required but they are recommended as they improve keyboard accessibility.\n Separator elements must be direct DOM children of their parent Group elements.\n Separator elements always include the following attributes:\n < div data-separator data-testid = \"separator-id-prop\" id = \"separator-id-prop\" role = \"separator\" > Test id can be used to narrow selection when unit testing.\n In addition to the attributes shown above, separator also renders all required WAI-ARIA properties.\n Optional props className?: string CSS class name.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n disabled?: boolean When disabled, the separator cannot be used to resize its neighboring panels.\n The panels may still be resized indirectly (while other panels are being resized).\nTo prevent a panel from being resized at all, it needs to also be disabled.\n disableDoubleClick?: boolean When true, double-clicking this Separator will not reset its Panel to its default size.\n elementRef?: Ref Ref attached to the root HTMLDivElement.\n id?: string | number Uniquely identifies the separator within the parent group.\nFalls back to useId when not provided.\n This value will also be assigned to the data-separator attribute.\n preview?: ReactNode Overrides the Group default preview for this Separator when resizePreviewMode is \"separator\".\n style?: CSSProperties CSS properties.\n Use the data-separator attribute for custom hover and active styles\n The following properties cannot be overridden: flex-grow, flex-shrink \n ", "title": "Separator component props" }, { diff --git a/src/App.tsx b/src/App.tsx index 8abb9c910..447c6acfe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,8 +54,11 @@ export default function App() { Fixed size panels Disabled panels + + Panel resize behavior + - Resize behaviors + Group resize behavior Overflow diff --git a/src/routes.ts b/src/routes.ts index 1e6b42051..3e0ea53d2 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -29,6 +29,9 @@ export const routes = { "/examples/fixed-size-panels": lazy( () => import("./routes/FixedSizePanelsRoute") ), + "/examples/panel-resize-behavior": lazy( + () => import("./routes/PanelResizeBehaviorRoute") + ), "/examples/group-resize-behavior": lazy( () => import("./routes/GroupResizeBehaviorRoute") ), diff --git a/src/routes/GroupResizeBehaviorRoute.tsx b/src/routes/GroupResizeBehaviorRoute.tsx index 7345110e7..150b8f07e 100644 --- a/src/routes/GroupResizeBehaviorRoute.tsx +++ b/src/routes/GroupResizeBehaviorRoute.tsx @@ -1,6 +1,5 @@ import { Box, Callout, Code, Header } from "react-lib-tools"; import { html as ExampleHTML } from "../../public/generated/examples/GroupResizeBehavior.json"; -import { html as PreviewHTML } from "../../public/generated/examples/ResizePreview.json"; import { Group } from "../components/styled-panels/Group"; import { Panel } from "../components/styled-panels/Panel"; import { Separator } from "../components/styled-panels/Separator"; @@ -8,32 +7,7 @@ import { Separator } from "../components/styled-panels/Separator"; export default function GroupResizeBehaviorRoute() { return ( -
-

Separator preview

-
- Set resizePreviewMode="separator" to preview a resize - without changing panel sizes until release. The preview respects panel - size constraints and reuses the clicked Separator’s class, inline - styles, and children. The default, "panel", resizes panels - while dragging. -
- - - - left - - - - right - - - - Render an explicit Separator for a visible preview. The - preview is rendered inside the Group and reuses the Separator’s - presentation. Panel-edge drags without a Separator still defer resizing - until release, but do not show a preview. - -

Group resize behavior

+
Resizing a group typically affects the size of panels within the group. The groupResizeBehavior prop can be used override this diff --git a/src/routes/LayoutBasicsRoute.tsx b/src/routes/LayoutBasicsRoute.tsx index 582484696..8081e416e 100644 --- a/src/routes/LayoutBasicsRoute.tsx +++ b/src/routes/LayoutBasicsRoute.tsx @@ -51,12 +51,6 @@ export default function LayoutBasicsRoute() { right -
- For panels that are expensive to resize, you can use{" "} - {''} to defer panel - resizing until release. See{" "} - Resize behaviors. -
Separators improve keyboard accessibility by providing a tab-focusable{" "} diff --git a/src/routes/PanelResizeBehaviorRoute.tsx b/src/routes/PanelResizeBehaviorRoute.tsx new file mode 100644 index 000000000..0c0b2bf31 --- /dev/null +++ b/src/routes/PanelResizeBehaviorRoute.tsx @@ -0,0 +1,79 @@ +import { Box, Callout, Code, Header } from "react-lib-tools"; +import { SeparatorOverlay } from "react-resizable-panels"; +import { html as ResizePreviewModeHTML } from "../../public/generated/examples/ResizePreviewMode.json"; +import { html as ResizePreviewWithImplicitSeparatorHTML } from "../../public/generated/examples/ResizePreviewWithImplicitSeparator.json"; +import { html as ResizePreviewWithSeparatorOverlayHTML } from "../../public/generated/examples/ResizePreviewWithSeparatorOverlay.json"; +import { Group } from "../components/styled-panels/Group"; +import { Panel } from "../components/styled-panels/Panel"; +import { Separator } from "../components/styled-panels/Separator"; +import { Link } from "../components/Link"; + +export default function PanelResizeBehaviorRoute() { + return ( + +
+
+ Dragging a resize separator causes panels to re-render with updated + sizes. In most cases, this is what you want, but if re-rendering the + contents of a panel is too slow, the resizePreviewMode prop + can be used to defer the re-render until the resize is finished. +
+ +
+ In place of a panel update, an overlay separator will be rendered + instead, as shown in the group below. +
+ + + left + + + + right + + +
+ By default, this overlay separator is just a partially transparent copy + of the separator element being dragged. The `SeparatorOverlay` component + allows users to customize the overlay. +
+ + + + left + + + + center + + + + right + + + +
+ Resize preview mode works even for groups with implicit separators. +
+ + + + left + + + center + + + right + + + + + The data-separator-overlay attribute can be used to + differentiate between a separator that's being active dragged and one + that's being moved as a result of{" "} + min/max size constraints. + + + ); +} diff --git a/src/routes/examples/ResizePreview.tsx b/src/routes/examples/ResizePreviewMode.tsx similarity index 64% rename from src/routes/examples/ResizePreview.tsx rename to src/routes/examples/ResizePreviewMode.tsx index 9a3726666..d6ccbfc6f 100644 --- a/src/routes/examples/ResizePreview.tsx +++ b/src/routes/examples/ResizePreviewMode.tsx @@ -4,7 +4,7 @@ import { Group, Panel, Separator } from "react-resizable-panels"; /* prettier-ignore */ - left + left - right + right diff --git a/src/routes/examples/ResizePreviewWithImplicitSeparator.tsx b/src/routes/examples/ResizePreviewWithImplicitSeparator.tsx new file mode 100644 index 000000000..61c593dd4 --- /dev/null +++ b/src/routes/examples/ResizePreviewWithImplicitSeparator.tsx @@ -0,0 +1,15 @@ +import { Group, Panel, SeparatorOverlay } from "react-resizable-panels"; + +// + +/* prettier-ignore */ + + left + center + right + right + + + diff --git a/src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx b/src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx new file mode 100644 index 000000000..226f66830 --- /dev/null +++ b/src/routes/examples/ResizePreviewWithSeparatorOverlay.tsx @@ -0,0 +1,21 @@ +import { + Group, + Panel, + Separator, + SeparatorOverlay +} from "react-resizable-panels"; + +// + +/* prettier-ignore */ + + left + + center + + right + + +