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 {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.overflow_hidden,
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 style,
198 disabled && t.atoms.text_contrast_low,
199 ]}>
200 {children}
201 </Text>
202 )
203}
204
205export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
206 const t = useTheme()
207 const {disabled} = useMenuItemContext()
208 return (
209 <Comp
210 size="lg"
211 fill={
212 fill
213 ? fill({disabled})
214 : disabled
215 ? t.atoms.text_contrast_low.color
216 : t.atoms.text_contrast_medium.color
217 }
218 />
219 )
220}
221
222export function ItemRadio({selected}: {selected: boolean}) {
223 const t = useTheme()
224 return (
225 <View
226 style={[
227 a.justify_center,
228 a.align_center,
229 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 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}