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_md,
279 a.border,
280 t.atoms.bg_contrast_25,
281 t.atoms.border_contrast_low,
282 {paddingVertical: 10},
283 style,
284 ]}>
285 {children}
286 </View>
287 )
288}
289
290export function LabelText({
291 children,
292 style,
293}: {
294 children: React.ReactNode
295 style?: StyleProp<TextStyle>
296}) {
297 const t = useTheme()
298 return (
299 <Text
300 style={[
301 a.font_semi_bold,
302 t.atoms.text_contrast_medium,
303 {marginBottom: -8},
304 style,
305 ]}>
306 {children}
307 </Text>
308 )
309}
310
311export function Group({children, style}: GroupProps) {
312 const t = useTheme()
313 return (
314 <View
315 style={[
316 a.rounded_md,
317 a.overflow_hidden,
318 a.border,
319 t.atoms.border_contrast_low,
320 style,
321 ]}>
322 {flattenReactChildren(children).map((child, i) => {
323 return isValidElement(child) &&
324 (child.type === Item || child.type === ContainerItem) ? (
325 <Fragment key={i}>
326 {i > 0 ? (
327 <View style={[a.border_b, t.atoms.border_contrast_low]} />
328 ) : null}
329 {cloneElement(child, {
330 // @ts-expect-error cloneElement is not aware of the types
331 style: {
332 borderRadius: 0,
333 borderWidth: 0,
334 },
335 })}
336 </Fragment>
337 ) : null
338 })}
339 </View>
340 )
341}
342
343function Cancel() {
344 const {_} = useLingui()
345 const context = useMenuContext()
346
347 return (
348 <Button
349 label={_(msg`Close this dialog`)}
350 size="small"
351 variant="ghost"
352 color="secondary"
353 onPress={() => context.control.close()}>
354 <ButtonText>
355 <Trans>Cancel</Trans>
356 </ButtonText>
357 </Button>
358 )
359}
360
361export function Divider() {
362 return null
363}