From 23d63d52b72bfd1e88251fee73622535c62ff48b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 9 Aug 2023 13:11:10 -1000 Subject: [PATCH] Replaced PanelGroup validateLayout with Panel units="static" --- .../src/components/Icon.tsx | 7 +- .../src/routes/EndToEndTesting/index.tsx | 126 ++-- .../routes/examples/ExternalPersistence.tsx | 3 + .../src/routes/examples/PixelBasedLayouts.tsx | 166 ++--- .../src/routes/examples/shared.module.css | 13 +- .../src/utils/UrlData.ts | 39 +- .../tests/CursorStyle.spec.ts | 5 +- .../tests/DevelopmentWarnings.spec.ts | 144 ----- .../DevelopmentWarningsAndErrors.spec.ts | 283 ++++++++ .../tests/Panel-StaticUnits.spec.ts | 280 ++++++++ .../usePanelGroupLayoutValidator.spec.ts | 177 ----- .../tests/utils/panels.ts | 25 + .../tests/utils/url.ts | 33 +- packages/react-resizable-panels/CHANGELOG.md | 3 + packages/react-resizable-panels/src/Panel.ts | 81 ++- .../react-resizable-panels/src/PanelGroup.ts | 608 +++++++++--------- .../src/hooks/usePanelGroupLayoutValidator.ts | 118 ---- .../src/hooks/useWindowSplitterBehavior.ts | 24 +- packages/react-resizable-panels/src/index.ts | 18 +- packages/react-resizable-panels/src/types.ts | 11 +- .../react-resizable-panels/src/utils/group.ts | 93 ++- 21 files changed, 1205 insertions(+), 1052 deletions(-) delete mode 100644 packages/react-resizable-panels-website/tests/DevelopmentWarnings.spec.ts create mode 100644 packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts create mode 100644 packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts delete mode 100644 packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts delete mode 100644 packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts diff --git a/packages/react-resizable-panels-website/src/components/Icon.tsx b/packages/react-resizable-panels-website/src/components/Icon.tsx index 1275d070a..8dea6313f 100644 --- a/packages/react-resizable-panels-website/src/components/Icon.tsx +++ b/packages/react-resizable-panels-website/src/components/Icon.tsx @@ -13,7 +13,8 @@ export type IconType = | "resize-horizontal" | "resize-vertical" | "search" - | "typescript"; + | "typescript" + | "warning"; export default function Icon({ className = "", @@ -75,6 +76,10 @@ export default function Icon({ path = "M3,3H21V21H3V3M13.71,17.86C14.21,18.84 15.22,19.59 16.8,19.59C18.4,19.59 19.6,18.76 19.6,17.23C19.6,15.82 18.79,15.19 17.35,14.57L16.93,14.39C16.2,14.08 15.89,13.87 15.89,13.37C15.89,12.96 16.2,12.64 16.7,12.64C17.18,12.64 17.5,12.85 17.79,13.37L19.1,12.5C18.55,11.54 17.77,11.17 16.7,11.17C15.19,11.17 14.22,12.13 14.22,13.4C14.22,14.78 15.03,15.43 16.25,15.95L16.67,16.13C17.45,16.47 17.91,16.68 17.91,17.26C17.91,17.74 17.46,18.09 16.76,18.09C15.93,18.09 15.45,17.66 15.09,17.06L13.71,17.86M13,11.25H8V12.75H9.5V20H11.25V12.75H13V11.25Z"; break; + case "warning": + path = + "M5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3M13,13V7H11V13H13M13,17V15H11V17H13Z"; + break; } return ( diff --git a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx index 1ee087d2e..bb35b4569 100644 --- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx +++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx @@ -1,35 +1,47 @@ -import { ChangeEvent, useRef, useState } from "react"; +import { + ChangeEvent, + Component, + ErrorInfo, + PropsWithChildren, + useRef, + useState, +} from "react"; import { ImperativePanelGroupHandle, ImperativePanelHandle, + getAvailableGroupSizePixels, } from "react-resizable-panels"; -import { urlToUrlData, PanelGroupForUrlData } from "../../utils/UrlData"; +import { urlPanelGroupToPanelGroup, urlToUrlData } from "../../utils/UrlData"; import DebugLog, { ImperativeDebugLogHandle } from "../examples/DebugLog"; -import "./styles.css"; -import styles from "./styles.module.css"; +import { useLayoutEffect } from "react"; import { assertImperativePanelGroupHandle, assertImperativePanelHandle, } from "../../../tests/utils/assert"; -import { useLayoutEffect } from "react"; -import { Metadata } from "../../../tests/utils/url"; +import "./styles.css"; +import styles from "./styles.module.css"; // Special route that can be configured via URL parameters. -export default function EndToEndTesting() { - const [metadata, setMetadata] = useState(() => { - const url = new URL( - typeof window !== undefined ? window.location.href : "" - ); - const metadata = url.searchParams.get("metadata"); +class ErrorBoundary extends Component { + state = { + didError: false, + }; - return metadata ? JSON.parse(metadata) : null; - }); + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error(error); + } + + render() { + return this.state.didError ? null : this.props.children; + } +} - const [urlPanelGroup, setUrlPanelGroup] = useState(() => { +function EndToEndTesting() { + const [urlData, setUrlData] = useState(() => { const url = new URL( typeof window !== undefined ? window.location.href : "" ); @@ -43,52 +55,31 @@ export default function EndToEndTesting() { typeof window !== undefined ? window.location.href : "" ); - setUrlPanelGroup(urlToUrlData(url)); - - const metadata = url.searchParams.get("metadata"); - setMetadata(metadata ? JSON.parse(metadata) : null); + setUrlData(urlToUrlData(url)); }); }, []); useLayoutEffect(() => { + const calculatePanelSize = (panelElement: HTMLElement) => { + if (panelElement.childElementCount > 0) { + return; // Don't override nested groups + } + + const panelSize = parseFloat(panelElement.style.flexGrow); + + const panelGroupElement = panelElement.parentElement!; + const groupId = panelGroupElement.getAttribute("data-panel-group-id")!; + const panelGroupPixels = getAvailableGroupSizePixels(groupId); + + panelElement.textContent = `${panelSize.toFixed(1)}%\n${( + (panelSize / 100) * + panelGroupPixels + ).toFixed(1)}px`; + }; + const observer = new MutationObserver((mutationRecords) => { mutationRecords.forEach((mutationRecord) => { - const panelElement = mutationRecord.target as HTMLElement; - if (panelElement.childElementCount > 0) { - return; - } - - const panelSize = parseFloat(panelElement.style.flexGrow); - - const panelGroupElement = panelElement.parentElement!; - const groupId = panelGroupElement.getAttribute("data-panel-group-id"); - const direction = panelGroupElement.getAttribute( - "data-panel-group-direction" - ); - const resizeHandles = Array.from( - panelGroupElement.querySelectorAll( - `[data-panel-resize-handle-id][data-panel-group-id="${groupId}"]` - ) - ) as HTMLElement[]; - - let panelGroupPixels = - direction === "horizontal" - ? panelGroupElement.offsetWidth - : panelGroupElement.offsetHeight; - if (direction === "horizontal") { - panelGroupPixels -= resizeHandles.reduce((accumulated, handle) => { - return accumulated + handle.offsetWidth; - }, 0); - } else { - panelGroupPixels -= resizeHandles.reduce((accumulated, handle) => { - return accumulated + handle.offsetHeight; - }, 0); - } - - panelElement.textContent = `${panelSize.toFixed(1)}%\n${( - (panelSize / 100) * - panelGroupPixels - ).toFixed(1)}px`; + calculatePanelSize(mutationRecord.target as HTMLElement); }); }); @@ -97,6 +88,8 @@ export default function EndToEndTesting() { observer.observe(element, { attributes: true, }); + + calculatePanelSize(element as HTMLElement); }); return () => { @@ -114,6 +107,10 @@ export default function EndToEndTesting() { Map >(new Map()); + const children = urlData + ? urlPanelGroupToPanelGroup(urlData, debugLogRef, idToRefMapRef) + : null; + const onLayoutInputChange = (event: ChangeEvent) => { const value = event.currentTarget.value; setLayoutString(value); @@ -208,17 +205,16 @@ export default function EndToEndTesting() { -
- {urlPanelGroup && ( - - )} -
+
{children}
); } + +export default function Page() { + return ( + + + + ); +} diff --git a/packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx b/packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx index c5b56521f..4c00c138d 100644 --- a/packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx +++ b/packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx @@ -6,6 +6,7 @@ import ResizeHandle from "../../components/ResizeHandle"; import Example from "./Example"; import styles from "./shared.module.css"; +import Icon from "../../components/Icon"; export default function ExternalPersistence() { return ( @@ -22,11 +23,13 @@ export default function ExternalPersistence() { layout is saved as part of the URL hash.

+ Note the storage API is synchronous. If an async source is used (e.g. a database) then values should be pre-fetched during the initial render (e.g. using Suspense).

+ Note calls to storage.setItem are debounced by{" "} 100ms. Depending on your implementation, you may wish to use a larger interval than that. diff --git a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx index b681f03a6..44971a0e6 100644 --- a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx +++ b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx @@ -1,8 +1,4 @@ -import { - Panel, - PanelGroup, - usePanelGroupLayoutValidator, -} from "react-resizable-panels"; +import { Panel, PanelGroup } from "react-resizable-panels"; import ResizeHandle from "../../components/ResizeHandle"; @@ -14,28 +10,9 @@ import sharedStyles from "./shared.module.css"; import { PropsWithChildren } from "react"; import Code from "../../components/Code"; -import { dir } from "console"; +import Icon from "../../components/Icon"; export default function PixelBasedLayouts() { - const validateLayoutLeft = usePanelGroupLayoutValidator({ - maxPixels: 200, - minPixels: 100, - position: "left", - }); - - const validateLayoutRight = usePanelGroupLayoutValidator({ - collapseBelowPixels: 100, - maxPixels: 300, - minPixels: 200, - position: "right", - }); - - const validateLayoutTop = usePanelGroupLayoutValidator({ - maxPixels: 125, - minPixels: 75, - position: "top", - }); - return (

@@ -45,23 +22,28 @@ export default function PixelBasedLayouts() { →Pixel based layouts

- Resizable panels typically use percentage-based layout constraints. - PanelGroup also supports custom validation functions for - pixel-base constraints. + Resizable panels typically use percentage-based layout constraints, but + pixel units are also supported via the units prop. The + example below shows a horizontal panel group where the first panel is + limited to a range of 100-200 pixels.

-

- The easiest way to do this is with the{" "} - usePanelGroupLayoutValidator hook, as shown in the example - below. +

+ + Pixel units should be used sparingly because they require more complex + layout logic.

- +

100px - 200px

@@ -88,7 +70,7 @@ export default function PixelBasedLayouts() {

Panels with pixel constraints can also be configured to collapse as - shown below + shown below.

@@ -96,7 +78,6 @@ export default function PixelBasedLayouts() {
left
@@ -106,7 +87,14 @@ export default function PixelBasedLayouts() {
middle
- +

200px - 300px

@@ -123,52 +111,6 @@ export default function PixelBasedLayouts() { language="jsx" showLineNumbers /> -
-

Vertical groups can also be managed with this hook.

-
-
-
- - -
- -

75px - 125px

-
-
-
- - -
middle
-
- - -
bottom
-
-
-
-
- -
-

- The validateLayout prop can also be used directly to - implement an entirely custom layout. -

-
-
); } @@ -197,55 +139,21 @@ function Size({ } const CODE_HOOK = ` -const validateLayout = usePanelGroupLayoutValidator({ - maxPixels: 200, - minPixels: 100, - position: "left", -}); - - - {/* Panels ... */} + + + + + + `; const CODE_HOOK_COLLAPSIBLE = ` -const validateLayout = usePanelGroupLayoutValidator({ - collapseBelowPixels: 100, - maxPixels: 300, - minPixels: 200, - position: "right", -}); - - - {/* Panels ... */} + + + + + + `; - -const CODE_HOOK_VERTICAL = ` -const validateLayout = usePanelGroupLayoutValidator({ - maxPixels: 125, - minPixels: 75, - position: "top", -}); - - - {/* Panels ... */} - -`; - -const CODE_CUSTOM = ` -function validateLayout({ - availableHeight, - availableWidth, - nextSizes, - prevSizes, -}: { - availableHeight: number; - availableWidth: number; - nextSizes: number[]; - prevSizes: number[]; -}): number[] { - // Compute and return an array of sizes - // Note the values in the sizes array should total 100 -} -`; diff --git a/packages/react-resizable-panels-website/src/routes/examples/shared.module.css b/packages/react-resizable-panels-website/src/routes/examples/shared.module.css index 1baf009c7..b94c09bf8 100644 --- a/packages/react-resizable-panels-website/src/routes/examples/shared.module.css +++ b/packages/react-resizable-panels-website/src/routes/examples/shared.module.css @@ -84,11 +84,20 @@ } .WarningBlock { - display: inline-block; + display: inline-flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 1ch; background: var(--color-warning-background); - padding: 0.25em 1ch; + padding: 0.5em; border-radius: 0.5rem; } +.WarningIcon { + flex: 0 0 2rem; + width: 2rem; + height: 2rem; +} .InlineCode { margin-right: 1.5ch; diff --git a/packages/react-resizable-panels-website/src/utils/UrlData.ts b/packages/react-resizable-panels-website/src/utils/UrlData.ts index 3beb51b79..4a9875ce4 100644 --- a/packages/react-resizable-panels-website/src/utils/UrlData.ts +++ b/packages/react-resizable-panels-website/src/utils/UrlData.ts @@ -19,9 +19,8 @@ import { PanelResizeHandle, PanelResizeHandleOnDragging, PanelResizeHandleProps, - usePanelGroupLayoutValidator, + PanelUnits, } from "react-resizable-panels"; -import { Metadata } from "../../tests/utils/url"; import { ImperativeDebugLogHandle } from "../routes/examples/DebugLog"; type UrlPanel = { @@ -30,11 +29,12 @@ type UrlPanel = { collapsible?: boolean; defaultSize?: number | null; id?: string | null; - maxSize?: number; + maxSize?: number | null; minSize?: number; order?: number | null; style?: CSSProperties; type: "UrlPanel"; + units: PanelUnits; }; type UrlPanelGroup = { @@ -112,6 +112,7 @@ function UrlPanelToData(urlPanel: ReactElement): UrlPanel { order: urlPanel.props.order, style: urlPanel.props.style, type: "UrlPanel", + units: urlPanel.props.units ?? "relative", }; } @@ -209,16 +210,16 @@ function urlPanelToPanel( order: urlPanel.order, ref: refSetter, style: urlPanel.style, + units: urlPanel.units, }, urlPanel.children.map((child, index) => { if (isUrlPanelGroup(child)) { - return createElement(PanelGroupForUrlData, { + return urlPanelGroupToPanelGroup( + child, debugLogRef, idToRefMapRef, - key: index, - metadata: null, - urlPanelGroup: child, - }); + index + ); } else { return createElement(Fragment, { key: index }, child); } @@ -226,19 +227,14 @@ function urlPanelToPanel( ); } -export function PanelGroupForUrlData({ - debugLogRef, - idToRefMapRef, - metadata, - urlPanelGroup, -}: { - debugLogRef: RefObject; +export function urlPanelGroupToPanelGroup( + urlPanelGroup: UrlPanelGroup, + debugLogRef: RefObject, idToRefMapRef: RefObject< Map - >; - metadata: Metadata | null; - urlPanelGroup: UrlPanelGroup; -}): ReactElement { + >, + key?: any +): ReactElement { let onLayout: PanelGroupOnLayout | undefined = undefined; let refSetter; @@ -261,9 +257,6 @@ export function PanelGroupForUrlData({ }; } - const config = metadata ? metadata.usePanelGroupLayoutValidator : undefined; - const validateLayout = usePanelGroupLayoutValidator((config ?? {}) as any); - return createElement( PanelGroup, { @@ -271,10 +264,10 @@ export function PanelGroupForUrlData({ className: "PanelGroup", direction: urlPanelGroup.direction, id: urlPanelGroup.id, + key: key, onLayout, ref: refSetter, style: urlPanelGroup.style, - validateLayout: config ? validateLayout : undefined, }, urlPanelGroup.children.map((child, index) => { if (isUrlPanel(child)) { diff --git a/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts b/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts index 780b7fa83..8984f889e 100644 --- a/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts +++ b/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts @@ -38,7 +38,10 @@ test.describe("cursor style", () => { createElement( PanelGroup, { direction }, - createElement(Panel, { defaultSize: 50, id: "first-panel" }), + createElement(Panel, { + defaultSize: 50, + id: "first-panel", + }), createElement(PanelResizeHandle), createElement(Panel, { defaultSize: 50, id: "last-panel" }) ) diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarnings.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarnings.spec.ts deleted file mode 100644 index 27eb106ee..000000000 --- a/packages/react-resizable-panels-website/tests/DevelopmentWarnings.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { createElement } from "react"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; - -import { goToUrl, updateUrl } from "./utils/url"; - -function createElements({ - numPanels, - omitIdProp = false, - omitOrderProp = false, -}: { - numPanels: 1 | 2; - omitIdProp?: boolean; - omitOrderProp?: boolean; -}) { - const panels = [ - createElement(Panel, { - collapsible: true, - defaultSize: numPanels === 2 ? 50 : 100, - id: omitIdProp ? undefined : "left", - order: omitOrderProp ? undefined : 1, - }), - ]; - - if (numPanels === 2) { - panels.push( - createElement(PanelResizeHandle, { id: "right-handle" }), - createElement(Panel, { - collapsible: true, - defaultSize: 50, - id: omitIdProp ? undefined : "right", - order: omitOrderProp ? undefined : 2, - }) - ); - } - - return createElement( - PanelGroup, - { direction: "horizontal", id: "group" }, - ...panels - ); -} - -test.describe("Development warnings", () => { - test.describe("conditional panels", () => { - test("should warning about missing id props", async ({ page }) => { - await goToUrl( - page, - createElements({ - omitIdProp: true, - numPanels: 1, - }) - ); - - const warnings: string[] = []; - page.on("console", (message) => { - if (message.type() === "warning") { - warnings.push(message.text()); - } - }); - - await updateUrl( - page, - createElements({ - omitIdProp: true, - numPanels: 2, - }) - ); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("id and order props recommended"); - - await updateUrl( - page, - createElements({ - omitIdProp: true, - numPanels: 1, - }) - ); - expect(warnings).toHaveLength(1); - }); - - test("should warning about missing order props", async ({ page }) => { - await goToUrl( - page, - createElements({ - omitOrderProp: true, - numPanels: 1, - }) - ); - - const warnings: string[] = []; - page.on("console", (message) => { - if (message.type() === "warning") { - warnings.push(message.text()); - } - }); - - await updateUrl( - page, - createElements({ - omitOrderProp: true, - numPanels: 2, - }) - ); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("id and order props recommended"); - - await updateUrl( - page, - createElements({ - omitOrderProp: true, - numPanels: 1, - }) - ); - expect(warnings).toHaveLength(1); - }); - - test("should not warn if id an order props are specified", async ({ - page, - }) => { - await goToUrl( - page, - createElements({ - numPanels: 1, - }) - ); - - const warnings: string[] = []; - page.on("console", (message) => { - if (message.type() === "warning") { - warnings.push(message.text()); - } - }); - - await updateUrl( - page, - createElements({ - numPanels: 2, - }) - ); - expect(warnings).toHaveLength(0); - }); - }); -}); diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts new file mode 100644 index 000000000..1d6866809 --- /dev/null +++ b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts @@ -0,0 +1,283 @@ +import { Page, expect, test } from "@playwright/test"; +import { createElement } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; + +import { goToUrl, updateUrl } from "./utils/url"; + +function createElements({ + numPanels, + omitIdProp = false, + omitOrderProp = false, +}: { + numPanels: 1 | 2; + omitIdProp?: boolean; + omitOrderProp?: boolean; +}) { + const panels = [ + createElement(Panel, { + collapsible: true, + defaultSize: numPanels === 2 ? 50 : 100, + id: omitIdProp ? undefined : "left", + order: omitOrderProp ? undefined : 1, + }), + ]; + + if (numPanels === 2) { + panels.push( + createElement(PanelResizeHandle, { id: "right-handle" }), + createElement(Panel, { + collapsible: true, + defaultSize: 50, + id: omitIdProp ? undefined : "right", + order: omitOrderProp ? undefined : 2, + }) + ); + } + + return createElement( + PanelGroup, + { direction: "horizontal", id: "group" }, + ...panels + ); +} + +async function flushMessages(page: Page) { + await goToUrl(page, createElement(PanelGroup)); +} + +test.describe("Development warnings and errors", () => { + const errors: string[] = []; + const warnings: string[] = []; + + test.beforeEach(({ page }) => { + errors.splice(0); + warnings.splice(0); + + page.on("console", (message) => { + switch (message.type()) { + case "error": + errors.push(message.text()); + break; + case "warning": + warnings.push(message.text()); + break; + } + }); + }); + + test.describe("conditional panels", () => { + test("should warning about missing id props", async ({ page }) => { + await goToUrl( + page, + createElements({ + omitIdProp: true, + numPanels: 1, + }) + ); + + await updateUrl( + page, + createElements({ + omitIdProp: true, + numPanels: 2, + }) + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("id and order props recommended"); + + await updateUrl( + page, + createElements({ + omitIdProp: true, + numPanels: 1, + }) + ); + expect(warnings).toHaveLength(1); + }); + + test("should warning about missing order props", async ({ page }) => { + await goToUrl( + page, + createElements({ + omitOrderProp: true, + numPanels: 1, + }) + ); + + await updateUrl( + page, + createElements({ + omitOrderProp: true, + numPanels: 2, + }) + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("id and order props recommended"); + + await updateUrl( + page, + createElements({ + omitOrderProp: true, + numPanels: 1, + }) + ); + expect(warnings).toHaveLength(1); + }); + + test("should not warn if id an order props are specified", async ({ + page, + }) => { + await goToUrl( + page, + createElements({ + numPanels: 1, + }) + ); + + await updateUrl( + page, + createElements({ + numPanels: 2, + }) + ); + expect(warnings).toHaveLength(0); + }); + + test("should throw if defaultSize is less than 0", async ({ page }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { defaultSize: -1 }) + ) + ); + + await flushMessages(page); + + expect(errors).not.toHaveLength(0); + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("Invalid Panel defaultSize provided, -1"), + ]) + ); + }); + + test("should throw if defaultSize is greater than 100 and units are relative", async ({ + page, + }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { defaultSize: 400 }) + ) + ); + + await flushMessages(page); + + expect(errors).not.toHaveLength(0); + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("Invalid Panel defaultSize provided, 400"), + ]) + ); + }); + + test("should not throw if defaultSize is greater than 100 and units are static", async ({ + page, + }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { defaultSize: 400, units: "static" }) + ) + ); + + await flushMessages(page); + + expect(errors).toHaveLength(0); + }); + + test("should warn if defaultSize is less than minSize", async ({ + page, + }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { defaultSize: 25, minSize: 50 }), + createElement(PanelResizeHandle), + createElement(Panel) + ) + ); + + await flushMessages(page); + + expect(errors).not.toHaveLength(0); + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Panel minSize (50) cannot be greater than defaultSize (25)" + ), + ]) + ); + }); + + test("should warn if defaultSize is greater than maxSize", async ({ + page, + }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { defaultSize: 75, maxSize: 50 }), + createElement(PanelResizeHandle), + createElement(Panel) + ) + ); + + await flushMessages(page); + + expect(errors).not.toHaveLength(0); + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Panel maxSize (50) cannot be less than defaultSize (75)" + ), + ]) + ); + }); + + test("should warn if total defaultSizes do not add up to 100", async ({ + page, + }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal" }, + createElement(Panel, { defaultSize: 25 }), + createElement(PanelResizeHandle), + createElement(Panel, { defaultSize: 25 }) + ) + ); + + await flushMessages(page); + + expect(errors).not.toHaveLength(0); + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Invalid panel group configuration; default panel sizes should total 100 but was 50" + ), + ]) + ); + }); + }); +}); diff --git a/packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts b/packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts new file mode 100644 index 000000000..f27c3fb35 --- /dev/null +++ b/packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts @@ -0,0 +1,280 @@ +import { Page, test } from "@playwright/test"; +import { createElement } from "react"; +import { + Panel, + PanelGroup, + PanelGroupProps, + PanelProps, + PanelResizeHandle, + PanelResizeHandleProps, +} from "react-resizable-panels"; + +import { + dragResizeBy, + imperativeResizePanel, + verifyPanelSizePixels, +} from "./utils/panels"; +import { goToUrl } from "./utils/url"; + +async function goToUrlHelper( + page: Page, + props: { + leftPanelProps?: PanelProps; + leftResizeHandleProps?: PanelResizeHandleProps; + middlePanelProps?: PanelProps; + panelGroupProps?: PanelGroupProps; + rightPanelProps?: PanelProps; + rightResizeHandleProps?: PanelResizeHandleProps; + } = {} +) { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal", id: "group", ...props.panelGroupProps }, + createElement(Panel, { + id: "left-panel", + ...props.leftPanelProps, + }), + createElement(PanelResizeHandle, { + id: "left-resize-handle", + ...props.leftResizeHandleProps, + }), + createElement(Panel, { + id: "middle-panel", + ...props.middlePanelProps, + }), + createElement(PanelResizeHandle, { + id: "right-resize-handle", + ...props.rightResizeHandleProps, + }), + createElement(Panel, { + id: "right-panel", + ...props.rightPanelProps, + }) + ) + ); +} + +test.describe("Static Panel units", () => { + test.describe("initial layout", () => { + test("should observe max size constraint for default layout", async ({ + page, + }) => { + // Static left panel + await goToUrlHelper(page, { + leftPanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + const leftPanel = page.locator('[data-panel-id="left-panel"]'); + await verifyPanelSizePixels(leftPanel, 100); + + // Static middle panel + await goToUrlHelper(page, { + middlePanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + const middlePanel = page.locator('[data-panel-id="middle-panel"]'); + await verifyPanelSizePixels(middlePanel, 100); + + // Static right panel + await goToUrlHelper(page, { + rightPanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + const rightPanel = page.locator('[data-panel-id="right-panel"]'); + await verifyPanelSizePixels(rightPanel, 100); + }); + + test("should observe min size constraint for default layout", async ({ + page, + }) => { + await goToUrlHelper(page, { + leftPanelProps: { maxSize: 300, minSize: 200, units: "static" }, + }); + + const leftPanel = page.locator("[data-panel]").first(); + await verifyPanelSizePixels(leftPanel, 200); + }); + + test("should honor min/max constraint when resizing via keyboard", async ({ + page, + }) => { + await goToUrlHelper(page, { + leftPanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + + const leftPanel = page.locator("[data-panel]").first(); + await verifyPanelSizePixels(leftPanel, 100); + + const resizeHandle = page + .locator("[data-panel-resize-handle-id]") + .first(); + await resizeHandle.focus(); + + await page.keyboard.press("Home"); + await verifyPanelSizePixels(leftPanel, 50); + + await page.keyboard.press("End"); + await verifyPanelSizePixels(leftPanel, 100); + }); + + test("should honor min/max constraint when resizing via mouse", async ({ + page, + }) => { + await goToUrlHelper(page, { + leftPanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + + const leftPanel = page.locator("[data-panel]").first(); + + await dragResizeBy(page, "left-resize-handle", -100); + await verifyPanelSizePixels(leftPanel, 50); + + await dragResizeBy(page, "left-resize-handle", 200); + await verifyPanelSizePixels(leftPanel, 100); + }); + + test("should honor min/max constraint when resizing via imperative Panel API", async ({ + page, + }) => { + await goToUrlHelper(page, { + leftPanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + + const leftPanel = page.locator("[data-panel]").first(); + + await imperativeResizePanel(page, "left-panel", 80); + await verifyPanelSizePixels(leftPanel, 100); + + await imperativeResizePanel(page, "left-panel", 4); + await verifyPanelSizePixels(leftPanel, 50); + }); + + test("should honor min/max constraint when indirectly resizing via imperative Panel API", async ({ + page, + }) => { + await goToUrlHelper(page, { + rightPanelProps: { maxSize: 100, minSize: 50, units: "static" }, + }); + + const rightPanel = page.locator("[data-panel]").last(); + + await imperativeResizePanel(page, "middle-panel", 1); + await verifyPanelSizePixels(rightPanel, 100); + + await imperativeResizePanel(page, "middle-panel", 98); + await verifyPanelSizePixels(rightPanel, 50); + }); + + test("should support collapsable panels", async ({ page }) => { + await goToUrlHelper(page, { + leftPanelProps: { + collapsible: true, + minSize: 100, + maxSize: 200, + units: "static", + }, + }); + + const leftPanel = page.locator("[data-panel]").first(); + + await imperativeResizePanel(page, "left-panel", 25); + await verifyPanelSizePixels(leftPanel, 100); + + await imperativeResizePanel(page, "left-panel", 10); + await verifyPanelSizePixels(leftPanel, 0); + + await imperativeResizePanel(page, "left-panel", 15); + await verifyPanelSizePixels(leftPanel, 100); + }); + }); + + test("should observe min size constraint if the overall group size shrinks", async ({ + page, + }) => { + await goToUrlHelper(page, { + leftPanelProps: { + defaultSize: 50, + maxSize: 100, + minSize: 50, + units: "static", + }, + }); + const leftPanel = page.locator('[data-panel-id="left-panel"]'); + await verifyPanelSizePixels(leftPanel, 50); + + await page.setViewportSize({ width: 300, height: 300 }); + await verifyPanelSizePixels(leftPanel, 50); + }); + + test("should observe max size constraint if the overall group size expands", async ({ + page, + }) => { + await goToUrlHelper(page, { + leftPanelProps: { + defaultSize: 100, + maxSize: 100, + minSize: 50, + units: "static", + }, + }); + + const leftPanel = page.locator('[data-panel-id="left-panel"]'); + + await verifyPanelSizePixels(leftPanel, 100); + + await page.setViewportSize({ width: 500, height: 300 }); + await verifyPanelSizePixels(leftPanel, 100); + }); + + test("should observe max size constraint for multiple panels", async ({ + page, + }) => { + await goToUrl( + page, + createElement( + PanelGroup, + { direction: "horizontal", id: "group" }, + createElement(Panel, { + id: "first-panel", + minSize: 50, + maxSize: 75, + units: "static", + }), + createElement(PanelResizeHandle, { + id: "first-resize-handle", + }), + createElement(Panel, { + id: "second-panel", + }), + createElement(PanelResizeHandle, { + id: "second-resize-handle", + }), + createElement(Panel, { + id: "third-panel", + }), + createElement(PanelResizeHandle, { + id: "third-resize-handle", + }), + createElement(Panel, { + id: "fourth-panel", + minSize: 50, + maxSize: 75, + units: "static", + }) + ) + ); + + const firstPanel = page.locator('[data-panel-id="first-panel"]'); + await verifyPanelSizePixels(firstPanel, 75); + + const fourthPanel = page.locator('[data-panel-id="fourth-panel"]'); + await verifyPanelSizePixels(fourthPanel, 75); + + await dragResizeBy(page, "second-resize-handle", -200); + await verifyPanelSizePixels(firstPanel, 50); + await verifyPanelSizePixels(fourthPanel, 75); + + await dragResizeBy(page, "second-resize-handle", 400); + await verifyPanelSizePixels(firstPanel, 50); + await verifyPanelSizePixels(fourthPanel, 50); + }); +}); diff --git a/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts deleted file mode 100644 index 425b2fbfe..000000000 --- a/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Page, test } from "@playwright/test"; -import { createElement } from "react"; -import { - Panel, - PanelGroup, - PanelGroupProps, - PanelProps, - PanelResizeHandle, - usePanelGroupLayoutValidator, -} from "react-resizable-panels"; - -import { - dragResizeTo, - imperativeResizePanel, - imperativeResizePanelGroup, - verifyPanelSizePixels, -} from "./utils/panels"; -import { goToUrl } from "./utils/url"; - -type HookConfig = Parameters[0]; - -async function goToUrlHelper( - page: Page, - panelProps: { - leftPanelProps?: PanelProps; - middlePanelProps?: PanelProps; - panelGroupProps?: PanelGroupProps; - rightPanelProps?: PanelProps; - } = {}, - hookConfig?: Partial -) { - await goToUrl( - page, - createElement( - PanelGroup, - { direction: "horizontal", id: "group", ...panelProps.panelGroupProps }, - createElement(Panel, { - id: "left-panel", - minSize: 5, - ...panelProps.leftPanelProps, - }), - createElement(PanelResizeHandle), - createElement(Panel, { - id: "middle-panel", - minSize: 5, - ...panelProps.middlePanelProps, - }), - createElement(PanelResizeHandle), - createElement(Panel, { - id: "right-panel", - minSize: 5, - ...panelProps.rightPanelProps, - }) - ), - { - usePanelGroupLayoutValidator: { - minPixels: 50, - maxPixels: 100, - position: "left", - ...hookConfig, - }, - } - ); -} - -test.describe("usePanelGroupLayoutValidator", () => { - test.describe("initial layout", () => { - test("should observe max size constraint for default layout", async ({ - page, - }) => { - await goToUrlHelper(page, { - middlePanelProps: { defaultSize: 20 }, - rightPanelProps: { defaultSize: 20 }, - }); - - const leftPanel = page.locator("[data-panel]").first(); - await verifyPanelSizePixels(leftPanel, 100); - }); - - test("should observe min size constraint for default layout", async ({ - page, - }) => { - await goToUrlHelper(page, { - middlePanelProps: { defaultSize: 45 }, - rightPanelProps: { defaultSize: 45 }, - }); - - const leftPanel = page.locator("[data-panel]").first(); - await verifyPanelSizePixels(leftPanel, 50); - }); - - test("should honor min/max constraint when resizing via keyboard", async ({ - page, - }) => { - await goToUrlHelper(page); - - const leftPanel = page.locator("[data-panel]").first(); - await verifyPanelSizePixels(leftPanel, 100); - - const resizeHandle = page - .locator("[data-panel-resize-handle-id]") - .first(); - await resizeHandle.focus(); - - await page.keyboard.press("Home"); - await verifyPanelSizePixels(leftPanel, 50); - - await page.keyboard.press("End"); - await verifyPanelSizePixels(leftPanel, 100); - }); - - test("should honor min/max constraint when resizing via mouse", async ({ - page, - }) => { - await goToUrlHelper(page); - - const leftPanel = page.locator("[data-panel]").first(); - - await dragResizeTo(page, "left-panel", { size: 50 }); - await verifyPanelSizePixels(leftPanel, 100); - - await dragResizeTo(page, "left-panel", { size: 0 }); - await verifyPanelSizePixels(leftPanel, 50); - }); - - test("should honor min/max constraint when resizing via imperative Panel API", async ({ - page, - }) => { - await goToUrlHelper(page); - - const leftPanel = page.locator("[data-panel]").first(); - - await imperativeResizePanel(page, "left-panel", 80); - await verifyPanelSizePixels(leftPanel, 100); - - await imperativeResizePanel(page, "left-panel", 4); - await verifyPanelSizePixels(leftPanel, 50); - }); - - test("should honor min/max constraint when resizing via imperative PanelGroup API", async ({ - page, - }) => { - await goToUrlHelper(page); - - const leftPanel = page.locator("[data-panel]").first(); - - await imperativeResizePanelGroup(page, "group", [80, 10, 10]); - await verifyPanelSizePixels(leftPanel, 100); - - await imperativeResizePanelGroup(page, "group", [5, 55, 40]); - await verifyPanelSizePixels(leftPanel, 50); - }); - - test("should support collapsable panels", async ({ page }) => { - await goToUrlHelper( - page, - {}, - { - collapseBelowPixels: 50, - minPixels: 100, - maxPixels: 200, - } - ); - - const leftPanel = page.locator("[data-panel]").first(); - - await imperativeResizePanel(page, "left-panel", 25); - await verifyPanelSizePixels(leftPanel, 100); - - await imperativeResizePanel(page, "left-panel", 10); - await verifyPanelSizePixels(leftPanel, 0); - - await imperativeResizePanel(page, "left-panel", 15); - await verifyPanelSizePixels(leftPanel, 100); - }); - }); -}); diff --git a/packages/react-resizable-panels-website/tests/utils/panels.ts b/packages/react-resizable-panels-website/tests/utils/panels.ts index f39aea778..29b8c5481 100644 --- a/packages/react-resizable-panels-website/tests/utils/panels.ts +++ b/packages/react-resizable-panels-website/tests/utils/panels.ts @@ -9,6 +9,31 @@ type Operation = { size: number; }; +export async function dragResizeBy( + page: Page, + panelResizeHandleId: string, + delta: number +) { + const dragHandle = page.locator( + `[data-panel-resize-handle-id="${panelResizeHandleId}"]` + ); + const direction = await dragHandle.getAttribute( + "data-panel-group-direction" + )!; + + let dragHandleRect = (await dragHandle.boundingBox())!; + let pageX = dragHandleRect.x + dragHandleRect.width / 2; + let pageY = dragHandleRect.y + dragHandleRect.height / 2; + + await page.mouse.move(pageX, pageY); + await page.mouse.down(); + await page.mouse.move( + direction === "horizontal" ? pageX + delta : pageX, + direction === "vertical" ? pageY + delta : pageY + ); + await page.mouse.up(); +} + export async function dragResizeTo( page: Page, panelId: string, diff --git a/packages/react-resizable-panels-website/tests/utils/url.ts b/packages/react-resizable-panels-website/tests/utils/url.ts index cfc184eae..88f6bb1b0 100644 --- a/packages/react-resizable-panels-website/tests/utils/url.ts +++ b/packages/react-resizable-panels-website/tests/utils/url.ts @@ -1,53 +1,42 @@ import { Page } from "@playwright/test"; import { ReactElement } from "react"; -import { - PanelGroupProps, - usePanelGroupLayoutValidator, -} from "react-resizable-panels"; +import { PanelGroupProps } from "react-resizable-panels"; import { UrlPanelGroupToEncodedString } from "../../src/utils/UrlData"; -export type Metadata = { - usePanelGroupLayoutValidator?: Parameters< - typeof usePanelGroupLayoutValidator - >[0]; -}; - export async function goToUrl( page: Page, - element: ReactElement, - metadata?: Metadata + element: ReactElement ) { const encodedString = UrlPanelGroupToEncodedString(element); const url = new URL("http://localhost:1234/__e2e"); url.searchParams.set("urlPanelGroup", encodedString); - url.searchParams.set("metadata", metadata ? JSON.stringify(metadata) : ""); + + // Uncomment when testing for easier debugging + // console.log(url.toString()); await page.goto(url.toString()); } export async function updateUrl( page: Page, - element: ReactElement, - metadata?: Metadata + element: ReactElement ) { - const urlPanelGroupString = UrlPanelGroupToEncodedString(element); - const metadataString = metadata ? JSON.stringify(metadata) : ""; + const encodedString = UrlPanelGroupToEncodedString(element); await page.evaluate( - ([metadataString, urlPanelGroupString]) => { + ([encodedString]) => { const url = new URL(window.location.href); - url.searchParams.set("urlPanelGroup", urlPanelGroupString); - url.searchParams.set("metadata", metadataString); + url.searchParams.set("urlPanelGroup", encodedString); window.history.pushState( - { urlPanelGroup: urlPanelGroupString }, + { urlPanelGroup: encodedString }, "", url.toString() ); window.dispatchEvent(new Event("popstate")); }, - [metadataString, urlPanelGroupString] + [encodedString] ); } diff --git a/packages/react-resizable-panels/CHANGELOG.md b/packages/react-resizable-panels/CHANGELOG.md index 1e3a81152..5dba64fdc 100644 --- a/packages/react-resizable-panels/CHANGELOG.md +++ b/packages/react-resizable-panels/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.0.55 +* New `units` prop added to `Panel` to support pixel-based panel size constraints. + ## 0.0.54 * [172](https://github.com/bvaughn/react-resizable-panels/issues/172): Development warning added to `PanelGroup` for conditionally-rendered `Panel`(s) that don't have `id` and `order` props * [156](https://github.com/bvaughn/react-resizable-panels/pull/156): Package exports now used to select between node (server-rendering) and browser (client-rendering) bundles diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts index 026b88e99..6798206f7 100644 --- a/packages/react-resizable-panels/src/Panel.ts +++ b/packages/react-resizable-panels/src/Panel.ts @@ -19,7 +19,9 @@ import { PanelData, PanelOnCollapse, PanelOnResize, + PanelUnits, } from "./types"; +import { isDevelopment } from "#is-development"; export type PanelProps = { children?: ReactNode; @@ -28,13 +30,14 @@ export type PanelProps = { collapsible?: boolean; defaultSize?: number | null; id?: string | null; - maxSize?: number; + maxSize?: number | null; minSize?: number; onCollapse?: PanelOnCollapse | null; onResize?: PanelOnResize | null; order?: number | null; style?: CSSProperties; tagName?: ElementType; + units?: PanelUnits; }; export type ImperativePanelHandle = { @@ -53,13 +56,14 @@ function PanelWithForwardedRef({ defaultSize = null, forwardedRef, id: idFromProps = null, - maxSize = 100, + maxSize = null, minSize = 10, onCollapse = null, onResize = null, order = null, style: styleFromProps = {}, tagName: Type = "div", + units = "relative", }: PanelProps & { forwardedRef: ForwardedRef; }) { @@ -92,23 +96,65 @@ function PanelWithForwardedRef({ }); // Basic props validation - if (minSize < 0 || minSize > 100) { - throw Error(`Panel minSize must be between 0 and 100, but was ${minSize}`); - } else if (maxSize < 0 || maxSize > 100) { - throw Error(`Panel maxSize must be between 0 and 100, but was ${maxSize}`); - } else { - if (defaultSize !== null) { - if (defaultSize < 0 || defaultSize > 100) { - throw Error( - `Panel defaultSize must be between 0 and 100, but was ${defaultSize}` - ); - } else if (minSize > defaultSize && !collapsible) { + if (minSize < 0) { + if (isDevelopment) { + console.error(`Invalid Panel minSize provided, ${minSize}`); + } + + minSize = 0; + } else if (units === "relative" && minSize > 100) { + if (isDevelopment) { + console.error(`Invalid Panel minSize provided, ${minSize}`); + } + + minSize = 0; + } + + if (maxSize != null) { + if (maxSize < 0) { + if (isDevelopment) { + console.error(`Invalid Panel maxSize provided, ${maxSize}`); + } + + maxSize = null; + } else if (units === "relative" && maxSize > 100) { + if (isDevelopment) { + console.error(`Invalid Panel maxSize provided, ${maxSize}`); + } + + maxSize = null; + } + } + + if (defaultSize !== null) { + if (defaultSize < 0) { + if (isDevelopment) { + console.error(`Invalid Panel defaultSize provided, ${defaultSize}`); + } + + defaultSize = null; + } else if (defaultSize > 100 && units === "relative") { + if (isDevelopment) { + console.error(`Invalid Panel defaultSize provided, ${defaultSize}`); + } + + defaultSize = null; + } else if (defaultSize < minSize && !collapsible) { + if (isDevelopment) { console.error( - `Panel minSize ${minSize} cannot be greater than defaultSize ${defaultSize}` + `Panel minSize (${minSize}) cannot be greater than defaultSize (${defaultSize})` ); + } - defaultSize = minSize; + defaultSize = minSize; + } else if (maxSize != null && defaultSize > maxSize) { + if (isDevelopment) { + console.error( + `Panel maxSize (${maxSize}) cannot be less than defaultSize (${defaultSize})` + ); } + + defaultSize = maxSize; } } @@ -126,9 +172,10 @@ function PanelWithForwardedRef({ defaultSize: number | null; id: string; idWasAutoGenerated: boolean; - maxSize: number; + maxSize: number | null; minSize: number; order: number | null; + units: PanelUnits; }>({ callbacksRef, collapsedSize, @@ -139,6 +186,7 @@ function PanelWithForwardedRef({ maxSize, minSize, order, + units, }); useIsomorphicLayoutEffect(() => { committedValuesRef.current.size = parseSizeFromStyle(style); @@ -152,6 +200,7 @@ function PanelWithForwardedRef({ panelDataRef.current.maxSize = maxSize; panelDataRef.current.minSize = minSize; panelDataRef.current.order = order; + panelDataRef.current.units = units; }); useIsomorphicLayoutEffect(() => { diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts index e46dc0ef0..95fd87c34 100644 --- a/packages/react-resizable-panels/src/PanelGroup.ts +++ b/packages/react-resizable-panels/src/PanelGroup.ts @@ -24,7 +24,6 @@ import { PanelData, PanelGroupOnLayout, PanelGroupStorage, - PanelGroupValidateLayout, ResizeEvent, } from "./types"; import { areEqual } from "./utils/arrays"; @@ -40,12 +39,12 @@ import debounce from "./utils/debounce"; import { adjustByDelta, callPanelCallbacks, + getAvailableGroupSizePixels, getBeforeAndAfterIds, getFlexGrow, getPanelGroup, getResizeHandle, getResizeHandlePanelIds, - getResizeHandlesForGroup, panelsMapToSortedArray, } from "./utils/group"; import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization"; @@ -97,6 +96,8 @@ const defaultStorage: PanelGroupStorage = { export type CommittedValues = { direction: Direction; + id: string; + panelIdsWithStaticUnits: Set; panels: Map; sizes: number[]; }; @@ -131,7 +132,6 @@ export type PanelGroupProps = { storage?: PanelGroupStorage; style?: CSSProperties; tagName?: ElementType; - validateLayout?: PanelGroupValidateLayout; }; export type ImperativePanelGroupHandle = { @@ -151,7 +151,6 @@ function PanelGroupWithForwardedRef({ storage = defaultStorage, style: styleFromProps = {}, tagName: Type = "div", - validateLayout, }: PanelGroupProps & { forwardedRef: ForwardedRef; }) { @@ -180,89 +179,15 @@ function PanelGroupWithForwardedRef({ // Use a ref to guard against users passing inline props const callbacksRef = useRef<{ onLayout: PanelGroupOnLayout | undefined; - validateLayout: PanelGroupValidateLayout | undefined; - }>({ onLayout, validateLayout }); + }>({ onLayout }); useEffect(() => { callbacksRef.current.onLayout = onLayout; - callbacksRef.current.validateLayout = validateLayout; }); const panelIdToLastNotifiedSizeMapRef = useRef>({}); // 0-1 values representing the relative size of each panel. - const [sizes, setSizesUnsafe] = useState([]); - - const validateLayoutHelper = useCallback( - (nextSizes: number[]) => { - const { direction, sizes: prevSizes } = committedValuesRef.current; - const { validateLayout } = callbacksRef.current; - if (validateLayout) { - const groupElement = getPanelGroup(groupId)!; - const resizeHandles = getResizeHandlesForGroup(groupId); - - let availableHeight = groupElement.offsetHeight; - let availableWidth = groupElement.offsetWidth; - - if (direction === "horizontal") { - availableWidth -= resizeHandles.reduce((accumulated, handle) => { - return accumulated + handle.offsetWidth; - }, 0); - } else { - availableHeight -= resizeHandles.reduce((accumulated, handle) => { - return accumulated + handle.offsetHeight; - }, 0); - } - - let nextSizesBefore; - if (isDevelopment) { - nextSizesBefore = [...nextSizes]; - } - - nextSizes = validateLayout({ - availableHeight, - availableWidth, - nextSizes, - prevSizes, - }); - - if (isDevelopment) { - const { didLogInvalidLayoutWarning } = devWarningsRef.current; - if (!didLogInvalidLayoutWarning) { - const total = nextSizes.reduce( - (accumulated, current) => accumulated + current, - 0 - ); - if (total < 99 || total > 101) { - devWarningsRef.current.didLogInvalidLayoutWarning = true; - - console.warn( - "Invalid layout.\nGiven:", - nextSizesBefore, - "\nReturned:", - nextSizes - ); - } - } - } - } - - return nextSizes; - }, - [groupId] - ); - - const setSizes = useCallback( - (nextSizes: number[]) => { - const { sizes: prevSizes } = committedValuesRef.current; - - nextSizes = validateLayoutHelper(nextSizes); - - if (!areEqual(prevSizes, nextSizes)) { - setSizesUnsafe(nextSizes); - } - }, - [validateLayoutHelper] - ); + const [sizes, setSizes] = useState([]); // Used to support imperative collapse/expand API. const panelSizeBeforeCollapse = useRef>(new Map()); @@ -272,6 +197,8 @@ function PanelGroupWithForwardedRef({ // Store committed values to avoid unnecessarily re-running memoization/effects functions. const committedValuesRef = useRef({ direction, + id: groupId, + panelIdsWithStaticUnits: new Set(), panels, sizes, }); @@ -296,16 +223,21 @@ function PanelGroupWithForwardedRef({ panelIdToLastNotifiedSizeMapRef.current; const panelsArray = panelsMapToSortedArray(panels); + // Note this API does not validate min/max sizes or "static" units + // There would be too many edge cases to handle + // Use the API at your own risk + setSizes(sizes); callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap); }, }), - [setSizes] + [] ); useIsomorphicLayoutEffect(() => { committedValuesRef.current.direction = direction; + committedValuesRef.current.id = groupId; committedValuesRef.current.panels = panels; committedValuesRef.current.sizes = sizes; }); @@ -347,7 +279,11 @@ function PanelGroupWithForwardedRef({ // Compute the initial sizes based on default weights. // This assumes that panels register during initial mount (no conditional rendering)! useIsomorphicLayoutEffect(() => { - const sizes = committedValuesRef.current.sizes; + const { + id: groupId, + panelIdsWithStaticUnits, + sizes, + } = committedValuesRef.current; if (sizes.length === panels.size) { // Only compute (or restore) default sizes once per panel configuration. return; @@ -361,53 +297,100 @@ function PanelGroupWithForwardedRef({ defaultSizes = loadPanelLayout(autoSaveId, panelsArray, storage); } + let groupSizePixels = + panelIdsWithStaticUnits.size > 0 + ? getAvailableGroupSizePixels(groupId) + : NaN; + if (defaultSizes != null) { setSizes(defaultSizes); } else { const panelsArray = panelsMapToSortedArray(panels); - let panelsWithNullDefaultSize = 0; let totalDefaultSize = 0; - let totalMinSize = 0; - // TODO - // Implicit default size calculations below do not account for inferred min/max size values. - // e.g. if Panel A has a maxSize of 40 then Panels A and B can't both have an implicit default size of 50. - // For now, these logic edge cases are left to the user to handle via props. + const panelsWithSizes = new Set(); + const sizes = Array(panelsArray.length); + + // Assigning default sizes requires a couple of passes: + // First, all panels with defaultSize should be set as-is + for (let index = 0; index < panelsArray.length; index++) { + const panel = panelsArray[index]; + const { defaultSize, id, maxSize, minSize, units } = panel.current; - panelsArray.forEach((panel) => { - totalMinSize += panel.current.minSize; + if (defaultSize != null) { + panelsWithSizes.add(id); - if (panel.current.defaultSize === null) { - panelsWithNullDefaultSize++; - } else { - totalDefaultSize += panel.current.defaultSize; + sizes[index] = + units === "static" + ? (defaultSize / groupSizePixels) * 100 + : defaultSize; + + totalDefaultSize += sizes[index]; } - }); - - if (totalDefaultSize > 100) { - throw new Error(`Default panel sizes cannot exceed 100%`); - } else if ( - panelsArray.length > 1 && - panelsWithNullDefaultSize === 0 && - totalDefaultSize !== 100 - ) { - throw new Error(`Invalid default sizes specified for panels`); - } else if (totalMinSize > 100) { - throw new Error(`Minimum panel sizes cannot exceed 100%`); } - setSizes( - panelsArray.map((panel) => { - if (panel.current.defaultSize === null) { - return (100 - totalDefaultSize) / panelsWithNullDefaultSize; + // Remaining total size should be distributed evenly between panels in two additional passes. + // First, panels with minSize/maxSize should get their portions + for (let index = 0; index < panelsArray.length; index++) { + const panel = panelsArray[index]; + let { id, maxSize, minSize, units } = panel.current; + if (panelsWithSizes.has(id)) { + continue; + } + + if (units === "static") { + minSize = (minSize / groupSizePixels) * 100; + if (maxSize != null) { + maxSize = (maxSize / groupSizePixels) * 100; } + } - return panel.current.defaultSize; - }) - ); + if (minSize === 0 && (maxSize === null || maxSize === 100)) { + continue; + } + + const remainingSize = 100 - totalDefaultSize; + const remainingPanels = panelsArray.length - panelsWithSizes.size; + const size = Math.min( + maxSize != null ? maxSize : 100, + Math.max(minSize, remainingSize / remainingPanels) + ); + + sizes[index] = size; + totalDefaultSize += size; + panelsWithSizes.add(id); + } + + // And finally: The remaining size should be evenly distributed between the remaining panels + for (let index = 0; index < panelsArray.length; index++) { + const panel = panelsArray[index]; + let { id } = panel.current; + if (panelsWithSizes.has(id)) { + continue; + } + + const remainingSize = 100 - totalDefaultSize; + const remainingPanels = panelsArray.length - panelsWithSizes.size; + const size = remainingSize / remainingPanels; + + sizes[index] = size; + totalDefaultSize += size; + panelsWithSizes.add(id); + } + + // Finally: If there is any left-over values at the end, log an error + if (totalDefaultSize !== 100) { + if (isDevelopment) { + console.error( + `Invalid panel group configuration; default panel sizes should total 100 but was ${totalDefaultSize}` + ); + } + } + + setSizes(sizes); } - }, [autoSaveId, panels, setSizes, storage]); + }, [autoSaveId, panels, storage]); useEffect(() => { // If this panel has been configured to persist sizing information, save sizes to local storage. @@ -455,25 +438,40 @@ function PanelGroupWithForwardedRef({ }, [autoSaveId, panels, sizes, storage]); useIsomorphicLayoutEffect(() => { - // This is a bit of a hack; - // in order to avoid recreating ResizeObservers if an inline function is passed - // we assume that validator will be provided initially - if (callbacksRef.current.validateLayout) { - const resizeObserver = new ResizeObserver(() => { - const { sizes: prevSizes } = committedValuesRef.current; - const nextSizes = validateLayoutHelper(prevSizes); + const resizeObserver = new ResizeObserver(() => { + const { + panelIdsWithStaticUnits, + panels, + sizes: prevSizes, + } = committedValuesRef.current; + + if (panelIdsWithStaticUnits.size > 0) { + const [idBefore, idAfter] = Array.from(panels.values()).map( + (panel) => panel.current.id + ); + + const nextSizes = adjustByDelta( + null, + committedValuesRef.current, + idBefore, + idAfter, + 0, + prevSizes, + panelSizeBeforeCollapse.current, + initialDragStateRef.current + ); if (!areEqual(prevSizes, nextSizes)) { - setSizesUnsafe(nextSizes); + setSizes(nextSizes); } - }); + } + }); - resizeObserver.observe(getPanelGroup(groupId)!); + resizeObserver.observe(getPanelGroup(groupId)!); - return () => { - resizeObserver.disconnect(); - }; - } - }, [groupId, setSizes, validateLayoutHelper]); + return () => { + resizeObserver.disconnect(); + }; + }, [groupId]); const getPanelStyle = useCallback( (id: string, defaultSize: number | null): CSSProperties => { @@ -526,6 +524,10 @@ function PanelGroupWithForwardedRef({ ); const registerPanel = useCallback((id: string, panelRef: PanelData) => { + if (panelRef.current.units === "static") { + committedValuesRef.current.panelIdsWithStaticUnits.add(id); + } + setPanels((prevPanels) => { if (prevPanels.has(id)) { return prevPanels; @@ -587,17 +589,15 @@ function PanelGroupWithForwardedRef({ // If a validateLayout method has been provided // it's important to use it before updating the mouse cursor - const nextSizes = validateLayoutHelper( - adjustByDelta( - event, - panels, - idBefore, - idAfter, - delta, - prevSizes, - panelSizeBeforeCollapse.current, - initialDragStateRef.current - ) + const nextSizes = adjustByDelta( + event, + committedValuesRef.current, + idBefore, + idAfter, + delta, + prevSizes, + panelSizeBeforeCollapse.current, + initialDragStateRef.current ); const sizesChanged = !areEqual(prevSizes, nextSizes); @@ -634,7 +634,7 @@ function PanelGroupWithForwardedRef({ panelIdToLastNotifiedSizeMapRef.current; // It's okay to bypass in this case because we already validated above - setSizesUnsafe(nextSizes); + setSizes(nextSizes); // If resize change handlers have been declared, this is the time to call them. // Trigger user callbacks after updating state, so that user code can override the sizes. @@ -650,10 +650,12 @@ function PanelGroupWithForwardedRef({ return resizeHandler; }, - [groupId, validateLayoutHelper] + [groupId] ); const unregisterPanel = useCallback((id: string) => { + committedValuesRef.current.panelIdsWithStaticUnits.delete(id); + setPanels((prevPanels) => { if (!prevPanels.has(id)) { return prevPanels; @@ -666,205 +668,197 @@ function PanelGroupWithForwardedRef({ }); }, []); - const collapsePanel = useCallback( - (id: string) => { - const { panels, sizes: prevSizes } = committedValuesRef.current; + const collapsePanel = useCallback((id: string) => { + const { panels, sizes: prevSizes } = committedValuesRef.current; - const panel = panels.get(id); - if (panel == null) { - return; - } + const panel = panels.get(id); + if (panel == null) { + return; + } - const { collapsedSize, collapsible } = panel.current; - if (!collapsible) { - return; - } + const { collapsedSize, collapsible } = panel.current; + if (!collapsible) { + return; + } - const panelsArray = panelsMapToSortedArray(panels); + const panelsArray = panelsMapToSortedArray(panels); - const index = panelsArray.indexOf(panel); - if (index < 0) { - return; - } + const index = panelsArray.indexOf(panel); + if (index < 0) { + return; + } - const currentSize = prevSizes[index]; - if (currentSize === collapsedSize) { - // Panel is already collapsed. - return; - } + const currentSize = prevSizes[index]; + if (currentSize === collapsedSize) { + // Panel is already collapsed. + return; + } - panelSizeBeforeCollapse.current.set(id, currentSize); + panelSizeBeforeCollapse.current.set(id, currentSize); - const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray); - if (idBefore == null || idAfter == null) { - return; - } + const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray); + if (idBefore == null || idAfter == null) { + return; + } - const isLastPanel = index === panelsArray.length - 1; - const delta = isLastPanel ? currentSize : collapsedSize - currentSize; + const isLastPanel = index === panelsArray.length - 1; + const delta = isLastPanel ? currentSize : collapsedSize - currentSize; + + const nextSizes = adjustByDelta( + null, + committedValuesRef.current, + idBefore, + idAfter, + delta, + prevSizes, + panelSizeBeforeCollapse.current, + null + ); + if (prevSizes !== nextSizes) { + const panelIdToLastNotifiedSizeMap = + panelIdToLastNotifiedSizeMapRef.current; - const nextSizes = adjustByDelta( - null, - panels, - idBefore, - idAfter, - delta, - prevSizes, - panelSizeBeforeCollapse.current, - null - ); - if (prevSizes !== nextSizes) { - const panelIdToLastNotifiedSizeMap = - panelIdToLastNotifiedSizeMapRef.current; + setSizes(nextSizes); - setSizes(nextSizes); + // If resize change handlers have been declared, this is the time to call them. + // Trigger user callbacks after updating state, so that user code can override the sizes. + callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap); + } + }, []); - // If resize change handlers have been declared, this is the time to call them. - // Trigger user callbacks after updating state, so that user code can override the sizes. - callPanelCallbacks( - panelsArray, - nextSizes, - panelIdToLastNotifiedSizeMap - ); - } - }, - [setSizes] - ); + const expandPanel = useCallback((id: string) => { + const { panels, sizes: prevSizes } = committedValuesRef.current; - const expandPanel = useCallback( - (id: string) => { - const { panels, sizes: prevSizes } = committedValuesRef.current; + const panel = panels.get(id); + if (panel == null) { + return; + } - const panel = panels.get(id); - if (panel == null) { - return; - } + const { collapsedSize, minSize } = panel.current; - const { collapsedSize, minSize } = panel.current; + const sizeBeforeCollapse = + panelSizeBeforeCollapse.current.get(id) || minSize; + if (!sizeBeforeCollapse) { + return; + } - const sizeBeforeCollapse = - panelSizeBeforeCollapse.current.get(id) || minSize; - if (!sizeBeforeCollapse) { - return; - } + const panelsArray = panelsMapToSortedArray(panels); - const panelsArray = panelsMapToSortedArray(panels); + const index = panelsArray.indexOf(panel); + if (index < 0) { + return; + } - const index = panelsArray.indexOf(panel); - if (index < 0) { - return; - } + const currentSize = prevSizes[index]; + if (currentSize !== collapsedSize) { + // Panel is already expanded. + return; + } - const currentSize = prevSizes[index]; - if (currentSize !== collapsedSize) { - // Panel is already expanded. - return; - } + const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray); + if (idBefore == null || idAfter == null) { + return; + } - const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray); - if (idBefore == null || idAfter == null) { - return; - } + const isLastPanel = index === panelsArray.length - 1; + const delta = isLastPanel + ? collapsedSize - sizeBeforeCollapse + : sizeBeforeCollapse; + + const nextSizes = adjustByDelta( + null, + committedValuesRef.current, + idBefore, + idAfter, + delta, + prevSizes, + panelSizeBeforeCollapse.current, + null + ); + if (prevSizes !== nextSizes) { + const panelIdToLastNotifiedSizeMap = + panelIdToLastNotifiedSizeMapRef.current; - const isLastPanel = index === panelsArray.length - 1; - const delta = isLastPanel - ? collapsedSize - sizeBeforeCollapse - : sizeBeforeCollapse; + setSizes(nextSizes); - const nextSizes = adjustByDelta( - null, - panels, - idBefore, - idAfter, - delta, - prevSizes, - panelSizeBeforeCollapse.current, - null - ); - if (prevSizes !== nextSizes) { - const panelIdToLastNotifiedSizeMap = - panelIdToLastNotifiedSizeMapRef.current; + // If resize change handlers have been declared, this is the time to call them. + // Trigger user callbacks after updating state, so that user code can override the sizes. + callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap); + } + }, []); - setSizes(nextSizes); + const resizePanel = useCallback((id: string, nextSize: number) => { + const { + id: groupId, + panels, + sizes: prevSizes, + } = committedValuesRef.current; - // If resize change handlers have been declared, this is the time to call them. - // Trigger user callbacks after updating state, so that user code can override the sizes. - callPanelCallbacks( - panelsArray, - nextSizes, - panelIdToLastNotifiedSizeMap - ); - } - }, - [setSizes] - ); + const panel = panels.get(id); + if (panel == null) { + return; + } - const resizePanel = useCallback( - (id: string, nextSize: number) => { - const { panels, sizes: prevSizes } = committedValuesRef.current; + let { collapsedSize, collapsible, maxSize, minSize, units } = panel.current; - const panel = panels.get(id); - if (panel == null) { - return; + if (units === "static") { + const groupSizePixels = getAvailableGroupSizePixels(groupId); + minSize = (minSize / groupSizePixels) * 100; + if (maxSize != null) { + maxSize = (maxSize / groupSizePixels) * 100; } + } - const { collapsedSize, collapsible, maxSize, minSize } = panel.current; - - const panelsArray = panelsMapToSortedArray(panels); - - const index = panelsArray.indexOf(panel); - if (index < 0) { - return; - } + const panelsArray = panelsMapToSortedArray(panels); - const currentSize = prevSizes[index]; - if (currentSize === nextSize) { - return; - } + const index = panelsArray.indexOf(panel); + if (index < 0) { + return; + } - if (collapsible && nextSize === collapsedSize) { - // This is a valid resize state. - } else { - nextSize = Math.min(maxSize, Math.max(minSize, nextSize)); - } + const currentSize = prevSizes[index]; + if (currentSize === nextSize) { + return; + } - const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray); - if (idBefore == null || idAfter == null) { - return; - } + if (collapsible && nextSize === collapsedSize) { + // This is a valid resize state. + } else { + nextSize = Math.min( + maxSize != null ? maxSize : 100, + Math.max(minSize, nextSize) + ); + } - const isLastPanel = index === panelsArray.length - 1; - const delta = isLastPanel - ? currentSize - nextSize - : nextSize - currentSize; + const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray); + if (idBefore == null || idAfter == null) { + return; + } - const nextSizes = adjustByDelta( - null, - panels, - idBefore, - idAfter, - delta, - prevSizes, - panelSizeBeforeCollapse.current, - null - ); - if (prevSizes !== nextSizes) { - const panelIdToLastNotifiedSizeMap = - panelIdToLastNotifiedSizeMapRef.current; + const isLastPanel = index === panelsArray.length - 1; + const delta = isLastPanel ? currentSize - nextSize : nextSize - currentSize; + + const nextSizes = adjustByDelta( + null, + committedValuesRef.current, + idBefore, + idAfter, + delta, + prevSizes, + panelSizeBeforeCollapse.current, + null + ); + if (prevSizes !== nextSizes) { + const panelIdToLastNotifiedSizeMap = + panelIdToLastNotifiedSizeMapRef.current; - setSizes(nextSizes); + setSizes(nextSizes); - // If resize change handlers have been declared, this is the time to call them. - // Trigger user callbacks after updating state, so that user code can override the sizes. - callPanelCallbacks( - panelsArray, - nextSizes, - panelIdToLastNotifiedSizeMap - ); - } - }, - [setSizes] - ); + // If resize change handlers have been declared, this is the time to call them. + // Trigger user callbacks after updating state, so that user code can override the sizes. + callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap); + } + }, []); const context = useMemo( () => ({ diff --git a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts deleted file mode 100644 index f04e386d1..000000000 --- a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useCallback } from "../vendor/react"; -import { PanelGroupValidateLayout } from "../types"; - -export function usePanelGroupLayoutValidator({ - collapseBelowPixels, - maxPixels, - minPixels, - position, -}: { - collapseBelowPixels?: number; - maxPixels?: number; - minPixels?: number; - position: "bottom" | "left" | "right" | "top"; -}): PanelGroupValidateLayout { - return useCallback( - ({ - availableHeight, - availableWidth, - nextSizes, - prevSizes, - }: { - availableHeight: number; - availableWidth: number; - nextSizes: number[]; - prevSizes: number[]; - }) => { - if (minPixels == null && maxPixels == null) { - return nextSizes; - } - - let availablePixels; - switch (position) { - case "bottom": - case "top": { - availablePixels = availableHeight; - break; - } - case "left": - case "right": { - availablePixels = availableWidth; - break; - } - } - - const collapseThresholdSize = collapseBelowPixels - ? (collapseBelowPixels / availablePixels) * 100 - : null; - const minSize = minPixels ? (minPixels / availablePixels) * 100 : null; - const maxSize = maxPixels ? (maxPixels / availablePixels) * 100 : null; - - switch (position) { - case "left": - case "top": { - const firstSize = nextSizes[0]; - const secondSize = nextSizes[1]; - const restSizes = nextSizes.slice(2); - - if (minSize != null && firstSize < minSize) { - if ( - collapseThresholdSize != null && - firstSize < collapseThresholdSize - ) { - return [0, secondSize + firstSize, ...restSizes]; - } else if (prevSizes[0] === minSize) { - // Prevent dragging from resizing other panels - return prevSizes; - } else { - const delta = minSize - firstSize; - return [minSize, secondSize - delta, ...restSizes]; - } - } else if (maxSize != null && firstSize > maxSize) { - if (prevSizes[0] === maxSize) { - // Prevent dragging from resizing other panels - return prevSizes; - } else { - const delta = firstSize - maxSize; - return [maxSize, secondSize + delta, ...restSizes]; - } - } else { - return nextSizes; - } - } - case "bottom": - case "right": { - const lastSize = nextSizes[nextSizes.length - 1]; - const nextButLastSize = nextSizes[nextSizes.length - 2]; - const restSizes = nextSizes.slice(0, nextSizes.length - 2); - - if (minSize != null && lastSize < minSize) { - if ( - collapseThresholdSize != null && - lastSize < collapseThresholdSize - ) { - return [...restSizes, nextButLastSize + lastSize, 0]; - } else if (prevSizes[2] === minSize) { - // Prevent dragging from resizing other panels - return prevSizes; - } else { - const delta = minSize - lastSize; - return [...restSizes, nextButLastSize - delta, minSize]; - } - } else if (maxSize != null && lastSize > maxSize) { - if (prevSizes[2] === maxSize) { - // Prevent dragging from resizing other panels - return prevSizes; - } else { - const delta = lastSize - maxSize; - return [...restSizes, nextButLastSize + delta, maxSize]; - } - } else { - return nextSizes; - } - } - } - }, - [collapseBelowPixels, maxPixels, minPixels, position] - ); -} diff --git a/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts b/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts index 83aa37472..1903b8806 100644 --- a/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts +++ b/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts @@ -38,9 +38,6 @@ export function useWindowSplitterPanelGroupBehavior({ const { direction, panels } = committedValuesRef.current!; const groupElement = getPanelGroup(groupId); - if (!groupElement) { - console.log(document.body.innerHTML); - } assert(groupElement != null, `No group found for id "${groupId}"`); const { height, width } = groupElement.getBoundingClientRect(); @@ -59,25 +56,26 @@ export function useWindowSplitterPanelGroupBehavior({ return () => {}; } - let minSize = 0; - let maxSize = 100; + let currentMinSize = 0; + let currentMaxSize = 100; let totalMinSize = 0; let totalMaxSize = 0; // A panel's effective min/max sizes also need to account for other panel's sizes. panelsArray.forEach((panelData) => { - if (panelData.current.id === idBefore) { - maxSize = panelData.current.maxSize; - minSize = panelData.current.minSize; + const { id, maxSize, minSize } = panelData.current; + if (id === idBefore) { + currentMinSize = minSize; + currentMaxSize = maxSize != null ? maxSize : 100; } else { - totalMinSize += panelData.current.minSize; - totalMaxSize += panelData.current.maxSize; + totalMinSize += minSize; + totalMaxSize += maxSize != null ? maxSize : 100; } }); - const ariaValueMax = Math.min(maxSize, 100 - totalMinSize); + const ariaValueMax = Math.min(currentMaxSize, 100 - totalMinSize); const ariaValueMin = Math.max( - minSize, + currentMinSize, (panelsArray.length - 1) * 100 - totalMaxSize ); @@ -115,7 +113,7 @@ export function useWindowSplitterPanelGroupBehavior({ const nextSizes = adjustByDelta( event, - panels, + committedValuesRef.current!, idBefore, idAfter, delta, diff --git a/packages/react-resizable-panels/src/index.ts b/packages/react-resizable-panels/src/index.ts index 7fb14618c..10f5d2fc8 100644 --- a/packages/react-resizable-panels/src/index.ts +++ b/packages/react-resizable-panels/src/index.ts @@ -1,35 +1,39 @@ import { Panel } from "./Panel"; import { PanelGroup } from "./PanelGroup"; import { PanelResizeHandle } from "./PanelResizeHandle"; -import { usePanelGroupLayoutValidator } from "./hooks/usePanelGroupLayoutValidator"; import type { ImperativePanelHandle, PanelProps } from "./Panel"; import type { ImperativePanelGroupHandle, PanelGroupProps } from "./PanelGroup"; import type { PanelResizeHandleProps } from "./PanelResizeHandle"; +import { getAvailableGroupSizePixels } from "./utils/group"; import type { PanelGroupOnLayout, PanelGroupStorage, - PanelGroupValidateLayout, PanelOnCollapse, PanelOnResize, PanelResizeHandleOnDragging, + PanelUnits, } from "./types"; export { // TypeScript types ImperativePanelGroupHandle, ImperativePanelHandle, - Panel, PanelOnCollapse, PanelOnResize, - PanelGroup, PanelGroupOnLayout, PanelGroupProps, PanelGroupStorage, - PanelGroupValidateLayout, PanelProps, - PanelResizeHandle, PanelResizeHandleOnDragging, PanelResizeHandleProps, - usePanelGroupLayoutValidator, + PanelUnits, + + // React components + Panel, + PanelGroup, + PanelResizeHandle, + + // Utility methods + getAvailableGroupSizePixels, }; diff --git a/packages/react-resizable-panels/src/types.ts b/packages/react-resizable-panels/src/types.ts index d9c365893..07e9ed416 100644 --- a/packages/react-resizable-panels/src/types.ts +++ b/packages/react-resizable-panels/src/types.ts @@ -11,18 +11,14 @@ export type PanelGroupOnLayout = (sizes: number[]) => void; export type PanelOnCollapse = (collapsed: boolean) => void; export type PanelOnResize = (size: number, prevSize: number) => void; export type PanelResizeHandleOnDragging = (isDragging: boolean) => void; -export type PanelGroupValidateLayout = (param: { - availableHeight: number; - availableWidth: number; - nextSizes: number[]; - prevSizes: number[]; -}) => number[]; export type PanelCallbackRef = RefObject<{ onCollapse: PanelOnCollapse | null; onResize: PanelOnResize | null; }>; +export type PanelUnits = "relative" | "static"; + export type PanelData = { current: { callbacksRef: PanelCallbackRef; @@ -31,9 +27,10 @@ export type PanelData = { defaultSize: number | null; id: string; idWasAutoGenerated: boolean; - maxSize: number; + maxSize: number | null; minSize: number; order: number | null; + units: PanelUnits; }; }; diff --git a/packages/react-resizable-panels/src/utils/group.ts b/packages/react-resizable-panels/src/utils/group.ts index ad36ce5cc..31b3ab51a 100644 --- a/packages/react-resizable-panels/src/utils/group.ts +++ b/packages/react-resizable-panels/src/utils/group.ts @@ -1,27 +1,30 @@ -import { InitialDragState } from "../PanelGroup"; +import { CommittedValues, InitialDragState } from "../PanelGroup"; import { PRECISION } from "../constants"; import { PanelData, ResizeEvent } from "../types"; export function adjustByDelta( event: ResizeEvent | null, - panels: Map, + committedValues: CommittedValues, idBefore: string, idAfter: string, - delta: number, + deltaPixels: number, prevSizes: number[], panelSizeBeforeCollapse: Map, initialDragState: InitialDragState | null ): number[] { + const { id: groupId, panelIdsWithStaticUnits, panels } = committedValues; + + const groupSizePixels = + panelIdsWithStaticUnits.size > 0 + ? getAvailableGroupSizePixels(groupId) + : NaN; + const { sizes: initialSizes } = initialDragState || {}; // If we're resizing by mouse or touch, use the initial sizes as a base. // This has the benefit of causing force-collapsed panels to spring back open if drag is reversed. const baseSizes = initialSizes || prevSizes; - if (delta === 0) { - return baseSizes; - } - const panelsArray = panelsMapToSortedArray(panels); const nextSizes = baseSizes.concat(); @@ -38,14 +41,20 @@ export function adjustByDelta( // Max-bounds check the panel being expanded first. { - const pivotId = delta < 0 ? idAfter : idBefore; + const pivotId = deltaPixels < 0 ? idAfter : idBefore; const index = panelsArray.findIndex( (panel) => panel.current.id === pivotId ); const panel = panelsArray[index]; const baseSize = baseSizes[index]; - const nextSize = safeResizePanel(panel, Math.abs(delta), baseSize, event); + const nextSize = safeResizePanel( + groupSizePixels, + panel, + Math.abs(deltaPixels), + baseSize, + event + ); if (baseSize === nextSize) { // If there's no room for the pivot panel to grow, we can ignore this drag update. return baseSizes; @@ -54,19 +63,20 @@ export function adjustByDelta( panelSizeBeforeCollapse.set(pivotId, baseSize); } - delta = delta < 0 ? baseSize - nextSize : nextSize - baseSize; + deltaPixels = deltaPixels < 0 ? baseSize - nextSize : nextSize - baseSize; } } - let pivotId = delta < 0 ? idBefore : idAfter; + let pivotId = deltaPixels < 0 ? idBefore : idAfter; let index = panelsArray.findIndex((panel) => panel.current.id === pivotId); while (true) { const panel = panelsArray[index]; const baseSize = baseSizes[index]; - const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied); + const deltaRemaining = Math.abs(deltaPixels) - Math.abs(deltaApplied); const nextSize = safeResizePanel( + groupSizePixels, panel, 0 - deltaRemaining, baseSize, @@ -84,15 +94,19 @@ export function adjustByDelta( if ( deltaApplied .toPrecision(PRECISION) - .localeCompare(Math.abs(delta).toPrecision(PRECISION), undefined, { - numeric: true, - }) >= 0 + .localeCompare( + Math.abs(deltaPixels).toPrecision(PRECISION), + undefined, + { + numeric: true, + } + ) >= 0 ) { break; } } - if (delta < 0) { + if (deltaPixels < 0) { if (--index < 0) { break; } @@ -110,7 +124,7 @@ export function adjustByDelta( } // Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract. - pivotId = delta < 0 ? idAfter : idBefore; + pivotId = deltaPixels < 0 ? idAfter : idBefore; index = panelsArray.findIndex((panel) => panel.current.id === pivotId); nextSizes[index] = baseSizes[index] + deltaApplied; @@ -179,6 +193,33 @@ export function getBeforeAndAfterIds( return [idBefore, idAfter]; } +export function getAvailableGroupSizePixels(groupId: string): number { + const panelGroupElement = getPanelGroup(groupId); + if (panelGroupElement == null) { + return NaN; + } + + const direction = panelGroupElement.getAttribute( + "data-panel-group-direction" + ); + const resizeHandles = getResizeHandlesForGroup(groupId); + if (direction === "horizontal") { + return ( + panelGroupElement.offsetWidth - + resizeHandles.reduce((accumulated, handle) => { + return accumulated + handle.offsetWidth; + }, 0) + ); + } else { + return ( + panelGroupElement.offsetHeight - + resizeHandles.reduce((accumulated, handle) => { + return accumulated + handle.offsetHeight; + }, 0) + ); + } +} + // This method returns a number between 1 and 100 representing // the % of the group's overall space this panel should occupy. export function getFlexGrow( @@ -280,7 +321,8 @@ export function panelsMapToSortedArray( }); } -function safeResizePanel( +export function safeResizePanel( + groupSizePixels: number, panel: PanelData, delta: number, prevSize: number, @@ -288,7 +330,15 @@ function safeResizePanel( ): number { const nextSizeUnsafe = prevSize + delta; - const { collapsedSize, collapsible, maxSize, minSize } = panel.current; + let { collapsedSize, collapsible, maxSize, minSize, units } = panel.current; + + if (units === "static") { + collapsedSize = (collapsedSize / groupSizePixels) * 100; + if (maxSize != null) { + maxSize = (maxSize / groupSizePixels) * 100; + } + minSize = (minSize / groupSizePixels) * 100; + } if (collapsible) { if (prevSize > collapsedSize) { @@ -309,7 +359,10 @@ function safeResizePanel( } } - const nextSize = Math.min(maxSize, Math.max(minSize, nextSizeUnsafe)); + const nextSize = Math.min( + maxSize != null ? maxSize : 100, + Math.max(minSize, nextSizeUnsafe) + ); return nextSize; }