mirror of
https://github.com/arkorty/Expensso.git
synced 2026-03-18 00:47:11 +00:00
init
This commit is contained in:
433
src/components/CustomBottomSheet.tsx
Normal file
433
src/components/CustomBottomSheet.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user