Files
beenvoice-app/components/ui/SelectField.tsx
T
soconnor 32ffe782ea Fix Live Activity lock screen rendering and polish multi-account auth.
Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:23:36 -04:00

219 lines
5.7 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export type SelectOption = {
label: string;
value: string;
};
type SelectFieldProps = {
label: string;
placeholder: string;
value: string;
options: SelectOption[];
disabled?: boolean;
required?: boolean;
error?: string;
onValueChange: (value: string) => void;
};
export function SelectField({
label,
placeholder,
value,
options,
disabled,
required,
error,
onValueChange,
}: SelectFieldProps) {
const { colors } = useAppTheme();
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={() => setOpen(true)}
style={({ pressed }) => [
styles.trigger,
{
borderColor: error ? colors.destructive : colors.borderGlass,
backgroundColor: colors.cardGlass,
},
disabled && styles.triggerDisabled,
pressed && !disabled && styles.triggerPressed,
]}
>
<Text
style={[
styles.triggerText,
{ color: colors.foreground },
!selected && { color: colors.mutedForeground },
]}
numberOfLines={1}
>
{selected?.label ?? placeholder}
</Text>
<Ionicons name="chevron-down" size={18} color={colors.mutedForeground} />
</Pressable>
<Modal
animationType="slide"
onRequestClose={() => setOpen(false)}
transparent
visible={open}
>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.background }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{options.map((option) => {
const isSelected = option.value === value;
return (
<Pressable
key={option.value || "__empty__"}
accessibilityRole="button"
onPress={() => {
onValueChange(option.value);
setOpen(false);
}}
style={({ pressed }) => [
styles.option,
isSelected && { backgroundColor: colors.muted },
pressed && styles.optionPressed,
]}
>
<Text
style={[
styles.optionText,
{ color: colors.foreground },
isSelected && styles.optionTextSelected,
]}
numberOfLines={2}
>
{option.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
trigger: {
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
triggerDisabled: {
opacity: 0.55,
},
triggerPressed: {
opacity: 0.92,
},
triggerText: {
flex: 1,
fontSize: 14,
fontFamily: fonts.body,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.45)",
},
sheet: {
maxHeight: "70%",
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
paddingBottom: spacing.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sheetTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
},
done: {
fontSize: 15,
fontFamily: fonts.bodyMedium,
},
option: {
minHeight: 48,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
optionPressed: {
opacity: 0.9,
},
optionText: {
flex: 1,
fontSize: 15,
fontFamily: fonts.body,
},
optionTextSelected: {
fontFamily: fonts.bodySemiBold,
},
});