This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.
allotment/packages/app/src/components/SelectSheet.tsx

142 lines
4.4 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { FONT, RADIUS, withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Button } from './Button';
import { Icon } from './Icon';
import { Input } from './Input';
import { Text } from './Text';
export interface SheetOption {
value: string;
label: string;
detail?: string;
leading?: ReactNode;
}
/**
* Native page sheet with a searchable list of options that toggle on tap,
* styled like the web client's multi-select dropdown.
*/
export function MultiSelectSheet({
visible,
title,
options,
selected,
onCancel,
onDone,
saving,
error,
emptyMessage = 'Nothing to choose from',
}: {
visible: boolean;
title: string;
options: SheetOption[];
selected: string[];
onCancel: () => void;
onDone: (values: string[]) => void;
saving?: boolean;
error?: string | null;
emptyMessage?: string;
}) {
const theme = useTheme();
const [values, setValues] = useState<string[]>(selected);
const [query, setQuery] = useState('');
useEffect(() => {
if (visible) {
setValues(selected);
setQuery('');
}
}, [visible, selected]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
}, [options, query]);
const toggle = (v: string) => setValues((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onCancel}>
<View style={[styles.sheet, { backgroundColor: theme.surface }]}>
<View style={styles.header}>
<Pressable onPress={onCancel} hitSlop={12}>
<Text style={{ color: theme.muted, fontFamily: FONT.medium }}>Cancel</Text>
</Pressable>
<Text variant="heading">{title}</Text>
<Button title="Save" variant="primary" small loading={saving} onPress={() => onDone(values)} />
</View>
<View style={{ paddingHorizontal: 20 }}>
<Input
placeholder="Search"
value={query}
onChangeText={setQuery}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
prefix={<Icon name="magnifyingglass" size={15} color={theme.muted} />}
/>
{error ? (
<Text variant="small" style={{ color: theme.danger, marginTop: 10 }}>
{error}
</Text>
) : null}
</View>
<ScrollView contentContainerStyle={styles.list} keyboardShouldPersistTaps="handled">
{filtered.length === 0 ? (
<Text variant="muted" style={{ textAlign: 'center', paddingVertical: 24 }}>
{options.length === 0 ? emptyMessage : 'No matches'}
</Text>
) : (
filtered.map((o) => {
const on = values.includes(o.value);
return (
<Pressable
key={o.value}
onPress={() => toggle(o.value)}
style={({ pressed }) => [
styles.option,
{
backgroundColor: on ? withAlpha(theme.accent, 0.18) : pressed ? theme.wash : 'transparent',
},
]}
>
{o.leading}
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: FONT.medium }}>{o.label}</Text>
{o.detail ? <Text variant="faint">{o.detail}</Text> : null}
</View>
{on ? <Icon name="checkmark" size={16} color={theme.accent} /> : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
sheet: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 16,
},
list: { padding: 14, paddingBottom: 48, gap: 4 },
option: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 12,
paddingVertical: 11,
borderRadius: RADIUS.option,
},
});