1
1
import { Colors } from '@blueprintjs/core' ;
2
2
import styled from '@emotion/styled' ;
3
+ import { useControllableState } from '@radix-ui/react-use-controllable-state' ;
3
4
import type {
4
5
CSSProperties ,
5
6
PointerEvent as ReactPointerEvent ,
6
7
ReactNode ,
7
8
RefObject ,
8
9
} from 'react' ;
9
- import { useEffect , useReducer , useRef , useState } from 'react' ;
10
+ import { useEffect , useReducer , useRef } from 'react' ;
10
11
import { match } from 'ts-pattern' ;
11
12
import useResizeObserver from 'use-resize-observer' ;
12
13
13
- import { useOnOff } from '../hooks/useOnOff.js' ;
14
-
15
14
import { useSplitPaneSize } from './useSplitPaneSize.js' ;
16
15
17
16
export type SplitPaneDirection = 'vertical' | 'horizontal' ;
@@ -26,39 +25,65 @@ export interface SplitPaneProps {
26
25
*/
27
26
direction ?: SplitPaneDirection ;
28
27
/**
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` .
33
32
* @default 'start'
34
33
*/
35
34
controlledSide ?: SplitPaneSide ;
35
+
36
36
/**
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'.
38
40
* @default '50%'
39
41
*/
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
+
41
58
/**
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
49
68
* no longer open or close automatically.
50
- * @default false
51
69
*/
52
- closed ?: boolean | number ;
70
+ closeThreshold ?: number ;
71
+
53
72
/**
54
73
* Called whenever the user finishes resizing the pane.
55
74
*/
56
75
onResize ?: ( position : SplitPaneSize ) => void ;
76
+
57
77
/**
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.
60
79
*/
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
+
62
87
/**
63
88
* The two React elements to show on both sides of the pane.
64
89
*/
@@ -69,40 +94,33 @@ export function SplitPane(props: SplitPaneProps) {
69
94
const {
70
95
direction = 'horizontal' ,
71
96
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 ,
74
103
onResize,
75
- onToggle ,
104
+ onOpenChange ,
76
105
children,
77
106
} = props ;
78
107
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
+ } ) ;
80
118
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 ) ;
86
120
87
121
// Whether the user has already interacted with the pane.
88
122
const [ hasTouched , touch ] = useReducer ( ( ) => true , false ) ;
89
123
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
-
106
124
const splitterRef = useRef < HTMLDivElement > ( null ) ;
107
125
const { onPointerDown } = useSplitPaneSize ( {
108
126
controlledSide,
@@ -111,48 +129,38 @@ export function SplitPane(props: SplitPaneProps) {
111
129
sizeType,
112
130
onSizeChange ( value ) {
113
131
touch ( ) ;
114
- setSize ( value ) ;
132
+ const serialized = serializeSize ( value ) ;
133
+ setSize ( serialized ) ;
115
134
} ,
116
135
onResize,
117
136
} ) ;
118
137
119
138
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
120
139
// @ts -ignore Module exists.
121
140
const rootSize = useResizeObserver < HTMLDivElement > ( ) ;
141
+ const mainDirectionSize =
142
+ direction === 'horizontal' ? rootSize . width : rootSize . height ;
122
143
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 ;
135
151
}
136
- }
152
+ const shouldBeOpen = mainDirectionSize >= closeThreshold ;
153
+ setIsOpen ( shouldBeOpen ) ;
154
+ } , [ mainDirectionSize , closeThreshold , hasTouched , setIsOpen , isOpen ] ) ;
137
155
138
156
function handleToggle ( ) {
139
157
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 ) ;
151
159
}
152
160
153
161
function getSplitSideStyle ( side : SplitPaneSide ) {
154
162
return getItemStyle (
155
- isFinalClosed ,
163
+ isOpen ?? defaultOpen ,
156
164
controlledSide === side ,
157
165
direction ,
158
166
splitSize ,
@@ -175,8 +183,8 @@ export function SplitPane(props: SplitPaneProps) {
175
183
176
184
< Splitter
177
185
onDoubleClick = { handleToggle }
178
- onPointerDown = { isFinalClosed ? undefined : onPointerDown }
179
- isFinalClosed = { isFinalClosed }
186
+ onPointerDown = { isOpen ? onPointerDown : undefined }
187
+ isOpen = { isOpen ?? defaultOpen }
180
188
direction = { direction }
181
189
splitterRef = { splitterRef }
182
190
/>
@@ -190,24 +198,19 @@ interface SplitterProps {
190
198
onDoubleClick : ( ) => void ;
191
199
onPointerDown ?: ( event : ReactPointerEvent ) => void ;
192
200
direction : SplitPaneDirection ;
193
- isFinalClosed : boolean ;
201
+ isOpen : boolean ;
194
202
splitterRef : RefObject < HTMLDivElement > ;
195
203
}
196
204
197
205
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 ;
205
208
206
209
return (
207
210
< Split
208
211
onDoubleClick = { onDoubleClick }
209
212
onPointerDown = { onPointerDown }
210
- enabled = { ! isFinalClosed }
213
+ enabled = { isOpen }
211
214
direction = { direction }
212
215
ref = { splitterRef }
213
216
>
@@ -229,29 +232,35 @@ function SplitSide(props: SplitSideProps) {
229
232
return < div style = { style } > { children } </ div > ;
230
233
}
231
234
232
- function parseSize ( size : string ) : [ number , SplitPaneType ] {
235
+ type ParsedSplitPaneSize = [ number , SplitPaneType ] ;
236
+
237
+ function parseSize ( size : string ) : ParsedSplitPaneSize {
233
238
const value = Number . parseFloat ( size ) ;
234
239
// remove numbers and dots from the string
235
240
const type = size . replaceAll ( / [ \d . ] / g, '' ) as SplitPaneType ;
236
241
237
242
return [ value , type ] ;
238
243
}
239
244
245
+ function serializeSize ( size : [ number , SplitPaneType ] ) : SplitPaneSize {
246
+ return `${ size [ 0 ] } ${ size [ 1 ] } ` ;
247
+ }
248
+
240
249
const flexBase = 100 ;
241
250
function percentToFlex ( percent : number ) : number {
242
251
percent /= 100 ;
243
252
return ( flexBase - percent * flexBase ) / percent ;
244
253
}
245
254
246
255
function getItemStyle (
247
- isClosed : boolean ,
256
+ isOpen : boolean ,
248
257
isControlledSide : boolean ,
249
258
direction : SplitPaneDirection ,
250
259
size : number ,
251
260
type : SplitPaneType ,
252
261
) {
253
262
const isHorizontal = direction === 'horizontal' ;
254
- if ( isClosed ) {
263
+ if ( ! isOpen ) {
255
264
return isControlledSide
256
265
? { display : 'none' }
257
266
: { flex : '1 1 0%' , display : 'flex' } ;
0 commit comments