Bluesky app fork with some witchin' additions 馃挮
at readme-update 362 lines 8.4 kB view raw
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}