diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index 0bda4dc3f..d69ff368f 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -5,7 +5,7 @@ import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-rout import { useState, useEffect, useRef, useCallback } from 'react'; import { Resolver, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View, TouchableOpacity, Alert, Keyboard, KeyboardAvoidingView, Platform, Pressable } from 'react-native'; +import { StyleSheet, View, Alert, Keyboard, KeyboardAvoidingView, Platform } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import Toast from 'react-native-toast-message'; @@ -28,6 +28,7 @@ import LoadingOverlay from '@/components/LoadingOverlay'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedText } from '@/components/themed/ThemedText'; import { AliasVaultToast } from '@/components/Toast'; +import { RobustPressable } from '@/components/ui/RobustPressable'; import { useAuth } from '@/context/AuthContext'; import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; @@ -536,51 +537,44 @@ export default function AddEditCredentialScreen() : React.ReactNode { // Set header buttons useEffect(() => { - if (Platform.OS === 'ios') { - navigation.setOptions({ + navigation.setOptions({ + /** + * Header left button (iOS only). + */ + ...(Platform.OS === 'ios' && { /** - * Header left button. + * */ headerLeft: () => ( - router.back()} style={styles.headerLeftButton} + accessibilityRole="button" + accessibilityLabel="Cancel" > {t('common.cancel')} - + ), - /** - * Header right button. - */ - headerRight: () => ( - - - - ), - }); - } else { - navigation.setOptions({ - /** - * Header right button. - */ - headerRight: () => ( - - - - ), - }); - } + }), + /** + * Header right button. + */ + headerRight: () => ( + + + + ), + }); }, [navigation, mode, handleSubmit, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerLeftButtonText, styles.headerRightButton, styles.headerRightButtonDisabled, isSaveDisabled, t]); return ( @@ -602,9 +596,11 @@ export default function AddEditCredentialScreen() : React.ReactNode { > {!isEditMode && ( - setMode('random')} + accessibilityRole="button" + accessibilityState={{ selected: mode === 'random' }} > {t('credentials.randomAlias')} - - + setMode('manual')} + accessibilityRole="button" + accessibilityState={{ selected: mode === 'manual' }} > {t('credentials.manual')} - + )} @@ -702,10 +700,15 @@ export default function AddEditCredentialScreen() : React.ReactNode { {t('credentials.alias')} - + {t('credentials.generateRandomAlias')} - + {isEditMode && ( - {t('credentials.deleteCredential')} - + )} )} diff --git a/apps/mobile-app/components/ui/RobustPressable.tsx b/apps/mobile-app/components/ui/RobustPressable.tsx new file mode 100644 index 000000000..eea60ddab --- /dev/null +++ b/apps/mobile-app/components/ui/RobustPressable.tsx @@ -0,0 +1,105 @@ +import React, { useRef, useCallback } from 'react'; +import { Pressable, PressableProps, GestureResponderEvent, Platform } from 'react-native'; + +interface IRobustPressableProps extends Omit { + onPress?: (event: GestureResponderEvent) => void; + children: React.ReactNode; + activeOpacity?: number; + style?: PressableProps['style']; +} + +/** + * A more robust Pressable component that better handles Magic Keyboard trackpad interactions. + * This component ensures clicks register even when the cursor is moving slightly during tap, + * while maintaining TouchableOpacity-like activeOpacity behavior. + */ +export const RobustPressable: React.FC = ({ + onPress, + onPressIn, + onPressOut, + hitSlop, + pressRetentionOffset, + delayLongPress, + disabled, + activeOpacity = 0.7, + style, + ...props +}) => { + const pressStartTime = useRef(0); + const pressStartLocation = useRef<{ x: number; y: number } | null>(null); + const isPressing = useRef(false); + const hasMoved = useRef(false); + + const handlePressIn = useCallback((event: GestureResponderEvent) => { + pressStartTime.current = Date.now(); + pressStartLocation.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY + }; + isPressing.current = true; + hasMoved.current = false; + + onPressIn?.(event); + }, [onPressIn]); + + const handlePressOut = useCallback((event: GestureResponderEvent) => { + const pressDuration = Date.now() - pressStartTime.current; + + // Check if the press moved too much (for trackpad detection) + if (pressStartLocation.current) { + const moveDistance = Math.sqrt( + Math.pow(event.nativeEvent.pageX - pressStartLocation.current.x, 2) + + Math.pow(event.nativeEvent.pageY - pressStartLocation.current.y, 2) + ); + + // Allow up to 15 pixels of movement for trackpad taps + if (moveDistance > 15) { + hasMoved.current = true; + } + } + + /** + * For iPad with trackpad/mouse: be more lenient with tap detection + * Accept taps up to 600ms and allow small movements + */ + if (isPressing.current && pressDuration < 600 && !hasMoved.current) { + onPress?.(event); + } + + isPressing.current = false; + pressStartLocation.current = null; + onPressOut?.(event); + }, [onPress, onPressOut]); + + // Increase hit slop for better trackpad interaction + const enhancedHitSlop = Platform.select({ + ios: hitSlop ?? { top: 15, bottom: 15, left: 15, right: 15 }, + default: hitSlop ?? { top: 12, bottom: 12, left: 12, right: 12 } + }); + + // Increase press retention offset to handle cursor movement during tap + const enhancedPressRetentionOffset = pressRetentionOffset ?? { + top: 25, + bottom: 25, + left: 25, + right: 25 + }; + + return ( + [ + typeof style === 'function' ? style({ pressed, hovered }) : style, + pressed && { opacity: activeOpacity }, + hovered && Platform.OS !== 'ios' && { opacity: Math.max(activeOpacity, 0.8) } + ]} + onPress={undefined} // We handle onPress in onPressOut for better trackpad support + onPressIn={handlePressIn} + onPressOut={handlePressOut} + hitSlop={enhancedHitSlop} + pressRetentionOffset={enhancedPressRetentionOffset} + delayLongPress={delayLongPress ?? 500} + disabled={disabled} + /> + ); +};