react-resizable-panels 0.0.54 → 0.0.56

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