Bluesky app fork with some witchin' additions 馃挮
at readme-update 412 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 IS_NATIVE: 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 a.overflow_hidden, 267 {minHeight: 32, paddingHorizontal: 10}, 268 web({outline: 0}), 269 (hovered || focused) && 270 !rest.disabled && [ 271 web({outline: '0 !important'}), 272 t.name === 'light' 273 ? t.atoms.bg_contrast_25 274 : t.atoms.bg_contrast_50, 275 ], 276 style, 277 ])} 278 {...web({ 279 onMouseEnter, 280 onMouseLeave, 281 })}> 282 <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}> 283 {children} 284 </ItemContext.Provider> 285 </Pressable> 286 </DropdownMenu.Item> 287 ) 288} 289 290export function ItemText({children, style}: ItemTextProps) { 291 const t = useTheme() 292 const {disabled} = useMenuItemContext() 293 return ( 294 <Text 295 style={[ 296 a.flex_1, 297 a.font_semi_bold, 298 t.atoms.text_contrast_high, 299 style, 300 disabled && t.atoms.text_contrast_low, 301 ]}> 302 {children} 303 </Text> 304 ) 305} 306 307export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) { 308 const t = useTheme() 309 const {disabled} = useMenuItemContext() 310 return ( 311 <View 312 style={[ 313 position === 'left' && { 314 marginLeft: -2, 315 }, 316 position === 'right' && { 317 marginRight: -2, 318 marginLeft: 12, 319 }, 320 ]}> 321 <Comp 322 size="md" 323 fill={ 324 fill 325 ? fill({disabled}) 326 : disabled 327 ? t.atoms.text_contrast_low.color 328 : t.atoms.text_contrast_medium.color 329 } 330 /> 331 </View> 332 ) 333} 334 335export function ItemRadio({selected}: {selected: boolean}) { 336 const t = useTheme() 337 const enableSquareButtons = useEnableSquareButtons() 338 return ( 339 <View 340 style={[ 341 a.justify_center, 342 a.align_center, 343 enableSquareButtons ? a.rounded_sm : a.rounded_full, 344 t.atoms.border_contrast_high, 345 { 346 borderWidth: 1, 347 height: 20, 348 width: 20, 349 }, 350 ]}> 351 {selected ? ( 352 <View 353 style={[ 354 a.absolute, 355 enableSquareButtons ? a.rounded_sm : a.rounded_full, 356 {height: 14, width: 14}, 357 selected 358 ? { 359 backgroundColor: t.palette.primary_500, 360 } 361 : {}, 362 ]} 363 /> 364 ) : null} 365 </View> 366 ) 367} 368 369export function LabelText({ 370 children, 371 style, 372}: { 373 children: React.ReactNode 374 style?: StyleProp<TextStyle> 375}) { 376 const t = useTheme() 377 return ( 378 <Text 379 style={[ 380 a.font_semi_bold, 381 a.p_sm, 382 t.atoms.text_contrast_low, 383 a.leading_snug, 384 {paddingHorizontal: 10}, 385 style, 386 ]}> 387 {children} 388 </Text> 389 ) 390} 391 392export function Group({children}: GroupProps) { 393 return children 394} 395 396export function Divider() { 397 const t = useTheme() 398 return ( 399 <DropdownMenu.Separator 400 style={flatten([ 401 a.my_xs, 402 t.atoms.bg_contrast_100, 403 a.flex_shrink_0, 404 {height: 1}, 405 ])} 406 /> 407 ) 408} 409 410export function ContainerItem() { 411 return null 412}