react-resizable-panels 0.0.54 → 0.0.56

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