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