Bluesky app fork with some witchin' additions 馃挮
at post-text-option 409 lines 10 kB view raw
1import {forwardRef, useCallback, useId, useMemo, useState} from 'react' 2import { 3 Pressable, 4 type StyleProp, 5 type TextStyle, 6 View, 7 type ViewStyle, 8} from 'react-native' 9import {msg} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11import {DropdownMenu} from 'radix-ui' 12 13import {useA11y} from '#/state/a11y' 14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 15import {atoms as a, flatten, useTheme, web} from '#/alf' 16import type * 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 RadixPassThroughTriggerProps, 31 type TriggerProps, 32} from '#/components/Menu/types' 33import {Portal} from '#/components/Portal' 34import {Text} from '#/components/Typography' 35 36export {useMenuContext} 37 38export function useMenuControl(): Dialog.DialogControlProps { 39 const id = useId() 40 const [isOpen, setIsOpen] = useState(false) 41 42 return useMemo( 43 () => ({ 44 id, 45 ref: {current: null}, 46 isOpen, 47 open() { 48 setIsOpen(true) 49 }, 50 close() { 51 setIsOpen(false) 52 }, 53 }), 54 [id, isOpen, setIsOpen], 55 ) 56} 57 58export function Root({ 59 children, 60 control, 61}: React.PropsWithChildren<{ 62 control?: Dialog.DialogControlProps 63}>) { 64 const {_} = useLingui() 65 const defaultControl = useMenuControl() 66 const context = useMemo<ContextType>( 67 () => ({ 68 control: control || defaultControl, 69 }), 70 [control, defaultControl], 71 ) 72 const onOpenChange = useCallback( 73 (open: boolean) => { 74 if (context.control.isOpen && !open) { 75 context.control.close() 76 } else if (!context.control.isOpen && open) { 77 context.control.open() 78 } 79 }, 80 [context.control], 81 ) 82 83 return ( 84 <Context.Provider value={context}> 85 {context.control.isOpen && ( 86 <Portal> 87 <Pressable 88 style={[a.fixed, a.inset_0, a.z_50]} 89 onPress={() => context.control.close()} 90 accessibilityHint="" 91 accessibilityLabel={_( 92 msg`Context menu backdrop, click to close the menu.`, 93 )} 94 /> 95 </Portal> 96 )} 97 <DropdownMenu.Root 98 open={context.control.isOpen} 99 onOpenChange={onOpenChange}> 100 {children} 101 </DropdownMenu.Root> 102 </Context.Provider> 103 ) 104} 105 106const RadixTriggerPassThrough = forwardRef( 107 ( 108 props: { 109 children: ( 110 props: RadixPassThroughTriggerProps & { 111 ref: React.Ref<any> 112 }, 113 ) => React.ReactNode 114 }, 115 ref, 116 ) => { 117 // @ts-expect-error Radix provides no types of this stuff 118 return props.children({...props, ref}) 119 }, 120) 121RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough' 122 123export function Trigger({ 124 children, 125 label, 126 role = 'button', 127 hint, 128}: TriggerProps) { 129 const {control} = useMenuContext() 130 const { 131 state: hovered, 132 onIn: onMouseEnter, 133 onOut: onMouseLeave, 134 } = useInteractionState() 135 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 136 137 return ( 138 <DropdownMenu.Trigger asChild> 139 <RadixTriggerPassThrough> 140 {props => 141 children({ 142 isNative: false, 143 control, 144 state: { 145 hovered, 146 focused, 147 pressed: false, 148 }, 149 props: { 150 ...props, 151 // No-op override to prevent false positive that interprets mobile scroll as a tap. 152 // This requires the custom onPress handler below to compensate. 153 // https://github.com/radix-ui/primitives/issues/1912 154 onPointerDown: undefined, 155 onPress: () => { 156 if (window.event instanceof KeyboardEvent) { 157 // The onPointerDown hack above is not relevant to this press, so don't do anything. 158 return 159 } 160 // Compensate for the disabled onPointerDown above by triggering it manually. 161 if (control.isOpen) { 162 control.close() 163 } else { 164 control.open() 165 } 166 }, 167 onFocus: onFocus, 168 onBlur: onBlur, 169 onMouseEnter, 170 onMouseLeave, 171 accessibilityHint: hint, 172 accessibilityLabel: label, 173 accessibilityRole: role, 174 }, 175 }) 176 } 177 </RadixTriggerPassThrough> 178 </DropdownMenu.Trigger> 179 ) 180} 181 182export function Outer({ 183 children, 184 style, 185}: React.PropsWithChildren<{ 186 showCancel?: boolean 187 style?: StyleProp<ViewStyle> 188}>) { 189 const t = useTheme() 190 const {reduceMotionEnabled} = useA11y() 191 192 return ( 193 <DropdownMenu.Portal> 194 <DropdownMenu.Content 195 sideOffset={5} 196 collisionPadding={{left: 5, right: 5, bottom: 5}} 197 loop 198 aria-label="Test" 199 className="dropdown-menu-transform-origin dropdown-menu-constrain-size"> 200 <View 201 style={[ 202 a.rounded_sm, 203 a.p_xs, 204 a.border, 205 t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, 206 t.atoms.shadow_md, 207 t.atoms.border_contrast_low, 208 a.overflow_auto, 209 !reduceMotionEnabled && a.zoom_fade_in, 210 style, 211 ]}> 212 {children} 213 </View> 214 215 {/* Disabled until we can fix positioning 216 <DropdownMenu.Arrow 217 className="DropdownMenuArrow" 218 fill={ 219 (t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25) 220 .backgroundColor 221 } 222 /> 223 */} 224 </DropdownMenu.Content> 225 </DropdownMenu.Portal> 226 ) 227} 228 229export function Item({children, label, onPress, style, ...rest}: ItemProps) { 230 const t = useTheme() 231 const {control} = useMenuContext() 232 const { 233 state: hovered, 234 onIn: onMouseEnter, 235 onOut: onMouseLeave, 236 } = useInteractionState() 237 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 238 239 return ( 240 <DropdownMenu.Item asChild> 241 <Pressable 242 {...rest} 243 className="radix-dropdown-item" 244 accessibilityHint="" 245 accessibilityLabel={label} 246 onPress={e => { 247 onPress(e) 248 249 /** 250 * Ported forward from Radix 251 * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item 252 */ 253 if (!e.defaultPrevented) { 254 control.close() 255 } 256 }} 257 onFocus={onFocus} 258 onBlur={onBlur} 259 // need `flatten` here for Radix compat 260 style={flatten([ 261 a.flex_row, 262 a.align_center, 263 a.gap_lg, 264 a.py_sm, 265 a.rounded_xs, 266 {minHeight: 32, paddingHorizontal: 10}, 267 web({outline: 0}), 268 (hovered || focused) && 269 !rest.disabled && [ 270 web({outline: '0 !important'}), 271 t.name === 'light' 272 ? t.atoms.bg_contrast_25 273 : t.atoms.bg_contrast_50, 274 ], 275 style, 276 ])} 277 {...web({ 278 onMouseEnter, 279 onMouseLeave, 280 })}> 281 <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}> 282 {children} 283 </ItemContext.Provider> 284 </Pressable> 285 </DropdownMenu.Item> 286 ) 287} 288 289export function ItemText({children, style}: ItemTextProps) { 290 const t = useTheme() 291 const {disabled} = useMenuItemContext() 292 return ( 293 <Text 294 style={[ 295 a.flex_1, 296 a.font_semi_bold, 297 t.atoms.text_contrast_high, 298 style, 299 disabled && t.atoms.text_contrast_low, 300 ]}> 301 {children} 302 </Text> 303 ) 304} 305 306export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { 307 const t = useTheme() 308 const {disabled} = useMenuItemContext() 309 return ( 310 <View 311 style={[ 312 position === 'left' && { 313 marginLeft: -2, 314 }, 315 position === 'right' && { 316 marginRight: -2, 317 marginLeft: 12, 318 }, 319 ]}> 320 <Comp 321 size="md" 322 fill={ 323 disabled 324 ? t.atoms.text_contrast_low.color 325 : t.atoms.text_contrast_medium.color 326 } 327 /> 328 </View> 329 ) 330} 331 332export function ItemRadio({selected}: {selected: boolean}) { 333 const t = useTheme() 334 const enableSquareButtons = useEnableSquareButtons() 335 return ( 336 <View 337 style={[ 338 a.justify_center, 339 a.align_center, 340 enableSquareButtons ? a.rounded_sm : a.rounded_full, 341 t.atoms.border_contrast_high, 342 { 343 borderWidth: 1, 344 height: 20, 345 width: 20, 346 }, 347 ]}> 348 {selected ? ( 349 <View 350 style={[ 351 a.absolute, 352 enableSquareButtons ? a.rounded_sm : a.rounded_full, 353 {height: 14, width: 14}, 354 selected 355 ? { 356 backgroundColor: t.palette.primary_500, 357 } 358 : {}, 359 ]} 360 /> 361 ) : null} 362 </View> 363 ) 364} 365 366export function LabelText({ 367 children, 368 style, 369}: { 370 children: React.ReactNode 371 style?: StyleProp<TextStyle> 372}) { 373 const t = useTheme() 374 return ( 375 <Text 376 style={[ 377 a.font_semi_bold, 378 a.p_sm, 379 t.atoms.text_contrast_low, 380 a.leading_snug, 381 {paddingHorizontal: 10}, 382 style, 383 ]}> 384 {children} 385 </Text> 386 ) 387} 388 389export function Group({children}: GroupProps) { 390 return children 391} 392 393export function Divider() { 394 const t = useTheme() 395 return ( 396 <DropdownMenu.Separator 397 style={flatten([ 398 a.my_xs, 399 t.atoms.bg_contrast_100, 400 a.flex_shrink_0, 401 {height: 1}, 402 ])} 403 /> 404 ) 405} 406 407export function ContainerItem() { 408 return null 409}