265 lines
8.2 KiB
TypeScript
265 lines
8.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
|
import { useCategories } from '@/hooks/useDatabase';
|
|
import { useSettings } from '@/theme';
|
|
import Svg, { Path } from 'react-native-svg';
|
|
|
|
interface CategorySelectorProps {
|
|
value: string[];
|
|
onChange: (value: string[]) => void;
|
|
error?: string;
|
|
}
|
|
|
|
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
|
|
const categories = useCategories();
|
|
const { theme } = useSettings();
|
|
const [showModal, setShowModal] = useState(false);
|
|
|
|
const selected = new Set(value);
|
|
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
|
|
|
const toggle = (id: string) => {
|
|
const next = new Set(selected);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
onChange(Array.from(next));
|
|
};
|
|
|
|
const summary =
|
|
selectedCategories.length === 0
|
|
? 'None'
|
|
: selectedCategories
|
|
.slice(0, 2)
|
|
.map((c) => c.name)
|
|
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Text style={[styles.label, { color: theme.text }]}>Tags</Text>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.selectorButton,
|
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
|
]}
|
|
onPress={() => setShowModal(true)}
|
|
activeOpacity={0.8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Select tags"
|
|
accessibilityHint="Opens a list of tags to choose from"
|
|
>
|
|
<View style={styles.selectorContent}>
|
|
{selectedCategories.length > 0 ? (
|
|
<View style={styles.chipRow}>
|
|
{selectedCategories.slice(0, 3).map((c) => (
|
|
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
|
|
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
|
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
|
|
)}
|
|
</View>
|
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
</Svg>
|
|
</TouchableOpacity>
|
|
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
|
|
|
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
|
<KeyboardAvoidingView
|
|
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
|
behavior="padding"
|
|
>
|
|
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
|
|
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
|
<View style={styles.modalHeaderRight}>
|
|
<TouchableOpacity
|
|
onPress={() => { onChange([]); setShowModal(false); }}
|
|
activeOpacity={0.7}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Clear all tags"
|
|
>
|
|
<Text style={[styles.clearText, { color: theme.textMuted }]}>Clear</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => setShowModal(false)}
|
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Done selecting tags"
|
|
>
|
|
<Text style={[styles.doneText, { color: theme.accent }]}>Done</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
<ScrollView contentContainerStyle={styles.modalContent}>
|
|
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
|
A task can have multiple tags. Selected: {selected.size}
|
|
</Text>
|
|
{categories.map((category) => {
|
|
const isSelected = selected.has(category.id);
|
|
return (
|
|
<TouchableOpacity
|
|
key={category.id}
|
|
style={[
|
|
styles.modalOption,
|
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
|
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
|
]}
|
|
onPress={() => toggle(category.id)}
|
|
activeOpacity={0.8}
|
|
accessibilityRole="checkbox"
|
|
accessibilityLabel={`Tag ${category.name}`}
|
|
accessibilityState={{ checked: isSelected }}
|
|
>
|
|
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
|
<Text style={[
|
|
styles.categoryName,
|
|
{ color: theme.textSecondary },
|
|
isSelected && { color: theme.text, fontWeight: '600' },
|
|
]}>
|
|
{category.name}
|
|
</Text>
|
|
{isSelected && (
|
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
</Svg>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</Pressable>
|
|
</Pressable>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
gap: 6,
|
|
},
|
|
label: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
selectorButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
minHeight: 52,
|
|
},
|
|
selectorContent: {
|
|
flex: 1,
|
|
paddingRight: 8,
|
|
},
|
|
chipRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 6,
|
|
},
|
|
chip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
},
|
|
chipText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
},
|
|
colorCircle: {
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: 6,
|
|
},
|
|
colorCircleSelected: {
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: 7,
|
|
},
|
|
selectorValue: {
|
|
fontSize: 15,
|
|
fontWeight: '500',
|
|
},
|
|
errorText: {
|
|
fontSize: 12,
|
|
marginLeft: 4,
|
|
},
|
|
modalOverlay: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: 24,
|
|
},
|
|
modalSheet: {
|
|
width: '100%',
|
|
maxWidth: 400,
|
|
borderRadius: 20,
|
|
overflow: 'hidden',
|
|
maxHeight: '80%',
|
|
},
|
|
modalHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: 20,
|
|
borderBottomWidth: 1,
|
|
},
|
|
modalHeaderRight: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
},
|
|
modalTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
},
|
|
clearText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
doneText: {
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
},
|
|
modalHint: {
|
|
fontSize: 12,
|
|
paddingHorizontal: 4,
|
|
paddingBottom: 4,
|
|
},
|
|
modalContent: {
|
|
padding: 12,
|
|
gap: 8,
|
|
},
|
|
modalOption: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
},
|
|
categoryName: {
|
|
fontSize: 15,
|
|
fontWeight: '500',
|
|
flex: 1,
|
|
},
|
|
}); |