react-resizable-panels 0.0.54 → 0.0.56

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