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