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