Files
carry-your-live/carry-your-live/src/components/CategorySelector.tsx
T
2026-08-23 11:36:17 +02:00

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