/**
* CustomBottomSheet — replaces ALL system Alert/Modal patterns.
*
* Built on @gorhom/bottom-sheet with MD3 styling, drag-handle,
* scrim overlay, and smooth reanimated transitions.
*
* Usage:
*
*
*
*
* 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(
(
{
title,
snapPoints: snapPointsProp,
onDismiss,
scrollable = true,
headerLeft,
headerRight,
children,
enableDynamicSizing = false,
},
ref,
) => {
const theme = useTheme();
const s = makeStyles(theme);
const sheetRef = useRef(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) => (
),
[],
);
// Handle indicator
const renderHandle = useCallback(
() => (
),
[s],
);
const Wrapper = scrollable ? BottomSheetScrollView : BottomSheetView;
return (
{/* Sheet Header */}
{(title || headerLeft || headerRight) && (
{headerLeft ? (
{headerLeft.label}
) : (
)}
{title ? {title} : }
{headerRight ? (
{headerRight.label}
) : (
)}
)}
{/* Sheet Body */}
{children}
);
},
);
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 = ({
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 (
{label}
{prefix && (
{prefix}
)}
setFocused(true)}
onBlur={() => setFocused(false)}
/>
{error && (
{error}
)}
);
};
/**
* MD3 chip selector row for use inside bottom sheets.
*/
export interface BottomSheetChipSelectorProps {
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({
label,
options,
selected,
onSelect,
}: BottomSheetChipSelectorProps) {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
return (
{label && (
{label}
)}
{options.map(opt => {
const isSelected = opt.value === selected;
return (
{
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 && (
)}
{opt.label}
);
})}
);
}