mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {PropsWithChildren, useMemo, useRef} from 'react'
2import {
3 Dimensions,
4 GestureResponderEvent,
5 StyleProp,
6 StyleSheet,
7 TouchableOpacity,
8 TouchableWithoutFeedback,
9 useWindowDimensions,
10 View,
11 ViewStyle,
12} from 'react-native'
13import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated'
14import RootSiblings from 'react-native-root-siblings'
15import {IconProp} from '@fortawesome/fontawesome-svg-core'
16import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
17import {msg} from '@lingui/macro'
18import {useLingui} from '@lingui/react'
19
20import {HITSLOP_10} from 'lib/constants'
21import {usePalette} from 'lib/hooks/usePalette'
22import {colors} from 'lib/styles'
23import {useTheme} from 'lib/ThemeContext'
24import {isWeb} from 'platform/detection'
25import {native} from '#/alf'
26import {FullWindowOverlay} from '#/components/FullWindowOverlay'
27import {Text} from '../text/Text'
28import {Button, ButtonType} from './Button'
29
30const ESTIMATED_BTN_HEIGHT = 50
31const ESTIMATED_SEP_HEIGHT = 16
32const ESTIMATED_HEADING_HEIGHT = 60
33
34export interface DropdownItemButton {
35 testID?: string
36 icon?: IconProp
37 label: string
38 onPress: () => void
39}
40export interface DropdownItemSeparator {
41 sep: true
42}
43export interface DropdownItemHeading {
44 heading: true
45 label: string
46}
47export type DropdownItem =
48 | DropdownItemButton
49 | DropdownItemSeparator
50 | DropdownItemHeading
51type MaybeDropdownItem = DropdownItem | false | undefined
52
53export type DropdownButtonType = ButtonType | 'bare'
54
55interface DropdownButtonProps {
56 testID?: string
57 type?: DropdownButtonType
58 style?: StyleProp<ViewStyle>
59 items: MaybeDropdownItem[]
60 label?: string
61 menuWidth?: number
62 children?: React.ReactNode
63 openToRight?: boolean
64 openUpwards?: boolean
65 rightOffset?: number
66 bottomOffset?: number
67 accessibilityLabel?: string
68 accessibilityHint?: string
69}
70
71export function DropdownButton({
72 testID,
73 type = 'bare',
74 style,
75 items,
76 label,
77 menuWidth,
78 children,
79 openToRight = false,
80 openUpwards = false,
81 rightOffset = 0,
82 bottomOffset = 0,
83 accessibilityLabel,
84}: PropsWithChildren<DropdownButtonProps>) {
85 const {_} = useLingui()
86
87 const ref1 = useRef<TouchableOpacity>(null)
88 const ref2 = useRef<View>(null)
89
90 const onPress = (e: GestureResponderEvent) => {
91 const ref = ref1.current || ref2.current
92 const {height: winHeight} = Dimensions.get('window')
93 const pressY = e.nativeEvent.pageY
94 ref?.measure(
95 (
96 _x: number,
97 _y: number,
98 width: number,
99 _height: number,
100 pageX: number,
101 pageY: number,
102 ) => {
103 if (!menuWidth) {
104 menuWidth = 200
105 }
106 let estimatedMenuHeight = 0
107 for (const item of items) {
108 if (item && isSep(item)) {
109 estimatedMenuHeight += ESTIMATED_SEP_HEIGHT
110 } else if (item && isBtn(item)) {
111 estimatedMenuHeight += ESTIMATED_BTN_HEIGHT
112 } else if (item && isHeading(item)) {
113 estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT
114 }
115 }
116 const newX = openToRight
117 ? pageX + width + rightOffset
118 : pageX + width - menuWidth
119
120 // Add a bit of additional room
121 let newY = pressY + bottomOffset + 20
122 if (openUpwards || newY + estimatedMenuHeight > winHeight) {
123 newY -= estimatedMenuHeight
124 }
125 createDropdownMenu(
126 newX,
127 newY,
128 pageY,
129 menuWidth,
130 items.filter(v => !!v) as DropdownItem[],
131 openUpwards,
132 )
133 },
134 )
135 }
136
137 const numItems = useMemo(
138 () =>
139 items.filter(item => {
140 if (item === undefined || item === false) {
141 return false
142 }
143
144 return isBtn(item)
145 }).length,
146 [items],
147 )
148
149 if (type === 'bare') {
150 return (
151 <TouchableOpacity
152 testID={testID}
153 style={style}
154 onPress={onPress}
155 hitSlop={HITSLOP_10}
156 ref={ref1}
157 accessibilityRole="button"
158 accessibilityLabel={
159 accessibilityLabel || _(msg`Opens ${numItems} options`)
160 }
161 accessibilityHint="">
162 {children}
163 </TouchableOpacity>
164 )
165 }
166 return (
167 <View ref={ref2}>
168 <Button
169 type={type}
170 testID={testID}
171 onPress={onPress}
172 style={style}
173 label={label}>
174 {children}
175 </Button>
176 </View>
177 )
178}
179
180function createDropdownMenu(
181 x: number,
182 y: number,
183 pageY: number,
184 width: number,
185 items: DropdownItem[],
186 opensUpwards = false,
187): RootSiblings {
188 const onPressItem = (index: number) => {
189 sibling.destroy()
190 const item = items[index]
191 if (isBtn(item)) {
192 item.onPress()
193 }
194 }
195 const onOuterPress = () => sibling.destroy()
196 const sibling = new RootSiblings(
197 (
198 <DropdownItems
199 onOuterPress={onOuterPress}
200 x={x}
201 y={y}
202 pageY={pageY}
203 width={width}
204 items={items}
205 onPressItem={onPressItem}
206 openUpwards={opensUpwards}
207 />
208 ),
209 )
210 return sibling
211}
212
213type DropDownItemProps = {
214 onOuterPress: () => void
215 x: number
216 y: number
217 pageY: number
218 width: number
219 items: DropdownItem[]
220 onPressItem: (index: number) => void
221 openUpwards: boolean
222}
223
224const DropdownItems = ({
225 onOuterPress,
226 x,
227 y,
228 pageY,
229 width,
230 items,
231 onPressItem,
232 openUpwards,
233}: DropDownItemProps) => {
234 const pal = usePalette('default')
235 const theme = useTheme()
236 const {_} = useLingui()
237 const {height: screenHeight} = useWindowDimensions()
238 const dropDownBackgroundColor =
239 theme.colorScheme === 'dark' ? pal.btn : pal.view
240 const separatorColor =
241 theme.colorScheme === 'dark' ? pal.borderDark : pal.border
242
243 const numItems = items.filter(isBtn).length
244
245 // TODO: Refactor dropdown components to:
246 // - (On web, if not handled by React Native) use semantic <select />
247 // and <option /> elements for keyboard navigation out of the box
248 // - (On mobile) be buttons by default, accept `label` and `nativeID`
249 // props, and always have an explicit label
250 return (
251 <FullWindowOverlay>
252 {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
253 <TouchableWithoutFeedback
254 onPress={onOuterPress}
255 accessibilityLabel={_(msg`Toggle dropdown`)}
256 accessibilityHint="">
257 <Animated.View
258 entering={FadeIn}
259 style={[
260 styles.bg,
261 // On web we need to adjust the top and bottom relative to the scroll position
262 isWeb
263 ? {
264 top: -pageY,
265 bottom: pageY - screenHeight,
266 }
267 : {
268 top: 0,
269 bottom: 0,
270 },
271 ]}
272 />
273 </TouchableWithoutFeedback>
274 <Animated.View
275 entering={native(
276 openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000),
277 )}
278 style={[
279 styles.menu,
280 {left: x, top: y, width},
281 dropDownBackgroundColor,
282 ]}>
283 {items.map((item, index) => {
284 if (isBtn(item)) {
285 return (
286 <TouchableOpacity
287 testID={item.testID}
288 key={index}
289 style={[styles.menuItem]}
290 onPress={() => onPressItem(index)}
291 accessibilityRole="button"
292 accessibilityLabel={item.label}
293 accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}>
294 {item.icon && (
295 <FontAwesomeIcon
296 style={styles.icon}
297 icon={item.icon}
298 color={pal.text.color as string}
299 />
300 )}
301 <Text style={[styles.label, pal.text]}>{item.label}</Text>
302 </TouchableOpacity>
303 )
304 } else if (isSep(item)) {
305 return (
306 <View key={index} style={[styles.separator, separatorColor]} />
307 )
308 } else if (isHeading(item)) {
309 return (
310 <View style={[styles.heading, pal.border]} key={index}>
311 <Text style={[pal.text, styles.headingLabel]}>
312 {item.label}
313 </Text>
314 </View>
315 )
316 }
317 return null
318 })}
319 </Animated.View>
320 </FullWindowOverlay>
321 )
322}
323
324function isSep(item: DropdownItem): item is DropdownItemSeparator {
325 return 'sep' in item && item.sep
326}
327function isHeading(item: DropdownItem): item is DropdownItemHeading {
328 return 'heading' in item && item.heading
329}
330function isBtn(item: DropdownItem): item is DropdownItemButton {
331 return !isSep(item) && !isHeading(item)
332}
333
334const styles = StyleSheet.create({
335 bg: {
336 position: 'absolute',
337 left: 0,
338 width: '100%',
339 backgroundColor: 'rgba(0, 0, 0, 0.1)',
340 },
341 menu: {
342 position: 'absolute',
343 backgroundColor: '#fff',
344 borderRadius: 14,
345 paddingVertical: 6,
346 },
347 menuItem: {
348 flexDirection: 'row',
349 alignItems: 'center',
350 paddingVertical: 10,
351 paddingLeft: 15,
352 paddingRight: 40,
353 },
354 menuItemBorder: {
355 borderTopWidth: 1,
356 borderTopColor: colors.gray1,
357 marginTop: 4,
358 paddingTop: 12,
359 },
360 icon: {
361 marginLeft: 2,
362 marginRight: 8,
363 flexShrink: 0,
364 },
365 label: {
366 fontSize: 18,
367 flexShrink: 1,
368 flexGrow: 1,
369 },
370 separator: {
371 borderTopWidth: 1,
372 marginVertical: 8,
373 },
374 heading: {
375 flexDirection: 'row',
376 justifyContent: 'center',
377 paddingVertical: 10,
378 paddingLeft: 15,
379 paddingRight: 20,
380 borderBottomWidth: 1,
381 marginBottom: 6,
382 },
383 headingLabel: {
384 fontSize: 18,
385 fontWeight: '500',
386 },
387})