@startupjs-ui/drawer 0.1.3
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.
- package/CHANGELOG.md +21 -0
- package/README.mdx +206 -0
- package/Swipe.tsx +110 -0
- package/animate.ts +88 -0
- package/index.cssx.styl +38 -0
- package/index.d.ts +36 -0
- package/index.tsx +206 -0
- package/package.json +20 -0
- package/useDrawerDismiss.ts +36 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @startupjs-ui/drawer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **abstract-popover:** refactor AbstractPopover component ([4a19018](https://github.com/startupjs/startupjs-ui/commit/4a190183b9e6903b6758d1d006fce7eca39bce0a))
|
|
20
|
+
* add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
|
|
21
|
+
* **drawer:** refactor Drawer component ([3b9bac2](https://github.com/startupjs/startupjs-ui/commit/3b9bac2b3d15b9b3676e7f2adfe6ca9c6a9e597a))
|
package/README.mdx
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { View, TouchableWithoutFeedback, Text } from 'react-native'
|
|
3
|
+
import { Sandbox } from '@startupjs-ui/docs'
|
|
4
|
+
import { useRouter } from 'expo-router'
|
|
5
|
+
import Drawer, { _PropsJsonSchema as DrawerPropsJsonSchema, useDrawerDismiss } from './index'
|
|
6
|
+
import Div from '@startupjs-ui/div'
|
|
7
|
+
import Button from '@startupjs-ui/button'
|
|
8
|
+
|
|
9
|
+
# Drawer
|
|
10
|
+
|
|
11
|
+
Navigation bars are designed to provide links to different parts of your application
|
|
12
|
+
Sidebars provide additional information and dock to the left or right side of the browser window
|
|
13
|
+
```jsx
|
|
14
|
+
import { Drawer } from 'startupjs-ui'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Initialization
|
|
18
|
+
|
|
19
|
+
Before use you need to configure [Portal](/docs/components/Portal)
|
|
20
|
+
|
|
21
|
+
## Simple example
|
|
22
|
+
```jsx example
|
|
23
|
+
const [visible, setVisible] = useState(false)
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<View>
|
|
27
|
+
<Drawer
|
|
28
|
+
visible={visible}
|
|
29
|
+
style={{ width: 250 }}
|
|
30
|
+
onDismiss={()=> setVisible(false)}
|
|
31
|
+
>
|
|
32
|
+
<View style={{ padding: 16 }}>
|
|
33
|
+
<Text>Content</Text>
|
|
34
|
+
</View>
|
|
35
|
+
</Drawer>
|
|
36
|
+
<Button
|
|
37
|
+
style={{ width: 160 }}
|
|
38
|
+
onPress={()=> setVisible(true)}
|
|
39
|
+
>Show</Button>
|
|
40
|
+
</View>
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Position
|
|
45
|
+
Сomponent can be deployed from different directions (left, right, top, buttom)
|
|
46
|
+
```jsx example
|
|
47
|
+
const [visible, setVisible] = useState('')
|
|
48
|
+
|
|
49
|
+
const data = {
|
|
50
|
+
left: {
|
|
51
|
+
name: 'Left',
|
|
52
|
+
style: { width: 240 }
|
|
53
|
+
},
|
|
54
|
+
right: {
|
|
55
|
+
name: 'Right',
|
|
56
|
+
style: { width: 240 }
|
|
57
|
+
},
|
|
58
|
+
top: {
|
|
59
|
+
name: 'Up',
|
|
60
|
+
style: { height: 240 }
|
|
61
|
+
},
|
|
62
|
+
bottom: {
|
|
63
|
+
name: 'Down',
|
|
64
|
+
style: { height: 240 }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Div row style={{ width: '100%', flexWrap: 'wrap' }}>
|
|
70
|
+
{
|
|
71
|
+
Object.keys(data).map((key, index) => (
|
|
72
|
+
<View key={index}>
|
|
73
|
+
<Drawer
|
|
74
|
+
position={key}
|
|
75
|
+
style={data[key].style}
|
|
76
|
+
visible={visible === key}
|
|
77
|
+
onDismiss={()=> setVisible('')}
|
|
78
|
+
>
|
|
79
|
+
<View style={{ padding: 16 }}>
|
|
80
|
+
<Text>{data[key].name}</Text>
|
|
81
|
+
</View>
|
|
82
|
+
</Drawer>
|
|
83
|
+
<Button
|
|
84
|
+
onPress={()=> setVisible(key)}
|
|
85
|
+
style={{ width: 120, marginRight: 16, marginTop: 16 }}
|
|
86
|
+
>{data[key].name}</Button>
|
|
87
|
+
</View>
|
|
88
|
+
))
|
|
89
|
+
}
|
|
90
|
+
</Div>
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Swipe
|
|
95
|
+
The component has support for closing using a swipe
|
|
96
|
+
```jsx example
|
|
97
|
+
const [visible, setVisible] = useState('')
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Div row style={{ width: '100%', flexWrap: 'wrap' }}>
|
|
101
|
+
<Drawer
|
|
102
|
+
visible={visible === 'zone'}
|
|
103
|
+
onDismiss={()=> setVisible('')}
|
|
104
|
+
style={{ width: 250 }}
|
|
105
|
+
swipeStyle={{ backgroundColor: '#eeeeee' }}
|
|
106
|
+
/>
|
|
107
|
+
<Button
|
|
108
|
+
style={{ width: 280, marginRight: 24, marginTop: 16 }}
|
|
109
|
+
onPress={()=> setVisible('zone')}
|
|
110
|
+
>Swipe zone</Button>
|
|
111
|
+
<Drawer
|
|
112
|
+
visible={visible === 'custom'}
|
|
113
|
+
onDismiss={()=> setVisible('')}
|
|
114
|
+
style={{ width: 250 }}
|
|
115
|
+
swipeStyle={{ backgroundColor: '#eeeeee', width: '30%',
|
|
116
|
+
height: 100, top: 30 }}
|
|
117
|
+
/>
|
|
118
|
+
<Button
|
|
119
|
+
style={{ width: 280, marginTop: 16 }}
|
|
120
|
+
onPress={()=> setVisible('custom')}
|
|
121
|
+
>Custom swipe zone</Button>
|
|
122
|
+
</Div>
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Hook for smooth closing
|
|
127
|
+
If the component has events, before the execution of which the panel must be hidden, e.g. go to another page, and you need it to close smoothly with animation
|
|
128
|
+
There is a hook - `useDrawerDismiss`, into which the default function is passed, which works out for each event `onDismiss`, and additional ones that will be called after it
|
|
129
|
+
```jsx
|
|
130
|
+
import { useDrawerDismiss } from 'startupjs-ui'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```jsx example
|
|
134
|
+
const router = useRouter()
|
|
135
|
+
const [leftDrawer, setLeftDrawer] = useState(false)
|
|
136
|
+
const [rightDrawer, setRightDrawer] = useState(false)
|
|
137
|
+
const [leftVisible, setLeftVisible] = useState(false)
|
|
138
|
+
|
|
139
|
+
const [onDismiss, setOnDismiss] = useDrawerDismiss({
|
|
140
|
+
rightDrawer: () => setRightDrawer(true),
|
|
141
|
+
goToPage: path => router.push(path),
|
|
142
|
+
default: () => setLeftDrawer(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Div row style={{ width: '100%', flexWrap: 'wrap' }}>
|
|
147
|
+
<Drawer
|
|
148
|
+
visible={rightDrawer}
|
|
149
|
+
position='right'
|
|
150
|
+
onDismiss={()=> setRightDrawer(false)}
|
|
151
|
+
style={{ width: 240 }}
|
|
152
|
+
/>
|
|
153
|
+
<Drawer
|
|
154
|
+
visible={leftVisible}
|
|
155
|
+
onDismiss={()=> setLeftVisible(false)}
|
|
156
|
+
style={{ width: 240 }}
|
|
157
|
+
>
|
|
158
|
+
<TouchableWithoutFeedback onPress={()=> router.navigate('/docs/components/Button')}>
|
|
159
|
+
<View>
|
|
160
|
+
<Text>Open another page</Text>
|
|
161
|
+
</View>
|
|
162
|
+
</TouchableWithoutFeedback>
|
|
163
|
+
</Drawer>
|
|
164
|
+
<Button
|
|
165
|
+
onPress={() => setLeftVisible(true)}
|
|
166
|
+
style={{ width: 280, marginRight: 24, marginTop: 16 }}
|
|
167
|
+
>No hook</Button>
|
|
168
|
+
<Drawer
|
|
169
|
+
visible={leftDrawer}
|
|
170
|
+
onDismiss={onDismiss}
|
|
171
|
+
style={{ width: 240 }}
|
|
172
|
+
>
|
|
173
|
+
<TouchableWithoutFeedback onPress={()=> setOnDismiss('rightDrawer')}>
|
|
174
|
+
<View>
|
|
175
|
+
<Text>Open right Drawer</Text>
|
|
176
|
+
</View>
|
|
177
|
+
</TouchableWithoutFeedback>
|
|
178
|
+
<TouchableWithoutFeedback onPress={()=> setOnDismiss('goToPage', '/docs/components/Button')}>
|
|
179
|
+
<View>
|
|
180
|
+
<Text>Open another page</Text>
|
|
181
|
+
</View>
|
|
182
|
+
</TouchableWithoutFeedback>
|
|
183
|
+
</Drawer>
|
|
184
|
+
<Button
|
|
185
|
+
onPress={()=> setLeftDrawer(true)}
|
|
186
|
+
style={{ width: 280, marginRight: 24, marginTop: 16 }}
|
|
187
|
+
>With hook</Button>
|
|
188
|
+
</Div>
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Sandbox
|
|
193
|
+
|
|
194
|
+
<Sandbox
|
|
195
|
+
Component={Drawer}
|
|
196
|
+
propsJsonSchema={DrawerPropsJsonSchema}
|
|
197
|
+
props={{
|
|
198
|
+
visible: false,
|
|
199
|
+
position: 'left',
|
|
200
|
+
hasOverlay: true,
|
|
201
|
+
isSwipe: true,
|
|
202
|
+
showResponder: true,
|
|
203
|
+
onDismiss: () => undefined,
|
|
204
|
+
children: <View style={{ padding: 16 }}><Text>Content</Text></View>
|
|
205
|
+
}}
|
|
206
|
+
/>
|
package/Swipe.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import { PanResponder, View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import { pug, observer } from 'startupjs'
|
|
4
|
+
import { themed } from '@startupjs-ui/core'
|
|
5
|
+
import './index.cssx.styl'
|
|
6
|
+
import type { DrawerAnimateStates } from './animate'
|
|
7
|
+
|
|
8
|
+
const RESPONDER_STYLES = {
|
|
9
|
+
left: { right: 0, width: '10%', height: '100%' },
|
|
10
|
+
right: { left: 0, width: '10%', height: '100%' },
|
|
11
|
+
bottom: { top: 0, width: '100%', height: '10%' },
|
|
12
|
+
top: { bottom: 0, width: '100%', height: '10%' }
|
|
13
|
+
} satisfies Record<string, ViewStyle>
|
|
14
|
+
|
|
15
|
+
interface DrawerSwipeProps {
|
|
16
|
+
position: 'left' | 'right' | 'top' | 'bottom'
|
|
17
|
+
contentSize: { width?: number, height?: number }
|
|
18
|
+
swipeStyle?: StyleProp<ViewStyle>
|
|
19
|
+
isHorizontal: boolean
|
|
20
|
+
isSwipe: boolean
|
|
21
|
+
isInvertPosition: boolean
|
|
22
|
+
animateStates: DrawerAnimateStates
|
|
23
|
+
runHide: () => void
|
|
24
|
+
runShow: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Swipe ({
|
|
28
|
+
position,
|
|
29
|
+
contentSize,
|
|
30
|
+
swipeStyle,
|
|
31
|
+
isHorizontal,
|
|
32
|
+
isSwipe,
|
|
33
|
+
isInvertPosition,
|
|
34
|
+
animateStates,
|
|
35
|
+
runHide,
|
|
36
|
+
runShow
|
|
37
|
+
}: DrawerSwipeProps): ReactNode {
|
|
38
|
+
const [startDrag, setStartDrag] = useState<number | null>(null)
|
|
39
|
+
const [endDrag, setEndDrag] = useState(false)
|
|
40
|
+
const [offset, setOffset] = useState<number | null>(null)
|
|
41
|
+
|
|
42
|
+
const dragZoneValue = useMemo(() => {
|
|
43
|
+
// 15 percent
|
|
44
|
+
return isHorizontal
|
|
45
|
+
? (((contentSize.width ?? 0) / 100) * 15)
|
|
46
|
+
: (((contentSize.height ?? 0) / 100) * 15)
|
|
47
|
+
}, [contentSize, isHorizontal])
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (offset === null) return
|
|
51
|
+
if (endDrag) {
|
|
52
|
+
const validOffset = isInvertPosition ? -offset : offset
|
|
53
|
+
|
|
54
|
+
if (validOffset >= dragZoneValue) {
|
|
55
|
+
runHide()
|
|
56
|
+
} else {
|
|
57
|
+
runShow()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setOffset(null)
|
|
61
|
+
setStartDrag(null)
|
|
62
|
+
setEndDrag(false)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isInvertPosition && offset < 0) animateStates.position.setValue(offset)
|
|
67
|
+
if (!isInvertPosition && offset > 0) animateStates.position.setValue(offset)
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [offset, endDrag])
|
|
70
|
+
|
|
71
|
+
const responder = useMemo(() => PanResponder.create({
|
|
72
|
+
onStartShouldSetPanResponder: () => true,
|
|
73
|
+
onMoveShouldSetPanResponder: () => false,
|
|
74
|
+
onPanResponderTerminationRequest: () => false,
|
|
75
|
+
onShouldBlockNativeResponder: () => false,
|
|
76
|
+
onStartShouldSetPanResponderCapture: (evt, gestureState) => {
|
|
77
|
+
return isHorizontal ? (gestureState.dx !== 0) : (gestureState.dy !== 0)
|
|
78
|
+
},
|
|
79
|
+
onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
|
|
80
|
+
return isHorizontal ? (gestureState.dx !== 0) : (gestureState.dy !== 0)
|
|
81
|
+
},
|
|
82
|
+
onPanResponderGrant: e => {
|
|
83
|
+
if (isHorizontal) {
|
|
84
|
+
setStartDrag(e.nativeEvent.locationX)
|
|
85
|
+
} else {
|
|
86
|
+
setStartDrag(e.nativeEvent.locationY)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
onPanResponderMove: (e, gesture) => {
|
|
90
|
+
if (startDrag) {
|
|
91
|
+
setOffset(isHorizontal ? gesture.dx : gesture.dy)
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
onPanResponderEnd: () => {
|
|
95
|
+
if (startDrag) setEndDrag(true)
|
|
96
|
+
}
|
|
97
|
+
}), [startDrag, isHorizontal])
|
|
98
|
+
|
|
99
|
+
const _responder = !isSwipe ? { panHandlers: {} } : responder
|
|
100
|
+
const _responderStyle = StyleSheet.flatten([
|
|
101
|
+
RESPONDER_STYLES[position],
|
|
102
|
+
swipeStyle
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
return pug`
|
|
106
|
+
View.responder(..._responder.panHandlers style=_responderStyle)
|
|
107
|
+
`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default observer(themed('Drawer', Swipe))
|
package/animate.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Animated } from 'react-native'
|
|
2
|
+
|
|
3
|
+
export interface DrawerAnimateStates {
|
|
4
|
+
opacity: Animated.Value
|
|
5
|
+
position: Animated.Value
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DrawerAnimateParams {
|
|
9
|
+
width: number
|
|
10
|
+
height: number
|
|
11
|
+
animateStates: DrawerAnimateStates
|
|
12
|
+
hasOverlay: boolean
|
|
13
|
+
isHorizontal: boolean
|
|
14
|
+
isInvertPosition: boolean
|
|
15
|
+
isInit?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
show ({
|
|
20
|
+
width,
|
|
21
|
+
height,
|
|
22
|
+
animateStates,
|
|
23
|
+
hasOverlay,
|
|
24
|
+
isHorizontal,
|
|
25
|
+
isInvertPosition,
|
|
26
|
+
isInit
|
|
27
|
+
}: DrawerAnimateParams, callback?: () => void) {
|
|
28
|
+
if (isInit) {
|
|
29
|
+
animateStates.position.setValue(
|
|
30
|
+
isHorizontal
|
|
31
|
+
? (isInvertPosition ? -width : width)
|
|
32
|
+
: (isInvertPosition ? -height : height)
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const animations = [
|
|
37
|
+
Animated.timing(animateStates.position, {
|
|
38
|
+
toValue: 0,
|
|
39
|
+
duration: 300,
|
|
40
|
+
useNativeDriver: false
|
|
41
|
+
})
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
if (hasOverlay) {
|
|
45
|
+
animations.push(
|
|
46
|
+
Animated.timing(animateStates.opacity, {
|
|
47
|
+
toValue: 1,
|
|
48
|
+
duration: 300,
|
|
49
|
+
useNativeDriver: false
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Animated.parallel(animations).start(callback)
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
hide ({
|
|
58
|
+
width,
|
|
59
|
+
height,
|
|
60
|
+
animateStates,
|
|
61
|
+
hasOverlay,
|
|
62
|
+
isHorizontal,
|
|
63
|
+
isInvertPosition
|
|
64
|
+
}: DrawerAnimateParams, callback?: () => void) {
|
|
65
|
+
const animations = [
|
|
66
|
+
Animated.timing(animateStates.position, {
|
|
67
|
+
toValue:
|
|
68
|
+
isHorizontal
|
|
69
|
+
? (isInvertPosition ? -width : width)
|
|
70
|
+
: (isInvertPosition ? -height : height),
|
|
71
|
+
duration: 200,
|
|
72
|
+
useNativeDriver: false
|
|
73
|
+
})
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
if (hasOverlay) {
|
|
77
|
+
animations.push(
|
|
78
|
+
Animated.timing(animateStates.opacity, {
|
|
79
|
+
toValue: 0,
|
|
80
|
+
duration: 200,
|
|
81
|
+
useNativeDriver: false
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Animated.parallel(animations).start(callback)
|
|
87
|
+
}
|
|
88
|
+
}
|
package/index.cssx.styl
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
.overlayCase
|
|
2
|
+
width 100%
|
|
3
|
+
height 100%
|
|
4
|
+
position absolute
|
|
5
|
+
|
|
6
|
+
.overlay
|
|
7
|
+
position absolute
|
|
8
|
+
width 100%
|
|
9
|
+
height 100%
|
|
10
|
+
background-color rgba(0, 0, 0, .5)
|
|
11
|
+
|
|
12
|
+
.case
|
|
13
|
+
height 100%
|
|
14
|
+
width 100%
|
|
15
|
+
|
|
16
|
+
.area
|
|
17
|
+
height 100%
|
|
18
|
+
width 100%
|
|
19
|
+
position absolute
|
|
20
|
+
|
|
21
|
+
.contentDefault
|
|
22
|
+
background-color #ffffff
|
|
23
|
+
shadow(2)
|
|
24
|
+
|
|
25
|
+
.fullVertical
|
|
26
|
+
height 80%
|
|
27
|
+
width 100%
|
|
28
|
+
|
|
29
|
+
.fullHorizontal
|
|
30
|
+
width 80%
|
|
31
|
+
height 100%
|
|
32
|
+
|
|
33
|
+
.contentBottom
|
|
34
|
+
height 60%
|
|
35
|
+
border-radius 2u 2u 0 0
|
|
36
|
+
|
|
37
|
+
.responder
|
|
38
|
+
position absolute
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
|
|
3
|
+
|
|
4
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
5
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
6
|
+
import './index.cssx.styl';
|
|
7
|
+
export { default as useDrawerDismiss } from './useDrawerDismiss';
|
|
8
|
+
export declare const _PropsJsonSchema: {};
|
|
9
|
+
export interface DrawerProps {
|
|
10
|
+
/** Custom styles applied to the drawer container */
|
|
11
|
+
style?: StyleProp<ViewStyle>;
|
|
12
|
+
/** Root component wrapping the drawer area @default SafeAreaView */
|
|
13
|
+
AreaComponent?: ComponentType<any>;
|
|
14
|
+
/** Component used as the drawer content wrapper @default View */
|
|
15
|
+
ContentComponent?: ComponentType<any>;
|
|
16
|
+
/** Custom styles applied to the swipe responder zone */
|
|
17
|
+
swipeStyle?: StyleProp<ViewStyle>;
|
|
18
|
+
/** Content rendered inside the drawer */
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
/** Controlled visibility flag @default false */
|
|
21
|
+
visible?: boolean;
|
|
22
|
+
/** Drawer position relative to the screen @default 'left' */
|
|
23
|
+
position?: 'left' | 'right' | 'top' | 'bottom';
|
|
24
|
+
/** Enable swipe-to-close interaction @default true */
|
|
25
|
+
isSwipe?: boolean;
|
|
26
|
+
/** Render a dimming overlay behind the drawer @default true */
|
|
27
|
+
hasOverlay?: boolean;
|
|
28
|
+
/** Show swipe responder zone @default true */
|
|
29
|
+
showResponder?: boolean;
|
|
30
|
+
/** Called after drawer is dismissed (after hide animation completes) */
|
|
31
|
+
onDismiss: () => void;
|
|
32
|
+
/** Called after drawer becomes visible (after show animation completes) */
|
|
33
|
+
onRequestOpen?: () => void;
|
|
34
|
+
}
|
|
35
|
+
declare const _default: ComponentType<DrawerProps>;
|
|
36
|
+
export default _default;
|
package/index.tsx
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, type ComponentType, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
SafeAreaView,
|
|
4
|
+
Animated,
|
|
5
|
+
View,
|
|
6
|
+
TouchableWithoutFeedback,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
type ViewStyle
|
|
10
|
+
} from 'react-native'
|
|
11
|
+
import { pug, observer } from 'startupjs'
|
|
12
|
+
import { themed } from '@startupjs-ui/core'
|
|
13
|
+
import Portal from '@startupjs-ui/portal'
|
|
14
|
+
import Swipe from './Swipe'
|
|
15
|
+
import animate, { type DrawerAnimateStates } from './animate'
|
|
16
|
+
import './index.cssx.styl'
|
|
17
|
+
|
|
18
|
+
export { default as useDrawerDismiss } from './useDrawerDismiss'
|
|
19
|
+
|
|
20
|
+
const POSITION_STYLES = {
|
|
21
|
+
left: { alignItems: 'flex-start' },
|
|
22
|
+
right: { alignItems: 'flex-end' },
|
|
23
|
+
top: { justifyContent: 'flex-start' },
|
|
24
|
+
bottom: { justifyContent: 'flex-end' }
|
|
25
|
+
} satisfies Record<string, ViewStyle>
|
|
26
|
+
|
|
27
|
+
const POSITION_NAMES = {
|
|
28
|
+
left: 'translateX',
|
|
29
|
+
right: 'translateX',
|
|
30
|
+
top: 'translateY',
|
|
31
|
+
bottom: 'translateY'
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
export const _PropsJsonSchema = {/* DrawerProps */}
|
|
35
|
+
|
|
36
|
+
export interface DrawerProps {
|
|
37
|
+
/** Custom styles applied to the drawer container */
|
|
38
|
+
style?: StyleProp<ViewStyle>
|
|
39
|
+
/** Root component wrapping the drawer area @default SafeAreaView */
|
|
40
|
+
AreaComponent?: ComponentType<any>
|
|
41
|
+
/** Component used as the drawer content wrapper @default View */
|
|
42
|
+
ContentComponent?: ComponentType<any>
|
|
43
|
+
/** Custom styles applied to the swipe responder zone */
|
|
44
|
+
swipeStyle?: StyleProp<ViewStyle>
|
|
45
|
+
/** Content rendered inside the drawer */
|
|
46
|
+
children?: ReactNode
|
|
47
|
+
/** Controlled visibility flag @default false */
|
|
48
|
+
visible?: boolean
|
|
49
|
+
/** Drawer position relative to the screen @default 'left' */
|
|
50
|
+
position?: 'left' | 'right' | 'top' | 'bottom'
|
|
51
|
+
/** Enable swipe-to-close interaction @default true */
|
|
52
|
+
isSwipe?: boolean
|
|
53
|
+
/** Render a dimming overlay behind the drawer @default true */
|
|
54
|
+
hasOverlay?: boolean
|
|
55
|
+
/** Show swipe responder zone @default true */
|
|
56
|
+
showResponder?: boolean
|
|
57
|
+
/** Called after drawer is dismissed (after hide animation completes) */
|
|
58
|
+
onDismiss: () => void
|
|
59
|
+
/** Called after drawer becomes visible (after show animation completes) */
|
|
60
|
+
onRequestOpen?: () => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// TODO: more test for work responder with ScrollView
|
|
64
|
+
// https://material-ui.com/ru/components/drawers/#%D1%81%D1%82%D0%BE%D0%B9%D0%BA%D0%B0%D1%8F-%D0%BF%D0%B0%D0%BD%D0%B5%D0%BB%D1%8C
|
|
65
|
+
function Drawer ({
|
|
66
|
+
style,
|
|
67
|
+
AreaComponent = SafeAreaView,
|
|
68
|
+
ContentComponent = View,
|
|
69
|
+
swipeStyle,
|
|
70
|
+
children,
|
|
71
|
+
visible = false,
|
|
72
|
+
position = 'left',
|
|
73
|
+
isSwipe = true,
|
|
74
|
+
hasOverlay = true,
|
|
75
|
+
showResponder = true,
|
|
76
|
+
onDismiss,
|
|
77
|
+
onRequestOpen
|
|
78
|
+
}: DrawerProps): ReactNode {
|
|
79
|
+
const isHorizontal = position === 'left' || position === 'right'
|
|
80
|
+
const isInvertPosition = position === 'left' || position === 'top'
|
|
81
|
+
|
|
82
|
+
const refContent = useRef<any>(null)
|
|
83
|
+
const [isShow, setIsShow] = useState(false)
|
|
84
|
+
const [contentSize, setContentSize] = useState<{ width?: number, height?: number }>({})
|
|
85
|
+
|
|
86
|
+
const [animateStates] = useState<DrawerAnimateStates>({
|
|
87
|
+
opacity: new Animated.Value(visible ? 1 : 0),
|
|
88
|
+
position: new Animated.Value(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// -main
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (visible) {
|
|
94
|
+
setIsShow(true)
|
|
95
|
+
setTimeout(() => { void runShow() }, 0)
|
|
96
|
+
} else {
|
|
97
|
+
runHide()
|
|
98
|
+
}
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, [visible])
|
|
101
|
+
// -
|
|
102
|
+
|
|
103
|
+
async function waitForDrawerRef () {
|
|
104
|
+
let attempts = 0
|
|
105
|
+
|
|
106
|
+
while (attempts < 5) {
|
|
107
|
+
if (refContent.current) return true
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
109
|
+
attempts++
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return !!refContent.current
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runShow () {
|
|
116
|
+
await waitForDrawerRef()
|
|
117
|
+
|
|
118
|
+
getValidNode(refContent.current).measure((x: number, y: number, width: number, height: number) => {
|
|
119
|
+
const isInit = !contentSize.width
|
|
120
|
+
setContentSize({ width, height })
|
|
121
|
+
|
|
122
|
+
animate.show({
|
|
123
|
+
width,
|
|
124
|
+
height,
|
|
125
|
+
animateStates,
|
|
126
|
+
hasOverlay,
|
|
127
|
+
isHorizontal,
|
|
128
|
+
isInvertPosition,
|
|
129
|
+
isInit
|
|
130
|
+
}, () => {
|
|
131
|
+
onRequestOpen && onRequestOpen()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runHide () {
|
|
137
|
+
if (!refContent.current) return
|
|
138
|
+
|
|
139
|
+
getValidNode(refContent.current).measure((x: number, y: number, width: number, height: number) => {
|
|
140
|
+
animate.hide({
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
animateStates,
|
|
144
|
+
hasOverlay,
|
|
145
|
+
isHorizontal,
|
|
146
|
+
isInvertPosition
|
|
147
|
+
}, () => {
|
|
148
|
+
setContentSize({})
|
|
149
|
+
setIsShow(false)
|
|
150
|
+
onDismiss()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const _styleCase = StyleSheet.flatten([
|
|
156
|
+
POSITION_STYLES[position],
|
|
157
|
+
{ opacity: isShow ? 1 : 0 }
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
const _styleContent = StyleSheet.flatten([
|
|
161
|
+
{ transform: [{ [POSITION_NAMES[position]]: animateStates.position }] },
|
|
162
|
+
style
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
return pug`
|
|
166
|
+
if isShow
|
|
167
|
+
Portal
|
|
168
|
+
AreaComponent.area
|
|
169
|
+
ContentComponent.case(style=_styleCase)
|
|
170
|
+
if hasOverlay
|
|
171
|
+
TouchableWithoutFeedback.overlayCase(onPress=onDismiss)
|
|
172
|
+
Animated.View.overlay(style={ opacity: animateStates.opacity })
|
|
173
|
+
|
|
174
|
+
Animated.View(
|
|
175
|
+
ref=refContent
|
|
176
|
+
styleName={
|
|
177
|
+
contentDefault: isShow,
|
|
178
|
+
contentBottom: isShow && position === 'bottom',
|
|
179
|
+
fullHorizontal: isShow && isHorizontal,
|
|
180
|
+
fullVertical: isShow && !isHorizontal
|
|
181
|
+
}
|
|
182
|
+
style=_styleContent
|
|
183
|
+
)
|
|
184
|
+
if showResponder
|
|
185
|
+
Swipe(
|
|
186
|
+
position=position
|
|
187
|
+
contentSize=contentSize
|
|
188
|
+
swipeStyle=swipeStyle
|
|
189
|
+
isHorizontal=isHorizontal
|
|
190
|
+
isSwipe=isSwipe
|
|
191
|
+
isInvertPosition=isInvertPosition
|
|
192
|
+
animateStates=animateStates
|
|
193
|
+
runHide=runHide
|
|
194
|
+
runShow=runShow
|
|
195
|
+
)
|
|
196
|
+
= children
|
|
197
|
+
`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getValidNode (current: any) {
|
|
201
|
+
return current?.measure
|
|
202
|
+
? current
|
|
203
|
+
: current?.getNode?.()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default observer(themed('Drawer', Drawer))
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startupjs-ui/drawer",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"main": "index.tsx",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@startupjs-ui/core": "^0.1.3",
|
|
12
|
+
"@startupjs-ui/portal": "^0.1.3"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"react": "*",
|
|
16
|
+
"react-native": "*",
|
|
17
|
+
"startupjs": "*"
|
|
18
|
+
},
|
|
19
|
+
"gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface DrawerDismissState {
|
|
4
|
+
tag: string
|
|
5
|
+
data: any
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DrawerDismissCallbacks {
|
|
9
|
+
default: (data?: any) => void
|
|
10
|
+
[tag: string]: ((data?: any) => void) | any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function useDrawerDismiss (
|
|
14
|
+
callbacks: DrawerDismissCallbacks
|
|
15
|
+
): [
|
|
16
|
+
onDismiss: () => void,
|
|
17
|
+
setOnDismiss: (tag: string, data?: any) => void
|
|
18
|
+
] {
|
|
19
|
+
const [state, setState] = useState<DrawerDismissState>({ tag: 'default', data: null })
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (state.tag !== 'default') {
|
|
23
|
+
callbacks.default()
|
|
24
|
+
}
|
|
25
|
+
// preserve legacy behavior (no dependency array)
|
|
26
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
return [
|
|
30
|
+
() => {
|
|
31
|
+
callbacks[state.tag]?.(state.data)
|
|
32
|
+
setState({ tag: 'default', data: null })
|
|
33
|
+
},
|
|
34
|
+
(tag, data) => { setState({ tag, data }) }
|
|
35
|
+
]
|
|
36
|
+
}
|