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