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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
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'
33import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
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 IS_NATIVE: 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 {IS_NATIVE && 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 (IS_ANDROID) {
142 /**
143 * Below fix for iOS doesn't work for Android, this does.
144 */
145 onPress?.(e)
146 context.control.close()
147 } else if (IS_IOS) {
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.overflow_hidden,
172 a.border,
173 t.atoms.bg_contrast_25,
174 t.atoms.border_contrast_low,
175 {minHeight: 44, paddingVertical: 10},
176 style,
177 (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
178 ]}>
179 <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
180 {children}
181 </ItemContext.Provider>
182 </Pressable>
183 )
184}
185
186export function ItemText({children, style}: ItemTextProps) {
187 const t = useTheme()
188 const {disabled} = useMenuItemContext()
189 return (
190 <Text
191 numberOfLines={1}
192 ellipsizeMode="middle"
193 style={[
194 a.flex_1,
195 a.text_md,
196 a.font_semi_bold,
197 t.atoms.text_contrast_high,
198 style,
199 disabled && t.atoms.text_contrast_low,
200 ]}>
201 {children}
202 </Text>
203 )
204}
205
206export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
207 const t = useTheme()
208 const {disabled} = useMenuItemContext()
209 return (
210 <Comp
211 size="lg"
212 fill={
213 fill
214 ? fill({disabled})
215 : disabled
216 ? t.atoms.text_contrast_low.color
217 : t.atoms.text_contrast_medium.color
218 }
219 />
220 )
221}
222
223export function ItemRadio({selected}: {selected: boolean}) {
224 const t = useTheme()
225 const enableSquareButtons = useEnableSquareButtons()
226 return (
227 <View
228 style={[
229 a.justify_center,
230 a.align_center,
231 enableSquareButtons ? a.rounded_sm : a.rounded_full,
232 t.atoms.border_contrast_high,
233 {
234 borderWidth: 1,
235 height: 20,
236 width: 20,
237 },
238 ]}>
239 {selected ? (
240 <View
241 style={[
242 a.absolute,
243 enableSquareButtons ? a.rounded_sm : a.rounded_full,
244 {height: 14, width: 14},
245 selected
246 ? {
247 backgroundColor: t.palette.primary_500,
248 }
249 : {},
250 ]}
251 />
252 ) : null}
253 </View>
254 )
255}
256
257/**
258 * NATIVE ONLY - for adding non-pressable items to the menu
259 *
260 * @platform ios, android
261 */
262export function ContainerItem({
263 children,
264 style,
265}: {
266 children: React.ReactNode
267 style?: StyleProp<ViewStyle>
268}) {
269 const t = useTheme()
270 return (
271 <View
272 style={[
273 a.flex_row,
274 a.align_center,
275 a.gap_sm,
276 a.px_md,
277 a.rounded_md,
278 a.border,
279 t.atoms.bg_contrast_25,
280 t.atoms.border_contrast_low,
281 {paddingVertical: 10},
282 style,
283 ]}>
284 {children}
285 </View>
286 )
287}
288
289export function LabelText({
290 children,
291 style,
292}: {
293 children: React.ReactNode
294 style?: StyleProp<TextStyle>
295}) {
296 const t = useTheme()
297 return (
298 <Text
299 style={[
300 a.font_semi_bold,
301 t.atoms.text_contrast_medium,
302 {marginBottom: -8},
303 style,
304 ]}>
305 {children}
306 </Text>
307 )
308}
309
310export function Group({children, style}: GroupProps) {
311 const t = useTheme()
312 return (
313 <View
314 style={[
315 a.rounded_md,
316 a.overflow_hidden,
317 a.border,
318 t.atoms.border_contrast_low,
319 style,
320 ]}>
321 {flattenReactChildren(children).map((child, i) => {
322 return isValidElement(child) &&
323 (child.type === Item || child.type === ContainerItem) ? (
324 <Fragment key={i}>
325 {i > 0 ? (
326 <View style={[a.border_b, t.atoms.border_contrast_low]} />
327 ) : null}
328 {cloneElement(child, {
329 // @ts-expect-error cloneElement is not aware of the types
330 style: {
331 borderRadius: 0,
332 borderWidth: 0,
333 },
334 })}
335 </Fragment>
336 ) : null
337 })}
338 </View>
339 )
340}
341
342function Cancel() {
343 const {_} = useLingui()
344 const context = useMenuContext()
345
346 return (
347 <Button
348 label={_(msg`Close this dialog`)}
349 size="small"
350 variant="ghost"
351 color="secondary"
352 onPress={() => context.control.close()}>
353 <ButtonText>
354 <Trans>Cancel</Trans>
355 </ButtonText>
356 </Button>
357 )
358}
359
360export function Divider() {
361 return null
362}