Skip to content

Commit 4cf816c

Browse files
authored
feat(SplitPane): rework SplitPane API
Refactor the SplitPane API to be more consistent and idiomatic. Make the `open` and `size` state of the component controllable. BREAKING CHANGE: The `onToggle` prop has been renamed to `onOpenChange` and receives true when the split pane opens (previously received false). Contrary to the previous onToggle, onOpenChange is called in any circumstance when the split pane state changes, for example also when it automatically closes after it reaches the `closeThreshold` size. The original behavior of the `closed` and `size` props has changed. Their behavior were a mix of a control value prop and default value prop for internally managed state. There is now a clear separation between control props (`open` and `size`) and default value props `defaultOpen` and `defaultSize`. For example for the open state you should either: - Use `open` to control if the SplitPane is open or not, and `onOpenChange` to react to changes. Using `defaultOpen` will have no effect. - Use `defaultOpen` to set the initial value (only affects first render), with state changes being handled internally by the component. If you used `closed` with a number, use the new `closeThreshold` prop instead. Closes: #852
1 parent 729c39f commit 4cf816c

10 files changed

+627
-426
lines changed

package-lock.json

+253-130
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+7-5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
},
5858
"dependencies": {
5959
"@emotion/styled": "^11.14.0",
60+
"@radix-ui/react-use-controllable-state": "^1.1.0",
6061
"@tanstack/react-table": "^8.20.6",
6162
"@tanstack/react-virtual": "^3.12.0",
6263
"d3-scale-chromatic": "^3.1.0",
@@ -74,11 +75,12 @@
7475
"@floating-ui/react": "^0.27.3",
7576
"@playwright/experimental-ct-react": "^1.50.1",
7677
"@playwright/test": "^1.50.1",
77-
"@storybook/addon-essentials": "^8.5.3",
78-
"@storybook/addon-storysource": "^8.5.3",
79-
"@storybook/blocks": "^8.5.3",
80-
"@storybook/react": "^8.5.3",
81-
"@storybook/react-vite": "^8.5.3",
78+
"@storybook/addon-actions": "^8.5.8",
79+
"@storybook/addon-essentials": "^8.5.8",
80+
"@storybook/addon-storysource": "^8.5.8",
81+
"@storybook/blocks": "^8.5.8",
82+
"@storybook/react": "^8.5.8",
83+
"@storybook/react-vite": "^8.5.8",
8284
"@tanstack/react-query": "^5.66.0",
8385
"@types/babel__core": "^7.20.5",
8486
"@types/d3-scale-chromatic": "^3.1.0",

src/components/split-pane/SplitPane.tsx

+95-86
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { Colors } from '@blueprintjs/core';
22
import styled from '@emotion/styled';
3+
import { useControllableState } from '@radix-ui/react-use-controllable-state';
34
import type {
45
CSSProperties,
56
PointerEvent as ReactPointerEvent,
67
ReactNode,
78
RefObject,
89
} from 'react';
9-
import { useEffect, useReducer, useRef, useState } from 'react';
10+
import { useEffect, useReducer, useRef } from 'react';
1011
import { match } from 'ts-pattern';
1112
import useResizeObserver from 'use-resize-observer';
1213

13-
import { useOnOff } from '../hooks/useOnOff.js';
14-
1514
import { useSplitPaneSize } from './useSplitPaneSize.js';
1615

1716
export type SplitPaneDirection = 'vertical' | 'horizontal';
@@ -26,39 +25,65 @@ export interface SplitPaneProps {
2625
*/
2726
direction?: SplitPaneDirection;
2827
/**
29-
* Defines which side of the pane is controlled by the split value.
30-
* It is also the side that will be closed when the user double clicks on the
31-
* splitter.
32-
* A value of 'start' means 'left' or 'top' (depending on the direction).
28+
* Defines which side of the pane is controlled by the size value.
29+
* It is also the side that can be closed.
30+
* 'start' means 'left' if the direction is `horizontal` and 'top' if the
31+
* direction is `vertical`.
3332
* @default 'start'
3433
*/
3534
controlledSide?: SplitPaneSide;
35+
3636
/**
37-
* size of the controlled side. Unit can be either '%' or 'px'.
37+
* Initial size of the controlled side.
38+
* This prop has no effect if the `size` prop is used.
39+
* Unit can be either '%' or 'px'.
3840
* @default '50%'
3941
*/
40-
size?: SplitPaneSize;
42+
defaultSize?: SplitPaneSize;
43+
44+
/**
45+
* Defines whether the pane is initially open.
46+
* This prop has no effect if the `open` prop is used.
47+
* A value of `true` means the pane is initially open.
48+
* A value of `false` means the pane is initially closed.
49+
* @default true
50+
*/
51+
defaultOpen?: boolean;
52+
53+
/**
54+
* Controls the open state of the pane.
55+
*/
56+
open?: boolean;
57+
4158
/**
42-
* Defines whether the pane is initially closed.
43-
* A value of `true` means the pane is always initially closed.
44-
* A value of `false` means the pane is always initially open.
45-
* A value of type `number` means the pane is initially closed if its total
46-
* size is smaller than the specified value. In that case, the pane will
47-
* dynamically open or close when the total size changes, until the user
48-
* interacts with the splitter. After the first interaction, the pane will
59+
* Called whenever the open state of the pane changes.
60+
*/
61+
onOpenChange?: (isOpen: boolean) => void;
62+
63+
/**
64+
* If specified, closes / opens the pane automatically once the available space
65+
* for the SplitPane along the axis defined by the `direction` prop becomes
66+
* smaller / larger (respectively) than this value. The value is in pixels.
67+
* After the user manually opens or closes the splitter, the pane will
4968
* no longer open or close automatically.
50-
* @default false
5169
*/
52-
closed?: boolean | number;
70+
closeThreshold?: number;
71+
5372
/**
5473
* Called whenever the user finishes resizing the pane.
5574
*/
5675
onResize?: (position: SplitPaneSize) => void;
76+
5777
/**
58-
* Called whenever the user double clicks on the splitter to open or close
59-
* the pane.
78+
* Controls the size of the controlled side.
6079
*/
61-
onToggle?: (isClosed: boolean) => void;
80+
size?: SplitPaneSize;
81+
82+
/**
83+
* Called whenever the size of the controlled side changes.
84+
*/
85+
onSizeChange?: (size: SplitPaneSize) => void;
86+
6287
/**
6388
* The two React elements to show on both sides of the pane.
6489
*/
@@ -69,40 +94,33 @@ export function SplitPane(props: SplitPaneProps) {
6994
const {
7095
direction = 'horizontal',
7196
controlledSide = 'start',
72-
size = '50%',
73-
closed = false,
97+
defaultSize = '50%',
98+
defaultOpen = true,
99+
open: openProp,
100+
size: sizeProp,
101+
onSizeChange,
102+
closeThreshold = null,
74103
onResize,
75-
onToggle,
104+
onOpenChange,
76105
children,
77106
} = props;
78107

79-
const minimumSize = typeof closed === 'number' ? closed : null;
108+
const [isOpen, setIsOpen] = useControllableState({
109+
prop: openProp,
110+
defaultProp: defaultOpen,
111+
onChange: onOpenChange,
112+
});
113+
const [size, setSize] = useControllableState<SplitPaneSize>({
114+
prop: sizeProp,
115+
defaultProp: defaultSize,
116+
onChange: onSizeChange,
117+
});
80118

81-
// Whether the pane is explicitly closed. If the value is `false`, the pane
82-
// may still be currently closed because it is smaller than the minimum size.
83-
const [isPaneClosed, closePane, openPane] = useOnOff(
84-
typeof closed === 'boolean' ? closed : false,
85-
);
119+
const [splitSize, sizeType] = parseSize(size ?? defaultSize);
86120

87121
// Whether the user has already interacted with the pane.
88122
const [hasTouched, touch] = useReducer(() => true, false);
89123

90-
const [[splitSize, sizeType], setSize] = useState(() => parseSize(size));
91-
92-
useEffect(() => {
93-
setSize(parseSize(size));
94-
}, [size]);
95-
96-
useEffect(() => {
97-
if (typeof closed === 'boolean') {
98-
if (closed) {
99-
closePane();
100-
} else {
101-
openPane();
102-
}
103-
}
104-
}, [closePane, closed, openPane]);
105-
106124
const splitterRef = useRef<HTMLDivElement>(null);
107125
const { onPointerDown } = useSplitPaneSize({
108126
controlledSide,
@@ -111,48 +129,38 @@ export function SplitPane(props: SplitPaneProps) {
111129
sizeType,
112130
onSizeChange(value) {
113131
touch();
114-
setSize(value);
132+
const serialized = serializeSize(value);
133+
setSize(serialized);
115134
},
116135
onResize,
117136
});
118137

119138
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
120139
// @ts-ignore Module exists.
121140
const rootSize = useResizeObserver<HTMLDivElement>();
141+
const mainDirectionSize =
142+
direction === 'horizontal' ? rootSize.width : rootSize.height;
122143

123-
let isFinalClosed = isPaneClosed;
124-
if (
125-
!isFinalClosed &&
126-
minimumSize !== null &&
127-
!hasTouched &&
128-
rootSize.width !== undefined &&
129-
rootSize.height !== undefined
130-
) {
131-
if (direction === 'horizontal') {
132-
isFinalClosed = rootSize.width < minimumSize;
133-
} else {
134-
isFinalClosed = rootSize.height < minimumSize;
144+
useEffect(() => {
145+
if (
146+
closeThreshold === null ||
147+
hasTouched ||
148+
mainDirectionSize === undefined
149+
) {
150+
return;
135151
}
136-
}
152+
const shouldBeOpen = mainDirectionSize >= closeThreshold;
153+
setIsOpen(shouldBeOpen);
154+
}, [mainDirectionSize, closeThreshold, hasTouched, setIsOpen, isOpen]);
137155

138156
function handleToggle() {
139157
touch();
140-
if (isFinalClosed) {
141-
openPane();
142-
if (isPaneClosed && onToggle) {
143-
onToggle(false);
144-
}
145-
} else {
146-
closePane();
147-
if (!isPaneClosed && onToggle) {
148-
onToggle(true);
149-
}
150-
}
158+
setIsOpen(!isOpen);
151159
}
152160

153161
function getSplitSideStyle(side: SplitPaneSide) {
154162
return getItemStyle(
155-
isFinalClosed,
163+
isOpen ?? defaultOpen,
156164
controlledSide === side,
157165
direction,
158166
splitSize,
@@ -175,8 +183,8 @@ export function SplitPane(props: SplitPaneProps) {
175183

176184
<Splitter
177185
onDoubleClick={handleToggle}
178-
onPointerDown={isFinalClosed ? undefined : onPointerDown}
179-
isFinalClosed={isFinalClosed}
186+
onPointerDown={isOpen ? onPointerDown : undefined}
187+
isOpen={isOpen ?? defaultOpen}
180188
direction={direction}
181189
splitterRef={splitterRef}
182190
/>
@@ -190,24 +198,19 @@ interface SplitterProps {
190198
onDoubleClick: () => void;
191199
onPointerDown?: (event: ReactPointerEvent) => void;
192200
direction: SplitPaneDirection;
193-
isFinalClosed: boolean;
201+
isOpen: boolean;
194202
splitterRef: RefObject<HTMLDivElement>;
195203
}
196204

197205
function Splitter(props: SplitterProps) {
198-
const {
199-
onDoubleClick,
200-
onPointerDown,
201-
direction,
202-
isFinalClosed,
203-
splitterRef,
204-
} = props;
206+
const { onDoubleClick, onPointerDown, direction, isOpen, splitterRef } =
207+
props;
205208

206209
return (
207210
<Split
208211
onDoubleClick={onDoubleClick}
209212
onPointerDown={onPointerDown}
210-
enabled={!isFinalClosed}
213+
enabled={isOpen}
211214
direction={direction}
212215
ref={splitterRef}
213216
>
@@ -229,29 +232,35 @@ function SplitSide(props: SplitSideProps) {
229232
return <div style={style}>{children}</div>;
230233
}
231234

232-
function parseSize(size: string): [number, SplitPaneType] {
235+
type ParsedSplitPaneSize = [number, SplitPaneType];
236+
237+
function parseSize(size: string): ParsedSplitPaneSize {
233238
const value = Number.parseFloat(size);
234239
// remove numbers and dots from the string
235240
const type = size.replaceAll(/[\d .]/g, '') as SplitPaneType;
236241

237242
return [value, type];
238243
}
239244

245+
function serializeSize(size: [number, SplitPaneType]): SplitPaneSize {
246+
return `${size[0]}${size[1]}`;
247+
}
248+
240249
const flexBase = 100;
241250
function percentToFlex(percent: number): number {
242251
percent /= 100;
243252
return (flexBase - percent * flexBase) / percent;
244253
}
245254

246255
function getItemStyle(
247-
isClosed: boolean,
256+
isOpen: boolean,
248257
isControlledSide: boolean,
249258
direction: SplitPaneDirection,
250259
size: number,
251260
type: SplitPaneType,
252261
) {
253262
const isHorizontal = direction === 'horizontal';
254-
if (isClosed) {
263+
if (!isOpen) {
255264
return isControlledSide
256265
? { display: 'none' }
257266
: { flex: '1 1 0%', display: 'flex' };

src/pages/demo/MainLayout.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ export default function MainLayout() {
110110
flex: 1,
111111
}}
112112
>
113-
<SplitPane size="400px" closed={500} controlledSide="end">
113+
<SplitPane
114+
defaultSize="400px"
115+
closeThreshold={500}
116+
controlledSide="end"
117+
>
114118
<ErrorBoundary
115119
FallbackComponent={ErrorFallback}
116120
onReset={() => {

stories/components/accordion.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ export function WithToggle() {
265265
height: '300px',
266266
}}
267267
>
268-
<SplitPane size="35%">
268+
<SplitPane defaultSize="35%">
269269
<div style={{ padding: 5, display: 'flex', gap: 5, height: 40 }}>
270270
<button
271271
type="button"

stories/components/activity_bar.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export function ActivityToolbarLayout() {
115115
}}
116116
>
117117
{selected.length > 0 ? (
118-
<SplitPane size="30%" controlledSide="end">
118+
<SplitPane defaultSize="30%" controlledSide="end">
119119
<PlaceHolder />
120120
<ActivityPanel>
121121
{itemsBlueprintIcons

stories/components/dialog.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ function DemoPage(props: { openDialog: () => void }) {
309309
height: '300px',
310310
}}
311311
>
312-
<SplitPane size="35%">
312+
<SplitPane defaultSize="35%">
313313
<div style={{ padding: 5 }}>
314314
<Button intent="primary" onClick={props.openDialog}>
315315
Open

0 commit comments

Comments
 (0)