freshyo/packages/ui/src/components/bottom-dropdown.tsx
2026-03-09 21:05:58 +05:30

272 lines
No EOL
8.5 KiB
TypeScript

import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Text, View, TouchableOpacity, ScrollView, TextInput } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { BottomDialog } from './dialog';
import MyText from './text';
import { useTheme } from '../../hooks/theme-context';
import tw from '../lib/tailwind';
import { colors } from '../lib/theme-colors';
export interface DropdownOption {
label: string;
value: string | number;
disabled?: boolean;
}
interface BottomDropdownProps {
label: string;
topLabel?: string;
value: string | number | string[] | number[];
options: DropdownOption[];
onValueChange: (value: string | number | string[] | number[]) => void;
multiple?: boolean;
error?: boolean;
style?: any;
placeholder?: string;
disabled?: boolean;
className?: string;
triggerComponent?: React.ComponentType<{
onPress: () => void;
disabled?: boolean;
displayText: string;
}>;
onSearch?: (query: string) => void;
testID?: string;
}
const BottomDropdown: React.FC<BottomDropdownProps> = ({
label,
topLabel,
value,
options,
onValueChange,
multiple = false,
error,
style,
placeholder,
disabled,
className,
triggerComponent,
onSearch,
testID,
}) => {
const { theme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const debounceTimeoutRef = useRef<number | null>(null);
// Debounced search function
const debouncedOnSearch = useCallback((query: string) => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
onSearch?.(query);
}, 2000);
}, [onSearch]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
// Filter options based on search query
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
);
const getDisplayText = () => {
if (multiple) {
const selectedValues = value as string[];
if (selectedValues.length === 0) return placeholder ?? label;
const selectedLabels = options
.filter(option => selectedValues.includes(option.value as string))
.map(option => option.label);
return selectedLabels.length > 0 ? selectedLabels.join(', ') : (placeholder ?? label);
} else {
const selectedOption = options.find(option => option.value === value);
return selectedOption ? selectedOption.label : (placeholder ?? label);
}
};
const handleSelect = (optionValue: string | number) => {
if (multiple) {
const currentValues = value as string[];
const newValues = currentValues.includes(optionValue as string)
? currentValues.filter(v => v !== optionValue)
: [...currentValues, optionValue as string];
onValueChange(newValues);
} else {
onValueChange(optionValue);
setIsOpen(false);
}
};
const handleDone = () => {
setIsOpen(false);
setSearchQuery(''); // Clear search when done
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = null;
}
};
const isSelected = (optionValue: string | number) => {
if (multiple) {
return (value as string[]).includes(optionValue as string);
} else {
return value === optionValue;
}
};
return (
<View style={[tw``, style]}>
{topLabel && (
<MyText
weight="medium"
style={{ color: theme.colors.gray4, fontSize: 14, marginBottom: 4 }}
>
{topLabel}
</MyText>
)}
{triggerComponent ? (
React.createElement(triggerComponent, {
onPress: () => !disabled && setIsOpen(true),
disabled,
displayText: getDisplayText(),
})
) : (
<TouchableOpacity
testID={testID}
accessibilityLabel={testID}
style={[
tw`border rounded-md px-3 py-2 flex-row items-center justify-between`,
error ? tw`border-red-500` : tw`border-gray-300`,
disabled && tw`bg-gray-100 border-gray-200`,
tw`${className || ''}`,
]}
onPress={() => !disabled && setIsOpen(true)}
disabled={disabled}
>
<Text
style={[
tw`flex-1`,
(multiple ? (value as string[]).length > 0 : options.some(opt => opt.value === value))
? tw`text-gray-900 font-medium`
: tw`text-gray-500`,
disabled && tw`text-gray-400`,
]}
numberOfLines={1}
>
{getDisplayText()}
</Text>
<Ionicons
name="chevron-down"
size={16}
color={disabled ? colors.gray4 : colors.gray5}
style={tw`ml-2`}
/>
</TouchableOpacity>
)}
<BottomDialog open={isOpen} onClose={() => {
setIsOpen(false);
setSearchQuery(''); // Clear search when closed
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = null;
}
}}>
<View style={tw`py-4`}>
<Text style={tw`text-lg font-semibold mb-4 text-center`}>{label}</Text>
{/* Search Input - Fixed at top */}
{onSearch && (
<View style={tw`px-4 pb-2 border-b border-gray-200 mb-2`}>
<TextInput
testID={`${testID}-search`}
style={tw`border border-gray-300 rounded-md px-3 py-2 text-base`}
placeholder="Search options..."
value={searchQuery}
onChangeText={(text) => {
setSearchQuery(text);
debouncedOnSearch(text);
}}
autoFocus={true}
clearButtonMode="while-editing"
/>
</View>
)}
<ScrollView style={tw`max-h-80`}>
{filteredOptions.length === 0 && searchQuery ? (
<View style={tw`py-8 px-4 items-center`}>
<Text style={tw`text-gray-500 text-center`}>
No options found for "{searchQuery}"
</Text>
</View>
) : (
filteredOptions.map((option) => {
const selected = isSelected(option.value);
const isDisabled = option.disabled || disabled;
return (
<TouchableOpacity
key={option.value}
style={[
tw`px-4 py-3 rounded-md my-1 mx-2 flex-row items-center justify-between`,
selected ? { backgroundColor: colors.brand50 } : tw`bg-transparent`,
isDisabled && tw`opacity-50`,
]}
onPress={() => !isDisabled && handleSelect(option.value)}
disabled={isDisabled}
>
<Text
style={[
selected ? tw`text-brand700 font-semibold` : tw`text-gray-800`,
isDisabled && tw`text-gray-400`,
]}
>
{(() => {
const labelStr = option.label as string;
const [code, rest] = labelStr.split(' - ', 2);
return (
<>
<Text style={tw`font-bold`}>{code}</Text>
{rest ? ` - ${rest}` : ''}
</>
);
})()}
</Text>
{selected && (
<Ionicons name="checkmark" size={20} color={colors.brand500} />
)}
</TouchableOpacity>
);
})
)}
</ScrollView>
{multiple && (
<View style={tw`flex-row justify-between mt-4 px-4`}>
<TouchableOpacity
testID={`${testID}-done`}
accessibilityLabel={`${testID}-done`}
style={[tw`px-4 py-2 rounded-md flex-1 mr-2`, { backgroundColor: colors.brand500 }]}
onPress={handleDone}
>
<Text style={tw`text-white text-center font-medium`}>Done</Text>
</TouchableOpacity>
</View>
)}
</View>
</BottomDialog>
</View>
);
};
export default BottomDropdown;