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