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 IS_NATIVE: 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 a.overflow_hidden,
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', fill}: 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 fill
324 ? fill({disabled})
325 : disabled
326 ? t.atoms.text_contrast_low.color
327 : t.atoms.text_contrast_medium.color
328 }
329 />
330 </View>
331 )
332}
333
334export function ItemRadio({selected}: {selected: boolean}) {
335 const t = useTheme()
336 return (
337 <View
338 style={[
339 a.justify_center,
340 a.align_center,
341 a.rounded_full,
342 t.atoms.border_contrast_high,
343 {
344 borderWidth: 1,
345 height: 20,
346 width: 20,
347 },
348 ]}>
349 {selected ? (
350 <View
351 style={[
352 a.absolute,
353 a.rounded_full,
354 {height: 14, width: 14},
355 selected
356 ? {
357 backgroundColor: t.palette.primary_500,
358 }
359 : {},
360 ]}
361 />
362 ) : null}
363 </View>
364 )
365}
366
367export function LabelText({
368 children,
369 style,
370}: {
371 children: React.ReactNode
372 style?: StyleProp<TextStyle>
373}) {
374 const t = useTheme()
375 return (
376 <Text
377 style={[
378 a.font_semi_bold,
379 a.p_sm,
380 t.atoms.text_contrast_low,
381 a.leading_snug,
382 {paddingHorizontal: 10},
383 style,
384 ]}>
385 {children}
386 </Text>
387 )
388}
389
390export function Group({children}: GroupProps) {
391 return children
392}
393
394export function Divider() {
395 const t = useTheme()
396 return (
397 <DropdownMenu.Separator
398 style={flatten([
399 a.my_xs,
400 t.atoms.bg_contrast_100,
401 a.flex_shrink_0,
402 {height: 1},
403 ])}
404 />
405 )
406}
407
408export function ContainerItem() {
409 return null
410}