import { Root, createRoot } from "react-dom/client"; import { act } from "react-dom/test-utils"; import { ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle } from "."; import { assert } from "./utils/assert"; import { getPanelElement } from "./utils/dom/getPanelElement"; import { mockPanelGroupOffsetWidthAndHeight, verifyAttribute, verifyExpandedPanelGroupLayout, } from "./utils/test-utils"; import { createRef } from "./vendor/react"; describe("PanelGroup", () => { let expectedWarnings: string[] = []; let root: Root; let container: HTMLElement; let uninstallMockOffsetWidthAndHeight: () => void; function expectWarning(expectedMessage: string) { expectedWarnings.push(expectedMessage); } beforeEach(() => { // @ts-expect-error global.IS_REACT_ACT_ENVIRONMENT = true; uninstallMockOffsetWidthAndHeight = mockPanelGroupOffsetWidthAndHeight(); container = document.createElement("div"); document.body.appendChild(container); expectedWarnings = []; root = createRoot(container); jest.spyOn(console, "warn").mockImplementation((actualMessage: string) => { const match = expectedWarnings.findIndex((expectedMessage) => { return actualMessage.includes(expectedMessage); }); if (match >= 0) { expectedWarnings.splice(match, 1); return; } throw Error(`Unexpected warning: ${actualMessage}`); }); }); afterEach(() => { uninstallMockOffsetWidthAndHeight(); jest.clearAllMocks(); jest.resetModules(); act(() => { root.unmount(); }); expect(expectedWarnings).toHaveLength(0); }); describe("imperative handle API", () => { describe("collapse and expand", () => { let leftPanelRef = createRef(); let rightPanelRef = createRef(); let mostRecentLayout: number[] | null; beforeEach(() => { leftPanelRef = createRef(); rightPanelRef = createRef(); mostRecentLayout = null; const onLayout = (layout: number[]) => { mostRecentLayout = layout; }; act(() => { root.render( ); }); }); it("should expand and collapse the first panel in a group", () => { assert(mostRecentLayout, ""); verifyExpandedPanelGroupLayout(mostRecentLayout, [50, 50]); expect(leftPanelRef.current?.isCollapsed()).toBe(false); expect(rightPanelRef.current?.isCollapsed()).toBe(false); act(() => { leftPanelRef.current?.collapse(); }); expect(leftPanelRef.current?.isCollapsed()).toBe(true); expect(rightPanelRef.current?.isCollapsed()).toBe(false); verifyExpandedPanelGroupLayout(mostRecentLayout, [0, 100]); act(() => { leftPanelRef.current?.expand(); }); expect(leftPanelRef.current?.isCollapsed()).toBe(false); expect(rightPanelRef.current?.isCollapsed()).toBe(false); verifyExpandedPanelGroupLayout(mostRecentLayout, [50, 50]); }); it("should expand and collapse the last panel in a group", () => { assert(mostRecentLayout, ""); verifyExpandedPanelGroupLayout(mostRecentLayout, [50, 50]); expect(leftPanelRef.current?.isCollapsed()).toBe(false); expect(rightPanelRef.current?.isCollapsed()).toBe(false); act(() => { rightPanelRef.current?.collapse(); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [100, 0]); expect(leftPanelRef.current?.isCollapsed()).toBe(false); expect(rightPanelRef.current?.isCollapsed()).toBe(true); act(() => { rightPanelRef.current?.expand(); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [50, 50]); expect(leftPanelRef.current?.isCollapsed()).toBe(false); expect(rightPanelRef.current?.isCollapsed()).toBe(false); }); it("should re-expand to the most recent size before collapsing", () => { assert(mostRecentLayout, ""); verifyExpandedPanelGroupLayout(mostRecentLayout, [50, 50]); act(() => { leftPanelRef.current?.resize(30); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [30, 70]); act(() => { leftPanelRef.current?.collapse(); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [0, 100]); act(() => { leftPanelRef.current?.expand(); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [30, 70]); }); it("should report the correct state with collapsedSizes that have many decimal places", () => { act(() => { root.render( ); }); act(() => { leftPanelRef.current?.collapse(); }); expect(leftPanelRef.current?.isCollapsed()).toBe(true); expect(leftPanelRef.current?.isExpanded()).toBe(false); act(() => { root.render( ); }); expect(leftPanelRef.current?.isCollapsed()).toBe(true); expect(leftPanelRef.current?.isExpanded()).toBe(false); act(() => { leftPanelRef.current?.expand(); }); expect(leftPanelRef.current?.isCollapsed()).toBe(false); expect(leftPanelRef.current?.isExpanded()).toBe(true); }); describe("when a panel is mounted in a collapsed state", () => { beforeEach(() => { act(() => { root.unmount(); }); }); it("should expand to the panel's minSize", () => { const panelRef = createRef(); root = createRoot(container); function renderPanelGroup() { act(() => { root.render( ); }); } // Re-render and confirmed collapsed by default renderPanelGroup(); act(() => { panelRef.current?.collapse(); }); expect(panelRef.current?.getSize()).toEqual(0); // Toggling a panel should expand to the minSize (since there's no previous size to restore to) act(() => { panelRef.current?.expand(); }); expect(panelRef.current?.getSize()).toEqual(5); // Collapse again act(() => { panelRef.current?.collapse(); }); expect(panelRef.current?.getSize()).toEqual(0); // Toggling the panel should expand to the minSize override if one is specified // Note this only works because the previous non-collapsed size is less than the minSize override act(() => { panelRef.current?.expand(15); }); expect(panelRef.current?.getSize()).toEqual(15); }); it("should support the (optional) default size", () => { const panelRef = createRef(); root = createRoot(container); function renderPanelGroup() { act(() => { root.render( ); }); } // Re-render and confirmed collapsed by default renderPanelGroup(); act(() => { panelRef.current?.collapse(); }); expect(panelRef.current?.getSize()).toEqual(0); // In this case, toggling the panel to expanded will not change its size act(() => { panelRef.current?.expand(); }); expect(panelRef.current?.getSize()).toEqual(0); // But we can override the toggle behavior by passing an explicit min size act(() => { panelRef.current?.expand(10); }); expect(panelRef.current?.getSize()).toEqual(10); // Toggling an already-expanded panel should not do anything even if we pass a default size act(() => { panelRef.current?.expand(15); }); expect(panelRef.current?.getSize()).toEqual(10); }); }); }); describe("resize", () => { let leftPanelRef = createRef(); let middlePanelRef = createRef(); let rightPanelRef = createRef(); let mostRecentLayout: number[] | null; beforeEach(() => { leftPanelRef = createRef(); middlePanelRef = createRef(); rightPanelRef = createRef(); mostRecentLayout = null; const onLayout = (layout: number[]) => { mostRecentLayout = layout; }; act(() => { root.render( ); }); }); it("should resize the first panel in a group", () => { assert(mostRecentLayout, ""); verifyExpandedPanelGroupLayout(mostRecentLayout, [20, 60, 20]); act(() => { leftPanelRef.current?.resize(40); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [40, 40, 20]); }); it("should resize the middle panel in a group", () => { assert(mostRecentLayout, ""); verifyExpandedPanelGroupLayout(mostRecentLayout, [20, 60, 20]); act(() => { middlePanelRef.current?.resize(40); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [20, 40, 40]); }); it("should resize the last panel in a group", () => { assert(mostRecentLayout, ""); verifyExpandedPanelGroupLayout(mostRecentLayout, [20, 60, 20]); act(() => { rightPanelRef.current?.resize(40); }); verifyExpandedPanelGroupLayout(mostRecentLayout, [20, 40, 40]); }); }); }); describe("invariants", () => { beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => { // Noop }); }); it("should throw if default size is less than 0 or greater than 100", () => { expect(() => { act(() => { root.render( ); }); }).toThrow("Invalid layout"); expect(() => { act(() => { root.render( ); }); }).toThrow("Invalid layout"); }); it("should throw if rendered outside of a PanelGroup", () => { expect(() => { act(() => { root.render(); }); }).toThrow( "Panel components must be rendered within a PanelGroup container" ); }); }); it("should support ...rest attributes", () => { act(() => { root.render( ); }); const element = getPanelElement("panel", container); assert(element, ""); expect(element.tabIndex).toBe(123); expect(element.getAttribute("data-test-name")).toBe("foo"); expect(element.title).toBe("bar"); }); describe("constraints", () => { it("should resize a collapsed panel if the collapsedSize prop changes", () => { act(() => { root.render( ); }); let leftElement = getPanelElement("left", container); let middleElement = getPanelElement("middle", container); let rightElement = getPanelElement("right", container); assert(leftElement, ""); assert(middleElement, ""); assert(rightElement, ""); expect(leftElement.getAttribute("data-panel-size")).toBe("10.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("80.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("10.0"); act(() => { root.render( ); }); expect(leftElement.getAttribute("data-panel-size")).toBe("5.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("90.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("5.0"); }); it("it should not expand a collapsed panel if other constraints change", () => { act(() => { root.render( ); }); let leftElement = getPanelElement("left", container); let middleElement = getPanelElement("middle", container); let rightElement = getPanelElement("right", container); assert(leftElement, ""); assert(middleElement, ""); assert(rightElement, ""); expect(leftElement.getAttribute("data-panel-size")).toBe("10.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("80.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("10.0"); act(() => { root.render( ); }); expect(leftElement.getAttribute("data-panel-size")).toBe("10.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("80.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("10.0"); }); it("should resize a panel if the minSize prop changes", () => { act(() => { root.render( ); }); let leftElement = getPanelElement("left", container); let middleElement = getPanelElement("middle", container); let rightElement = getPanelElement("right", container); assert(leftElement, ""); assert(middleElement, ""); assert(rightElement, ""); expect(leftElement.getAttribute("data-panel-size")).toBe("15.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("70.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("15.0"); act(() => { root.render( ); }); expect(leftElement.getAttribute("data-panel-size")).toBe("20.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("60.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("20.0"); }); it("should resize a panel if the maxSize prop changes", () => { act(() => { root.render( ); }); let leftElement = getPanelElement("left", container); let middleElement = getPanelElement("middle", container); let rightElement = getPanelElement("right", container); assert(leftElement, ""); assert(middleElement, ""); assert(rightElement, ""); expect(leftElement.getAttribute("data-panel-size")).toBe("25.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("50.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("25.0"); act(() => { root.render( ); }); expect(leftElement.getAttribute("data-panel-size")).toBe("20.0"); expect(middleElement.getAttribute("data-panel-size")).toBe("60.0"); expect(rightElement.getAttribute("data-panel-size")).toBe("20.0"); }); }); describe("callbacks", () => { describe("onCollapse", () => { it("should be called on mount if a panels initial size is 0", () => { let onCollapseLeft = jest.fn(); let onCollapseRight = jest.fn(); act(() => { root.render( ); }); expect(onCollapseLeft).toHaveBeenCalledTimes(1); expect(onCollapseRight).not.toHaveBeenCalled(); }); it("should be called when a panel is collapsed", () => { let onCollapse = jest.fn(); let panelRef = createRef(); act(() => { root.render( ); }); expect(onCollapse).not.toHaveBeenCalled(); act(() => { panelRef.current?.collapse(); }); expect(onCollapse).toHaveBeenCalledTimes(1); }); it("should be called with collapsedSizes that have many decimal places", () => { let onCollapse = jest.fn(); let panelRef = createRef(); act(() => { root.render( ); }); expect(onCollapse).not.toHaveBeenCalled(); act(() => { panelRef.current?.collapse(); }); expect(onCollapse).toHaveBeenCalledTimes(1); act(() => { panelRef.current?.expand(); }); expect(onCollapse).toHaveBeenCalledTimes(1); act(() => { panelRef.current?.collapse(); }); expect(onCollapse).toHaveBeenCalledTimes(2); }); }); describe("onExpand", () => { it("should be called on mount if a collapsible panels initial size is not 0", () => { let onExpandLeft = jest.fn(); let onExpandRight = jest.fn(); act(() => { root.render( ); }); expect(onExpandLeft).toHaveBeenCalledTimes(1); expect(onExpandRight).not.toHaveBeenCalled(); }); it("should be called when a collapsible panel is expanded", () => { let onExpand = jest.fn(); let panelRef = createRef(); act(() => { root.render( ); }); expect(onExpand).not.toHaveBeenCalled(); act(() => { panelRef.current?.resize(25); }); expect(onExpand).toHaveBeenCalledTimes(1); }); it("should be called with collapsedSizes that have many decimal places", () => { let onExpand = jest.fn(); let panelRef = createRef(); act(() => { root.render( ); }); expect(onExpand).not.toHaveBeenCalled(); act(() => { panelRef.current?.resize(25); }); expect(onExpand).toHaveBeenCalledTimes(1); act(() => { panelRef.current?.collapse(); }); expect(onExpand).toHaveBeenCalledTimes(1); act(() => { panelRef.current?.expand(); }); expect(onExpand).toHaveBeenCalledTimes(2); }); }); describe("onResize", () => { it("should be called on mount", () => { let onResizeLeft = jest.fn(); let onResizeMiddle = jest.fn(); let onResizeRight = jest.fn(); act(() => { root.render( ); }); expect(onResizeLeft).toHaveBeenCalledTimes(1); expect(onResizeLeft).toHaveBeenCalledWith(25, undefined); expect(onResizeMiddle).toHaveBeenCalledTimes(1); expect(onResizeMiddle).toHaveBeenCalledWith(50, undefined); expect(onResizeRight).toHaveBeenCalledTimes(1); expect(onResizeRight).toHaveBeenCalledWith(25, undefined); }); it("should be called when a panel is added or removed from the group", () => { let onResizeLeft = jest.fn(); let onResizeMiddle = jest.fn(); let onResizeRight = jest.fn(); act(() => { root.render( ); }); expect(onResizeLeft).not.toHaveBeenCalled(); expect(onResizeMiddle).toHaveBeenCalledWith(100, undefined); expect(onResizeRight).not.toHaveBeenCalled(); onResizeLeft.mockReset(); onResizeMiddle.mockReset(); onResizeRight.mockReset(); act(() => { root.render( ); }); expect(onResizeLeft).toHaveBeenCalledTimes(1); expect(onResizeLeft).toHaveBeenCalledWith(25, undefined); expect(onResizeMiddle).toHaveBeenCalledTimes(1); expect(onResizeMiddle).toHaveBeenCalledWith(50, 100); expect(onResizeRight).toHaveBeenCalledTimes(1); expect(onResizeRight).toHaveBeenCalledWith(25, undefined); onResizeLeft.mockReset(); onResizeMiddle.mockReset(); onResizeRight.mockReset(); act(() => { root.render( ); }); expect(onResizeLeft).not.toHaveBeenCalled(); expect(onResizeMiddle).toHaveBeenCalledTimes(1); expect(onResizeMiddle).toHaveBeenCalledWith(75, 50); expect(onResizeRight).not.toHaveBeenCalled(); }); }); it("should support sizes with many decimal places", () => { let panelRef = createRef(); let onResize = jest.fn(); act(() => { root.render( ); }); expect(onResize).toHaveBeenCalledTimes(1); act(() => { panelRef.current?.resize(3.8764385221078133); }); expect(onResize).toHaveBeenCalledTimes(2); // An overly-high precision change should be ignored act(() => { panelRef.current?.resize(3.8764385221078132); }); expect(onResize).toHaveBeenCalledTimes(2); }); }); describe("data attributes", () => { it("should initialize with the correct props based attributes", () => { act(() => { root.render( ); }); const leftElement = getPanelElement("left-panel", container); const rightElement = getPanelElement("right-panel", container); assert(leftElement, ""); assert(rightElement, ""); verifyAttribute(leftElement, "data-panel", ""); verifyAttribute(leftElement, "data-panel-id", "left-panel"); verifyAttribute(leftElement, "data-panel-group-id", "test-group"); verifyAttribute(leftElement, "data-panel-size", "75.0"); verifyAttribute(leftElement, "data-panel-collapsible", null); verifyAttribute(rightElement, "data-panel", ""); verifyAttribute(rightElement, "data-panel-id", "right-panel"); verifyAttribute(rightElement, "data-panel-group-id", "test-group"); verifyAttribute(rightElement, "data-panel-size", "25.0"); verifyAttribute(rightElement, "data-panel-collapsible", "true"); }); it("should update the data-panel-size attribute when the panel resizes", () => { const leftPanelRef = createRef(); act(() => { root.render( ); }); const leftElement = getPanelElement("left-panel", container); const rightElement = getPanelElement("right-panel", container); assert(leftElement, ""); assert(rightElement, ""); verifyAttribute(leftElement, "data-panel-size", "75.0"); verifyAttribute(rightElement, "data-panel-size", "25.0"); act(() => { leftPanelRef.current?.resize(30); }); verifyAttribute(leftElement, "data-panel-size", "30.0"); verifyAttribute(rightElement, "data-panel-size", "70.0"); }); }); describe("a11y", () => { it("should pass explicit id prop to DOM", () => { act(() => { root.render( ); }); const element = container.querySelector("[data-panel]"); expect(element).not.toBeNull(); expect(element?.getAttribute("id")).toBe("explicit-id"); }); it("should not pass auto-generated id prop to DOM", () => { act(() => { root.render( ); }); const element = container.querySelector("[data-panel]"); expect(element).not.toBeNull(); expect(element?.getAttribute("id")).toBeNull(); }); }); describe("DEV warnings", () => { it("should warn about server rendered panels with no default size", () => { jest.resetModules(); jest.mock("#is-browser", () => ({ isBrowser: false })); const { TextEncoder } = require("util"); global.TextEncoder = TextEncoder; const { renderToStaticMarkup } = require("react-dom/server.browser"); const { act } = require("react-dom/test-utils"); const Panel = require("./Panel").Panel; const PanelGroup = require("./PanelGroup").PanelGroup; const PanelResizeHandle = require("./PanelResizeHandle").PanelResizeHandle; act(() => { // No warning expected if default sizes provided renderToStaticMarkup( ); }); expectWarning( "Panel defaultSize prop recommended to avoid layout shift after server rendering" ); act(() => { renderToStaticMarkup( ); }); }); it("should warn if invalid sizes are specified declaratively", () => { expectWarning("default size should not be less than 0"); act(() => { root.render( ); }); }); }); });