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