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