Files
RN_Template/RN_TEMPLATE/app/components/TextField.tsx

293 lines
8.0 KiB
TypeScript
Raw Normal View History

2026-02-05 13:16:05 +08:00
import { ComponentType, forwardRef, Ref, useImperativeHandle, useRef } from "react"
import {
ImageStyle,
StyleProp,
// eslint-disable-next-line no-restricted-imports
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
} from "react-native"
import { useTranslation } from "react-i18next"
import { isRTL } from "@/i18n"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
import { s, fs } from "@/utils/responsive"
import { Text, TextProps } from "./Text"
export interface TextFieldAccessoryProps {
style: StyleProp<ViewStyle | TextStyle | ImageStyle>
status: TextFieldProps["status"]
multiline: boolean
editable: boolean
}
export interface TextFieldProps extends Omit<TextInputProps, "ref"> {
/**
* A style modifier for different input states.
*/
status?: "error" | "disabled"
/**
* The label text to display if not using `labelTx`.
*/
label?: TextProps["text"]
/**
* Label text which is looked up via i18n.
*/
labelTx?: TextProps["tx"]
/**
* Optional label options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
labelTxOptions?: TextProps["txOptions"]
/**
* Pass any additional props directly to the label Text component.
*/
LabelTextProps?: TextProps
/**
* The helper text to display if not using `helperTx`.
*/
helper?: TextProps["text"]
/**
* Helper text which is looked up via i18n.
*/
helperTx?: TextProps["tx"]
/**
* Optional helper options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
helperTxOptions?: TextProps["txOptions"]
/**
* Pass any additional props directly to the helper Text component.
*/
HelperTextProps?: TextProps
/**
* The placeholder text to display if not using `placeholderTx`.
*/
placeholder?: TextProps["text"]
/**
* Placeholder text which is looked up via i18n.
*/
placeholderTx?: TextProps["tx"]
/**
* Optional placeholder options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
placeholderTxOptions?: TextProps["txOptions"]
/**
* Optional input style override.
*/
style?: StyleProp<TextStyle>
/**
* Style overrides for the container
*/
containerStyle?: StyleProp<ViewStyle>
/**
* Style overrides for the input wrapper
*/
inputWrapperStyle?: StyleProp<ViewStyle>
/**
* An optional component to render on the right side of the input.
* Example: `RightAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
* Note: It is a good idea to memoize this.
*/
RightAccessory?: ComponentType<TextFieldAccessoryProps>
/**
* An optional component to render on the left side of the input.
* Example: `LeftAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
* Note: It is a good idea to memoize this.
*/
LeftAccessory?: ComponentType<TextFieldAccessoryProps>
}
/**
* A component that allows for the entering and editing of text.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/TextField/}
* @param {TextFieldProps} props - The props for the `TextField` component.
* @returns {JSX.Element} The rendered `TextField` component.
*/
export const TextField = forwardRef(function TextField(props: TextFieldProps, ref: Ref<TextInput>) {
const {
labelTx,
label,
labelTxOptions,
placeholderTx,
placeholder,
placeholderTxOptions,
helper,
helperTx,
helperTxOptions,
status,
RightAccessory,
LeftAccessory,
HelperTextProps,
LabelTextProps,
style: $inputStyleOverride,
containerStyle: $containerStyleOverride,
inputWrapperStyle: $inputWrapperStyleOverride,
...TextInputProps
} = props
const input = useRef<TextInput>(null)
const { t } = useTranslation()
const {
themed,
theme: { colors },
} = useAppTheme()
const disabled = TextInputProps.editable === false || status === "disabled"
const placeholderContent = placeholderTx ? t(placeholderTx, placeholderTxOptions) : placeholder
const $containerStyles = [$containerStyleOverride]
const $labelStyles = [$labelStyle, LabelTextProps?.style]
const $inputWrapperStyles = [
$styles.row,
$inputWrapperStyle,
status === "error" && { borderColor: colors.error },
TextInputProps.multiline && { minHeight: s(112) },
LeftAccessory && { paddingStart: 0 },
RightAccessory && { paddingEnd: 0 },
$inputWrapperStyleOverride,
]
const $inputStyles: ThemedStyleArray<TextStyle> = [
$inputStyle,
disabled && { color: colors.textDim },
isRTL && { textAlign: "right" as TextStyle["textAlign"] },
TextInputProps.multiline && { height: "auto" },
$inputStyleOverride,
]
const $helperStyles = [
$helperStyle,
status === "error" && { color: colors.error },
HelperTextProps?.style,
]
/**
*
*/
function focusInput() {
if (disabled) return
input.current?.focus()
}
useImperativeHandle(ref, () => input.current as TextInput)
return (
<TouchableOpacity
activeOpacity={1}
style={$containerStyles}
onPress={focusInput}
accessibilityState={{ disabled }}
>
{!!(label || labelTx) && (
<Text
preset="formLabel"
text={label}
tx={labelTx}
txOptions={labelTxOptions}
{...LabelTextProps}
style={themed($labelStyles)}
/>
)}
<View style={themed($inputWrapperStyles)}>
{!!LeftAccessory && (
<LeftAccessory
style={themed($leftAccessoryStyle)}
status={status}
editable={!disabled}
multiline={TextInputProps.multiline ?? false}
/>
)}
<TextInput
ref={input}
underlineColorAndroid={colors.transparent}
textAlignVertical="top"
placeholder={placeholderContent}
placeholderTextColor={colors.textDim}
{...TextInputProps}
editable={!disabled}
style={themed($inputStyles)}
/>
{!!RightAccessory && (
<RightAccessory
style={themed($rightAccessoryStyle)}
status={status}
editable={!disabled}
multiline={TextInputProps.multiline ?? false}
/>
)}
</View>
{!!(helper || helperTx) && (
<Text
preset="formHelper"
text={helper}
tx={helperTx}
txOptions={helperTxOptions}
{...HelperTextProps}
style={themed($helperStyles)}
/>
)}
</TouchableOpacity>
)
})
const $labelStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.xs,
})
const $inputWrapperStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
alignItems: "flex-start",
borderWidth: 1,
borderRadius: s(4),
backgroundColor: colors.palette.neutral200,
borderColor: colors.palette.neutral400,
overflow: "hidden",
})
const $inputStyle: ThemedStyle<TextStyle> = ({ colors, typography, spacing }) => ({
flex: 1,
alignSelf: "stretch",
fontFamily: typography.primary.normal,
color: colors.text,
fontSize: fs(16),
height: s(24),
// https://github.com/facebook/react-native/issues/21720#issuecomment-532642093
paddingVertical: 0,
paddingHorizontal: 0,
marginVertical: spacing.xs,
marginHorizontal: spacing.sm,
})
const $helperStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $rightAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginEnd: spacing.xs,
height: s(40),
justifyContent: "center",
alignItems: "center",
})
const $leftAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginStart: spacing.xs,
height: s(40),
justifyContent: "center",
alignItems: "center",
})