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 {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}