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