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