Add RobustPressable component (#1187)

This commit is contained in:
Leendert de Borst
2025-09-09 23:46:15 +02:00
committed by Leendert de Borst
parent 779d2a6b43
commit a372348dbf
2 changed files with 156 additions and 46 deletions

View File

@@ -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: () => (
<TouchableOpacity
<RobustPressable
onPress={() => router.back()}
style={styles.headerLeftButton}
accessibilityRole="button"
accessibilityLabel="Cancel"
>
<ThemedText style={styles.headerLeftButtonText}>{t('common.cancel')}</ThemedText>
</TouchableOpacity>
</RobustPressable>
),
/**
* Header right button.
*/
headerRight: () => (
<TouchableOpacity
onPress={handleSubmit(onSubmit)}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
disabled={isSaveDisabled}
>
<MaterialIcons name="save" size={22} color={colors.primary} />
</TouchableOpacity>
),
});
} else {
navigation.setOptions({
/**
* Header right button.
*/
headerRight: () => (
<Pressable
onPressIn={handleSubmit(onSubmit)}
android_ripple={{ color: 'lightgray' }}
pressRetentionOffset={100}
hitSlop={100}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
disabled={isSaveDisabled}
>
<MaterialIcons name="save" size={24} color={colors.primary} />
</Pressable>
),
});
}
}),
/**
* Header right button.
*/
headerRight: () => (
<RobustPressable
onPress={handleSubmit(onSubmit)}
style={[styles.headerRightButton, isSaveDisabled && styles.headerRightButtonDisabled]}
disabled={isSaveDisabled}
accessibilityRole="button"
accessibilityLabel="Save credential"
>
<MaterialIcons
name="save"
size={Platform.OS === 'android' ? 24 : 22}
color={colors.primary}
/>
</RobustPressable>
),
});
}, [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 && (
<View style={styles.modeSelector}>
<TouchableOpacity
<RobustPressable
style={[styles.modeButton, mode === 'random' && styles.modeButtonActive]}
onPress={() => setMode('random')}
accessibilityRole="button"
accessibilityState={{ selected: mode === 'random' }}
>
<MaterialIcons
name="auto-fix-high"
@@ -614,10 +610,12 @@ export default function AddEditCredentialScreen() : React.ReactNode {
<ThemedText style={[styles.modeButtonText, mode === 'random' && styles.modeButtonTextActive]}>
{t('credentials.randomAlias')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
</RobustPressable>
<RobustPressable
style={[styles.modeButton, mode === 'manual' && styles.modeButtonActive]}
onPress={() => setMode('manual')}
accessibilityRole="button"
accessibilityState={{ selected: mode === 'manual' }}
>
<MaterialIcons
name="person"
@@ -627,7 +625,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
<ThemedText style={[styles.modeButtonText, mode === 'manual' && styles.modeButtonTextActive]}>
{t('credentials.manual')}
</ThemedText>
</TouchableOpacity>
</RobustPressable>
</View>
)}
@@ -702,10 +700,15 @@ export default function AddEditCredentialScreen() : React.ReactNode {
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>{t('credentials.alias')}</ThemedText>
<TouchableOpacity style={styles.generateButton} onPress={handleGenerateRandomAlias}>
<RobustPressable
style={styles.generateButton}
onPress={handleGenerateRandomAlias}
accessibilityRole="button"
accessibilityLabel="Generate random alias"
>
<MaterialIcons name="auto-fix-high" size={20} color="#fff" />
<ThemedText style={styles.generateButtonText}>{t('credentials.generateRandomAlias')}</ThemedText>
</TouchableOpacity>
</RobustPressable>
<ValidatedFormField
control={control}
name="Alias.FirstName"
@@ -758,12 +761,14 @@ export default function AddEditCredentialScreen() : React.ReactNode {
</View>
{isEditMode && (
<TouchableOpacity
<RobustPressable
style={styles.deleteButton}
onPress={handleDelete}
accessibilityRole="button"
accessibilityLabel="Delete credential"
>
<ThemedText style={styles.deleteButtonText}>{t('credentials.deleteCredential')}</ThemedText>
</TouchableOpacity>
</RobustPressable>
)}
</>
)}

View File

@@ -0,0 +1,105 @@
import React, { useRef, useCallback } from 'react';
import { Pressable, PressableProps, GestureResponderEvent, Platform } from 'react-native';
interface IRobustPressableProps extends Omit<PressableProps, 'style'> {
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<IRobustPressableProps> = ({
onPress,
onPressIn,
onPressOut,
hitSlop,
pressRetentionOffset,
delayLongPress,
disabled,
activeOpacity = 0.7,
style,
...props
}) => {
const pressStartTime = useRef<number>(0);
const pressStartLocation = useRef<{ x: number; y: number } | null>(null);
const isPressing = useRef<boolean>(false);
const hasMoved = useRef<boolean>(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 (
<Pressable
{...props}
style={({ pressed, hovered }) => [
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}
/>
);
};