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