orca/mobile/src/components/PickerModal.tsx

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
}
})