react-resizable-panels 0.0.55 → 0.0.56

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