feat: add BottomModal and Select components for Android (#4010)
* feat: add BottomModal component * feat: implement Select component with modal for Android
This commit is contained in:
parent
35c713a82a
commit
9251204f19
|
|
@ -0,0 +1,154 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import { useCallback, useState } from "react"
|
||||
import type { StyleProp, ViewStyle } from "react-native"
|
||||
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native"
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
|
||||
import { CheckFilledIcon } from "@/src/icons/check_filled"
|
||||
import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line"
|
||||
import { accentColor } from "@/src/theme/colors"
|
||||
|
||||
import { BottomModal } from "../modal/BottomModal"
|
||||
import { FormLabel } from "./Label"
|
||||
|
||||
interface SelectProps<T> {
|
||||
options: { label: string; value: T; subLabel?: string }[]
|
||||
value: T
|
||||
onValueChange: (value: T) => void
|
||||
displayValue?: string
|
||||
wrapperClassName?: string
|
||||
wrapperStyle?: StyleProp<ViewStyle>
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function Select<T>({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
displayValue,
|
||||
wrapperClassName,
|
||||
wrapperStyle,
|
||||
label,
|
||||
}: SelectProps<T>) {
|
||||
const [isModalVisible, setModalVisible] = useState(false)
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const showOptions = useCallback(() => {
|
||||
setModalVisible(true)
|
||||
}, [])
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModalVisible(false)
|
||||
}, [])
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
(optionValue: T) => {
|
||||
onValueChange(optionValue)
|
||||
closeModal()
|
||||
},
|
||||
[onValueChange, closeModal],
|
||||
)
|
||||
|
||||
const renderOption = useCallback(
|
||||
({ item }: { item: { label: string; value: T; subLabel?: string } }) => {
|
||||
const isSelected = value === item.value
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleSelectOption(item.value)}
|
||||
className="flex-row items-center justify-between p-4"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={cn("text-label text-base", isSelected ? "font-semibold" : "font-normal")}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.subLabel ? (
|
||||
<Text className="text-secondary-label mt-0.5 text-xs">{item.subLabel}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View className="ml-2">
|
||||
<CheckFilledIcon color={accentColor} height={20} width={20} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
className={cn("bg-opaque-separator/70 ml-4")}
|
||||
style={{ height: StyleSheet.hairlineWidth }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
[handleSelectOption, value],
|
||||
)
|
||||
const Trigger = (
|
||||
<TouchableOpacity
|
||||
className={cn(
|
||||
"min-w-24 flex-1 shrink flex-row items-center rounded-lg pl-3",
|
||||
|
||||
wrapperClassName,
|
||||
)}
|
||||
hitSlop={30}
|
||||
onPress={showOptions}
|
||||
style={wrapperStyle}
|
||||
>
|
||||
<Text
|
||||
className="text-accent flex-1 text-right font-semibold"
|
||||
ellipsizeMode="middle"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayValue || selectedOption?.label || "Select"}
|
||||
</Text>
|
||||
<View className="ml-auto shrink-0 pl-1">
|
||||
<MingcuteDownLineIcon color={accentColor} height={18} width={18} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
const SelectModal = (
|
||||
<BottomModal visible={isModalVisible} onClose={closeModal}>
|
||||
<View className="border-opaque-separator flex-row items-center justify-between border-b p-4">
|
||||
<Text className="text-label text-xl font-semibold">Select an option</Text>
|
||||
<TouchableOpacity onPress={closeModal}>
|
||||
<Text style={{ color: accentColor }} className="text-lg font-bold">
|
||||
Done
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={options}
|
||||
renderItem={renderOption}
|
||||
keyExtractor={(item) => String(item.value)}
|
||||
className="grow"
|
||||
style={{
|
||||
marginBottom: insets.bottom + 16,
|
||||
}}
|
||||
/>
|
||||
</BottomModal>
|
||||
)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<>
|
||||
{Trigger}
|
||||
{SelectModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="flex-1 flex-row items-center justify-between">
|
||||
<FormLabel className="pl-2" label={label} />
|
||||
<View className="flex-1">{Trigger}</View>
|
||||
</View>
|
||||
{SelectModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import type { ReactNode } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Modal, Pressable } from "react-native"
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated"
|
||||
|
||||
interface BottomModalProps {
|
||||
/**
|
||||
* Whether the modal is visible
|
||||
*/
|
||||
visible: boolean
|
||||
|
||||
/**
|
||||
* Function to call when the modal should be closed (backdrop press or programmatically)
|
||||
*/
|
||||
onClose: () => void
|
||||
|
||||
/**
|
||||
* Content to render inside the modal
|
||||
*/
|
||||
children: ReactNode
|
||||
|
||||
/**
|
||||
* Modal container class name
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Duration for the open animation in milliseconds
|
||||
* @default 300
|
||||
*/
|
||||
openDuration?: number
|
||||
|
||||
/**
|
||||
* Duration for the close animation in milliseconds
|
||||
* @default 250
|
||||
*/
|
||||
closeDuration?: number
|
||||
|
||||
/**
|
||||
* Allow closing the modal by tapping on the backdrop
|
||||
* @default true
|
||||
*/
|
||||
closeOnBackdropPress?: boolean
|
||||
|
||||
/**
|
||||
* Backdrop opacity when fully visible
|
||||
* @default 0.5
|
||||
*/
|
||||
backdropOpacity?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* An animated modal that slides up from the bottom of the screen with a fading backdrop.
|
||||
* Great for bottom sheets, pickers, and other content that should appear from the bottom.
|
||||
*/
|
||||
export function BottomModal({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
className = "max-h-[40%]",
|
||||
openDuration = 300,
|
||||
closeDuration = 250,
|
||||
closeOnBackdropPress = true,
|
||||
backdropOpacity = 0.3,
|
||||
}: BottomModalProps) {
|
||||
const [internalVisible, setInternalVisible] = useState(false)
|
||||
const backdropAnim = useSharedValue(0)
|
||||
const contentTranslateY = useSharedValue(300)
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropAnim.value,
|
||||
}))
|
||||
|
||||
const modalContentStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: contentTranslateY.value }],
|
||||
}))
|
||||
|
||||
const showModal = useCallback(() => {
|
||||
setInternalVisible(true)
|
||||
backdropAnim.value = withTiming(backdropOpacity, {
|
||||
duration: openDuration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
|
||||
contentTranslateY.value = withTiming(0, {
|
||||
duration: openDuration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
}, [backdropAnim, contentTranslateY, backdropOpacity, openDuration])
|
||||
|
||||
const hideModal = useCallback(() => {
|
||||
backdropAnim.value = withTiming(0, {
|
||||
duration: closeDuration,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
})
|
||||
|
||||
contentTranslateY.value = withTiming(
|
||||
300,
|
||||
{
|
||||
duration: closeDuration,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
},
|
||||
() => {
|
||||
runOnJS(setInternalVisible)(false)
|
||||
runOnJS(onClose)()
|
||||
},
|
||||
)
|
||||
}, [backdropAnim, contentTranslateY, closeDuration, onClose])
|
||||
|
||||
const handleBackdropPress = useCallback(() => {
|
||||
if (closeOnBackdropPress) {
|
||||
hideModal()
|
||||
}
|
||||
}, [closeOnBackdropPress, hideModal])
|
||||
|
||||
// Start animations when visibility changes
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
showModal()
|
||||
} else if (internalVisible) {
|
||||
hideModal()
|
||||
}
|
||||
}, [visible, showModal, hideModal, internalVisible])
|
||||
|
||||
if (!internalVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={internalVisible}
|
||||
transparent={true}
|
||||
animationType="none"
|
||||
onRequestClose={hideModal}
|
||||
statusBarTranslucent
|
||||
navigationBarTranslucent
|
||||
>
|
||||
<Animated.View className="absolute inset-0 bg-black" style={backdropStyle}>
|
||||
<Pressable
|
||||
className="flex-1"
|
||||
onPress={handleBackdropPress}
|
||||
android_ripple={{ color: "white" }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
className={cn(
|
||||
"bg-system-background mt-auto flex-1 overflow-hidden rounded-t-2xl",
|
||||
className,
|
||||
)}
|
||||
style={modalContentStyle}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue