react-resizable-panels 0.0.54 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.eslintrc.cjs +26 -0
  2. package/CHANGELOG.md +253 -80
  3. package/README.md +55 -49
  4. package/dist/declarations/src/Panel.d.ts +76 -20
  5. package/dist/declarations/src/PanelGroup.d.ts +29 -21
  6. package/dist/declarations/src/PanelResizeHandle.d.ts +1 -1
  7. package/dist/declarations/src/index.d.ts +5 -5
  8. package/dist/declarations/src/types.d.ts +3 -25
  9. package/dist/declarations/src/vendor/react.d.ts +4 -4
  10. package/dist/react-resizable-panels.browser.cjs.js +1279 -796
  11. package/dist/react-resizable-panels.browser.development.cjs.js +1404 -809
  12. package/dist/react-resizable-panels.browser.development.esm.js +1398 -803
  13. package/dist/react-resizable-panels.browser.esm.js +1279 -796
  14. package/dist/react-resizable-panels.cjs.js +1279 -796
  15. package/dist/react-resizable-panels.cjs.js.map +1 -0
  16. package/dist/react-resizable-panels.development.cjs.js +1399 -804
  17. package/dist/react-resizable-panels.development.esm.js +1400 -805
  18. package/dist/react-resizable-panels.development.node.cjs.js +1172 -755
  19. package/dist/react-resizable-panels.development.node.esm.js +1173 -756
  20. package/dist/react-resizable-panels.esm.js +1279 -796
  21. package/dist/react-resizable-panels.esm.js.map +1 -0
  22. package/dist/react-resizable-panels.node.cjs.js +1064 -749
  23. package/dist/react-resizable-panels.node.esm.js +1065 -750
  24. package/jest.config.js +10 -0
  25. package/package.json +3 -1
  26. package/src/Panel.test.tsx +308 -0
  27. package/src/Panel.ts +179 -127
  28. package/src/PanelGroup.test.tsx +210 -0
  29. package/src/PanelGroup.ts +751 -580
  30. package/src/PanelGroupContext.ts +33 -0
  31. package/src/PanelResizeHandle.ts +13 -8
  32. package/src/hooks/useUniqueId.ts +1 -1
  33. package/src/hooks/useWindowSplitterBehavior.ts +9 -161
  34. package/src/hooks/useWindowSplitterPanelGroupBehavior.ts +185 -0
  35. package/src/index.ts +24 -11
  36. package/src/types.ts +3 -29
  37. package/src/utils/adjustLayoutByDelta.test.ts +1808 -0
  38. package/src/utils/adjustLayoutByDelta.ts +211 -0
  39. package/src/utils/calculateAriaValues.test.ts +111 -0
  40. package/src/utils/calculateAriaValues.ts +67 -0
  41. package/src/utils/calculateDeltaPercentage.ts +68 -0
  42. package/src/utils/calculateDragOffsetPercentage.ts +30 -0
  43. package/src/utils/calculateUnsafeDefaultLayout.test.ts +92 -0
  44. package/src/utils/calculateUnsafeDefaultLayout.ts +55 -0
  45. package/src/utils/callPanelCallbacks.ts +81 -0
  46. package/src/utils/compareLayouts.test.ts +9 -0
  47. package/src/utils/compareLayouts.ts +12 -0
  48. package/src/utils/computePanelFlexBoxStyle.ts +44 -0
  49. package/src/utils/computePercentagePanelConstraints.test.ts +71 -0
  50. package/src/utils/computePercentagePanelConstraints.ts +56 -0
  51. package/src/utils/convertPercentageToPixels.test.ts +9 -0
  52. package/src/utils/convertPercentageToPixels.ts +6 -0
  53. package/src/utils/convertPixelConstraintsToPercentages.ts +55 -0
  54. package/src/utils/convertPixelsToPercentage.test.ts +9 -0
  55. package/src/utils/convertPixelsToPercentage.ts +6 -0
  56. package/src/utils/determinePivotIndices.ts +10 -0
  57. package/src/utils/dom/calculateAvailablePanelSizeInPixels.ts +29 -0
  58. package/src/utils/dom/getAvailableGroupSizePixels.ts +29 -0
  59. package/src/utils/dom/getPanelElement.ts +7 -0
  60. package/src/utils/dom/getPanelGroupElement.ts +7 -0
  61. package/src/utils/dom/getResizeHandleElement.ts +9 -0
  62. package/src/utils/dom/getResizeHandleElementIndex.ts +12 -0
  63. package/src/utils/dom/getResizeHandleElementsForGroup.ts +9 -0
  64. package/src/utils/dom/getResizeHandlePanelIds.ts +18 -0
  65. package/src/utils/events.ts +13 -0
  66. package/src/utils/getPercentageSizeFromMixedSizes.test.ts +47 -0
  67. package/src/utils/getPercentageSizeFromMixedSizes.ts +15 -0
  68. package/src/utils/getResizeEventCursorPosition.ts +19 -0
  69. package/src/utils/initializeDefaultStorage.ts +26 -0
  70. package/src/utils/numbers/fuzzyCompareNumbers.test.ts +16 -0
  71. package/src/utils/numbers/fuzzyCompareNumbers.ts +17 -0
  72. package/src/utils/numbers/fuzzyNumbersEqual.ts +9 -0
  73. package/src/utils/resizePanel.ts +41 -0
  74. package/src/utils/serialization.ts +9 -4
  75. package/src/utils/shouldMonitorPixelBasedConstraints.test.ts +23 -0
  76. package/src/utils/shouldMonitorPixelBasedConstraints.ts +13 -0
  77. package/src/utils/test-utils.ts +136 -0
  78. package/src/utils/validatePanelConstraints.test.ts +151 -0
  79. package/src/utils/validatePanelConstraints.ts +103 -0
  80. package/src/utils/validatePanelGroupLayout.test.ts +233 -0
  81. package/src/utils/validatePanelGroupLayout.ts +88 -0
  82. package/src/vendor/react.ts +4 -0
  83. package/.eslintrc.json +0 -22
  84. package/src/PanelContexts.ts +0 -20
  85. package/src/utils/coordinates.ts +0 -149
  86. package/src/utils/group.ts +0 -315
package/src/PanelGroup.ts CHANGED
@@ -1,12 +1,40 @@
1
- import { isBrowser } from "#is-browser";
2
1
  import { isDevelopment } from "#is-development";
2
+ import { PanelData } from "./Panel";
3
+ import { DragState, PanelGroupContext, ResizeEvent } from "./PanelGroupContext";
4
+ import useIsomorphicLayoutEffect from "./hooks/useIsomorphicEffect";
5
+ import useUniqueId from "./hooks/useUniqueId";
6
+ import { useWindowSplitterPanelGroupBehavior } from "./hooks/useWindowSplitterPanelGroupBehavior";
7
+ import { Direction, MixedSizes } from "./types";
8
+ import { adjustLayoutByDelta } from "./utils/adjustLayoutByDelta";
9
+ import { areEqual } from "./utils/arrays";
10
+ import { calculateDeltaPercentage } from "./utils/calculateDeltaPercentage";
11
+ import { calculateUnsafeDefaultLayout } from "./utils/calculateUnsafeDefaultLayout";
12
+ import { callPanelCallbacks } from "./utils/callPanelCallbacks";
13
+ import { compareLayouts } from "./utils/compareLayouts";
14
+ import { computePanelFlexBoxStyle } from "./utils/computePanelFlexBoxStyle";
15
+ import { computePercentagePanelConstraints } from "./utils/computePercentagePanelConstraints";
16
+ import { convertPercentageToPixels } from "./utils/convertPercentageToPixels";
17
+ import { resetGlobalCursorStyle, setGlobalCursorStyle } from "./utils/cursor";
18
+ import debounce from "./utils/debounce";
19
+ import { determinePivotIndices } from "./utils/determinePivotIndices";
20
+ import { calculateAvailablePanelSizeInPixels } from "./utils/dom/calculateAvailablePanelSizeInPixels";
21
+ import { getPanelGroupElement } from "./utils/dom/getPanelGroupElement";
22
+ import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement";
23
+ import { isKeyDown, isMouseEvent, isTouchEvent } from "./utils/events";
24
+ import { getPercentageSizeFromMixedSizes } from "./utils/getPercentageSizeFromMixedSizes";
25
+ import { getResizeEventCursorPosition } from "./utils/getResizeEventCursorPosition";
26
+ import { initializeDefaultStorage } from "./utils/initializeDefaultStorage";
27
+ import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization";
28
+ import { shouldMonitorPixelBasedConstraints } from "./utils/shouldMonitorPixelBasedConstraints";
29
+ import { validatePanelConstraints } from "./utils/validatePanelConstraints";
30
+ import { validatePanelGroupLayout } from "./utils/validatePanelGroupLayout";
3
31
  import {
4
- createElement,
5
32
  CSSProperties,
6
33
  ElementType,
7
34
  ForwardedRef,
35
+ PropsWithChildren,
36
+ createElement,
8
37
  forwardRef,
9
- ReactNode,
10
38
  useCallback,
11
39
  useEffect,
12
40
  useImperativeHandle,
@@ -15,72 +43,20 @@ import {
15
43
  useState,
16
44
  } from "./vendor/react";
17
45
 
18
- import useIsomorphicLayoutEffect from "./hooks/useIsomorphicEffect";
19
- import useUniqueId from "./hooks/useUniqueId";
20
- import { useWindowSplitterPanelGroupBehavior } from "./hooks/useWindowSplitterBehavior";
21
- import { PanelGroupContext } from "./PanelContexts";
22
- import {
23
- Direction,
24
- PanelData,
25
- PanelGroupOnLayout,
26
- PanelGroupStorage,
27
- ResizeEvent,
28
- } from "./types";
29
- import { areEqual } from "./utils/arrays";
30
- import { assert } from "./utils/assert";
31
- import {
32
- getDragOffset,
33
- getMovement,
34
- isMouseEvent,
35
- isTouchEvent,
36
- } from "./utils/coordinates";
37
- import { resetGlobalCursorStyle, setGlobalCursorStyle } from "./utils/cursor";
38
- import debounce from "./utils/debounce";
39
- import {
40
- adjustByDelta,
41
- callPanelCallbacks,
42
- getBeforeAndAfterIds,
43
- getFlexGrow,
44
- getPanelGroup,
45
- getResizeHandle,
46
- getResizeHandlePanelIds,
47
- panelsMapToSortedArray,
48
- } from "./utils/group";
49
- import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization";
46
+ const LOCAL_STORAGE_DEBOUNCE_INTERVAL = 100;
50
47
 
51
- const debounceMap: {
52
- [key: string]: (
53
- autoSaveId: string,
54
- panels: PanelData[],
55
- sizes: number[],
56
- storage: PanelGroupStorage
57
- ) => void;
58
- } = {};
48
+ export type ImperativePanelGroupHandle = {
49
+ getId: () => string;
50
+ getLayout: () => MixedSizes[];
51
+ setLayout: (layout: Partial<MixedSizes>[]) => void;
52
+ };
59
53
 
60
- // PanelGroup might be rendering in a server-side environment where localStorage is not available
61
- // or on a browser with cookies/storage disabled.
62
- // In either case, this function avoids accessing localStorage until needed,
63
- // and avoids throwing user-visible errors.
64
- function initializeDefaultStorage(storageObject: PanelGroupStorage) {
65
- try {
66
- if (typeof localStorage !== "undefined") {
67
- // Bypass this check for future calls
68
- storageObject.getItem = (name: string) => {
69
- return localStorage.getItem(name);
70
- };
71
- storageObject.setItem = (name: string, value: string) => {
72
- localStorage.setItem(name, value);
73
- };
74
- } else {
75
- throw new Error("localStorage not supported in this environment");
76
- }
77
- } catch (error) {
78
- console.error(error);
54
+ export type PanelGroupStorage = {
55
+ getItem(name: string): string | null;
56
+ setItem(name: string, value: string): void;
57
+ };
79
58
 
80
- storageObject.getItem = () => null;
81
- storageObject.setItem = () => {};
82
- }
83
- }
59
+ export type PanelGroupOnLayout = (layout: MixedSizes[]) => void;
84
60
 
85
61
  const defaultStorage: PanelGroupStorage = {
86
62
  getItem: (name: string) => {
@@ -93,132 +69,145 @@ const defaultStorage: PanelGroupStorage = {
93
69
  },
94
70
  };
95
71
 
96
- export type CommittedValues = {
97
- direction: Direction;
98
- panels: Map<string, PanelData>;
99
- sizes: number[];
100
- };
101
-
102
- export type PanelDataMap = Map<string, PanelData>;
103
-
104
- // Initial drag state serves a few purposes:
105
- // * dragOffset:
106
- // Resize is calculated by the distance between the current pointer event and the resize handle being "dragged"
107
- // This value accounts for the initial offset when the touch/click starts, so the handle doesn't appear to "jump"
108
- // * dragHandleRect, sizes:
109
- // When resizing is done via mouse/touch event– some initial state is stored
110
- // so that any panels that contract will also expand if drag direction is reversed.
111
- export type InitialDragState = {
112
- dragHandleRect: DOMRect;
113
- dragOffset: number;
114
- sizes: number[];
115
- };
116
-
117
- // TODO
118
- // Within an active drag, remember original positions to refine more easily on expand.
119
- // Look at what the Chrome devtools Sources does.
120
-
121
- export type PanelGroupProps = {
72
+ export type PanelGroupProps = PropsWithChildren<{
122
73
  autoSaveId?: string;
123
- children?: ReactNode;
124
74
  className?: string;
125
75
  direction: Direction;
126
- disablePointerEventsDuringResize?: boolean;
127
76
  id?: string | null;
128
- onLayout?: PanelGroupOnLayout;
77
+ keyboardResizeByPercentage?: number | null;
78
+ keyboardResizeByPixels?: number | null;
79
+ onLayout?: PanelGroupOnLayout | null;
129
80
  storage?: PanelGroupStorage;
130
81
  style?: CSSProperties;
131
82
  tagName?: ElementType;
132
- };
83
+ }>;
133
84
 
134
- export type ImperativePanelGroupHandle = {
135
- getLayout: () => number[];
136
- setLayout: (panelSizes: number[]) => void;
137
- };
85
+ const debounceMap: {
86
+ [key: string]: typeof savePanelGroupLayout;
87
+ } = {};
138
88
 
139
89
  function PanelGroupWithForwardedRef({
140
90
  autoSaveId,
141
- children = null,
91
+ children,
142
92
  className: classNameFromProps = "",
143
93
  direction,
144
- disablePointerEventsDuringResize = false,
145
94
  forwardedRef,
146
- id: idFromProps = null,
147
- onLayout,
95
+ id: idFromProps,
96
+ onLayout = null,
97
+ keyboardResizeByPercentage = null,
98
+ keyboardResizeByPixels = null,
148
99
  storage = defaultStorage,
149
- style: styleFromProps = {},
100
+ style: styleFromProps,
150
101
  tagName: Type = "div",
151
102
  }: PanelGroupProps & {
152
103
  forwardedRef: ForwardedRef<ImperativePanelGroupHandle>;
153
104
  }) {
154
105
  const groupId = useUniqueId(idFromProps);
155
106
 
156
- const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
157
- const [panels, setPanels] = useState<PanelDataMap>(new Map());
107
+ const [dragState, setDragState] = useState<DragState | null>(null);
108
+ const [layout, setLayout] = useState<number[]>([]);
109
+ const [panelDataArray, setPanelDataArray] = useState<PanelData[]>([]);
110
+
111
+ const panelIdToLastNotifiedMixedSizesMapRef = useRef<
112
+ Record<string, MixedSizes>
113
+ >({});
114
+ const panelSizeBeforeCollapseRef = useRef<Map<string, number>>(new Map());
115
+ const prevDeltaRef = useRef<number>(0);
158
116
 
159
- // When resizing is done via mouse/touch event–
160
- // We store the initial Panel sizes in this ref, and apply move deltas to them instead of to the current sizes.
161
- // This has the benefit of causing force-collapsed panels to spring back open if drag is reversed.
162
- const initialDragStateRef = useRef<InitialDragState | null>(null);
117
+ const committedValuesRef = useRef<{
118
+ direction: Direction;
119
+ dragState: DragState | null;
120
+ id: string;
121
+ keyboardResizeByPercentage: number | null;
122
+ keyboardResizeByPixels: number | null;
123
+ layout: number[];
124
+ onLayout: PanelGroupOnLayout | null;
125
+ panelDataArray: PanelData[];
126
+ }>({
127
+ direction,
128
+ dragState,
129
+ id: groupId,
130
+ keyboardResizeByPercentage,
131
+ keyboardResizeByPixels,
132
+ layout,
133
+ onLayout,
134
+ panelDataArray,
135
+ });
163
136
 
164
137
  const devWarningsRef = useRef<{
165
- didLogDefaultSizeWarning: boolean;
166
138
  didLogIdAndOrderWarning: boolean;
139
+ didLogPanelConstraintsWarning: boolean;
167
140
  prevPanelIds: string[];
168
141
  }>({
169
- didLogDefaultSizeWarning: false,
170
142
  didLogIdAndOrderWarning: false,
143
+ didLogPanelConstraintsWarning: false,
171
144
  prevPanelIds: [],
172
145
  });
173
146
 
174
- // Use a ref to guard against users passing inline props
175
- const callbacksRef = useRef<{
176
- onLayout: PanelGroupOnLayout | undefined;
177
- }>({ onLayout });
178
- useEffect(() => {
179
- callbacksRef.current.onLayout = onLayout;
180
- });
181
-
182
- const panelIdToLastNotifiedSizeMapRef = useRef<Record<string, number>>({});
183
-
184
- // 0-1 values representing the relative size of each panel.
185
- const [sizes, setSizes] = useState<number[]>([]);
186
-
187
- // Used to support imperative collapse/expand API.
188
- const panelSizeBeforeCollapse = useRef<Map<string, number>>(new Map());
189
-
190
- const prevDeltaRef = useRef<number>(0);
191
-
192
- // Store committed values to avoid unnecessarily re-running memoization/effects functions.
193
- const committedValuesRef = useRef<CommittedValues>({
194
- direction,
195
- panels,
196
- sizes,
197
- });
198
-
199
147
  useImperativeHandle(
200
148
  forwardedRef,
201
149
  () => ({
150
+ getId: () => committedValuesRef.current.id,
202
151
  getLayout: () => {
203
- const { sizes } = committedValuesRef.current;
204
- return sizes;
152
+ const { id: groupId, layout } = committedValuesRef.current;
153
+
154
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
155
+
156
+ return layout.map((sizePercentage) => {
157
+ return {
158
+ sizePercentage,
159
+ sizePixels: convertPercentageToPixels(
160
+ sizePercentage,
161
+ groupSizePixels
162
+ ),
163
+ };
164
+ });
205
165
  },
206
- setLayout: (sizes: number[]) => {
207
- const total = sizes.reduce(
208
- (accumulated, current) => accumulated + current,
209
- 0
210
- );
166
+ setLayout: (mixedSizes: Partial<MixedSizes>[]) => {
167
+ const {
168
+ id: groupId,
169
+ layout: prevLayout,
170
+ onLayout,
171
+ panelDataArray,
172
+ } = committedValuesRef.current;
211
173
 
212
- assert(total === 100, "Panel sizes must add up to 100%");
174
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
213
175
 
214
- const { panels } = committedValuesRef.current;
215
- const panelIdToLastNotifiedSizeMap =
216
- panelIdToLastNotifiedSizeMapRef.current;
217
- const panelsArray = panelsMapToSortedArray(panels);
176
+ const unsafeLayout = mixedSizes.map(
177
+ (mixedSize) =>
178
+ getPercentageSizeFromMixedSizes(mixedSize, groupSizePixels)!
179
+ );
218
180
 
219
- setSizes(sizes);
181
+ const safeLayout = validatePanelGroupLayout({
182
+ groupSizePixels,
183
+ layout: unsafeLayout,
184
+ panelConstraints: panelDataArray.map(
185
+ (panelData) => panelData.constraints
186
+ ),
187
+ });
188
+
189
+ if (!areEqual(prevLayout, safeLayout)) {
190
+ setLayout(safeLayout);
191
+
192
+ if (onLayout) {
193
+ onLayout(
194
+ safeLayout.map((sizePercentage) => ({
195
+ sizePercentage,
196
+ sizePixels: convertPercentageToPixels(
197
+ sizePercentage,
198
+ groupSizePixels
199
+ ),
200
+ }))
201
+ );
202
+ }
220
203
 
221
- callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
204
+ callPanelCallbacks(
205
+ groupId,
206
+ panelDataArray,
207
+ safeLayout,
208
+ panelIdToLastNotifiedMixedSizesMapRef.current
209
+ );
210
+ }
222
211
  },
223
212
  }),
224
213
  []
@@ -226,131 +215,169 @@ function PanelGroupWithForwardedRef({
226
215
 
227
216
  useIsomorphicLayoutEffect(() => {
228
217
  committedValuesRef.current.direction = direction;
229
- committedValuesRef.current.panels = panels;
230
- committedValuesRef.current.sizes = sizes;
218
+ committedValuesRef.current.dragState = dragState;
219
+ committedValuesRef.current.id = groupId;
220
+ committedValuesRef.current.layout = layout;
221
+ committedValuesRef.current.onLayout = onLayout;
222
+ committedValuesRef.current.panelDataArray = panelDataArray;
231
223
  });
232
224
 
233
225
  useWindowSplitterPanelGroupBehavior({
234
226
  committedValuesRef,
235
227
  groupId,
236
- panels,
237
- setSizes,
238
- sizes,
239
- panelSizeBeforeCollapse,
228
+ layout,
229
+ panelDataArray,
230
+ setLayout,
240
231
  });
241
232
 
242
- // Notify external code when sizes have changed.
243
233
  useEffect(() => {
244
- const { onLayout } = callbacksRef.current!;
245
- const { panels, sizes } = committedValuesRef.current;
246
-
247
- // Don't commit layout until all panels have registered and re-rendered with their actual sizes.
248
- if (sizes.length > 0) {
249
- if (onLayout) {
250
- onLayout(sizes);
234
+ // If this panel has been configured to persist sizing information, save sizes to local storage.
235
+ if (autoSaveId) {
236
+ if (layout.length === 0 || layout.length !== panelDataArray.length) {
237
+ return;
251
238
  }
252
239
 
253
- const panelIdToLastNotifiedSizeMap =
254
- panelIdToLastNotifiedSizeMapRef.current;
255
-
256
- // When possible, we notify before the next render so that rendering work can be batched together.
257
- // Some cases are difficult to detect though,
258
- // for example– panels that are conditionally rendered can affect the size of neighboring panels.
259
- // In this case, the best we can do is notify on commit.
260
- // The callPanelCallbacks() uses its own memoization to avoid notifying panels twice in these cases.
261
- const panelsArray = panelsMapToSortedArray(panels);
262
- callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
240
+ // Limit the frequency of localStorage updates.
241
+ if (!debounceMap[autoSaveId]) {
242
+ debounceMap[autoSaveId] = debounce(
243
+ savePanelGroupLayout,
244
+ LOCAL_STORAGE_DEBOUNCE_INTERVAL
245
+ );
246
+ }
247
+ debounceMap[autoSaveId](autoSaveId, panelDataArray, layout, storage);
263
248
  }
264
- }, [sizes]);
249
+ }, [autoSaveId, layout, panelDataArray, storage]);
265
250
 
266
251
  // Once all panels have registered themselves,
267
252
  // Compute the initial sizes based on default weights.
268
253
  // This assumes that panels register during initial mount (no conditional rendering)!
269
254
  useIsomorphicLayoutEffect(() => {
270
- const sizes = committedValuesRef.current.sizes;
271
- if (sizes.length === panels.size) {
272
- // Only compute (or restore) default sizes once per panel configuration.
255
+ const { id: groupId, layout, onLayout } = committedValuesRef.current;
256
+ if (layout.length === panelDataArray.length) {
257
+ // Only compute (or restore) default layout once per panel configuration.
273
258
  return;
274
259
  }
275
260
 
276
261
  // If this panel has been configured to persist sizing information,
277
262
  // default size should be restored from local storage if possible.
278
- let defaultSizes: number[] | null = null;
263
+ let unsafeLayout: number[] | null = null;
279
264
  if (autoSaveId) {
280
- const panelsArray = panelsMapToSortedArray(panels);
281
- defaultSizes = loadPanelLayout(autoSaveId, panelsArray, storage);
265
+ unsafeLayout = loadPanelLayout(autoSaveId, panelDataArray, storage);
282
266
  }
283
267
 
284
- if (defaultSizes != null) {
285
- setSizes(defaultSizes);
286
- } else {
287
- const panelsArray = panelsMapToSortedArray(panels);
268
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
288
269
 
289
- let panelsWithNullDefaultSize = 0;
290
- let totalDefaultSize = 0;
291
- let totalMinSize = 0;
270
+ if (unsafeLayout == null) {
271
+ unsafeLayout = calculateUnsafeDefaultLayout({
272
+ groupSizePixels,
273
+ panelDataArray,
274
+ });
275
+ }
292
276
 
293
- // TODO
294
- // Implicit default size calculations below do not account for inferred min/max size values.
295
- // 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.
296
- // For now, these logic edge cases are left to the user to handle via props.
277
+ // Validate even saved layouts in case something has changed since last render
278
+ // e.g. for pixel groups, this could be the size of the window
279
+ const validatedLayout = validatePanelGroupLayout({
280
+ groupSizePixels,
281
+ layout: unsafeLayout,
282
+ panelConstraints: panelDataArray.map(
283
+ (panelData) => panelData.constraints
284
+ ),
285
+ });
297
286
 
298
- panelsArray.forEach((panel) => {
299
- totalMinSize += panel.current.minSize;
287
+ if (!areEqual(layout, validatedLayout)) {
288
+ setLayout(validatedLayout);
289
+ }
300
290
 
301
- if (panel.current.defaultSize === null) {
302
- panelsWithNullDefaultSize++;
303
- } else {
304
- totalDefaultSize += panel.current.defaultSize;
305
- }
306
- });
291
+ if (onLayout) {
292
+ onLayout(
293
+ validatedLayout.map((sizePercentage) => ({
294
+ sizePercentage,
295
+ sizePixels: convertPercentageToPixels(
296
+ sizePercentage,
297
+ groupSizePixels
298
+ ),
299
+ }))
300
+ );
301
+ }
307
302
 
308
- if (totalDefaultSize > 100) {
309
- throw new Error(`Default panel sizes cannot exceed 100%`);
310
- } else if (
311
- panelsArray.length > 1 &&
312
- panelsWithNullDefaultSize === 0 &&
313
- totalDefaultSize !== 100
314
- ) {
315
- throw new Error(`Invalid default sizes specified for panels`);
316
- } else if (totalMinSize > 100) {
317
- throw new Error(`Minimum panel sizes cannot exceed 100%`);
318
- }
303
+ callPanelCallbacks(
304
+ groupId,
305
+ panelDataArray,
306
+ validatedLayout,
307
+ panelIdToLastNotifiedMixedSizesMapRef.current
308
+ );
309
+ }, [autoSaveId, layout, panelDataArray, storage]);
319
310
 
320
- setSizes(
321
- panelsArray.map((panel) => {
322
- if (panel.current.defaultSize === null) {
323
- return (100 - totalDefaultSize) / panelsWithNullDefaultSize;
324
- }
311
+ useIsomorphicLayoutEffect(() => {
312
+ const constraints = panelDataArray.map(({ constraints }) => constraints);
313
+ if (!shouldMonitorPixelBasedConstraints(constraints)) {
314
+ // Avoid the overhead of ResizeObserver if no pixel constraints require monitoring
315
+ return;
316
+ }
325
317
 
326
- return panel.current.defaultSize;
327
- })
318
+ if (typeof ResizeObserver === "undefined") {
319
+ console.warn(
320
+ `WARNING: Pixel based constraints require ResizeObserver but it is not supported by the current browser.`
328
321
  );
329
- }
330
- }, [autoSaveId, panels, storage]);
322
+ } else {
323
+ const resizeObserver = new ResizeObserver(() => {
324
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
325
+
326
+ const { layout: prevLayout, onLayout } = committedValuesRef.current;
327
+
328
+ const nextLayout = validatePanelGroupLayout({
329
+ groupSizePixels,
330
+ layout: prevLayout,
331
+ panelConstraints: panelDataArray.map(
332
+ (panelData) => panelData.constraints
333
+ ),
334
+ });
335
+
336
+ if (!areEqual(prevLayout, nextLayout)) {
337
+ setLayout(nextLayout);
338
+
339
+ if (onLayout) {
340
+ onLayout(
341
+ nextLayout.map((sizePercentage) => ({
342
+ sizePercentage,
343
+ sizePixels: convertPercentageToPixels(
344
+ sizePercentage,
345
+ groupSizePixels
346
+ ),
347
+ }))
348
+ );
349
+ }
331
350
 
332
- useEffect(() => {
333
- // If this panel has been configured to persist sizing information, save sizes to local storage.
334
- if (autoSaveId) {
335
- if (sizes.length === 0 || sizes.length !== panels.size) {
336
- return;
337
- }
351
+ callPanelCallbacks(
352
+ groupId,
353
+ panelDataArray,
354
+ nextLayout,
355
+ panelIdToLastNotifiedMixedSizesMapRef.current
356
+ );
357
+ }
358
+ });
338
359
 
339
- const panelsArray = panelsMapToSortedArray(panels);
360
+ resizeObserver.observe(getPanelGroupElement(groupId)!);
340
361
 
341
- // Limit the frequency of localStorage updates.
342
- if (!debounceMap[autoSaveId]) {
343
- debounceMap[autoSaveId] = debounce(savePanelGroupLayout, 100);
344
- }
345
- debounceMap[autoSaveId](autoSaveId, panelsArray, sizes, storage);
362
+ return () => {
363
+ resizeObserver.disconnect();
364
+ };
346
365
  }
366
+ }, [groupId, panelDataArray]);
347
367
 
368
+ // DEV warnings
369
+ useEffect(() => {
348
370
  if (isDevelopment) {
349
- const { didLogIdAndOrderWarning, prevPanelIds } = devWarningsRef.current;
371
+ const {
372
+ didLogIdAndOrderWarning,
373
+ didLogPanelConstraintsWarning,
374
+ prevPanelIds,
375
+ } = devWarningsRef.current;
376
+
350
377
  if (!didLogIdAndOrderWarning) {
351
- const { panels } = committedValuesRef.current;
378
+ const { panelDataArray } = committedValuesRef.current;
352
379
 
353
- const panelIds = Array.from(panels.keys());
380
+ const panelIds = panelDataArray.map(({ id }) => id);
354
381
 
355
382
  devWarningsRef.current.prevPanelIds = panelIds;
356
383
 
@@ -358,9 +385,8 @@ function PanelGroupWithForwardedRef({
358
385
  prevPanelIds.length > 0 && !areEqual(prevPanelIds, panelIds);
359
386
  if (panelsHaveChanged) {
360
387
  if (
361
- Array.from(panels.values()).find(
362
- (panel) =>
363
- panel.current.idWasAutoGenerated || panel.current.order == null
388
+ panelDataArray.find(
389
+ ({ idIsFromProps, order }) => !idIsFromProps || order == null
364
390
  )
365
391
  ) {
366
392
  devWarningsRef.current.didLogIdAndOrderWarning = true;
@@ -371,414 +397,509 @@ function PanelGroupWithForwardedRef({
371
397
  }
372
398
  }
373
399
  }
374
- }
375
- }, [autoSaveId, panels, sizes, storage]);
376
400
 
377
- const getPanelStyle = useCallback(
378
- (id: string, defaultSize: number | null): CSSProperties => {
379
- const { panels } = committedValuesRef.current;
380
-
381
- // Before mounting, Panels will not yet have registered themselves.
382
- // This includes server rendering.
383
- // At this point the best we can do is render everything with the same size.
384
- if (panels.size === 0) {
385
- if (isDevelopment) {
386
- if (!devWarningsRef.current.didLogDefaultSizeWarning) {
387
- if (!isBrowser && defaultSize == null) {
388
- devWarningsRef.current.didLogDefaultSizeWarning = true;
389
- console.warn(
390
- `WARNING: Panel defaultSize prop recommended to avoid layout shift after server rendering`
391
- );
392
- }
393
- }
394
- }
395
-
396
- return {
397
- flexBasis: 0,
398
- flexGrow: defaultSize != null ? defaultSize : undefined,
399
- flexShrink: 1,
401
+ if (!didLogPanelConstraintsWarning) {
402
+ const panelConstraints = panelDataArray.map(
403
+ (panelData) => panelData.constraints
404
+ );
400
405
 
401
- // Without this, Panel sizes may be unintentionally overridden by their content.
402
- overflow: "hidden",
403
- };
404
- }
406
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
405
407
 
406
- const flexGrow = getFlexGrow(panels, id, sizes);
408
+ for (
409
+ let panelIndex = 0;
410
+ panelIndex < panelConstraints.length;
411
+ panelIndex++
412
+ ) {
413
+ const isValid = validatePanelConstraints({
414
+ groupSizePixels,
415
+ panelConstraints,
416
+ panelId: panelDataArray[panelIndex].id,
417
+ panelIndex,
418
+ });
407
419
 
408
- return {
409
- flexBasis: 0,
410
- flexGrow,
411
- flexShrink: 1,
412
-
413
- // Without this, Panel sizes may be unintentionally overridden by their content.
414
- overflow: "hidden",
415
-
416
- // Disable pointer events inside of a panel during resize.
417
- // This avoid edge cases like nested iframes.
418
- pointerEvents:
419
- disablePointerEventsDuringResize && activeHandleId !== null
420
- ? "none"
421
- : undefined,
422
- };
423
- },
424
- [activeHandleId, disablePointerEventsDuringResize, sizes]
425
- );
420
+ if (!isValid) {
421
+ devWarningsRef.current.didLogPanelConstraintsWarning = true;
426
422
 
427
- const registerPanel = useCallback((id: string, panelRef: PanelData) => {
428
- setPanels((prevPanels) => {
429
- if (prevPanels.has(id)) {
430
- return prevPanels;
423
+ break;
424
+ }
425
+ }
431
426
  }
427
+ }
428
+ });
432
429
 
433
- const nextPanels = new Map(prevPanels);
434
- nextPanels.set(id, panelRef);
435
-
436
- return nextPanels;
437
- });
438
- }, []);
439
-
440
- const registerResizeHandle = useCallback(
441
- (handleId: string) => {
442
- const resizeHandler = (event: ResizeEvent) => {
443
- event.preventDefault();
444
-
445
- const {
446
- direction,
447
- panels,
448
- sizes: prevSizes,
449
- } = committedValuesRef.current;
450
-
451
- const panelsArray = panelsMapToSortedArray(panels);
452
-
453
- const [idBefore, idAfter] = getResizeHandlePanelIds(
454
- groupId,
455
- handleId,
456
- panelsArray
430
+ // External APIs are safe to memoize via committed values ref
431
+ const collapsePanel = useCallback(
432
+ (panelData: PanelData) => {
433
+ const {
434
+ layout: prevLayout,
435
+ onLayout,
436
+ panelDataArray,
437
+ } = committedValuesRef.current;
438
+
439
+ if (panelData.constraints.collapsible) {
440
+ const panelConstraintsArray = panelDataArray.map(
441
+ (panelData) => panelData.constraints
457
442
  );
458
- if (idBefore == null || idAfter == null) {
459
- return;
460
- }
461
443
 
462
- let movement = getMovement(
463
- event,
464
- groupId,
465
- handleId,
466
- panelsArray,
467
- direction,
468
- prevSizes,
469
- initialDragStateRef.current
470
- );
471
- if (movement === 0) {
472
- return;
473
- }
444
+ const {
445
+ collapsedSizePercentage,
446
+ panelSizePercentage,
447
+ pivotIndices,
448
+ groupSizePixels,
449
+ } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout);
450
+
451
+ if (panelSizePercentage !== collapsedSizePercentage) {
452
+ // Store size before collapse;
453
+ // This is the size that gets restored if the expand() API is used.
454
+ panelSizeBeforeCollapseRef.current.set(
455
+ panelData.id,
456
+ panelSizePercentage
457
+ );
474
458
 
475
- const groupElement = getPanelGroup(groupId)!;
476
- const rect = groupElement.getBoundingClientRect();
477
- const isHorizontal = direction === "horizontal";
459
+ const isLastPanel =
460
+ panelDataArray.indexOf(panelData) === panelDataArray.length - 1;
461
+ const delta = isLastPanel
462
+ ? panelSizePercentage - collapsedSizePercentage
463
+ : collapsedSizePercentage - panelSizePercentage;
464
+
465
+ const nextLayout = adjustLayoutByDelta({
466
+ delta,
467
+ groupSizePixels,
468
+ layout: prevLayout,
469
+ panelConstraints: panelConstraintsArray,
470
+ pivotIndices,
471
+ trigger: "imperative-api",
472
+ });
473
+
474
+ if (!compareLayouts(prevLayout, nextLayout)) {
475
+ setLayout(nextLayout);
476
+
477
+ if (onLayout) {
478
+ onLayout(
479
+ nextLayout.map((sizePercentage) => ({
480
+ sizePercentage,
481
+ sizePixels: convertPercentageToPixels(
482
+ sizePercentage,
483
+ groupSizePixels
484
+ ),
485
+ }))
486
+ );
487
+ }
478
488
 
479
- // Support RTL layouts
480
- if (document.dir === "rtl" && isHorizontal) {
481
- movement = -movement;
489
+ callPanelCallbacks(
490
+ groupId,
491
+ panelDataArray,
492
+ nextLayout,
493
+ panelIdToLastNotifiedMixedSizesMapRef.current
494
+ );
495
+ }
482
496
  }
497
+ }
498
+ },
499
+ [groupId]
500
+ );
483
501
 
484
- const size = isHorizontal ? rect.width : rect.height;
485
- const delta = (movement / size) * 100;
486
-
487
- const nextSizes = adjustByDelta(
488
- event,
489
- panels,
490
- idBefore,
491
- idAfter,
492
- delta,
493
- prevSizes,
494
- panelSizeBeforeCollapse.current,
495
- initialDragStateRef.current
502
+ // External APIs are safe to memoize via committed values ref
503
+ const expandPanel = useCallback(
504
+ (panelData: PanelData) => {
505
+ const {
506
+ layout: prevLayout,
507
+ onLayout,
508
+ panelDataArray,
509
+ } = committedValuesRef.current;
510
+
511
+ if (panelData.constraints.collapsible) {
512
+ const panelConstraintsArray = panelDataArray.map(
513
+ (panelData) => panelData.constraints
496
514
  );
497
515
 
498
- const sizesChanged = !areEqual(prevSizes, nextSizes);
499
-
500
- // Don't update cursor for resizes triggered by keyboard interactions.
501
- if (isMouseEvent(event) || isTouchEvent(event)) {
502
- // Watch for multiple subsequent deltas; this might occur for tiny cursor movements.
503
- // In this case, Panel sizes might not change–
504
- // but updating cursor in this scenario would cause a flicker.
505
- if (prevDeltaRef.current != delta) {
506
- if (!sizesChanged) {
507
- // If the pointer has moved too far to resize the panel any further,
508
- // update the cursor style for a visual clue.
509
- // This mimics VS Code behavior.
510
-
511
- if (isHorizontal) {
512
- setGlobalCursorStyle(
513
- movement < 0 ? "horizontal-min" : "horizontal-max"
514
- );
515
- } else {
516
- setGlobalCursorStyle(
517
- movement < 0 ? "vertical-min" : "vertical-max"
518
- );
519
- }
520
- } else {
521
- // Reset the cursor style to the the normal resize cursor.
522
- setGlobalCursorStyle(isHorizontal ? "horizontal" : "vertical");
516
+ const {
517
+ collapsedSizePercentage,
518
+ panelSizePercentage,
519
+ minSizePercentage,
520
+ pivotIndices,
521
+ groupSizePixels,
522
+ } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout);
523
+
524
+ if (panelSizePercentage === collapsedSizePercentage) {
525
+ // Restore this panel to the size it was before it was collapsed, if possible.
526
+ const prevPanelSizePercentage =
527
+ panelSizeBeforeCollapseRef.current.get(panelData.id);
528
+
529
+ const baseSizePercentage =
530
+ prevPanelSizePercentage != null
531
+ ? prevPanelSizePercentage
532
+ : minSizePercentage;
533
+
534
+ const isLastPanel =
535
+ panelDataArray.indexOf(panelData) === panelDataArray.length - 1;
536
+ const delta = isLastPanel
537
+ ? panelSizePercentage - baseSizePercentage
538
+ : baseSizePercentage - panelSizePercentage;
539
+
540
+ const nextLayout = adjustLayoutByDelta({
541
+ delta,
542
+ groupSizePixels,
543
+ layout: prevLayout,
544
+ panelConstraints: panelConstraintsArray,
545
+ pivotIndices,
546
+ trigger: "imperative-api",
547
+ });
548
+
549
+ if (!compareLayouts(prevLayout, nextLayout)) {
550
+ setLayout(nextLayout);
551
+
552
+ if (onLayout) {
553
+ onLayout(
554
+ nextLayout.map((sizePercentage) => ({
555
+ sizePercentage,
556
+ sizePixels: convertPercentageToPixels(
557
+ sizePercentage,
558
+ groupSizePixels
559
+ ),
560
+ }))
561
+ );
523
562
  }
563
+
564
+ callPanelCallbacks(
565
+ groupId,
566
+ panelDataArray,
567
+ nextLayout,
568
+ panelIdToLastNotifiedMixedSizesMapRef.current
569
+ );
524
570
  }
525
571
  }
572
+ }
573
+ },
574
+ [groupId]
575
+ );
526
576
 
527
- if (sizesChanged) {
528
- const panelIdToLastNotifiedSizeMap =
529
- panelIdToLastNotifiedSizeMapRef.current;
530
-
531
- setSizes(nextSizes);
577
+ // External APIs are safe to memoize via committed values ref
578
+ const getPanelSize = useCallback(
579
+ (panelData: PanelData) => {
580
+ const { layout, panelDataArray } = committedValuesRef.current;
532
581
 
533
- // If resize change handlers have been declared, this is the time to call them.
534
- // Trigger user callbacks after updating state, so that user code can override the sizes.
535
- callPanelCallbacks(
536
- panelsArray,
537
- nextSizes,
538
- panelIdToLastNotifiedSizeMap
539
- );
540
- }
582
+ const { panelSizePercentage, panelSizePixels } = panelDataHelper(
583
+ groupId,
584
+ panelDataArray,
585
+ panelData,
586
+ layout
587
+ );
541
588
 
542
- prevDeltaRef.current = delta;
589
+ return {
590
+ sizePercentage: panelSizePercentage,
591
+ sizePixels: panelSizePixels,
543
592
  };
544
-
545
- return resizeHandler;
546
593
  },
547
594
  [groupId]
548
595
  );
549
596
 
550
- const unregisterPanel = useCallback((id: string) => {
551
- setPanels((prevPanels) => {
552
- if (!prevPanels.has(id)) {
553
- return prevPanels;
554
- }
555
-
556
- const nextPanels = new Map(prevPanels);
557
- nextPanels.delete(id);
558
-
559
- return nextPanels;
560
- });
561
- }, []);
562
-
563
- const collapsePanel = useCallback((id: string) => {
564
- const { panels, sizes: prevSizes } = committedValuesRef.current;
565
-
566
- const panel = panels.get(id);
567
- if (panel == null) {
568
- return;
569
- }
570
-
571
- const { collapsedSize, collapsible } = panel.current;
572
- if (!collapsible) {
573
- return;
574
- }
575
-
576
- const panelsArray = panelsMapToSortedArray(panels);
597
+ // This API should never read from committedValuesRef
598
+ const getPanelStyle = useCallback(
599
+ (panelData: PanelData) => {
600
+ const panelIndex = panelDataArray.indexOf(panelData);
601
+
602
+ return computePanelFlexBoxStyle({
603
+ dragState,
604
+ layout,
605
+ panelData: panelDataArray,
606
+ panelIndex,
607
+ });
608
+ },
609
+ [dragState, layout, panelDataArray]
610
+ );
577
611
 
578
- const index = panelsArray.indexOf(panel);
579
- if (index < 0) {
580
- return;
581
- }
612
+ // External APIs are safe to memoize via committed values ref
613
+ const isPanelCollapsed = useCallback(
614
+ (panelData: PanelData) => {
615
+ const { layout, panelDataArray } = committedValuesRef.current;
582
616
 
583
- const currentSize = prevSizes[index];
584
- if (currentSize === collapsedSize) {
585
- // Panel is already collapsed.
586
- return;
587
- }
617
+ const { collapsedSizePercentage, collapsible, panelSizePercentage } =
618
+ panelDataHelper(groupId, panelDataArray, panelData, layout);
588
619
 
589
- panelSizeBeforeCollapse.current.set(id, currentSize);
620
+ return (
621
+ collapsible === true && panelSizePercentage === collapsedSizePercentage
622
+ );
623
+ },
624
+ [groupId]
625
+ );
590
626
 
591
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
592
- if (idBefore == null || idAfter == null) {
593
- return;
594
- }
627
+ // External APIs are safe to memoize via committed values ref
628
+ const isPanelExpanded = useCallback(
629
+ (panelData: PanelData) => {
630
+ const { layout, panelDataArray } = committedValuesRef.current;
595
631
 
596
- const isLastPanel = index === panelsArray.length - 1;
597
- const delta = isLastPanel ? currentSize : collapsedSize - currentSize;
598
-
599
- const nextSizes = adjustByDelta(
600
- null,
601
- panels,
602
- idBefore,
603
- idAfter,
604
- delta,
605
- prevSizes,
606
- panelSizeBeforeCollapse.current,
607
- null
608
- );
609
- if (prevSizes !== nextSizes) {
610
- const panelIdToLastNotifiedSizeMap =
611
- panelIdToLastNotifiedSizeMapRef.current;
632
+ const { collapsedSizePercentage, collapsible, panelSizePercentage } =
633
+ panelDataHelper(groupId, panelDataArray, panelData, layout);
612
634
 
613
- setSizes(nextSizes);
635
+ return !collapsible || panelSizePercentage > collapsedSizePercentage;
636
+ },
637
+ [groupId]
638
+ );
614
639
 
615
- // If resize change handlers have been declared, this is the time to call them.
616
- // Trigger user callbacks after updating state, so that user code can override the sizes.
617
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
618
- }
640
+ const registerPanel = useCallback((panelData: PanelData) => {
641
+ setPanelDataArray((prevPanelDataArray) => {
642
+ const nextPanelDataArray = [...prevPanelDataArray, panelData];
643
+ return nextPanelDataArray.sort((panelA, panelB) => {
644
+ const orderA = panelA.order;
645
+ const orderB = panelB.order;
646
+ if (orderA == null && orderB == null) {
647
+ return 0;
648
+ } else if (orderA == null) {
649
+ return -1;
650
+ } else if (orderB == null) {
651
+ return 1;
652
+ } else {
653
+ return orderA - orderB;
654
+ }
655
+ });
656
+ });
619
657
  }, []);
620
658
 
621
- const expandPanel = useCallback((id: string) => {
622
- const { panels, sizes: prevSizes } = committedValuesRef.current;
623
-
624
- const panel = panels.get(id);
625
- if (panel == null) {
626
- return;
627
- }
628
-
629
- const { collapsedSize, minSize } = panel.current;
630
-
631
- const sizeBeforeCollapse =
632
- panelSizeBeforeCollapse.current.get(id) || minSize;
633
- if (!sizeBeforeCollapse) {
634
- return;
635
- }
636
-
637
- const panelsArray = panelsMapToSortedArray(panels);
659
+ const registerResizeHandle = useCallback((dragHandleId: string) => {
660
+ return function resizeHandler(event: ResizeEvent) {
661
+ event.preventDefault();
662
+
663
+ const {
664
+ direction,
665
+ dragState,
666
+ id: groupId,
667
+ keyboardResizeByPercentage,
668
+ keyboardResizeByPixels,
669
+ onLayout,
670
+ panelDataArray,
671
+ layout: prevLayout,
672
+ } = committedValuesRef.current;
673
+
674
+ const { initialLayout } = dragState ?? {};
675
+
676
+ const pivotIndices = determinePivotIndices(groupId, dragHandleId);
677
+
678
+ let delta = calculateDeltaPercentage(
679
+ event,
680
+ groupId,
681
+ dragHandleId,
682
+ direction,
683
+ dragState!,
684
+ {
685
+ percentage: keyboardResizeByPercentage,
686
+ pixels: keyboardResizeByPixels,
687
+ }
688
+ );
689
+ if (delta === 0) {
690
+ return;
691
+ }
638
692
 
639
- const index = panelsArray.indexOf(panel);
640
- if (index < 0) {
641
- return;
642
- }
693
+ // Support RTL layouts
694
+ const isHorizontal = direction === "horizontal";
695
+ if (document.dir === "rtl" && isHorizontal) {
696
+ delta = -delta;
697
+ }
643
698
 
644
- const currentSize = prevSizes[index];
645
- if (currentSize !== collapsedSize) {
646
- // Panel is already expanded.
647
- return;
648
- }
699
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
700
+ const panelConstraints = panelDataArray.map(
701
+ (panelData) => panelData.constraints
702
+ );
649
703
 
650
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
651
- if (idBefore == null || idAfter == null) {
652
- return;
653
- }
704
+ const nextLayout = adjustLayoutByDelta({
705
+ delta,
706
+ groupSizePixels,
707
+ layout: initialLayout ?? prevLayout,
708
+ panelConstraints,
709
+ pivotIndices,
710
+ trigger: isKeyDown(event) ? "keyboard" : "mouse-or-touch",
711
+ });
654
712
 
655
- const isLastPanel = index === panelsArray.length - 1;
656
- const delta = isLastPanel
657
- ? collapsedSize - sizeBeforeCollapse
658
- : sizeBeforeCollapse;
659
-
660
- const nextSizes = adjustByDelta(
661
- null,
662
- panels,
663
- idBefore,
664
- idAfter,
665
- delta,
666
- prevSizes,
667
- panelSizeBeforeCollapse.current,
668
- null
669
- );
670
- if (prevSizes !== nextSizes) {
671
- const panelIdToLastNotifiedSizeMap =
672
- panelIdToLastNotifiedSizeMapRef.current;
713
+ const layoutChanged = !compareLayouts(prevLayout, nextLayout);
714
+
715
+ // Only update the cursor for layout changes triggered by touch/mouse events (not keyboard)
716
+ // Update the cursor even if the layout hasn't changed (we may need to show an invalid cursor state)
717
+ if (isMouseEvent(event) || isTouchEvent(event)) {
718
+ // Watch for multiple subsequent deltas; this might occur for tiny cursor movements.
719
+ // In this case, Panel sizes might not change–
720
+ // but updating cursor in this scenario would cause a flicker.
721
+ if (prevDeltaRef.current != delta) {
722
+ prevDeltaRef.current = delta;
723
+
724
+ if (!layoutChanged) {
725
+ // If the pointer has moved too far to resize the panel any further,
726
+ // update the cursor style for a visual clue.
727
+ // This mimics VS Code behavior.
728
+
729
+ if (isHorizontal) {
730
+ setGlobalCursorStyle(
731
+ delta < 0 ? "horizontal-min" : "horizontal-max"
732
+ );
733
+ } else {
734
+ setGlobalCursorStyle(delta < 0 ? "vertical-min" : "vertical-max");
735
+ }
736
+ } else {
737
+ // Reset the cursor style to the the normal resize cursor.
738
+ setGlobalCursorStyle(isHorizontal ? "horizontal" : "vertical");
739
+ }
740
+ }
741
+ }
673
742
 
674
- setSizes(nextSizes);
743
+ if (layoutChanged) {
744
+ setLayout(nextLayout);
745
+
746
+ if (onLayout) {
747
+ onLayout(
748
+ nextLayout.map((sizePercentage) => ({
749
+ sizePercentage,
750
+ sizePixels: convertPercentageToPixels(
751
+ sizePercentage,
752
+ groupSizePixels
753
+ ),
754
+ }))
755
+ );
756
+ }
675
757
 
676
- // If resize change handlers have been declared, this is the time to call them.
677
- // Trigger user callbacks after updating state, so that user code can override the sizes.
678
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
679
- }
758
+ callPanelCallbacks(
759
+ groupId,
760
+ panelDataArray,
761
+ nextLayout,
762
+ panelIdToLastNotifiedMixedSizesMapRef.current
763
+ );
764
+ }
765
+ };
680
766
  }, []);
681
767
 
682
- const resizePanel = useCallback((id: string, nextSize: number) => {
683
- const { panels, sizes: prevSizes } = committedValuesRef.current;
768
+ // External APIs are safe to memoize via committed values ref
769
+ const resizePanel = useCallback(
770
+ (panelData: PanelData, mixedSizes: Partial<MixedSizes>) => {
771
+ const {
772
+ layout: prevLayout,
773
+ onLayout,
774
+ panelDataArray,
775
+ } = committedValuesRef.current;
776
+
777
+ const panelConstraintsArray = panelDataArray.map(
778
+ (panelData) => panelData.constraints
779
+ );
684
780
 
685
- const panel = panels.get(id);
686
- if (panel == null) {
687
- return;
688
- }
781
+ const { groupSizePixels, panelSizePercentage, pivotIndices } =
782
+ panelDataHelper(groupId, panelDataArray, panelData, prevLayout);
783
+
784
+ const sizePercentage = getPercentageSizeFromMixedSizes(
785
+ mixedSizes,
786
+ groupSizePixels
787
+ )!;
788
+
789
+ const isLastPanel =
790
+ panelDataArray.indexOf(panelData) === panelDataArray.length - 1;
791
+ const delta = isLastPanel
792
+ ? panelSizePercentage - sizePercentage
793
+ : sizePercentage - panelSizePercentage;
794
+
795
+ const nextLayout = adjustLayoutByDelta({
796
+ delta,
797
+ groupSizePixels,
798
+ layout: prevLayout,
799
+ panelConstraints: panelConstraintsArray,
800
+ pivotIndices,
801
+ trigger: "imperative-api",
802
+ });
689
803
 
690
- const { collapsedSize, collapsible, maxSize, minSize } = panel.current;
804
+ if (!compareLayouts(prevLayout, nextLayout)) {
805
+ setLayout(nextLayout);
806
+
807
+ if (onLayout) {
808
+ onLayout(
809
+ nextLayout.map((sizePercentage) => ({
810
+ sizePercentage,
811
+ sizePixels: convertPercentageToPixels(
812
+ sizePercentage,
813
+ groupSizePixels
814
+ ),
815
+ }))
816
+ );
817
+ }
691
818
 
692
- const panelsArray = panelsMapToSortedArray(panels);
819
+ callPanelCallbacks(
820
+ groupId,
821
+ panelDataArray,
822
+ nextLayout,
823
+ panelIdToLastNotifiedMixedSizesMapRef.current
824
+ );
825
+ }
826
+ },
827
+ [groupId]
828
+ );
693
829
 
694
- const index = panelsArray.indexOf(panel);
695
- if (index < 0) {
696
- return;
697
- }
830
+ const startDragging = useCallback(
831
+ (dragHandleId: string, event: ResizeEvent) => {
832
+ const { direction, layout } = committedValuesRef.current;
698
833
 
699
- const currentSize = prevSizes[index];
700
- if (currentSize === nextSize) {
701
- return;
702
- }
834
+ const handleElement = getResizeHandleElement(dragHandleId)!;
703
835
 
704
- if (collapsible && nextSize === collapsedSize) {
705
- // This is a valid resize state.
706
- } else {
707
- nextSize = Math.min(maxSize, Math.max(minSize, nextSize));
708
- }
836
+ const initialCursorPosition = getResizeEventCursorPosition(
837
+ direction,
838
+ event
839
+ );
709
840
 
710
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
711
- if (idBefore == null || idAfter == null) {
712
- return;
713
- }
841
+ setDragState({
842
+ dragHandleId,
843
+ dragHandleRect: handleElement.getBoundingClientRect(),
844
+ initialCursorPosition,
845
+ initialLayout: layout,
846
+ });
847
+ },
848
+ []
849
+ );
714
850
 
715
- const isLastPanel = index === panelsArray.length - 1;
716
- const delta = isLastPanel ? currentSize - nextSize : nextSize - currentSize;
717
-
718
- const nextSizes = adjustByDelta(
719
- null,
720
- panels,
721
- idBefore,
722
- idAfter,
723
- delta,
724
- prevSizes,
725
- panelSizeBeforeCollapse.current,
726
- null
727
- );
728
- if (prevSizes !== nextSizes) {
729
- const panelIdToLastNotifiedSizeMap =
730
- panelIdToLastNotifiedSizeMapRef.current;
851
+ const stopDragging = useCallback(() => {
852
+ resetGlobalCursorStyle();
853
+ setDragState(null);
854
+ }, []);
731
855
 
732
- setSizes(nextSizes);
856
+ const unregisterPanel = useCallback((panelData: PanelData) => {
857
+ delete panelIdToLastNotifiedMixedSizesMapRef.current[panelData.id];
733
858
 
734
- // If resize change handlers have been declared, this is the time to call them.
735
- // Trigger user callbacks after updating state, so that user code can override the sizes.
736
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
737
- }
859
+ setPanelDataArray((panelDataArray) => {
860
+ const index = panelDataArray.indexOf(panelData);
861
+ if (index >= 0) {
862
+ panelDataArray = [...panelDataArray];
863
+ panelDataArray.splice(index, 1);
864
+ }
865
+
866
+ return panelDataArray;
867
+ });
738
868
  }, []);
739
869
 
740
870
  const context = useMemo(
741
871
  () => ({
742
- activeHandleId,
743
872
  collapsePanel,
744
873
  direction,
874
+ dragState,
745
875
  expandPanel,
876
+ getPanelSize,
746
877
  getPanelStyle,
747
878
  groupId,
879
+ isPanelCollapsed,
880
+ isPanelExpanded,
748
881
  registerPanel,
749
882
  registerResizeHandle,
750
883
  resizePanel,
751
- startDragging: (id: string, event: ResizeEvent) => {
752
- setActiveHandleId(id);
753
-
754
- if (isMouseEvent(event) || isTouchEvent(event)) {
755
- const handleElement = getResizeHandle(id)!;
756
-
757
- initialDragStateRef.current = {
758
- dragHandleRect: handleElement.getBoundingClientRect(),
759
- dragOffset: getDragOffset(event, id, direction),
760
- sizes: committedValuesRef.current.sizes,
761
- };
762
- }
763
- },
764
- stopDragging: () => {
765
- resetGlobalCursorStyle();
766
- setActiveHandleId(null);
767
-
768
- initialDragStateRef.current = null;
769
- },
884
+ startDragging,
885
+ stopDragging,
770
886
  unregisterPanel,
771
887
  }),
772
888
  [
773
- activeHandleId,
774
889
  collapsePanel,
890
+ dragState,
775
891
  direction,
776
892
  expandPanel,
893
+ getPanelSize,
777
894
  getPanelStyle,
778
895
  groupId,
896
+ isPanelCollapsed,
897
+ isPanelExpanded,
779
898
  registerPanel,
780
899
  registerResizeHandle,
781
900
  resizePanel,
901
+ startDragging,
902
+ stopDragging,
782
903
  unregisterPanel,
783
904
  ]
784
905
  );
@@ -791,17 +912,25 @@ function PanelGroupWithForwardedRef({
791
912
  width: "100%",
792
913
  };
793
914
 
794
- return createElement(PanelGroupContext.Provider, {
795
- children: createElement(Type, {
915
+ return createElement(
916
+ PanelGroupContext.Provider,
917
+ { value: context },
918
+ createElement(Type, {
796
919
  children,
797
920
  className: classNameFromProps,
921
+ style: {
922
+ ...style,
923
+ ...styleFromProps,
924
+ },
925
+
926
+ // CSS selectors
798
927
  "data-panel-group": "",
799
- "data-panel-group-direction": direction,
800
- "data-panel-group-id": groupId,
801
- style: { ...style, ...styleFromProps },
802
- }),
803
- value: context,
804
- });
928
+
929
+ // e2e test attributes
930
+ "data-panel-group-direction": isDevelopment ? direction : undefined,
931
+ "data-panel-group-id": isDevelopment ? groupId : undefined,
932
+ })
933
+ );
805
934
  }
806
935
 
807
936
  export const PanelGroup = forwardRef<
@@ -813,3 +942,45 @@ export const PanelGroup = forwardRef<
813
942
 
814
943
  PanelGroupWithForwardedRef.displayName = "PanelGroup";
815
944
  PanelGroup.displayName = "forwardRef(PanelGroup)";
945
+
946
+ function panelDataHelper(
947
+ groupId: string,
948
+ panelDataArray: PanelData[],
949
+ panelData: PanelData,
950
+ layout: number[]
951
+ ) {
952
+ const panelConstraintsArray = panelDataArray.map(
953
+ (panelData) => panelData.constraints
954
+ );
955
+
956
+ const panelIndex = panelDataArray.indexOf(panelData);
957
+ const panelConstraints = panelConstraintsArray[panelIndex];
958
+
959
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
960
+
961
+ const percentagePanelConstraints = computePercentagePanelConstraints(
962
+ panelConstraintsArray,
963
+ panelIndex,
964
+ groupSizePixels
965
+ );
966
+
967
+ const isLastPanel = panelIndex === panelDataArray.length - 1;
968
+ const pivotIndices = isLastPanel
969
+ ? [panelIndex - 1, panelIndex]
970
+ : [panelIndex, panelIndex + 1];
971
+
972
+ const panelSizePercentage = layout[panelIndex];
973
+ const panelSizePixels = convertPercentageToPixels(
974
+ panelSizePercentage,
975
+ groupSizePixels
976
+ );
977
+
978
+ return {
979
+ ...percentagePanelConstraints,
980
+ collapsible: panelConstraints.collapsible,
981
+ panelSizePercentage,
982
+ panelSizePixels,
983
+ groupSizePixels,
984
+ pivotIndices,
985
+ };
986
+ }