From 8860cf9b353e03f5d8006787a3691c19eedd7479 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 4 Feb 2026 18:11:36 -0500 Subject: [PATCH 1/2] Allow `Panel` and `Separator` components to be disabled (#657) See #604 - [x] Implement feature - [x] Update documentation - [x] Unit tests for `adjustLayoutByDelta` - [x] e2e tests for `Separator` cursor and tab/focus --- CHANGELOG.md | 4 + README.md | 12 ++ integrations/tests/tests/cursor.spec.tsx | 54 ++++++- .../tests/keyboard-interactions.spec.tsx | 17 ++ .../tests/tests/pointer-interactions.spec.tsx | 42 +++++ lib/components/panel/Panel.tsx | 14 +- lib/components/panel/types.ts | 13 +- lib/components/separator/Separator.tsx | 10 +- lib/components/separator/types.ts | 9 ++ lib/global/cursor/getCursorStyle.test.ts | 12 +- lib/global/cursor/getCursorStyle.ts | 4 +- lib/global/dom/calculateHitRegions.test.ts | 143 +++++++++++++++++ lib/global/dom/calculateHitRegions.ts | 17 ++ lib/global/dom/calculatePanelConstraints.ts | 2 + lib/global/test/mockGroup.ts | 16 +- lib/global/utils/adjustLayoutByDelta.test.ts | 145 +++++++++++++++++- lib/global/utils/adjustLayoutByDelta.ts | 4 + lib/global/utils/findClosestHitRegion.ts | 4 + lib/global/utils/validatePanelGroupLayout.ts | 5 + lib/global/utils/validatePanelSize.ts | 7 + public/generated/docs/Panel.json | 10 ++ public/generated/docs/Separator.json | 14 ++ public/generated/examples/DisabledPanel.json | 3 + .../examples/DisabledPanelAndSeparator.json | 3 + public/generated/examples/DisabledPanels.json | 3 + .../generated/examples/DisabledSeparator.json | 3 + .../examples/SeparatorDataAttributes.json | 2 +- src/App.tsx | 1 + src/components/styled-panels/Panel.tsx | 9 +- src/components/styled-panels/Separator.tsx | 5 +- src/routes.ts | 3 + src/routes/DisabledPanelsRoute.tsx | 81 ++++++++++ src/routes/examples/DisabledPanel.tsx | 12 ++ .../examples/DisabledPanelAndSeparator.tsx | 12 ++ src/routes/examples/DisabledPanels.tsx | 9 ++ src/routes/examples/DisabledSeparator.tsx | 10 ++ .../examples/SeparatorDataAttributes.html | 1 + 37 files changed, 692 insertions(+), 23 deletions(-) create mode 100644 public/generated/examples/DisabledPanel.json create mode 100644 public/generated/examples/DisabledPanelAndSeparator.json create mode 100644 public/generated/examples/DisabledPanels.json create mode 100644 public/generated/examples/DisabledSeparator.json create mode 100644 src/routes/DisabledPanelsRoute.tsx create mode 100644 src/routes/examples/DisabledPanel.tsx create mode 100644 src/routes/examples/DisabledPanelAndSeparator.tsx create mode 100644 src/routes/examples/DisabledPanels.tsx create mode 100644 src/routes/examples/DisabledSeparator.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 51dd5de3f..dad4a6f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 4.6.0 + +- [657](https://github.com/bvaughn/react-resizable-panels/pull/657): Allow `Panel` and `Separator` components to be disabled + ## 4.5.9 - [649](https://github.com/bvaughn/react-resizable-panels/pull/649): Optimization: Replace `useForceUpdate` with `useSyncExternalStore` to avoid side effect of swallowing "click" events in certain cases diff --git a/README.md b/README.md index eb6cca08c..f6160ae66 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,11 @@ Falls back to useId when not provided.

defaultSize

Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels.

+ + + + disabled +

When disabled, a panel cannot be resized either directly or indirectly (by resizing another panel).

@@ -333,6 +338,13 @@ Falls back to useId when not provided.

CSS properties.

ℹ️ Use the data-separator attribute for custom hover and active styles

⚠️ The following properties cannot be overridden: flex-grow, flex-shrink

+ + + + disabled +

When disabled, the separator cannot be used to resize its neighboring panels.

+

ℹ️ The panels may still be resized indirectly (while other panels are being resized). +To prevent a panel from being resized at all, it needs to also be disabled.

diff --git a/integrations/tests/tests/cursor.spec.tsx b/integrations/tests/tests/cursor.spec.tsx index 994ecc2fd..162bcc693 100644 --- a/integrations/tests/tests/cursor.spec.tsx +++ b/integrations/tests/tests/cursor.spec.tsx @@ -211,7 +211,7 @@ test.describe("cursor", () => { ).toBe("move"); }); - test("disabled", async ({ page: mainPage }) => { + test("disabled group cursor", async ({ page: mainPage }) => { const page = await goToUrl( mainPage, @@ -234,4 +234,56 @@ test.describe("cursor", () => { await page.evaluate(() => getComputedStyle(document.body).cursor) ).toBe("auto"); }); + + test("disabled panel(s)", async ({ page: mainPage }) => { + const page = await goToUrl( + mainPage, + + + + + ); + + const hitAreaBox = await calculateHitArea(page, ["left", "right"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + }); + + test("disabled separator", async ({ page: mainPage }) => { + const page = await goToUrl( + mainPage, + + + + + + ); + + const separator = page.getByTestId("separator"); + const boundingBox = (await separator.boundingBox())!; + const x = boundingBox.x + boundingBox.width / 2; + const y = boundingBox.y + boundingBox.height / 2; + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate( + () => + getComputedStyle(document.querySelector('[role="separator"]')!).cursor + ) + ).toBe("not-allowed"); + }); }); diff --git a/integrations/tests/tests/keyboard-interactions.spec.tsx b/integrations/tests/tests/keyboard-interactions.spec.tsx index 37b0f0d35..84b039b86 100644 --- a/integrations/tests/tests/keyboard-interactions.spec.tsx +++ b/integrations/tests/tests/keyboard-interactions.spec.tsx @@ -313,4 +313,21 @@ test.describe("keyboard interactions: window splitter api", () => { await assertLayoutChangeCounts(mainPage, 1); await expect(mainPage.getByText('"left": 30')).toBeVisible(); }); + + test("should not allow disabled separators to be focused", async ({ + page: mainPage + }) => { + const page = await goToUrl( + mainPage, + + + + + + ); + + const separator = page.getByTestId("separator"); + await expect(separator).toHaveAttribute("aria-disabled", "true"); + await expect(separator).not.toHaveAttribute("tabIndex"); + }); }); diff --git a/integrations/tests/tests/pointer-interactions.spec.tsx b/integrations/tests/tests/pointer-interactions.spec.tsx index 14e7837c5..a850c7c32 100644 --- a/integrations/tests/tests/pointer-interactions.spec.tsx +++ b/integrations/tests/tests/pointer-interactions.spec.tsx @@ -570,4 +570,46 @@ test.describe("pointer interactions", () => { await page.mouse.up(); await expect(panel).toContainText("click:1"); }); + + test("should not allow a disabled separator to be used to resize a panels", async ({ + page: mainPage + }) => { + const page = await goToUrl( + mainPage, + + + + + + ); + + await assertLayoutChangeCounts(mainPage, 1); + await expect(mainPage.getByText('"left": 50')).toBeVisible(); + + await resizeHelper(page, ["left", "right"], 100, 0); + + await assertLayoutChangeCounts(mainPage, 1); + await expect(mainPage.getByText('"left": 50')).toBeVisible(); + }); + + test("should not allow a disabled panel to be resized", async ({ + page: mainPage + }) => { + const page = await goToUrl( + mainPage, + + + + + + ); + + await assertLayoutChangeCounts(mainPage, 1); + await expect(mainPage.getByText('"left": 50')).toBeVisible(); + + await resizeHelper(page, ["left", "right"], 100, 0); + + await assertLayoutChangeCounts(mainPage, 1); + await expect(mainPage.getByText('"left": 50')).toBeVisible(); + }); }); diff --git a/lib/components/panel/Panel.tsx b/lib/components/panel/Panel.tsx index 2dd972050..aba4497a3 100644 --- a/lib/components/panel/Panel.tsx +++ b/lib/components/panel/Panel.tsx @@ -1,14 +1,14 @@ "use client"; import { useRef, useSyncExternalStore, type CSSProperties } from "react"; +import { eventEmitter } from "../../global/mutableState"; import { useId } from "../../hooks/useId"; import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; import { useMergedRefs } from "../../hooks/useMergedRefs"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useGroupContext } from "../group/useGroupContext"; -import type { PanelProps, PanelSize } from "./types"; +import type { PanelProps, PanelSize, RegisteredPanel } from "./types"; import { usePanelImperativeHandle } from "./usePanelImperativeHandle"; -import { eventEmitter } from "../../global/mutableState"; /** * A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior. @@ -41,6 +41,7 @@ export function Panel({ collapsedSize = "0%", collapsible = false, defaultSize, + disabled, elementRef: elementRefProp, id: idProp, maxSize = "100%", @@ -75,7 +76,7 @@ export function Panel({ useIsomorphicLayoutEffect(() => { const element = elementRef.current; if (element !== null) { - return registerPanel({ + const registeredPanel: RegisteredPanel = { element, id, idIsStable, @@ -88,15 +89,19 @@ export function Panel({ collapsedSize, collapsible, defaultSize, + disabled, maxSize, minSize } - }); + }; + + return registerPanel(registeredPanel); } }, [ collapsedSize, collapsible, defaultSize, + disabled, hasOnResize, id, idIsStable, @@ -126,6 +131,7 @@ export function Panel({ return (
; diff --git a/lib/components/separator/Separator.tsx b/lib/components/separator/Separator.tsx index a73756f69..dbfdd962b 100644 --- a/lib/components/separator/Separator.tsx +++ b/lib/components/separator/Separator.tsx @@ -28,6 +28,7 @@ import type { RegisteredSeparator, SeparatorProps } from "./types"; export function Separator({ children, className, + disabled, elementRef: elementRefProp, id: idProp, style, @@ -64,6 +65,7 @@ export function Separator({ const element = elementRef.current; if (element !== null) { const separator: RegisteredSeparator = { + disabled, element, id }; @@ -119,30 +121,32 @@ export function Separator({ unregisterSeparator(); }; } - }, [groupId, id, registerSeparator]); + }, [disabled, groupId, id, registerSeparator]); return (