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