Skip to content

Commit e9e67de

Browse files
authored
feat: implement imperative modal (#4041)
* feat: implement ImperativeModalProvider with modal context and controls * fix: make onClose prop optional for bottom modal * chore: add ImperativeModalProvider to context providers * feat: wrap BottomModal content in KeyboardAvoidingView for better keyboard handling * chore: add polyfill for Promise.withResolvers * fix: wrap ImperativeModal in a View and update onClose to accept modal ID * feat: implement ImperativeModal with context and modal control hooks * refactor: redesign imperative modal with root-siblings * fix: wrap BottomModal in a View to resolve rendering issues on Android * refactor: replace Alert.prompt with modalPrompt for category creation and renaming
1 parent e91b2bf commit e9e67de

9 files changed

Lines changed: 352 additions & 64 deletions

File tree

apps/mobile/src/components/ui/modal/BottomModal.tsx

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cn } from "@follow/utils/utils"
22
import type { ReactNode } from "react"
33
import { useCallback, useEffect, useState } from "react"
4-
import { Modal, Pressable } from "react-native"
4+
import { KeyboardAvoidingView, Modal, Pressable, View } from "react-native"
55
import Animated, {
66
Easing,
77
runOnJS,
@@ -10,7 +10,8 @@ import Animated, {
1010
withTiming,
1111
} from "react-native-reanimated"
1212

13-
interface BottomModalProps {
13+
export interface BottomModalProps {
14+
// ref?: Ref<{ close: () => void }>
1415
/**
1516
* Whether the modal is visible
1617
*/
@@ -19,7 +20,7 @@ interface BottomModalProps {
1920
/**
2021
* Function to call when the modal should be closed (backdrop press or programmatically)
2122
*/
22-
onClose: () => void
23+
onClose?: () => void
2324

2425
/**
2526
* Content to render inside the modal
@@ -82,6 +83,19 @@ export function BottomModal({
8283
transform: [{ translateY: contentTranslateY.value }],
8384
}))
8485

86+
// useImperativeHandle(ref, () => ({
87+
// close: () => {
88+
// if (internalVisible) {
89+
// hideModal()
90+
// }
91+
// },
92+
// open: () => {
93+
// if (!internalVisible) {
94+
// showModal()
95+
// }
96+
// },
97+
// }))
98+
8599
const showModal = useCallback(() => {
86100
setInternalVisible(true)
87101
backdropAnim.value = withTiming(backdropOpacity, {
@@ -109,7 +123,9 @@ export function BottomModal({
109123
},
110124
() => {
111125
runOnJS(setInternalVisible)(false)
112-
runOnJS(onClose)()
126+
if (onClose) {
127+
runOnJS(onClose)()
128+
}
113129
},
114130
)
115131
}, [backdropAnim, contentTranslateY, closeDuration, onClose])
@@ -122,9 +138,12 @@ export function BottomModal({
122138

123139
// Start animations when visibility changes
124140
useEffect(() => {
141+
if (visible === internalVisible) {
142+
return // No change, do nothing
143+
}
125144
if (visible) {
126145
showModal()
127-
} else if (internalVisible) {
146+
} else {
128147
hideModal()
129148
}
130149
}, [visible, showModal, hideModal, internalVisible])
@@ -134,31 +153,36 @@ export function BottomModal({
134153
}
135154

136155
return (
137-
<Modal
138-
visible={internalVisible}
139-
transparent={true}
140-
animationType="none"
141-
onRequestClose={hideModal}
142-
statusBarTranslucent
143-
navigationBarTranslucent
144-
>
145-
<Animated.View className="absolute inset-0 bg-black" style={backdropStyle}>
146-
<Pressable
147-
className="flex-1"
148-
onPress={handleBackdropPress}
149-
android_ripple={{ color: "white" }}
150-
/>
151-
</Animated.View>
152-
153-
<Animated.View
154-
className={cn(
155-
"bg-system-background mt-auto flex-1 overflow-hidden rounded-t-2xl",
156-
className,
157-
)}
158-
style={modalContentStyle}
156+
// Wrap in a View to avoid rendering issues with Modal on Android
157+
<View>
158+
<Modal
159+
visible={internalVisible}
160+
transparent={true}
161+
animationType="none"
162+
onRequestClose={hideModal}
163+
statusBarTranslucent
164+
navigationBarTranslucent
159165
>
160-
{children}
161-
</Animated.View>
162-
</Modal>
166+
<KeyboardAvoidingView className="flex-1" behavior="padding">
167+
<Animated.View className="absolute inset-0 bg-black" style={backdropStyle}>
168+
<Pressable
169+
className="flex-1"
170+
onPress={handleBackdropPress}
171+
android_ripple={{ color: "white" }}
172+
/>
173+
</Animated.View>
174+
175+
<Animated.View
176+
className={cn(
177+
"bg-system-background mt-auto flex-1 overflow-hidden rounded-t-2xl",
178+
className,
179+
)}
180+
style={modalContentStyle}
181+
>
182+
{children}
183+
</Animated.View>
184+
</KeyboardAvoidingView>
185+
</Modal>
186+
</View>
163187
)
164188
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./modal"
2+
export * as ModalTemplate from "./templates"
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { nanoid } from "nanoid/non-secure"
2+
import type { ReactNode } from "react"
3+
import { cloneElement, useState } from "react"
4+
import { useTranslation } from "react-i18next"
5+
import { Text, TouchableOpacity, View } from "react-native"
6+
import RootSiblings from "react-native-root-siblings"
7+
8+
import { HeaderSubmitTextButton } from "@/src/components/layouts/header/HeaderElements"
9+
10+
import type { BottomModalProps } from "../BottomModal"
11+
import { BottomModal } from "../BottomModal"
12+
import { Header, HeaderText, Input } from "./templates"
13+
14+
export type Modal = { id: string; content: ReactNode } & Omit<
15+
BottomModalProps,
16+
"visible" | "children"
17+
>
18+
19+
export type ModalInput = Omit<Modal, "id" | "closeOnBackdropPress"> & {
20+
/**
21+
* Optional: Will be auto-generated with nanoid if not provided
22+
*/
23+
id?: string
24+
/**
25+
* Default is true
26+
*/
27+
closeOnBackdropPress?: false
28+
/**
29+
* Select template for the modal.
30+
*
31+
* @default 'plain'
32+
*/
33+
// type?: "plain" // | 'select' | 'confirm' | 'input' | 'custom'
34+
abortController?: AbortController
35+
}
36+
37+
export const openModal = (modal: ModalInput) => {
38+
const promise = Promise.withResolvers<void>()
39+
const abortController = modal.abortController || new AbortController()
40+
41+
const node = (
42+
<BottomModal
43+
id={modal.id || nanoid()}
44+
visible={true}
45+
{...modal}
46+
onClose={() => {
47+
abortController.abort()
48+
siblings.destroy()
49+
modal.onClose?.()
50+
promise.resolve()
51+
}}
52+
>
53+
{modal.content}
54+
</BottomModal>
55+
)
56+
const siblings = new RootSiblings(node)
57+
58+
abortController.signal.addEventListener("abort", () => {
59+
const newNode = cloneElement(node, {
60+
visible: false,
61+
})
62+
siblings.update(newNode)
63+
})
64+
65+
return promise.promise
66+
}
67+
68+
const PromptModal = ({
69+
defaultValue,
70+
title,
71+
placeholder,
72+
onSave,
73+
abortController,
74+
}: {
75+
defaultValue?: string
76+
title?: string
77+
placeholder?: string
78+
onSave: (newCategory: string) => void
79+
abortController: AbortController
80+
}) => {
81+
const { t } = useTranslation()
82+
const [text, setText] = useState(defaultValue ?? "")
83+
return (
84+
<View className="flex-1">
85+
<Header
86+
renderLeft={() => (
87+
<HeaderSubmitTextButton
88+
label={t("words.cancel", { ns: "common" })}
89+
isValid={true}
90+
onPress={() => {
91+
abortController.abort()
92+
}}
93+
/>
94+
)}
95+
>
96+
<HeaderText>{title}</HeaderText>
97+
</Header>
98+
<View className="flex flex-1 gap-4 p-4">
99+
<Input
100+
className="box-border w-full"
101+
value={text}
102+
onChangeText={setText}
103+
placeholder={placeholder}
104+
/>
105+
<TouchableOpacity
106+
className="bg-accent w-full rounded-xl px-6 py-3"
107+
disabled={text.trim().length === 0}
108+
onPress={() => {
109+
onSave(text)
110+
abortController.abort()
111+
}}
112+
>
113+
<Text className="text-center text-base font-semibold text-white">
114+
{t("words.save", { ns: "common" })}
115+
</Text>
116+
</TouchableOpacity>
117+
</View>
118+
</View>
119+
)
120+
}
121+
122+
export const modalPrompt = (
123+
title: string,
124+
message: string,
125+
callback: (text: string) => void,
126+
type?: undefined,
127+
defaultValue?: string,
128+
) => {
129+
const abortController = new AbortController()
130+
openModal({
131+
abortController,
132+
closeOnBackdropPress: false,
133+
content: (
134+
<PromptModal
135+
title={title}
136+
defaultValue={defaultValue}
137+
placeholder={message}
138+
abortController={abortController}
139+
onSave={callback}
140+
/>
141+
),
142+
})
143+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { cn } from "@follow/utils"
2+
import type {
3+
LayoutChangeEvent,
4+
StyleProp,
5+
TextInputProps,
6+
TextStyle,
7+
ViewStyle,
8+
} from "react-native"
9+
import { Text, TextInput, View } from "react-native"
10+
11+
export function Header({
12+
renderLeft,
13+
renderRight,
14+
children,
15+
className,
16+
style,
17+
onLayout,
18+
}: {
19+
renderLeft?: () => React.ReactNode
20+
renderRight?: () => React.ReactNode
21+
children?: React.ReactNode
22+
className?: string
23+
style?: StyleProp<ViewStyle>
24+
onLayout?: (event: LayoutChangeEvent) => void
25+
}) {
26+
return (
27+
<View
28+
className={cn(
29+
"border-non-opaque-separator relative min-h-[50px] w-full flex-row items-center justify-center rounded-t-md border-b py-2",
30+
className,
31+
)}
32+
style={style}
33+
onLayout={onLayout}
34+
>
35+
{renderLeft && <View className="absolute left-1">{renderLeft()}</View>}
36+
{children}
37+
{renderRight && <View className="absolute right-1">{renderRight()}</View>}
38+
</View>
39+
)
40+
}
41+
42+
export function HeaderText({
43+
children,
44+
style,
45+
}: {
46+
children?: React.ReactNode
47+
style?: StyleProp<TextStyle>
48+
}) {
49+
return (
50+
<Text className="text-center text-lg font-bold" style={style}>
51+
{children}
52+
</Text>
53+
)
54+
}
55+
56+
export function Input({
57+
value,
58+
onChangeText,
59+
placeholder,
60+
className,
61+
style,
62+
wrapperClassName,
63+
wrapperStyle,
64+
...rest
65+
}: {
66+
value: string
67+
onChangeText: (text: string) => void
68+
placeholder?: string
69+
className?: string
70+
style?: StyleProp<TextStyle>
71+
wrapperClassName?: string
72+
wrapperStyle?: StyleProp<ViewStyle>
73+
} & TextInputProps) {
74+
return (
75+
<View
76+
className={cn(
77+
"bg-tertiary-system-fill relative h-10 flex-row items-center rounded-lg px-3",
78+
wrapperClassName,
79+
)}
80+
style={wrapperStyle}
81+
>
82+
<TextInput
83+
className={cn("text-label w-full flex-1 p-0", className)}
84+
clearButtonMode="always"
85+
style={style}
86+
value={value}
87+
onChangeText={onChangeText}
88+
placeholder={placeholder}
89+
{...rest}
90+
/>
91+
</View>
92+
)
93+
}

apps/mobile/src/main.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import "./global.css"
2+
import "./polyfill"
23

34
import {
45
apiClientSimpleContext,

0 commit comments

Comments
 (0)