diff --git a/CHANGELOG.md b/CHANGELOG.md index dad4a6f05..07173f19d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 4.6.1 + +- [658](https://github.com/bvaughn/react-resizable-panels/pull/658): Imperative `Panel` and `Group` APIs ignored `disabled` status when resizing panels; this is an explicit override of the _disabled_ state and is required to support conditionally disabled groups. +- [658](https://github.com/bvaughn/react-resizable-panels/pull/658): `Separator` component does not set a `cursor: not-allowed` style if the parent `Group` has cursors disabled. + ## 4.6.0 - [657](https://github.com/bvaughn/react-resizable-panels/pull/657): Allow `Panel` and `Separator` components to be disabled diff --git a/integrations/tests/tests/cursor.spec.tsx b/integrations/tests/tests/cursor.spec.tsx index 162bcc693..1e45b4a0e 100644 --- a/integrations/tests/tests/cursor.spec.tsx +++ b/integrations/tests/tests/cursor.spec.tsx @@ -235,17 +235,137 @@ test.describe("cursor", () => { ).toBe("auto"); }); - test("disabled panel(s)", async ({ page: mainPage }) => { + test("all panels disabled", async ({ page: mainPage }) => { + const states = [ + [true, true], + [true, false], + [false, true] + ] satisfies [boolean, boolean][]; + + for (const [leftDisabled, rightDisabled] of states) { + 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("all but one panels disabled", async ({ page: mainPage }) => { + const states = [ + [true, true, false], + [false, true, true], + [true, false, true] + ] satisfies [boolean, boolean, boolean][]; + + for (const [leftDisabled, centerDisabled, rightDisabled] of states) { + const page = await goToUrl( + mainPage, + + + + + + ); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + + { + const hitAreaBox = await calculateHitArea(page, ["left", "center"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + } + + { + const hitAreaBox = await calculateHitArea(page, ["center", "right"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + } + } + }); + + test("only some panels disabled", async ({ page: mainPage }) => { + const states = [ + [true, false, false], + [false, true, false], + [false, false, true] + ] satisfies [boolean, boolean, boolean][]; + + for (const [leftDisabled, centerDisabled, rightDisabled] of states) { + const page = await goToUrl( + mainPage, + + + + + + ); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + + if (!leftDisabled) { + const hitAreaBox = await calculateHitArea(page, ["left", "center"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("ew-resize"); + } + + if (!rightDisabled) { + const hitAreaBox = await calculateHitArea(page, ["center", "right"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("ew-resize"); + } + } + }); + + test("disabled separator", async ({ page: mainPage }) => { const page = await goToUrl( mainPage, - + + ); - const hitAreaBox = await calculateHitArea(page, ["left", "right"]); - const { x, y } = getCenterCoordinates(hitAreaBox); + 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) @@ -253,15 +373,51 @@ test.describe("cursor", () => { await page.mouse.move(x, y, moveConfig); + expect( + await page.evaluate( + () => + getComputedStyle(document.querySelector('[role="separator"]')!).cursor + ) + ).toBe("not-allowed"); + }); + + test("disabled separator within a disabled group", 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"); }); - test("disabled separator", async ({ page: mainPage }) => { + test("disabled separator within a cursor-disabled group", async ({ + page: mainPage + }) => { const page = await goToUrl( mainPage, - + @@ -284,6 +440,82 @@ test.describe("cursor", () => { () => getComputedStyle(document.querySelector('[role="separator"]')!).cursor ) - ).toBe("not-allowed"); + ).toBe("auto"); + }); + + test("should not show a cursor at the panel's edge if all panels before or after are disabled", async ({ + page: mainPage + }) => { + { + // All before + const page = await goToUrl( + mainPage, + + + + + + ); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + + { + const hitAreaBox = await calculateHitArea(page, ["left", "center"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + } + + { + const hitAreaBox = await calculateHitArea(page, ["center", "right"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("ew-resize"); + } + } + + { + // All before + const page = await goToUrl( + mainPage, + + + + + + ); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + + { + const hitAreaBox = await calculateHitArea(page, ["left", "center"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("ew-resize"); + } + + { + const hitAreaBox = await calculateHitArea(page, ["center", "right"]); + const { x, y } = getCenterCoordinates(hitAreaBox); + await page.mouse.move(x, y, moveConfig); + + expect( + await page.evaluate(() => getComputedStyle(document.body).cursor) + ).toBe("auto"); + } + } }); }); diff --git a/integrations/tests/tests/pointer-interactions.spec.tsx b/integrations/tests/tests/pointer-interactions.spec.tsx index a850c7c32..8ff0d1425 100644 --- a/integrations/tests/tests/pointer-interactions.spec.tsx +++ b/integrations/tests/tests/pointer-interactions.spec.tsx @@ -612,4 +612,50 @@ test.describe("pointer interactions", () => { await assertLayoutChangeCounts(mainPage, 1); await expect(mainPage.getByText('"left": 50')).toBeVisible(); }); + + test("should allow a disabled panel's border to resize another panel indirectly", async ({ + page: mainPage + }) => { + const page = await goToUrl( + mainPage, + + + + + + ); + + await assertLayoutChangeCounts(mainPage, 1); + await expect(mainPage.getByText('"left": 33')).toBeVisible(); + await expect(mainPage.getByText('"center": 33')).toBeVisible(); + await expect(mainPage.getByText('"right": 33')).toBeVisible(); + + await resizeHelper(page, ["left", "center"], 100, 0); + + await assertLayoutChangeCounts(mainPage, 2); + await expect(mainPage.getByText('"left": 43')).toBeVisible(); + await expect(mainPage.getByText('"center": 33')).toBeVisible(); + await expect(mainPage.getByText('"right": 23')).toBeVisible(); + + await resizeHelper(page, ["center", "right"], -200, 0); + + await assertLayoutChangeCounts(mainPage, 3); + await expect(mainPage.getByText('"left": 23')).toBeVisible(); + await expect(mainPage.getByText('"center": 33')).toBeVisible(); + await expect(mainPage.getByText('"right": 43')).toBeVisible(); + + await resizeHelper(page, ["center", "right"], 200, 0); + + await assertLayoutChangeCounts(mainPage, 4); + await expect(mainPage.getByText('"left": 43')).toBeVisible(); + await expect(mainPage.getByText('"center": 33')).toBeVisible(); + await expect(mainPage.getByText('"right": 23')).toBeVisible(); + + await resizeHelper(page, ["center", "right"], -100, 0); + + await assertLayoutChangeCounts(mainPage, 5); + await expect(mainPage.getByText('"left": 33')).toBeVisible(); + await expect(mainPage.getByText('"center": 33')).toBeVisible(); + await expect(mainPage.getByText('"right": 33')).toBeVisible(); + }); }); diff --git a/lib/components/group/Group.test.tsx b/lib/components/group/Group.test.tsx index b4b084f0e..c8fa3aee1 100644 --- a/lib/components/group/Group.test.tsx +++ b/lib/components/group/Group.test.tsx @@ -311,6 +311,7 @@ describe("Group", () => { render(); }); }); + describe("onLayoutChange and onLayoutChanged", () => { beforeEach(() => { setElementBoundsFunction((element) => { diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx index e3df0fcda..6cb833ce9 100644 --- a/lib/components/group/Group.tsx +++ b/lib/components/group/Group.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, type CSSProperties } from "react"; +import { calculatePanelConstraints } from "../../global/dom/calculatePanelConstraints"; import { mountGroup } from "../../global/mountGroup"; import { eventEmitter, read } from "../../global/mutableState"; import { layoutsEqual } from "../../global/utils/layoutsEqual"; @@ -139,8 +140,16 @@ export function Group({ } ); + const stableProps = useStableObject({ + defaultLayout, + disableCursor + }); + const context = useMemo( () => ({ + get disableCursor() { + return !!stableProps.disableCursor; + }, getPanelStyles, id, orientation, @@ -177,16 +186,39 @@ export function Group({ forceUpdate(); }; + }, + togglePanelDisabled: (panelId: string, disabled: boolean) => { + const inMemoryValues = inMemoryValuesRef.current; + const panel = inMemoryValues.panels.find( + (current) => current.id === panelId + ); + if (panel) { + panel.panelConstraints.disabled = disabled; + } + + const { mountedGroups } = read(); + for (const group of mountedGroups.keys()) { + if (group.id === id) { + const match = mountedGroups.get(group); + if (match) { + match.derivedPanelConstraints = calculatePanelConstraints(group); + } + } + } + }, + toggleSeparatorDisabled: (separatorId: string, disabled: boolean) => { + const inMemoryValues = inMemoryValuesRef.current; + const separator = inMemoryValues.separators.find( + (current) => current.id === separatorId + ); + if (separator) { + separator.disabled = disabled; + } } }), - [getPanelStyles, id, forceUpdate, orientation] + [getPanelStyles, id, forceUpdate, orientation, stableProps] ); - const stableProps = useStableObject({ - defaultLayout, - disableCursor - }); - const registeredGroupRef = useRef(null); // Register Group and child Panels/Separators with global state diff --git a/lib/components/group/types.ts b/lib/components/group/types.ts index 288702bc9..0767d750b 100644 --- a/lib/components/group/types.ts +++ b/lib/components/group/types.ts @@ -48,11 +48,14 @@ export type RegisteredGroup = { }; export type GroupContextType = { + disableCursor: boolean; getPanelStyles: (groupId: string, panelId: string) => CSSProperties; id: string; orientation: Orientation; registerPanel: (panel: RegisteredPanel) => () => void; registerSeparator: (separator: RegisteredSeparator) => () => void; + togglePanelDisabled: (id: string, disabled: boolean) => void; + toggleSeparatorDisabled: (id: string, disabled: boolean) => void; }; /** diff --git a/lib/components/panel/Panel.test.tsx b/lib/components/panel/Panel.test.tsx index 154e80df9..ec7a2c5a7 100644 --- a/lib/components/panel/Panel.test.tsx +++ b/lib/components/panel/Panel.test.tsx @@ -1,13 +1,116 @@ import { act, render } from "@testing-library/react"; import { createRef, Profiler } from "react"; import { describe, expect, test, vi } from "vitest"; +import { eventEmitter } from "../../global/mutableState"; import { assert } from "../../utils/assert"; -import { setDefaultElementBounds } from "../../utils/test/mockBoundingClientRect"; +import { + setDefaultElementBounds, + setElementBoundsFunction +} from "../../utils/test/mockBoundingClientRect"; import { Group } from "../group/Group"; import type { GroupImperativeHandle } from "../group/types"; +import { Separator } from "../separator/Separator"; import { Panel } from "./Panel"; +import { moveSeparator } from "../../global/test/moveSeparator"; describe("Panel", () => { + describe("disabled prop", () => { + test("changes to disabled prop should not cause the Panel to remount", () => { + const onMountedGroupsChange = vi.fn(); + const removeListener = eventEmitter.addListener( + "mountedGroupsChange", + onMountedGroupsChange + ); + + const { rerender } = render( + + + + + ); + expect(onMountedGroupsChange).toHaveBeenCalled(); + + onMountedGroupsChange.mockReset(); + + rerender( + + + + + ); + expect(onMountedGroupsChange).not.toHaveBeenCalled(); + + removeListener(); + }); + + test("changes to this prop should update Panel behavior", async () => { + setElementBoundsFunction((element) => { + switch (element.id) { + case "left": { + return new DOMRect(0, 0, 50, 50); + } + case "separator": { + return new DOMRect(50, 0, 10, 50); + } + case "right": { + return new DOMRect(60, 0, 50, 50); + } + } + }); + + const onLayoutChange = vi.fn(); + const onLayoutChanged = vi.fn(); + + const { rerender } = render( + + + + + + ); + + onLayoutChange.mockReset(); + onLayoutChanged.mockReset(); + + rerender( + + + + + + ); + + // Resize attempts should be ignored because the Panel is disabled + await moveSeparator(25); + expect(onLayoutChange).not.toHaveBeenCalled(); + expect(onLayoutChanged).not.toHaveBeenCalled(); + onLayoutChange.mockReset(); + onLayoutChanged.mockReset(); + + rerender( + + + + + + ); + + // Resize attempts should work now that the Panel has been re-enabled + await moveSeparator(25); + expect(onLayoutChange).toHaveBeenCalled(); + expect(onLayoutChanged).toHaveBeenCalled(); + }); + }); + test("should throw if rendered outside of a Group", () => { expect(() => render()).toThrow( "Group Context not found; did you render a Panel or Separator outside of a Group?" diff --git a/lib/components/panel/Panel.tsx b/lib/components/panel/Panel.tsx index aba4497a3..1b68d20f7 100644 --- a/lib/components/panel/Panel.tsx +++ b/lib/components/panel/Panel.tsx @@ -1,6 +1,11 @@ "use client"; -import { useRef, useSyncExternalStore, type CSSProperties } from "react"; +import { + useEffect, + useRef, + useSyncExternalStore, + type CSSProperties +} from "react"; import { eventEmitter } from "../../global/mutableState"; import { useId } from "../../hooks/useId"; import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; @@ -9,6 +14,7 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { useGroupContext } from "../group/useGroupContext"; import type { PanelProps, PanelSize, RegisteredPanel } from "./types"; import { usePanelImperativeHandle } from "./usePanelImperativeHandle"; +import { useStableObject } from "../../hooks/useStableObject"; /** * A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior. @@ -55,11 +61,20 @@ export function Panel({ const id = useId(idProp); + const stableProps = useStableObject({ + disabled + }); + const elementRef = useRef(null); const mergedRef = useMergedRefs(elementRef, elementRefProp); - const { getPanelStyles, id: groupId, registerPanel } = useGroupContext(); + const { + getPanelStyles, + id: groupId, + registerPanel, + togglePanelDisabled + } = useGroupContext(); const hasOnResize = onResizeUnstable !== null; const onResizeStable = useStableCallback( @@ -89,7 +104,7 @@ export function Panel({ collapsedSize, collapsible, defaultSize, - disabled, + disabled: stableProps.disabled, maxSize, minSize } @@ -101,16 +116,21 @@ export function Panel({ collapsedSize, collapsible, defaultSize, - disabled, hasOnResize, id, idIsStable, maxSize, minSize, onResizeStable, - registerPanel + registerPanel, + stableProps ]); + // Not all props require re-registering the panel; + useEffect(() => { + togglePanelDisabled(id, !!disabled); + }, [disabled, id, togglePanelDisabled]); + usePanelImperativeHandle(id, panelRef); const panelStylesString = useSyncExternalStore( @@ -131,7 +151,7 @@ export function Panel({ return (
{ + describe("disabled prop", () => { + test("changes to disabled prop should not cause the Separator to remount", () => { + const onMountedGroupsChange = vi.fn(); + const removeListener = eventEmitter.addListener( + "mountedGroupsChange", + onMountedGroupsChange + ); + + const { rerender } = render( + + + + + + + + ); + expect(onMountedGroupsChange).toHaveBeenCalled(); + + onMountedGroupsChange.mockReset(); + + rerender( + + + + + + + + ); + expect(onMountedGroupsChange).not.toHaveBeenCalled(); + + removeListener(); + }); + + test("changes to this prop should update Separator behavior", async () => { + setElementBoundsFunction((element) => { + switch (element.id) { + case "left": { + return new DOMRect(0, 0, 50, 50); + } + case "separator": { + return new DOMRect(50, 0, 10, 50); + } + case "right": { + return new DOMRect(60, 0, 50, 50); + } + } + }); + + const onLayoutChange = vi.fn(); + const onLayoutChanged = vi.fn(); + + const { rerender } = render( + + + + + + ); + + onLayoutChange.mockReset(); + onLayoutChanged.mockReset(); + + rerender( + + + + + + ); + + // Resize attempts should be ignored because the Panel is disabled + await moveSeparator(25); + expect(onLayoutChange).not.toHaveBeenCalled(); + expect(onLayoutChanged).not.toHaveBeenCalled(); + onLayoutChange.mockReset(); + onLayoutChanged.mockReset(); + + rerender( + + + + + + ); + + // Resize attempts should work now that the Panel has been re-enabled + await moveSeparator(25); + expect(onLayoutChange).toHaveBeenCalled(); + expect(onLayoutChanged).toHaveBeenCalled(); + }); + }); + describe("HTML attributes", () => { test("should expose id and data-testid", () => { render( diff --git a/lib/components/separator/Separator.tsx b/lib/components/separator/Separator.tsx index dbfdd962b..6208555e6 100644 --- a/lib/components/separator/Separator.tsx +++ b/lib/components/separator/Separator.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { eventEmitter } from "../../global/mutableState"; import type { InteractionState } from "../../global/types"; import { calculateSeparatorAriaValues } from "../../global/utils/calculateSeparatorAriaValues"; @@ -9,6 +9,8 @@ import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect import { useMergedRefs } from "../../hooks/useMergedRefs"; import { useGroupContext } from "../group/useGroupContext"; import type { RegisteredSeparator, SeparatorProps } from "./types"; +import type { Properties } from "csstype"; +import { useStableObject } from "../../hooks/useStableObject"; /** * Separators are not _required_ but they are _recommended_ as they improve keyboard accessibility. @@ -36,6 +38,10 @@ export function Separator({ }: SeparatorProps) { const id = useId(idProp); + const stableProps = useStableObject({ + disabled + }); + const [aria, setAria] = useState<{ valueControls?: string | undefined; valueMin?: number | undefined; @@ -51,9 +57,11 @@ export function Separator({ const mergedRef = useMergedRefs(elementRef, elementRefProp); const { + disableCursor, id: groupId, orientation: groupOrientation, - registerSeparator + registerSeparator, + toggleSeparatorDisabled } = useGroupContext(); const orientation = @@ -65,7 +73,7 @@ export function Separator({ const element = elementRef.current; if (element !== null) { const separator: RegisteredSeparator = { - disabled, + disabled: stableProps.disabled, element, id }; @@ -121,13 +129,23 @@ export function Separator({ unregisterSeparator(); }; } - }, [disabled, groupId, id, registerSeparator]); + }, [groupId, id, registerSeparator, stableProps]); + + // Not all props require re-registering the separator; + useEffect(() => { + toggleSeparatorDisabled(id, !!disabled); + }, [disabled, id, toggleSeparatorDisabled]); + + let cursor: Properties["cursor"] = undefined; + if (disabled && !disableCursor) { + cursor = "not-allowed"; + } return (
{ return JSON.stringify( hitRegions.map((region) => ({ - disabled: region.disabled || undefined, panels: region.panels.map((panel) => panel.id), rect: `${region.rect.x},${region.rect.y} ${region.rect.width} x ${region.rect.height}`, separator: region.separator?.id @@ -276,98 +275,67 @@ describe("calculateHitRegions", () => { group.addPanel(new DOMRect(55, 0, 50, 50), "right"); expect(serialize(group)).toMatchInlineSnapshot(` - "[ - { - "disabled": true, - "panels": [ - "group-1-left", - "group-1-right" - ], - "rect": "47.5,0 10 x 50", - "separator": "group-1-separator" - } - ]" + "[]" `); }); - test("should disable a hit region if one or both panels are disabled and there is no explicit separator", () => { - { - const group = mockGroup(new DOMRect(0, 0, 100, 50)); - group.addPanel(new DOMRect(0, 0, 50, 50), "left", { disabled: true }); - group.addPanel(new DOMRect(50, 0, 50, 50), "right"); + test("should not disable a hit region if one or both panels are disabled but there is an enabled separator", () => { + const group = mockGroup(new DOMRect(0, 0, 100, 50)); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); + group.addSeparator(new DOMRect(50, 0, 5, 50), "separator"); + group.addPanel(new DOMRect(55, 0, 50, 50), "center", { disabled: true }); + group.addPanel(new DOMRect(105, 0, 50, 50), "right"); - expect(serialize(group)).toMatchInlineSnapshot(` + expect(serialize(group)).toMatchInlineSnapshot(` "[ { - "disabled": true, "panels": [ "group-1-left", + "group-1-center" + ], + "rect": "47.5,0 10 x 50", + "separator": "group-1-separator" + }, + { + "panels": [ + "group-1-center", "group-1-right" ], - "rect": "45,0 10 x 50" + "rect": "100,0 10 x 50" } ]" `); - } + }); + test("should disable all hit regions if there is one or fewer enabled panels", () => { { const group = mockGroup(new DOMRect(0, 0, 100, 50)); - group.addPanel(new DOMRect(0, 0, 50, 50), "left"); - group.addPanel(new DOMRect(50, 0, 50, 50), "right", { disabled: true }); + group.addPanel(new DOMRect(0, 0, 50, 50), "left", { disabled: true }); + group.addPanel(new DOMRect(50, 0, 50, 50), "right"); expect(serialize(group)).toMatchInlineSnapshot(` - "[ - { - "disabled": true, - "panels": [ - "group-2-left", - "group-2-right" - ], - "rect": "45,0 10 x 50" - } - ]" + "[]" `); } { const group = mockGroup(new DOMRect(0, 0, 100, 50)); - group.addPanel(new DOMRect(0, 0, 50, 50), "left", { disabled: true }); + group.addPanel(new DOMRect(0, 0, 50, 50), "left"); group.addPanel(new DOMRect(50, 0, 50, 50), "right", { disabled: true }); expect(serialize(group)).toMatchInlineSnapshot(` - "[ - { - "disabled": true, - "panels": [ - "group-3-left", - "group-3-right" - ], - "rect": "45,0 10 x 50" - } - ]" + "[]" `); } - }); - test("should not disable a hit region if one or both panels are disabled but there is an enabled separator", () => { { const group = mockGroup(new DOMRect(0, 0, 100, 50)); group.addPanel(new DOMRect(0, 0, 50, 50), "left", { disabled: true }); - group.addSeparator(new DOMRect(50, 0, 5, 50), "separator"); - group.addPanel(new DOMRect(55, 0, 50, 50), "right"); + group.addPanel(new DOMRect(50, 0, 50, 50), "right", { disabled: true }); expect(serialize(group)).toMatchInlineSnapshot(` - "[ - { - "panels": [ - "group-1-left", - "group-1-right" - ], - "rect": "47.5,0 10 x 50", - "separator": "group-1-separator" - } - ]" - `); + "[]" + `); } { @@ -377,16 +345,7 @@ describe("calculateHitRegions", () => { group.addPanel(new DOMRect(55, 0, 50, 50), "right", { disabled: true }); expect(serialize(group)).toMatchInlineSnapshot(` - "[ - { - "panels": [ - "group-2-left", - "group-2-right" - ], - "rect": "47.5,0 10 x 50", - "separator": "group-2-separator" - } - ]" + "[]" `); } @@ -394,20 +353,65 @@ describe("calculateHitRegions", () => { const group = mockGroup(new DOMRect(0, 0, 100, 50)); group.addPanel(new DOMRect(0, 0, 50, 50), "left", { disabled: true }); group.addSeparator(new DOMRect(50, 0, 5, 50), "separator"); - group.addPanel(new DOMRect(55, 0, 50, 50), "right", { disabled: true }); + group.addPanel(new DOMRect(55, 0, 50, 50), "right"); expect(serialize(group)).toMatchInlineSnapshot(` - "[ - { - "panels": [ - "group-3-left", - "group-3-right" - ], - "rect": "47.5,0 10 x 50", - "separator": "group-3-separator" - } - ]" + "[]" `); } }); + + test("should disable panel boundaries if there are no resizable panels before the current boundary", () => { + const group = mockGroup(new DOMRect(0, 0, 100, 50)); + group.addPanel(new DOMRect(0, 0, 25, 50), "a", { disabled: true }); + group.addPanel(new DOMRect(25, 0, 25, 50), "b"); + group.addPanel(new DOMRect(50, 0, 25, 50), "c", { disabled: true }); + group.addPanel(new DOMRect(75, 0, 25, 50), "d"); + + expect(serialize(group)).toMatchInlineSnapshot(` + "[ + { + "panels": [ + "group-1-b", + "group-1-c" + ], + "rect": "45,0 10 x 50" + }, + { + "panels": [ + "group-1-c", + "group-1-d" + ], + "rect": "70,0 10 x 50" + } + ]" + `); + }); + + test("should disable panel boundaries if there are no resizable panels after the current boundary", () => { + const group = mockGroup(new DOMRect(0, 0, 100, 50)); + group.addPanel(new DOMRect(0, 0, 25, 50), "a"); + group.addPanel(new DOMRect(25, 0, 25, 50), "b", { disabled: true }); + group.addPanel(new DOMRect(50, 0, 25, 50), "c"); + group.addPanel(new DOMRect(75, 0, 25, 50), "d", { disabled: true }); + + expect(serialize(group)).toMatchInlineSnapshot(` + "[ + { + "panels": [ + "group-1-a", + "group-1-b" + ], + "rect": "20,0 10 x 50" + }, + { + "panels": [ + "group-1-b", + "group-1-c" + ], + "rect": "45,0 10 x 50" + } + ]" + `); + }); }); diff --git a/lib/global/dom/calculateHitRegions.ts b/lib/global/dom/calculateHitRegions.ts index bc32a4e1f..74eb195f8 100644 --- a/lib/global/dom/calculateHitRegions.ts +++ b/lib/global/dom/calculateHitRegions.ts @@ -10,7 +10,6 @@ import { calculateAvailableGroupSize } from "./calculateAvailableGroupSize"; type PanelsTuple = [panel: RegisteredPanel, panel: RegisteredPanel]; export type HitRegion = { - disabled: boolean; group: RegisteredGroup; groupSize: number; panels: PanelsTuple; @@ -38,165 +37,197 @@ export function calculateHitRegions(group: RegisteredGroup) { const hitRegions: HitRegion[] = []; - let disabledPanel = false; let disabledSeparator = false; let hasInterleavedStaticContent = false; + let firstEnabledPanelIndex = -1; + let lastEnabledPanelIndex = -1; + let numEnabledPanels = 0; let prevPanel: RegisteredPanel | undefined = undefined; let pendingSeparators: RegisteredSeparator[] = []; - for (const childElement of sortedChildElements) { - if (childElement.hasAttribute("data-panel")) { - if (childElement.ariaDisabled !== null) { - disabledPanel = true; - } + { + let currentPanelIndex = -1; - const panelData = panels.find( - (current) => current.element === childElement - ); - if (panelData) { - if (prevPanel) { - const prevRect = prevPanel.element.getBoundingClientRect(); - const rect = childElement.getBoundingClientRect(); - - let pendingRectsOrSeparators: (DOMRect | RegisteredSeparator)[]; - - // If an explicit Separator has been rendered, always watch it - // Otherwise watch the entire space between the panels - // The one caveat is when there are non-interactive element(s) between panels, - // in which case we may need to watch individual panel edges - if (hasInterleavedStaticContent) { - const firstPanelEdgeRect = - orientation === "horizontal" - ? new DOMRect(prevRect.right, prevRect.top, 0, prevRect.height) - : new DOMRect( - prevRect.left, - prevRect.bottom, - prevRect.width, - 0 - ); - const secondPanelEdgeRect = - orientation === "horizontal" - ? new DOMRect(rect.left, rect.top, 0, rect.height) - : new DOMRect(rect.left, rect.top, rect.width, 0); - - switch (pendingSeparators.length) { - case 0: { - pendingRectsOrSeparators = [ - firstPanelEdgeRect, - secondPanelEdgeRect - ]; - break; - } - case 1: { - const separator = pendingSeparators[0]; - const closestRect = findClosestRect({ - orientation, - rects: [prevRect, rect], - targetRect: separator.element.getBoundingClientRect() - }); + for (const childElement of sortedChildElements) { + if (childElement.hasAttribute("data-panel")) { + currentPanelIndex++; - pendingRectsOrSeparators = [ - separator, - closestRect === prevRect - ? secondPanelEdgeRect - : firstPanelEdgeRect - ]; - break; - } - default: { - pendingRectsOrSeparators = pendingSeparators; - break; - } - } - } else { - if (pendingSeparators.length) { - pendingRectsOrSeparators = pendingSeparators; - } else { - pendingRectsOrSeparators = [ + if (childElement.ariaDisabled === null) { + numEnabledPanels++; + + if (firstEnabledPanelIndex === -1) { + firstEnabledPanelIndex = currentPanelIndex; + } + + lastEnabledPanelIndex = currentPanelIndex; + } + } + } + } + + // If all (or all but one) of the Panels are disabled, there can be no resize interactions. + if (numEnabledPanels > 1) { + let currentPanelIndex = -1; + + for (const childElement of sortedChildElements) { + if (childElement.hasAttribute("data-panel")) { + currentPanelIndex++; + + const panelData = panels.find( + (current) => current.element === childElement + ); + if (panelData) { + if (prevPanel) { + const prevRect = prevPanel.element.getBoundingClientRect(); + const rect = childElement.getBoundingClientRect(); + + let pendingRectsOrSeparators: (DOMRect | RegisteredSeparator)[]; + + // If an explicit Separator has been rendered, always watch it + // Otherwise watch the entire space between the panels + // The one caveat is when there are non-interactive element(s) between panels, + // in which case we may need to watch individual panel edges + if (hasInterleavedStaticContent) { + const firstPanelEdgeRect = orientation === "horizontal" ? new DOMRect( prevRect.right, - rect.top, - rect.left - prevRect.right, - rect.height + prevRect.top, + 0, + prevRect.height ) : new DOMRect( - rect.left, + prevRect.left, prevRect.bottom, - rect.width, - rect.top - prevRect.bottom - ) - ]; - } - } - - for (const rectOrSeparator of pendingRectsOrSeparators) { - let rect = - "width" in rectOrSeparator - ? rectOrSeparator - : rectOrSeparator.element.getBoundingClientRect(); - - const minHitTargetSize = isCoarsePointer() - ? group.resizeTargetMinimumSize.coarse - : group.resizeTargetMinimumSize.fine; - if (rect.width < minHitTargetSize) { - const delta = minHitTargetSize - rect.width; - rect = new DOMRect( - rect.x - delta / 2, - rect.y, - rect.width + delta, - rect.height - ); - } - if (rect.height < minHitTargetSize) { - const delta = minHitTargetSize - rect.height; - rect = new DOMRect( - rect.x, - rect.y - delta / 2, - rect.width, - rect.height + delta - ); + prevRect.width, + 0 + ); + const secondPanelEdgeRect = + orientation === "horizontal" + ? new DOMRect(rect.left, rect.top, 0, rect.height) + : new DOMRect(rect.left, rect.top, rect.width, 0); + + switch (pendingSeparators.length) { + case 0: { + pendingRectsOrSeparators = [ + firstPanelEdgeRect, + secondPanelEdgeRect + ]; + break; + } + case 1: { + const separator = pendingSeparators[0]; + const closestRect = findClosestRect({ + orientation, + rects: [prevRect, rect], + targetRect: separator.element.getBoundingClientRect() + }); + + pendingRectsOrSeparators = [ + separator, + closestRect === prevRect + ? secondPanelEdgeRect + : firstPanelEdgeRect + ]; + break; + } + default: { + pendingRectsOrSeparators = pendingSeparators; + break; + } + } + } else { + if (pendingSeparators.length) { + pendingRectsOrSeparators = pendingSeparators; + } else { + pendingRectsOrSeparators = [ + orientation === "horizontal" + ? new DOMRect( + prevRect.right, + rect.top, + rect.left - prevRect.right, + rect.height + ) + : new DOMRect( + rect.left, + prevRect.bottom, + rect.width, + rect.top - prevRect.bottom + ) + ]; + } } - const hasSeparator = !("width" in rectOrSeparator); + for (const rectOrSeparator of pendingRectsOrSeparators) { + let rect = + "width" in rectOrSeparator + ? rectOrSeparator + : rectOrSeparator.element.getBoundingClientRect(); + + const minHitTargetSize = isCoarsePointer() + ? group.resizeTargetMinimumSize.coarse + : group.resizeTargetMinimumSize.fine; + if (rect.width < minHitTargetSize) { + const delta = minHitTargetSize - rect.width; + rect = new DOMRect( + rect.x - delta / 2, + rect.y, + rect.width + delta, + rect.height + ); + } + if (rect.height < minHitTargetSize) { + const delta = minHitTargetSize - rect.height; + rect = new DOMRect( + rect.x, + rect.y - delta / 2, + rect.width, + rect.height + delta + ); + } - hitRegions.push({ - disabled: disabledSeparator || (disabledPanel && !hasSeparator), - group, - groupSize: calculateAvailableGroupSize({ group }), - panels: [prevPanel, panelData], - separator: - "width" in rectOrSeparator ? undefined : rectOrSeparator, - rect - }); + const skip = + currentPanelIndex <= firstEnabledPanelIndex || + currentPanelIndex > lastEnabledPanelIndex; + + if (!disabledSeparator && !skip) { + hitRegions.push({ + group, + groupSize: calculateAvailableGroupSize({ group }), + panels: [prevPanel, panelData], + separator: + "width" in rectOrSeparator ? undefined : rectOrSeparator, + rect + }); + } - disabledPanel = false; - disabledSeparator = false; + disabledSeparator = false; + } } - } - hasInterleavedStaticContent = false; - prevPanel = panelData; - pendingSeparators = []; - } - } else if (childElement.hasAttribute("data-separator")) { - if (childElement.ariaDisabled !== null) { - disabledSeparator = true; - } + hasInterleavedStaticContent = false; + prevPanel = panelData; + pendingSeparators = []; + } + } else if (childElement.hasAttribute("data-separator")) { + if (childElement.ariaDisabled !== null) { + disabledSeparator = true; + } - const separatorData = separators.find( - (current) => current.element === childElement - ); - if (separatorData) { - // Separators will be included implicitly in the area between the previous and next panel - // It's important to track them though, to handle the scenario of non-interactive group content - pendingSeparators.push(separatorData); + const separatorData = separators.find( + (current) => current.element === childElement + ); + if (separatorData) { + // Separators will be included implicitly in the area between the previous and next panel + // It's important to track them though, to handle the scenario of non-interactive group content + pendingSeparators.push(separatorData); + } else { + prevPanel = undefined; + pendingSeparators = []; + } } else { - prevPanel = undefined; - pendingSeparators = []; + hasInterleavedStaticContent = true; } - } else { - hasInterleavedStaticContent = true; } } diff --git a/lib/global/utils/adjustLayoutByDelta.test.ts b/lib/global/utils/adjustLayoutByDelta.test.ts index 413b0b12d..34d501ca0 100644 --- a/lib/global/utils/adjustLayoutByDelta.test.ts +++ b/lib/global/utils/adjustLayoutByDelta.test.ts @@ -34,6 +34,7 @@ function c(partials: Partial[]) { collapsedSize: 0, collapsible: false, defaultSize: undefined, + disabled: current.disabled, maxSize: 100, minSize: 0, ...current, @@ -2475,5 +2476,26 @@ describe("adjustLayoutByDelta", () => { ).toEqual(l([25, 50, 25])); }); }); + + test("should be resizable via the imperative API", () => { + ( + [ + [-5, c([{ disabled: true }, {}]), l([45, 55])], + [5, c([{ disabled: true }, {}]), l([55, 45])], + [-5, c([{}, { disabled: true }]), l([45, 55])], + [5, c([{}, { disabled: true }]), l([55, 45])] + ] satisfies [number, PanelConstraints[], Layout][] + ).forEach(([delta, panelConstraints, expectedLayout]) => { + expect( + adjustLayoutByDelta({ + delta, + initialLayout: l([50, 50]), + panelConstraints, + prevLayout: l([50, 50]), + trigger: "imperative-api" + }) + ).toEqual(expectedLayout); + }); + }); }); }); diff --git a/lib/global/utils/adjustLayoutByDelta.ts b/lib/global/utils/adjustLayoutByDelta.ts index 56a9dc4bc..6fa260f33 100644 --- a/lib/global/utils/adjustLayoutByDelta.ts +++ b/lib/global/utils/adjustLayoutByDelta.ts @@ -26,6 +26,8 @@ export function adjustLayoutByDelta({ return initialLayoutProp; } + const overrideDisabledPanels = trigger === "imperative-api"; + const initialLayout = Object.values(initialLayoutProp); const prevLayout = Object.values(prevLayoutProp); const nextLayout = [...initialLayout]; @@ -210,6 +212,7 @@ export function adjustLayoutByDelta({ ); const maxSafeSize = validatePanelSize({ + overrideDisabledPanels, panelConstraints: panelConstraintsArray[index], prevSize, size: 100 @@ -248,6 +251,7 @@ export function adjustLayoutByDelta({ const unsafeSize = prevSize - deltaRemaining; const safeSize = validatePanelSize({ + overrideDisabledPanels, panelConstraints: panelConstraintsArray[index], prevSize, size: unsafeSize @@ -301,6 +305,7 @@ export function adjustLayoutByDelta({ const unsafeSize = prevSize + deltaApplied; const safeSize = validatePanelSize({ + overrideDisabledPanels, panelConstraints: panelConstraintsArray[pivotIndex], prevSize, size: unsafeSize @@ -324,6 +329,7 @@ export function adjustLayoutByDelta({ const unsafeSize = prevSize + deltaRemaining; const safeSize = validatePanelSize({ + overrideDisabledPanels, panelConstraints: panelConstraintsArray[index], prevSize, size: unsafeSize diff --git a/lib/global/utils/calculateDefaultLayout.test.ts b/lib/global/utils/calculateDefaultLayout.test.ts index 30974e560..3259c92cf 100644 --- a/lib/global/utils/calculateDefaultLayout.test.ts +++ b/lib/global/utils/calculateDefaultLayout.test.ts @@ -8,6 +8,7 @@ const c = ( collapsedSize: 0, collapsible: false, defaultSize: undefined, + disabled: undefined, maxSize: 100, minSize: 0, ...partial diff --git a/lib/global/utils/calculateSeparatorAriaValues.test.ts b/lib/global/utils/calculateSeparatorAriaValues.test.ts index 866e088a8..7a0c0f7d2 100644 --- a/lib/global/utils/calculateSeparatorAriaValues.test.ts +++ b/lib/global/utils/calculateSeparatorAriaValues.test.ts @@ -6,6 +6,7 @@ const DEFAULT_PANEL_CONSTRAINTS = { collapsedSize: 0, collapsible: false, defaultSize: undefined, + disabled: undefined, minSize: 0, maxSize: 100 }; @@ -17,6 +18,7 @@ describe("calculateSeparatorAriaValues", () => { ...DEFAULT_PANEL_CONSTRAINTS, collapsedSize: 5, collapsible: true, + disabled: undefined, maxSize: 70, minSize: 20, panelId: "left" diff --git a/lib/global/utils/findClosestHitRegion.ts b/lib/global/utils/findClosestHitRegion.ts index ee308276d..5ec66196b 100644 --- a/lib/global/utils/findClosestHitRegion.ts +++ b/lib/global/utils/findClosestHitRegion.ts @@ -15,10 +15,6 @@ export function findClosestHitRegion( }; for (const hitRegion of hitRegions) { - if (hitRegion.disabled) { - continue; - } - const data = getDistanceBetweenPointAndRect(point, hitRegion.rect); switch (orientation) { case "horizontal": { diff --git a/lib/global/utils/getImperativeGroupMethods.test.ts b/lib/global/utils/getImperativeGroupMethods.test.ts index 580b1c611..b1e9853cf 100644 --- a/lib/global/utils/getImperativeGroupMethods.test.ts +++ b/lib/global/utils/getImperativeGroupMethods.test.ts @@ -155,5 +155,32 @@ describe("getImperativeGroupMethods", () => { "A-2": 70 }); }); + + test("allows disabled panels to be resized", () => { + const { api } = init([ + { defaultSize: 200, disabled: true, minSize: 100 }, + { defaultSize: 800, disabled: true } + ]); + + expect(api.getLayout()).toMatchInlineSnapshot(` + { + "A-1": 20, + "A-2": 80, + } + `); + + api.setLayout({ + "A-1": 30, + "A-2": 70 + }); + + expect(onMountedGroupsChange).toHaveBeenCalledTimes(1); + expect(api.getLayout()).toMatchInlineSnapshot(` + { + "A-1": 30, + "A-2": 70, + } + `); + }); }); }); diff --git a/lib/global/utils/getImperativePanelMethods.test.ts b/lib/global/utils/getImperativePanelMethods.test.ts index 08c815e3e..5a59b806d 100644 --- a/lib/global/utils/getImperativePanelMethods.test.ts +++ b/lib/global/utils/getImperativePanelMethods.test.ts @@ -56,7 +56,14 @@ describe("getImperativePanelMethods", () => { const group = mockGroup(bounds, "horizontal", "A"); panelConstraints.forEach( - ({ collapsedSize, collapsible, defaultSize, maxSize, minSize }) => { + ({ + collapsedSize, + collapsible, + defaultSize, + disabled, + maxSize, + minSize + }) => { group.addPanel( new DOMRect( 0, @@ -73,6 +80,7 @@ describe("getImperativePanelMethods", () => { collapsible, defaultSize: defaultSize !== undefined ? `${defaultSize}%` : undefined, + disabled, maxSize: maxSize !== undefined ? `${maxSize}%` : undefined, minSize: minSize !== undefined ? `${minSize}%` : 0 } @@ -152,6 +160,21 @@ describe("getImperativePanelMethods", () => { expect(onLayoutChange).toHaveBeenCalledTimes(1); expect(onLayoutChange).toHaveBeenCalledWith([0, 100]); }); + + test("allows disabled panel to be collapsed", () => { + const { panelApis } = init([ + { + collapsible: true, + defaultSize: 50, + disabled: true + }, + {} + ]); + panelApis[0].collapse(); + + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange).toHaveBeenCalledWith([0, 100]); + }); }); describe("expand", () => { @@ -213,6 +236,17 @@ describe("getImperativePanelMethods", () => { expect(onLayoutChange).toHaveBeenCalledTimes(1); expect(onLayoutChange).toHaveBeenCalledWith([1, 99]); }); + + test("allows disabled panel to be expanded", () => { + const { panelApis } = init([ + { defaultSize: 0, collapsible: true, disabled: true, minSize: 10 }, + {} + ]); + + panelApis[0].expand(); + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange).toHaveBeenCalledWith([10, 90]); + }); }); describe("getSize", () => { @@ -282,6 +316,18 @@ describe("getImperativePanelMethods", () => { expect(onLayoutChange).toHaveBeenCalledTimes(1); expect(onLayoutChange).toHaveBeenCalledWith([10, 90]); }); + + test("allows disabled panel to be resized", () => { + const { panelApis } = init([ + { defaultSize: 25, disabled: true, minSize: 10 }, + {} + ]); + + panelApis[0].resize(0); + + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange).toHaveBeenCalledWith([10, 90]); + }); }); }); }); diff --git a/lib/global/utils/validatePanelGroupLayout.test.ts b/lib/global/utils/validatePanelGroupLayout.test.ts index 234567a35..cc382c225 100644 --- a/lib/global/utils/validatePanelGroupLayout.test.ts +++ b/lib/global/utils/validatePanelGroupLayout.test.ts @@ -11,6 +11,7 @@ function c(partials: Partial[]) { collapsedSize: 0, collapsible: false, defaultSize: undefined, + disabled: undefined, maxSize: 100, minSize: 0, ...current, diff --git a/lib/global/utils/validatePanelGroupLayout.ts b/lib/global/utils/validatePanelGroupLayout.ts index 0452dcf94..ac058dba3 100644 --- a/lib/global/utils/validatePanelGroupLayout.ts +++ b/lib/global/utils/validatePanelGroupLayout.ts @@ -50,6 +50,7 @@ export function validatePanelGroupLayout({ assert(unsafeSize != null, `No layout data found for index ${index}`); const safeSize = validatePanelSize({ + overrideDisabledPanels: true, panelConstraints: panelConstraints[index], prevSize, size: unsafeSize @@ -70,6 +71,7 @@ export function validatePanelGroupLayout({ assert(prevSize != null, `No layout data found for index ${index}`); const unsafeSize = prevSize + remainingSize; const safeSize = validatePanelSize({ + overrideDisabledPanels: true, panelConstraints: panelConstraints[index], prevSize, size: unsafeSize diff --git a/lib/global/utils/validatePanelSize.ts b/lib/global/utils/validatePanelSize.ts index c2aaa24df..eb32fbc11 100644 --- a/lib/global/utils/validatePanelSize.ts +++ b/lib/global/utils/validatePanelSize.ts @@ -4,10 +4,12 @@ import { formatLayoutNumber } from "./formatLayoutNumber"; // Panel size must be in percentages; pixel values should be pre-converted export function validatePanelSize({ + overrideDisabledPanels, panelConstraints, prevSize, size }: { + overrideDisabledPanels?: boolean; panelConstraints: PanelConstraints; prevSize: number; size: number; @@ -20,7 +22,7 @@ export function validatePanelSize({ minSize = 0 } = panelConstraints; - if (disabled) { + if (disabled && !overrideDisabledPanels) { return prevSize; } diff --git a/package.json b/package.json index 246e8cc9d..dc3518877 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-resizable-panels", - "version": "4.6.0", + "version": "4.6.1", "type": "module", "author": "Brian Vaughn (https://github.com/bvaughn/)", "contributors": [ diff --git a/src/App.tsx b/src/App.tsx index daef2ab83..71c140684 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -227,7 +227,8 @@ const commonQuestions: CommonQuestion[] = [ Putting the defaultSize on the conditional{" "} - Panel is an easy way to avoid invalid layout constraints. + Panel is the easiest way to avoid invalid layout + constraints in this type of scenario. ) diff --git a/src/components/accordion/AccordionGroup.tsx b/src/components/accordion/AccordionGroup.tsx new file mode 100644 index 000000000..b775ab286 --- /dev/null +++ b/src/components/accordion/AccordionGroup.tsx @@ -0,0 +1,11 @@ +import { type PropsWithChildren } from "react"; +import { Group, Panel } from "react-resizable-panels"; + +export function AccordionGroup({ children }: PropsWithChildren) { + return ( + + {children} + + + ); +} diff --git a/src/components/accordion/AccordionPanel.tsx b/src/components/accordion/AccordionPanel.tsx new file mode 100644 index 000000000..4c850cfd9 --- /dev/null +++ b/src/components/accordion/AccordionPanel.tsx @@ -0,0 +1,35 @@ +import { useState, type PropsWithChildren } from "react"; +import { Panel } from "react-resizable-panels"; + +export function AccordionPanel({ + children, + index +}: PropsWithChildren<{ + index: number; +}>) { + const [isCollapsed, setIsCollapsed] = useState(false); + + return ( + +
{ + setIsCollapsed(!isCollapsed); + }} + > + {index} +
+ {isCollapsed || ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/routes/DisabledPanelsRoute.tsx b/src/routes/DisabledPanelsRoute.tsx index 4c7541341..941febf39 100644 --- a/src/routes/DisabledPanelsRoute.tsx +++ b/src/routes/DisabledPanelsRoute.tsx @@ -1,8 +1,9 @@ import { Box, Callout, Code, Header } from "react-lib-tools"; -import { html as DisabledSeparatorHTML } from "../../public/generated/examples/DisabledSeparator.json"; -import { html as DisabledPanelsHTML } from "../../public/generated/examples/DisabledPanels.json"; import { html as DisabledPanelHTML } from "../../public/generated/examples/DisabledPanel.json"; import { html as DisabledPanelAndSeparatorHTML } from "../../public/generated/examples/DisabledPanelAndSeparator.json"; +import { html as DisabledPanelsHTML } from "../../public/generated/examples/DisabledPanels.json"; +import { html as DisabledSeparatorHTML } from "../../public/generated/examples/DisabledSeparator.json"; +import { Link } from "../components/Link"; import { Group } from "../components/styled-panels/Group"; import { Panel } from "../components/styled-panels/Panel"; import { Separator } from "../components/styled-panels/Separator"; @@ -22,9 +23,9 @@ export default function DisabledPanelsRoute() {
- left + left - right + right
The same applies to disabling one or both panels when there is no @@ -36,8 +37,12 @@ export default function DisabledPanelsRoute() { - left - right + + left + + + right +
In groups with three or more panels, disabling a separator does not @@ -46,11 +51,11 @@ export default function DisabledPanelsRoute() { resized as well.
- left + left - center + center - right + right
Disabling a panel prevents it from being resized, though its separator @@ -58,11 +63,24 @@ export default function DisabledPanelsRoute() {
- left + left - center (disabled) + + center (disabled) + - right + right + +
+ When there is no separator, a disabled panel's edges can also be used to + resize other panels. +
+ + left + + center (disabled) + + right
You can also disable both a panel and its separator to completely @@ -70,12 +88,18 @@ export default function DisabledPanelsRoute() {
- left (disabled) + + left (disabled) + - center + center - right + right + + Note that a disabled Panel can still be resized using the{" "} + imperative API. + ); } diff --git a/src/routes/LayoutBasicsRoute.tsx b/src/routes/LayoutBasicsRoute.tsx index c8b0e539f..8081e416e 100644 --- a/src/routes/LayoutBasicsRoute.tsx +++ b/src/routes/LayoutBasicsRoute.tsx @@ -34,7 +34,7 @@ export default function LayoutBasicsRoute() { read more - ) . + ). top diff --git a/src/routes/TestRoute.tsx b/src/routes/TestRoute.tsx index d731c8a68..4a95c9756 100644 --- a/src/routes/TestRoute.tsx +++ b/src/routes/TestRoute.tsx @@ -1,21 +1,19 @@ -import { Fragment } from "react"; -import { Box, Callout, ExternalLink, type Intent } from "react-lib-tools"; +import { Box } from "react-lib-tools"; +import { AccordionGroup } from "../components/accordion/AccordionGroup"; +import { AccordionPanel } from "../components/accordion/AccordionPanel"; export default function TestRoute() { return ( - - {INTENTS.map((intent) => ( - - - Text and link text. - - - Text and link text. - - - ))} + +
+ This is a test route. Any content shown here is temporary and should not + be referenced externally. +
+ + This is the first panel. + This is the second panel. + This is the third panel. +
); } - -const INTENTS: Intent[] = ["none", "primary", "success", "warning", "danger"];