refactor: replace inline password inputs with reusable PasswordInput component

Consolidates duplicate password input implementations across login and settings screens into a single reusable component.

Improves code maintainability by eliminating redundant password visibility toggle logic and standardizing password input behavior throughout the application.

Adds consistent accessibility support and test identifiers across all password input instances.
This commit is contained in:
Uruk
2025-09-01 15:48:55 +02:00
parent 87eff6f80c
commit 3919bb346f
3 changed files with 148 additions and 87 deletions

View File

@@ -20,6 +20,7 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PasswordInput } from "@/components/PasswordInput";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
@@ -276,33 +277,20 @@ const Login: React.FC = () => {
/>
{/* Password */}
<View className='relative mb-4'>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry={!showPassword}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
className='pr-12'
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className='absolute right-3 top-4 p-1'
>
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={24}
color='white'
/>
</TouchableOpacity>
</View>
<PasswordInput
value={credentials.password}
onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text })
}
onSubmitEditing={handleLogin}
placeholder={t("login.password_placeholder")}
className='mb-4'
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
topPosition='4'
testID='tv-password-input'
accessibilityLabel={t("login.password_placeholder")}
/>
<View className='mt-4'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
@@ -417,33 +405,18 @@ const Login: React.FC = () => {
maxLength={500}
/>
<View className='relative'>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry={!showPassword}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
className='pr-12'
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className='absolute right-3 top-3.5 p-1'
>
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={24}
color='white'
/>
</TouchableOpacity>
</View>
<PasswordInput
value={credentials.password}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
placeholder={t("login.password_placeholder")}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
topPosition='3.5'
testID='mobile-password-input'
accessibilityLabel={t("login.password_placeholder")}
/>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}

View File

@@ -0,0 +1,101 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { Input } from "./common/Input";
interface PasswordInputProps {
value?: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
placeholder: string;
className?: string;
testID?: string;
accessibilityLabel?: string;
maxLength?: number;
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
topPosition?: "3.5" | "4";
editable?: boolean;
autoComplete?:
| "password"
| "username"
| "name"
| "email"
| "tel"
| "url"
| "off";
autoCorrect?: boolean;
}
export const PasswordInput: React.FC<PasswordInputProps> = ({
value = "",
onChangeText,
onSubmitEditing,
placeholder,
className = "",
testID,
accessibilityLabel,
maxLength = 500,
showPassword: controlledShowPassword,
onShowPasswordChange,
topPosition = "3.5",
editable = true,
autoComplete,
autoCorrect,
}) => {
const { t } = useTranslation();
const [internalShowPassword, setInternalShowPassword] = useState(false);
// Use controlled state if provided, otherwise use internal state
const showPassword = controlledShowPassword ?? internalShowPassword;
const setShowPassword = onShowPasswordChange ?? setInternalShowPassword;
const topClass = topPosition === "4" ? "top-4" : "top-3.5";
return (
<View className={`relative ${className}`}>
<Input
placeholder={placeholder}
onChangeText={onChangeText}
value={value}
secureTextEntry={!showPassword}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={maxLength}
className='pr-12'
onSubmitEditing={onSubmitEditing}
testID={testID}
accessibilityLabel={accessibilityLabel}
editable={editable}
autoComplete={autoComplete}
autoCorrect={autoCorrect}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className={`absolute right-3 ${topClass} p-1`}
accessible={true}
accessibilityRole='button'
accessibilityLabel={
showPassword
? t("accessibility.hide_password")
: t("accessibility.show_password")
}
accessibilityHint={t("accessibility.toggle_password_visibility")}
accessibilityState={{ selected: showPassword }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
testID={testID ? `${testID}-toggle` : undefined}
>
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={24}
color='white'
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,10 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import { useMutation } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { View } from "react-native";
import { toast } from "sonner-native";
import { PasswordInput } from "@/components/PasswordInput";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -150,37 +150,24 @@ export const JellyseerrSettings = () => {
<Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<View className='relative'>
<Input
className='border border-neutral-800 pr-12'
autoFocus={true}
focusable={true}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
value={jellyseerrPassword}
keyboardType='default'
secureTextEntry={!showJellyseerrPassword}
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
onChangeText={setJellyseerrPassword}
editable={!loginToJellyseerrMutation.isPending}
/>
<TouchableOpacity
onPress={() =>
setShowJellyseerrPassword(!showJellyseerrPassword)
}
className='absolute right-3 top-3.5 p-1'
>
<Ionicons
name={showJellyseerrPassword ? "eye-off" : "eye"}
size={24}
color='white'
/>
</TouchableOpacity>
</View>
<PasswordInput
value={jellyseerrPassword}
onChangeText={setJellyseerrPassword}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
className='border border-neutral-800'
showPassword={showJellyseerrPassword}
onShowPasswordChange={setShowJellyseerrPassword}
editable={!loginToJellyseerrMutation.isPending}
autoComplete='password'
autoCorrect={false}
testID='jellyseerr-password-input'
accessibilityLabel={t(
"home.settings.plugins.jellyseerr.password",
)}
/>
<Button
loading={loginToJellyseerrMutation.isPending}
disabled={loginToJellyseerrMutation.isPending}