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={_(msg`Option ${index + 1} of ${numItems}`)}>
297 {item.icon && (
298 <FontAwesomeIcon
299 style={styles.icon}
300 icon={item.icon}
301 color={pal.text.color as string}
302 />
303 )}
304 <Text style={[styles.label, pal.text]}>{item.label}</Text>
305 </TouchableOpacity>
306 )
307 } else if (isSep(item)) {
308 return (
309 <View key={index} style={[styles.separator, separatorColor]} />
310 )
311 } else if (isHeading(item)) {
312 return (
313 <View style={[styles.heading, pal.border]} key={index}>
314 <Text style={[pal.text, styles.headingLabel]}>
315 {item.label}
316 </Text>
317 </View>
318 )
319 }
320 return null
321 })}
322 </Animated.View>
323 </FullWindowOverlay>
324 )
325}
326
327function isSep(item: DropdownItem): item is DropdownItemSeparator {
328 return 'sep' in item && item.sep
329}
330function isHeading(item: DropdownItem): item is DropdownItemHeading {
331 return 'heading' in item && item.heading
332}
333function isBtn(item: DropdownItem): item is DropdownItemButton {
334 return !isSep(item) && !isHeading(item)
335}
336
337const styles = StyleSheet.create({
338 bg: {
339 position: 'absolute',
340 left: 0,
341 width: '100%',
342 backgroundColor: 'rgba(0, 0, 0, 0.1)',
343 },
344 menu: {
345 position: 'absolute',
346 backgroundColor: '#fff',
347 borderRadius: 14,
348 paddingVertical: 6,
349 },
350 menuItem: {
351 flexDirection: 'row',
352 alignItems: 'center',
353 paddingVertical: 10,
354 paddingLeft: 15,
355 paddingRight: 40,
356 },
357 menuItemBorder: {
358 borderTopWidth: 1,
359 borderTopColor: colors.gray1,
360 marginTop: 4,
361 paddingTop: 12,
362 },
363 icon: {
364 marginLeft: 2,
365 marginRight: 8,
366 flexShrink: 0,
367 },
368 label: {
369 fontSize: 18,
370 flexShrink: 1,
371 flexGrow: 1,
372 },
373 separator: {
374 borderTopWidth: 1,
375 marginVertical: 8,
376 },
377 heading: {
378 flexDirection: 'row',
379 justifyContent: 'center',
380 paddingVertical: 10,
381 paddingLeft: 15,
382 paddingRight: 20,
383 borderBottomWidth: 1,
384 marginBottom: 6,
385 },
386 headingLabel: {
387 fontSize: 18,
388 fontWeight: '600',
389 },
390})