172 lines
4.2 KiB
TypeScript
172 lines
4.2 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
|
import { Check } from 'lucide-react-native'
|
|
import { colors, spacing, typography } from '../theme/mobile-theme'
|
|
import { BottomDrawer } from './BottomDrawer'
|
|
|
|
export type PickerOption<T extends string = string> = {
|
|
value: T
|
|
label: string
|
|
subtitle?: string
|
|
disabled?: boolean
|
|
renderIcon?: (selected: boolean) => ReactNode
|
|
}
|
|
|
|
type Props<T extends string = string> = {
|
|
visible: boolean
|
|
title: string
|
|
options: PickerOption<T>[]
|
|
selected: T
|
|
onSelect: (value: T) => void
|
|
onLongSelect?: (value: T) => void
|
|
onClose: () => void
|
|
zIndex?: number
|
|
}
|
|
|
|
type PickerModalContentProps<T extends string = string> = Pick<
|
|
Props<T>,
|
|
'options' | 'selected' | 'onSelect' | 'onLongSelect' | 'onClose'
|
|
>
|
|
|
|
export function PickerModal<T extends string = string>({
|
|
visible,
|
|
title,
|
|
options,
|
|
selected,
|
|
onSelect,
|
|
onLongSelect,
|
|
onClose,
|
|
zIndex
|
|
}: Props<T>) {
|
|
return (
|
|
<BottomDrawer visible={visible} onClose={onClose} zIndex={zIndex}>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>{title}</Text>
|
|
</View>
|
|
|
|
<PickerModalContent
|
|
options={options}
|
|
selected={selected}
|
|
onSelect={onSelect}
|
|
onLongSelect={onLongSelect}
|
|
onClose={onClose}
|
|
/>
|
|
</BottomDrawer>
|
|
)
|
|
}
|
|
|
|
function PickerModalContent<T extends string = string>({
|
|
options,
|
|
selected,
|
|
onSelect,
|
|
onLongSelect,
|
|
onClose
|
|
}: PickerModalContentProps<T>) {
|
|
// Why: closed BottomDrawer instances return null, so keeping option rows in
|
|
// this child avoids rebuilding hidden picker contents on every parent render.
|
|
return (
|
|
<View style={styles.group}>
|
|
{options.map((opt, i) => {
|
|
const isSelected = opt.value === selected
|
|
return (
|
|
<View key={opt.value}>
|
|
{i > 0 && <View style={styles.separator} />}
|
|
<Pressable
|
|
disabled={opt.disabled}
|
|
style={({ pressed }) => [
|
|
styles.row,
|
|
pressed && !opt.disabled && styles.rowPressed,
|
|
opt.disabled && styles.rowDisabled
|
|
]}
|
|
onPress={() => {
|
|
if (opt.disabled) {
|
|
return
|
|
}
|
|
onSelect(opt.value)
|
|
onClose()
|
|
}}
|
|
onLongPress={
|
|
onLongSelect
|
|
? () => {
|
|
if (opt.disabled) {
|
|
return
|
|
}
|
|
onLongSelect(opt.value)
|
|
onClose()
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{opt.renderIcon ? (
|
|
<View style={styles.rowIcon}>{opt.renderIcon(isSelected)}</View>
|
|
) : null}
|
|
<View style={styles.rowContent}>
|
|
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
|
|
{opt.label}
|
|
</Text>
|
|
{opt.subtitle ? <Text style={styles.rowSubtitle}>{opt.subtitle}</Text> : null}
|
|
</View>
|
|
{isSelected && <Check size={16} color={colors.textPrimary} />}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
header: {
|
|
paddingHorizontal: spacing.xs,
|
|
paddingBottom: spacing.sm
|
|
},
|
|
title: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
color: colors.textMuted
|
|
},
|
|
group: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12,
|
|
overflow: 'hidden'
|
|
},
|
|
separator: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginHorizontal: spacing.md
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.md,
|
|
paddingHorizontal: spacing.md + 2
|
|
},
|
|
rowPressed: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
rowDisabled: {
|
|
opacity: 0.45
|
|
},
|
|
rowContent: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
rowIcon: {
|
|
width: 22,
|
|
alignItems: 'center',
|
|
marginRight: spacing.sm
|
|
},
|
|
rowLabel: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
rowLabelSelected: {
|
|
fontWeight: '600'
|
|
},
|
|
rowSubtitle: {
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
marginTop: 1
|
|
}
|
|
})
|