forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {useImperativeHandle} from 'react'
2import {
3 FlatList,
4 type FlatListProps,
5 type GestureResponderEvent,
6 Pressable,
7 type StyleProp,
8 View,
9 type ViewStyle,
10} from 'react-native'
11import {msg} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
14import {RemoveScrollBar} from 'react-remove-scroll-bar'
15
16import {logger} from '#/logger'
17import {useA11y} from '#/state/a11y'
18import {useDialogStateControlContext} from '#/state/dialogs'
19import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
20import {Button, ButtonIcon} from '#/components/Button'
21import {Context} from '#/components/Dialog/context'
22import {
23 type DialogControlProps,
24 type DialogInnerProps,
25 type DialogOuterProps,
26} from '#/components/Dialog/types'
27import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
28import {Portal} from '#/components/Portal'
29
30export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
31export * from '#/components/Dialog/shared'
32export * from '#/components/Dialog/types'
33export * from '#/components/Dialog/utils'
34export {Input} from '#/components/forms/TextField'
35
36// 100 minus 10vh of paddingVertical
37export const WEB_DIALOG_HEIGHT = '80vh'
38
39const stopPropagation = (e: any) => e.stopPropagation()
40const preventDefault = (e: any) => e.preventDefault()
41
42export function Outer({
43 children,
44 control,
45 onClose,
46 webOptions,
47}: React.PropsWithChildren<DialogOuterProps>) {
48 const {_} = useLingui()
49 const {gtMobile} = useBreakpoints()
50 const [isOpen, setIsOpen] = React.useState(false)
51 const {setDialogIsOpen} = useDialogStateControlContext()
52
53 const open = React.useCallback(() => {
54 setDialogIsOpen(control.id, true)
55 setIsOpen(true)
56 }, [setIsOpen, setDialogIsOpen, control.id])
57
58 const close = React.useCallback<DialogControlProps['close']>(
59 cb => {
60 setDialogIsOpen(control.id, false)
61 setIsOpen(false)
62
63 try {
64 if (cb && typeof cb === 'function') {
65 // This timeout ensures that the callback runs at the same time as it would on native. I.e.
66 // console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
67 // This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
68 // 'Step 1', 'Step 3', 'Step 2'.
69 setTimeout(cb)
70 }
71 } catch (e: any) {
72 logger.error(`Dialog closeCallback failed`, {
73 message: e.message,
74 })
75 }
76
77 onClose?.()
78 },
79 [control.id, onClose, setDialogIsOpen],
80 )
81
82 const handleBackgroundPress = React.useCallback(
83 async (e: GestureResponderEvent) => {
84 webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close()
85 },
86 [webOptions, close],
87 )
88
89 useImperativeHandle(
90 control.ref,
91 () => ({
92 open,
93 close,
94 }),
95 [close, open],
96 )
97
98 const context = React.useMemo(
99 () => ({
100 close,
101 IS_NATIVEDialog: false,
102 nativeSnapPoint: 0,
103 disableDrag: false,
104 setDisableDrag: () => {},
105 isWithinDialog: true,
106 }),
107 [close],
108 )
109
110 return (
111 <>
112 {isOpen && (
113 <Portal>
114 <Context.Provider value={context}>
115 <RemoveScrollBar />
116 <Pressable
117 accessibilityHint={undefined}
118 accessibilityLabel={_(msg`Close active dialog`)}
119 onPress={handleBackgroundPress}>
120 <View
121 style={[
122 web(a.fixed),
123 a.inset_0,
124 a.z_10,
125 a.px_xl,
126 webOptions?.alignCenter ? a.justify_center : undefined,
127 a.align_center,
128 {
129 overflowY: 'auto',
130 paddingVertical: gtMobile ? '10vh' : a.pt_xl.paddingTop,
131 },
132 ]}>
133 <Backdrop />
134 {/**
135 * This is needed to prevent centered dialogs from overflowing
136 * above the screen, and provides a "natural" centering so that
137 * stacked dialogs appear relatively aligned.
138 */}
139 <View
140 style={[
141 a.w_full,
142 a.z_20,
143 a.align_center,
144 web({minHeight: '60vh', position: 'static'}),
145 ]}>
146 {children}
147 </View>
148 </View>
149 </Pressable>
150 </Context.Provider>
151 </Portal>
152 )}
153 </>
154 )
155}
156
157export function Inner({
158 children,
159 style,
160 label,
161 accessibilityLabelledBy,
162 accessibilityDescribedBy,
163 header,
164 contentContainerStyle,
165}: DialogInnerProps) {
166 const t = useTheme()
167 const {close} = React.useContext(Context)
168 const {gtMobile} = useBreakpoints()
169 const {reduceMotionEnabled} = useA11y()
170 FocusGuards.useFocusGuards()
171 return (
172 <FocusScope.FocusScope loop asChild trapped>
173 <View
174 role="dialog"
175 aria-role="dialog"
176 aria-label={label}
177 aria-labelledby={accessibilityLabelledBy}
178 aria-describedby={accessibilityDescribedBy}
179 // @ts-expect-error web only -prf
180 onClick={stopPropagation}
181 onStartShouldSetResponder={_ => true}
182 onTouchEnd={stopPropagation}
183 // note: flatten is required for some reason -sfn
184 style={flatten([
185 a.relative,
186 a.rounded_md,
187 a.w_full,
188 a.border,
189 t.atoms.bg,
190 {
191 maxWidth: 600,
192 borderColor: t.palette.contrast_200,
193 shadowColor: t.palette.black,
194 shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
195 shadowRadius: 30,
196 },
197 !reduceMotionEnabled && a.zoom_fade_in,
198 style,
199 ])}>
200 <DismissableLayer.DismissableLayer
201 onInteractOutside={preventDefault}
202 onFocusOutside={preventDefault}
203 onDismiss={close}
204 style={{height: '100%', display: 'flex', flexDirection: 'column'}}>
205 {header}
206 <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
207 {children}
208 </View>
209 </DismissableLayer.DismissableLayer>
210 </View>
211 </FocusScope.FocusScope>
212 )
213}
214
215export const ScrollableInner = Inner
216
217export const InnerFlatList = React.forwardRef<
218 FlatList,
219 FlatListProps<any> & {label: string} & {
220 webInnerStyle?: StyleProp<ViewStyle>
221 webInnerContentContainerStyle?: StyleProp<ViewStyle>
222 footer?: React.ReactNode
223 }
224>(function InnerFlatList(
225 {
226 label,
227 style,
228 webInnerStyle,
229 webInnerContentContainerStyle,
230 footer,
231 ...props
232 },
233 ref,
234) {
235 const {gtMobile} = useBreakpoints()
236 return (
237 <Inner
238 label={label}
239 style={[
240 a.overflow_hidden,
241 a.px_0,
242 web({maxHeight: WEB_DIALOG_HEIGHT}),
243 webInnerStyle,
244 ]}
245 contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
246 <FlatList
247 ref={ref}
248 style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]}
249 {...props}
250 />
251 {footer}
252 </Inner>
253 )
254})
255
256export function FlatListFooter({children}: {children: React.ReactNode}) {
257 const t = useTheme()
258
259 return (
260 <View
261 style={[
262 a.absolute,
263 a.bottom_0,
264 a.w_full,
265 a.z_10,
266 t.atoms.bg,
267 a.border_t,
268 t.atoms.border_contrast_low,
269 a.px_lg,
270 a.py_md,
271 ]}>
272 {children}
273 </View>
274 )
275}
276
277export function Close() {
278 const {_} = useLingui()
279 const {close} = React.useContext(Context)
280 return (
281 <View
282 style={[
283 a.absolute,
284 a.z_10,
285 {
286 top: a.pt_md.paddingTop,
287 right: a.pr_md.paddingRight,
288 },
289 ]}>
290 <Button
291 size="small"
292 variant="ghost"
293 color="secondary"
294 shape="round"
295 onPress={() => close()}
296 label={_(msg`Close active dialog`)}>
297 <ButtonIcon icon={X} size="md" />
298 </Button>
299 </View>
300 )
301}
302
303export function Handle() {
304 return null
305}
306
307export function Backdrop() {
308 const t = useTheme()
309 const {reduceMotionEnabled} = useA11y()
310 return (
311 <View style={{opacity: 0.8}}>
312 <View
313 style={[
314 a.fixed,
315 a.inset_0,
316 {backgroundColor: t.palette.black},
317 !reduceMotionEnabled && a.fade_in,
318 ]}
319 />
320 </View>
321 )
322}