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