142 lines
4.4 KiB
TypeScript
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,
|
|
},
|
|
});
|