Files
Expensso/src/components/CustomBottomSheet.tsx
Arkaprabha Chakraborty 53742d0134 init
2026-02-18 13:16:51 +05:30

434 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CustomBottomSheet — replaces ALL system Alert/Modal patterns.
*
* Built on @gorhom/bottom-sheet with MD3 styling, drag-handle,
* scrim overlay, and smooth reanimated transitions.
*
* Usage:
* <CustomBottomSheet ref={sheetRef} snapPoints={['50%']}>
* <BottomSheetContent />
* </CustomBottomSheet>
*
* Imperative API:
* sheetRef.current?.present() open
* sheetRef.current?.dismiss() close
*/
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Pressable,
} from 'react-native';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetView,
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
const hapticOptions = {enableVibrateFallback: true, ignoreAndroidSystemSettings: false};
export const triggerHaptic = (type: 'impactLight' | 'impactMedium' | 'selection' | 'notificationSuccess' = 'impactLight') =>
ReactNativeHapticFeedback.trigger(type, hapticOptions);
// ─── Public Handle ───────────────────────────────────────────────────
export interface CustomBottomSheetHandle {
present: () => void;
dismiss: () => void;
}
// ─── Props ───────────────────────────────────────────────────────────
export interface CustomBottomSheetProps {
/** Title displayed in the sheet header */
title?: string;
/** Snap-point percentages or absolute values */
snapPoints?: (string | number)[];
/** Callback when the sheet is fully closed */
onDismiss?: () => void;
/** Whether to wrap children in a ScrollView (default: true) */
scrollable?: boolean;
/** Header left action (e.g. Cancel) */
headerLeft?: {label: string; onPress: () => void};
/** Header right action (e.g. Save) */
headerRight?: {label: string; onPress: () => void; color?: string};
/** Content */
children: React.ReactNode;
/** Enable dynamic sizing instead of snapPoints */
enableDynamicSizing?: boolean;
}
// ─── Component ───────────────────────────────────────────────────────
const CustomBottomSheetInner = forwardRef<CustomBottomSheetHandle, CustomBottomSheetProps>(
(
{
title,
snapPoints: snapPointsProp,
onDismiss,
scrollable = true,
headerLeft,
headerRight,
children,
enableDynamicSizing = false,
},
ref,
) => {
const theme = useTheme();
const s = makeStyles(theme);
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => snapPointsProp ?? ['60%'], [snapPointsProp]);
// Imperative handle
useImperativeHandle(ref, () => ({
present: () => {
triggerHaptic('impactMedium');
sheetRef.current?.snapToIndex(0);
},
dismiss: () => {
triggerHaptic('impactLight');
sheetRef.current?.close();
},
}));
// Backdrop with scrim
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.4}
pressBehavior="close"
/>
),
[],
);
// Handle indicator
const renderHandle = useCallback(
() => (
<View style={s.handleContainer}>
<View style={s.handle} />
</View>
),
[s],
);
const Wrapper = scrollable ? BottomSheetScrollView : BottomSheetView;
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={enableDynamicSizing ? undefined : snapPoints}
enableDynamicSizing={enableDynamicSizing}
enablePanDownToClose
onClose={onDismiss}
backdropComponent={renderBackdrop}
handleComponent={renderHandle}
backgroundStyle={s.background}
style={s.container}
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize">
{/* Sheet Header */}
{(title || headerLeft || headerRight) && (
<View style={s.header}>
{headerLeft ? (
<TouchableOpacity onPress={headerLeft.onPress} hitSlop={8}>
<Text style={s.headerLeftText}>{headerLeft.label}</Text>
</TouchableOpacity>
) : (
<View style={s.headerPlaceholder} />
)}
{title ? <Text style={s.headerTitle}>{title}</Text> : <View />}
{headerRight ? (
<TouchableOpacity onPress={headerRight.onPress} hitSlop={8}>
<Text
style={[
s.headerRightText,
headerRight.color ? {color: headerRight.color} : undefined,
]}>
{headerRight.label}
</Text>
</TouchableOpacity>
) : (
<View style={s.headerPlaceholder} />
)}
</View>
)}
{/* Sheet Body */}
<Wrapper
style={s.body}
contentContainerStyle={s.bodyContent}
showsVerticalScrollIndicator={false}>
{children}
</Wrapper>
</BottomSheet>
);
},
);
CustomBottomSheetInner.displayName = 'CustomBottomSheet';
export const CustomBottomSheet = CustomBottomSheetInner;
// ─── Styles ──────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, shape, spacing} = theme;
return StyleSheet.create({
container: {
zIndex: 999,
},
background: {
backgroundColor: colors.surfaceContainerLow,
borderTopLeftRadius: shape.extraLarge,
borderTopRightRadius: shape.extraLarge,
},
handleContainer: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
handle: {
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: colors.onSurfaceVariant,
opacity: 0.4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xl,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant,
},
headerTitle: {
...typography.titleMedium,
color: colors.onSurface,
fontWeight: '600',
},
headerLeftText: {
...typography.labelLarge,
color: colors.onSurfaceVariant,
},
headerRightText: {
...typography.labelLarge,
color: colors.primary,
fontWeight: '600',
},
headerPlaceholder: {
width: 48,
},
body: {
flex: 1,
},
bodyContent: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.xxxl + 20,
},
});
}
// ─── Convenience Sub-Components ──────────────────────────────────────
/**
* MD3-styled text input for use inside bottom sheets.
*/
export interface BottomSheetInputProps {
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
keyboardType?: 'default' | 'decimal-pad' | 'number-pad' | 'email-address';
error?: string;
multiline?: boolean;
prefix?: string;
autoFocus?: boolean;
}
import {TextInput} from 'react-native';
export const BottomSheetInput: React.FC<BottomSheetInputProps> = ({
label,
value,
onChangeText,
placeholder,
keyboardType = 'default',
error,
multiline = false,
prefix,
autoFocus = false,
}) => {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
const [focused, setFocused] = React.useState(false);
const borderColor = error
? colors.error
: focused
? colors.primary
: colors.outline;
return (
<View style={{marginBottom: spacing.lg}}>
<Text
style={{
...typography.bodySmall,
color: error ? colors.error : focused ? colors.primary : colors.onSurfaceVariant,
marginBottom: spacing.xs,
}}>
{label}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
borderWidth: focused ? 2 : 1,
borderColor,
borderRadius: shape.small,
backgroundColor: colors.surfaceContainerLowest,
paddingHorizontal: spacing.md,
minHeight: multiline ? 80 : 48,
}}>
{prefix && (
<Text
style={{
...typography.bodyLarge,
color: colors.onSurfaceVariant,
marginRight: spacing.xs,
}}>
{prefix}
</Text>
)}
<TextInput
style={{
flex: 1,
...typography.bodyLarge,
color: colors.onSurface,
paddingVertical: spacing.sm,
textAlignVertical: multiline ? 'top' : 'center',
}}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.onSurfaceVariant + '80'}
keyboardType={keyboardType}
multiline={multiline}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
</View>
{error && (
<Text
style={{
...typography.bodySmall,
color: colors.error,
marginTop: spacing.xs,
}}>
{error}
</Text>
)}
</View>
);
};
/**
* MD3 chip selector row for use inside bottom sheets.
*/
export interface BottomSheetChipSelectorProps<T extends string> {
label?: string;
options: {value: T; label: string; icon?: string}[];
selected: T;
onSelect: (value: T) => void;
}
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
export function BottomSheetChipSelector<T extends string>({
label,
options,
selected,
onSelect,
}: BottomSheetChipSelectorProps<T>) {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
return (
<View style={{marginBottom: spacing.lg}}>
{label && (
<Text
style={{
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.sm,
}}>
{label}
</Text>
)}
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm}}>
{options.map(opt => {
const isSelected = opt.value === selected;
return (
<Pressable
key={opt.value}
onPress={() => {
triggerHaptic('selection');
onSelect(opt.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: shape.small,
borderWidth: 1,
borderColor: isSelected ? colors.primary : colors.outlineVariant,
backgroundColor: isSelected
? colors.primaryContainer
: colors.surfaceContainerLowest,
}}>
{opt.icon && (
<Icon
name={opt.icon}
size={16}
color={isSelected ? colors.primary : colors.onSurfaceVariant}
style={{marginRight: spacing.xs}}
/>
)}
<Text
style={{
...typography.labelMedium,
color: isSelected ? colors.onPrimaryContainer : colors.onSurfaceVariant,
}}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}