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