From d32b1e42cdb89124ff6c586aaca782bbc68d48cb Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 13:54:47 -0400
Subject: [PATCH 01/20] Add optional PanelGroup validateLayout prop and
usePanelGroupLayoutValidator() hook
---
.../react-resizable-panels-website/index.tsx | 5 +
.../src/routes/Home/index.tsx | 1 +
.../CustomLayoutValidation.module.css | 7 +
.../examples/CustomLayoutValidation.tsx | 146 +++++++
.../src/routes/examples/shared.module.css | 6 +
.../src/utils/withAutoSizer.ts | 9 +-
.../react-resizable-panels/src/PanelGroup.ts | 391 +++++++++++-------
.../src/hooks/usePanelGroupLayoutValidator.ts | 114 +++++
packages/react-resizable-panels/src/index.ts | 4 +
packages/react-resizable-panels/src/types.ts | 6 +
10 files changed, 538 insertions(+), 151 deletions(-)
create mode 100644 packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css
create mode 100644 packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx
create mode 100644 packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
diff --git a/packages/react-resizable-panels-website/index.tsx b/packages/react-resizable-panels-website/index.tsx
index 62c8b247c..c6be00c64 100644
--- a/packages/react-resizable-panels-website/index.tsx
+++ b/packages/react-resizable-panels-website/index.tsx
@@ -4,6 +4,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import HomeRoute from "./src/routes/Home";
import ConditionalExampleRoute from "./src/routes/examples/Conditional";
+import CustomLayoutValidationRoute from "./src/routes/examples/CustomLayoutValidation";
import ExternalPersistenceExampleRoute from "./src/routes/examples/ExternalPersistence";
import HorizontalExampleRoute from "./src/routes/examples/Horizontal";
import ImperativePanelApiExampleRoute from "./src/routes/examples/ImperativePanelApi";
@@ -24,6 +25,10 @@ const router = createBrowserRouter([
path: "/examples/conditional",
element: ,
},
+ {
+ path: "/examples/custom-layout-validation",
+ element: ,
+ },
{
path: "/examples/external-persistence",
element: ,
diff --git a/packages/react-resizable-panels-website/src/routes/Home/index.tsx b/packages/react-resizable-panels-website/src/routes/Home/index.tsx
index 79fddbdf7..7047b44cf 100644
--- a/packages/react-resizable-panels-website/src/routes/Home/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/Home/index.tsx
@@ -13,6 +13,7 @@ const LINKS = [
{ path: "overflow", title: "Overflow content" },
{ path: "collapsible", title: "Collapsible panels" },
{ path: "conditional", title: "Conditional panels" },
+ { path: "custom-layout-validation", title: "Custom layout validation" },
{ path: "external-persistence", title: "External persistence" },
{ path: "imperative-panel-api", title: "Imperative Panel API" },
{ path: "imperative-panel-group-api", title: "Imperative PanelGroup API" },
diff --git a/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css b/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css
new file mode 100644
index 000000000..cbecdac4e
--- /dev/null
+++ b/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css
@@ -0,0 +1,7 @@
+.PrimaryLabel {
+}
+
+.SecondaryLabel {
+ font-size: 0.8rem;
+ color: var(--color-dim);
+}
diff --git a/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx b/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx
new file mode 100644
index 000000000..1e72d8b57
--- /dev/null
+++ b/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx
@@ -0,0 +1,146 @@
+import {
+ Panel,
+ PanelGroup,
+ usePanelGroupLayoutValidator,
+} from "react-resizable-panels";
+
+import ResizeHandle from "../../components/ResizeHandle";
+
+import AutoSizer from "react-virtualized-auto-sizer";
+import styles from "./CustomLayoutValidation.module.css";
+import Example from "./Example";
+import sharedStyles from "./shared.module.css";
+
+export default function CustomLayoutValidation() {
+ return (
+ }
+ headerNode={
+ <>
+
+ Resizable panels typically use percentage-based layout constraints.
+ PanelGroup
also supports custom validation functions
+ for pixel-base constraints.
+
+
+ The examples below use the usePanelGroupLayoutValidator
{" "}
+ hook to set pixel constraints on certain panels.
+
+ >
+ }
+ title="Custom layout validation"
+ />
+ );
+}
+
+function Content() {
+ const validateLayoutLeft = usePanelGroupLayoutValidator({
+ maxPixels: 200,
+ minPixels: 100,
+ position: "left",
+ });
+ const validateLayoutRight = usePanelGroupLayoutValidator({
+ maxPixels: 200,
+ minPixels: 100,
+ position: "right",
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function Size({ label }: { label: string }) {
+ return (
+
+ {({ width }) => (
+
+
+
+ )}
+
+ );
+}
+
+function Labeled({
+ primary,
+ secondary,
+}: {
+ primary: string;
+ secondary: string;
+}) {
+ return (
+
+ {primary}
+ {secondary}
+
+ );
+}
+
+const CODE = `
+function validateLayout({
+ availableHeight,
+ availableWidth,
+ nextSizes,
+ prevSizes,
+}: {
+ availableHeight: number;
+ availableWidth: number;
+ nextSizes: number[];
+ prevSizes: number[];
+}) {
+ // Compute and return an array of sizes (totalling 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 34742179a..291eb5836 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
@@ -1,6 +1,12 @@
.PanelGroupWrapper {
height: 20rem;
}
+.PanelGroupWrapper[data-short] {
+ height: 10rem;
+}
+.PanelGroupWrapper[data-tall] {
+ height: 30rem;
+}
.PanelGroup {
font-size: 2rem;
diff --git a/packages/react-resizable-panels-website/src/utils/withAutoSizer.ts b/packages/react-resizable-panels-website/src/utils/withAutoSizer.ts
index 2fc03ff85..190b5789e 100644
--- a/packages/react-resizable-panels-website/src/utils/withAutoSizer.ts
+++ b/packages/react-resizable-panels-website/src/utils/withAutoSizer.ts
@@ -6,8 +6,13 @@ import type {
} from "react-virtualized-auto-sizer";
export default function withAutoSizer(
- Component: FunctionComponent,
- autoSizerProps?: AutoSizerProps
+ Component: FunctionComponent<
+ ComponentProps & {
+ height: number;
+ width: number;
+ }
+ >,
+ autoSizerProps?: Partial
): FunctionComponent> {
const AutoSizerWrapper = (
props: Omit
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index dc73b64be..0db5b93fc 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -24,6 +24,7 @@ import {
PanelData,
PanelGroupOnLayout,
PanelGroupStorage,
+ PanelGroupValidateLayout,
ResizeEvent,
} from "./types";
import { areEqual } from "./utils/arrays";
@@ -44,6 +45,7 @@ import {
getPanelGroup,
getResizeHandle,
getResizeHandlePanelIds,
+ getResizeHandlesForGroup,
panelsMapToSortedArray,
} from "./utils/group";
import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization";
@@ -129,6 +131,7 @@ export type PanelGroupProps = {
storage?: PanelGroupStorage;
style?: CSSProperties;
tagName?: ElementType;
+ validateLayout?: PanelGroupValidateLayout;
};
export type ImperativePanelGroupHandle = {
@@ -148,6 +151,7 @@ function PanelGroupWithForwardedRef({
storage = defaultStorage,
style: styleFromProps = {},
tagName: Type = "div",
+ validateLayout,
}: PanelGroupProps & {
forwardedRef: ForwardedRef;
}) {
@@ -164,25 +168,91 @@ function PanelGroupWithForwardedRef({
const devWarningsRef = useRef<{
didLogDefaultSizeWarning: boolean;
didLogIdAndOrderWarning: boolean;
+ didLogInvalidLayoutWarning: boolean;
prevPanelIds: string[];
}>({
didLogDefaultSizeWarning: false,
didLogIdAndOrderWarning: false,
+ didLogInvalidLayoutWarning: false,
prevPanelIds: [],
});
// Use a ref to guard against users passing inline props
const callbacksRef = useRef<{
onLayout: PanelGroupOnLayout | undefined;
- }>({ onLayout });
+ validateLayout: PanelGroupValidateLayout | undefined;
+ }>({ onLayout, validateLayout });
useEffect(() => {
callbacksRef.current.onLayout = onLayout;
+ callbacksRef.current.validateLayout = validateLayout;
});
const panelIdToLastNotifiedSizeMapRef = useRef>({});
// 0-1 values representing the relative size of each panel.
- const [sizes, setSizes] = useState([]);
+ const [sizes, setSizesUnsafe] = useState([]);
+
+ const setSizes = 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
+ );
+ }
+ }
+ }
+ }
+
+ if (!areEqual(prevSizes, nextSizes)) {
+ setSizesUnsafe(nextSizes);
+ }
+ },
+ [groupId]
+ );
// Used to support imperative collapse/expand API.
const panelSizeBeforeCollapse = useRef>(new Map());
@@ -221,7 +291,7 @@ function PanelGroupWithForwardedRef({
callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
},
}),
- []
+ [setSizes]
);
useIsomorphicLayoutEffect(() => {
@@ -327,7 +397,7 @@ function PanelGroupWithForwardedRef({
})
);
}
- }, [autoSaveId, panels, storage]);
+ }, [autoSaveId, panels, setSizes, storage]);
useEffect(() => {
// If this panel has been configured to persist sizing information, save sizes to local storage.
@@ -544,7 +614,7 @@ function PanelGroupWithForwardedRef({
return resizeHandler;
},
- [groupId]
+ [groupId, setSizes]
);
const unregisterPanel = useCallback((id: string) => {
@@ -560,182 +630,205 @@ 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 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 : collapsedSize - currentSize;
+
+ 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,
- panels,
- 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;
+
+ 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 resizePanel = useCallback((id: string, nextSize: number) => {
- const { panels, sizes: prevSizes } = committedValuesRef.current;
+ const resizePanel = useCallback(
+ (id: string, nextSize: number) => {
+ 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, maxSize, minSize } = panel.current;
+ const { collapsedSize, collapsible, maxSize, minSize } = panel.current;
- 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 === nextSize) {
- return;
- }
+ const currentSize = prevSizes[index];
+ if (currentSize === nextSize) {
+ return;
+ }
- if (collapsible && nextSize === collapsedSize) {
- // This is a valid resize state.
- } else {
- nextSize = Math.min(maxSize, Math.max(minSize, nextSize));
- }
+ if (collapsible && nextSize === collapsedSize) {
+ // This is a valid resize state.
+ } else {
+ nextSize = Math.min(maxSize, Math.max(minSize, nextSize));
+ }
- 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 - nextSize : nextSize - currentSize;
-
- 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,
+ 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 context = useMemo(
() => ({
diff --git a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
new file mode 100644
index 000000000..40614d124
--- /dev/null
+++ b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
@@ -0,0 +1,114 @@
+import { useCallback } from "../vendor/react";
+import { PanelGroupValidateLayout } from "../types";
+
+export function usePanelGroupLayoutValidator({
+ collapseThresholdPixels,
+ maxPixels,
+ minPixels,
+ position,
+}: {
+ collapseThresholdPixels?: 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[];
+ }) => {
+ let availablePixels;
+ switch (position) {
+ case "bottom":
+ case "top": {
+ availablePixels = availableHeight;
+ break;
+ }
+ case "left":
+ case "right": {
+ availablePixels = availableWidth;
+ break;
+ }
+ }
+
+ const collapseThresholdSize = collapseThresholdPixels
+ ? (collapseThresholdPixels / 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;
+ }
+ }
+ }
+ },
+ [collapseThresholdPixels, maxPixels, minPixels, position]
+ );
+}
diff --git a/packages/react-resizable-panels/src/index.ts b/packages/react-resizable-panels/src/index.ts
index 61f0979ad..7fb14618c 100644
--- a/packages/react-resizable-panels/src/index.ts
+++ b/packages/react-resizable-panels/src/index.ts
@@ -1,6 +1,7 @@
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";
@@ -8,6 +9,7 @@ import type { PanelResizeHandleProps } from "./PanelResizeHandle";
import type {
PanelGroupOnLayout,
PanelGroupStorage,
+ PanelGroupValidateLayout,
PanelOnCollapse,
PanelOnResize,
PanelResizeHandleOnDragging,
@@ -24,8 +26,10 @@ export {
PanelGroupOnLayout,
PanelGroupProps,
PanelGroupStorage,
+ PanelGroupValidateLayout,
PanelProps,
PanelResizeHandle,
PanelResizeHandleOnDragging,
PanelResizeHandleProps,
+ usePanelGroupLayoutValidator,
};
diff --git a/packages/react-resizable-panels/src/types.ts b/packages/react-resizable-panels/src/types.ts
index ff517a7df..d9c365893 100644
--- a/packages/react-resizable-panels/src/types.ts
+++ b/packages/react-resizable-panels/src/types.ts
@@ -11,6 +11,12 @@ 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;
From 5ecd7811b9d7b7c030f3a82bdff9301b9fc0db13 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 14:19:53 -0400
Subject: [PATCH 02/20] Updated docs to show more examples
---
.../react-resizable-panels-website/index.tsx | 6 +-
.../src/routes/Home/index.tsx | 2 +-
.../examples/CustomLayoutValidation.tsx | 146 ----------
...odule.css => PixelBasedLayouts.module.css} | 5 +-
.../src/routes/examples/PixelBasedLayouts.tsx | 256 ++++++++++++++++++
.../src/hooks/usePanelGroupLayoutValidator.ts | 10 +-
6 files changed, 266 insertions(+), 159 deletions(-)
delete mode 100644 packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx
rename packages/react-resizable-panels-website/src/routes/examples/{CustomLayoutValidation.module.css => PixelBasedLayouts.module.css} (57%)
create mode 100644 packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
diff --git a/packages/react-resizable-panels-website/index.tsx b/packages/react-resizable-panels-website/index.tsx
index c6be00c64..3293eb100 100644
--- a/packages/react-resizable-panels-website/index.tsx
+++ b/packages/react-resizable-panels-website/index.tsx
@@ -4,7 +4,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import HomeRoute from "./src/routes/Home";
import ConditionalExampleRoute from "./src/routes/examples/Conditional";
-import CustomLayoutValidationRoute from "./src/routes/examples/CustomLayoutValidation";
+import PixelBasedLayoutsRoute from "./src/routes/examples/PixelBasedLayouts";
import ExternalPersistenceExampleRoute from "./src/routes/examples/ExternalPersistence";
import HorizontalExampleRoute from "./src/routes/examples/Horizontal";
import ImperativePanelApiExampleRoute from "./src/routes/examples/ImperativePanelApi";
@@ -26,8 +26,8 @@ const router = createBrowserRouter([
element: ,
},
{
- path: "/examples/custom-layout-validation",
- element: ,
+ path: "/examples/pixel-based-layouts",
+ element: ,
},
{
path: "/examples/external-persistence",
diff --git a/packages/react-resizable-panels-website/src/routes/Home/index.tsx b/packages/react-resizable-panels-website/src/routes/Home/index.tsx
index 7047b44cf..7e39aaaa9 100644
--- a/packages/react-resizable-panels-website/src/routes/Home/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/Home/index.tsx
@@ -13,7 +13,7 @@ const LINKS = [
{ path: "overflow", title: "Overflow content" },
{ path: "collapsible", title: "Collapsible panels" },
{ path: "conditional", title: "Conditional panels" },
- { path: "custom-layout-validation", title: "Custom layout validation" },
+ { path: "pixel-based-layouts", title: "Pixel based layouts" },
{ path: "external-persistence", title: "External persistence" },
{ path: "imperative-panel-api", title: "Imperative Panel API" },
{ path: "imperative-panel-group-api", title: "Imperative PanelGroup API" },
diff --git a/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx b/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx
deleted file mode 100644
index 1e72d8b57..000000000
--- a/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import {
- Panel,
- PanelGroup,
- usePanelGroupLayoutValidator,
-} from "react-resizable-panels";
-
-import ResizeHandle from "../../components/ResizeHandle";
-
-import AutoSizer from "react-virtualized-auto-sizer";
-import styles from "./CustomLayoutValidation.module.css";
-import Example from "./Example";
-import sharedStyles from "./shared.module.css";
-
-export default function CustomLayoutValidation() {
- return (
- }
- headerNode={
- <>
-
- Resizable panels typically use percentage-based layout constraints.
- PanelGroup
also supports custom validation functions
- for pixel-base constraints.
-
-
- The examples below use the usePanelGroupLayoutValidator
{" "}
- hook to set pixel constraints on certain panels.
-
- >
- }
- title="Custom layout validation"
- />
- );
-}
-
-function Content() {
- const validateLayoutLeft = usePanelGroupLayoutValidator({
- maxPixels: 200,
- minPixels: 100,
- position: "left",
- });
- const validateLayoutRight = usePanelGroupLayoutValidator({
- maxPixels: 200,
- minPixels: 100,
- position: "right",
- });
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-function Size({ label }: { label: string }) {
- return (
-
- {({ width }) => (
-
-
-
- )}
-
- );
-}
-
-function Labeled({
- primary,
- secondary,
-}: {
- primary: string;
- secondary: string;
-}) {
- return (
-
- {primary}
- {secondary}
-
- );
-}
-
-const CODE = `
-function validateLayout({
- availableHeight,
- availableWidth,
- nextSizes,
- prevSizes,
-}: {
- availableHeight: number;
- availableWidth: number;
- nextSizes: number[];
- prevSizes: number[];
-}) {
- // Compute and return an array of sizes (totalling 100)
-}
-`;
diff --git a/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css
similarity index 57%
rename from packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css
rename to packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css
index cbecdac4e..621750412 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/CustomLayoutValidation.module.css
+++ b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css
@@ -1,7 +1,4 @@
-.PrimaryLabel {
-}
-
-.SecondaryLabel {
+.Small {
font-size: 0.8rem;
color: var(--color-dim);
}
diff --git a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
new file mode 100644
index 000000000..020dc448d
--- /dev/null
+++ b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
@@ -0,0 +1,256 @@
+import {
+ Panel,
+ PanelGroup,
+ usePanelGroupLayoutValidator,
+} from "react-resizable-panels";
+
+import ResizeHandle from "../../components/ResizeHandle";
+
+import { Link } from "react-router-dom";
+import AutoSizer from "react-virtualized-auto-sizer";
+import exampleStyles from "./Example.module.css";
+import styles from "./PixelBasedLayouts.module.css";
+import sharedStyles from "./shared.module.css";
+
+import { PropsWithChildren } from "react";
+import Code from "../../components/Code";
+
+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 (
+
+
+
+ Home
+
+ →Pixel based layouts
+
+
+ Resizable panels typically use percentage-based layout constraints.
+ PanelGroup
also supports custom validation functions for
+ pixel-base constraints.
+
+
+ The easiest way to do this is with the{" "}
+ usePanelGroupLayoutValidator
hook, as shown in the example
+ below.
+
+
+
+
+
+
+ 100px - 200px
+
+
+
+
+ middle
+
+
+
+ right
+
+
+
+
+
+
+
+ Panels with pixel constraints can also be configured to collapse as
+ shown below
+
+
+
+
+
+
+ left
+
+
+
+ middle
+
+
+
+
+ 200px - 300px
+ collapse below 100px
+
+
+
+
+
+
+
+
Vertical groups can also be managed with this hook.
+
+
+
+
+
+
+ 75px - 125px
+
+
+
+
+ middle
+
+
+
+ bottom
+
+
+
+
+
+
+
+ You can also use the validateLayout
prop directly to
+ implement an entirely custom layout.
+
+
+
+
+ );
+}
+
+function HorizontalSize({ children }: PropsWithChildren) {
+ return (
+
+ {({ width }) => (
+
+
+ {width}px
+ {children}
+
+
+ )}
+
+ );
+}
+
+function VerticalSize({ children }: PropsWithChildren) {
+ return (
+
+ {({ height }) => (
+
+
+ {height}px
+ {children}
+
+
+ )}
+
+ );
+}
+
+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 (totalling 100)
+}
+`;
diff --git a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
index 40614d124..fb63ab492 100644
--- a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
+++ b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
@@ -2,12 +2,12 @@ import { useCallback } from "../vendor/react";
import { PanelGroupValidateLayout } from "../types";
export function usePanelGroupLayoutValidator({
- collapseThresholdPixels,
+ collapseBelowPixels,
maxPixels,
minPixels,
position,
}: {
- collapseThresholdPixels?: number;
+ collapseBelowPixels?: number;
maxPixels?: number;
minPixels?: number;
position: "bottom" | "left" | "right" | "top";
@@ -38,8 +38,8 @@ export function usePanelGroupLayoutValidator({
}
}
- const collapseThresholdSize = collapseThresholdPixels
- ? (collapseThresholdPixels / availablePixels) * 100
+ const collapseThresholdSize = collapseBelowPixels
+ ? (collapseBelowPixels / availablePixels) * 100
: null;
const minSize = minPixels ? (minPixels / availablePixels) * 100 : null;
const maxSize = maxPixels ? (maxPixels / availablePixels) * 100 : null;
@@ -109,6 +109,6 @@ export function usePanelGroupLayoutValidator({
}
}
},
- [collapseThresholdPixels, maxPixels, minPixels, position]
+ [collapseBelowPixels, maxPixels, minPixels, position]
);
}
From ad08ab837aafdc275ae02f54a5d270e70ab664c9 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 14:42:39 -0400
Subject: [PATCH 03/20] Fixed layout issue with demo page
---
.../examples/PixelBasedLayouts.module.css | 18 +++++
.../src/routes/examples/PixelBasedLayouts.tsx | 69 +++++++++----------
.../src/routes/examples/shared.module.css | 2 +-
3 files changed, 51 insertions(+), 38 deletions(-)
diff --git a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css
index 621750412..8c98da04f 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css
+++ b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.module.css
@@ -1,3 +1,21 @@
+.AutoSizerWrapper {
+ flex: 1 1 auto;
+ background-color: var(--color-panel-background);
+ border-radius: 0.5rem;
+ overflow: hidden;
+ display: flex;
+}
+
+.AutoSizerInner {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+}
+
.Small {
font-size: 0.8rem;
color: var(--color-dim);
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 020dc448d..b681f03a6 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
@@ -14,6 +14,7 @@ import sharedStyles from "./shared.module.css";
import { PropsWithChildren } from "react";
import Code from "../../components/Code";
+import { dir } from "console";
export default function PixelBasedLayouts() {
const validateLayoutLeft = usePanelGroupLayoutValidator({
@@ -61,9 +62,11 @@ export default function PixelBasedLayouts() {
validateLayout={validateLayoutLeft}
>
-
- 100px - 200px
-
+
@@ -104,10 +107,12 @@ export default function PixelBasedLayouts() {
-
- 200px - 300px
- collapse below 100px
-
+
+
+ 200px - 300px
+ collapse below 100px
+
+
@@ -122,16 +127,18 @@ export default function PixelBasedLayouts() {
Vertical groups can also be managed with this hook.
-
+
-
- 75px - 125px
-
+
@@ -152,7 +159,7 @@ export default function PixelBasedLayouts() {
/>
- You can also use the validateLayout
prop directly to
+ The validateLayout
prop can also be used directly to
implement an entirely custom layout.
@@ -166,34 +173,21 @@ export default function PixelBasedLayouts() {
);
}
-function HorizontalSize({ children }: PropsWithChildren) {
+function Size({
+ children,
+ direction,
+}: PropsWithChildren & {
+ direction: "horizontal" | "vertical";
+}) {
return (
-
- {({ width }) => (
+
+ {({ height, width }) => (
- {width}px
- {children}
-
-
- )}
-
- );
-}
-
-function VerticalSize({ children }: PropsWithChildren) {
- return (
-
- {({ height }) => (
-
-
- {height}px
+ {direction === "horizontal" ? width : height}px
{children}
@@ -251,6 +245,7 @@ function validateLayout({
nextSizes: number[];
prevSizes: number[];
}): number[] {
- // Compute and return an array of sizes (totalling 100)
+ // 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 291eb5836..1baf009c7 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
@@ -36,7 +36,7 @@
justify-content: center;
background-color: var(--color-panel-background);
border-radius: 0.5rem;
- overflow: auto;
+ overflow: hidden;
font-size: 1rem;
padding: 0.5rem;
word-break: break-all;
From 97f277477220147b1ec5330836cd6efb5f6a83e2 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 17:42:47 -0400
Subject: [PATCH 04/20] Added initial e2e tests for validate layout hook
---
.../src/routes/EndToEndTesting/index.tsx | 92 ++++++++++++++++---
.../src/routes/EndToEndTesting/styles.css | 4 +-
.../src/utils/UrlData.ts | 33 ++++---
.../usePanelGroupLayoutValidator.spec.ts | 64 +++++++++++++
.../tests/utils/panels.ts | 9 ++
.../tests/utils/url.ts | 30 ++++--
.../src/hooks/usePanelGroupLayoutValidator.ts | 4 +
.../src/hooks/useWindowSplitterBehavior.ts | 7 +-
8 files changed, 209 insertions(+), 34 deletions(-)
create mode 100644 packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
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 907ac8bb2..e10f974af 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
@@ -4,7 +4,7 @@ import {
ImperativePanelHandle,
} from "react-resizable-panels";
-import { urlToUrlData, urlPanelGroupToPanelGroup } from "../../utils/UrlData";
+import { urlToUrlData, PanelGroupForUrlData } from "../../utils/UrlData";
import DebugLog, { ImperativeDebugLogHandle } from "../examples/DebugLog";
@@ -15,17 +15,26 @@ import {
assertImperativePanelHandle,
} from "../../../tests/utils/assert";
import { useLayoutEffect } from "react";
+import { Metadata } from "../../../tests/utils/url";
// Special route that can be configured via URL parameters.
export default function EndToEndTesting() {
- const [urlData, setUrlData] = useState(() => {
+ const [metadata, setMetadata] = useState(() => {
const url = new URL(
typeof window !== undefined ? window.location.href : ""
);
- const urlData = urlToUrlData(url);
+ const metadata = url.searchParams.get("metadata");
- return urlData;
+ return metadata ? JSON.parse(metadata) : null;
+ });
+
+ const [urlPanelGroup, setUrlPanelGroup] = useState(() => {
+ const url = new URL(
+ typeof window !== undefined ? window.location.href : ""
+ );
+
+ return urlToUrlData(url);
});
useLayoutEffect(() => {
@@ -33,12 +42,68 @@ export default function EndToEndTesting() {
const url = new URL(
typeof window !== undefined ? window.location.href : ""
);
- const urlData = urlToUrlData(url);
- setUrlData(urlData);
+ setUrlPanelGroup(urlToUrlData(url));
+
+ const metadata = url.searchParams.get("metadata");
+ setMetadata(metadata ? JSON.parse(metadata) : null);
});
}, []);
+ useLayoutEffect(() => {
+ const observer = new MutationObserver((mutationRecords) => {
+ mutationRecords.forEach((mutationRecord) => {
+ const panelElement = mutationRecord.target as HTMLElement;
+ if (panelElement.childNodes.length > 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`;
+ });
+ });
+
+ const elements = document.querySelectorAll("[data-panel]");
+ Array.from(elements).forEach((element) => {
+ observer.observe(element, {
+ attributes: true,
+ });
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
const [panelId, setPanelId] = useState("");
const [panelGroupId, setPanelGroupId] = useState("");
const [size, setSize] = useState(0);
@@ -49,10 +114,6 @@ 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);
@@ -147,7 +208,16 @@ export default function EndToEndTesting() {
-
{children}
+
+ {urlPanelGroup && (
+
+ )}
+
);
diff --git a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.css b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.css
index 20c9503b1..bdc055c24 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.css
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.css
@@ -1,13 +1,11 @@
.Panel {
background-color: rgba(0, 0, 0, 0.25);
-}
-.Panel::after {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
- content: attr(data-panel-size) "%";
+ white-space: pre-wrap;
}
.PanelGroup {
diff --git a/packages/react-resizable-panels-website/src/utils/UrlData.ts b/packages/react-resizable-panels-website/src/utils/UrlData.ts
index 04851a744..3beb51b79 100644
--- a/packages/react-resizable-panels-website/src/utils/UrlData.ts
+++ b/packages/react-resizable-panels-website/src/utils/UrlData.ts
@@ -19,7 +19,9 @@ import {
PanelResizeHandle,
PanelResizeHandleOnDragging,
PanelResizeHandleProps,
+ usePanelGroupLayoutValidator,
} from "react-resizable-panels";
+import { Metadata } from "../../tests/utils/url";
import { ImperativeDebugLogHandle } from "../routes/examples/DebugLog";
type UrlPanel = {
@@ -210,12 +212,13 @@ function urlPanelToPanel(
},
urlPanel.children.map((child, index) => {
if (isUrlPanelGroup(child)) {
- return urlPanelGroupToPanelGroup(
- child,
+ return createElement(PanelGroupForUrlData, {
debugLogRef,
idToRefMapRef,
- index
- );
+ key: index,
+ metadata: null,
+ urlPanelGroup: child,
+ });
} else {
return createElement(Fragment, { key: index }, child);
}
@@ -223,14 +226,19 @@ function urlPanelToPanel(
);
}
-export function urlPanelGroupToPanelGroup(
- urlPanelGroup: UrlPanelGroup,
- debugLogRef: RefObject,
+export function PanelGroupForUrlData({
+ debugLogRef,
+ idToRefMapRef,
+ metadata,
+ urlPanelGroup,
+}: {
+ debugLogRef: RefObject;
idToRefMapRef: RefObject<
Map
- >,
- key?: any
-): ReactElement {
+ >;
+ metadata: Metadata | null;
+ urlPanelGroup: UrlPanelGroup;
+}): ReactElement {
let onLayout: PanelGroupOnLayout | undefined = undefined;
let refSetter;
@@ -253,6 +261,9 @@ export function urlPanelGroupToPanelGroup(
};
}
+ const config = metadata ? metadata.usePanelGroupLayoutValidator : undefined;
+ const validateLayout = usePanelGroupLayoutValidator((config ?? {}) as any);
+
return createElement(
PanelGroup,
{
@@ -260,10 +271,10 @@ export function urlPanelGroupToPanelGroup(
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/usePanelGroupLayoutValidator.spec.ts b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
new file mode 100644
index 000000000..f5883f6ce
--- /dev/null
+++ b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
@@ -0,0 +1,64 @@
+import { test, expect, Page } from "@playwright/test";
+import { createElement } from "react";
+import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+
+import { goToUrl } from "./utils/url";
+import { verifyPanelSizePixels } from "./utils/panels";
+
+test.describe("usePanelGroupLayoutValidator", () => {
+ test.describe("initial layout", () => {
+ test("should observe max size constraint for default layout", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal" },
+ createElement(Panel),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { defaultSize: 20 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { defaultSize: 20 })
+ ),
+ {
+ usePanelGroupLayoutValidator: {
+ minPixels: 50,
+ maxPixels: 100,
+ position: "left",
+ },
+ }
+ );
+
+ const leftPanel = page.locator("[data-panel]").first();
+ await verifyPanelSizePixels(leftPanel, 100);
+ });
+
+ test("should observe min size constraint for default layout", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal" },
+ createElement(Panel),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { defaultSize: 45 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { defaultSize: 45 })
+ ),
+ {
+ usePanelGroupLayoutValidator: {
+ minPixels: 50,
+ maxPixels: 100,
+ position: "left",
+ },
+ }
+ );
+
+ const leftPanel = page.locator("[data-panel]").first();
+ await verifyPanelSizePixels(leftPanel, 50);
+ });
+ });
+});
diff --git a/packages/react-resizable-panels-website/tests/utils/panels.ts b/packages/react-resizable-panels-website/tests/utils/panels.ts
index fc0eb0663..59144e50c 100644
--- a/packages/react-resizable-panels-website/tests/utils/panels.ts
+++ b/packages/react-resizable-panels-website/tests/utils/panels.ts
@@ -132,3 +132,12 @@ export async function verifyPanelSize(locator: Locator, expectedSize: number) {
expectedSize.toFixed(1)
);
}
+
+export async function verifyPanelSizePixels(
+ locator: Locator,
+ expectedSize: number
+) {
+ await expect(await locator.textContent()).toContain(
+ `${expectedSize.toFixed(1)}px`
+ );
+}
diff --git a/packages/react-resizable-panels-website/tests/utils/url.ts b/packages/react-resizable-panels-website/tests/utils/url.ts
index bd83d2ee7..cfc184eae 100644
--- a/packages/react-resizable-panels-website/tests/utils/url.ts
+++ b/packages/react-resizable-panels-website/tests/utils/url.ts
@@ -1,39 +1,53 @@
import { Page } from "@playwright/test";
import { ReactElement } from "react";
-import { PanelGroupProps } from "react-resizable-panels";
+import {
+ PanelGroupProps,
+ usePanelGroupLayoutValidator,
+} 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
+ element: ReactElement,
+ metadata?: Metadata
) {
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) : "");
await page.goto(url.toString());
}
export async function updateUrl(
page: Page,
- element: ReactElement
+ element: ReactElement,
+ metadata?: Metadata
) {
- const encodedString = UrlPanelGroupToEncodedString(element);
+ const urlPanelGroupString = UrlPanelGroupToEncodedString(element);
+ const metadataString = metadata ? JSON.stringify(metadata) : "";
await page.evaluate(
- ([encodedString]) => {
+ ([metadataString, urlPanelGroupString]) => {
const url = new URL(window.location.href);
- url.searchParams.set("urlPanelGroup", encodedString);
+ url.searchParams.set("urlPanelGroup", urlPanelGroupString);
+ url.searchParams.set("metadata", metadataString);
window.history.pushState(
- { urlPanelGroup: encodedString },
+ { urlPanelGroup: urlPanelGroupString },
"",
url.toString()
);
window.dispatchEvent(new Event("popstate"));
},
- [encodedString]
+ [metadataString, urlPanelGroupString]
);
}
diff --git a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
index fb63ab492..f04e386d1 100644
--- a/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
+++ b/packages/react-resizable-panels/src/hooks/usePanelGroupLayoutValidator.ts
@@ -24,6 +24,10 @@ export function usePanelGroupLayoutValidator({
nextSizes: number[];
prevSizes: number[];
}) => {
+ if (minPixels == null && maxPixels == null) {
+ return nextSizes;
+ }
+
let availablePixels;
switch (position) {
case "bottom":
diff --git a/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts b/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts
index 578081786..83aa37472 100644
--- a/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts
+++ b/packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts
@@ -37,7 +37,12 @@ export function useWindowSplitterPanelGroupBehavior({
useEffect(() => {
const { direction, panels } = committedValuesRef.current!;
- const groupElement = getPanelGroup(groupId)!;
+ 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();
const handles = getResizeHandlesForGroup(groupId);
From 0ab67cae44ee18fe06c4f6b7a9ba911d6c68ed86 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 18:37:10 -0400
Subject: [PATCH 05/20] Additional validate layout hook tests
---
.../src/routes/EndToEndTesting/index.tsx | 2 +-
.../usePanelGroupLayoutValidator.spec.ts | 219 +++++++++++++++---
2 files changed, 182 insertions(+), 39 deletions(-)
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 e10f974af..1ee087d2e 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
@@ -54,7 +54,7 @@ export default function EndToEndTesting() {
const observer = new MutationObserver((mutationRecords) => {
mutationRecords.forEach((mutationRecord) => {
const panelElement = mutationRecord.target as HTMLElement;
- if (panelElement.childNodes.length > 0) {
+ if (panelElement.childElementCount > 0) {
return;
}
diff --git a/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
index f5883f6ce..ccb98ded6 100644
--- a/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
+++ b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
@@ -1,34 +1,72 @@
-import { test, expect, Page } from "@playwright/test";
+import { Page, test } from "@playwright/test";
import { createElement } from "react";
-import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+import {
+ Panel,
+ PanelGroup,
+ PanelGroupProps,
+ PanelProps,
+ PanelResizeHandle,
+ usePanelGroupLayoutValidator,
+} from "react-resizable-panels";
+import { dragResizeTo, verifyPanelSizePixels } from "./utils/panels";
import { goToUrl } from "./utils/url";
-import { verifyPanelSizePixels } from "./utils/panels";
+
+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 goToUrl(
- page,
- createElement(
- PanelGroup,
- { direction: "horizontal" },
- createElement(Panel),
- createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 20 }),
- createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 20 })
- ),
- {
- usePanelGroupLayoutValidator: {
- minPixels: 50,
- maxPixels: 100,
- position: "left",
- },
- }
- );
+ await goToUrlHelper(page, {
+ middlePanelProps: { defaultSize: 20 },
+ rightPanelProps: { defaultSize: 20 },
+ });
const leftPanel = page.locator("[data-panel]").first();
await verifyPanelSizePixels(leftPanel, 100);
@@ -37,28 +75,133 @@ test.describe("usePanelGroupLayoutValidator", () => {
test("should observe min size constraint for default layout", async ({
page,
}) => {
- await goToUrl(
+ 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 panelIdInput = page.locator("#panelIdInput");
+ const resizeButton = page.locator("#resizeButton");
+ const sizeInput = page.locator("#sizeInput");
+
+ await panelIdInput.focus();
+ await panelIdInput.fill("left-panel");
+
+ const leftPanel = page.locator("[data-panel]").first();
+
+ await sizeInput.focus();
+ await sizeInput.fill("80");
+ await resizeButton.click();
+ await verifyPanelSizePixels(leftPanel, 100);
+
+ await sizeInput.focus();
+ await sizeInput.fill("5");
+ await resizeButton.click();
+ await verifyPanelSizePixels(leftPanel, 50);
+ });
+
+ test("should honor min/max constraint when resizing via imperative PanelGroup API", async ({
+ page,
+ }) => {
+ await goToUrlHelper(page);
+
+ const panelGroupIdInput = page.locator("#panelGroupIdInput");
+ const setLayoutButton = page.locator("#setLayoutButton");
+ const layoutInput = page.locator("#layoutInput");
+
+ await panelGroupIdInput.focus();
+ await panelGroupIdInput.fill("group");
+
+ const leftPanel = page.locator("[data-panel]").first();
+
+ await layoutInput.focus();
+ await layoutInput.fill("[80, 10, 10]");
+ await setLayoutButton.click();
+ await verifyPanelSizePixels(leftPanel, 100);
+
+ await layoutInput.focus();
+ await layoutInput.fill("[5, 55, 40]");
+ await setLayoutButton.click();
+ await verifyPanelSizePixels(leftPanel, 50);
+ });
+
+ test("should support collapsable panels", async ({ page }) => {
+ await goToUrlHelper(
page,
- createElement(
- PanelGroup,
- { direction: "horizontal" },
- createElement(Panel),
- createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 45 }),
- createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 45 })
- ),
+ {},
{
- usePanelGroupLayoutValidator: {
- minPixels: 50,
- maxPixels: 100,
- position: "left",
- },
+ collapseBelowPixels: 50,
+ minPixels: 100,
+ maxPixels: 200,
}
);
+ const panelIdInput = page.locator("#panelIdInput");
+ const resizeButton = page.locator("#resizeButton");
+ const sizeInput = page.locator("#sizeInput");
+
+ await panelIdInput.focus();
+ await panelIdInput.fill("left-panel");
+
const leftPanel = page.locator("[data-panel]").first();
- await verifyPanelSizePixels(leftPanel, 50);
+
+ await sizeInput.focus();
+ await sizeInput.fill("25");
+ await resizeButton.click();
+ await verifyPanelSizePixels(leftPanel, 100);
+
+ await sizeInput.focus();
+ await sizeInput.fill("10");
+ await resizeButton.click();
+ await verifyPanelSizePixels(leftPanel, 0);
+
+ await sizeInput.focus();
+ await sizeInput.fill("15");
+ await resizeButton.click();
+ await verifyPanelSizePixels(leftPanel, 100);
});
});
});
From a23d022e4a68b9fe0df9fdafdcbbde742afd4199 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 18:48:35 -0400
Subject: [PATCH 06/20] Tidied up tests with helper methods
---
.../tests/ImperativePanelApi.spec.ts | 75 +++----------------
.../tests/ImperativePanelGroupApi.spec.ts | 16 +---
.../tests/Panel-OnCollapse.spec.ts | 18 +----
.../tests/Panel-OnResize.spec.ts | 12 +--
.../usePanelGroupLayoutValidator.spec.ts | 56 ++++----------
.../tests/utils/panels.ts | 38 +++++++++-
6 files changed, 69 insertions(+), 146 deletions(-)
diff --git a/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts b/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
index c5cc19dea..5fc307e3e 100644
--- a/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
@@ -4,6 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { goToUrl } from "./utils/url";
import { verifySizes } from "./utils/verify";
+import { imperativeResizePanel } from "./utils/panels";
async function openPage(
page: Page,
@@ -54,52 +55,21 @@ test.describe("Imperative Panel API", () => {
test("should resize panels within min/max boundaries", async ({ page }) => {
await verifySizes(page, 20, 60, 20);
- const panelIdInput = page.locator("#panelIdInput");
- const resizeButton = page.locator("#resizeButton");
- const sizeInput = page.locator("#sizeInput");
-
- // Left panel
- await panelIdInput.focus();
- await panelIdInput.fill("left");
- await sizeInput.focus();
- await sizeInput.fill("15");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left", 15);
await verifySizes(page, 15, 65, 20);
-
- await sizeInput.focus();
- await sizeInput.fill("5");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left", 5);
await verifySizes(page, 10, 70, 20);
-
- await sizeInput.focus();
- await sizeInput.fill("55");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left", 55);
await verifySizes(page, 30, 50, 20);
- // Middle panel
- await panelIdInput.focus();
- await panelIdInput.fill("middle");
- await sizeInput.focus();
- await sizeInput.fill("15");
- await resizeButton.click();
+ await imperativeResizePanel(page, "middle", 15);
await verifySizes(page, 30, 15, 55);
-
- await sizeInput.focus();
- await sizeInput.fill("5");
- await resizeButton.click();
+ await imperativeResizePanel(page, "middle", 5);
await verifySizes(page, 30, 10, 60);
- // Right panel
- await panelIdInput.focus();
- await panelIdInput.fill("right");
- await sizeInput.focus();
- await sizeInput.fill("15");
- await resizeButton.click();
+ await imperativeResizePanel(page, "right", 15);
await verifySizes(page, 30, 55, 15);
-
- await sizeInput.focus();
- await sizeInput.fill("5");
- await resizeButton.click();
+ await imperativeResizePanel(page, "right", 5);
await verifySizes(page, 30, 60, 10);
});
@@ -109,21 +79,9 @@ test.describe("Imperative Panel API", () => {
const collapseButton = page.locator("#collapseButton");
const expandButton = page.locator("#expandButton");
const panelIdInput = page.locator("#panelIdInput");
- const resizeButton = page.locator("#resizeButton");
- const sizeInput = page.locator("#sizeInput");
-
- await panelIdInput.focus();
- await panelIdInput.fill("left");
- await sizeInput.focus();
- await sizeInput.fill("15");
- await resizeButton.click();
-
- await panelIdInput.focus();
- await panelIdInput.fill("right");
- await sizeInput.focus();
- await sizeInput.fill("25");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left", 15);
+ await imperativeResizePanel(page, "right", 25);
await verifySizes(page, 15, 60, 25);
await panelIdInput.focus();
@@ -147,20 +105,11 @@ test.describe("Imperative Panel API", () => {
await verifySizes(page, 20, 60, 20);
const expandButton = page.locator("#expandButton");
- const panelIdInput = page.locator("#panelIdInput");
- const resizeButton = page.locator("#resizeButton");
- const sizeInput = page.locator("#sizeInput");
-
- await panelIdInput.focus();
- await panelIdInput.fill("left");
- await sizeInput.focus();
- await sizeInput.fill("15");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left", 15);
await verifySizes(page, 15, 65, 20);
- await sizeInput.fill("0");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left", 0);
await verifySizes(page, 0, 80, 20);
await expandButton.click();
diff --git a/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts b/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
index 524f346fd..427441149 100644
--- a/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
@@ -2,6 +2,7 @@ import { Page, test } from "@playwright/test";
import { createElement } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+import { imperativeResizePanelGroup } from "./utils/panels";
import { goToUrl } from "./utils/url";
import { verifySizes } from "./utils/verify";
@@ -54,21 +55,10 @@ test.describe("Imperative PanelGroup API", () => {
test("should resize all panels", async ({ page }) => {
await verifySizes(page, 20, 60, 20);
- const panelGroupIdInput = page.locator("#panelGroupIdInput");
- const setLayoutButton = page.locator("#setLayoutButton");
- const layoutInput = page.locator("#layoutInput");
-
- await panelGroupIdInput.focus();
- await panelGroupIdInput.fill("group");
-
- await layoutInput.focus();
- await layoutInput.fill("[10, 20, 70]");
- await setLayoutButton.click();
+ await imperativeResizePanelGroup(page, "group", [10, 20, 70]);
await verifySizes(page, 10, 20, 70);
- await layoutInput.focus();
- await layoutInput.fill("[90, 6, 4]");
- await setLayoutButton.click();
+ await imperativeResizePanelGroup(page, "group", [90, 6, 4]);
await verifySizes(page, 90, 6, 4);
});
});
diff --git a/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts b/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts
index 30d6d1516..1666eb412 100644
--- a/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts
@@ -5,6 +5,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { PanelCollapseLogEntry } from "../src/routes/examples/types";
import { clearLogEntries, getLogEntries } from "./utils/debug";
+import { imperativeResizePanelGroup } from "./utils/panels";
import { goToUrl } from "./utils/url";
async function openPage(
@@ -140,25 +141,12 @@ test.describe("Panel onCollapse prop", () => {
}) => {
await clearLogEntries(page);
- const panelGroupIdInput = page.locator("#panelGroupIdInput");
- const setLayoutButton = page.locator("#setLayoutButton");
- const layoutInput = page.locator("#layoutInput");
-
- await panelGroupIdInput.focus();
- await panelGroupIdInput.fill("group");
-
- await layoutInput.focus();
- await layoutInput.fill("[70, 30, 0]");
- await setLayoutButton.click();
-
+ await imperativeResizePanelGroup(page, "group", [70, 30, 0]);
await verifyEntries(page, [{ panelId: "right", collapsed: true }]);
await clearLogEntries(page);
- await layoutInput.focus();
- await layoutInput.fill("[0, 0, 100]");
- await setLayoutButton.click();
-
+ await imperativeResizePanelGroup(page, "group", [0, 0, 100]);
await verifyEntries(page, [
{ panelId: "left", collapsed: true },
{ panelId: "middle", collapsed: true },
diff --git a/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts b/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts
index 46d8b9b02..102c52350 100644
--- a/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts
@@ -6,6 +6,7 @@ import { PanelResizeLogEntry } from "../src/routes/examples/types";
import { clearLogEntries, getLogEntries } from "./utils/debug";
import { goToUrl, updateUrl } from "./utils/url";
+import { imperativeResizePanelGroup } from "./utils/panels";
function createElements(numPanels: 2 | 3) {
const panels = [
@@ -115,16 +116,7 @@ test.describe("Panel onResize prop", () => {
}) => {
await clearLogEntries(page);
- const panelGroupIdInput = page.locator("#panelGroupIdInput");
- const setLayoutButton = page.locator("#setLayoutButton");
- const layoutInput = page.locator("#layoutInput");
-
- await panelGroupIdInput.focus();
- await panelGroupIdInput.fill("group");
-
- await layoutInput.focus();
- await layoutInput.fill("[10, 20, 70]");
- await setLayoutButton.click();
+ await imperativeResizePanelGroup(page, "group", [10, 20, 70]);
await verifyEntries(page, [
{ panelId: "left", size: 10 },
diff --git a/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
index ccb98ded6..425b2fbfe 100644
--- a/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
+++ b/packages/react-resizable-panels-website/tests/usePanelGroupLayoutValidator.spec.ts
@@ -9,7 +9,12 @@ import {
usePanelGroupLayoutValidator,
} from "react-resizable-panels";
-import { dragResizeTo, verifyPanelSizePixels } from "./utils/panels";
+import {
+ dragResizeTo,
+ imperativeResizePanel,
+ imperativeResizePanelGroup,
+ verifyPanelSizePixels,
+} from "./utils/panels";
import { goToUrl } from "./utils/url";
type HookConfig = Parameters[0];
@@ -123,23 +128,12 @@ test.describe("usePanelGroupLayoutValidator", () => {
}) => {
await goToUrlHelper(page);
- const panelIdInput = page.locator("#panelIdInput");
- const resizeButton = page.locator("#resizeButton");
- const sizeInput = page.locator("#sizeInput");
-
- await panelIdInput.focus();
- await panelIdInput.fill("left-panel");
-
const leftPanel = page.locator("[data-panel]").first();
- await sizeInput.focus();
- await sizeInput.fill("80");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left-panel", 80);
await verifyPanelSizePixels(leftPanel, 100);
- await sizeInput.focus();
- await sizeInput.fill("5");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left-panel", 4);
await verifyPanelSizePixels(leftPanel, 50);
});
@@ -148,23 +142,12 @@ test.describe("usePanelGroupLayoutValidator", () => {
}) => {
await goToUrlHelper(page);
- const panelGroupIdInput = page.locator("#panelGroupIdInput");
- const setLayoutButton = page.locator("#setLayoutButton");
- const layoutInput = page.locator("#layoutInput");
-
- await panelGroupIdInput.focus();
- await panelGroupIdInput.fill("group");
-
const leftPanel = page.locator("[data-panel]").first();
- await layoutInput.focus();
- await layoutInput.fill("[80, 10, 10]");
- await setLayoutButton.click();
+ await imperativeResizePanelGroup(page, "group", [80, 10, 10]);
await verifyPanelSizePixels(leftPanel, 100);
- await layoutInput.focus();
- await layoutInput.fill("[5, 55, 40]");
- await setLayoutButton.click();
+ await imperativeResizePanelGroup(page, "group", [5, 55, 40]);
await verifyPanelSizePixels(leftPanel, 50);
});
@@ -179,28 +162,15 @@ test.describe("usePanelGroupLayoutValidator", () => {
}
);
- const panelIdInput = page.locator("#panelIdInput");
- const resizeButton = page.locator("#resizeButton");
- const sizeInput = page.locator("#sizeInput");
-
- await panelIdInput.focus();
- await panelIdInput.fill("left-panel");
-
const leftPanel = page.locator("[data-panel]").first();
- await sizeInput.focus();
- await sizeInput.fill("25");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left-panel", 25);
await verifyPanelSizePixels(leftPanel, 100);
- await sizeInput.focus();
- await sizeInput.fill("10");
- await resizeButton.click();
+ await imperativeResizePanel(page, "left-panel", 10);
await verifyPanelSizePixels(leftPanel, 0);
- await sizeInput.focus();
- await sizeInput.fill("15");
- await resizeButton.click();
+ 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 59144e50c..f39aea778 100644
--- a/packages/react-resizable-panels-website/tests/utils/panels.ts
+++ b/packages/react-resizable-panels-website/tests/utils/panels.ts
@@ -1,7 +1,7 @@
-import { Locator, expect, Page } from "@playwright/test";
+import { Locator, Page, expect } from "@playwright/test";
+import { assert } from "./assert";
import { getBodyCursorStyle } from "./cursor";
import { verifyFuzzySizes } from "./verify";
-import { assert } from "./assert";
type Operation = {
expectedCursor?: string;
@@ -127,6 +127,40 @@ export async function dragResizeTo(
await page.mouse.up();
}
+export async function imperativeResizePanel(
+ page: Page,
+ panelId: string,
+ size: number
+) {
+ const panelIdInput = page.locator("#panelIdInput");
+ await panelIdInput.focus();
+ await panelIdInput.fill(panelId);
+
+ const sizeInput = page.locator("#sizeInput");
+ await sizeInput.focus();
+ await sizeInput.fill("" + size);
+
+ const resizeButton = page.locator("#resizeButton");
+ await resizeButton.click();
+}
+
+export async function imperativeResizePanelGroup(
+ page: Page,
+ panelGroupId: string,
+ sizes: number[]
+) {
+ const panelGroupIdInput = page.locator("#panelGroupIdInput");
+ await panelGroupIdInput.focus();
+ await panelGroupIdInput.fill(panelGroupId);
+
+ const layoutInput = page.locator("#layoutInput");
+ await layoutInput.focus();
+ await layoutInput.fill(`[${sizes.join()}]`);
+
+ const setLayoutButton = page.locator("#setLayoutButton");
+ await setLayoutButton.click();
+}
+
export async function verifyPanelSize(locator: Locator, expectedSize: number) {
await expect(await locator.getAttribute("data-panel-size")).toBe(
expectedSize.toFixed(1)
From 492c9898c2e543a3fff53176c3a243a89fec7c91 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 18:58:49 -0400
Subject: [PATCH 07/20] Drag cursor reacts in response to validate layout
function
---
.../react-resizable-panels/src/PanelGroup.ts | 43 +++++++++++++------
1 file changed, 29 insertions(+), 14 deletions(-)
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index 0db5b93fc..a5b575718 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -192,11 +192,10 @@ function PanelGroupWithForwardedRef({
// 0-1 values representing the relative size of each panel.
const [sizes, setSizesUnsafe] = useState([]);
- const setSizes = useCallback(
+ 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);
@@ -247,11 +246,22 @@ function PanelGroupWithForwardedRef({
}
}
+ return nextSizes;
+ },
+ [groupId]
+ );
+
+ const setSizes = useCallback(
+ (nextSizes: number[]) => {
+ const { sizes: prevSizes } = committedValuesRef.current;
+
+ nextSizes = validateLayoutHelper(nextSizes);
+
if (!areEqual(prevSizes, nextSizes)) {
setSizesUnsafe(nextSizes);
}
},
- [groupId]
+ [validateLayoutHelper]
);
// Used to support imperative collapse/expand API.
@@ -554,15 +564,19 @@ function PanelGroupWithForwardedRef({
const size = isHorizontal ? rect.width : rect.height;
const delta = (movement / size) * 100;
- const nextSizes = adjustByDelta(
- event,
- panels,
- idBefore,
- idAfter,
- delta,
- prevSizes,
- panelSizeBeforeCollapse.current,
- initialDragStateRef.current
+ // 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 sizesChanged = !areEqual(prevSizes, nextSizes);
@@ -598,7 +612,8 @@ function PanelGroupWithForwardedRef({
const panelIdToLastNotifiedSizeMap =
panelIdToLastNotifiedSizeMapRef.current;
- setSizes(nextSizes);
+ // It's okay to bypass in this case because we already validated above
+ setSizesUnsafe(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.
@@ -614,7 +629,7 @@ function PanelGroupWithForwardedRef({
return resizeHandler;
},
- [groupId, setSizes]
+ [groupId, validateLayoutHelper]
);
const unregisterPanel = useCallback((id: string) => {
From e84062052bc89f79b3b6692f8af49c8f9c189282 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 5 Aug 2023 19:10:07 -0400
Subject: [PATCH 08/20] Re-run layout validator on resize (if one is provided)
---
.../react-resizable-panels/src/PanelGroup.ts | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index a5b575718..e46dc0ef0 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -454,6 +454,27 @@ 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);
+ if (!areEqual(prevSizes, nextSizes)) {
+ setSizesUnsafe(nextSizes);
+ }
+ });
+
+ resizeObserver.observe(getPanelGroup(groupId)!);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }
+ }, [groupId, setSizes, validateLayoutHelper]);
+
const getPanelStyle = useCallback(
(id: string, defaultSize: number | null): CSSProperties => {
const { panels } = committedValuesRef.current;
From 23d63d52b72bfd1e88251fee73622535c62ff48b Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Wed, 9 Aug 2023 13:11:10 -1000
Subject: [PATCH 09/20] 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.
-
-
-
-
-
-
-
-
-
- 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;
}
From 0ddebcc51c8b7dbf5e4065e2d96e662ea8ae616a Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Wed, 9 Aug 2023 19:33:52 -1000
Subject: [PATCH 10/20] Improved default layout algorithm to better handle edge
cases
---
.../DevelopmentWarningsAndErrors.spec.ts | 28 ++++++-
.../tests/utils/url.ts | 2 +-
packages/react-resizable-panels/src/Panel.ts | 19 ++++-
.../react-resizable-panels/src/PanelGroup.ts | 77 +++++++++++--------
4 files changed, 88 insertions(+), 38 deletions(-)
diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
index 1d6866809..8a8bda112 100644
--- a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
+++ b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
@@ -274,10 +274,36 @@ test.describe("Development warnings and errors", () => {
expect(errors).toEqual(
expect.arrayContaining([
expect.stringContaining(
- "Invalid panel group configuration; default panel sizes should total 100 but was 50"
+ "Invalid panel group configuration; default panel sizes should total 100% but was 50.0%"
),
])
);
});
});
+
+ test("should warn if no minSize is provided for a panel with static units", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal" },
+ createElement(Panel, { defaultSize: 25, units: "static" }),
+ createElement(PanelResizeHandle),
+ createElement(Panel)
+ )
+ );
+
+ await flushMessages(page);
+
+ expect(warnings).not.toHaveLength(0);
+ expect(warnings).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining(
+ 'Panels with "static" units should specify a minSize value'
+ ),
+ ])
+ );
+ });
});
diff --git a/packages/react-resizable-panels-website/tests/utils/url.ts b/packages/react-resizable-panels-website/tests/utils/url.ts
index 88f6bb1b0..ee5f9e121 100644
--- a/packages/react-resizable-panels-website/tests/utils/url.ts
+++ b/packages/react-resizable-panels-website/tests/utils/url.ts
@@ -12,7 +12,7 @@ export async function goToUrl(
const url = new URL("http://localhost:1234/__e2e");
url.searchParams.set("urlPanelGroup", encodedString);
- // Uncomment when testing for easier debugging
+ // Uncomment when testing for easier repros
// console.log(url.toString());
await page.goto(url.toString());
diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts
index 6798206f7..14cbf47b7 100644
--- a/packages/react-resizable-panels/src/Panel.ts
+++ b/packages/react-resizable-panels/src/Panel.ts
@@ -57,7 +57,7 @@ function PanelWithForwardedRef({
forwardedRef,
id: idFromProps = null,
maxSize = null,
- minSize = 10,
+ minSize,
onCollapse = null,
onResize = null,
order = null,
@@ -74,6 +74,21 @@ function PanelWithForwardedRef({
);
}
+ if (minSize == null) {
+ if (units === "static") {
+ if (isDevelopment) {
+ console.warn(
+ 'Panels with "static" units should specify a minSize value'
+ );
+ }
+
+ minSize = 0;
+ } else {
+ // Previous default minimize size for relative units
+ minSize = 10;
+ }
+ }
+
const panelId = useUniqueId(idFromProps);
const {
@@ -198,7 +213,7 @@ function PanelWithForwardedRef({
panelDataRef.current.id = panelId;
panelDataRef.current.idWasAutoGenerated = idFromProps == null;
panelDataRef.current.maxSize = maxSize;
- panelDataRef.current.minSize = minSize;
+ panelDataRef.current.minSize = minSize as number;
panelDataRef.current.order = order;
panelDataRef.current.units = units;
});
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index 95fd87c34..5184f807a 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -307,35 +307,35 @@ function PanelGroupWithForwardedRef({
} else {
const panelsArray = panelsMapToSortedArray(panels);
- let totalDefaultSize = 0;
-
- const panelsWithSizes = new Set();
const sizes = Array(panelsArray.length);
+ let numPanelsWithSizes = 0;
+ let remainingSize = 100;
+
// 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;
+ const { defaultSize, units } = panel.current;
if (defaultSize != null) {
- panelsWithSizes.add(id);
+ numPanelsWithSizes++;
sizes[index] =
units === "static"
? (defaultSize / groupSizePixels) * 100
: defaultSize;
- totalDefaultSize += sizes[index];
+ remainingSize -= sizes[index];
}
}
- // Remaining total size should be distributed evenly between panels in two additional passes.
- // First, panels with minSize/maxSize should get their portions
+ // Remaining total size should be distributed evenly between panels
+ // This may require two passes, depending on min/max constraints
for (let index = 0; index < panelsArray.length; index++) {
const panel = panelsArray[index];
- let { id, maxSize, minSize, units } = panel.current;
- if (panelsWithSizes.has(id)) {
+ let { defaultSize, id, maxSize, minSize, units } = panel.current;
+ if (defaultSize != null) {
continue;
}
@@ -346,44 +346,53 @@ function PanelGroupWithForwardedRef({
}
}
- if (minSize === 0 && (maxSize === null || maxSize === 100)) {
- continue;
- }
-
- const remainingSize = 100 - totalDefaultSize;
- const remainingPanels = panelsArray.length - panelsWithSizes.size;
+ const remainingPanels = panelsArray.length - numPanelsWithSizes;
const size = Math.min(
maxSize != null ? maxSize : 100,
Math.max(minSize, remainingSize / remainingPanels)
);
sizes[index] = size;
- totalDefaultSize += size;
- panelsWithSizes.add(id);
+ numPanelsWithSizes++;
+ remainingSize -= size;
}
- // 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;
- }
+ // If there is additional, left over space, assign it to any panel(s) that permits it
+ // (It's not worth taking multiple additional passes to evenly distribute)
+ if (remainingSize !== 0) {
+ for (let index = 0; index < panelsArray.length; index++) {
+ const panel = panelsArray[index];
+ let { defaultSize, maxSize, minSize } = panel.current;
+ if (defaultSize != null) {
+ continue;
+ }
- const remainingSize = 100 - totalDefaultSize;
- const remainingPanels = panelsArray.length - panelsWithSizes.size;
- const size = remainingSize / remainingPanels;
+ const size = Math.min(
+ maxSize != null ? maxSize : 100,
+ Math.max(minSize, sizes[index] + remainingSize)
+ );
- sizes[index] = size;
- totalDefaultSize += size;
- panelsWithSizes.add(id);
+ if (size !== sizes[index]) {
+ remainingSize -= size - sizes[index];
+ sizes[index] = size;
+
+ // Fuzzy comparison to account for imprecise floating point math
+ if (Math.abs(remainingSize).toFixed(3) === "0.000") {
+ break;
+ }
+ }
+ }
}
- // Finally: If there is any left-over values at the end, log an error
- if (totalDefaultSize !== 100) {
+ // Finally, if there is still left-over size, log an error
+ if (Math.abs(remainingSize).toFixed(3) !== "0.000") {
if (isDevelopment) {
console.error(
- `Invalid panel group configuration; default panel sizes should total 100 but was ${totalDefaultSize}`
+ `Invalid panel group configuration; default panel sizes should total 100% but was ${(
+ 100 - remainingSize
+ ).toFixed(
+ 1
+ )}%. This can cause the cursor to become misaligned while dragging.`
);
}
}
From 7b6ecf2eebde6615011d46b5fdd92bc334dc7b76 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Thu, 10 Aug 2023 09:01:37 -1000
Subject: [PATCH 11/20] Added additional layout warning e2e tests
---
.../DevelopmentWarningsAndErrors.spec.ts | 50 +++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
index 8a8bda112..0aef62e72 100644
--- a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
+++ b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
@@ -306,4 +306,54 @@ test.describe("Development warnings and errors", () => {
])
);
});
+
+ test("should warn if invalid layout constraints are provided", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal" },
+ createElement(Panel, { minSize: 75 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { minSize: 75 })
+ )
+ );
+
+ 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 150.0%."
+ ),
+ ])
+ );
+
+ errors.splice(0);
+
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal" },
+ createElement(Panel, { maxSize: 25 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { maxSize: 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.0%."
+ ),
+ ])
+ );
+ });
});
From 40713da62816d9392b1798ceef05954f33afa7b5 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Thu, 10 Aug 2023 09:21:21 -1000
Subject: [PATCH 12/20] Add warnings for invalid sizes set via imperative APIs
---
.../DevelopmentWarningsAndErrors.spec.ts | 86 +++++++++++++++++++
.../react-resizable-panels/src/PanelGroup.ts | 42 ++++++++-
2 files changed, 124 insertions(+), 4 deletions(-)
diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
index 0aef62e72..0287f46b8 100644
--- a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
+++ b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
@@ -3,6 +3,10 @@ import { createElement } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { goToUrl, updateUrl } from "./utils/url";
+import {
+ imperativeResizePanel,
+ imperativeResizePanelGroup,
+} from "./utils/panels";
function createElements({
numPanels,
@@ -356,4 +360,86 @@ test.describe("Development warnings and errors", () => {
])
);
});
+
+ test("should warn about invalid sizes set via imperative Panel API", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal" },
+ createElement(Panel, { id: "left-panel", minSize: 25 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { id: "middle-panel", minSize: 25 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { id: "right-panel", maxSize: 25 })
+ )
+ );
+
+ await imperativeResizePanel(page, "left-panel", 20);
+
+ expect(errors).not.toHaveLength(0);
+ expect(errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining(
+ 'Invalid size (20) specified for Panel "left-panel"'
+ ),
+ ])
+ );
+
+ errors.splice(0);
+
+ await imperativeResizePanel(page, "right-panel", 50);
+
+ expect(errors).not.toHaveLength(0);
+ expect(errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining(
+ 'Invalid size (50) specified for Panel "right-panel"'
+ ),
+ ])
+ );
+ });
+
+ test("should warn about invalid layouts set via imperative PanelGroup API", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal", id: "group" },
+ createElement(Panel, { id: "left-panel", minSize: 25 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { id: "middle-panel", minSize: 25 }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, { id: "right-panel", maxSize: 25 })
+ )
+ );
+
+ await imperativeResizePanelGroup(page, "group", [20, 60, 20]);
+
+ expect(errors).not.toHaveLength(0);
+ expect(errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining(
+ 'Invalid size (20) specified for Panel "left-panel"'
+ ),
+ ])
+ );
+
+ errors.splice(0);
+
+ await imperativeResizePanelGroup(page, "group", [25, 25, 50]);
+
+ expect(errors).not.toHaveLength(0);
+ expect(errors).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining(
+ 'Invalid size (50) specified for Panel "right-panel"'
+ ),
+ ])
+ );
+ });
});
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index 5184f807a..e4ddcdc80 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -46,6 +46,7 @@ import {
getResizeHandle,
getResizeHandlePanelIds,
panelsMapToSortedArray,
+ safeResizePanel,
} from "./utils/group";
import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization";
@@ -218,14 +219,37 @@ function PanelGroupWithForwardedRef({
assert(total === 100, "Panel sizes must add up to 100%");
- const { panels } = committedValuesRef.current;
+ const {
+ id: groupId,
+ panels,
+ sizes: prevSizes,
+ } = committedValuesRef.current;
const panelIdToLastNotifiedSizeMap =
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
+ if (isDevelopment) {
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
+
+ for (let index = 0; index < sizes.length; index++) {
+ const panel = panelsArray[index];
+ const prevSize = prevSizes[index];
+ const nextSize = sizes[index];
+ const safeSize = safeResizePanel(
+ groupSizePixels,
+ panel,
+ nextSize - prevSize,
+ prevSize,
+ null
+ );
+
+ if (nextSize !== safeSize) {
+ console.error(
+ `Invalid size (${nextSize}) specified for Panel "${panel.current.id}" given the panel's min/max size constraints`
+ );
+ }
+ }
+ }
setSizes(sizes);
@@ -833,10 +857,20 @@ function PanelGroupWithForwardedRef({
if (collapsible && nextSize === collapsedSize) {
// This is a valid resize state.
} else {
+ const unsafeNextSize = nextSize;
+
nextSize = Math.min(
maxSize != null ? maxSize : 100,
Math.max(minSize, nextSize)
);
+
+ if (isDevelopment) {
+ if (unsafeNextSize !== nextSize) {
+ console.error(
+ `Invalid size (${unsafeNextSize}) specified for Panel "${panel.current.id}" given the panel's min/max size constraints`
+ );
+ }
+ }
}
const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
From e0b16904c1521b538bc157d84f0c1d7d7297b403 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Thu, 10 Aug 2023 09:24:00 -1000
Subject: [PATCH 13/20] Tweaked collapsible panel demo to use static units for
more precise layout
---
.../src/routes/examples/Collapsible.tsx | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx b/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx
index 9c5fe2adf..7ca8a9c55 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx
@@ -69,12 +69,13 @@ function Content() {
From 2a4c0ab8baaed688e190e48cebe0882e0c681b39 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Thu, 10 Aug 2023 19:34:17 -1000
Subject: [PATCH 14/20] Moved units prop from Panel to PanelGroup
---
.../src/routes/examples/Collapsible.tsx | 5 +-
.../src/routes/examples/Conditional.tsx | 6 +-
.../routes/examples/ExternalPersistence.tsx | 6 +-
.../examples/ImperativePanelGroupApi.tsx | 4 +-
.../src/routes/examples/Nested.tsx | 12 +--
.../src/routes/examples/Persistence.tsx | 6 +-
.../src/routes/examples/PixelBasedLayouts.tsx | 24 ++---
.../src/routes/examples/Vertical.tsx | 9 +-
.../src/utils/UrlData.ts | 10 +-
.../tests/Collapsing.spec.ts | 6 +-
.../tests/CursorStyle.spec.ts | 3 +-
.../DevelopmentWarningsAndErrors.spec.ts | 62 ++++---------
.../tests/Group-OnLayout.spec.ts | 16 +++-
.../tests/NestedGroups.spec.ts | 14 +--
.../tests/Panel-OnCollapse.spec.ts | 3 +
.../tests/Panel-OnResize.spec.ts | 9 +-
....spec.ts => PanelGroup-PixelUnits.spec.ts} | 68 ++++++++++----
.../tests/ResizeHandle-OnDragging.spec.ts | 16 +++-
.../tests/ResizeHandle.spec.ts | 6 +-
.../tests/Springy.spec.ts | 4 +-
.../tests/Storage.spec.ts | 14 +--
.../tests/WindowSplitter.spec.ts | 22 ++---
packages/react-resizable-panels/CHANGELOG.md | 3 +-
packages/react-resizable-panels/src/Panel.ts | 92 +------------------
.../react-resizable-panels/src/PanelGroup.ts | 72 +++++++--------
packages/react-resizable-panels/src/index.ts | 4 +-
packages/react-resizable-panels/src/types.ts | 4 +-
.../react-resizable-panels/src/utils/group.ts | 78 ++++++++++++++--
28 files changed, 302 insertions(+), 276 deletions(-)
rename packages/react-resizable-panels-website/tests/{Panel-StaticUnits.spec.ts => PanelGroup-PixelUnits.spec.ts} (81%)
diff --git a/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx b/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx
index 7ca8a9c55..02968bc37 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/Collapsible.tsx
@@ -62,7 +62,7 @@ function Content() {
return (
-
+
@@ -75,7 +75,6 @@ function Content() {
maxSize={150}
minSize={60}
onCollapse={toggleCollapsed}
- units="static"
>
@@ -104,7 +103,7 @@ function Content() {
: styles.ResizeHandle
}
/>
-
+
{Array.from(openFiles).map((file) => (
{showLeftPanel && (
<>
-
+
left
>
)}
-
+
middle
{showRightPanel && (
<>
-
+
right
>
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 4c00c138d..b1b6b0b8d 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx
@@ -81,15 +81,15 @@ function Content() {
direction="horizontal"
storage={urlStorage}
>
-
+
left
-
+
middle
-
+
right
diff --git a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx
index d6e81ab5a..10584d974 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx
@@ -87,13 +87,13 @@ function Content() {
onLayout={onLayout}
ref={panelGroupRef}
>
-
+
left: {Math.round(sizes[0])}
-
+
right: {Math.round(sizes[1])}
diff --git a/packages/react-resizable-panels-website/src/routes/examples/Nested.tsx b/packages/react-resizable-panels-website/src/routes/examples/Nested.tsx
index 78c3588b9..e7adfb706 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/Nested.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/Nested.tsx
@@ -20,23 +20,23 @@ function Content() {
return (
-
+
left
-
+
top
-
+
-
+
left
-
+
right
@@ -44,7 +44,7 @@ function Content() {
-
+
right
diff --git a/packages/react-resizable-panels-website/src/routes/examples/Persistence.tsx b/packages/react-resizable-panels-website/src/routes/examples/Persistence.tsx
index a6d1a75c2..3fc7ca465 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/Persistence.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/Persistence.tsx
@@ -30,15 +30,15 @@ function Content() {
className={styles.PanelGroup}
direction="horizontal"
>
-
+
left
-
+
middle
-
+
right
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 44971a0e6..3a4a08c3d 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/PixelBasedLayouts.tsx
@@ -29,20 +29,20 @@ export default function PixelBasedLayouts() {
- Pixel units should be used sparingly because they require more complex
- layout logic.
+ Pixel units should only be used when necessary because they require more
+ complex layout logic.
@@ -51,11 +51,11 @@ export default function PixelBasedLayouts() {
-
+
middle
-
+
right
@@ -78,12 +78,13 @@ export default function PixelBasedLayouts() {
-
+
left
-
+
middle
@@ -93,7 +94,6 @@ export default function PixelBasedLayouts() {
collapsedSize={75}
minSize={200}
maxSize={300}
- units="static"
>
@@ -139,8 +139,8 @@ function Size({
}
const CODE_HOOK = `
-
-
+
+
@@ -149,11 +149,11 @@ const CODE_HOOK = `
`;
const CODE_HOOK_COLLAPSIBLE = `
-
+
-
+
`;
diff --git a/packages/react-resizable-panels-website/src/routes/examples/Vertical.tsx b/packages/react-resizable-panels-website/src/routes/examples/Vertical.tsx
index 5498e220d..71abcd520 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/Vertical.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/Vertical.tsx
@@ -33,11 +33,16 @@ function Content() {
return (
-
+
top
-
+
bottom
diff --git a/packages/react-resizable-panels-website/src/utils/UrlData.ts b/packages/react-resizable-panels-website/src/utils/UrlData.ts
index 4a9875ce4..8b2b6feb4 100644
--- a/packages/react-resizable-panels-website/src/utils/UrlData.ts
+++ b/packages/react-resizable-panels-website/src/utils/UrlData.ts
@@ -19,7 +19,7 @@ import {
PanelResizeHandle,
PanelResizeHandleOnDragging,
PanelResizeHandleProps,
- PanelUnits,
+ Units,
} from "react-resizable-panels";
import { ImperativeDebugLogHandle } from "../routes/examples/DebugLog";
@@ -30,11 +30,10 @@ type UrlPanel = {
defaultSize?: number | null;
id?: string | null;
maxSize?: number | null;
- minSize?: number;
+ minSize: number;
order?: number | null;
style?: CSSProperties;
type: "UrlPanel";
- units: PanelUnits;
};
type UrlPanelGroup = {
@@ -44,6 +43,7 @@ type UrlPanelGroup = {
id?: string | null;
style?: CSSProperties;
type: "UrlPanelGroup";
+ units: Units;
};
type UrlPanelResizeHandle = {
@@ -112,7 +112,6 @@ function UrlPanelToData(urlPanel: ReactElement
): UrlPanel {
order: urlPanel.props.order,
style: urlPanel.props.style,
type: "UrlPanel",
- units: urlPanel.props.units ?? "relative",
};
}
@@ -134,6 +133,7 @@ function UrlPanelGroupToData(
id: urlPanelGroup.props.id,
style: urlPanelGroup.props.style,
type: "UrlPanelGroup",
+ units: urlPanelGroup.props.units ?? "percentages",
};
}
@@ -210,7 +210,6 @@ function urlPanelToPanel(
order: urlPanel.order,
ref: refSetter,
style: urlPanel.style,
- units: urlPanel.units,
},
urlPanel.children.map((child, index) => {
if (isUrlPanelGroup(child)) {
@@ -268,6 +267,7 @@ export function urlPanelGroupToPanelGroup(
onLayout,
ref: refSetter,
style: urlPanelGroup.style,
+ units: urlPanelGroup.units,
},
urlPanelGroup.children.map((child, index) => {
if (isUrlPanel(child)) {
diff --git a/packages/react-resizable-panels-website/tests/Collapsing.spec.ts b/packages/react-resizable-panels-website/tests/Collapsing.spec.ts
index b948564d9..2c46e41cd 100644
--- a/packages/react-resizable-panels-website/tests/Collapsing.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Collapsing.spec.ts
@@ -18,7 +18,9 @@ test.describe("collapsible prop", () => {
minSize: 10,
}),
createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }),
- createElement(Panel),
+ createElement(Panel, {
+ minSize: 10,
+ }),
createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }),
createElement(Panel, {
collapsible: true,
@@ -81,7 +83,7 @@ test.describe("collapsible prop", () => {
minSize: 10,
}),
createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
diff --git a/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts b/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts
index 8984f889e..a961cac86 100644
--- a/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts
+++ b/packages/react-resizable-panels-website/tests/CursorStyle.spec.ts
@@ -41,9 +41,10 @@ test.describe("cursor style", () => {
createElement(Panel, {
defaultSize: 50,
id: "first-panel",
+ minSize: 10,
}),
createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 50, id: "last-panel" })
+ createElement(Panel, { defaultSize: 50, id: "last-panel", minSize: 10 })
)
);
}
diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
index 0287f46b8..0abdabded 100644
--- a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
+++ b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
@@ -1,5 +1,5 @@
import { Page, expect, test } from "@playwright/test";
-import { createElement } from "react";
+import { ReactNode, createElement } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { goToUrl, updateUrl } from "./utils/url";
@@ -17,11 +17,12 @@ function createElements({
omitIdProp?: boolean;
omitOrderProp?: boolean;
}) {
- const panels = [
+ const panels: ReactNode[] = [
createElement(Panel, {
collapsible: true,
defaultSize: numPanels === 2 ? 50 : 100,
id: omitIdProp ? undefined : "left",
+ minSize: 10,
order: omitOrderProp ? undefined : 1,
}),
];
@@ -33,6 +34,7 @@ function createElements({
collapsible: true,
defaultSize: 50,
id: omitIdProp ? undefined : "right",
+ minSize: 10,
order: omitOrderProp ? undefined : 2,
})
);
@@ -153,7 +155,7 @@ test.describe("Development warnings and errors", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { defaultSize: -1 })
+ createElement(Panel, { defaultSize: -1, minSize: 10 })
)
);
@@ -167,7 +169,7 @@ test.describe("Development warnings and errors", () => {
);
});
- test("should throw if defaultSize is greater than 100 and units are relative", async ({
+ test("should throw if defaultSize is greater than 100 and units are percentages", async ({
page,
}) => {
await goToUrl(
@@ -175,7 +177,7 @@ test.describe("Development warnings and errors", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { defaultSize: 400 })
+ createElement(Panel, { defaultSize: 400, minSize: 10 })
)
);
@@ -189,15 +191,15 @@ test.describe("Development warnings and errors", () => {
);
});
- test("should not throw if defaultSize is greater than 100 and units are static", async ({
+ test("should not throw if defaultSize is greater than 100 and units are pixels", async ({
page,
}) => {
await goToUrl(
page,
createElement(
PanelGroup,
- { direction: "horizontal" },
- createElement(Panel, { defaultSize: 400, units: "static" })
+ { direction: "horizontal", units: "pixels" },
+ createElement(Panel, { defaultSize: 400, minSize: 10 })
)
);
@@ -216,7 +218,7 @@ test.describe("Development warnings and errors", () => {
{ direction: "horizontal" },
createElement(Panel, { defaultSize: 25, minSize: 50 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
@@ -240,9 +242,9 @@ test.describe("Development warnings and errors", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { defaultSize: 75, maxSize: 50 }),
+ createElement(Panel, { defaultSize: 75, maxSize: 50, minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
@@ -266,9 +268,9 @@ test.describe("Development warnings and errors", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { defaultSize: 25 }),
+ createElement(Panel, { defaultSize: 25, minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 25 })
+ createElement(Panel, { defaultSize: 25, minSize: 10 })
)
);
@@ -285,32 +287,6 @@ test.describe("Development warnings and errors", () => {
});
});
- test("should warn if no minSize is provided for a panel with static units", async ({
- page,
- }) => {
- await goToUrl(
- page,
- createElement(
- PanelGroup,
- { direction: "horizontal" },
- createElement(Panel, { defaultSize: 25, units: "static" }),
- createElement(PanelResizeHandle),
- createElement(Panel)
- )
- );
-
- await flushMessages(page);
-
- expect(warnings).not.toHaveLength(0);
- expect(warnings).toEqual(
- expect.arrayContaining([
- expect.stringContaining(
- 'Panels with "static" units should specify a minSize value'
- ),
- ])
- );
- });
-
test("should warn if invalid layout constraints are provided", async ({
page,
}) => {
@@ -343,9 +319,9 @@ test.describe("Development warnings and errors", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { maxSize: 25 }),
+ createElement(Panel, { maxSize: 25, minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel, { maxSize: 25 })
+ createElement(Panel, { maxSize: 25, minSize: 10 })
)
);
@@ -373,7 +349,7 @@ test.describe("Development warnings and errors", () => {
createElement(PanelResizeHandle),
createElement(Panel, { id: "middle-panel", minSize: 25 }),
createElement(PanelResizeHandle),
- createElement(Panel, { id: "right-panel", maxSize: 25 })
+ createElement(Panel, { id: "right-panel", maxSize: 25, minSize: 10 })
)
);
@@ -414,7 +390,7 @@ test.describe("Development warnings and errors", () => {
createElement(PanelResizeHandle),
createElement(Panel, { id: "middle-panel", minSize: 25 }),
createElement(PanelResizeHandle),
- createElement(Panel, { id: "right-panel", maxSize: 25 })
+ createElement(Panel, { id: "right-panel", maxSize: 25, minSize: 10 })
)
);
diff --git a/packages/react-resizable-panels-website/tests/Group-OnLayout.spec.ts b/packages/react-resizable-panels-website/tests/Group-OnLayout.spec.ts
index 10b45fc6f..098be9952 100644
--- a/packages/react-resizable-panels-website/tests/Group-OnLayout.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Group-OnLayout.spec.ts
@@ -11,11 +11,21 @@ async function openPage(page: Page) {
const panelGroup = createElement(
PanelGroup,
{ direction: "horizontal", id: "group" },
- createElement(Panel, { collapsible: true, defaultSize: 20, order: 1 }),
+ createElement(Panel, {
+ collapsible: true,
+ defaultSize: 20,
+ minSize: 10,
+ order: 1,
+ }),
createElement(PanelResizeHandle, { id: "left-handle" }),
- createElement(Panel, { defaultSize: 60, order: 2 }),
+ createElement(Panel, { defaultSize: 60, minSize: 10, order: 2 }),
createElement(PanelResizeHandle, { id: "right-handle" }),
- createElement(Panel, { collapsible: true, defaultSize: 20, order: 3 })
+ createElement(Panel, {
+ collapsible: true,
+ defaultSize: 20,
+ minSize: 10,
+ order: 3,
+ })
);
await goToUrl(page, panelGroup);
diff --git a/packages/react-resizable-panels-website/tests/NestedGroups.spec.ts b/packages/react-resizable-panels-website/tests/NestedGroups.spec.ts
index 308f99826..1575583d2 100644
--- a/packages/react-resizable-panels-website/tests/NestedGroups.spec.ts
+++ b/packages/react-resizable-panels-website/tests/NestedGroups.spec.ts
@@ -12,31 +12,31 @@ test.describe("Nested groups", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
createElement(
Panel,
- undefined,
+ { minSize: 10 },
createElement(
PanelGroup,
{ direction: "vertical" },
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
createElement(
Panel,
- undefined,
+ { minSize: 10 },
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
)
)
),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
diff --git a/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts b/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts
index 1666eb412..d769fb4b0 100644
--- a/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Panel-OnCollapse.spec.ts
@@ -24,12 +24,14 @@ async function openPage(
collapsible: true,
defaultSize: collapsedByDefault ? 0 : 20,
id: "left",
+ minSize: 10,
order: 1,
}),
createElement(PanelResizeHandle, { id: "left-handle" }),
createElement(Panel, {
collapsible: middleCollapsible,
id: "middle",
+ minSize: 10,
order: 2,
}),
createElement(PanelResizeHandle, { id: "right-handle" }),
@@ -37,6 +39,7 @@ async function openPage(
collapsible: true,
defaultSize: collapsedByDefault ? 0 : 20,
id: "right",
+ minSize: 10,
order: 3,
})
);
diff --git a/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts b/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts
index 102c52350..e12c3a621 100644
--- a/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Panel-OnResize.spec.ts
@@ -14,10 +14,16 @@ function createElements(numPanels: 2 | 3) {
collapsible: true,
defaultSize: numPanels === 3 ? 20 : 40,
id: "left",
+ minSize: 10,
order: 1,
}),
createElement(PanelResizeHandle, { id: "left-handle" }),
- createElement(Panel, { defaultSize: 60, id: "middle", order: 2 }),
+ createElement(Panel, {
+ defaultSize: 60,
+ id: "middle",
+ minSize: 10,
+ order: 2,
+ }),
];
if (numPanels === 3) {
@@ -27,6 +33,7 @@ function createElements(numPanels: 2 | 3) {
collapsible: true,
defaultSize: 20,
id: "right",
+ minSize: 10,
order: 3,
})
);
diff --git a/packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
similarity index 81%
rename from packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts
rename to packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
index f27c3fb35..ac3f75bdb 100644
--- a/packages/react-resizable-panels-website/tests/Panel-StaticUnits.spec.ts
+++ b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
@@ -31,9 +31,15 @@ async function goToUrlHelper(
page,
createElement(
PanelGroup,
- { direction: "horizontal", id: "group", ...props.panelGroupProps },
+ {
+ direction: "horizontal",
+ id: "group",
+ units: "pixels",
+ ...props.panelGroupProps,
+ },
createElement(Panel, {
id: "left-panel",
+ minSize: 10,
...props.leftPanelProps,
}),
createElement(PanelResizeHandle, {
@@ -42,6 +48,7 @@ async function goToUrlHelper(
}),
createElement(Panel, {
id: "middle-panel",
+ minSize: 10,
...props.middlePanelProps,
}),
createElement(PanelResizeHandle, {
@@ -50,34 +57,35 @@ async function goToUrlHelper(
}),
createElement(Panel, {
id: "right-panel",
+ minSize: 10,
...props.rightPanelProps,
})
)
);
}
-test.describe("Static Panel units", () => {
+test.describe("Pixel 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" },
+ leftPanelProps: { maxSize: 100, minSize: 50 },
});
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" },
+ middlePanelProps: { maxSize: 100, minSize: 50 },
});
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" },
+ rightPanelProps: { maxSize: 100, minSize: 50 },
});
const rightPanel = page.locator('[data-panel-id="right-panel"]');
await verifyPanelSizePixels(rightPanel, 100);
@@ -87,7 +95,7 @@ test.describe("Static Panel units", () => {
page,
}) => {
await goToUrlHelper(page, {
- leftPanelProps: { maxSize: 300, minSize: 200, units: "static" },
+ leftPanelProps: { maxSize: 300, minSize: 200 },
});
const leftPanel = page.locator("[data-panel]").first();
@@ -98,7 +106,7 @@ test.describe("Static Panel units", () => {
page,
}) => {
await goToUrlHelper(page, {
- leftPanelProps: { maxSize: 100, minSize: 50, units: "static" },
+ leftPanelProps: { maxSize: 100, minSize: 50 },
});
const leftPanel = page.locator("[data-panel]").first();
@@ -120,7 +128,7 @@ test.describe("Static Panel units", () => {
page,
}) => {
await goToUrlHelper(page, {
- leftPanelProps: { maxSize: 100, minSize: 50, units: "static" },
+ leftPanelProps: { maxSize: 100, minSize: 50 },
});
const leftPanel = page.locator("[data-panel]").first();
@@ -136,7 +144,7 @@ test.describe("Static Panel units", () => {
page,
}) => {
await goToUrlHelper(page, {
- leftPanelProps: { maxSize: 100, minSize: 50, units: "static" },
+ leftPanelProps: { maxSize: 100, minSize: 50 },
});
const leftPanel = page.locator("[data-panel]").first();
@@ -152,7 +160,7 @@ test.describe("Static Panel units", () => {
page,
}) => {
await goToUrlHelper(page, {
- rightPanelProps: { maxSize: 100, minSize: 50, units: "static" },
+ rightPanelProps: { maxSize: 100, minSize: 50 },
});
const rightPanel = page.locator("[data-panel]").last();
@@ -170,7 +178,6 @@ test.describe("Static Panel units", () => {
collapsible: true,
minSize: 100,
maxSize: 200,
- units: "static",
},
});
@@ -195,7 +202,6 @@ test.describe("Static Panel units", () => {
defaultSize: 50,
maxSize: 100,
minSize: 50,
- units: "static",
},
});
const leftPanel = page.locator('[data-panel-id="left-panel"]');
@@ -203,6 +209,20 @@ test.describe("Static Panel units", () => {
await page.setViewportSize({ width: 300, height: 300 });
await verifyPanelSizePixels(leftPanel, 50);
+
+ await page.setViewportSize({ width: 400, height: 300 });
+ await goToUrlHelper(page, {
+ rightPanelProps: {
+ defaultSize: 50,
+ maxSize: 100,
+ minSize: 50,
+ },
+ });
+ const rightPanel = page.locator('[data-panel-id="right-panel"]');
+ await verifyPanelSizePixels(rightPanel, 50);
+
+ await page.setViewportSize({ width: 300, height: 300 });
+ await verifyPanelSizePixels(rightPanel, 50);
});
test("should observe max size constraint if the overall group size expands", async ({
@@ -213,7 +233,6 @@ test.describe("Static Panel units", () => {
defaultSize: 100,
maxSize: 100,
minSize: 50,
- units: "static",
},
});
@@ -223,6 +242,23 @@ test.describe("Static Panel units", () => {
await page.setViewportSize({ width: 500, height: 300 });
await verifyPanelSizePixels(leftPanel, 100);
+
+ await page.setViewportSize({ width: 400, height: 300 });
+
+ await goToUrlHelper(page, {
+ rightPanelProps: {
+ defaultSize: 100,
+ maxSize: 100,
+ minSize: 50,
+ },
+ });
+
+ const rightPanel = page.locator('[data-panel-id="right-panel"]');
+
+ await verifyPanelSizePixels(rightPanel, 100);
+
+ await page.setViewportSize({ width: 500, height: 300 });
+ await verifyPanelSizePixels(rightPanel, 100);
});
test("should observe max size constraint for multiple panels", async ({
@@ -232,24 +268,25 @@ test.describe("Static Panel units", () => {
page,
createElement(
PanelGroup,
- { direction: "horizontal", id: "group" },
+ { direction: "horizontal", id: "group", units: "pixels" },
createElement(Panel, {
id: "first-panel",
minSize: 50,
maxSize: 75,
- units: "static",
}),
createElement(PanelResizeHandle, {
id: "first-resize-handle",
}),
createElement(Panel, {
id: "second-panel",
+ minSize: 10,
}),
createElement(PanelResizeHandle, {
id: "second-resize-handle",
}),
createElement(Panel, {
id: "third-panel",
+ minSize: 10,
}),
createElement(PanelResizeHandle, {
id: "third-resize-handle",
@@ -258,7 +295,6 @@ test.describe("Static Panel units", () => {
id: "fourth-panel",
minSize: 50,
maxSize: 75,
- units: "static",
})
)
);
diff --git a/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts b/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts
index 74bc503b2..387de53f9 100644
--- a/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ResizeHandle-OnDragging.spec.ts
@@ -11,11 +11,21 @@ async function openPage(page: Page) {
const panelGroup = createElement(
PanelGroup,
{ direction: "horizontal", id: "group" },
- createElement(Panel, { collapsible: true, defaultSize: 20, order: 1 }),
+ createElement(Panel, {
+ collapsible: true,
+ defaultSize: 20,
+ minSize: 10,
+ order: 1,
+ }),
createElement(PanelResizeHandle, { id: "left-handle" }),
- createElement(Panel, { defaultSize: 60, order: 2 }),
+ createElement(Panel, { defaultSize: 60, minSize: 10, order: 2 }),
createElement(PanelResizeHandle, { id: "right-handle" }),
- createElement(Panel, { collapsible: true, defaultSize: 20, order: 3 })
+ createElement(Panel, {
+ collapsible: true,
+ defaultSize: 20,
+ minSize: 10,
+ order: 3,
+ })
);
await goToUrl(page, panelGroup);
diff --git a/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts b/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts
index 3669624d8..75a56959a 100644
--- a/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts
@@ -13,11 +13,11 @@ test.describe("Resize handle", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }),
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
diff --git a/packages/react-resizable-panels-website/tests/Springy.spec.ts b/packages/react-resizable-panels-website/tests/Springy.spec.ts
index 17af3ed89..f0e907ab4 100644
--- a/packages/react-resizable-panels-website/tests/Springy.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Springy.spec.ts
@@ -13,15 +13,17 @@ async function openPage(page: Page) {
createElement(Panel, {
defaultSize: 25,
id: "left-panel",
+ minSize: 10,
order: 1,
}),
createElement(PanelResizeHandle, { id: "left-handle" }),
- createElement(Panel, { id: "middle-panel", order: 2 }),
+ createElement(Panel, { id: "middle-panel", minSize: 10, order: 2 }),
createElement(PanelResizeHandle, { id: "right-handle" }),
createElement(Panel, {
collapsible: true,
defaultSize: 25,
id: "right-panel",
+ minSize: 10,
order: 4,
})
);
diff --git a/packages/react-resizable-panels-website/tests/Storage.spec.ts b/packages/react-resizable-panels-website/tests/Storage.spec.ts
index 4d596335f..cbae2c3b8 100644
--- a/packages/react-resizable-panels-website/tests/Storage.spec.ts
+++ b/packages/react-resizable-panels-website/tests/Storage.spec.ts
@@ -8,27 +8,27 @@ import { goToUrl } from "./utils/url";
const panelGroupABC = createElement(
PanelGroup,
{ autoSaveId: "test-group", direction: "horizontal" },
- createElement(Panel, { order: 1 }),
+ createElement(Panel, { minSize: 10, order: 1 }),
createElement(PanelResizeHandle),
- createElement(Panel, { order: 2 }),
+ createElement(Panel, { minSize: 10, order: 2 }),
createElement(PanelResizeHandle),
- createElement(Panel, { order: 3 })
+ createElement(Panel, { minSize: 10, order: 3 })
);
const panelGroupBC = createElement(
PanelGroup,
{ autoSaveId: "test-group", direction: "horizontal" },
- createElement(Panel, { order: 2 }),
+ createElement(Panel, { minSize: 10, order: 2 }),
createElement(PanelResizeHandle),
- createElement(Panel, { order: 3 })
+ createElement(Panel, { minSize: 10, order: 3 })
);
const panelGroupAB = createElement(
PanelGroup,
{ autoSaveId: "test-group", direction: "horizontal" },
- createElement(Panel, { order: 1 }),
+ createElement(Panel, { minSize: 10, order: 1 }),
createElement(PanelResizeHandle),
- createElement(Panel, { order: 2 })
+ createElement(Panel, { minSize: 10, order: 2 })
);
test.describe("Storage", () => {
diff --git a/packages/react-resizable-panels-website/tests/WindowSplitter.spec.ts b/packages/react-resizable-panels-website/tests/WindowSplitter.spec.ts
index ffe6c142d..40d8acb99 100644
--- a/packages/react-resizable-panels-website/tests/WindowSplitter.spec.ts
+++ b/packages/react-resizable-panels-website/tests/WindowSplitter.spec.ts
@@ -14,9 +14,9 @@ async function goToDefaultUrl(
createElement(
PanelGroup,
{ direction },
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
}
@@ -70,9 +70,9 @@ test.describe("Window Splitter", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { maxSize: 50 }),
+ createElement(Panel, { maxSize: 50, minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
@@ -90,9 +90,9 @@ test.describe("Window Splitter", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { defaultSize: 65 }),
+ createElement(Panel, { defaultSize: 65, minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel, { maxSize: 40 })
+ createElement(Panel, { maxSize: 40, minSize: 10 })
)
);
@@ -234,7 +234,7 @@ test.describe("Window Splitter", () => {
{ direction: "horizontal" },
createElement(Panel, { defaultSize: 40, maxSize: 70, minSize: 20 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
@@ -264,13 +264,13 @@ test.describe("Window Splitter", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel),
+ createElement(Panel, { minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel)
+ createElement(Panel, { minSize: 10 })
)
);
diff --git a/packages/react-resizable-panels/CHANGELOG.md b/packages/react-resizable-panels/CHANGELOG.md
index 5dba64fdc..0e402e35c 100644
--- a/packages/react-resizable-panels/CHANGELOG.md
+++ b/packages/react-resizable-panels/CHANGELOG.md
@@ -1,7 +1,8 @@
# Changelog
## 0.0.55
-* New `units` prop added to `Panel` to support pixel-based panel size constraints.
+* New `units` prop added to `PanelGroup` to support pixel-based panel size constraints.
+* `Panel` prop `minSize` is now required to simplify upgrade path.
## 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
diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts
index 14cbf47b7..2f71508cf 100644
--- a/packages/react-resizable-panels/src/Panel.ts
+++ b/packages/react-resizable-panels/src/Panel.ts
@@ -19,9 +19,8 @@ import {
PanelData,
PanelOnCollapse,
PanelOnResize,
- PanelUnits,
} from "./types";
-import { isDevelopment } from "#is-development";
+import { isDevelopment } from "./env-conditions/production";
export type PanelProps = {
children?: ReactNode;
@@ -31,13 +30,12 @@ export type PanelProps = {
defaultSize?: number | null;
id?: string | null;
maxSize?: number | null;
- minSize?: number;
+ minSize: number;
onCollapse?: PanelOnCollapse | null;
onResize?: PanelOnResize | null;
order?: number | null;
style?: CSSProperties;
tagName?: ElementType;
- units?: PanelUnits;
};
export type ImperativePanelHandle = {
@@ -63,7 +61,6 @@ function PanelWithForwardedRef({
order = null,
style: styleFromProps = {},
tagName: Type = "div",
- units = "relative",
}: PanelProps & {
forwardedRef: ForwardedRef;
}) {
@@ -74,21 +71,6 @@ function PanelWithForwardedRef({
);
}
- if (minSize == null) {
- if (units === "static") {
- if (isDevelopment) {
- console.warn(
- 'Panels with "static" units should specify a minSize value'
- );
- }
-
- minSize = 0;
- } else {
- // Previous default minimize size for relative units
- minSize = 10;
- }
- }
-
const panelId = useUniqueId(idFromProps);
const {
@@ -110,69 +92,6 @@ function PanelWithForwardedRef({
callbacksRef.current.onResize = onResize;
});
- // Basic props validation
- 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})`
- );
- }
-
- defaultSize = minSize;
- } else if (maxSize != null && defaultSize > maxSize) {
- if (isDevelopment) {
- console.error(
- `Panel maxSize (${maxSize}) cannot be less than defaultSize (${defaultSize})`
- );
- }
-
- defaultSize = maxSize;
- }
- }
-
const style = getPanelStyle(panelId, defaultSize);
const committedValuesRef = useRef<{
@@ -180,6 +99,7 @@ function PanelWithForwardedRef({
}>({
size: parseSizeFromStyle(style),
});
+
const panelDataRef = useRef<{
callbacksRef: PanelCallbackRef;
collapsedSize: number;
@@ -190,7 +110,6 @@ function PanelWithForwardedRef({
maxSize: number | null;
minSize: number;
order: number | null;
- units: PanelUnits;
}>({
callbacksRef,
collapsedSize,
@@ -201,8 +120,8 @@ function PanelWithForwardedRef({
maxSize,
minSize,
order,
- units,
});
+
useIsomorphicLayoutEffect(() => {
committedValuesRef.current.size = parseSizeFromStyle(style);
@@ -213,9 +132,8 @@ function PanelWithForwardedRef({
panelDataRef.current.id = panelId;
panelDataRef.current.idWasAutoGenerated = idFromProps == null;
panelDataRef.current.maxSize = maxSize;
- panelDataRef.current.minSize = minSize as number;
+ 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 e4ddcdc80..d8db673e4 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -25,6 +25,7 @@ import {
PanelGroupOnLayout,
PanelGroupStorage,
ResizeEvent,
+ Units,
} from "./types";
import { areEqual } from "./utils/arrays";
import { assert } from "./utils/assert";
@@ -47,6 +48,7 @@ import {
getResizeHandlePanelIds,
panelsMapToSortedArray,
safeResizePanel,
+ validatePanelProps,
} from "./utils/group";
import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization";
@@ -98,9 +100,9 @@ const defaultStorage: PanelGroupStorage = {
export type CommittedValues = {
direction: Direction;
id: string;
- panelIdsWithStaticUnits: Set;
panels: Map;
sizes: number[];
+ units: Units;
};
export type PanelDataMap = Map;
@@ -133,6 +135,7 @@ export type PanelGroupProps = {
storage?: PanelGroupStorage;
style?: CSSProperties;
tagName?: ElementType;
+ units?: Units;
};
export type ImperativePanelGroupHandle = {
@@ -152,6 +155,7 @@ function PanelGroupWithForwardedRef({
storage = defaultStorage,
style: styleFromProps = {},
tagName: Type = "div",
+ units = "percentages",
}: PanelGroupProps & {
forwardedRef: ForwardedRef;
}) {
@@ -199,9 +203,9 @@ function PanelGroupWithForwardedRef({
const committedValuesRef = useRef({
direction,
id: groupId,
- panelIdsWithStaticUnits: new Set(),
panels,
sizes,
+ units,
});
useImperativeHandle(
@@ -223,7 +227,9 @@ function PanelGroupWithForwardedRef({
id: groupId,
panels,
sizes: prevSizes,
+ units,
} = committedValuesRef.current;
+
const panelIdToLastNotifiedSizeMap =
panelIdToLastNotifiedSizeMapRef.current;
const panelsArray = panelsMapToSortedArray(panels);
@@ -236,6 +242,7 @@ function PanelGroupWithForwardedRef({
const prevSize = prevSizes[index];
const nextSize = sizes[index];
const safeSize = safeResizePanel(
+ units,
groupSizePixels,
panel,
nextSize - prevSize,
@@ -264,6 +271,7 @@ function PanelGroupWithForwardedRef({
committedValuesRef.current.id = groupId;
committedValuesRef.current.panels = panels;
committedValuesRef.current.sizes = sizes;
+ committedValuesRef.current.units = units;
});
useWindowSplitterPanelGroupBehavior({
@@ -303,11 +311,7 @@ function PanelGroupWithForwardedRef({
// Compute the initial sizes based on default weights.
// This assumes that panels register during initial mount (no conditional rendering)!
useIsomorphicLayoutEffect(() => {
- const {
- id: groupId,
- panelIdsWithStaticUnits,
- sizes,
- } = committedValuesRef.current;
+ const { id: groupId, sizes, units } = committedValuesRef.current;
if (sizes.length === panels.size) {
// Only compute (or restore) default sizes once per panel configuration.
return;
@@ -322,9 +326,7 @@ function PanelGroupWithForwardedRef({
}
let groupSizePixels =
- panelIdsWithStaticUnits.size > 0
- ? getAvailableGroupSizePixels(groupId)
- : NaN;
+ units === "pixels" ? getAvailableGroupSizePixels(groupId) : NaN;
if (defaultSizes != null) {
setSizes(defaultSizes);
@@ -340,13 +342,13 @@ function PanelGroupWithForwardedRef({
// First, all panels with defaultSize should be set as-is
for (let index = 0; index < panelsArray.length; index++) {
const panel = panelsArray[index];
- const { defaultSize, units } = panel.current;
+ const { defaultSize } = panel.current;
if (defaultSize != null) {
numPanelsWithSizes++;
sizes[index] =
- units === "static"
+ units === "pixels"
? (defaultSize / groupSizePixels) * 100
: defaultSize;
@@ -358,12 +360,12 @@ function PanelGroupWithForwardedRef({
// This may require two passes, depending on min/max constraints
for (let index = 0; index < panelsArray.length; index++) {
const panel = panelsArray[index];
- let { defaultSize, id, maxSize, minSize, units } = panel.current;
+ let { defaultSize, id, maxSize, minSize } = panel.current;
if (defaultSize != null) {
continue;
}
- if (units === "static") {
+ if (units === "pixels") {
minSize = (minSize / groupSizePixels) * 100;
if (maxSize != null) {
maxSize = (maxSize / groupSizePixels) * 100;
@@ -471,14 +473,11 @@ function PanelGroupWithForwardedRef({
}, [autoSaveId, panels, sizes, storage]);
useIsomorphicLayoutEffect(() => {
- const resizeObserver = new ResizeObserver(() => {
- const {
- panelIdsWithStaticUnits,
- panels,
- sizes: prevSizes,
- } = committedValuesRef.current;
-
- if (panelIdsWithStaticUnits.size > 0) {
+ // Pixel panel constraints need to be reassessed after a group resize
+ // We can avoid the ResizeObserver overhead for relative layouts
+ if (units === "pixels") {
+ const resizeObserver = new ResizeObserver(() => {
+ const { panels, sizes: prevSizes } = committedValuesRef.current;
const [idBefore, idAfter] = Array.from(panels.values()).map(
(panel) => panel.current.id
);
@@ -488,7 +487,7 @@ function PanelGroupWithForwardedRef({
committedValuesRef.current,
idBefore,
idAfter,
- 0,
+ null,
prevSizes,
panelSizeBeforeCollapse.current,
initialDragStateRef.current
@@ -496,15 +495,15 @@ function PanelGroupWithForwardedRef({
if (!areEqual(prevSizes, nextSizes)) {
setSizes(nextSizes);
}
- }
- });
+ });
- resizeObserver.observe(getPanelGroup(groupId)!);
+ resizeObserver.observe(getPanelGroup(groupId)!);
- return () => {
- resizeObserver.disconnect();
- };
- }, [groupId]);
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }
+ }, [groupId, units]);
const getPanelStyle = useCallback(
(id: string, defaultSize: number | null): CSSProperties => {
@@ -557,9 +556,9 @@ function PanelGroupWithForwardedRef({
);
const registerPanel = useCallback((id: string, panelRef: PanelData) => {
- if (panelRef.current.units === "static") {
- committedValuesRef.current.panelIdsWithStaticUnits.add(id);
- }
+ const { units } = committedValuesRef.current;
+
+ validatePanelProps(units, panelRef);
setPanels((prevPanels) => {
if (prevPanels.has(id)) {
@@ -687,8 +686,6 @@ function PanelGroupWithForwardedRef({
);
const unregisterPanel = useCallback((id: string) => {
- committedValuesRef.current.panelIdsWithStaticUnits.delete(id);
-
setPanels((prevPanels) => {
if (!prevPanels.has(id)) {
return prevPanels;
@@ -825,6 +822,7 @@ function PanelGroupWithForwardedRef({
id: groupId,
panels,
sizes: prevSizes,
+ units,
} = committedValuesRef.current;
const panel = panels.get(id);
@@ -832,9 +830,9 @@ function PanelGroupWithForwardedRef({
return;
}
- let { collapsedSize, collapsible, maxSize, minSize, units } = panel.current;
+ let { collapsedSize, collapsible, maxSize, minSize } = panel.current;
- if (units === "static") {
+ if (units === "pixels") {
const groupSizePixels = getAvailableGroupSizePixels(groupId);
minSize = (minSize / groupSizePixels) * 100;
if (maxSize != null) {
diff --git a/packages/react-resizable-panels/src/index.ts b/packages/react-resizable-panels/src/index.ts
index 10f5d2fc8..331599162 100644
--- a/packages/react-resizable-panels/src/index.ts
+++ b/packages/react-resizable-panels/src/index.ts
@@ -12,7 +12,7 @@ import type {
PanelOnCollapse,
PanelOnResize,
PanelResizeHandleOnDragging,
- PanelUnits,
+ Units,
} from "./types";
export {
@@ -27,7 +27,7 @@ export {
PanelProps,
PanelResizeHandleOnDragging,
PanelResizeHandleProps,
- PanelUnits,
+ Units,
// React components
Panel,
diff --git a/packages/react-resizable-panels/src/types.ts b/packages/react-resizable-panels/src/types.ts
index 07e9ed416..89e1c9a61 100644
--- a/packages/react-resizable-panels/src/types.ts
+++ b/packages/react-resizable-panels/src/types.ts
@@ -1,6 +1,7 @@
import { RefObject } from "./vendor/react";
export type Direction = "horizontal" | "vertical";
+export type Units = "percentages" | "pixels";
export type PanelGroupStorage = {
getItem(name: string): string | null;
@@ -17,8 +18,6 @@ export type PanelCallbackRef = RefObject<{
onResize: PanelOnResize | null;
}>;
-export type PanelUnits = "relative" | "static";
-
export type PanelData = {
current: {
callbacksRef: PanelCallbackRef;
@@ -30,7 +29,6 @@ export type PanelData = {
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 31b3ab51a..13bdd4340 100644
--- a/packages/react-resizable-panels/src/utils/group.ts
+++ b/packages/react-resizable-panels/src/utils/group.ts
@@ -1,23 +1,22 @@
+import { isDevelopment } from "#is-development";
import { CommittedValues, InitialDragState } from "../PanelGroup";
import { PRECISION } from "../constants";
-import { PanelData, ResizeEvent } from "../types";
+import { PanelData, ResizeEvent, Units } from "../types";
export function adjustByDelta(
event: ResizeEvent | null,
committedValues: CommittedValues,
idBefore: string,
idAfter: string,
- deltaPixels: number,
+ deltaPixels: number | null,
prevSizes: number[],
panelSizeBeforeCollapse: Map,
initialDragState: InitialDragState | null
): number[] {
- const { id: groupId, panelIdsWithStaticUnits, panels } = committedValues;
+ const { id: groupId, panels, units } = committedValues;
const groupSizePixels =
- panelIdsWithStaticUnits.size > 0
- ? getAvailableGroupSizePixels(groupId)
- : NaN;
+ units === "pixels" ? getAvailableGroupSizePixels(groupId) : NaN;
const { sizes: initialSizes } = initialDragState || {};
@@ -31,6 +30,13 @@ export function adjustByDelta(
let deltaApplied = 0;
+ // A null delta means that layout is being recalculated (e.g. after a panel group resize)
+ // In that scenario it is not safe for this method to bail out early
+ const safeToBailOut = deltaPixels != null;
+ if (deltaPixels === null) {
+ deltaPixels = 0;
+ }
+
// A resizing panel affects the panels before or after it.
//
// A negative delta means the panel immediately after the resizer should grow/expand by decreasing its offset.
@@ -49,6 +55,7 @@ export function adjustByDelta(
const baseSize = baseSizes[index];
const nextSize = safeResizePanel(
+ units,
groupSizePixels,
panel,
Math.abs(deltaPixels),
@@ -57,7 +64,9 @@ export function adjustByDelta(
);
if (baseSize === nextSize) {
// If there's no room for the pivot panel to grow, we can ignore this drag update.
- return baseSizes;
+ if (safeToBailOut) {
+ return baseSizes;
+ }
} else {
if (nextSize === 0 && baseSize > 0) {
panelSizeBeforeCollapse.set(pivotId, baseSize);
@@ -76,6 +85,7 @@ export function adjustByDelta(
const deltaRemaining = Math.abs(deltaPixels) - Math.abs(deltaApplied);
const nextSize = safeResizePanel(
+ units,
groupSizePixels,
panel,
0 - deltaRemaining,
@@ -322,6 +332,7 @@ export function panelsMapToSortedArray(
}
export function safeResizePanel(
+ units: Units,
groupSizePixels: number,
panel: PanelData,
delta: number,
@@ -330,9 +341,9 @@ export function safeResizePanel(
): number {
const nextSizeUnsafe = prevSize + delta;
- let { collapsedSize, collapsible, maxSize, minSize, units } = panel.current;
+ let { collapsedSize, collapsible, maxSize, minSize } = panel.current;
- if (units === "static") {
+ if (units === "pixels") {
collapsedSize = (collapsedSize / groupSizePixels) * 100;
if (maxSize != null) {
maxSize = (maxSize / groupSizePixels) * 100;
@@ -366,3 +377,52 @@ export function safeResizePanel(
return nextSize;
}
+
+export function validatePanelProps(units: Units, panelData: PanelData) {
+ const { collapsible, defaultSize, maxSize, minSize } = panelData.current;
+
+ // Basic props validation
+ if (minSize < 0 || (units === "percentages" && minSize > 100)) {
+ if (isDevelopment) {
+ console.error(`Invalid Panel minSize provided, ${minSize}`);
+ }
+
+ panelData.current.minSize = 0;
+ }
+
+ if (maxSize != null) {
+ if (maxSize < 0 || (units === "percentages" && maxSize > 100)) {
+ if (isDevelopment) {
+ console.error(`Invalid Panel maxSize provided, ${maxSize}`);
+ }
+
+ panelData.current.maxSize = null;
+ }
+ }
+
+ if (defaultSize !== null) {
+ if (defaultSize < 0 || (units === "percentages" && defaultSize > 100)) {
+ if (isDevelopment) {
+ console.error(`Invalid Panel defaultSize provided, ${defaultSize}`);
+ }
+
+ panelData.current.defaultSize = null;
+ } else if (defaultSize < minSize && !collapsible) {
+ if (isDevelopment) {
+ console.error(
+ `Panel minSize (${minSize}) cannot be greater than defaultSize (${defaultSize})`
+ );
+ }
+
+ panelData.current.defaultSize = minSize;
+ } else if (maxSize != null && defaultSize > maxSize) {
+ if (isDevelopment) {
+ console.error(
+ `Panel maxSize (${maxSize}) cannot be less than defaultSize (${defaultSize})`
+ );
+ }
+
+ panelData.current.defaultSize = maxSize;
+ }
+ }
+}
From 756f7f1b4c8cd45ec705994fe25ad69e3be681a4 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Fri, 11 Aug 2023 11:19:14 -1000
Subject: [PATCH 15/20] Update imperative APIs to support unit override
---
.../src/components/Icon.tsx | 15 ++
.../src/routes/EndToEndTesting/index.tsx | 65 +++++--
.../routes/EndToEndTesting/styles.module.css | 4 +
.../tests/ImperativePanelApi.spec.ts | 53 +++++-
.../tests/ImperativePanelGroupApi.spec.ts | 62 +++++-
.../tests/PanelGroup-PixelUnits.spec.ts | 4 +-
.../tests/utils/panels.ts | 23 ++-
.../tests/utils/verify.ts | 23 +++
packages/react-resizable-panels/src/Panel.ts | 6 +-
.../src/PanelContexts.ts | 4 +-
.../react-resizable-panels/src/PanelGroup.ts | 177 ++++++++++--------
11 files changed, 333 insertions(+), 103 deletions(-)
diff --git a/packages/react-resizable-panels-website/src/components/Icon.tsx b/packages/react-resizable-panels-website/src/components/Icon.tsx
index 8dea6313f..d0031ea92 100644
--- a/packages/react-resizable-panels-website/src/components/Icon.tsx
+++ b/packages/react-resizable-panels-website/src/components/Icon.tsx
@@ -3,13 +3,16 @@ import styles from "./Icon.module.css";
export type IconType =
| "chevron-down"
| "close"
+ | "collapse"
| "css"
+ | "expand"
| "files"
| "horizontal-collapse"
| "horizontal-expand"
| "html"
| "loading"
| "markdown"
+ | "resize"
| "resize-horizontal"
| "resize-vertical"
| "search"
@@ -32,10 +35,18 @@ export default function Icon({
path =
"M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z";
break;
+ case "collapse":
+ path =
+ "M19.5,3.09L15,7.59V4H13V11H20V9H16.41L20.91,4.5L19.5,3.09M4,13V15H7.59L3.09,19.5L4.5,20.91L9,16.41V20H11V13H4Z";
+ break;
case "css":
path =
"M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z";
break;
+ case "expand":
+ path =
+ "M10,21V19H6.41L10.91,14.5L9.5,13.09L5,17.59V14H3V21H10M14.5,10.91L19,6.41V10H21V3H14V5H17.59L13.09,9.5L14.5,10.91Z";
+ break;
case "files":
path =
"M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z";
@@ -60,6 +71,10 @@ export default function Icon({
path =
"M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M6.81 15.19V11.53L8.73 13.88L10.65 11.53V15.19H12.58V8.81H10.65L8.73 11.16L6.81 8.81H4.89V15.19H6.81M19.69 12H17.77V8.81H15.85V12H13.92L16.81 15.28L19.69 12Z";
break;
+ case "resize":
+ path =
+ "M10.59,12L14.59,8H11V6H18V13H16V9.41L12,13.41V16H20V4H8V12H10.59M22,2V18H12V22H2V12H6V2H22M10,14H4V20H10V14Z";
+ break;
case "resize-horizontal":
path =
"M18,16V13H15V22H13V2H15V11H18V8L22,12L18,16M2,12L6,16V13H9V22H11V2H9V11H6V8L2,12Z";
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 bb35b4569..83de01ab3 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
@@ -21,6 +21,7 @@ import {
assertImperativePanelGroupHandle,
assertImperativePanelHandle,
} from "../../../tests/utils/assert";
+import Icon from "../../components/Icon";
import "./styles.css";
import styles from "./styles.module.css";
@@ -100,6 +101,7 @@ function EndToEndTesting() {
const [panelId, setPanelId] = useState("");
const [panelGroupId, setPanelGroupId] = useState("");
const [size, setSize] = useState(0);
+ const [units, setUnits] = useState("");
const [layoutString, setLayoutString] = useState("");
const debugLogRef = useRef(null);
@@ -131,6 +133,11 @@ function EndToEndTesting() {
setSize(parseFloat(value));
};
+ const onUnitsInputChange = (event: ChangeEvent) => {
+ const value = event.currentTarget.value;
+ setUnits(value);
+ };
+
const onCollapseButtonClick = () => {
const idToRefMap = idToRefMapRef.current;
const panel = idToRefMap.get(panelId);
@@ -151,7 +158,7 @@ function EndToEndTesting() {
const idToRefMap = idToRefMapRef.current;
const panel = idToRefMap.get(panelId);
if (panel && assertImperativePanelHandle(panel)) {
- panel.resize(size);
+ panel.resize(size, (units as any) || undefined);
}
};
@@ -159,7 +166,10 @@ function EndToEndTesting() {
const idToRefMap = idToRefMapRef.current;
const panelGroup = idToRefMap.get(panelGroupId);
if (panelGroup && assertImperativePanelGroupHandle(panelGroup)) {
- panelGroup.setLayout(JSON.parse(layoutString));
+ panelGroup.setLayout(
+ JSON.parse(layoutString),
+ (units as any) || undefined
+ );
}
};
@@ -168,40 +178,73 @@ function EndToEndTesting() {
diff --git a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.module.css b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.module.css
index aee478ed4..5a3d4854d 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.module.css
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.module.css
@@ -21,3 +21,7 @@
.Children {
flex: 1 1 auto;
}
+
+.Input {
+ width: 7ch;
+}
diff --git a/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts b/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
index 5fc307e3e..3551b33cc 100644
--- a/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
@@ -2,9 +2,13 @@ import { Page, test } from "@playwright/test";
import { createElement } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+import {
+ imperativeResizePanel,
+ verifyPanelSize,
+ verifyPanelSizePixels,
+} from "./utils/panels";
import { goToUrl } from "./utils/url";
import { verifySizes } from "./utils/verify";
-import { imperativeResizePanel } from "./utils/panels";
async function openPage(
page: Page,
@@ -136,4 +140,51 @@ test.describe("Imperative Panel API", () => {
await verifySizes(page, 10, 80, 10);
});
+
+ test("should allow default group units of percentages to be overridden with pixels", async ({
+ page,
+ }) => {
+ await verifySizes(page, 20, 60, 20);
+
+ const leftPanel = page.locator('[data-panel-id="left"]');
+
+ await imperativeResizePanel(page, "left", 15);
+ await verifySizes(page, 15, 65, 20);
+
+ await imperativeResizePanel(page, "left", 50, "pixels");
+ await verifyPanelSizePixels(leftPanel, 50);
+ });
+
+ test("should allow default group units of pixels to be overridden with percentages", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal", units: "pixels" },
+ createElement(Panel, {
+ defaultSize: 200,
+ id: "left",
+ maxSize: 300,
+ minSize: 100,
+ }),
+ createElement(PanelResizeHandle),
+ createElement(Panel, {
+ id: "right",
+ maxSize: 300,
+ minSize: 100,
+ })
+ )
+ );
+
+ const leftPanel = page.locator('[data-panel-id="left"]');
+ await verifyPanelSizePixels(leftPanel, 200);
+
+ await imperativeResizePanel(page, "left", 150);
+ await verifyPanelSizePixels(leftPanel, 150);
+
+ await imperativeResizePanel(page, "left", 40, "percentages");
+ await verifyPanelSize(leftPanel, 40);
+ });
});
diff --git a/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts b/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
index 427441149..ba5b53fd0 100644
--- a/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
@@ -4,7 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { imperativeResizePanelGroup } from "./utils/panels";
import { goToUrl } from "./utils/url";
-import { verifySizes } from "./utils/verify";
+import { verifySizes, verifySizesPixels } from "./utils/verify";
async function openPage(
page: Page,
@@ -48,11 +48,8 @@ async function openPage(
}
test.describe("Imperative PanelGroup API", () => {
- test.beforeEach(async ({ page }) => {
- await openPage(page);
- });
-
test("should resize all panels", async ({ page }) => {
+ await openPage(page);
await verifySizes(page, 20, 60, 20);
await imperativeResizePanelGroup(page, "group", [10, 20, 70]);
@@ -61,4 +58,59 @@ test.describe("Imperative PanelGroup API", () => {
await imperativeResizePanelGroup(page, "group", [90, 6, 4]);
await verifySizes(page, 90, 6, 4);
});
+
+ test("should allow default group units of percentages to be overridden with pixels", async ({
+ page,
+ }) => {
+ await openPage(page);
+ await verifySizes(page, 20, 60, 20);
+
+ await imperativeResizePanelGroup(page, "group", [10, 20, 70]);
+ await verifySizes(page, 10, 20, 70);
+
+ await imperativeResizePanelGroup(page, "group", [60, 100, 236], "pixels");
+ await verifySizesPixels(page, 60, 100, 236);
+ });
+
+ test("should allow default group units of pixels to be overridden with percentages", async ({
+ page,
+ }) => {
+ await goToUrl(
+ page,
+ createElement(
+ PanelGroup,
+ { direction: "horizontal", id: "group", units: "pixels" },
+ createElement(Panel, {
+ defaultSize: 60,
+ maxSize: 350,
+ minSize: 10,
+ }),
+ createElement(PanelResizeHandle, { id: "left-handle" }),
+ createElement(Panel, {
+ defaultSize: 100,
+ maxSize: 350,
+ minSize: 10,
+ }),
+ createElement(PanelResizeHandle, { id: "right-handle" }),
+ createElement(Panel, {
+ defaultSize: 236,
+ maxSize: 350,
+ minSize: 10,
+ })
+ )
+ );
+
+ await verifySizesPixels(page, 60, 100, 236);
+
+ await imperativeResizePanelGroup(page, "group", [70, 90, 236]);
+ await verifySizesPixels(page, 70, 90, 236);
+
+ await imperativeResizePanelGroup(
+ page,
+ "group",
+ [10, 20, 70],
+ "percentages"
+ );
+ await verifySizes(page, 10, 20, 70);
+ });
});
diff --git a/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
index ac3f75bdb..af08548ba 100644
--- a/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
+++ b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
@@ -149,7 +149,7 @@ test.describe("Pixel units", () => {
const leftPanel = page.locator("[data-panel]").first();
- await imperativeResizePanel(page, "left-panel", 80);
+ await imperativeResizePanel(page, "left-panel", 150);
await verifyPanelSizePixels(leftPanel, 100);
await imperativeResizePanel(page, "left-panel", 4);
@@ -168,7 +168,7 @@ test.describe("Pixel units", () => {
await imperativeResizePanel(page, "middle-panel", 1);
await verifyPanelSizePixels(rightPanel, 100);
- await imperativeResizePanel(page, "middle-panel", 98);
+ await imperativeResizePanel(page, "left-panel", 350);
await verifyPanelSizePixels(rightPanel, 50);
});
diff --git a/packages/react-resizable-panels-website/tests/utils/panels.ts b/packages/react-resizable-panels-website/tests/utils/panels.ts
index 29b8c5481..54ab706a5 100644
--- a/packages/react-resizable-panels-website/tests/utils/panels.ts
+++ b/packages/react-resizable-panels-website/tests/utils/panels.ts
@@ -2,6 +2,7 @@ import { Locator, Page, expect } from "@playwright/test";
import { assert } from "./assert";
import { getBodyCursorStyle } from "./cursor";
import { verifyFuzzySizes } from "./verify";
+import { Units } from "react-resizable-panels";
type Operation = {
expectedCursor?: string;
@@ -155,7 +156,8 @@ export async function dragResizeTo(
export async function imperativeResizePanel(
page: Page,
panelId: string,
- size: number
+ size: number,
+ units?: Units
) {
const panelIdInput = page.locator("#panelIdInput");
await panelIdInput.focus();
@@ -165,6 +167,14 @@ export async function imperativeResizePanel(
await sizeInput.focus();
await sizeInput.fill("" + size);
+ const unitsInput = page.locator("#unitsInput");
+ if (units) {
+ await unitsInput.focus();
+ await unitsInput.fill(units);
+ } else {
+ await unitsInput.clear();
+ }
+
const resizeButton = page.locator("#resizeButton");
await resizeButton.click();
}
@@ -172,7 +182,8 @@ export async function imperativeResizePanel(
export async function imperativeResizePanelGroup(
page: Page,
panelGroupId: string,
- sizes: number[]
+ sizes: number[],
+ units?: Units
) {
const panelGroupIdInput = page.locator("#panelGroupIdInput");
await panelGroupIdInput.focus();
@@ -182,6 +193,14 @@ export async function imperativeResizePanelGroup(
await layoutInput.focus();
await layoutInput.fill(`[${sizes.join()}]`);
+ const unitsInput = page.locator("#unitsInput");
+ if (units) {
+ await unitsInput.focus();
+ await unitsInput.fill(units);
+ } else {
+ await unitsInput.clear();
+ }
+
const setLayoutButton = page.locator("#setLayoutButton");
await setLayoutButton.click();
}
diff --git a/packages/react-resizable-panels-website/tests/utils/verify.ts b/packages/react-resizable-panels-website/tests/utils/verify.ts
index 6fead2afa..aa0e5f773 100644
--- a/packages/react-resizable-panels-website/tests/utils/verify.ts
+++ b/packages/react-resizable-panels-website/tests/utils/verify.ts
@@ -3,6 +3,7 @@ import { expect, Page } from "@playwright/test";
import { PanelGroupLayoutLogEntry } from "../../src/routes/examples/types";
import { getLogEntries } from "./debug";
+import { verifyPanelSizePixels } from "./panels";
export async function verifySizes(page: Page, ...expectedSizes: number[]) {
const logEntries = await getLogEntries(
@@ -14,6 +15,28 @@ export async function verifySizes(page: Page, ...expectedSizes: number[]) {
expect(actualSizes).toEqual(expectedSizes);
}
+export async function verifySizesPixels(
+ page: Page,
+ ...expectedSizesPixels: number[]
+) {
+ const panels = page.locator("[data-panel-id]");
+
+ const count = await panels.count();
+ await expect(count).toBe(expectedSizesPixels.length);
+
+ for (let index = 0; index < count; index++) {
+ const panel = await panels.nth(index);
+ const textContent = (await panel.textContent()) || "";
+
+ const expectedSizePixels = expectedSizesPixels[index];
+ const actualSizePixels = parseFloat(
+ textContent.split("\n")[1].replace("px", "")
+ );
+
+ expect(expectedSizePixels).toBe(actualSizePixels);
+ }
+}
+
export async function verifyFuzzySizes(
page: Page,
precision: number,
diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts
index 2f71508cf..233b16632 100644
--- a/packages/react-resizable-panels/src/Panel.ts
+++ b/packages/react-resizable-panels/src/Panel.ts
@@ -19,6 +19,7 @@ import {
PanelData,
PanelOnCollapse,
PanelOnResize,
+ Units,
} from "./types";
import { isDevelopment } from "./env-conditions/production";
@@ -43,7 +44,7 @@ export type ImperativePanelHandle = {
expand: () => void;
getCollapsed(): boolean;
getSize(): number;
- resize: (percentage: number) => void;
+ resize: (percentage: number, units?: Units) => void;
};
function PanelWithForwardedRef({
@@ -155,7 +156,8 @@ function PanelWithForwardedRef({
getSize() {
return committedValuesRef.current.size;
},
- resize: (percentage: number) => resizePanel(panelId, percentage),
+ resize: (percentage: number, units) =>
+ resizePanel(panelId, percentage, units),
}),
[collapsePanel, expandPanel, panelId, resizePanel]
);
diff --git a/packages/react-resizable-panels/src/PanelContexts.ts b/packages/react-resizable-panels/src/PanelContexts.ts
index 61165951f..409f5f8e8 100644
--- a/packages/react-resizable-panels/src/PanelContexts.ts
+++ b/packages/react-resizable-panels/src/PanelContexts.ts
@@ -1,6 +1,6 @@
import { CSSProperties, createContext } from "./vendor/react";
-import { PanelData, ResizeEvent, ResizeHandler } from "./types";
+import { PanelData, ResizeEvent, ResizeHandler, Units } from "./types";
export const PanelGroupContext = createContext<{
activeHandleId: string | null;
@@ -11,7 +11,7 @@ export const PanelGroupContext = createContext<{
groupId: string;
registerPanel: (id: string, panel: PanelData) => void;
registerResizeHandle: (id: string) => ResizeHandler;
- resizePanel: (id: string, percentage: number) => void;
+ resizePanel: (id: string, percentage: number, units?: Units) => void;
startDragging: (id: string, event: ResizeEvent) => void;
stopDragging: () => void;
unregisterPanel: (id: string) => void;
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index d8db673e4..cfcf8702e 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -140,7 +140,7 @@ export type PanelGroupProps = {
export type ImperativePanelGroupHandle = {
getLayout: () => number[];
- setLayout: (panelSizes: number[]) => void;
+ setLayout: (panelSizes: number[], units?: Units) => void;
};
function PanelGroupWithForwardedRef({
@@ -215,14 +215,7 @@ function PanelGroupWithForwardedRef({
const { sizes } = committedValuesRef.current;
return sizes;
},
- setLayout: (sizes: number[]) => {
- const total = sizes.reduce(
- (accumulated, current) => accumulated + current,
- 0
- );
-
- assert(total === 100, "Panel sizes must add up to 100%");
-
+ setLayout: (sizes: number[], unitsFromParams?: Units) => {
const {
id: groupId,
panels,
@@ -230,6 +223,18 @@ function PanelGroupWithForwardedRef({
units,
} = committedValuesRef.current;
+ if ((unitsFromParams || units) === "pixels") {
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
+ sizes = sizes.map((size) => (size / groupSizePixels) * 100);
+ }
+
+ const total = sizes.reduce(
+ (accumulated, current) => accumulated + current,
+ 0
+ );
+
+ assert(total === 100, "Panel sizes must add up to 100%");
+
const panelIdToLastNotifiedSizeMap =
panelIdToLastNotifiedSizeMapRef.current;
const panelsArray = panelsMapToSortedArray(panels);
@@ -258,9 +263,11 @@ function PanelGroupWithForwardedRef({
}
}
- setSizes(sizes);
+ if (!areEqual(prevSizes, sizes)) {
+ setSizes(sizes);
- callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
+ callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
+ }
},
}),
[]
@@ -817,89 +824,103 @@ function PanelGroupWithForwardedRef({
}
}, []);
- const resizePanel = useCallback((id: string, nextSize: number) => {
- const {
- id: groupId,
- panels,
- sizes: prevSizes,
- units,
- } = committedValuesRef.current;
+ const resizePanel = useCallback(
+ (id: string, nextSize: number, unitsFromParams?: Units) => {
+ const {
+ id: groupId,
+ panels,
+ sizes: prevSizes,
+ units,
+ } = committedValuesRef.current;
+
+ if ((unitsFromParams || units) === "pixels") {
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
+ nextSize = (nextSize / groupSizePixels) * 100;
+ }
- const panel = panels.get(id);
- if (panel == null) {
- return;
- }
+ const panel = panels.get(id);
+ if (panel == null) {
+ return;
+ }
- let { collapsedSize, collapsible, maxSize, minSize } = panel.current;
+ let { collapsedSize, collapsible, maxSize, minSize } = panel.current;
- if (units === "pixels") {
- const groupSizePixels = getAvailableGroupSizePixels(groupId);
- minSize = (minSize / groupSizePixels) * 100;
- if (maxSize != null) {
- maxSize = (maxSize / groupSizePixels) * 100;
+ if (units === "pixels") {
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
+ minSize = (minSize / groupSizePixels) * 100;
+ if (maxSize != null) {
+ maxSize = (maxSize / groupSizePixels) * 100;
+ }
}
- }
- 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 === nextSize) {
- return;
- }
+ const currentSize = prevSizes[index];
+ if (currentSize === nextSize) {
+ return;
+ }
- if (collapsible && nextSize === collapsedSize) {
- // This is a valid resize state.
- } else {
- const unsafeNextSize = nextSize;
+ if (collapsible && nextSize === collapsedSize) {
+ // This is a valid resize state.
+ } else {
+ const unsafeNextSize = nextSize;
- nextSize = Math.min(
- maxSize != null ? maxSize : 100,
- Math.max(minSize, nextSize)
- );
+ nextSize = Math.min(
+ maxSize != null ? maxSize : 100,
+ Math.max(minSize, nextSize)
+ );
- if (isDevelopment) {
- if (unsafeNextSize !== nextSize) {
- console.error(
- `Invalid size (${unsafeNextSize}) specified for Panel "${panel.current.id}" given the panel's min/max size constraints`
- );
+ if (isDevelopment) {
+ if (unsafeNextSize !== nextSize) {
+ console.error(
+ `Invalid size (${unsafeNextSize}) specified for Panel "${panel.current.id}" given the panel's min/max size constraints`
+ );
+ }
}
}
- }
-
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
- if (idBefore == null || idAfter == null) {
- return;
- }
- 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,
- 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
+ ? 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);
- }
- }, []);
+ // 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(
() => ({
From fbac3fa253ff4c566a8498af76f1bc3a8e106e3a Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Fri, 11 Aug 2023 15:14:34 -1000
Subject: [PATCH 16/20] More aggressively recover from invalidate layouts set
via imperative API calls
---
.../DevelopmentWarningsAndErrors.spec.ts | 7 +-
.../tests/ImperativePanelGroupApi.spec.ts | 4 +-
.../tests/utils/verify.ts | 22 +-
.../react-resizable-panels/src/PanelGroup.ts | 169 +++----------
.../react-resizable-panels/src/utils/group.ts | 226 ++++++++++++++++--
5 files changed, 248 insertions(+), 180 deletions(-)
diff --git a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
index 0abdabded..d8ba243ef 100644
--- a/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
+++ b/packages/react-resizable-panels-website/tests/DevelopmentWarningsAndErrors.spec.ts
@@ -7,6 +7,7 @@ import {
imperativeResizePanel,
imperativeResizePanelGroup,
} from "./utils/panels";
+import { verifySizes } from "./utils/verify";
function createElements({
numPanels,
@@ -268,13 +269,13 @@ test.describe("Development warnings and errors", () => {
createElement(
PanelGroup,
{ direction: "horizontal" },
- createElement(Panel, { defaultSize: 25, minSize: 10 }),
+ createElement(Panel, { defaultSize: 25, maxSize: 25, minSize: 10 }),
createElement(PanelResizeHandle),
- createElement(Panel, { defaultSize: 25, minSize: 10 })
+ createElement(Panel, { defaultSize: 25, maxSize: 25, minSize: 10 })
)
);
- await flushMessages(page);
+ await verifySizes(page, 25, 25);
expect(errors).not.toHaveLength(0);
expect(errors).toEqual(
diff --git a/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts b/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
index ba5b53fd0..66f487b0b 100644
--- a/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ImperativePanelGroupApi.spec.ts
@@ -55,8 +55,8 @@ test.describe("Imperative PanelGroup API", () => {
await imperativeResizePanelGroup(page, "group", [10, 20, 70]);
await verifySizes(page, 10, 20, 70);
- await imperativeResizePanelGroup(page, "group", [90, 6, 4]);
- await verifySizes(page, 90, 6, 4);
+ await imperativeResizePanelGroup(page, "group", [80, 6, 14]);
+ await verifySizes(page, 30, 56, 14);
});
test("should allow default group units of percentages to be overridden with pixels", async ({
diff --git a/packages/react-resizable-panels-website/tests/utils/verify.ts b/packages/react-resizable-panels-website/tests/utils/verify.ts
index aa0e5f773..bf4e55986 100644
--- a/packages/react-resizable-panels-website/tests/utils/verify.ts
+++ b/packages/react-resizable-panels-website/tests/utils/verify.ts
@@ -3,16 +3,22 @@ import { expect, Page } from "@playwright/test";
import { PanelGroupLayoutLogEntry } from "../../src/routes/examples/types";
import { getLogEntries } from "./debug";
-import { verifyPanelSizePixels } from "./panels";
export async function verifySizes(page: Page, ...expectedSizes: number[]) {
- const logEntries = await getLogEntries(
- page,
- "onLayout"
- );
- const { sizes: actualSizes } = logEntries[logEntries.length - 1];
+ const panels = page.locator("[data-panel-id]");
+
+ const count = await panels.count();
+ expect(count).toBe(expectedSizes.length);
- expect(actualSizes).toEqual(expectedSizes);
+ for (let index = 0; index < count; index++) {
+ const panel = await panels.nth(index);
+ const textContent = (await panel.textContent()) || "";
+
+ const expectedSize = expectedSizes[index];
+ const actualSize = parseFloat(textContent.split("\n")[0].replace("%", ""));
+
+ expect(expectedSize).toBe(actualSize);
+ }
}
export async function verifySizesPixels(
@@ -22,7 +28,7 @@ export async function verifySizesPixels(
const panels = page.locator("[data-panel-id]");
const count = await panels.count();
- await expect(count).toBe(expectedSizesPixels.length);
+ expect(count).toBe(expectedSizesPixels.length);
for (let index = 0; index < count; index++) {
const panel = await panels.nth(index);
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index cfcf8702e..81393c081 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -28,7 +28,6 @@ import {
Units,
} from "./types";
import { areEqual } from "./utils/arrays";
-import { assert } from "./utils/assert";
import {
getDragOffset,
getMovement,
@@ -39,6 +38,7 @@ import { resetGlobalCursorStyle, setGlobalCursorStyle } from "./utils/cursor";
import debounce from "./utils/debounce";
import {
adjustByDelta,
+ calculateDefaultLayout,
callPanelCallbacks,
getAvailableGroupSizePixels,
getBeforeAndAfterIds,
@@ -47,7 +47,7 @@ import {
getResizeHandle,
getResizeHandlePanelIds,
panelsMapToSortedArray,
- safeResizePanel,
+ validatePanelGroupLayout,
validatePanelProps,
} from "./utils/group";
import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization";
@@ -228,45 +228,25 @@ function PanelGroupWithForwardedRef({
sizes = sizes.map((size) => (size / groupSizePixels) * 100);
}
- const total = sizes.reduce(
- (accumulated, current) => accumulated + current,
- 0
- );
-
- assert(total === 100, "Panel sizes must add up to 100%");
-
const panelIdToLastNotifiedSizeMap =
panelIdToLastNotifiedSizeMapRef.current;
const panelsArray = panelsMapToSortedArray(panels);
- if (isDevelopment) {
- const groupSizePixels = getAvailableGroupSizePixels(groupId);
-
- for (let index = 0; index < sizes.length; index++) {
- const panel = panelsArray[index];
- const prevSize = prevSizes[index];
- const nextSize = sizes[index];
- const safeSize = safeResizePanel(
- units,
- groupSizePixels,
- panel,
- nextSize - prevSize,
- prevSize,
- null
- );
-
- if (nextSize !== safeSize) {
- console.error(
- `Invalid size (${nextSize}) specified for Panel "${panel.current.id}" given the panel's min/max size constraints`
- );
- }
- }
- }
-
- if (!areEqual(prevSizes, sizes)) {
- setSizes(sizes);
+ const nextSizes = validatePanelGroupLayout({
+ groupId,
+ panels,
+ nextSizes: sizes,
+ prevSizes,
+ units,
+ });
+ if (!areEqual(prevSizes, nextSizes)) {
+ setSizes(nextSizes);
- callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
+ callPanelCallbacks(
+ panelsArray,
+ nextSizes,
+ panelIdToLastNotifiedSizeMap
+ );
}
},
}),
@@ -332,103 +312,14 @@ function PanelGroupWithForwardedRef({
defaultSizes = loadPanelLayout(autoSaveId, panelsArray, storage);
}
- let groupSizePixels =
- units === "pixels" ? getAvailableGroupSizePixels(groupId) : NaN;
-
if (defaultSizes != null) {
setSizes(defaultSizes);
} else {
- const panelsArray = panelsMapToSortedArray(panels);
-
- const sizes = Array(panelsArray.length);
-
- let numPanelsWithSizes = 0;
- let remainingSize = 100;
-
- // 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 } = panel.current;
-
- if (defaultSize != null) {
- numPanelsWithSizes++;
-
- sizes[index] =
- units === "pixels"
- ? (defaultSize / groupSizePixels) * 100
- : defaultSize;
-
- remainingSize -= sizes[index];
- }
- }
-
- // Remaining total size should be distributed evenly between panels
- // This may require two passes, depending on min/max constraints
- for (let index = 0; index < panelsArray.length; index++) {
- const panel = panelsArray[index];
- let { defaultSize, id, maxSize, minSize } = panel.current;
- if (defaultSize != null) {
- continue;
- }
-
- if (units === "pixels") {
- minSize = (minSize / groupSizePixels) * 100;
- if (maxSize != null) {
- maxSize = (maxSize / groupSizePixels) * 100;
- }
- }
-
- const remainingPanels = panelsArray.length - numPanelsWithSizes;
- const size = Math.min(
- maxSize != null ? maxSize : 100,
- Math.max(minSize, remainingSize / remainingPanels)
- );
-
- sizes[index] = size;
- numPanelsWithSizes++;
- remainingSize -= size;
- }
-
- // If there is additional, left over space, assign it to any panel(s) that permits it
- // (It's not worth taking multiple additional passes to evenly distribute)
- if (remainingSize !== 0) {
- for (let index = 0; index < panelsArray.length; index++) {
- const panel = panelsArray[index];
- let { defaultSize, maxSize, minSize } = panel.current;
- if (defaultSize != null) {
- continue;
- }
-
- const size = Math.min(
- maxSize != null ? maxSize : 100,
- Math.max(minSize, sizes[index] + remainingSize)
- );
-
- if (size !== sizes[index]) {
- remainingSize -= size - sizes[index];
- sizes[index] = size;
-
- // Fuzzy comparison to account for imprecise floating point math
- if (Math.abs(remainingSize).toFixed(3) === "0.000") {
- break;
- }
- }
- }
- }
-
- // Finally, if there is still left-over size, log an error
- if (Math.abs(remainingSize).toFixed(3) !== "0.000") {
- if (isDevelopment) {
- console.error(
- `Invalid panel group configuration; default panel sizes should total 100% but was ${(
- 100 - remainingSize
- ).toFixed(
- 1
- )}%. This can cause the cursor to become misaligned while dragging.`
- );
- }
- }
+ const sizes = calculateDefaultLayout({
+ groupId,
+ panels,
+ units,
+ });
setSizes(sizes);
}
@@ -485,20 +376,14 @@ function PanelGroupWithForwardedRef({
if (units === "pixels") {
const resizeObserver = new ResizeObserver(() => {
const { panels, sizes: prevSizes } = committedValuesRef.current;
- const [idBefore, idAfter] = Array.from(panels.values()).map(
- (panel) => panel.current.id
- );
- const nextSizes = adjustByDelta(
- null,
- committedValuesRef.current,
- idBefore,
- idAfter,
- null,
+ const nextSizes = validatePanelGroupLayout({
+ groupId,
+ panels,
+ nextSizes: prevSizes,
prevSizes,
- panelSizeBeforeCollapse.current,
- initialDragStateRef.current
- );
+ units,
+ });
if (!areEqual(prevSizes, nextSizes)) {
setSizes(nextSizes);
}
diff --git a/packages/react-resizable-panels/src/utils/group.ts b/packages/react-resizable-panels/src/utils/group.ts
index 13bdd4340..ecd344304 100644
--- a/packages/react-resizable-panels/src/utils/group.ts
+++ b/packages/react-resizable-panels/src/utils/group.ts
@@ -8,7 +8,7 @@ export function adjustByDelta(
committedValues: CommittedValues,
idBefore: string,
idAfter: string,
- deltaPixels: number | null,
+ deltaPixels: number,
prevSizes: number[],
panelSizeBeforeCollapse: Map,
initialDragState: InitialDragState | null
@@ -30,13 +30,6 @@ export function adjustByDelta(
let deltaApplied = 0;
- // A null delta means that layout is being recalculated (e.g. after a panel group resize)
- // In that scenario it is not safe for this method to bail out early
- const safeToBailOut = deltaPixels != null;
- if (deltaPixels === null) {
- deltaPixels = 0;
- }
-
// A resizing panel affects the panels before or after it.
//
// A negative delta means the panel immediately after the resizer should grow/expand by decreasing its offset.
@@ -58,15 +51,13 @@ export function adjustByDelta(
units,
groupSizePixels,
panel,
- Math.abs(deltaPixels),
baseSize,
+ baseSize + Math.abs(deltaPixels),
event
);
if (baseSize === nextSize) {
// If there's no room for the pivot panel to grow, we can ignore this drag update.
- if (safeToBailOut) {
- return baseSizes;
- }
+ return baseSizes;
} else {
if (nextSize === 0 && baseSize > 0) {
panelSizeBeforeCollapse.set(pivotId, baseSize);
@@ -88,8 +79,8 @@ export function adjustByDelta(
units,
groupSizePixels,
panel,
- 0 - deltaRemaining,
baseSize,
+ baseSize - deltaRemaining,
event
);
if (baseSize !== nextSize) {
@@ -183,6 +174,115 @@ export function callPanelCallbacks(
});
}
+export function calculateDefaultLayout({
+ groupId,
+ panels,
+ units,
+}: {
+ groupId: string;
+ panels: Map;
+ units: Units;
+}): number[] {
+ const groupSizePixels =
+ units === "pixels" ? getAvailableGroupSizePixels(groupId) : NaN;
+ const panelsArray = panelsMapToSortedArray(panels);
+ const sizes = Array(panelsArray.length);
+
+ let numPanelsWithSizes = 0;
+ let remainingSize = 100;
+
+ // 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 } = panel.current;
+
+ if (defaultSize != null) {
+ numPanelsWithSizes++;
+
+ sizes[index] =
+ units === "pixels"
+ ? (defaultSize / groupSizePixels) * 100
+ : defaultSize;
+
+ remainingSize -= sizes[index];
+ }
+ }
+
+ // Remaining total size should be distributed evenly between panels
+ // This may require two passes, depending on min/max constraints
+ for (let index = 0; index < panelsArray.length; index++) {
+ const panel = panelsArray[index];
+ let { defaultSize, id, maxSize, minSize } = panel.current;
+ if (defaultSize != null) {
+ continue;
+ }
+
+ if (units === "pixels") {
+ minSize = (minSize / groupSizePixels) * 100;
+ if (maxSize != null) {
+ maxSize = (maxSize / groupSizePixels) * 100;
+ }
+ }
+
+ const remainingPanels = panelsArray.length - numPanelsWithSizes;
+ const size = Math.min(
+ maxSize != null ? maxSize : 100,
+ Math.max(minSize, remainingSize / remainingPanels)
+ );
+
+ sizes[index] = size;
+ numPanelsWithSizes++;
+ remainingSize -= size;
+ }
+
+ // If there is additional, left over space, assign it to any panel(s) that permits it
+ // (It's not worth taking multiple additional passes to evenly distribute)
+ if (remainingSize !== 0) {
+ for (let index = 0; index < panelsArray.length; index++) {
+ const panel = panelsArray[index];
+ let { maxSize, minSize } = panel.current;
+
+ if (units === "pixels") {
+ minSize = (minSize / groupSizePixels) * 100;
+ if (maxSize != null) {
+ maxSize = (maxSize / groupSizePixels) * 100;
+ }
+ }
+
+ const size = Math.min(
+ maxSize != null ? maxSize : 100,
+ Math.max(minSize, sizes[index] + remainingSize)
+ );
+
+ if (size !== sizes[index]) {
+ remainingSize -= size - sizes[index];
+ sizes[index] = size;
+
+ // Fuzzy comparison to account for imprecise floating point math
+ if (Math.abs(remainingSize).toFixed(3) === "0.000") {
+ break;
+ }
+ }
+ }
+ }
+
+ // Finally, if there is still left-over size, log an error
+ if (Math.abs(remainingSize).toFixed(3) !== "0.000") {
+ if (isDevelopment) {
+ console.error(
+ `Invalid panel group configuration; default panel sizes should total 100% but was ${(
+ 100 - remainingSize
+ ).toFixed(
+ 1
+ )}%. This can cause the cursor to become misaligned while dragging.`
+ );
+ }
+ }
+
+ return sizes;
+}
+
export function getBeforeAndAfterIds(
id: string,
panelsArray: PanelData[]
@@ -335,12 +435,10 @@ export function safeResizePanel(
units: Units,
groupSizePixels: number,
panel: PanelData,
- delta: number,
prevSize: number,
- event: ResizeEvent | null
+ nextSize: number,
+ event: ResizeEvent | null = null
): number {
- const nextSizeUnsafe = prevSize + delta;
-
let { collapsedSize, collapsible, maxSize, minSize } = panel.current;
if (units === "pixels") {
@@ -354,7 +452,7 @@ export function safeResizePanel(
if (collapsible) {
if (prevSize > collapsedSize) {
// Mimic VS COde behavior; collapse a panel if it's smaller than half of its min-size
- if (nextSizeUnsafe <= minSize / 2 + collapsedSize) {
+ if (nextSize <= minSize / 2 + collapsedSize) {
return collapsedSize;
}
} else {
@@ -363,19 +461,14 @@ export function safeResizePanel(
// Keyboard events should expand a collapsed panel to the min size,
// but mouse events should wait until the panel has reached its min size
// to avoid a visual flickering when dragging between collapsed and min size.
- if (nextSizeUnsafe < minSize) {
+ if (nextSize < minSize) {
return collapsedSize;
}
}
}
}
- const nextSize = Math.min(
- maxSize != null ? maxSize : 100,
- Math.max(minSize, nextSizeUnsafe)
- );
-
- return nextSize;
+ return Math.min(maxSize != null ? maxSize : 100, Math.max(minSize, nextSize));
}
export function validatePanelProps(units: Units, panelData: PanelData) {
@@ -426,3 +519,86 @@ export function validatePanelProps(units: Units, panelData: PanelData) {
}
}
}
+
+export function validatePanelGroupLayout({
+ groupId,
+ panels,
+ nextSizes,
+ prevSizes,
+ units,
+}: {
+ groupId: string;
+ panels: Map;
+ nextSizes: number[];
+ prevSizes: number[];
+ units: Units;
+}): number[] {
+ const panelsArray = panelsMapToSortedArray(panels);
+
+ const groupSizePixels =
+ units === "pixels" ? getAvailableGroupSizePixels(groupId) : NaN;
+
+ let remainingSize = 0;
+
+ // First, check all of the proposed sizes against the min/max constraints
+ for (let index = 0; index < panelsArray.length; index++) {
+ const panel = panelsArray[index];
+ const prevSize = prevSizes[index];
+ const nextSize = nextSizes[index];
+ const safeNextSize = safeResizePanel(
+ units,
+ groupSizePixels,
+ panel,
+ prevSize,
+ nextSize
+ );
+ if (nextSize != safeNextSize) {
+ remainingSize += nextSize - safeNextSize;
+ nextSizes[index] = safeNextSize;
+
+ if (isDevelopment) {
+ console.error(
+ `Invalid size (${nextSize}) specified for Panel "${panel.current.id}" given the panel's min/max size constraints`
+ );
+ }
+ }
+ }
+
+ // If there is additional, left over space, assign it to any panel(s) that permits it
+ // (It's not worth taking multiple additional passes to evenly distribute)
+ if (remainingSize.toFixed(3) !== "0.000") {
+ for (let index = 0; index < panelsArray.length; index++) {
+ const panel = panelsArray[index];
+
+ let { maxSize, minSize } = panel.current;
+
+ const size = Math.min(
+ maxSize != null ? maxSize : 100,
+ Math.max(minSize, nextSizes[index] + remainingSize)
+ );
+
+ if (size !== nextSizes[index]) {
+ remainingSize -= size - nextSizes[index];
+ nextSizes[index] = size;
+
+ // Fuzzy comparison to account for imprecise floating point math
+ if (Math.abs(remainingSize).toFixed(3) === "0.000") {
+ break;
+ }
+ }
+ }
+ }
+
+ // If we still have remainder, the requested layout wasn't valid and we should warn about it
+ if (remainingSize.toFixed(3) !== "0.000") {
+ if (isDevelopment) {
+ console.error(
+ `"Invalid panel group configuration; default panel sizes should total 100% but was ${
+ 100 - remainingSize
+ }%`
+ );
+ }
+ }
+
+ return nextSizes;
+}
From c96af767bd87626fe4b6981349d20fd2ab265a9e Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 12 Aug 2023 08:11:11 -1000
Subject: [PATCH 17/20] getSize and getLayout APIs accept units overrides
---
.../src/routes/EndToEndTesting/index.tsx | 158 ++++++++++++------
.../routes/EndToEndTesting/styles.module.css | 9 +-
.../routes/examples/ImperativePanelApi.tsx | 9 +-
.../examples/ImperativePanelGroupApi.tsx | 8 +-
.../src/utils/UrlData.ts | 32 ++--
.../tests/ImperativePanelApi.spec.ts | 8 +-
.../tests/PanelGroup-PixelUnits.spec.ts | 1 +
.../tests/utils/panels.ts | 34 +---
packages/react-resizable-panels/src/Panel.ts | 17 +-
.../src/PanelContexts.ts | 2 +
.../react-resizable-panels/src/PanelGroup.ts | 44 ++++-
.../react-resizable-panels/src/utils/group.ts | 10 ++
12 files changed, 212 insertions(+), 120 deletions(-)
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 83de01ab3..e6367341d 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
@@ -9,6 +9,7 @@ import {
import {
ImperativePanelGroupHandle,
ImperativePanelHandle,
+ Units,
getAvailableGroupSizePixels,
} from "react-resizable-panels";
@@ -50,14 +51,52 @@ function EndToEndTesting() {
return urlToUrlData(url);
});
+ const [panelId, setPanelId] = useState("");
+ const [panelIds, setPanelIds] = useState([]);
+ const [panelGroupId, setPanelGroupId] = useState("");
+ const [panelGroupIds, setPanelGroupIds] = useState([]);
+ const [size, setSize] = useState(0);
+ const [units, setUnits] = useState("");
+ const [layoutString, setLayoutString] = useState("");
+
+ const debugLogRef = useRef(null);
+ const idToRefMapRef = useRef<
+ Map
+ >(new Map());
+
useLayoutEffect(() => {
+ const populateDropDowns = () => {
+ const panelElements = document.querySelectorAll("[data-panel-id]");
+ const panelIds = Array.from(panelElements).map(
+ (element) => element.getAttribute("data-panel-id")!
+ );
+ setPanelIds(panelIds);
+ setPanelId(panelIds[0]);
+
+ const panelGroupElements =
+ document.querySelectorAll("[data-panel-group]");
+ const panelGroupIds = Array.from(panelGroupElements).map(
+ (element) => element.getAttribute("data-panel-group-id")!
+ );
+ setPanelGroupIds(panelGroupIds);
+ setPanelGroupId(panelGroupIds[0]);
+
+ // const panelGroupElement = document.querySelector("[data-panel-group]")!;
+ // const units = panelGroupElement.getAttribute("data-panel-group-units")!;
+ // setUnits(units);
+ };
+
window.addEventListener("popstate", (event) => {
const url = new URL(
typeof window !== undefined ? window.location.href : ""
);
setUrlData(urlToUrlData(url));
+
+ populateDropDowns();
});
+
+ populateDropDowns();
}, []);
useLayoutEffect(() => {
@@ -66,64 +105,69 @@ function EndToEndTesting() {
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`;
+ // Let layout effects fire first
+ setTimeout(() => {
+ const panelId = panelElement.getAttribute("data-panel-id");
+ if (panelId != null) {
+ const panel = idToRefMapRef.current.get(
+ panelId
+ ) as ImperativePanelHandle;
+ if (panel != null) {
+ const percentage = panel.getSize("percentages");
+ const pixels = panel.getSize("pixels");
+
+ panelElement.textContent = `${percentage.toFixed(
+ 1
+ )}%\n${pixels.toFixed(1)}px`;
+ }
+ }
+ }, 0);
};
- const observer = new MutationObserver((mutationRecords) => {
- mutationRecords.forEach((mutationRecord) => {
- calculatePanelSize(mutationRecord.target as HTMLElement);
+ const mutationObserver = new MutationObserver((records) => {
+ records.forEach((record) => {
+ calculatePanelSize(record.target as HTMLElement);
+ });
+ });
+ const resizeObserver = new ResizeObserver((records) => {
+ records.forEach((record) => {
+ calculatePanelSize(record.target as HTMLElement);
});
});
const elements = document.querySelectorAll("[data-panel]");
Array.from(elements).forEach((element) => {
- observer.observe(element, {
+ mutationObserver.observe(element, {
attributes: true,
});
+ resizeObserver.observe(element);
calculatePanelSize(element as HTMLElement);
});
return () => {
- observer.disconnect();
+ mutationObserver.disconnect();
+ resizeObserver.disconnect();
};
}, []);
- const [panelId, setPanelId] = useState("");
- const [panelGroupId, setPanelGroupId] = useState("");
- const [size, setSize] = useState(0);
- const [units, setUnits] = useState("");
- const [layoutString, setLayoutString] = useState("");
-
- const debugLogRef = useRef(null);
- const idToRefMapRef = useRef<
- Map
- >(new Map());
-
const children = urlData
? urlPanelGroupToPanelGroup(urlData, debugLogRef, idToRefMapRef)
: null;
const onLayoutInputChange = (event: ChangeEvent) => {
const value = event.currentTarget.value;
- setLayoutString(value);
+ setLayoutString(value.startsWith("[") ? value : `[${value}]`);
};
- const onPanelIdInputChange = (event: ChangeEvent) => {
+ const onPanelIdSelectChange = (event: ChangeEvent) => {
const value = event.currentTarget.value;
setPanelId(value);
};
- const onPanelGroupIdInputChange = (event: ChangeEvent) => {
+ const onPanelGroupIdSelectChange = (
+ event: ChangeEvent
+ ) => {
const value = event.currentTarget.value;
setPanelGroupId(value);
};
@@ -133,7 +177,7 @@ function EndToEndTesting() {
setSize(parseFloat(value));
};
- const onUnitsInputChange = (event: ChangeEvent) => {
+ const onUnitsSelectChange = (event: ChangeEvent) => {
const value = event.currentTarget.value;
setUnits(value);
};
@@ -177,13 +221,18 @@ function EndToEndTesting() {
-
+ >
+ {panelIds.map((panelId) => (
+
+ {panelId}
+
+ ))}
+
-
-
-
+
-
-
-
+
+
percentages
+
pixels
+
+
+
+ id="panelGroupIdSelect"
+ onChange={onPanelGroupIdSelectChange}
+ placeholder="Panel group id"
+ >
+ {panelGroupIds.map((panelGroupId) => (
+
+ {panelGroupId}
+
+ ))}
+
- Panel's current size
+ Panel's current size in the specified unit (or group default)
- Resize the panel to the specified percentage
+ Resize the panel to the specified size in the specified unit (or
+ group default)
>
diff --git a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx
index 10584d974..89088d9f7 100644
--- a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx
+++ b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx
@@ -34,18 +34,18 @@ export default function ImperativePanelGroupApiRoute() {
- Current size of panels
+ Current size of panels in the specified unit (or group default)
- Resize all panels
+ Resize all panels using the specified unit (or group default)
>
diff --git a/packages/react-resizable-panels-website/src/utils/UrlData.ts b/packages/react-resizable-panels-website/src/utils/UrlData.ts
index 8b2b6feb4..182e5db3e 100644
--- a/packages/react-resizable-panels-website/src/utils/UrlData.ts
+++ b/packages/react-resizable-panels-website/src/utils/UrlData.ts
@@ -158,7 +158,6 @@ function urlPanelToPanel(
): ReactElement {
let onCollapse: PanelOnCollapse | undefined = undefined;
let onResize: PanelOnResize | undefined = undefined;
- let refSetter;
const panelId = urlPanel.id;
if (panelId) {
@@ -183,16 +182,15 @@ function urlPanelToPanel(
});
}
};
+ }
- refSetter = (panel: ImperativePanelHandle | null) => {
+ const refSetter = (panel: ImperativePanelHandle | null) => {
+ if (panel) {
+ const id = panel.getId();
const idToRefMap = idToRefMapRef.current!;
- if (panel) {
- idToRefMap.set(panelId, panel);
- } else {
- idToRefMap.delete(panelId);
- }
- };
- }
+ idToRefMap.set(id, panel);
+ }
+ };
return createElement(
Panel,
@@ -235,7 +233,6 @@ export function urlPanelGroupToPanelGroup(
key?: any
): ReactElement {
let onLayout: PanelGroupOnLayout | undefined = undefined;
- let refSetter;
const groupId = urlPanelGroup.id;
if (groupId) {
@@ -245,16 +242,15 @@ export function urlPanelGroupToPanelGroup(
debugLog.log({ groupId, type: "onLayout", sizes });
}
};
+ }
- refSetter = (panelGroup: ImperativePanelGroupHandle | null) => {
+ const refSetter = (panelGroup: ImperativePanelGroupHandle | null) => {
+ if (panelGroup) {
+ const id = panelGroup.getId();
const idToRefMap = idToRefMapRef.current!;
- if (panelGroup) {
- idToRefMap.set(groupId, panelGroup);
- } else {
- idToRefMap.delete(groupId);
- }
- };
- }
+ idToRefMap.set(id, panelGroup);
+ }
+ };
return createElement(
PanelGroup,
diff --git a/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts b/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
index 3551b33cc..136680804 100644
--- a/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
+++ b/packages/react-resizable-panels-website/tests/ImperativePanelApi.spec.ts
@@ -82,21 +82,19 @@ test.describe("Imperative Panel API", () => {
}) => {
const collapseButton = page.locator("#collapseButton");
const expandButton = page.locator("#expandButton");
- const panelIdInput = page.locator("#panelIdInput");
+ const panelIdSelect = page.locator("#panelIdSelect");
await imperativeResizePanel(page, "left", 15);
await imperativeResizePanel(page, "right", 25);
await verifySizes(page, 15, 60, 25);
- await panelIdInput.focus();
- await panelIdInput.fill("left");
+ await panelIdSelect.selectOption("left");
await collapseButton.click();
await verifySizes(page, 0, 75, 25);
await expandButton.click();
await verifySizes(page, 15, 60, 25);
- await panelIdInput.focus();
- await panelIdInput.fill("right");
+ await panelIdSelect.selectOption("right");
await collapseButton.click();
await verifySizes(page, 15, 85, 0);
await expandButton.click();
diff --git a/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
index af08548ba..0535d43e5 100644
--- a/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
+++ b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
@@ -208,6 +208,7 @@ test.describe("Pixel units", () => {
await verifyPanelSizePixels(leftPanel, 50);
await page.setViewportSize({ width: 300, height: 300 });
+ await new Promise((r) => setTimeout(r, 30));
await verifyPanelSizePixels(leftPanel, 50);
await page.setViewportSize({ width: 400, height: 300 });
diff --git a/packages/react-resizable-panels-website/tests/utils/panels.ts b/packages/react-resizable-panels-website/tests/utils/panels.ts
index 54ab706a5..c28c03b81 100644
--- a/packages/react-resizable-panels-website/tests/utils/panels.ts
+++ b/packages/react-resizable-panels-website/tests/utils/panels.ts
@@ -92,12 +92,6 @@ export async function dragResizeTo(
const prevSize = (await panel.getAttribute("data-panel-size"))!;
const isExpanding = parseFloat(prevSize) < nextSize;
- console.log(
- `${
- isExpanding ? "Expanding" : "Contracting"
- } panel "${panelId}" from ${prevSize} to ${nextSize}`
- );
-
// Last panel should drag the handle before it back (left/up)
// All other panels should drag the handle after it forward (right/down)
let dragIncrement = 0;
@@ -159,21 +153,15 @@ export async function imperativeResizePanel(
size: number,
units?: Units
) {
- const panelIdInput = page.locator("#panelIdInput");
- await panelIdInput.focus();
- await panelIdInput.fill(panelId);
+ const panelIdSelect = page.locator("#panelIdSelect");
+ await panelIdSelect.selectOption(panelId);
const sizeInput = page.locator("#sizeInput");
await sizeInput.focus();
await sizeInput.fill("" + size);
- const unitsInput = page.locator("#unitsInput");
- if (units) {
- await unitsInput.focus();
- await unitsInput.fill(units);
- } else {
- await unitsInput.clear();
- }
+ const unitsSelect = page.locator("#unitsSelect");
+ unitsSelect.selectOption(units ?? "");
const resizeButton = page.locator("#resizeButton");
await resizeButton.click();
@@ -185,21 +173,15 @@ export async function imperativeResizePanelGroup(
sizes: number[],
units?: Units
) {
- const panelGroupIdInput = page.locator("#panelGroupIdInput");
- await panelGroupIdInput.focus();
- await panelGroupIdInput.fill(panelGroupId);
+ const panelGroupIdSelect = page.locator("#panelGroupIdSelect");
+ panelGroupIdSelect.selectOption(panelGroupId);
const layoutInput = page.locator("#layoutInput");
await layoutInput.focus();
await layoutInput.fill(`[${sizes.join()}]`);
- const unitsInput = page.locator("#unitsInput");
- if (units) {
- await unitsInput.focus();
- await unitsInput.fill(units);
- } else {
- await unitsInput.clear();
- }
+ const unitsSelect = page.locator("#unitsSelect");
+ unitsSelect.selectOption(units ?? "");
const setLayoutButton = page.locator("#setLayoutButton");
await setLayoutButton.click();
diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts
index 233b16632..d48fd730b 100644
--- a/packages/react-resizable-panels/src/Panel.ts
+++ b/packages/react-resizable-panels/src/Panel.ts
@@ -21,7 +21,7 @@ import {
PanelOnResize,
Units,
} from "./types";
-import { isDevelopment } from "./env-conditions/production";
+import { getAvailableGroupSizePixels } from "./utils/group";
export type PanelProps = {
children?: ReactNode;
@@ -43,7 +43,8 @@ export type ImperativePanelHandle = {
collapse: () => void;
expand: () => void;
getCollapsed(): boolean;
- getSize(): number;
+ getId(): string;
+ getSize(units?: Units): number;
resize: (percentage: number, units?: Units) => void;
};
@@ -77,9 +78,12 @@ function PanelWithForwardedRef({
const {
collapsePanel,
expandPanel,
+ getPanelSize,
getPanelStyle,
+ groupId,
registerPanel,
resizePanel,
+ units,
unregisterPanel,
} = context;
@@ -153,13 +157,16 @@ function PanelWithForwardedRef({
getCollapsed() {
return committedValuesRef.current.size === 0;
},
- getSize() {
- return committedValuesRef.current.size;
+ getId() {
+ return panelId;
+ },
+ getSize(units) {
+ return getPanelSize(panelId, units);
},
resize: (percentage: number, units) =>
resizePanel(panelId, percentage, units),
}),
- [collapsePanel, expandPanel, panelId, resizePanel]
+ [collapsePanel, expandPanel, getPanelSize, panelId, resizePanel]
);
return createElement(Type, {
diff --git a/packages/react-resizable-panels/src/PanelContexts.ts b/packages/react-resizable-panels/src/PanelContexts.ts
index 409f5f8e8..109361b5a 100644
--- a/packages/react-resizable-panels/src/PanelContexts.ts
+++ b/packages/react-resizable-panels/src/PanelContexts.ts
@@ -7,6 +7,7 @@ export const PanelGroupContext = createContext<{
collapsePanel: (id: string) => void;
direction: "horizontal" | "vertical";
expandPanel: (id: string) => void;
+ getPanelSize: (id: string, units?: Units) => number;
getPanelStyle: (id: string, defaultSize: number | null) => CSSProperties;
groupId: string;
registerPanel: (id: string, panel: PanelData) => void;
@@ -15,6 +16,7 @@ export const PanelGroupContext = createContext<{
startDragging: (id: string, event: ResizeEvent) => void;
stopDragging: () => void;
unregisterPanel: (id: string) => void;
+ units: Units;
} | null>(null);
PanelGroupContext.displayName = "PanelGroupContext";
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index 81393c081..863deb1de 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -139,7 +139,8 @@ export type PanelGroupProps = {
};
export type ImperativePanelGroupHandle = {
- getLayout: () => number[];
+ getId: () => string;
+ getLayout: (units?: Units) => number[];
setLayout: (panelSizes: number[], units?: Units) => void;
};
@@ -211,9 +212,17 @@ function PanelGroupWithForwardedRef({
useImperativeHandle(
forwardedRef,
() => ({
- getLayout: () => {
- const { sizes } = committedValuesRef.current;
- return sizes;
+ getId: () => groupId,
+ getLayout: (unitsFromParams?: Units) => {
+ const { sizes, units: unitsFromProps } = committedValuesRef.current;
+
+ const units = unitsFromParams ?? unitsFromProps;
+ if (units === "pixels") {
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
+ return sizes.map((size) => (size / 100) * groupSizePixels);
+ } else {
+ return sizes;
+ }
},
setLayout: (sizes: number[], unitsFromParams?: Units) => {
const {
@@ -250,7 +259,7 @@ function PanelGroupWithForwardedRef({
}
},
}),
- []
+ [groupId]
);
useIsomorphicLayoutEffect(() => {
@@ -397,6 +406,26 @@ function PanelGroupWithForwardedRef({
}
}, [groupId, units]);
+ const getPanelSize = useCallback(
+ (id: string, unitsFromParams?: Units) => {
+ const { panels, units: unitsFromProps } = committedValuesRef.current;
+
+ const panelsArray = panelsMapToSortedArray(panels);
+
+ const index = panelsArray.findIndex((panel) => panel.current.id === id);
+ const size = sizes[index];
+
+ const units = unitsFromParams ?? unitsFromProps;
+ if (units === "pixels") {
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
+ return (size / 100) * groupSizePixels;
+ } else {
+ return size;
+ }
+ },
+ [groupId, sizes]
+ );
+
const getPanelStyle = useCallback(
(id: string, defaultSize: number | null): CSSProperties => {
const { panels } = committedValuesRef.current;
@@ -813,6 +842,7 @@ function PanelGroupWithForwardedRef({
collapsePanel,
direction,
expandPanel,
+ getPanelSize,
getPanelStyle,
groupId,
registerPanel,
@@ -837,6 +867,7 @@ function PanelGroupWithForwardedRef({
initialDragStateRef.current = null;
},
+ units,
unregisterPanel,
}),
[
@@ -844,11 +875,13 @@ function PanelGroupWithForwardedRef({
collapsePanel,
direction,
expandPanel,
+ getPanelSize,
getPanelStyle,
groupId,
registerPanel,
registerResizeHandle,
resizePanel,
+ units,
unregisterPanel,
]
);
@@ -868,6 +901,7 @@ function PanelGroupWithForwardedRef({
"data-panel-group": "",
"data-panel-group-direction": direction,
"data-panel-group-id": groupId,
+ "data-panel-group-units": units,
style: { ...style, ...styleFromProps },
}),
value: context,
diff --git a/packages/react-resizable-panels/src/utils/group.ts b/packages/react-resizable-panels/src/utils/group.ts
index ecd344304..54a5cfec3 100644
--- a/packages/react-resizable-panels/src/utils/group.ts
+++ b/packages/react-resizable-panels/src/utils/group.ts
@@ -533,6 +533,9 @@ export function validatePanelGroupLayout({
prevSizes: number[];
units: Units;
}): number[] {
+ // Clone because this method modifies
+ nextSizes = [...nextSizes];
+
const panelsArray = panelsMapToSortedArray(panels);
const groupSizePixels =
@@ -572,6 +575,13 @@ export function validatePanelGroupLayout({
let { maxSize, minSize } = panel.current;
+ if (units === "pixels") {
+ minSize = (minSize / groupSizePixels) * 100;
+ if (maxSize != null) {
+ maxSize = (maxSize / groupSizePixels) * 100;
+ }
+ }
+
const size = Math.min(
maxSize != null ? maxSize : 100,
Math.max(minSize, nextSizes[index] + remainingSize)
From 85bea2f9975616ec2b5150f118524c57e36ebff2 Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 12 Aug 2023 08:17:08 -1000
Subject: [PATCH 18/20] Delete outdated TODO comment
---
packages/react-resizable-panels/src/PanelGroup.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index 863deb1de..f1d60a7fb 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -120,10 +120,6 @@ export type InitialDragState = {
sizes: number[];
};
-// TODO
-// Within an active drag, remember original positions to refine more easily on expand.
-// Look at what the Chrome devtools Sources does.
-
export type PanelGroupProps = {
autoSaveId?: string;
children?: ReactNode;
From 5fe570080950fbd8d17f25d2ee20dc7d09ca11ef Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 12 Aug 2023 08:25:27 -1000
Subject: [PATCH 19/20] Relaxed minSize required prop constraint
---
.../src/utils/UrlData.ts | 2 +-
packages/react-resizable-panels/CHANGELOG.md | 27 ++++++++++++++++++-
packages/react-resizable-panels/src/Panel.ts | 15 ++++++++---
3 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/packages/react-resizable-panels-website/src/utils/UrlData.ts b/packages/react-resizable-panels-website/src/utils/UrlData.ts
index 182e5db3e..b3e2cf122 100644
--- a/packages/react-resizable-panels-website/src/utils/UrlData.ts
+++ b/packages/react-resizable-panels-website/src/utils/UrlData.ts
@@ -30,7 +30,7 @@ type UrlPanel = {
defaultSize?: number | null;
id?: string | null;
maxSize?: number | null;
- minSize: number;
+ minSize?: number;
order?: number | null;
style?: CSSProperties;
type: "UrlPanel";
diff --git a/packages/react-resizable-panels/CHANGELOG.md b/packages/react-resizable-panels/CHANGELOG.md
index 0e402e35c..3dfe03666 100644
--- a/packages/react-resizable-panels/CHANGELOG.md
+++ b/packages/react-resizable-panels/CHANGELOG.md
@@ -2,7 +2,32 @@
## 0.0.55
* New `units` prop added to `PanelGroup` to support pixel-based panel size constraints.
-* `Panel` prop `minSize` is now required to simplify upgrade path.
+
+This prop defaults to "percentage" but can be set to "pixels" for static, pixel based layout constraints.
+
+This can be used to add enable pixel-based min/max and default size values, e.g.:
+```tsx
+
+ {/* Will be constrained to 100-200 pixels (assuming group is large enough to permit this) */}
+
+
+
+
+
+
+```
+
+Imperative API methods are also able to work with either pixels or percentages now. They default to whatever units the group has been configured to use, but can be overridden with an additional, optional parameter, e.g.
+```ts
+panelRef.resize(100, "pixels");
+panelGroupRef.setLayout([25, 50, 25], "percentages");
+
+// Works for getters too, e.g.
+const percentage = panelRef.getSize("percentages");
+const pixels = panelRef.getSize("pixels");
+
+const layout = panelGroupRef.getLayout("pixels");
+```
## 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
diff --git a/packages/react-resizable-panels/src/Panel.ts b/packages/react-resizable-panels/src/Panel.ts
index d48fd730b..f72f4ff80 100644
--- a/packages/react-resizable-panels/src/Panel.ts
+++ b/packages/react-resizable-panels/src/Panel.ts
@@ -31,7 +31,7 @@ export type PanelProps = {
defaultSize?: number | null;
id?: string | null;
maxSize?: number | null;
- minSize: number;
+ minSize?: number;
onCollapse?: PanelOnCollapse | null;
onResize?: PanelOnResize | null;
order?: number | null;
@@ -80,13 +80,22 @@ function PanelWithForwardedRef({
expandPanel,
getPanelSize,
getPanelStyle,
- groupId,
registerPanel,
resizePanel,
units,
unregisterPanel,
} = context;
+ if (minSize == null) {
+ if (units === "percentages") {
+ // Mimics legacy default value for percentage based panel groups
+ minSize = 10;
+ } else {
+ // There is no meaningful minimum pixel default we can provide
+ minSize = 0;
+ }
+ }
+
// Use a ref to guard against users passing inline props
const callbacksRef = useRef<{
onCollapse: PanelOnCollapse | null;
@@ -137,7 +146,7 @@ function PanelWithForwardedRef({
panelDataRef.current.id = panelId;
panelDataRef.current.idWasAutoGenerated = idFromProps == null;
panelDataRef.current.maxSize = maxSize;
- panelDataRef.current.minSize = minSize;
+ panelDataRef.current.minSize = minSize as number;
panelDataRef.current.order = order;
});
From 71a6439170b75ba8ad6b9b3622cd8dd12869ecea Mon Sep 17 00:00:00 2001
From: Brian Vaughn
Date: Sat, 12 Aug 2023 15:34:49 -1000
Subject: [PATCH 20/20] Validate saved layouts before restoring
---
.../src/routes/EndToEndTesting/index.tsx | 2 +-
.../tests/PanelGroup-PixelUnits.spec.ts | 133 +++++++++++++-----
.../tests/utils/url.ts | 8 +-
.../tests/utils/verify.ts | 14 +-
.../react-resizable-panels/src/PanelGroup.ts | 12 +-
5 files changed, 118 insertions(+), 51 deletions(-)
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 e6367341d..75015aaa0 100644
--- a/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
+++ b/packages/react-resizable-panels-website/src/routes/EndToEndTesting/index.tsx
@@ -149,7 +149,7 @@ function EndToEndTesting() {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
- }, []);
+ }, [urlData]);
const children = urlData
? urlPanelGroupToPanelGroup(urlData, debugLogRef, idToRefMapRef)
diff --git a/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
index 0535d43e5..0d6892b98 100644
--- a/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
+++ b/packages/react-resizable-panels-website/tests/PanelGroup-PixelUnits.spec.ts
@@ -14,54 +14,65 @@ import {
imperativeResizePanel,
verifyPanelSizePixels,
} from "./utils/panels";
-import { goToUrl } from "./utils/url";
+import { goToUrl, updateUrl } from "./utils/url";
+import { verifySizesPixels } from "./utils/verify";
+
+function createElements(
+ props: {
+ leftPanelProps?: PanelProps;
+ leftResizeHandleProps?: Partial;
+ middlePanelProps?: PanelProps;
+ panelGroupProps?: Partial;
+ rightPanelProps?: PanelProps;
+ rightResizeHandleProps?: Partial;
+ } = {}
+) {
+ return createElement(
+ PanelGroup,
+ {
+ direction: "horizontal",
+ id: "group",
+ units: "pixels",
+ ...props.panelGroupProps,
+ },
+ createElement(Panel, {
+ id: "left-panel",
+ minSize: 10,
+ ...props.leftPanelProps,
+ }),
+ createElement(PanelResizeHandle, {
+ id: "left-resize-handle",
+ ...props.leftResizeHandleProps,
+ }),
+ createElement(Panel, {
+ id: "middle-panel",
+ minSize: 10,
+ ...props.middlePanelProps,
+ }),
+ createElement(PanelResizeHandle, {
+ id: "right-resize-handle",
+ ...props.rightResizeHandleProps,
+ }),
+ createElement(Panel, {
+ id: "right-panel",
+ minSize: 10,
+ ...props.rightPanelProps,
+ })
+ );
+}
async function goToUrlHelper(
page: Page,
props: {
leftPanelProps?: PanelProps;
- leftResizeHandleProps?: PanelResizeHandleProps;
+ leftResizeHandleProps?: Partial;
middlePanelProps?: PanelProps;
- panelGroupProps?: PanelGroupProps;
+ panelGroupProps?: Partial;
rightPanelProps?: PanelProps;
- rightResizeHandleProps?: PanelResizeHandleProps;
+ rightResizeHandleProps?: Partial;
} = {}
) {
- await goToUrl(
- page,
- createElement(
- PanelGroup,
- {
- direction: "horizontal",
- id: "group",
- units: "pixels",
- ...props.panelGroupProps,
- },
- createElement(Panel, {
- id: "left-panel",
- minSize: 10,
- ...props.leftPanelProps,
- }),
- createElement(PanelResizeHandle, {
- id: "left-resize-handle",
- ...props.leftResizeHandleProps,
- }),
- createElement(Panel, {
- id: "middle-panel",
- minSize: 10,
- ...props.middlePanelProps,
- }),
- createElement(PanelResizeHandle, {
- id: "right-resize-handle",
- ...props.rightResizeHandleProps,
- }),
- createElement(Panel, {
- id: "right-panel",
- minSize: 10,
- ...props.rightPanelProps,
- })
- )
- );
+ await goToUrl(page, createElements(props));
}
test.describe("Pixel units", () => {
@@ -314,4 +325,48 @@ test.describe("Pixel units", () => {
await verifyPanelSizePixels(firstPanel, 50);
await verifyPanelSizePixels(fourthPanel, 50);
});
+
+ test("should validate persisted pixel layouts before re-applying", async ({
+ page,
+ }) => {
+ let stored: { [name: string]: string } = {};
+ const elements = createElements({
+ panelGroupProps: {
+ autoSaveId: "test-group",
+ storage: {
+ getItem(name: string): string | null {
+ return stored[name] ?? null;
+ },
+ setItem(name: string, value: string): void {
+ stored[name] = value;
+ },
+ },
+ },
+ leftPanelProps: {
+ minSize: 50,
+ },
+ middlePanelProps: {
+ minSize: 50,
+ },
+ rightPanelProps: {
+ minSize: 50,
+ },
+ });
+ await goToUrl(page, elements as any);
+ await verifySizesPixels(page, 132, 132, 132);
+
+ await imperativeResizePanel(page, "left-panel", 50);
+ await verifySizesPixels(page, 50, 214, 132);
+
+ // Wait for localStorage write debounce
+ await new Promise((resolve) => setTimeout(resolve, 250));
+
+ // Unload page and resize window
+ await updateUrl(page, null);
+ await page.setViewportSize({ width: 300, height: 300 });
+
+ // Reload page and verify pixel validation has re-run on saved percentages
+ await updateUrl(page, elements);
+ await verifySizesPixels(page, 50, 147.3, 98.7);
+ });
});
diff --git a/packages/react-resizable-panels-website/tests/utils/url.ts b/packages/react-resizable-panels-website/tests/utils/url.ts
index ee5f9e121..c0e11285e 100644
--- a/packages/react-resizable-panels-website/tests/utils/url.ts
+++ b/packages/react-resizable-panels-website/tests/utils/url.ts
@@ -5,9 +5,9 @@ import { UrlPanelGroupToEncodedString } from "../../src/utils/UrlData";
export async function goToUrl(
page: Page,
- element: ReactElement
+ element: ReactElement | null
) {
- const encodedString = UrlPanelGroupToEncodedString(element);
+ const encodedString = element ? UrlPanelGroupToEncodedString(element) : "";
const url = new URL("http://localhost:1234/__e2e");
url.searchParams.set("urlPanelGroup", encodedString);
@@ -20,9 +20,9 @@ export async function goToUrl(
export async function updateUrl(
page: Page,
- element: ReactElement
+ element: ReactElement | null
) {
- const encodedString = UrlPanelGroupToEncodedString(element);
+ const encodedString = element ? UrlPanelGroupToEncodedString(element) : "";
await page.evaluate(
([encodedString]) => {
diff --git a/packages/react-resizable-panels-website/tests/utils/verify.ts b/packages/react-resizable-panels-website/tests/utils/verify.ts
index bf4e55986..1460c7ad4 100644
--- a/packages/react-resizable-panels-website/tests/utils/verify.ts
+++ b/packages/react-resizable-panels-website/tests/utils/verify.ts
@@ -15,9 +15,11 @@ export async function verifySizes(page: Page, ...expectedSizes: number[]) {
const textContent = (await panel.textContent()) || "";
const expectedSize = expectedSizes[index];
- const actualSize = parseFloat(textContent.split("\n")[0].replace("%", ""));
+ const rows = textContent.split("\n");
+ const actualSize =
+ rows.length === 2 ? parseFloat(rows[0].replace("%", "")) : NaN;
- expect(expectedSize).toBe(actualSize);
+ expect(actualSize).toBe(expectedSize);
}
}
@@ -35,11 +37,11 @@ export async function verifySizesPixels(
const textContent = (await panel.textContent()) || "";
const expectedSizePixels = expectedSizesPixels[index];
- const actualSizePixels = parseFloat(
- textContent.split("\n")[1].replace("px", "")
- );
+ const rows = textContent.split("\n");
+ const actualSizePixels =
+ rows.length === 2 ? parseFloat(rows[1].replace("px", "")) : NaN;
- expect(expectedSizePixels).toBe(actualSizePixels);
+ expect(actualSizePixels).toBe(expectedSizePixels);
}
}
diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts
index f1d60a7fb..272c05ef6 100644
--- a/packages/react-resizable-panels/src/PanelGroup.ts
+++ b/packages/react-resizable-panels/src/PanelGroup.ts
@@ -318,7 +318,17 @@ function PanelGroupWithForwardedRef({
}
if (defaultSizes != null) {
- setSizes(defaultSizes);
+ // Validate saved sizes in case something has changed since last render
+ // e.g. for pixel groups, this could be the size of the window
+ const validatedSizes = validatePanelGroupLayout({
+ groupId,
+ panels,
+ nextSizes: defaultSizes,
+ prevSizes: defaultSizes,
+ units,
+ });
+
+ setSizes(validatedSizes);
} else {
const sizes = calculateDefaultLayout({
groupId,