Skip to content

Commit e4db341

Browse files
authored
fix: account for divider size in pane calculations (#874)
Previously, percentage-based pane sizes were calculated against the full container size, ignoring the space taken by dividers. This caused panes totaling 100% to overflow their container by the combined width of all dividers. Changes: - Add `dividerSize` prop (default: 1px) to SplitPane for accurate sizing - Subtract total divider width from available space before calculating percentage-based pane sizes - Apply same adjustment when redistributing pane sizes on container resize - Update tests to verify correct behavior with divider width accounting Fixes #871
1 parent a6e5c70 commit e4db341

File tree

3 files changed

+161
-28
lines changed

3 files changed

+161
-28
lines changed

src/components/SplitPane.test.tsx

Lines changed: 143 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { triggerResize, clearResizeObservers } from '../test/setup';
77
// These match the mock in test/setup.ts
88
const CONTAINER_WIDTH = 1024;
99
const CONTAINER_HEIGHT = 768;
10+
const DEFAULT_DIVIDER_SIZE = 1;
1011

1112
describe('SplitPane', () => {
1213
beforeEach(() => {
@@ -122,10 +123,14 @@ describe('SplitPane initial size calculation', () => {
122123
const panes = container.querySelectorAll('[data-pane="true"]');
123124
expect(panes).toHaveLength(2);
124125

125-
// First pane: 30% of 1024 = 307.2px
126-
expect(panes[0]).toHaveStyle({ width: `${CONTAINER_WIDTH * 0.3}px` });
127-
// Second pane: remaining 70% = 716.8px
128-
expect(panes[1]).toHaveStyle({ width: `${CONTAINER_WIDTH * 0.7}px` });
126+
// Available space = container - divider = 1024 - 1 = 1023px
127+
const availableWidth = CONTAINER_WIDTH - DEFAULT_DIVIDER_SIZE;
128+
// First pane: 30% of available space (306.9px)
129+
const pane1Width = parseFloat((panes[0] as HTMLElement).style.width);
130+
expect(pane1Width).toBeCloseTo(availableWidth * 0.3, 1);
131+
// Second pane: remaining 70% of available space (716.1px)
132+
const pane2Width = parseFloat((panes[1] as HTMLElement).style.width);
133+
expect(pane2Width).toBeCloseTo(availableWidth * 0.7, 1);
129134
});
130135

131136
it('distributes remaining space equally among multiple auto-sized panes', async () => {
@@ -144,10 +149,12 @@ describe('SplitPane initial size calculation', () => {
144149
const panes = container.querySelectorAll('[data-pane="true"]');
145150
expect(panes).toHaveLength(3);
146151

152+
// Available space = container - (2 dividers * 1px) = 1024 - 2 = 1022px
153+
const availableWidth = CONTAINER_WIDTH - DEFAULT_DIVIDER_SIZE * 2;
147154
// First pane: 200px fixed
148155
expect(panes[0]).toHaveStyle({ width: '200px' });
149-
// Remaining (1024 - 200) = 824px split equally: 412px each
150-
const remainingEach = (CONTAINER_WIDTH - 200) / 2;
156+
// Remaining (1022 - 200) = 822px split equally: 411px each
157+
const remainingEach = (availableWidth - 200) / 2;
151158
expect(panes[1]).toHaveStyle({ width: `${remainingEach}px` });
152159
expect(panes[2]).toHaveStyle({ width: `${remainingEach}px` });
153160
});
@@ -167,8 +174,10 @@ describe('SplitPane initial size calculation', () => {
167174
const panes = container.querySelectorAll('[data-pane="true"]');
168175
expect(panes).toHaveLength(2);
169176

170-
// Each pane gets 50% = 512px
171-
const halfWidth = CONTAINER_WIDTH / 2;
177+
// Available space = container - divider = 1024 - 1 = 1023px
178+
// Each pane gets 50% = 511.5px
179+
const availableWidth = CONTAINER_WIDTH - DEFAULT_DIVIDER_SIZE;
180+
const halfWidth = availableWidth / 2;
172181
expect(panes[0]).toHaveStyle({ width: `${halfWidth}px` });
173182
expect(panes[1]).toHaveStyle({ width: `${halfWidth}px` });
174183
});
@@ -189,12 +198,14 @@ describe('SplitPane initial size calculation', () => {
189198
const panes = container.querySelectorAll('[data-pane="true"]');
190199
expect(panes).toHaveLength(3);
191200

192-
// First two panes: 25% each = 256px
193-
const quarterWidth = CONTAINER_WIDTH * 0.25;
201+
// Available space = container - (2 dividers * 1px) = 1024 - 2 = 1022px
202+
const availableWidth = CONTAINER_WIDTH - DEFAULT_DIVIDER_SIZE * 2;
203+
// First two panes: 25% each of available space
204+
const quarterWidth = availableWidth * 0.25;
194205
expect(panes[0]).toHaveStyle({ width: `${quarterWidth}px` });
195206
expect(panes[1]).toHaveStyle({ width: `${quarterWidth}px` });
196-
// Third pane: remaining 50% = 512px
197-
expect(panes[2]).toHaveStyle({ width: `${CONTAINER_WIDTH * 0.5}px` });
207+
// Third pane: remaining 50% of available space
208+
expect(panes[2]).toHaveStyle({ width: `${availableWidth * 0.5}px` });
198209
});
199210

200211
it('handles vertical direction correctly', async () => {
@@ -212,10 +223,12 @@ describe('SplitPane initial size calculation', () => {
212223
const panes = container.querySelectorAll('[data-pane="true"]');
213224
expect(panes).toHaveLength(2);
214225

215-
// First pane: 25% of 768 = 192px
216-
expect(panes[0]).toHaveStyle({ height: `${CONTAINER_HEIGHT * 0.25}px` });
217-
// Second pane: remaining 75% = 576px
218-
expect(panes[1]).toHaveStyle({ height: `${CONTAINER_HEIGHT * 0.75}px` });
226+
// Available height = container - divider = 768 - 1 = 767px
227+
const availableHeight = CONTAINER_HEIGHT - DEFAULT_DIVIDER_SIZE;
228+
// First pane: 25% of available height
229+
expect(panes[0]).toHaveStyle({ height: `${availableHeight * 0.25}px` });
230+
// Second pane: remaining 75% of available height
231+
expect(panes[1]).toHaveStyle({ height: `${availableHeight * 0.75}px` });
219232
});
220233

221234
it('respects controlled size prop over defaultSize', async () => {
@@ -235,10 +248,12 @@ describe('SplitPane initial size calculation', () => {
235248
const panes = container.querySelectorAll('[data-pane="true"]');
236249
expect(panes).toHaveLength(2);
237250

251+
// Available space = container - divider = 1024 - 1 = 1023px
252+
const availableWidth = CONTAINER_WIDTH - DEFAULT_DIVIDER_SIZE;
238253
// First pane: controlled size of 400px (not 30%)
239254
expect(panes[0]).toHaveStyle({ width: '400px' });
240-
// Second pane: remaining 624px
241-
expect(panes[1]).toHaveStyle({ width: `${CONTAINER_WIDTH - 400}px` });
255+
// Second pane: remaining available space minus first pane
256+
expect(panes[1]).toHaveStyle({ width: `${availableWidth - 400}px` });
242257
});
243258

244259
it('updates pane sizes when controlled size prop changes', async () => {
@@ -355,19 +370,27 @@ describe('SplitPane container resize behavior', () => {
355370

356371
const panes = container.querySelectorAll('[data-pane="true"]');
357372

358-
// Initial: 200px + 824px = 1024px
373+
// Available space = container - divider = 1024 - 1 = 1023px
374+
const initialAvailable = CONTAINER_WIDTH - DEFAULT_DIVIDER_SIZE;
375+
// Initial: 200px + 823px = 1023px (available space)
359376
expect(panes[0]).toHaveStyle({ width: '200px' });
360-
expect(panes[1]).toHaveStyle({ width: `${CONTAINER_WIDTH - 200}px` });
377+
expect(panes[1]).toHaveStyle({ width: `${initialAvailable - 200}px` });
361378

362379
// Simulate container resize to 2048px (double)
363380
await act(async () => {
364381
triggerResize(2048, 768);
365382
await vi.runAllTimersAsync();
366383
});
367384

385+
// Available space at new size = 2048 - 1 = 2047px
386+
const newAvailable = 2048 - DEFAULT_DIVIDER_SIZE;
368387
// Uncontrolled panes should scale proportionally
369-
expect(panes[0]).toHaveStyle({ width: '400px' });
370-
expect(panes[1]).toHaveStyle({ width: `${2048 - 400}px` });
388+
// Original ratio: 200/1023 and 823/1023
389+
const expectedPane1 = (200 / initialAvailable) * newAvailable;
390+
const expectedPane2 =
391+
((initialAvailable - 200) / initialAvailable) * newAvailable;
392+
expect(panes[0]).toHaveStyle({ width: `${expectedPane1}px` });
393+
expect(panes[1]).toHaveStyle({ width: `${expectedPane2}px` });
371394
});
372395

373396
it('maintains controlled sizes in vertical direction on container resize', async () => {
@@ -398,3 +421,101 @@ describe('SplitPane container resize behavior', () => {
398421
expect(panes[1]).toHaveStyle({ height: '300px' });
399422
});
400423
});
424+
425+
describe('SplitPane divider size accounting', () => {
426+
beforeEach(() => {
427+
clearResizeObservers();
428+
});
429+
430+
it('accounts for divider width when calculating percentage-based pane sizes', async () => {
431+
// Custom divider with a known width
432+
const DividerWithWidth = (props: {
433+
direction: string;
434+
onMouseDown?: () => void;
435+
onTouchStart?: () => void;
436+
onTouchEnd?: () => void;
437+
onKeyDown?: () => void;
438+
}) => (
439+
<div
440+
role="separator"
441+
style={{ width: props.direction === 'horizontal' ? '10px' : undefined }}
442+
data-testid="divider"
443+
onMouseDown={props.onMouseDown}
444+
onTouchStart={props.onTouchStart}
445+
onTouchEnd={props.onTouchEnd}
446+
onKeyDown={props.onKeyDown}
447+
/>
448+
);
449+
450+
const { container } = render(
451+
<SplitPane
452+
direction="horizontal"
453+
divider={DividerWithWidth}
454+
dividerSize={10}
455+
>
456+
<Pane defaultSize="33%">Pane 1</Pane>
457+
<Pane defaultSize="34%">Pane 2</Pane>
458+
<Pane defaultSize="33%">Pane 3</Pane>
459+
</SplitPane>
460+
);
461+
462+
await act(async () => {
463+
await vi.runAllTimersAsync();
464+
});
465+
466+
const panes = container.querySelectorAll('[data-pane="true"]');
467+
expect(panes).toHaveLength(3);
468+
469+
// Get actual pane widths
470+
const pane1Width = parseFloat(
471+
(panes[0] as HTMLElement).style.width.replace('px', '')
472+
);
473+
const pane2Width = parseFloat(
474+
(panes[1] as HTMLElement).style.width.replace('px', '')
475+
);
476+
const pane3Width = parseFloat(
477+
(panes[2] as HTMLElement).style.width.replace('px', '')
478+
);
479+
480+
// With 2 dividers at 10px each, available space for panes is 1024 - 20 = 1004px
481+
// Currently the bug causes panes to total 100% of 1024 = 1024px (overflow!)
482+
// After fix: panes should total 1004px (accounting for divider widths)
483+
const totalPaneWidth = pane1Width + pane2Width + pane3Width;
484+
const dividerWidth = 10;
485+
const dividerCount = 2;
486+
const expectedTotalPaneWidth =
487+
CONTAINER_WIDTH - dividerWidth * dividerCount;
488+
489+
// The total pane width should not exceed container minus dividers
490+
expect(totalPaneWidth).toBeLessThanOrEqual(CONTAINER_WIDTH);
491+
expect(totalPaneWidth).toBeCloseTo(expectedTotalPaneWidth, 0);
492+
});
493+
494+
it('accounts for default 1px divider width in size calculations', async () => {
495+
const { container } = render(
496+
<SplitPane direction="horizontal">
497+
<Pane defaultSize="50%">Pane 1</Pane>
498+
<Pane defaultSize="50%">Pane 2</Pane>
499+
</SplitPane>
500+
);
501+
502+
await act(async () => {
503+
await vi.runAllTimersAsync();
504+
});
505+
506+
const panes = container.querySelectorAll('[data-pane="true"]');
507+
const pane1Width = parseFloat(
508+
(panes[0] as HTMLElement).style.width.replace('px', '')
509+
);
510+
const pane2Width = parseFloat(
511+
(panes[1] as HTMLElement).style.width.replace('px', '')
512+
);
513+
514+
// With 1 divider at 1px, available space is 1024 - 1 = 1023px
515+
// Each pane should be 50% of 1023 = 511.5px
516+
const totalPaneWidth = pane1Width + pane2Width;
517+
const expectedTotal = CONTAINER_WIDTH - 1; // minus 1px divider
518+
519+
expect(totalPaneWidth).toBeCloseTo(expectedTotal, 0);
520+
});
521+
});

src/components/SplitPane.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function SplitPane(props: SplitPaneProps) {
5959
divider: CustomDivider,
6060
dividerStyle,
6161
dividerClassName,
62+
dividerSize = 1,
6263
children,
6364
} = props;
6465

@@ -124,11 +125,15 @@ export function SplitPane(props: SplitPaneProps) {
124125
return new Array(paneCount).fill(0);
125126
}
126127

128+
// Account for divider widths when calculating available space
129+
const totalDividerWidth = dividerSize * (paneCount - 1);
130+
const availableSpace = containerSz - totalDividerWidth;
131+
127132
// First pass: calculate sizes for panes with explicit sizes
128133
const sizes: (number | null)[] = paneConfigs.map((config) => {
129134
const paneSize = config.size ?? config.defaultSize;
130135
if (paneSize !== undefined) {
131-
return convertToPixels(paneSize, containerSz);
136+
return convertToPixels(paneSize, availableSpace);
132137
}
133138
return null; // Mark as needing auto-size
134139
});
@@ -139,13 +144,13 @@ export function SplitPane(props: SplitPaneProps) {
139144
0
140145
);
141146
const autoSizedCount = sizes.filter((s) => s === null).length;
142-
const remainingSpace = containerSz - explicitTotal;
147+
const remainingSpace = availableSpace - explicitTotal;
143148
const autoSize = autoSizedCount > 0 ? remainingSpace / autoSizedCount : 0;
144149

145150
// Second pass: fill in auto-sized panes
146151
return sizes.map((size) => (size === null ? autoSize : size));
147152
},
148-
[paneCount, paneConfigs]
153+
[paneCount, paneConfigs, dividerSize]
149154
);
150155

151156
const [paneSizes, setPaneSizes] = useState<number[]>(() =>
@@ -191,6 +196,10 @@ export function SplitPane(props: SplitPaneProps) {
191196
(config) => config.size !== undefined
192197
);
193198

199+
// Calculate available space after accounting for dividers
200+
const totalDividerWidth = dividerSize * (paneCount - 1);
201+
const availableSpace = newContainerSize - totalDividerWidth;
202+
194203
setPaneSizes((currentSizes) => {
195204
// If sizes are uninitialized or pane count changed
196205
if (
@@ -206,8 +215,8 @@ export function SplitPane(props: SplitPaneProps) {
206215
if (hasControlledSizes) {
207216
return calculateInitialSizes(newContainerSize);
208217
}
209-
// For uncontrolled panes, distribute proportionally
210-
return distributeSizes(currentSizes, newContainerSize);
218+
// For uncontrolled panes, distribute proportionally using available space
219+
return distributeSizes(currentSizes, availableSpace);
211220
}
212221

213222
// First measurement - use initial sizes
@@ -218,7 +227,7 @@ export function SplitPane(props: SplitPaneProps) {
218227
return currentSizes;
219228
});
220229
},
221-
[paneCount, paneConfigs, calculateInitialSizes]
230+
[paneCount, paneConfigs, calculateInitialSizes, dividerSize]
222231
);
223232

224233
// Measure container size with ResizeObserver

src/types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export interface SplitPaneProps {
5050
/** Custom divider class name */
5151
dividerClassName?: string;
5252

53+
/** Size of the divider in pixels (used for accurate pane size calculations) */
54+
dividerSize?: number;
55+
5356
/** Pane children */
5457
children: ReactNode;
5558
}

0 commit comments

Comments
 (0)