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