mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-11 08:48:33 -04:00
Add RobustPressable component (#1187)
This commit is contained in:
committed by
Leendert de Borst
parent
779d2a6b43
commit
a372348dbf
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
105
apps/mobile-app/components/ui/RobustPressable.tsx
Normal file
105
apps/mobile-app/components/ui/RobustPressable.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user