diff --git a/README.md b/README.md index 1a98f832b..0d9c98ab0 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 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.

@@ -392,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 aef2a5d64..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,8 +25,12 @@ 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"; import type { GroupProps, @@ -29,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. @@ -54,6 +65,7 @@ export function Group({ onLayoutChange: onLayoutChangeUnstable, onLayoutChanged: onLayoutChangedUnstable, orientation = "horizontal", + resizePreviewMode = "panel", resizeTargetMinimumSize = { coarse: 20, fine: 10 @@ -93,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(); @@ -164,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, [ @@ -266,6 +292,7 @@ export function Group({ }, orientation, panels: inMemoryValues.panels, + resizePreviewMode, resizeTargetMinimumSize: inMemoryValues.resizeTargetMinimumSize, separators: inMemoryValues.separators }; @@ -343,6 +370,7 @@ export function Group({ onLayoutChangeStable, orientation, panelOrSeparatorChangeSigil, + resizePreviewMode, stableProps ]); @@ -369,6 +397,7 @@ export function Group({ height: "100%", width: "100%", overflow: "hidden", + position: resizePreviewMode === "separator" ? "relative" : undefined, ...style, @@ -384,6 +413,13 @@ export function Group({ }} > {children} + {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 new file mode 100644 index 000000000..9572c547c --- /dev/null +++ b/lib/components/group/ResizePreview.tsx @@ -0,0 +1,64 @@ +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({ + overlay, + preview +}: { + overlay?: SeparatorOverlayProps | undefined; + preview: ResizePreviewState; +}) { + const { group, offset, rect, separator } = preview; + + const horizontal = group.orientation === "horizontal"; + + 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 580a90573..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"; /** @@ -38,6 +39,8 @@ export type DragState = { separatorId: string | undefined; }; +export type ResizePreviewMode = "panel" | "separator"; + export type ResizeTargetMinimumSize = { coarse: number; fine: number; @@ -59,6 +62,7 @@ export type RegisteredGroup = Readonly<{ }; orientation: Orientation; panels: RegisteredPanel[]; + resizePreviewMode: ResizePreviewMode; resizeTargetMinimumSize: ResizeTargetMinimumSize; separators: RegisteredSeparator[]; }>; @@ -71,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: ( @@ -183,6 +188,16 @@ export type GroupProps = HTMLAttributes & { */ onLayoutChanged?: (layout: Layout, meta: LayoutChangedMeta) => void; + /** + * 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. + * + * Customize previews using the `SeparatorOverlay` component. + */ + 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/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 518b1125b..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) { @@ -42,7 +46,11 @@ export function Separator({ const stableProps = useStableObject({ disabled, - disableDoubleClick + disableDoubleClick, + children, + className, + preview, + style }); const [aria, setAria] = useState<{ @@ -57,6 +65,7 @@ export function Separator({ const [isFocused, setIsFocused] = useState(false); const elementRef = useRef(null); + const registeredSeparatorRef = useRef(null); const mergedRef = useMergedRefs(elementRef, elementRefProp); @@ -80,9 +89,23 @@ export function Separator({ disabled: stableProps.disabled, disableDoubleClick: stableProps.disableDoubleClick, element, - id + id, + get children() { + return stableProps.children; + }, + 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( @@ -128,6 +151,8 @@ export function Separator({ ); return () => { + registeredSeparatorRef.current = null; + removeInteractionStateChangeListener(); removeMountedGroupsChangeListener(); unregisterSeparator(); @@ -135,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 5844b411a..559b81cff 100644 --- a/lib/components/separator/types.ts +++ b/lib/components/separator/types.ts @@ -1,10 +1,14 @@ -import type { CSSProperties, HTMLAttributes, Ref } from "react"; +import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from "react"; export type RegisteredSeparator = { + children?: ReactNode; + className?: string | undefined; disabled?: boolean | undefined; disableDoubleClick?: boolean | undefined; element: HTMLDivElement; id: string; + preview?: ReactNode; + style?: CSSProperties | undefined; }; type BaseSeparatorAttributes = Omit< @@ -48,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. * @@ -57,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 49dfe067c..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 @@ -32,6 +40,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); @@ -66,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) { @@ -164,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( @@ -190,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/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..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,7 +16,6 @@ export function onDocumentPointerDown(event: PointerEvent) { const hitRegions = findMatchingHitRegions(event, mountedGroups); const initialLayoutMap = new Map(); - let didChangeFocus = false; hitRegions.forEach((current) => { @@ -42,11 +42,19 @@ export function onDocumentPointerDown(event: PointerEvent) { } }); + 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 }, + previewLayoutMap: new Map(initialLayoutMap), + previews, 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..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,6 +28,19 @@ export function onDocumentPointerMove(event: PointerEvent) { // Skip this check for "pointerleave" events, else Firefox triggers a false positive (see #514) event.buttons === 0 ) { + // 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({ cursorFlags: 0, state: "inactive" @@ -48,6 +62,8 @@ export function onDocumentPointerMove(event: PointerEvent) { }); }); + updateCursorStyle(event.currentTarget as Document); + return; } @@ -64,6 +80,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/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 4c479c541..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 } from "./types"; +import type { + InteractionActive, + InteractionState, + ResizePreview +} from "./types"; let state: InteractionState = { cursorFlags: 0, @@ -25,11 +31,21 @@ export function subscribeToInteractionState( return eventEmitter.addListener("change", callback); } -export function updateCursorFlags(cursorFlags: number) { +export function updateCursorFlags( + cursorFlags: number, + previews: ResizePreview[] = [], + previewLayoutMap?: InteractionActive["previewLayoutMap"] +) { const prev = state; const next = { ...state }; next.cursorFlags = cursorFlags; + if (next.state === "active") { + next.previews = previews; + if (previewLayoutMap) { + next.previewLayoutMap = previewLayoutMap; + } + } state = next; @@ -49,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 034f27c74..d695f7f15 100644 --- a/lib/global/mutable-state/types.ts +++ b/lib/global/mutable-state/types.ts @@ -15,11 +15,23 @@ export type InteractionHover = { state: "hover"; }; +export type ResizePreview = { + active: boolean; + group: RegisteredGroup; + key: string; + offset: number; + panelIndex: number; + rect: DOMRect; + separator?: RegisteredSeparator | undefined; +}; + export type InteractionActive = { cursorFlags: number; hitRegions: HitRegion[]; initialLayoutMap: Map; pointerDownAtPoint: Point; + previewLayoutMap: Map; + previews: ResizePreview[]; 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/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/completeActivePointerResize.ts b/lib/global/utils/completeActivePointerResize.ts index 38bde1e06..efb3bb56c 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,17 @@ export function completeActivePointerResize(document: Document) { switch (interactionState.state) { case "active": { + 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/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 dcc2ca5a0..69df15d87 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,20 @@ export function updateActiveHitRegions({ prevCursorFlags: number; }) { let nextCursorFlags = 0; + const interaction = getInteractionState(); + 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 hitRegions.forEach((current) => { const { group, groupSize } = current; const { orientation, panels } = group; + if (commit && group.resizePreviewMode !== "separator") { + return; + } const { disableCursor } = group.mutableState; let deltaAsPercentage = 0; @@ -76,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, @@ -89,9 +106,33 @@ export function updateActiveHitRegions({ trigger: "mouse-or-touch" }); + // Preview every moved boundary, deferring the layout update until release. + if ( + group.resizePreviewMode === "separator" && + !commit && + !layoutsEqual(nextLayout, prevLayout) + ) { + previewLayoutMap.set(group, nextLayout); + + 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 |= @@ -109,7 +150,12 @@ export function updateActiveHitRegions({ } } } - } else { + } + + if ( + (group.resizePreviewMode !== "separator" || commit) && + !layoutsEqual(nextLayout, mountedLayout) + ) { updateMountedGroup(current.group, { defaultLayoutDeferred, derivedPanelConstraints: derivedPanelConstraints, @@ -136,6 +182,6 @@ export function updateActiveHitRegions({ cursorFlags |= nextCursorFlags & CURSOR_FLAGS_VERTICAL; } - updateCursorFlags(cursorFlags); + 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 a024db92a..4f352646e 100644 --- a/public/generated/docs/Group.json +++ b/public/generated/docs/Group.json @@ -159,6 +159,22 @@ "name": "onLayoutChanged", "required": false }, + "resizePreviewMode": { + "description": [ + { + "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\"
", + "name": "resizePreviewMode", + "required": false + }, "resizeTargetMinimumSize": { "description": [ { 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/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 041f47e29..28285d93d 100644 --- a/public/generated/site-map.json +++ b/public/generated/site-map.json @@ -75,6 +75,12 @@ "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", @@ -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 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 a37ca5122..447c6acfe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,9 @@ export default function App() { Fixed size panels Disabled panels + + Panel resize behavior + Group resize behavior 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/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/ResizePreviewMode.tsx b/src/routes/examples/ResizePreviewMode.tsx new file mode 100644 index 000000000..d6ccbfc6f --- /dev/null +++ b/src/routes/examples/ResizePreviewMode.tsx @@ -0,0 +1,10 @@ +import { Group, Panel, Separator } from "react-resizable-panels"; + +// + +/* prettier-ignore */ + + left + + 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 + + +