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