Skip to content

Commit 8974566

Browse files
hugs7Hugo Burton
andauthored
fix: edge case collapse last panel (#703)
When collapsing the a panel within a group where all other (previous) panels are already collapsed, there's currently a bug where the delta calculation cascades to cause the first panel to expand. This is undesired since the first panel should remain collapsed given we are not touching it. This fix handles the edge case by computing the remaining space percentage for the last panel to take up, thus leaving previous panels undisturbed. By "previous" panels here I mean panels above (vertical mode) or to the left (horizontal) mode. --------- Co-authored-by: Hugo Burton <hugo.burton@westpac.com.au>
1 parent a803194 commit 8974566

2 files changed

Lines changed: 104 additions & 10 deletions

File tree

lib/global/utils/getImperativePanelMethods.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,42 @@ describe("getImperativePanelMethods", () => {
375375
expect(onLayoutChange).toHaveBeenCalledTimes(1);
376376
expect(onLayoutChange).toHaveBeenCalledWith([10, 90]);
377377
});
378+
379+
describe("edge cases", () => {
380+
test("does not throw when resizing the only panel in the group", () => {
381+
const { panelApis } = init([{ defaultSize: 100 }]);
382+
383+
expect(() => panelApis[0].resize("50%")).not.toThrow();
384+
expect(onLayoutChange).not.toHaveBeenCalled();
385+
});
386+
387+
test("last panel keeps the remainder when all preceding panels are collapsed and it is resized smaller", () => {
388+
const { panelApis } = init([
389+
{ collapsible: true, defaultSize: 0, minSize: 20 },
390+
{ collapsible: true, defaultSize: 0, minSize: 20 },
391+
{ defaultSize: 100 }
392+
]);
393+
394+
panelApis[2].resize("50%");
395+
396+
// The last panel should remain at 100% (the remainder) rather than
397+
// cascading the freed space to the first panel.
398+
expect(onLayoutChange).not.toHaveBeenCalled();
399+
});
400+
401+
test("last panel can still be resized normally when preceding panels are not all collapsed", () => {
402+
const { panelApis } = init([
403+
{ defaultSize: 30 },
404+
{ defaultSize: 30 },
405+
{ defaultSize: 40 }
406+
]);
407+
408+
panelApis[2].resize("20%");
409+
410+
expect(onLayoutChange).toHaveBeenCalledTimes(1);
411+
expect(onLayoutChange).toHaveBeenCalledWith([30, 50, 20]);
412+
});
413+
});
378414
});
379415
});
380416
});

lib/global/utils/getImperativePanelMethods.ts

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { PanelImperativeHandle } from "../../components/panel/types";
1+
import type { Layout } from "../../components/group/types";
2+
import type {
3+
PanelConstraints,
4+
PanelImperativeHandle,
5+
RegisteredPanel
6+
} from "../../components/panel/types";
27
import { calculateAvailableGroupSize } from "../dom/calculateAvailableGroupSize";
38
import { getMountedGroups, updateMountedGroup } from "../mutable-state/groups";
49
import { sizeStyleToPixels } from "../styles/sizeStyleToPixels";
@@ -71,6 +76,64 @@ export function getImperativePanelMethods({
7176
throw Error(`Layout not found for Panel ${panelId}`);
7277
};
7378

79+
/**
80+
* Compute the next (unvalidated) layout when resizing a panel imperatively.
81+
*
82+
* Handles two edge cases for the last panel:
83+
* 1. Single panel in the group — no sibling exists to form valid pivot indices.
84+
* 2. All preceding panels are already collapsed — the normal reversed-delta
85+
* logic would cascade the freed space to the first panel. Instead the last
86+
* panel keeps the remainder so it stays the largest.
87+
*/
88+
const computeLayout = ({
89+
nextSize,
90+
panels,
91+
prevLayout,
92+
derivedPanelConstraints
93+
}: {
94+
nextSize: number;
95+
panels: RegisteredPanel[];
96+
prevLayout: Layout;
97+
derivedPanelConstraints: PanelConstraints[];
98+
}): Layout => {
99+
const prevSize = getPanelSize();
100+
101+
const index = panels.findIndex((current) => current.id === panelId);
102+
const isFirstPanel = index === 0;
103+
const isLastPanel = index === panels.length - 1;
104+
105+
const allPreviousCollapsed =
106+
isLastPanel &&
107+
nextSize < prevSize &&
108+
(isFirstPanel ||
109+
panels.slice(0, index).every((_panel, panelIndex) => {
110+
const pc = derivedPanelConstraints[panelIndex];
111+
return (
112+
pc?.collapsible &&
113+
layoutNumbersEqual(pc.collapsedSize, prevLayout[pc.panelId])
114+
);
115+
}));
116+
117+
if (allPreviousCollapsed) {
118+
const occupiedByPrevious = panels
119+
.slice(0, index)
120+
.reduce((total, panel) => total + prevLayout[panel.id], 0);
121+
return {
122+
...prevLayout,
123+
[panelId]: formatLayoutNumber(100 - occupiedByPrevious)
124+
};
125+
}
126+
127+
return adjustLayoutByDelta({
128+
delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize,
129+
initialLayout: prevLayout,
130+
panelConstraints: derivedPanelConstraints,
131+
pivotIndices: isLastPanel ? [index - 1, index] : [index, index + 1],
132+
prevLayout,
133+
trigger: "imperative-api"
134+
});
135+
};
136+
74137
const setPanelSize = (nextSize: number) => {
75138
const prevSize = getPanelSize();
76139
if (nextSize === prevSize) {
@@ -86,16 +149,11 @@ export function getImperativePanelMethods({
86149
separatorToPanels
87150
} = find();
88151

89-
const index = group.panels.findIndex((current) => current.id === panelId);
90-
const isLastPanel = index === group.panels.length - 1;
91-
92-
const unsafeLayout = adjustLayoutByDelta({
93-
delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize,
94-
initialLayout: prevLayout,
95-
panelConstraints: derivedPanelConstraints,
96-
pivotIndices: isLastPanel ? [index - 1, index] : [index, index + 1],
152+
const unsafeLayout = computeLayout({
153+
nextSize,
154+
panels: group.panels,
97155
prevLayout,
98-
trigger: "imperative-api"
156+
derivedPanelConstraints
99157
});
100158

101159
const nextLayout = validatePanelGroupLayout({

0 commit comments

Comments
 (0)