import {
forwardRef,
useCallback,
useMemo,
useState,
type ForwardedRef,
type ReactNode,
} from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
View,
type PressableStateCallbackType,
type TextInput,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
interface FieldProps {
label: string;
children: ReactNode;
hint?: string;
error?: string | null;
testID?: string;
}
export function Field({ label, children, hint, error, testID }: FieldProps) {
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
const subtext = useMemo(() => {
if (error) {
return (
{error}
);
}
if (hint) {
return (
{hint}
);
}
return null;
}, [error, hint, errorTestID, hintTestID]);
return (
{label}
{children}
{subtext}
);
}
type FormTextInputProps = AdaptiveTextInputProps & {
size?: FieldControlSize;
};
type FlatFormTextInputStyle = ViewStyle & TextStyle;
interface SplitFormTextInputStyle {
chromeStyle?: ViewStyle;
inputStyle?: TextStyle;
}
function splitFormTextInputStyle(style: AdaptiveTextInputProps["style"]): SplitFormTextInputStyle {
const flattened = RNStyleSheet.flatten(style) as FlatFormTextInputStyle | undefined;
if (!flattened) {
return {};
}
const {
color,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
includeFontPadding,
letterSpacing,
lineHeight,
textAlign,
textAlignVertical,
textDecorationColor,
textDecorationLine,
textDecorationStyle,
textShadowColor,
textShadowOffset,
textShadowRadius,
textTransform,
writingDirection,
...chromeStyle
} = flattened;
const inputStyle: TextStyle = {
color,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
includeFontPadding,
letterSpacing,
lineHeight,
textAlign,
textAlignVertical,
textDecorationColor,
textDecorationLine,
textDecorationStyle,
textShadowColor,
textShadowOffset,
textShadowRadius,
textTransform,
writingDirection,
};
return {
chromeStyle: stripUnistylesMetadata(chromeStyle),
inputStyle: stripUnistylesMetadata(inputStyle),
};
}
function stripUnistylesMetadata(style: TStyle): TStyle {
const cleanStyle: Record = {};
for (const [key, value] of Object.entries(style as Record)) {
if (key.startsWith("unistyles_") || value === undefined) {
continue;
}
cleanStyle[key] = value;
}
return cleanStyle as TStyle;
}
function assignTextInputRef(forwardedRef: ForwardedRef, node: TextInput | null): void {
if (typeof forwardedRef === "function") {
forwardedRef(node);
return;
}
if (forwardedRef) {
forwardedRef.current = node;
}
}
export const FormTextInput = forwardRef(function FormTextInput(
{ size = "md", style, onFocus, onBlur, editable, ...props },
ref,
) {
const [focused, setFocused] = useState(false);
const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: TextInput | null) => {
assignTextInputRef(ref, node);
},
[ref],
);
const handleFocus = useCallback>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback>(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const inputStyle = useMemo(
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
[inputSizeStyle, splitStyle.inputStyle],
) as AdaptiveTextInputProps["style"];
const chromeStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
formInputStyles.chrome,
chromeSizeStyle,
resolveControlInteractionStyles(
{
controlRest: formInputStyles.controlRest,
controlHover: formInputStyles.controlHover,
controlActive: formInputStyles.controlActive,
controlDisabled: formInputStyles.controlDisabled,
},
{
hovered,
focused,
disabled: isDisabled,
},
),
splitStyle.chromeStyle,
],
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
);
return (
);
});
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: Math.round(theme.fontSize.sm * 1.4),
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
lineHeight: Math.round(theme.fontSize.sm * 1.4),
},
}));
const formInputStyles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeSm: {
...geometry.fieldControlSm,
},
chromeMd: {
...geometry.fieldControlMd,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
input: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
paddingHorizontal: 0,
paddingVertical: 0,
outlineColor: "transparent",
outlineWidth: 0,
},
inputSm: {
...geometry.fieldTextSm,
},
inputMd: {
...geometry.fieldTextMd,
},
};
});