react-resizable-panels 0.0.54 → 0.0.56

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