mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {cloneElement, Fragment, isValidElement, useMemo} from 'react'
2import {
3 Pressable,
4 type StyleProp,
5 type TextStyle,
6 View,
7 type ViewStyle,
8} from 'react-native'
9import {msg, Trans} from '@lingui/macro'
10import {useLingui} from '@lingui/react'
11import flattenReactChildren from 'react-keyed-flatten-children'
12
13import {isAndroid, isIOS, isNative} from '#/platform/detection'
14import {atoms as a, useTheme} from '#/alf'
15import {Button, ButtonText} from '#/components/Button'
16import * as Dialog from '#/components/Dialog'
17import {useInteractionState} from '#/components/hooks/useInteractionState'
18import {
19 Context,
20 ItemContext,
21 useMenuContext,
22 useMenuItemContext,
23} from '#/components/Menu/context'
24import {
25 type ContextType,
26 type GroupProps,
27 type ItemIconProps,
28 type ItemProps,
29 type ItemTextProps,
30 type TriggerProps,
31} from '#/components/Menu/types'
32import {Text} from '#/components/Typography'
33
34export {
35 type DialogControlProps as MenuControlProps,
36 useDialogControl as useMenuControl,
37} from '#/components/Dialog'
38
39export {useMenuContext}
40
41export function Root({
42 children,
43 control,
44}: React.PropsWithChildren<{
45 control?: Dialog.DialogControlProps
46}>) {
47 const defaultControl = Dialog.useDialogControl()
48 const context = useMemo<ContextType>(
49 () => ({
50 control: control || defaultControl,
51 }),
52 [control, defaultControl],
53 )
54
55 return <Context.Provider value={context}>{children}</Context.Provider>
56}
57
58export function Trigger({
59 children,
60 label,
61 role = 'button',
62 hint,
63}: TriggerProps) {
64 const context = useMenuContext()
65 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
66 const {
67 state: pressed,
68 onIn: onPressIn,
69 onOut: onPressOut,
70 } = useInteractionState()
71
72 return children({
73 isNative: true,
74 control: context.control,
75 state: {
76 hovered: false,
77 focused,
78 pressed,
79 },
80 props: {
81 ref: null,
82 onPress: context.control.open,
83 onFocus,
84 onBlur,
85 onPressIn,
86 onPressOut,
87 accessibilityHint: hint,
88 accessibilityLabel: label,
89 accessibilityRole: role,
90 },
91 })
92}
93
94export function Outer({
95 children,
96 showCancel,
97}: React.PropsWithChildren<{
98 showCancel?: boolean
99 style?: StyleProp<ViewStyle>
100}>) {
101 const context = useMenuContext()
102 const {_} = useLingui()
103
104 return (
105 <Dialog.Outer
106 control={context.control}
107 nativeOptions={{preventExpansion: true}}>
108 <Dialog.Handle />
109 {/* Re-wrap with context since Dialogs are portal-ed to root */}
110 <Context.Provider value={context}>
111 <Dialog.ScrollableInner label={_(msg`Menu`)}>
112 <View style={[a.gap_lg]}>
113 {children}
114 {isNative && showCancel && <Cancel />}
115 </View>
116 </Dialog.ScrollableInner>
117 </Context.Provider>
118 </Dialog.Outer>
119 )
120}
121
122export function Item({children, label, style, onPress, ...rest}: ItemProps) {
123 const t = useTheme()
124 const context = useMenuContext()
125 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
126 const {
127 state: pressed,
128 onIn: onPressIn,
129 onOut: onPressOut,
130 } = useInteractionState()
131
132 return (
133 <Pressable
134 {...rest}
135 accessibilityHint=""
136 accessibilityLabel={label}
137 onFocus={onFocus}
138 onBlur={onBlur}
139 onPress={async e => {
140 if (isAndroid) {
141 /**
142 * Below fix for iOS doesn't work for Android, this does.
143 */
144 onPress?.(e)
145 context.control.close()
146 } else if (isIOS) {
147 /**
148 * Fixes a subtle bug on iOS
149 * {@link https://github.com/bluesky-social/social-app/pull/5849/files#diff-de516ef5e7bd9840cd639213301df38cf03acfcad5bda85a1d63efd249ba79deL124-L127}
150 */
151 context.control.close(() => {
152 onPress?.(e)
153 })
154 }
155 }}
156 onPressIn={e => {
157 onPressIn()
158 rest.onPressIn?.(e)
159 }}
160 onPressOut={e => {
161 onPressOut()
162 rest.onPressOut?.(e)
163 }}
164 style={[
165 a.flex_row,
166 a.align_center,
167 a.gap_sm,
168 a.px_md,
169 a.rounded_md,
170 a.border,
171 t.atoms.bg_contrast_25,
172 t.atoms.border_contrast_low,
173 {minHeight: 44, paddingVertical: 10},
174 style,
175 (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
176 ]}>
177 <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
178 {children}
179 </ItemContext.Provider>
180 </Pressable>
181 )
182}
183
184export function ItemText({children, style}: ItemTextProps) {
185 const t = useTheme()
186 const {disabled} = useMenuItemContext()
187 return (
188 <Text
189 numberOfLines={1}
190 ellipsizeMode="middle"
191 style={[
192 a.flex_1,
193 a.text_md,
194 a.font_bold,
195 t.atoms.text_contrast_high,
196 {paddingTop: 3},
197 style,
198 disabled && t.atoms.text_contrast_low,
199 ]}>
200 {children}
201 </Text>
202 )
203}
204
205export function ItemIcon({icon: Comp}: ItemIconProps) {
206 const t = useTheme()
207 const {disabled} = useMenuItemContext()
208 return (
209 <Comp
210 size="lg"
211 fill={
212 disabled
213 ? t.atoms.text_contrast_low.color
214 : t.atoms.text_contrast_medium.color
215 }
216 />
217 )
218}
219
220export function ItemRadio({selected}: {selected: boolean}) {
221 const t = useTheme()
222 return (
223 <View
224 style={[
225 a.justify_center,
226 a.align_center,
227 a.rounded_full,
228 t.atoms.border_contrast_high,
229 {
230 borderWidth: 1,
231 height: 20,
232 width: 20,
233 },
234 ]}>
235 {selected ? (
236 <View
237 style={[
238 a.absolute,
239 a.rounded_full,
240 {height: 14, width: 14},
241 selected
242 ? {
243 backgroundColor: t.palette.primary_500,
244 }
245 : {},
246 ]}
247 />
248 ) : null}
249 </View>
250 )
251}
252
253/**
254 * NATIVE ONLY - for adding non-pressable items to the menu
255 *
256 * @platform ios, android
257 */
258export function ContainerItem({
259 children,
260 style,
261}: {
262 children: React.ReactNode
263 style?: StyleProp<ViewStyle>
264}) {
265 const t = useTheme()
266 return (
267 <View
268 style={[
269 a.flex_row,
270 a.align_center,
271 a.gap_sm,
272 a.px_md,
273 a.rounded_md,
274 a.border,
275 t.atoms.bg_contrast_25,
276 t.atoms.border_contrast_low,
277 {paddingVertical: 10},
278 style,
279 ]}>
280 {children}
281 </View>
282 )
283}
284
285export function LabelText({
286 children,
287 style,
288}: {
289 children: React.ReactNode
290 style?: StyleProp<TextStyle>
291}) {
292 const t = useTheme()
293 return (
294 <Text
295 style={[
296 a.font_bold,
297 t.atoms.text_contrast_medium,
298 {marginBottom: -8},
299 style,
300 ]}>
301 {children}
302 </Text>
303 )
304}
305
306export function Group({children, style}: GroupProps) {
307 const t = useTheme()
308 return (
309 <View
310 style={[
311 a.rounded_md,
312 a.overflow_hidden,
313 a.border,
314 t.atoms.border_contrast_low,
315 style,
316 ]}>
317 {flattenReactChildren(children).map((child, i) => {
318 return isValidElement(child) &&
319 (child.type === Item || child.type === ContainerItem) ? (
320 <Fragment key={i}>
321 {i > 0 ? (
322 <View style={[a.border_b, t.atoms.border_contrast_low]} />
323 ) : null}
324 {cloneElement(child, {
325 // @ts-expect-error cloneElement is not aware of the types
326 style: {
327 borderRadius: 0,
328 borderWidth: 0,
329 },
330 })}
331 </Fragment>
332 ) : null
333 })}
334 </View>
335 )
336}
337
338function Cancel() {
339 const {_} = useLingui()
340 const context = useMenuContext()
341
342 return (
343 <Button
344 label={_(msg`Close this dialog`)}
345 size="small"
346 variant="ghost"
347 color="secondary"
348 onPress={() => context.control.close()}>
349 <ButtonText>
350 <Trans>Cancel</Trans>
351 </ButtonText>
352 </Button>
353 )
354}
355
356export function Divider() {
357 return null
358}