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
@@ -9,6 +9,7 @@ const isBrowser = typeof window !== "undefined";
9
9
  const {
10
10
  createElement,
11
11
  createContext,
12
+ createRef,
12
13
  forwardRef,
13
14
  useCallback,
14
15
  useContext,
@@ -23,6 +24,9 @@ const {
23
24
  // `toString()` prevents bundlers from trying to `import { useId } from 'react'`
24
25
  const useId = React["useId".toString()];
25
26
 
27
+ const PanelGroupContext = createContext(null);
28
+ PanelGroupContext.displayName = "PanelGroupContext";
29
+
26
30
  const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : () => {};
27
31
 
28
32
  const wrappedUseId = typeof useId === "function" ? useId : () => null;
@@ -33,123 +37,134 @@ function useUniqueId(idFromParams = null) {
33
37
  if (idRef.current === null) {
34
38
  idRef.current = "" + counter++;
35
39
  }
36
- return idRef.current;
40
+ return idFromParams ?? idRef.current;
37
41
  }
38
42
 
39
- const PanelGroupContext = createContext(null);
40
- PanelGroupContext.displayName = "PanelGroupContext";
41
-
42
43
  function PanelWithForwardedRef({
43
- children = null,
44
+ children,
44
45
  className: classNameFromProps = "",
45
- collapsedSize = 0,
46
- collapsible = false,
47
- defaultSize = null,
46
+ collapsedSizePercentage,
47
+ collapsedSizePixels,
48
+ collapsible,
49
+ defaultSizePercentage,
50
+ defaultSizePixels,
48
51
  forwardedRef,
49
- id: idFromProps = null,
50
- maxSize = 100,
51
- minSize = 10,
52
- onCollapse = null,
53
- onResize = null,
54
- order = null,
55
- style: styleFromProps = {},
52
+ id: idFromProps,
53
+ maxSizePercentage,
54
+ maxSizePixels,
55
+ minSizePercentage,
56
+ minSizePixels,
57
+ onCollapse,
58
+ onExpand,
59
+ onResize,
60
+ order,
61
+ style: styleFromProps,
56
62
  tagName: Type = "div"
57
63
  }) {
58
64
  const context = useContext(PanelGroupContext);
59
65
  if (context === null) {
60
66
  throw Error(`Panel components must be rendered within a PanelGroup container`);
61
67
  }
62
- const panelId = useUniqueId(idFromProps);
63
68
  const {
64
69
  collapsePanel,
65
70
  expandPanel,
71
+ getPanelSize,
66
72
  getPanelStyle,
73
+ isPanelCollapsed,
67
74
  registerPanel,
68
75
  resizePanel,
69
76
  unregisterPanel
70
77
  } = context;
71
-
72
- // Use a ref to guard against users passing inline props
73
- const callbacksRef = useRef({
74
- onCollapse,
75
- onResize
76
- });
77
- useEffect(() => {
78
- callbacksRef.current.onCollapse = onCollapse;
79
- callbacksRef.current.onResize = onResize;
80
- });
81
-
82
- // Basic props validation
83
- if (minSize < 0 || minSize > 100) {
84
- throw Error(`Panel minSize must be between 0 and 100, but was ${minSize}`);
85
- } else if (maxSize < 0 || maxSize > 100) {
86
- throw Error(`Panel maxSize must be between 0 and 100, but was ${maxSize}`);
87
- } else {
88
- if (defaultSize !== null) {
89
- if (defaultSize < 0 || defaultSize > 100) {
90
- throw Error(`Panel defaultSize must be between 0 and 100, but was ${defaultSize}`);
91
- } else if (minSize > defaultSize && !collapsible) {
92
- console.error(`Panel minSize ${minSize} cannot be greater than defaultSize ${defaultSize}`);
93
- defaultSize = minSize;
94
- }
95
- }
96
- }
97
- const style = getPanelStyle(panelId, defaultSize);
98
- const committedValuesRef = useRef({
99
- size: parseSizeFromStyle(style)
100
- });
78
+ const panelId = useUniqueId(idFromProps);
101
79
  const panelDataRef = useRef({
102
- callbacksRef,
103
- collapsedSize,
104
- collapsible,
105
- defaultSize,
80
+ callbacks: {
81
+ onCollapse,
82
+ onExpand,
83
+ onResize
84
+ },
85
+ constraints: {
86
+ collapsedSizePercentage,
87
+ collapsedSizePixels,
88
+ collapsible,
89
+ defaultSizePercentage,
90
+ defaultSizePixels,
91
+ maxSizePercentage,
92
+ maxSizePixels,
93
+ minSizePercentage,
94
+ minSizePixels
95
+ },
106
96
  id: panelId,
107
- idWasAutoGenerated: idFromProps == null,
108
- maxSize,
109
- minSize,
97
+ idIsFromProps: idFromProps !== undefined,
110
98
  order
111
99
  });
100
+ useRef({
101
+ didLogMissingDefaultSizeWarning: false
102
+ });
112
103
  useIsomorphicLayoutEffect(() => {
113
- committedValuesRef.current.size = parseSizeFromStyle(style);
114
- panelDataRef.current.callbacksRef = callbacksRef;
115
- panelDataRef.current.collapsedSize = collapsedSize;
116
- panelDataRef.current.collapsible = collapsible;
117
- panelDataRef.current.defaultSize = defaultSize;
104
+ const {
105
+ callbacks,
106
+ constraints
107
+ } = panelDataRef.current;
118
108
  panelDataRef.current.id = panelId;
119
- panelDataRef.current.idWasAutoGenerated = idFromProps == null;
120
- panelDataRef.current.maxSize = maxSize;
121
- panelDataRef.current.minSize = minSize;
109
+ panelDataRef.current.idIsFromProps = idFromProps !== undefined;
122
110
  panelDataRef.current.order = order;
111
+ callbacks.onCollapse = onCollapse;
112
+ callbacks.onExpand = onExpand;
113
+ callbacks.onResize = onResize;
114
+ constraints.collapsedSizePercentage = collapsedSizePercentage;
115
+ constraints.collapsedSizePixels = collapsedSizePixels;
116
+ constraints.collapsible = collapsible;
117
+ constraints.defaultSizePercentage = defaultSizePercentage;
118
+ constraints.defaultSizePixels = defaultSizePixels;
119
+ constraints.maxSizePercentage = maxSizePercentage;
120
+ constraints.maxSizePixels = maxSizePixels;
121
+ constraints.minSizePercentage = minSizePercentage;
122
+ constraints.minSizePixels = minSizePixels;
123
123
  });
124
124
  useIsomorphicLayoutEffect(() => {
125
- registerPanel(panelId, panelDataRef);
125
+ const panelData = panelDataRef.current;
126
+ registerPanel(panelData);
126
127
  return () => {
127
- unregisterPanel(panelId);
128
+ unregisterPanel(panelData);
128
129
  };
129
130
  }, [order, panelId, registerPanel, unregisterPanel]);
130
131
  useImperativeHandle(forwardedRef, () => ({
131
- collapse: () => collapsePanel(panelId),
132
- expand: () => expandPanel(panelId),
133
- getCollapsed() {
134
- return committedValuesRef.current.size === 0;
132
+ collapse: () => {
133
+ collapsePanel(panelDataRef.current);
134
+ },
135
+ expand: () => {
136
+ expandPanel(panelDataRef.current);
137
+ },
138
+ getId() {
139
+ return panelId;
135
140
  },
136
141
  getSize() {
137
- return committedValuesRef.current.size;
142
+ return getPanelSize(panelDataRef.current);
138
143
  },
139
- resize: percentage => resizePanel(panelId, percentage)
140
- }), [collapsePanel, expandPanel, panelId, resizePanel]);
144
+ isCollapsed() {
145
+ return isPanelCollapsed(panelDataRef.current);
146
+ },
147
+ isExpanded() {
148
+ return !isPanelCollapsed(panelDataRef.current);
149
+ },
150
+ resize: mixedSizes => {
151
+ resizePanel(panelDataRef.current, mixedSizes);
152
+ }
153
+ }), [collapsePanel, expandPanel, getPanelSize, isPanelCollapsed, panelId, resizePanel]);
154
+ const style = getPanelStyle(panelDataRef.current);
141
155
  return createElement(Type, {
142
156
  children,
143
157
  className: classNameFromProps,
144
- "data-panel": "",
145
- "data-panel-collapsible": collapsible || undefined,
146
- "data-panel-id": panelId,
147
- "data-panel-size": parseFloat("" + style.flexGrow).toFixed(1),
148
- id: `data-panel-id-${panelId}`,
149
158
  style: {
150
159
  ...style,
151
160
  ...styleFromProps
152
- }
161
+ },
162
+ // CSS selectors
163
+ "data-panel": "",
164
+ // e2e test attributes
165
+ "data-panel-collapsible": undefined,
166
+ "data-panel-id": undefined,
167
+ "data-panel-size": undefined
153
168
  });
154
169
  }
155
170
  const Panel = forwardRef((props, ref) => createElement(PanelWithForwardedRef, {
@@ -159,33 +174,133 @@ const Panel = forwardRef((props, ref) => createElement(PanelWithForwardedRef, {
159
174
  PanelWithForwardedRef.displayName = "Panel";
160
175
  Panel.displayName = "forwardRef(Panel)";
161
176
 
162
- // HACK
163
- function parseSizeFromStyle(style) {
177
+ const PRECISION = 10;
178
+
179
+ function convertPixelsToPercentage(pixels, groupSizePixels) {
180
+ return pixels / groupSizePixels * 100;
181
+ }
182
+
183
+ function convertPixelConstraintsToPercentages(panelConstraints, groupSizePixels) {
184
+ let {
185
+ collapsedSizePercentage = 0,
186
+ collapsedSizePixels,
187
+ defaultSizePercentage,
188
+ defaultSizePixels,
189
+ maxSizePercentage = 100,
190
+ maxSizePixels,
191
+ minSizePercentage = 0,
192
+ minSizePixels
193
+ } = panelConstraints;
194
+ if (collapsedSizePixels != null) {
195
+ collapsedSizePercentage = convertPixelsToPercentage(collapsedSizePixels, groupSizePixels);
196
+ }
197
+ if (defaultSizePixels != null) {
198
+ defaultSizePercentage = convertPixelsToPercentage(defaultSizePixels, groupSizePixels);
199
+ }
200
+ if (minSizePixels != null) {
201
+ minSizePercentage = convertPixelsToPercentage(minSizePixels, groupSizePixels);
202
+ }
203
+ if (maxSizePixels != null) {
204
+ maxSizePercentage = convertPixelsToPercentage(maxSizePixels, groupSizePixels);
205
+ }
206
+ return {
207
+ collapsedSizePercentage,
208
+ defaultSizePercentage,
209
+ maxSizePercentage,
210
+ minSizePercentage
211
+ };
212
+ }
213
+
214
+ function computePercentagePanelConstraints(panelConstraintsArray, panelIndex, groupSizePixels) {
215
+ // All panel constraints, excluding the current one
216
+ let totalMinConstraints = 0;
217
+ let totalMaxConstraints = 0;
218
+ for (let index = 0; index < panelConstraintsArray.length; index++) {
219
+ if (index !== panelIndex) {
220
+ const {
221
+ collapsible
222
+ } = panelConstraintsArray[index];
223
+ const {
224
+ collapsedSizePercentage,
225
+ maxSizePercentage,
226
+ minSizePercentage
227
+ } = convertPixelConstraintsToPercentages(panelConstraintsArray[index], groupSizePixels);
228
+ totalMaxConstraints += maxSizePercentage;
229
+ totalMinConstraints += collapsible ? collapsedSizePercentage : minSizePercentage;
230
+ }
231
+ }
164
232
  const {
165
- flexGrow
166
- } = style;
167
- if (typeof flexGrow === "string") {
168
- return parseFloat(flexGrow);
233
+ collapsedSizePercentage,
234
+ defaultSizePercentage,
235
+ maxSizePercentage,
236
+ minSizePercentage
237
+ } = convertPixelConstraintsToPercentages(panelConstraintsArray[panelIndex], groupSizePixels);
238
+ return {
239
+ collapsedSizePercentage,
240
+ defaultSizePercentage,
241
+ maxSizePercentage: panelConstraintsArray.length > 1 ? Math.min(maxSizePercentage, 100 - totalMinConstraints) : maxSizePercentage,
242
+ minSizePercentage: panelConstraintsArray.length > 1 ? Math.max(minSizePercentage, 100 - totalMaxConstraints) : minSizePercentage
243
+ };
244
+ }
245
+
246
+ function fuzzyCompareNumbers(actual, expected, fractionDigits = PRECISION) {
247
+ actual = parseFloat(actual.toFixed(fractionDigits));
248
+ expected = parseFloat(expected.toFixed(fractionDigits));
249
+ const delta = actual - expected;
250
+ if (delta === 0) {
251
+ return 0;
169
252
  } else {
170
- return flexGrow;
253
+ return delta > 0 ? 1 : -1;
171
254
  }
172
255
  }
173
256
 
174
- const PRECISION = 10;
257
+ function fuzzyNumbersEqual(actual, expected, fractionDigits) {
258
+ return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0;
259
+ }
175
260
 
176
- function adjustByDelta(event, panels, idBefore, idAfter, delta, prevSizes, panelSizeBeforeCollapse, initialDragState) {
261
+ // Panel size must be in percentages; pixel values should be pre-converted
262
+ function resizePanel({
263
+ groupSizePixels,
264
+ panelConstraints,
265
+ panelIndex,
266
+ size
267
+ }) {
268
+ let {
269
+ collapsible
270
+ } = panelConstraints[panelIndex];
177
271
  const {
178
- sizes: initialSizes
179
- } = initialDragState || {};
272
+ collapsedSizePercentage,
273
+ maxSizePercentage,
274
+ minSizePercentage
275
+ } = computePercentagePanelConstraints(panelConstraints, panelIndex, groupSizePixels);
276
+ if (minSizePercentage != null) {
277
+ if (fuzzyCompareNumbers(size, minSizePercentage) < 0) {
278
+ if (collapsible) {
279
+ size = collapsedSizePercentage;
280
+ } else {
281
+ size = minSizePercentage;
282
+ }
283
+ }
284
+ }
285
+ if (maxSizePercentage != null) {
286
+ size = Math.min(maxSizePercentage, size);
287
+ }
288
+ return size;
289
+ }
180
290
 
181
- // If we're resizing by mouse or touch, use the initial sizes as a base.
182
- // This has the benefit of causing force-collapsed panels to spring back open if drag is reversed.
183
- const baseSizes = initialSizes || prevSizes;
184
- if (delta === 0) {
185
- return baseSizes;
291
+ // All units must be in percentages; pixel values should be pre-converted
292
+ function adjustLayoutByDelta({
293
+ delta,
294
+ groupSizePixels,
295
+ layout: prevLayout,
296
+ panelConstraints,
297
+ pivotIndices,
298
+ trigger
299
+ }) {
300
+ if (fuzzyNumbersEqual(delta, 0)) {
301
+ return prevLayout;
186
302
  }
187
- const panelsArray = panelsMapToSortedArray(panels);
188
- const nextSizes = baseSizes.concat();
303
+ const nextLayout = [...prevLayout];
189
304
  let deltaApplied = 0;
190
305
 
191
306
  // A resizing panel affects the panels before or after it.
@@ -196,268 +311,350 @@ function adjustByDelta(event, panels, idBefore, idAfter, delta, prevSizes, panel
196
311
  // A positive delta means the panel immediately before the resizer should "expand".
197
312
  // This is accomplished by shrinking/contracting (and shifting) one or more of the panels after the resizer.
198
313
 
199
- // Max-bounds check the panel being expanded first.
314
+ // First, check the panel we're pivoting around;
315
+ // We should only expand or contract by as much as its constraints allow
200
316
  {
201
- const pivotId = delta < 0 ? idAfter : idBefore;
202
- const index = panelsArray.findIndex(panel => panel.current.id === pivotId);
203
- const panel = panelsArray[index];
204
- const baseSize = baseSizes[index];
205
- const nextSize = safeResizePanel(panel, Math.abs(delta), baseSize, event);
206
- if (baseSize === nextSize) {
207
- // If there's no room for the pivot panel to grow, we can ignore this drag update.
208
- return baseSizes;
209
- } else {
210
- if (nextSize === 0 && baseSize > 0) {
211
- panelSizeBeforeCollapse.set(pivotId, baseSize);
317
+ const pivotIndex = delta < 0 ? pivotIndices[1] : pivotIndices[0];
318
+ const initialSize = nextLayout[pivotIndex];
319
+ const {
320
+ collapsible
321
+ } = panelConstraints[pivotIndex];
322
+ const {
323
+ collapsedSizePercentage,
324
+ maxSizePercentage,
325
+ minSizePercentage
326
+ } = computePercentagePanelConstraints(panelConstraints, pivotIndex, groupSizePixels);
327
+ const isCollapsed = collapsible && fuzzyNumbersEqual(initialSize, collapsedSizePercentage);
328
+ let unsafeSize = initialSize + Math.abs(delta);
329
+ if (isCollapsed) {
330
+ switch (trigger) {
331
+ case "keyboard":
332
+ if (minSizePercentage > unsafeSize) {
333
+ unsafeSize = minSizePercentage;
334
+ }
212
335
  }
213
- delta = delta < 0 ? baseSize - nextSize : nextSize - baseSize;
214
336
  }
215
- }
216
- let pivotId = delta < 0 ? idBefore : idAfter;
217
- let index = panelsArray.findIndex(panel => panel.current.id === pivotId);
218
- while (true) {
219
- const panel = panelsArray[index];
220
- const baseSize = baseSizes[index];
221
- const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied);
222
- const nextSize = safeResizePanel(panel, 0 - deltaRemaining, baseSize, event);
223
- if (baseSize !== nextSize) {
224
- if (nextSize === 0 && baseSize > 0) {
225
- panelSizeBeforeCollapse.set(panel.current.id, baseSize);
226
- }
227
- deltaApplied += baseSize - nextSize;
228
- nextSizes[index] = nextSize;
229
- if (deltaApplied.toPrecision(PRECISION).localeCompare(Math.abs(delta).toPrecision(PRECISION), undefined, {
230
- numeric: true
231
- }) >= 0) {
232
- break;
233
- }
337
+ const safeSize = resizePanel({
338
+ groupSizePixels,
339
+ panelConstraints,
340
+ panelIndex: pivotIndex,
341
+ size: unsafeSize
342
+ });
343
+ if (fuzzyNumbersEqual(initialSize, safeSize)) {
344
+ // If there's no room for the pivot panel to grow, we should ignore this change
345
+ return nextLayout;
346
+ } else {
347
+ delta = delta < 0 ? initialSize - safeSize : safeSize - initialSize;
234
348
  }
235
- if (delta < 0) {
236
- if (--index < 0) {
237
- break;
349
+ }
350
+
351
+ // Delta added to a panel needs to be subtracted from other panels
352
+ // within the constraints that those panels allow
353
+ {
354
+ const pivotIndex = delta < 0 ? pivotIndices[0] : pivotIndices[1];
355
+ let index = pivotIndex;
356
+ while (index >= 0 && index < panelConstraints.length) {
357
+ const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied);
358
+ const prevSize = prevLayout[index];
359
+ const unsafeSize = prevSize - deltaRemaining;
360
+ let safeSize = resizePanel({
361
+ groupSizePixels,
362
+ panelConstraints,
363
+ panelIndex: index,
364
+ size: unsafeSize
365
+ });
366
+ if (!fuzzyNumbersEqual(prevSize, safeSize)) {
367
+ deltaApplied += prevSize - safeSize;
368
+ nextLayout[index] = safeSize;
369
+ if (deltaApplied.toPrecision(3).localeCompare(Math.abs(delta).toPrecision(3), undefined, {
370
+ numeric: true
371
+ }) >= 0) {
372
+ break;
373
+ }
238
374
  }
239
- } else {
240
- if (++index >= panelsArray.length) {
241
- break;
375
+ if (delta < 0) {
376
+ index--;
377
+ } else {
378
+ index++;
242
379
  }
243
380
  }
244
381
  }
245
382
 
246
383
  // If we were unable to resize any of the panels panels, return the previous state.
247
- // This will essentially bailout and ignore the "mousemove" event.
248
- if (deltaApplied === 0) {
249
- return baseSizes;
384
+ // This will essentially bailout and ignore e.g. drags past a panel's boundaries
385
+ if (fuzzyNumbersEqual(deltaApplied, 0)) {
386
+ return prevLayout;
250
387
  }
388
+ {
389
+ const pivotIndex = delta < 0 ? pivotIndices[1] : pivotIndices[0];
390
+ const unsafeSize = prevLayout[pivotIndex] + deltaApplied;
391
+ const safeSize = resizePanel({
392
+ groupSizePixels,
393
+ panelConstraints,
394
+ panelIndex: pivotIndex,
395
+ size: unsafeSize
396
+ });
251
397
 
252
- // Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract.
253
- pivotId = delta < 0 ? idAfter : idBefore;
254
- index = panelsArray.findIndex(panel => panel.current.id === pivotId);
255
- nextSizes[index] = baseSizes[index] + deltaApplied;
256
- return nextSizes;
257
- }
258
- function callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap) {
259
- sizes.forEach((size, index) => {
260
- const panelRef = panelsArray[index];
261
- if (!panelRef) {
262
- // Handle initial mount (when panels are registered too late to be in the panels array)
263
- // The subsequent render+effects will handle the resize notification
264
- return;
265
- }
266
- const {
267
- callbacksRef,
268
- collapsedSize,
269
- collapsible,
270
- id
271
- } = panelRef.current;
272
- const lastNotifiedSize = panelIdToLastNotifiedSizeMap[id];
273
- if (lastNotifiedSize !== size) {
274
- panelIdToLastNotifiedSizeMap[id] = size;
275
- const {
276
- onCollapse,
277
- onResize
278
- } = callbacksRef.current;
279
- if (onResize) {
280
- onResize(size, lastNotifiedSize);
398
+ // Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract.
399
+ nextLayout[pivotIndex] = safeSize;
400
+
401
+ // Edge case where expanding or contracting one panel caused another one to change collapsed state
402
+ if (!fuzzyNumbersEqual(safeSize, unsafeSize)) {
403
+ let deltaRemaining = unsafeSize - safeSize;
404
+ const pivotIndex = delta < 0 ? pivotIndices[1] : pivotIndices[0];
405
+ let index = pivotIndex;
406
+ while (index >= 0 && index < panelConstraints.length) {
407
+ const prevSize = nextLayout[index];
408
+ const unsafeSize = prevSize + deltaRemaining;
409
+ const safeSize = resizePanel({
410
+ groupSizePixels,
411
+ panelConstraints,
412
+ panelIndex: index,
413
+ size: unsafeSize
414
+ });
415
+ if (!fuzzyNumbersEqual(prevSize, safeSize)) {
416
+ deltaRemaining -= safeSize - prevSize;
417
+ nextLayout[index] = safeSize;
418
+ }
419
+ if (fuzzyNumbersEqual(deltaRemaining, 0)) {
420
+ break;
421
+ }
422
+ if (delta > 0) {
423
+ index--;
424
+ } else {
425
+ index++;
426
+ }
281
427
  }
282
- if (collapsible && onCollapse) {
283
- if ((lastNotifiedSize == null || lastNotifiedSize === collapsedSize) && size !== collapsedSize) {
284
- onCollapse(false);
285
- } else if (lastNotifiedSize !== collapsedSize && size === collapsedSize) {
286
- onCollapse(true);
428
+
429
+ // If we can't redistribute, this layout is invalid;
430
+ // There may be an incremental layout that is valid though
431
+ if (!fuzzyNumbersEqual(deltaRemaining, 0)) {
432
+ try {
433
+ return adjustLayoutByDelta({
434
+ delta: delta < 0 ? delta + 1 : delta - 1,
435
+ groupSizePixels,
436
+ layout: prevLayout,
437
+ panelConstraints,
438
+ pivotIndices,
439
+ trigger
440
+ });
441
+ } catch (error) {
442
+ if (error instanceof RangeError) {
443
+ console.error(`Could not apply delta ${delta} to layout`);
444
+ return prevLayout;
445
+ }
446
+ } finally {
287
447
  }
288
448
  }
289
449
  }
290
- });
291
- }
292
- function getBeforeAndAfterIds(id, panelsArray) {
293
- if (panelsArray.length < 2) {
294
- return [null, null];
295
- }
296
- const index = panelsArray.findIndex(panel => panel.current.id === id);
297
- if (index < 0) {
298
- return [null, null];
299
450
  }
300
- const isLastPanel = index === panelsArray.length - 1;
301
- const idBefore = isLastPanel ? panelsArray[index - 1].current.id : id;
302
- const idAfter = isLastPanel ? id : panelsArray[index + 1].current.id;
303
- return [idBefore, idAfter];
451
+ return nextLayout;
304
452
  }
305
453
 
306
- // This method returns a number between 1 and 100 representing
307
- // the % of the group's overall space this panel should occupy.
308
- function getFlexGrow(panels, id, sizes) {
309
- if (panels.size === 1) {
310
- return "100";
311
- }
312
- const panelsArray = panelsMapToSortedArray(panels);
313
- const index = panelsArray.findIndex(panel => panel.current.id === id);
314
- const size = sizes[index];
315
- if (size == null) {
316
- return "0";
454
+ function assert(expectedCondition, message = "Assertion failed!") {
455
+ if (!expectedCondition) {
456
+ console.error(message);
457
+ throw Error(message);
317
458
  }
318
- return size.toPrecision(PRECISION);
319
459
  }
320
- function getPanel(id) {
321
- const element = document.querySelector(`[data-panel-id="${id}"]`);
322
- if (element) {
323
- return element;
460
+
461
+ function getPercentageSizeFromMixedSizes({
462
+ sizePercentage,
463
+ sizePixels
464
+ }, groupSizePixels) {
465
+ if (sizePercentage != null) {
466
+ return sizePercentage;
467
+ } else if (sizePixels != null) {
468
+ return convertPixelsToPercentage(sizePixels, groupSizePixels);
324
469
  }
325
- return null;
470
+ return undefined;
471
+ }
472
+
473
+ function calculateAriaValues({
474
+ groupSizePixels,
475
+ layout,
476
+ panelsArray,
477
+ pivotIndices
478
+ }) {
479
+ let currentMinSize = 0;
480
+ let currentMaxSize = 100;
481
+ let totalMinSize = 0;
482
+ let totalMaxSize = 0;
483
+
484
+ // A panel's effective min/max sizes also need to account for other panel's sizes.
485
+ panelsArray.forEach((panelData, index) => {
486
+ const {
487
+ constraints
488
+ } = panelData;
489
+ const {
490
+ maxSizePercentage,
491
+ maxSizePixels,
492
+ minSizePercentage,
493
+ minSizePixels
494
+ } = constraints;
495
+ const minSize = getPercentageSizeFromMixedSizes({
496
+ sizePercentage: minSizePercentage,
497
+ sizePixels: minSizePixels
498
+ }, groupSizePixels) ?? 0;
499
+ const maxSize = getPercentageSizeFromMixedSizes({
500
+ sizePercentage: maxSizePercentage,
501
+ sizePixels: maxSizePixels
502
+ }, groupSizePixels) ?? 100;
503
+ if (index === pivotIndices[0]) {
504
+ currentMinSize = minSize;
505
+ currentMaxSize = maxSize;
506
+ } else {
507
+ totalMinSize += minSize;
508
+ totalMaxSize += maxSize;
509
+ }
510
+ });
511
+ const valueMax = Math.min(currentMaxSize, 100 - totalMinSize);
512
+ const valueMin = Math.max(currentMinSize, 100 - totalMaxSize);
513
+ const valueNow = layout[pivotIndices[0]];
514
+ return {
515
+ valueMax,
516
+ valueMin,
517
+ valueNow
518
+ };
519
+ }
520
+
521
+ function getResizeHandleElementsForGroup(groupId) {
522
+ return Array.from(document.querySelectorAll(`[data-panel-resize-handle-id][data-panel-group-id="${groupId}"]`));
523
+ }
524
+
525
+ function getResizeHandleElementIndex(groupId, id) {
526
+ const handles = getResizeHandleElementsForGroup(groupId);
527
+ const index = handles.findIndex(handle => handle.getAttribute("data-panel-resize-handle-id") === id);
528
+ return index ?? null;
529
+ }
530
+
531
+ function determinePivotIndices(groupId, dragHandleId) {
532
+ const index = getResizeHandleElementIndex(groupId, dragHandleId);
533
+ return index != null ? [index, index + 1] : [-1, -1];
326
534
  }
327
- function getPanelGroup(id) {
535
+
536
+ function getPanelGroupElement(id) {
328
537
  const element = document.querySelector(`[data-panel-group-id="${id}"]`);
329
538
  if (element) {
330
539
  return element;
331
540
  }
332
541
  return null;
333
542
  }
334
- function getResizeHandle(id) {
543
+
544
+ function calculateAvailablePanelSizeInPixels(groupId) {
545
+ const panelGroupElement = getPanelGroupElement(groupId);
546
+ if (panelGroupElement == null) {
547
+ return NaN;
548
+ }
549
+ const direction = panelGroupElement.getAttribute("data-panel-group-direction");
550
+ const resizeHandles = getResizeHandleElementsForGroup(groupId);
551
+ if (direction === "horizontal") {
552
+ return panelGroupElement.offsetWidth - resizeHandles.reduce((accumulated, handle) => {
553
+ return accumulated + handle.offsetWidth;
554
+ }, 0);
555
+ } else {
556
+ return panelGroupElement.offsetHeight - resizeHandles.reduce((accumulated, handle) => {
557
+ return accumulated + handle.offsetHeight;
558
+ }, 0);
559
+ }
560
+ }
561
+
562
+ function getAvailableGroupSizePixels(groupId) {
563
+ const panelGroupElement = getPanelGroupElement(groupId);
564
+ if (panelGroupElement == null) {
565
+ return NaN;
566
+ }
567
+ const direction = panelGroupElement.getAttribute("data-panel-group-direction");
568
+ const resizeHandles = getResizeHandleElementsForGroup(groupId);
569
+ if (direction === "horizontal") {
570
+ return panelGroupElement.offsetWidth - resizeHandles.reduce((accumulated, handle) => {
571
+ return accumulated + handle.offsetWidth;
572
+ }, 0);
573
+ } else {
574
+ return panelGroupElement.offsetHeight - resizeHandles.reduce((accumulated, handle) => {
575
+ return accumulated + handle.offsetHeight;
576
+ }, 0);
577
+ }
578
+ }
579
+
580
+ function getResizeHandleElement(id) {
335
581
  const element = document.querySelector(`[data-panel-resize-handle-id="${id}"]`);
336
582
  if (element) {
337
583
  return element;
338
584
  }
339
585
  return null;
340
586
  }
341
- function getResizeHandleIndex(id) {
342
- const handles = getResizeHandles();
343
- const index = handles.findIndex(handle => handle.getAttribute("data-panel-resize-handle-id") === id);
344
- return index ?? null;
345
- }
346
- function getResizeHandles() {
347
- return Array.from(document.querySelectorAll(`[data-panel-resize-handle-id]`));
348
- }
349
- function getResizeHandlesForGroup(groupId) {
350
- return Array.from(document.querySelectorAll(`[data-panel-resize-handle-id][data-panel-group-id="${groupId}"]`));
351
- }
587
+
352
588
  function getResizeHandlePanelIds(groupId, handleId, panelsArray) {
353
- const handle = getResizeHandle(handleId);
354
- const handles = getResizeHandlesForGroup(groupId);
589
+ const handle = getResizeHandleElement(handleId);
590
+ const handles = getResizeHandleElementsForGroup(groupId);
355
591
  const index = handle ? handles.indexOf(handle) : -1;
356
- const idBefore = panelsArray[index]?.current?.id ?? null;
357
- const idAfter = panelsArray[index + 1]?.current?.id ?? null;
592
+ const idBefore = panelsArray[index]?.id ?? null;
593
+ const idAfter = panelsArray[index + 1]?.id ?? null;
358
594
  return [idBefore, idAfter];
359
595
  }
360
- function panelsMapToSortedArray(panels) {
361
- return Array.from(panels.values()).sort((panelA, panelB) => {
362
- const orderA = panelA.current.order;
363
- const orderB = panelB.current.order;
364
- if (orderA == null && orderB == null) {
365
- return 0;
366
- } else if (orderA == null) {
367
- return -1;
368
- } else if (orderB == null) {
369
- return 1;
370
- } else {
371
- return orderA - orderB;
372
- }
373
- });
374
- }
375
- function safeResizePanel(panel, delta, prevSize, event) {
376
- const nextSizeUnsafe = prevSize + delta;
377
- const {
378
- collapsedSize,
379
- collapsible,
380
- maxSize,
381
- minSize
382
- } = panel.current;
383
- if (collapsible) {
384
- if (prevSize > collapsedSize) {
385
- // Mimic VS COde behavior; collapse a panel if it's smaller than half of its min-size
386
- if (nextSizeUnsafe <= minSize / 2 + collapsedSize) {
387
- return collapsedSize;
388
- }
389
- } else {
390
- const isKeyboardEvent = event?.type?.startsWith("key");
391
- if (!isKeyboardEvent) {
392
- // Keyboard events should expand a collapsed panel to the min size,
393
- // but mouse events should wait until the panel has reached its min size
394
- // to avoid a visual flickering when dragging between collapsed and min size.
395
- if (nextSizeUnsafe < minSize) {
396
- return collapsedSize;
397
- }
398
- }
399
- }
400
- }
401
- const nextSize = Math.min(maxSize, Math.max(minSize, nextSizeUnsafe));
402
- return nextSize;
403
- }
404
-
405
- function assert(expectedCondition, message = "Assertion failed!") {
406
- if (!expectedCondition) {
407
- console.error(message);
408
- throw Error(message);
409
- }
410
- }
411
596
 
412
597
  // https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
413
598
 
414
599
  function useWindowSplitterPanelGroupBehavior({
415
600
  committedValuesRef,
416
601
  groupId,
417
- panels,
418
- setSizes,
419
- sizes,
420
- panelSizeBeforeCollapse
602
+ layout,
603
+ panelDataArray,
604
+ setLayout
421
605
  }) {
606
+ useRef({
607
+ didWarnAboutMissingResizeHandle: false
608
+ });
609
+ useIsomorphicLayoutEffect(() => {
610
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
611
+ const resizeHandleElements = getResizeHandleElementsForGroup(groupId);
612
+ for (let index = 0; index < panelDataArray.length - 1; index++) {
613
+ const {
614
+ valueMax,
615
+ valueMin,
616
+ valueNow
617
+ } = calculateAriaValues({
618
+ groupSizePixels,
619
+ layout,
620
+ panelsArray: panelDataArray,
621
+ pivotIndices: [index, index + 1]
622
+ });
623
+ const resizeHandleElement = resizeHandleElements[index];
624
+ if (resizeHandleElement == null) ; else {
625
+ resizeHandleElement.setAttribute("aria-controls", panelDataArray[index].id);
626
+ resizeHandleElement.setAttribute("aria-valuemax", "" + Math.round(valueMax));
627
+ resizeHandleElement.setAttribute("aria-valuemin", "" + Math.round(valueMin));
628
+ resizeHandleElement.setAttribute("aria-valuenow", "" + Math.round(valueNow));
629
+ }
630
+ }
631
+ return () => {
632
+ resizeHandleElements.forEach((resizeHandleElement, index) => {
633
+ resizeHandleElement.removeAttribute("aria-controls");
634
+ resizeHandleElement.removeAttribute("aria-valuemax");
635
+ resizeHandleElement.removeAttribute("aria-valuemin");
636
+ resizeHandleElement.removeAttribute("aria-valuenow");
637
+ });
638
+ };
639
+ }, [groupId, layout, panelDataArray]);
422
640
  useEffect(() => {
423
641
  const {
424
642
  direction,
425
- panels
643
+ panelDataArray
426
644
  } = committedValuesRef.current;
427
- const groupElement = getPanelGroup(groupId);
645
+ const groupElement = getPanelGroupElement(groupId);
646
+ assert(groupElement != null, `No group found for id "${groupId}"`);
428
647
  const {
429
648
  height,
430
649
  width
431
650
  } = groupElement.getBoundingClientRect();
432
- const handles = getResizeHandlesForGroup(groupId);
651
+ const handles = getResizeHandleElementsForGroup(groupId);
433
652
  const cleanupFunctions = handles.map(handle => {
434
653
  const handleId = handle.getAttribute("data-panel-resize-handle-id");
435
- const panelsArray = panelsMapToSortedArray(panels);
436
- const [idBefore, idAfter] = getResizeHandlePanelIds(groupId, handleId, panelsArray);
654
+ const [idBefore, idAfter] = getResizeHandlePanelIds(groupId, handleId, panelDataArray);
437
655
  if (idBefore == null || idAfter == null) {
438
656
  return () => {};
439
657
  }
440
- let minSize = 0;
441
- let maxSize = 100;
442
- let totalMinSize = 0;
443
- let totalMaxSize = 0;
444
-
445
- // A panel's effective min/max sizes also need to account for other panel's sizes.
446
- panelsArray.forEach(panelData => {
447
- if (panelData.current.id === idBefore) {
448
- maxSize = panelData.current.maxSize;
449
- minSize = panelData.current.minSize;
450
- } else {
451
- totalMinSize += panelData.current.minSize;
452
- totalMaxSize += panelData.current.maxSize;
453
- }
454
- });
455
- const ariaValueMax = Math.min(maxSize, 100 - totalMinSize);
456
- const ariaValueMin = Math.max(minSize, (panelsArray.length - 1) * 100 - totalMaxSize);
457
- const flexGrow = getFlexGrow(panels, idBefore, sizes);
458
- handle.setAttribute("aria-valuemax", "" + Math.round(ariaValueMax));
459
- handle.setAttribute("aria-valuemin", "" + Math.round(ariaValueMin));
460
- handle.setAttribute("aria-valuenow", "" + Math.round(parseInt(flexGrow)));
461
658
  const onKeyDown = event => {
462
659
  if (event.defaultPrevented) {
463
660
  return;
@@ -466,20 +663,32 @@ function useWindowSplitterPanelGroupBehavior({
466
663
  case "Enter":
467
664
  {
468
665
  event.preventDefault();
469
- const index = panelsArray.findIndex(panel => panel.current.id === idBefore);
666
+ const index = panelDataArray.findIndex(panelData => panelData.id === idBefore);
470
667
  if (index >= 0) {
471
- const panelData = panelsArray[index];
472
- const size = sizes[index];
668
+ const panelData = panelDataArray[index];
669
+ const size = layout[index];
473
670
  if (size != null) {
671
+ const groupSizePixels = getAvailableGroupSizePixels(groupId);
672
+ const minSize = getPercentageSizeFromMixedSizes({
673
+ sizePercentage: panelData.constraints.minSizePercentage,
674
+ sizePixels: panelData.constraints.minSizePixels
675
+ }, groupSizePixels) ?? 0;
474
676
  let delta = 0;
475
- if (size.toPrecision(PRECISION) <= panelData.current.minSize.toPrecision(PRECISION)) {
677
+ if (size.toPrecision(PRECISION) <= minSize.toPrecision(PRECISION)) {
476
678
  delta = direction === "horizontal" ? width : height;
477
679
  } else {
478
680
  delta = -(direction === "horizontal" ? width : height);
479
681
  }
480
- const nextSizes = adjustByDelta(event, panels, idBefore, idAfter, delta, sizes, panelSizeBeforeCollapse.current, null);
481
- if (sizes !== nextSizes) {
482
- setSizes(nextSizes);
682
+ const nextLayout = adjustLayoutByDelta({
683
+ delta,
684
+ groupSizePixels,
685
+ layout,
686
+ panelConstraints: panelDataArray.map(panelData => panelData.constraints),
687
+ pivotIndices: determinePivotIndices(groupId, handleId),
688
+ trigger: "keyboard"
689
+ });
690
+ if (layout !== nextLayout) {
691
+ setLayout(nextLayout);
483
692
  }
484
693
  }
485
694
  }
@@ -488,72 +697,14 @@ function useWindowSplitterPanelGroupBehavior({
488
697
  }
489
698
  };
490
699
  handle.addEventListener("keydown", onKeyDown);
491
- const panelBefore = getPanel(idBefore);
492
- if (panelBefore != null) {
493
- handle.setAttribute("aria-controls", panelBefore.id);
494
- }
495
700
  return () => {
496
- handle.removeAttribute("aria-valuemax");
497
- handle.removeAttribute("aria-valuemin");
498
- handle.removeAttribute("aria-valuenow");
499
701
  handle.removeEventListener("keydown", onKeyDown);
500
- if (panelBefore != null) {
501
- handle.removeAttribute("aria-controls");
502
- }
503
702
  };
504
703
  });
505
704
  return () => {
506
705
  cleanupFunctions.forEach(cleanupFunction => cleanupFunction());
507
706
  };
508
- }, [committedValuesRef, groupId, panels, panelSizeBeforeCollapse, setSizes, sizes]);
509
- }
510
- function useWindowSplitterResizeHandlerBehavior({
511
- disabled,
512
- handleId,
513
- resizeHandler
514
- }) {
515
- useEffect(() => {
516
- if (disabled || resizeHandler == null) {
517
- return;
518
- }
519
- const handleElement = getResizeHandle(handleId);
520
- if (handleElement == null) {
521
- return;
522
- }
523
- const onKeyDown = event => {
524
- if (event.defaultPrevented) {
525
- return;
526
- }
527
- switch (event.key) {
528
- case "ArrowDown":
529
- case "ArrowLeft":
530
- case "ArrowRight":
531
- case "ArrowUp":
532
- case "End":
533
- case "Home":
534
- {
535
- event.preventDefault();
536
- resizeHandler(event);
537
- break;
538
- }
539
- case "F6":
540
- {
541
- event.preventDefault();
542
- const handles = getResizeHandles();
543
- const index = getResizeHandleIndex(handleId);
544
- assert(index !== null);
545
- const nextIndex = event.shiftKey ? index > 0 ? index - 1 : handles.length - 1 : index + 1 < handles.length ? index + 1 : 0;
546
- const nextHandle = handles[nextIndex];
547
- nextHandle.focus();
548
- break;
549
- }
550
- }
551
- };
552
- handleElement.addEventListener("keydown", onKeyDown);
553
- return () => {
554
- handleElement.removeEventListener("keydown", onKeyDown);
555
- };
556
- }, [disabled, handleId, resizeHandler]);
707
+ }, [committedValuesRef, groupId, layout, panelDataArray, setLayout]);
557
708
  }
558
709
 
559
710
  function areEqual(arrayA, arrayB) {
@@ -568,41 +719,61 @@ function areEqual(arrayA, arrayB) {
568
719
  return true;
569
720
  }
570
721
 
571
- function getDragOffset(event, handleId, direction, initialOffset = 0, initialHandleElementRect = null) {
722
+ function isKeyDown(event) {
723
+ return event.type === "keydown";
724
+ }
725
+ function isMouseEvent(event) {
726
+ return event.type.startsWith("mouse");
727
+ }
728
+ function isTouchEvent(event) {
729
+ return event.type.startsWith("touch");
730
+ }
731
+
732
+ function getResizeEventCursorPosition(direction, event) {
572
733
  const isHorizontal = direction === "horizontal";
573
- let pointerOffset = 0;
574
734
  if (isMouseEvent(event)) {
575
- pointerOffset = isHorizontal ? event.clientX : event.clientY;
735
+ return isHorizontal ? event.clientX : event.clientY;
576
736
  } else if (isTouchEvent(event)) {
577
737
  const firstTouch = event.touches[0];
578
- pointerOffset = isHorizontal ? firstTouch.screenX : firstTouch.screenY;
738
+ return isHorizontal ? firstTouch.screenX : firstTouch.screenY;
579
739
  } else {
580
- return 0;
740
+ throw Error(`Unsupported event type "${event.type}"`);
581
741
  }
582
- const handleElement = getResizeHandle(handleId);
583
- const rect = initialHandleElementRect || handleElement.getBoundingClientRect();
584
- const elementOffset = isHorizontal ? rect.left : rect.top;
585
- return pointerOffset - elementOffset - initialOffset;
742
+ }
743
+
744
+ function calculateDragOffsetPercentage(event, dragHandleId, direction, initialDragState) {
745
+ const isHorizontal = direction === "horizontal";
746
+ const handleElement = getResizeHandleElement(dragHandleId);
747
+ const groupId = handleElement.getAttribute("data-panel-group-id");
748
+ let {
749
+ initialCursorPosition
750
+ } = initialDragState;
751
+ const cursorPosition = getResizeEventCursorPosition(direction, event);
752
+ const groupElement = getPanelGroupElement(groupId);
753
+ const groupRect = groupElement.getBoundingClientRect();
754
+ const groupSizeInPixels = isHorizontal ? groupRect.width : groupRect.height;
755
+ const offsetPixels = cursorPosition - initialCursorPosition;
756
+ const offsetPercentage = offsetPixels / groupSizeInPixels * 100;
757
+ return offsetPercentage;
586
758
  }
587
759
 
588
760
  // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX
589
- function getMovement(event, groupId, handleId, panelsArray, direction, prevSizes, initialDragState) {
590
- const {
591
- dragOffset = 0,
592
- dragHandleRect,
593
- sizes: initialSizes
594
- } = initialDragState || {};
595
-
596
- // If we're resizing by mouse or touch, use the initial sizes as a base.
597
- // This has the benefit of causing force-collapsed panels to spring back open if drag is reversed.
598
- const baseSizes = initialSizes || prevSizes;
761
+ function calculateDeltaPercentage(event, groupId, dragHandleId, direction, initialDragState, keyboardResizeByOptions) {
599
762
  if (isKeyDown(event)) {
600
763
  const isHorizontal = direction === "horizontal";
601
- const groupElement = getPanelGroup(groupId);
764
+ const groupElement = getPanelGroupElement(groupId);
602
765
  const rect = groupElement.getBoundingClientRect();
603
766
  const groupSizeInPixels = isHorizontal ? rect.width : rect.height;
604
- const denominator = event.shiftKey ? 10 : 100;
605
- const delta = groupSizeInPixels / denominator;
767
+ let delta = 0;
768
+ if (event.shiftKey) {
769
+ delta = 100;
770
+ } else if (keyboardResizeByOptions.percentage != null) {
771
+ delta = keyboardResizeByOptions.percentage;
772
+ } else if (keyboardResizeByOptions.pixels != null) {
773
+ delta = keyboardResizeByOptions.pixels / groupSizeInPixels;
774
+ } else {
775
+ delta = 10;
776
+ }
606
777
  let movement = 0;
607
778
  switch (event.key) {
608
779
  case "ArrowDown":
@@ -618,40 +789,152 @@ function getMovement(event, groupId, handleId, panelsArray, direction, prevSizes
618
789
  movement = isHorizontal ? 0 : -delta;
619
790
  break;
620
791
  case "End":
621
- movement = groupSizeInPixels;
792
+ movement = 100;
622
793
  break;
623
794
  case "Home":
624
- movement = -groupSizeInPixels;
795
+ movement = -100;
625
796
  break;
626
797
  }
627
-
628
- // If the Panel being resized is collapsible,
629
- // we need to special case resizing around the minSize boundary.
630
- // If contracting, Panels should shrink to their minSize and then snap to fully collapsed.
631
- // If expanding from collapsed, they should snap back to their minSize.
632
- const [idBefore, idAfter] = getResizeHandlePanelIds(groupId, handleId, panelsArray);
633
- const targetPanelId = movement < 0 ? idBefore : idAfter;
634
- const targetPanelIndex = panelsArray.findIndex(panel => panel.current.id === targetPanelId);
635
- const targetPanel = panelsArray[targetPanelIndex];
636
- if (targetPanel.current.collapsible) {
637
- const baseSize = baseSizes[targetPanelIndex];
638
- if (baseSize === 0 || baseSize.toPrecision(PRECISION) === targetPanel.current.minSize.toPrecision(PRECISION)) {
639
- movement = movement < 0 ? -targetPanel.current.minSize * groupSizeInPixels : targetPanel.current.minSize * groupSizeInPixels;
640
- }
641
- }
642
798
  return movement;
643
799
  } else {
644
- return getDragOffset(event, handleId, direction, dragOffset, dragHandleRect);
800
+ return calculateDragOffsetPercentage(event, dragHandleId, direction, initialDragState);
645
801
  }
646
802
  }
647
- function isKeyDown(event) {
648
- return event.type === "keydown";
803
+
804
+ function calculateUnsafeDefaultLayout({
805
+ groupSizePixels,
806
+ panelDataArray
807
+ }) {
808
+ const layout = Array(panelDataArray.length);
809
+ const panelDataConstraints = panelDataArray.map(panelData => panelData.constraints);
810
+ let numPanelsWithSizes = 0;
811
+ let remainingSize = 100;
812
+
813
+ // Distribute default sizes first
814
+ for (let index = 0; index < panelDataArray.length; index++) {
815
+ const {
816
+ defaultSizePercentage
817
+ } = computePercentagePanelConstraints(panelDataConstraints, index, groupSizePixels);
818
+ if (defaultSizePercentage != null) {
819
+ numPanelsWithSizes++;
820
+ layout[index] = defaultSizePercentage;
821
+ remainingSize -= defaultSizePercentage;
822
+ }
823
+ }
824
+
825
+ // Remaining size should be distributed evenly between panels without default sizes
826
+ for (let index = 0; index < panelDataArray.length; index++) {
827
+ const {
828
+ defaultSizePercentage
829
+ } = computePercentagePanelConstraints(panelDataConstraints, index, groupSizePixels);
830
+ if (defaultSizePercentage != null) {
831
+ continue;
832
+ }
833
+ const numRemainingPanels = panelDataArray.length - numPanelsWithSizes;
834
+ const size = remainingSize / numRemainingPanels;
835
+ numPanelsWithSizes++;
836
+ layout[index] = size;
837
+ remainingSize -= size;
838
+ }
839
+ return layout;
649
840
  }
650
- function isMouseEvent(event) {
651
- return event.type.startsWith("mouse");
841
+
842
+ function convertPercentageToPixels(percentage, groupSizePixels) {
843
+ return percentage / 100 * groupSizePixels;
652
844
  }
653
- function isTouchEvent(event) {
654
- return event.type.startsWith("touch");
845
+
846
+ // Layout should be pre-converted into percentages
847
+ function callPanelCallbacks(groupId, panelsArray, layout, panelIdToLastNotifiedMixedSizesMap) {
848
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
849
+ layout.forEach((sizePercentage, index) => {
850
+ const panelData = panelsArray[index];
851
+ if (!panelData) {
852
+ // Handle initial mount (when panels are registered too late to be in the panels array)
853
+ // The subsequent render+effects will handle the resize notification
854
+ return;
855
+ }
856
+ const {
857
+ callbacks,
858
+ constraints,
859
+ id: panelId
860
+ } = panelData;
861
+ const {
862
+ collapsible
863
+ } = constraints;
864
+ const mixedSizes = {
865
+ sizePercentage,
866
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
867
+ };
868
+ const lastNotifiedMixedSizes = panelIdToLastNotifiedMixedSizesMap[panelId];
869
+ if (lastNotifiedMixedSizes == null || mixedSizes.sizePercentage !== lastNotifiedMixedSizes.sizePercentage || mixedSizes.sizePixels !== lastNotifiedMixedSizes.sizePixels) {
870
+ panelIdToLastNotifiedMixedSizesMap[panelId] = mixedSizes;
871
+ const {
872
+ onCollapse,
873
+ onExpand,
874
+ onResize
875
+ } = callbacks;
876
+ if (onResize) {
877
+ onResize(mixedSizes, lastNotifiedMixedSizes);
878
+ }
879
+ if (collapsible && (onCollapse || onExpand)) {
880
+ const collapsedSize = getPercentageSizeFromMixedSizes({
881
+ sizePercentage: constraints.collapsedSizePercentage,
882
+ sizePixels: constraints.collapsedSizePixels
883
+ }, groupSizePixels) ?? 0;
884
+ const size = getPercentageSizeFromMixedSizes(mixedSizes, groupSizePixels);
885
+ if (onExpand && (lastNotifiedMixedSizes == null || lastNotifiedMixedSizes.sizePercentage === collapsedSize) && size !== collapsedSize) {
886
+ onExpand();
887
+ }
888
+ if (onCollapse && (lastNotifiedMixedSizes == null || lastNotifiedMixedSizes.sizePercentage !== collapsedSize) && size === collapsedSize) {
889
+ onCollapse();
890
+ }
891
+ }
892
+ }
893
+ });
894
+ }
895
+
896
+ function compareLayouts(a, b) {
897
+ if (a.length !== b.length) {
898
+ return false;
899
+ } else {
900
+ for (let index = 0; index < a.length; index++) {
901
+ if (a[index] != b[index]) {
902
+ return false;
903
+ }
904
+ }
905
+ }
906
+ return true;
907
+ }
908
+
909
+ // This method returns a number between 1 and 100 representing
910
+
911
+ // the % of the group's overall space this panel should occupy.
912
+ function computePanelFlexBoxStyle({
913
+ dragState,
914
+ layout,
915
+ panelData,
916
+ panelIndex,
917
+ precision = 3
918
+ }) {
919
+ const size = layout[panelIndex];
920
+ let flexGrow;
921
+ if (panelData.length === 1) {
922
+ flexGrow = "100";
923
+ } else if (size == null) {
924
+ flexGrow = "0";
925
+ } else {
926
+ flexGrow = size.toPrecision(precision);
927
+ }
928
+ return {
929
+ flexBasis: 0,
930
+ flexGrow,
931
+ flexShrink: 1,
932
+ // Without this, Panel sizes may be unintentionally overridden by their content
933
+ overflow: "hidden",
934
+ // Disable pointer events inside of a panel during resize
935
+ // This avoid edge cases like nested iframes
936
+ pointerEvents: dragState !== null ? "none" : undefined
937
+ };
655
938
  }
656
939
 
657
940
  let currentState = null;
@@ -705,17 +988,47 @@ function debounce(callback, durationMs = 10) {
705
988
  return callable;
706
989
  }
707
990
 
991
+ // PanelGroup might be rendering in a server-side environment where localStorage is not available
992
+ // or on a browser with cookies/storage disabled.
993
+ // In either case, this function avoids accessing localStorage until needed,
994
+ // and avoids throwing user-visible errors.
995
+ function initializeDefaultStorage(storageObject) {
996
+ try {
997
+ if (typeof localStorage !== "undefined") {
998
+ // Bypass this check for future calls
999
+ storageObject.getItem = name => {
1000
+ return localStorage.getItem(name);
1001
+ };
1002
+ storageObject.setItem = (name, value) => {
1003
+ localStorage.setItem(name, value);
1004
+ };
1005
+ } else {
1006
+ throw new Error("localStorage not supported in this environment");
1007
+ }
1008
+ } catch (error) {
1009
+ console.error(error);
1010
+ storageObject.getItem = () => null;
1011
+ storageObject.setItem = () => {};
1012
+ }
1013
+ }
1014
+
708
1015
  // Note that Panel ids might be user-provided (stable) or useId generated (non-deterministic)
709
1016
  // so they should not be used as part of the serialization key.
710
- // Using an attribute like minSize instead should work well enough.
1017
+ // Using the min/max size attributes should work well enough as a backup.
711
1018
  // Pre-sorting by minSize allows remembering layouts even if panels are re-ordered/dragged.
712
1019
  function getSerializationKey(panels) {
713
1020
  return panels.map(panel => {
714
1021
  const {
715
- minSize,
1022
+ constraints,
1023
+ id,
1024
+ idIsFromProps,
716
1025
  order
717
- } = panel.current;
718
- return order ? `${order}:${minSize}` : `${minSize}`;
1026
+ } = panel;
1027
+ if (idIsFromProps) {
1028
+ return id;
1029
+ } else {
1030
+ return `${order}:${JSON.stringify(constraints)}`;
1031
+ }
719
1032
  }).sort((a, b) => a.localeCompare(b)).join(",");
720
1033
  }
721
1034
  function loadSerializedPanelGroupState(autoSaveId, storage) {
@@ -749,31 +1062,68 @@ function savePanelGroupLayout(autoSaveId, panels, sizes, storage) {
749
1062
  }
750
1063
  }
751
1064
 
752
- const debounceMap = {};
1065
+ function shouldMonitorPixelBasedConstraints(constraints) {
1066
+ return constraints.some(constraints => {
1067
+ return constraints.collapsedSizePixels !== undefined || constraints.maxSizePixels !== undefined || constraints.minSizePixels !== undefined;
1068
+ });
1069
+ }
753
1070
 
754
- // PanelGroup might be rendering in a server-side environment where localStorage is not available
755
- // or on a browser with cookies/storage disabled.
756
- // In either case, this function avoids accessing localStorage until needed,
757
- // and avoids throwing user-visible errors.
758
- function initializeDefaultStorage(storageObject) {
759
- try {
760
- if (typeof localStorage !== "undefined") {
761
- // Bypass this check for future calls
762
- storageObject.getItem = name => {
763
- return localStorage.getItem(name);
764
- };
765
- storageObject.setItem = (name, value) => {
766
- localStorage.setItem(name, value);
767
- };
768
- } else {
769
- throw new Error("localStorage not supported in this environment");
1071
+ // All units must be in percentages; pixel values should be pre-converted
1072
+ function validatePanelGroupLayout({
1073
+ groupSizePixels,
1074
+ layout: prevLayout,
1075
+ panelConstraints
1076
+ }) {
1077
+ const nextLayout = [...prevLayout];
1078
+
1079
+ // Validate layout expectations
1080
+ if (nextLayout.length !== panelConstraints.length) {
1081
+ throw Error(`Invalid ${panelConstraints.length} panel layout: ${nextLayout.map(size => `${size}%`).join(", ")}`);
1082
+ } else if (!fuzzyNumbersEqual(nextLayout.reduce((accumulated, current) => accumulated + current, 0), 100)) ;
1083
+ let remainingSize = 0;
1084
+
1085
+ // First pass: Validate the proposed layout given each panel's constraints
1086
+ for (let index = 0; index < panelConstraints.length; index++) {
1087
+ const unsafeSize = nextLayout[index];
1088
+ const safeSize = resizePanel({
1089
+ groupSizePixels,
1090
+ panelConstraints,
1091
+ panelIndex: index,
1092
+ size: unsafeSize
1093
+ });
1094
+ if (unsafeSize != safeSize) {
1095
+ remainingSize += unsafeSize - safeSize;
1096
+ nextLayout[index] = safeSize;
770
1097
  }
771
- } catch (error) {
772
- console.error(error);
773
- storageObject.getItem = () => null;
774
- storageObject.setItem = () => {};
775
1098
  }
1099
+
1100
+ // If there is additional, left over space, assign it to any panel(s) that permits it
1101
+ // (It's not worth taking multiple additional passes to evenly distribute)
1102
+ if (!fuzzyNumbersEqual(remainingSize, 0)) {
1103
+ for (let index = 0; index < panelConstraints.length; index++) {
1104
+ const prevSize = nextLayout[index];
1105
+ const unsafeSize = prevSize + remainingSize;
1106
+ const safeSize = resizePanel({
1107
+ groupSizePixels,
1108
+ panelConstraints,
1109
+ panelIndex: index,
1110
+ size: unsafeSize
1111
+ });
1112
+ if (prevSize !== safeSize) {
1113
+ remainingSize -= safeSize - prevSize;
1114
+ nextLayout[index] = safeSize;
1115
+
1116
+ // Once we've used up the remainder, bail
1117
+ if (fuzzyNumbersEqual(remainingSize, 0)) {
1118
+ break;
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ return nextLayout;
776
1124
  }
1125
+
1126
+ const LOCAL_STORAGE_DEBOUNCE_INTERVAL = 100;
777
1127
  const defaultStorage = {
778
1128
  getItem: name => {
779
1129
  initializeDefaultStorage(defaultStorage);
@@ -784,278 +1134,422 @@ const defaultStorage = {
784
1134
  defaultStorage.setItem(name, value);
785
1135
  }
786
1136
  };
787
-
788
- // Initial drag state serves a few purposes:
789
- // * dragOffset:
790
- // Resize is calculated by the distance between the current pointer event and the resize handle being "dragged"
791
- // This value accounts for the initial offset when the touch/click starts, so the handle doesn't appear to "jump"
792
- // * dragHandleRect, sizes:
793
- // When resizing is done via mouse/touch event– some initial state is stored
794
- // so that any panels that contract will also expand if drag direction is reversed.
795
- // TODO
796
- // Within an active drag, remember original positions to refine more easily on expand.
797
- // Look at what the Chrome devtools Sources does.
1137
+ const debounceMap = {};
798
1138
  function PanelGroupWithForwardedRef({
799
1139
  autoSaveId,
800
- children = null,
1140
+ children,
801
1141
  className: classNameFromProps = "",
802
1142
  direction,
803
- disablePointerEventsDuringResize = false,
804
1143
  forwardedRef,
805
- id: idFromProps = null,
806
- onLayout,
1144
+ id: idFromProps,
1145
+ onLayout = null,
1146
+ keyboardResizeByPercentage = null,
1147
+ keyboardResizeByPixels = null,
807
1148
  storage = defaultStorage,
808
- style: styleFromProps = {},
1149
+ style: styleFromProps,
809
1150
  tagName: Type = "div"
810
1151
  }) {
811
1152
  const groupId = useUniqueId(idFromProps);
812
- const [activeHandleId, setActiveHandleId] = useState(null);
813
- const [panels, setPanels] = useState(new Map());
814
-
815
- // When resizing is done via mouse/touch event–
816
- // We store the initial Panel sizes in this ref, and apply move deltas to them instead of to the current sizes.
817
- // This has the benefit of causing force-collapsed panels to spring back open if drag is reversed.
818
- const initialDragStateRef = useRef(null);
819
- useRef({
820
- didLogDefaultSizeWarning: false,
821
- didLogIdAndOrderWarning: false,
822
- prevPanelIds: []
823
- });
824
-
825
- // Use a ref to guard against users passing inline props
826
- const callbacksRef = useRef({
827
- onLayout
828
- });
829
- useEffect(() => {
830
- callbacksRef.current.onLayout = onLayout;
831
- });
832
- const panelIdToLastNotifiedSizeMapRef = useRef({});
833
-
834
- // 0-1 values representing the relative size of each panel.
835
- const [sizes, setSizes] = useState([]);
836
-
837
- // Used to support imperative collapse/expand API.
838
- const panelSizeBeforeCollapse = useRef(new Map());
1153
+ const [dragState, setDragState] = useState(null);
1154
+ const [layout, setLayout] = useState([]);
1155
+ const [panelDataArray, setPanelDataArray] = useState([]);
1156
+ const panelIdToLastNotifiedMixedSizesMapRef = useRef({});
1157
+ const panelSizeBeforeCollapseRef = useRef(new Map());
839
1158
  const prevDeltaRef = useRef(0);
840
-
841
- // Store committed values to avoid unnecessarily re-running memoization/effects functions.
842
1159
  const committedValuesRef = useRef({
843
1160
  direction,
844
- panels,
845
- sizes
1161
+ dragState,
1162
+ id: groupId,
1163
+ keyboardResizeByPercentage,
1164
+ keyboardResizeByPixels,
1165
+ layout,
1166
+ onLayout,
1167
+ panelDataArray
1168
+ });
1169
+ useRef({
1170
+ didLogIdAndOrderWarning: false,
1171
+ didLogPanelConstraintsWarning: false,
1172
+ prevPanelIds: []
846
1173
  });
847
1174
  useImperativeHandle(forwardedRef, () => ({
1175
+ getId: () => committedValuesRef.current.id,
848
1176
  getLayout: () => {
849
1177
  const {
850
- sizes
1178
+ id: groupId,
1179
+ layout
851
1180
  } = committedValuesRef.current;
852
- return sizes;
1181
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
1182
+ return layout.map(sizePercentage => {
1183
+ return {
1184
+ sizePercentage,
1185
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1186
+ };
1187
+ });
853
1188
  },
854
- setLayout: sizes => {
855
- const total = sizes.reduce((accumulated, current) => accumulated + current, 0);
856
- assert(total === 100, "Panel sizes must add up to 100%");
1189
+ setLayout: mixedSizes => {
857
1190
  const {
858
- panels
1191
+ id: groupId,
1192
+ layout: prevLayout,
1193
+ onLayout,
1194
+ panelDataArray
859
1195
  } = committedValuesRef.current;
860
- const panelIdToLastNotifiedSizeMap = panelIdToLastNotifiedSizeMapRef.current;
861
- const panelsArray = panelsMapToSortedArray(panels);
862
- setSizes(sizes);
863
- callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
1196
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
1197
+ const unsafeLayout = mixedSizes.map(mixedSize => getPercentageSizeFromMixedSizes(mixedSize, groupSizePixels));
1198
+ const safeLayout = validatePanelGroupLayout({
1199
+ groupSizePixels,
1200
+ layout: unsafeLayout,
1201
+ panelConstraints: panelDataArray.map(panelData => panelData.constraints)
1202
+ });
1203
+ if (!areEqual(prevLayout, safeLayout)) {
1204
+ setLayout(safeLayout);
1205
+ if (onLayout) {
1206
+ onLayout(safeLayout.map(sizePercentage => ({
1207
+ sizePercentage,
1208
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1209
+ })));
1210
+ }
1211
+ callPanelCallbacks(groupId, panelDataArray, safeLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
1212
+ }
864
1213
  }
865
1214
  }), []);
866
1215
  useIsomorphicLayoutEffect(() => {
867
1216
  committedValuesRef.current.direction = direction;
868
- committedValuesRef.current.panels = panels;
869
- committedValuesRef.current.sizes = sizes;
1217
+ committedValuesRef.current.dragState = dragState;
1218
+ committedValuesRef.current.id = groupId;
1219
+ committedValuesRef.current.layout = layout;
1220
+ committedValuesRef.current.onLayout = onLayout;
1221
+ committedValuesRef.current.panelDataArray = panelDataArray;
870
1222
  });
871
1223
  useWindowSplitterPanelGroupBehavior({
872
1224
  committedValuesRef,
873
1225
  groupId,
874
- panels,
875
- setSizes,
876
- sizes,
877
- panelSizeBeforeCollapse
1226
+ layout,
1227
+ panelDataArray,
1228
+ setLayout
878
1229
  });
879
-
880
- // Notify external code when sizes have changed.
881
1230
  useEffect(() => {
882
- const {
883
- onLayout
884
- } = callbacksRef.current;
885
- const {
886
- panels,
887
- sizes
888
- } = committedValuesRef.current;
1231
+ // If this panel has been configured to persist sizing information, save sizes to local storage.
1232
+ if (autoSaveId) {
1233
+ if (layout.length === 0 || layout.length !== panelDataArray.length) {
1234
+ return;
1235
+ }
889
1236
 
890
- // Don't commit layout until all panels have registered and re-rendered with their actual sizes.
891
- if (sizes.length > 0) {
892
- if (onLayout) {
893
- onLayout(sizes);
1237
+ // Limit the frequency of localStorage updates.
1238
+ if (!debounceMap[autoSaveId]) {
1239
+ debounceMap[autoSaveId] = debounce(savePanelGroupLayout, LOCAL_STORAGE_DEBOUNCE_INTERVAL);
894
1240
  }
895
- const panelIdToLastNotifiedSizeMap = panelIdToLastNotifiedSizeMapRef.current;
896
-
897
- // When possible, we notify before the next render so that rendering work can be batched together.
898
- // Some cases are difficult to detect though,
899
- // for example– panels that are conditionally rendered can affect the size of neighboring panels.
900
- // In this case, the best we can do is notify on commit.
901
- // The callPanelCallbacks() uses its own memoization to avoid notifying panels twice in these cases.
902
- const panelsArray = panelsMapToSortedArray(panels);
903
- callPanelCallbacks(panelsArray, sizes, panelIdToLastNotifiedSizeMap);
1241
+ debounceMap[autoSaveId](autoSaveId, panelDataArray, layout, storage);
904
1242
  }
905
- }, [sizes]);
1243
+ }, [autoSaveId, layout, panelDataArray, storage]);
906
1244
 
907
1245
  // Once all panels have registered themselves,
908
1246
  // Compute the initial sizes based on default weights.
909
1247
  // This assumes that panels register during initial mount (no conditional rendering)!
910
1248
  useIsomorphicLayoutEffect(() => {
911
- const sizes = committedValuesRef.current.sizes;
912
- if (sizes.length === panels.size) {
913
- // Only compute (or restore) default sizes once per panel configuration.
1249
+ const {
1250
+ id: groupId,
1251
+ layout,
1252
+ onLayout
1253
+ } = committedValuesRef.current;
1254
+ if (layout.length === panelDataArray.length) {
1255
+ // Only compute (or restore) default layout once per panel configuration.
914
1256
  return;
915
1257
  }
916
1258
 
917
1259
  // If this panel has been configured to persist sizing information,
918
1260
  // default size should be restored from local storage if possible.
919
- let defaultSizes = null;
1261
+ let unsafeLayout = null;
920
1262
  if (autoSaveId) {
921
- const panelsArray = panelsMapToSortedArray(panels);
922
- defaultSizes = loadPanelLayout(autoSaveId, panelsArray, storage);
1263
+ unsafeLayout = loadPanelLayout(autoSaveId, panelDataArray, storage);
923
1264
  }
924
- if (defaultSizes != null) {
925
- setSizes(defaultSizes);
1265
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
1266
+ if (unsafeLayout == null) {
1267
+ unsafeLayout = calculateUnsafeDefaultLayout({
1268
+ groupSizePixels,
1269
+ panelDataArray
1270
+ });
1271
+ }
1272
+
1273
+ // Validate even saved layouts in case something has changed since last render
1274
+ // e.g. for pixel groups, this could be the size of the window
1275
+ const validatedLayout = validatePanelGroupLayout({
1276
+ groupSizePixels,
1277
+ layout: unsafeLayout,
1278
+ panelConstraints: panelDataArray.map(panelData => panelData.constraints)
1279
+ });
1280
+ if (!areEqual(layout, validatedLayout)) {
1281
+ setLayout(validatedLayout);
1282
+ }
1283
+ if (onLayout) {
1284
+ onLayout(validatedLayout.map(sizePercentage => ({
1285
+ sizePercentage,
1286
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1287
+ })));
1288
+ }
1289
+ callPanelCallbacks(groupId, panelDataArray, validatedLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
1290
+ }, [autoSaveId, layout, panelDataArray, storage]);
1291
+ useIsomorphicLayoutEffect(() => {
1292
+ const constraints = panelDataArray.map(({
1293
+ constraints
1294
+ }) => constraints);
1295
+ if (!shouldMonitorPixelBasedConstraints(constraints)) {
1296
+ // Avoid the overhead of ResizeObserver if no pixel constraints require monitoring
1297
+ return;
1298
+ }
1299
+ if (typeof ResizeObserver === "undefined") {
1300
+ console.warn(`WARNING: Pixel based constraints require ResizeObserver but it is not supported by the current browser.`);
926
1301
  } else {
927
- const panelsArray = panelsMapToSortedArray(panels);
928
- let panelsWithNullDefaultSize = 0;
929
- let totalDefaultSize = 0;
930
- let totalMinSize = 0;
931
-
932
- // TODO
933
- // Implicit default size calculations below do not account for inferred min/max size values.
934
- // 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.
935
- // For now, these logic edge cases are left to the user to handle via props.
936
-
937
- panelsArray.forEach(panel => {
938
- totalMinSize += panel.current.minSize;
939
- if (panel.current.defaultSize === null) {
940
- panelsWithNullDefaultSize++;
941
- } else {
942
- totalDefaultSize += panel.current.defaultSize;
1302
+ const resizeObserver = new ResizeObserver(() => {
1303
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
1304
+ const {
1305
+ layout: prevLayout,
1306
+ onLayout
1307
+ } = committedValuesRef.current;
1308
+ const nextLayout = validatePanelGroupLayout({
1309
+ groupSizePixels,
1310
+ layout: prevLayout,
1311
+ panelConstraints: panelDataArray.map(panelData => panelData.constraints)
1312
+ });
1313
+ if (!areEqual(prevLayout, nextLayout)) {
1314
+ setLayout(nextLayout);
1315
+ if (onLayout) {
1316
+ onLayout(nextLayout.map(sizePercentage => ({
1317
+ sizePercentage,
1318
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1319
+ })));
1320
+ }
1321
+ callPanelCallbacks(groupId, panelDataArray, nextLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
943
1322
  }
944
1323
  });
945
- if (totalDefaultSize > 100) {
946
- throw new Error(`Default panel sizes cannot exceed 100%`);
947
- } else if (panelsArray.length > 1 && panelsWithNullDefaultSize === 0 && totalDefaultSize !== 100) {
948
- throw new Error(`Invalid default sizes specified for panels`);
949
- } else if (totalMinSize > 100) {
950
- throw new Error(`Minimum panel sizes cannot exceed 100%`);
951
- }
952
- setSizes(panelsArray.map(panel => {
953
- if (panel.current.defaultSize === null) {
954
- return (100 - totalDefaultSize) / panelsWithNullDefaultSize;
955
- }
956
- return panel.current.defaultSize;
957
- }));
1324
+ resizeObserver.observe(getPanelGroupElement(groupId));
1325
+ return () => {
1326
+ resizeObserver.disconnect();
1327
+ };
958
1328
  }
959
- }, [autoSaveId, panels, storage]);
1329
+ }, [groupId, panelDataArray]);
1330
+
1331
+ // DEV warnings
960
1332
  useEffect(() => {
961
- // If this panel has been configured to persist sizing information, save sizes to local storage.
962
- if (autoSaveId) {
963
- if (sizes.length === 0 || sizes.length !== panels.size) {
964
- return;
965
- }
966
- const panelsArray = panelsMapToSortedArray(panels);
1333
+ });
967
1334
 
968
- // Limit the frequency of localStorage updates.
969
- if (!debounceMap[autoSaveId]) {
970
- debounceMap[autoSaveId] = debounce(savePanelGroupLayout, 100);
1335
+ // External APIs are safe to memoize via committed values ref
1336
+ const collapsePanel = useCallback(panelData => {
1337
+ const {
1338
+ layout: prevLayout,
1339
+ onLayout,
1340
+ panelDataArray
1341
+ } = committedValuesRef.current;
1342
+ if (panelData.constraints.collapsible) {
1343
+ const panelConstraintsArray = panelDataArray.map(panelData => panelData.constraints);
1344
+ const {
1345
+ collapsedSizePercentage,
1346
+ panelSizePercentage,
1347
+ pivotIndices,
1348
+ groupSizePixels
1349
+ } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout);
1350
+ if (panelSizePercentage !== collapsedSizePercentage) {
1351
+ // Store size before collapse;
1352
+ // This is the size that gets restored if the expand() API is used.
1353
+ panelSizeBeforeCollapseRef.current.set(panelData.id, panelSizePercentage);
1354
+ const isLastPanel = panelDataArray.indexOf(panelData) === panelDataArray.length - 1;
1355
+ const delta = isLastPanel ? panelSizePercentage - collapsedSizePercentage : collapsedSizePercentage - panelSizePercentage;
1356
+ const nextLayout = adjustLayoutByDelta({
1357
+ delta,
1358
+ groupSizePixels,
1359
+ layout: prevLayout,
1360
+ panelConstraints: panelConstraintsArray,
1361
+ pivotIndices,
1362
+ trigger: "imperative-api"
1363
+ });
1364
+ if (!compareLayouts(prevLayout, nextLayout)) {
1365
+ setLayout(nextLayout);
1366
+ if (onLayout) {
1367
+ onLayout(nextLayout.map(sizePercentage => ({
1368
+ sizePercentage,
1369
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1370
+ })));
1371
+ }
1372
+ callPanelCallbacks(groupId, panelDataArray, nextLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
1373
+ }
971
1374
  }
972
- debounceMap[autoSaveId](autoSaveId, panelsArray, sizes, storage);
973
1375
  }
974
- }, [autoSaveId, panels, sizes, storage]);
975
- const getPanelStyle = useCallback((id, defaultSize) => {
1376
+ }, [groupId]);
1377
+
1378
+ // External APIs are safe to memoize via committed values ref
1379
+ const expandPanel = useCallback(panelData => {
976
1380
  const {
977
- panels
1381
+ layout: prevLayout,
1382
+ onLayout,
1383
+ panelDataArray
978
1384
  } = committedValuesRef.current;
979
-
980
- // Before mounting, Panels will not yet have registered themselves.
981
- // This includes server rendering.
982
- // At this point the best we can do is render everything with the same size.
983
- if (panels.size === 0) {
984
- return {
985
- flexBasis: 0,
986
- flexGrow: defaultSize != null ? defaultSize : undefined,
987
- flexShrink: 1,
988
- // Without this, Panel sizes may be unintentionally overridden by their content.
989
- overflow: "hidden"
990
- };
1385
+ if (panelData.constraints.collapsible) {
1386
+ const panelConstraintsArray = panelDataArray.map(panelData => panelData.constraints);
1387
+ const {
1388
+ collapsedSizePercentage,
1389
+ panelSizePercentage,
1390
+ minSizePercentage,
1391
+ pivotIndices,
1392
+ groupSizePixels
1393
+ } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout);
1394
+ if (panelSizePercentage === collapsedSizePercentage) {
1395
+ // Restore this panel to the size it was before it was collapsed, if possible.
1396
+ const prevPanelSizePercentage = panelSizeBeforeCollapseRef.current.get(panelData.id);
1397
+ const baseSizePercentage = prevPanelSizePercentage != null ? prevPanelSizePercentage : minSizePercentage;
1398
+ const isLastPanel = panelDataArray.indexOf(panelData) === panelDataArray.length - 1;
1399
+ const delta = isLastPanel ? panelSizePercentage - baseSizePercentage : baseSizePercentage - panelSizePercentage;
1400
+ const nextLayout = adjustLayoutByDelta({
1401
+ delta,
1402
+ groupSizePixels,
1403
+ layout: prevLayout,
1404
+ panelConstraints: panelConstraintsArray,
1405
+ pivotIndices,
1406
+ trigger: "imperative-api"
1407
+ });
1408
+ if (!compareLayouts(prevLayout, nextLayout)) {
1409
+ setLayout(nextLayout);
1410
+ if (onLayout) {
1411
+ onLayout(nextLayout.map(sizePercentage => ({
1412
+ sizePercentage,
1413
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1414
+ })));
1415
+ }
1416
+ callPanelCallbacks(groupId, panelDataArray, nextLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
1417
+ }
1418
+ }
991
1419
  }
992
- const flexGrow = getFlexGrow(panels, id, sizes);
1420
+ }, [groupId]);
1421
+
1422
+ // External APIs are safe to memoize via committed values ref
1423
+ const getPanelSize = useCallback(panelData => {
1424
+ const {
1425
+ layout,
1426
+ panelDataArray
1427
+ } = committedValuesRef.current;
1428
+ const {
1429
+ panelSizePercentage,
1430
+ panelSizePixels
1431
+ } = panelDataHelper(groupId, panelDataArray, panelData, layout);
993
1432
  return {
994
- flexBasis: 0,
995
- flexGrow,
996
- flexShrink: 1,
997
- // Without this, Panel sizes may be unintentionally overridden by their content.
998
- overflow: "hidden",
999
- // Disable pointer events inside of a panel during resize.
1000
- // This avoid edge cases like nested iframes.
1001
- pointerEvents: disablePointerEventsDuringResize && activeHandleId !== null ? "none" : undefined
1433
+ sizePercentage: panelSizePercentage,
1434
+ sizePixels: panelSizePixels
1002
1435
  };
1003
- }, [activeHandleId, disablePointerEventsDuringResize, sizes]);
1004
- const registerPanel = useCallback((id, panelRef) => {
1005
- setPanels(prevPanels => {
1006
- if (prevPanels.has(id)) {
1007
- return prevPanels;
1008
- }
1009
- const nextPanels = new Map(prevPanels);
1010
- nextPanels.set(id, panelRef);
1011
- return nextPanels;
1436
+ }, [groupId]);
1437
+
1438
+ // This API should never read from committedValuesRef
1439
+ const getPanelStyle = useCallback(panelData => {
1440
+ const panelIndex = panelDataArray.indexOf(panelData);
1441
+ return computePanelFlexBoxStyle({
1442
+ dragState,
1443
+ layout,
1444
+ panelData: panelDataArray,
1445
+ panelIndex
1446
+ });
1447
+ }, [dragState, layout, panelDataArray]);
1448
+
1449
+ // External APIs are safe to memoize via committed values ref
1450
+ const isPanelCollapsed = useCallback(panelData => {
1451
+ const {
1452
+ layout,
1453
+ panelDataArray
1454
+ } = committedValuesRef.current;
1455
+ const {
1456
+ collapsedSizePercentage,
1457
+ collapsible,
1458
+ panelSizePercentage
1459
+ } = panelDataHelper(groupId, panelDataArray, panelData, layout);
1460
+ return collapsible === true && panelSizePercentage === collapsedSizePercentage;
1461
+ }, [groupId]);
1462
+
1463
+ // External APIs are safe to memoize via committed values ref
1464
+ const isPanelExpanded = useCallback(panelData => {
1465
+ const {
1466
+ layout,
1467
+ panelDataArray
1468
+ } = committedValuesRef.current;
1469
+ const {
1470
+ collapsedSizePercentage,
1471
+ collapsible,
1472
+ panelSizePercentage
1473
+ } = panelDataHelper(groupId, panelDataArray, panelData, layout);
1474
+ return !collapsible || panelSizePercentage > collapsedSizePercentage;
1475
+ }, [groupId]);
1476
+ const registerPanel = useCallback(panelData => {
1477
+ setPanelDataArray(prevPanelDataArray => {
1478
+ const nextPanelDataArray = [...prevPanelDataArray, panelData];
1479
+ return nextPanelDataArray.sort((panelA, panelB) => {
1480
+ const orderA = panelA.order;
1481
+ const orderB = panelB.order;
1482
+ if (orderA == null && orderB == null) {
1483
+ return 0;
1484
+ } else if (orderA == null) {
1485
+ return -1;
1486
+ } else if (orderB == null) {
1487
+ return 1;
1488
+ } else {
1489
+ return orderA - orderB;
1490
+ }
1491
+ });
1012
1492
  });
1013
1493
  }, []);
1014
- const registerResizeHandle = useCallback(handleId => {
1015
- const resizeHandler = event => {
1494
+ const registerResizeHandle = useCallback(dragHandleId => {
1495
+ return function resizeHandler(event) {
1016
1496
  event.preventDefault();
1017
1497
  const {
1018
1498
  direction,
1019
- panels,
1020
- sizes: prevSizes
1499
+ dragState,
1500
+ id: groupId,
1501
+ keyboardResizeByPercentage,
1502
+ keyboardResizeByPixels,
1503
+ onLayout,
1504
+ panelDataArray,
1505
+ layout: prevLayout
1021
1506
  } = committedValuesRef.current;
1022
- const panelsArray = panelsMapToSortedArray(panels);
1023
- const [idBefore, idAfter] = getResizeHandlePanelIds(groupId, handleId, panelsArray);
1024
- if (idBefore == null || idAfter == null) {
1025
- return;
1026
- }
1027
- let movement = getMovement(event, groupId, handleId, panelsArray, direction, prevSizes, initialDragStateRef.current);
1028
- if (movement === 0) {
1507
+ const {
1508
+ initialLayout
1509
+ } = dragState ?? {};
1510
+ const pivotIndices = determinePivotIndices(groupId, dragHandleId);
1511
+ let delta = calculateDeltaPercentage(event, groupId, dragHandleId, direction, dragState, {
1512
+ percentage: keyboardResizeByPercentage,
1513
+ pixels: keyboardResizeByPixels
1514
+ });
1515
+ if (delta === 0) {
1029
1516
  return;
1030
1517
  }
1031
- const groupElement = getPanelGroup(groupId);
1032
- const rect = groupElement.getBoundingClientRect();
1033
- const isHorizontal = direction === "horizontal";
1034
1518
 
1035
1519
  // Support RTL layouts
1520
+ const isHorizontal = direction === "horizontal";
1036
1521
  if (document.dir === "rtl" && isHorizontal) {
1037
- movement = -movement;
1522
+ delta = -delta;
1038
1523
  }
1039
- const size = isHorizontal ? rect.width : rect.height;
1040
- const delta = movement / size * 100;
1041
- const nextSizes = adjustByDelta(event, panels, idBefore, idAfter, delta, prevSizes, panelSizeBeforeCollapse.current, initialDragStateRef.current);
1042
- const sizesChanged = !areEqual(prevSizes, nextSizes);
1524
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
1525
+ const panelConstraints = panelDataArray.map(panelData => panelData.constraints);
1526
+ const nextLayout = adjustLayoutByDelta({
1527
+ delta,
1528
+ groupSizePixels,
1529
+ layout: initialLayout ?? prevLayout,
1530
+ panelConstraints,
1531
+ pivotIndices,
1532
+ trigger: isKeyDown(event) ? "keyboard" : "mouse-or-touch"
1533
+ });
1534
+ const layoutChanged = !compareLayouts(prevLayout, nextLayout);
1043
1535
 
1044
- // Don't update cursor for resizes triggered by keyboard interactions.
1536
+ // Only update the cursor for layout changes triggered by touch/mouse events (not keyboard)
1537
+ // Update the cursor even if the layout hasn't changed (we may need to show an invalid cursor state)
1045
1538
  if (isMouseEvent(event) || isTouchEvent(event)) {
1046
1539
  // Watch for multiple subsequent deltas; this might occur for tiny cursor movements.
1047
1540
  // In this case, Panel sizes might not change–
1048
1541
  // but updating cursor in this scenario would cause a flicker.
1049
1542
  if (prevDeltaRef.current != delta) {
1050
- if (!sizesChanged) {
1543
+ prevDeltaRef.current = delta;
1544
+ if (!layoutChanged) {
1051
1545
  // If the pointer has moved too far to resize the panel any further,
1052
1546
  // update the cursor style for a visual clue.
1053
1547
  // This mimics VS Code behavior.
1054
1548
 
1055
1549
  if (isHorizontal) {
1056
- setGlobalCursorStyle(movement < 0 ? "horizontal-min" : "horizontal-max");
1550
+ setGlobalCursorStyle(delta < 0 ? "horizontal-min" : "horizontal-max");
1057
1551
  } else {
1058
- setGlobalCursorStyle(movement < 0 ? "vertical-min" : "vertical-max");
1552
+ setGlobalCursorStyle(delta < 0 ? "vertical-min" : "vertical-max");
1059
1553
  }
1060
1554
  } else {
1061
1555
  // Reset the cursor style to the the normal resize cursor.
@@ -1063,185 +1557,100 @@ function PanelGroupWithForwardedRef({
1063
1557
  }
1064
1558
  }
1065
1559
  }
1066
- if (sizesChanged) {
1067
- const panelIdToLastNotifiedSizeMap = panelIdToLastNotifiedSizeMapRef.current;
1068
- setSizes(nextSizes);
1069
-
1070
- // If resize change handlers have been declared, this is the time to call them.
1071
- // Trigger user callbacks after updating state, so that user code can override the sizes.
1072
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
1560
+ if (layoutChanged) {
1561
+ setLayout(nextLayout);
1562
+ if (onLayout) {
1563
+ onLayout(nextLayout.map(sizePercentage => ({
1564
+ sizePercentage,
1565
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1566
+ })));
1567
+ }
1568
+ callPanelCallbacks(groupId, panelDataArray, nextLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
1073
1569
  }
1074
- prevDeltaRef.current = delta;
1075
1570
  };
1076
- return resizeHandler;
1077
- }, [groupId]);
1078
- const unregisterPanel = useCallback(id => {
1079
- setPanels(prevPanels => {
1080
- if (!prevPanels.has(id)) {
1081
- return prevPanels;
1082
- }
1083
- const nextPanels = new Map(prevPanels);
1084
- nextPanels.delete(id);
1085
- return nextPanels;
1086
- });
1087
1571
  }, []);
1088
- const collapsePanel = useCallback(id => {
1572
+
1573
+ // External APIs are safe to memoize via committed values ref
1574
+ const resizePanel = useCallback((panelData, mixedSizes) => {
1089
1575
  const {
1090
- panels,
1091
- sizes: prevSizes
1576
+ layout: prevLayout,
1577
+ onLayout,
1578
+ panelDataArray
1092
1579
  } = committedValuesRef.current;
1093
- const panel = panels.get(id);
1094
- if (panel == null) {
1095
- return;
1096
- }
1580
+ const panelConstraintsArray = panelDataArray.map(panelData => panelData.constraints);
1097
1581
  const {
1098
- collapsedSize,
1099
- collapsible
1100
- } = panel.current;
1101
- if (!collapsible) {
1102
- return;
1103
- }
1104
- const panelsArray = panelsMapToSortedArray(panels);
1105
- const index = panelsArray.indexOf(panel);
1106
- if (index < 0) {
1107
- return;
1108
- }
1109
- const currentSize = prevSizes[index];
1110
- if (currentSize === collapsedSize) {
1111
- // Panel is already collapsed.
1112
- return;
1113
- }
1114
- panelSizeBeforeCollapse.current.set(id, currentSize);
1115
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
1116
- if (idBefore == null || idAfter == null) {
1117
- return;
1118
- }
1119
- const isLastPanel = index === panelsArray.length - 1;
1120
- const delta = isLastPanel ? currentSize : collapsedSize - currentSize;
1121
- const nextSizes = adjustByDelta(null, panels, idBefore, idAfter, delta, prevSizes, panelSizeBeforeCollapse.current, null);
1122
- if (prevSizes !== nextSizes) {
1123
- const panelIdToLastNotifiedSizeMap = panelIdToLastNotifiedSizeMapRef.current;
1124
- setSizes(nextSizes);
1125
-
1126
- // If resize change handlers have been declared, this is the time to call them.
1127
- // Trigger user callbacks after updating state, so that user code can override the sizes.
1128
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
1582
+ groupSizePixels,
1583
+ panelSizePercentage,
1584
+ pivotIndices
1585
+ } = panelDataHelper(groupId, panelDataArray, panelData, prevLayout);
1586
+ const sizePercentage = getPercentageSizeFromMixedSizes(mixedSizes, groupSizePixels);
1587
+ const isLastPanel = panelDataArray.indexOf(panelData) === panelDataArray.length - 1;
1588
+ const delta = isLastPanel ? panelSizePercentage - sizePercentage : sizePercentage - panelSizePercentage;
1589
+ const nextLayout = adjustLayoutByDelta({
1590
+ delta,
1591
+ groupSizePixels,
1592
+ layout: prevLayout,
1593
+ panelConstraints: panelConstraintsArray,
1594
+ pivotIndices,
1595
+ trigger: "imperative-api"
1596
+ });
1597
+ if (!compareLayouts(prevLayout, nextLayout)) {
1598
+ setLayout(nextLayout);
1599
+ if (onLayout) {
1600
+ onLayout(nextLayout.map(sizePercentage => ({
1601
+ sizePercentage,
1602
+ sizePixels: convertPercentageToPixels(sizePercentage, groupSizePixels)
1603
+ })));
1604
+ }
1605
+ callPanelCallbacks(groupId, panelDataArray, nextLayout, panelIdToLastNotifiedMixedSizesMapRef.current);
1129
1606
  }
1130
- }, []);
1131
- const expandPanel = useCallback(id => {
1607
+ }, [groupId]);
1608
+ const startDragging = useCallback((dragHandleId, event) => {
1132
1609
  const {
1133
- panels,
1134
- sizes: prevSizes
1610
+ direction,
1611
+ layout
1135
1612
  } = committedValuesRef.current;
1136
- const panel = panels.get(id);
1137
- if (panel == null) {
1138
- return;
1139
- }
1140
- const {
1141
- collapsedSize,
1142
- minSize
1143
- } = panel.current;
1144
- const sizeBeforeCollapse = panelSizeBeforeCollapse.current.get(id) || minSize;
1145
- if (!sizeBeforeCollapse) {
1146
- return;
1147
- }
1148
- const panelsArray = panelsMapToSortedArray(panels);
1149
- const index = panelsArray.indexOf(panel);
1150
- if (index < 0) {
1151
- return;
1152
- }
1153
- const currentSize = prevSizes[index];
1154
- if (currentSize !== collapsedSize) {
1155
- // Panel is already expanded.
1156
- return;
1157
- }
1158
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
1159
- if (idBefore == null || idAfter == null) {
1160
- return;
1161
- }
1162
- const isLastPanel = index === panelsArray.length - 1;
1163
- const delta = isLastPanel ? collapsedSize - sizeBeforeCollapse : sizeBeforeCollapse;
1164
- const nextSizes = adjustByDelta(null, panels, idBefore, idAfter, delta, prevSizes, panelSizeBeforeCollapse.current, null);
1165
- if (prevSizes !== nextSizes) {
1166
- const panelIdToLastNotifiedSizeMap = panelIdToLastNotifiedSizeMapRef.current;
1167
- setSizes(nextSizes);
1168
-
1169
- // If resize change handlers have been declared, this is the time to call them.
1170
- // Trigger user callbacks after updating state, so that user code can override the sizes.
1171
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
1172
- }
1613
+ const handleElement = getResizeHandleElement(dragHandleId);
1614
+ const initialCursorPosition = getResizeEventCursorPosition(direction, event);
1615
+ setDragState({
1616
+ dragHandleId,
1617
+ dragHandleRect: handleElement.getBoundingClientRect(),
1618
+ initialCursorPosition,
1619
+ initialLayout: layout
1620
+ });
1173
1621
  }, []);
1174
- const resizePanel = useCallback((id, nextSize) => {
1175
- const {
1176
- panels,
1177
- sizes: prevSizes
1178
- } = committedValuesRef.current;
1179
- const panel = panels.get(id);
1180
- if (panel == null) {
1181
- return;
1182
- }
1183
- const {
1184
- collapsedSize,
1185
- collapsible,
1186
- maxSize,
1187
- minSize
1188
- } = panel.current;
1189
- const panelsArray = panelsMapToSortedArray(panels);
1190
- const index = panelsArray.indexOf(panel);
1191
- if (index < 0) {
1192
- return;
1193
- }
1194
- const currentSize = prevSizes[index];
1195
- if (currentSize === nextSize) {
1196
- return;
1197
- }
1198
- if (collapsible && nextSize === collapsedSize) ; else {
1199
- nextSize = Math.min(maxSize, Math.max(minSize, nextSize));
1200
- }
1201
- const [idBefore, idAfter] = getBeforeAndAfterIds(id, panelsArray);
1202
- if (idBefore == null || idAfter == null) {
1203
- return;
1204
- }
1205
- const isLastPanel = index === panelsArray.length - 1;
1206
- const delta = isLastPanel ? currentSize - nextSize : nextSize - currentSize;
1207
- const nextSizes = adjustByDelta(null, panels, idBefore, idAfter, delta, prevSizes, panelSizeBeforeCollapse.current, null);
1208
- if (prevSizes !== nextSizes) {
1209
- const panelIdToLastNotifiedSizeMap = panelIdToLastNotifiedSizeMapRef.current;
1210
- setSizes(nextSizes);
1211
-
1212
- // If resize change handlers have been declared, this is the time to call them.
1213
- // Trigger user callbacks after updating state, so that user code can override the sizes.
1214
- callPanelCallbacks(panelsArray, nextSizes, panelIdToLastNotifiedSizeMap);
1215
- }
1622
+ const stopDragging = useCallback(() => {
1623
+ resetGlobalCursorStyle();
1624
+ setDragState(null);
1625
+ }, []);
1626
+ const unregisterPanel = useCallback(panelData => {
1627
+ delete panelIdToLastNotifiedMixedSizesMapRef.current[panelData.id];
1628
+ setPanelDataArray(panelDataArray => {
1629
+ const index = panelDataArray.indexOf(panelData);
1630
+ if (index >= 0) {
1631
+ panelDataArray = [...panelDataArray];
1632
+ panelDataArray.splice(index, 1);
1633
+ }
1634
+ return panelDataArray;
1635
+ });
1216
1636
  }, []);
1217
1637
  const context = useMemo(() => ({
1218
- activeHandleId,
1219
1638
  collapsePanel,
1220
1639
  direction,
1640
+ dragState,
1221
1641
  expandPanel,
1642
+ getPanelSize,
1222
1643
  getPanelStyle,
1223
1644
  groupId,
1645
+ isPanelCollapsed,
1646
+ isPanelExpanded,
1224
1647
  registerPanel,
1225
1648
  registerResizeHandle,
1226
1649
  resizePanel,
1227
- startDragging: (id, event) => {
1228
- setActiveHandleId(id);
1229
- if (isMouseEvent(event) || isTouchEvent(event)) {
1230
- const handleElement = getResizeHandle(id);
1231
- initialDragStateRef.current = {
1232
- dragHandleRect: handleElement.getBoundingClientRect(),
1233
- dragOffset: getDragOffset(event, id, direction),
1234
- sizes: committedValuesRef.current.sizes
1235
- };
1236
- }
1237
- },
1238
- stopDragging: () => {
1239
- resetGlobalCursorStyle();
1240
- setActiveHandleId(null);
1241
- initialDragStateRef.current = null;
1242
- },
1650
+ startDragging,
1651
+ stopDragging,
1243
1652
  unregisterPanel
1244
- }), [activeHandleId, collapsePanel, direction, expandPanel, getPanelStyle, groupId, registerPanel, registerResizeHandle, resizePanel, unregisterPanel]);
1653
+ }), [collapsePanel, dragState, direction, expandPanel, getPanelSize, getPanelStyle, groupId, isPanelCollapsed, isPanelExpanded, registerPanel, registerResizeHandle, resizePanel, startDragging, stopDragging, unregisterPanel]);
1245
1654
  const style = {
1246
1655
  display: "flex",
1247
1656
  flexDirection: direction === "horizontal" ? "row" : "column",
@@ -1250,19 +1659,20 @@ function PanelGroupWithForwardedRef({
1250
1659
  width: "100%"
1251
1660
  };
1252
1661
  return createElement(PanelGroupContext.Provider, {
1253
- children: createElement(Type, {
1254
- children,
1255
- className: classNameFromProps,
1256
- "data-panel-group": "",
1257
- "data-panel-group-direction": direction,
1258
- "data-panel-group-id": groupId,
1259
- style: {
1260
- ...style,
1261
- ...styleFromProps
1262
- }
1263
- }),
1264
1662
  value: context
1265
- });
1663
+ }, createElement(Type, {
1664
+ children,
1665
+ className: classNameFromProps,
1666
+ style: {
1667
+ ...style,
1668
+ ...styleFromProps
1669
+ },
1670
+ // CSS selectors
1671
+ "data-panel-group": "",
1672
+ // e2e test attributes
1673
+ "data-panel-group-direction": undefined,
1674
+ "data-panel-group-id": undefined
1675
+ }));
1266
1676
  }
1267
1677
  const PanelGroup = forwardRef((props, ref) => createElement(PanelGroupWithForwardedRef, {
1268
1678
  ...props,
@@ -1270,6 +1680,77 @@ const PanelGroup = forwardRef((props, ref) => createElement(PanelGroupWithForwar
1270
1680
  }));
1271
1681
  PanelGroupWithForwardedRef.displayName = "PanelGroup";
1272
1682
  PanelGroup.displayName = "forwardRef(PanelGroup)";
1683
+ function panelDataHelper(groupId, panelDataArray, panelData, layout) {
1684
+ const panelConstraintsArray = panelDataArray.map(panelData => panelData.constraints);
1685
+ const panelIndex = panelDataArray.indexOf(panelData);
1686
+ const panelConstraints = panelConstraintsArray[panelIndex];
1687
+ const groupSizePixels = calculateAvailablePanelSizeInPixels(groupId);
1688
+ const percentagePanelConstraints = computePercentagePanelConstraints(panelConstraintsArray, panelIndex, groupSizePixels);
1689
+ const isLastPanel = panelIndex === panelDataArray.length - 1;
1690
+ const pivotIndices = isLastPanel ? [panelIndex - 1, panelIndex] : [panelIndex, panelIndex + 1];
1691
+ const panelSizePercentage = layout[panelIndex];
1692
+ const panelSizePixels = convertPercentageToPixels(panelSizePercentage, groupSizePixels);
1693
+ return {
1694
+ ...percentagePanelConstraints,
1695
+ collapsible: panelConstraints.collapsible,
1696
+ panelSizePercentage,
1697
+ panelSizePixels,
1698
+ groupSizePixels,
1699
+ pivotIndices
1700
+ };
1701
+ }
1702
+
1703
+ // https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
1704
+
1705
+ function useWindowSplitterResizeHandlerBehavior({
1706
+ disabled,
1707
+ handleId,
1708
+ resizeHandler
1709
+ }) {
1710
+ useEffect(() => {
1711
+ if (disabled || resizeHandler == null) {
1712
+ return;
1713
+ }
1714
+ const handleElement = getResizeHandleElement(handleId);
1715
+ if (handleElement == null) {
1716
+ return;
1717
+ }
1718
+ const onKeyDown = event => {
1719
+ if (event.defaultPrevented) {
1720
+ return;
1721
+ }
1722
+ switch (event.key) {
1723
+ case "ArrowDown":
1724
+ case "ArrowLeft":
1725
+ case "ArrowRight":
1726
+ case "ArrowUp":
1727
+ case "End":
1728
+ case "Home":
1729
+ {
1730
+ event.preventDefault();
1731
+ resizeHandler(event);
1732
+ break;
1733
+ }
1734
+ case "F6":
1735
+ {
1736
+ event.preventDefault();
1737
+ const groupId = handleElement.getAttribute("data-panel-group-id");
1738
+ const handles = getResizeHandleElementsForGroup(groupId);
1739
+ const index = getResizeHandleElementIndex(groupId, handleId);
1740
+ assert(index !== null);
1741
+ const nextIndex = event.shiftKey ? index > 0 ? index - 1 : handles.length - 1 : index + 1 < handles.length ? index + 1 : 0;
1742
+ const nextHandle = handles[nextIndex];
1743
+ nextHandle.focus();
1744
+ break;
1745
+ }
1746
+ }
1747
+ };
1748
+ handleElement.addEventListener("keydown", onKeyDown);
1749
+ return () => {
1750
+ handleElement.removeEventListener("keydown", onKeyDown);
1751
+ };
1752
+ }, [disabled, handleId, resizeHandler]);
1753
+ }
1273
1754
 
1274
1755
  function PanelResizeHandle({
1275
1756
  children = null,
@@ -1294,15 +1775,15 @@ function PanelResizeHandle({
1294
1775
  throw Error(`PanelResizeHandle components must be rendered within a PanelGroup container`);
1295
1776
  }
1296
1777
  const {
1297
- activeHandleId,
1298
1778
  direction,
1779
+ dragState,
1299
1780
  groupId,
1300
1781
  registerResizeHandle,
1301
1782
  startDragging,
1302
1783
  stopDragging
1303
1784
  } = panelGroupContext;
1304
1785
  const resizeHandleId = useUniqueId(idFromProps);
1305
- const isDragging = activeHandleId === resizeHandleId;
1786
+ const isDragging = dragState?.dragHandleId === resizeHandleId;
1306
1787
  const [isFocused, setIsFocused] = useState(false);
1307
1788
  const [resizeHandler, setResizeHandler] = useState(null);
1308
1789
  const stopDraggingAndBlur = useCallback(() => {
@@ -1366,6 +1847,8 @@ function PanelResizeHandle({
1366
1847
  return createElement(Type, {
1367
1848
  children,
1368
1849
  className: classNameFromProps,
1850
+ // CSS selectors
1851
+ "data-resize-handle": "",
1369
1852
  "data-resize-handle-active": isDragging ? "pointer" : isFocused ? "keyboard" : undefined,
1370
1853
  "data-panel-group-direction": direction,
1371
1854
  "data-panel-group-id": groupId,