react-resizable-panels 0.0.55 → 0.0.56

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