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