This commit is contained in:
oneao committed 2025-11-05 15:57:11 +08:00
1 parent 44fb48ef6a
commit ca2fa3543d
10 files changed
+538 -382

No files matched your search

@@ -1,210 +0,0 @@
// FormWrapper.tsx
import React, { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react'
import {
FormControl,
FormControlError,
FormControlErrorIcon,
FormControlErrorText,
FormControlLabel,
FormControlLabelText,
} from '@/components/ui/form-control'
import { AlertCircleIcon, EyeIcon, EyeOffIcon } from '@/components/ui/icon'
import { InputField, InputIcon, InputSlot, Input as NativeBaseInput } from '@/components/ui/input'
export interface FormWrapperRef {
validate: () => boolean
getValues: () => Record<string, any>
}
// 创建 FormContext 来传递表单数据和验证器注册函数
interface FormContextType<T = any> {
form: T
registerValidator: (name: string, validator: () => boolean) => void
unregisterValidator: (name: string) => void
}
const FormContext = createContext<FormContextType | undefined>(undefined)
// ---------------- FormItem ----------------
export interface FormItemProps<T extends Record<string, any> = any> {
name: keyof T
label?: string
required?: boolean
rules?: ((val: any) => string | null)[]
children: React.ReactNode
}
export function FormItem<T extends Record<string, any>>({
name,
label,
required,
rules,
children,
}: FormItemProps<T>) {
const formContext = useContext(FormContext)
if (!formContext) {
throw new Error('FormItem must be used within FormWrapper')
}
const { form, registerValidator, unregisterValidator } = formContext
const [error, setError] = useState('')
// 从 form 中根据 name 获取对应的值
const value = form[name]
const validate = (): boolean => {
let err = ''
if (required && (value === undefined || value === '' || value === null)) {
err = `${label || String(name)} 不能为空`
}
else if (rules) {
for (const rule of rules) {
const msg = rule(value)
if (msg) {
err = msg
break
}
}
}
setError(err)
return !err
}
// 注册验证函数到父组件
useEffect(() => {
registerValidator(String(name), validate)
return () => unregisterValidator(String(name))
}, [validate, registerValidator, unregisterValidator, name])
return (
<FormControl size="md" className="w-full mb-6" isInvalid={!!error}>
{label && (
<FormControlLabel>
<FormControlLabelText>{label}</FormControlLabelText>
</FormControlLabel>
)}
{children}
{error && (
<FormControlError>
<FormControlErrorIcon as={AlertCircleIcon} className="text-red-500" />
<FormControlErrorText className="text-red-500">{error}</FormControlErrorText>
</FormControlError>
)}
</FormControl>
)
}
// ---------------- FormInput ----------------
export interface FormInputProps {
placeholder?: string
leftIcon?: React.ComponentType<any>
isPassword?: boolean
value?: string
onChangeText?: (text: string) => void
}
export function FormInput({
placeholder,
leftIcon,
isPassword,
value,
onChangeText,
}: FormInputProps) {
const [showPassword, setShowPassword] = useState(false)
return (
<NativeBaseInput size="md">
{leftIcon && (
<InputSlot className="pl-3">
<InputIcon as={leftIcon} />
</InputSlot>
)}
<InputField
placeholder={placeholder}
type={isPassword ? (showPassword ? 'text' : 'password') : 'text'}
value={value}
onChangeText={onChangeText}
/>
{isPassword && (
<InputSlot className="pr-3" onPress={() => setShowPassword(prev => !prev)}>
<InputIcon as={showPassword ? EyeIcon : EyeOffIcon} />
</InputSlot>
)}
</NativeBaseInput>
)
}
// ---------------- FormWrapper ----------------
interface FormWrapperProps<T extends Record<string, any>> {
children: React.ReactNode
form: T
}
// 定义组件接口
interface FormWrapperComponent extends React.ForwardRefExoticComponent<
FormWrapperProps<any> & React.RefAttributes<FormWrapperRef>
> {
FormItem: typeof FormItem
Input: typeof FormInput
}
// 内部组件实现
function FormWrapperInner<T extends Record<string, any>>(
props: FormWrapperProps<T>,
ref: React.Ref<FormWrapperRef>,
) {
const { children, form } = props
const validators = useRef<Map<string, () => boolean>>(new Map())
const registerValidator = (name: string, validator: () => boolean) => {
validators.current.set(name, validator)
}
const unregisterValidator = (name: string) => {
validators.current.delete(name)
}
const validate = (): boolean => {
let isValid = true
// 然后调用所有验证函数
validators.current.forEach((validator) => {
if (!validator()) {
isValid = false
}
})
return isValid
}
const getValues = (): Record<string, any> => form
useImperativeHandle(ref, () => ({ validate, getValues }))
const contextValue: FormContextType<T> = {
form,
registerValidator,
unregisterValidator,
}
return (
<FormContext.Provider value={contextValue}>
{children}
</FormContext.Provider>
)
}
// 创建基础组件
const BaseFormWrapper = forwardRef(FormWrapperInner) as <T extends Record<string, any>>(
props: FormWrapperProps<T> & { ref?: React.Ref<FormWrapperRef> },
) => React.JSX.Element
// 创建带有静态属性的组件
const FormWrapper = BaseFormWrapper as FormWrapperComponent
// 添加静态属性
FormWrapper.FormItem = FormItem
FormWrapper.Input = FormInput
export default FormWrapper
@@ -0,0 +1,94 @@
import type { ScrollView } from 'react-native'
import React, { createContext, forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'
export interface FormRef {
validate: () => boolean
scrollToField: (name: string) => void // 新增的滚动方法
}
interface FormProps<T extends Record<string, any>> {
children: React.ReactNode
form: T
scrollRef: React.RefObject<ScrollView>
}
interface FormContextType<T = any> {
form: T
registerValidator: (name: string, validator: () => boolean) => void
unregisterValidator: (name: string) => void
registerFieldPosition: (name: string, y: number) => void
}
export const FormContext = createContext<FormContextType | undefined>(undefined)
const Form = forwardRef<FormRef, FormProps<any>>(({ children, form, scrollRef }, ref) => {
// 使用 Array 来代替 Map
const validatorsRef = useRef<Array<{ name: string, validator: () => boolean }>>([]) // 持有验证器顺序
const fieldPositions = useRef<Map<string, number>>(new Map()) // 用来存储每个字段的 Y 坐标
const registerValidator = useCallback((name: string, validator: () => boolean) => {
console.log(`注册:${name}`)
validatorsRef.current.push({ name, validator })
}, [])
const unregisterValidator = useCallback((name: string) => {
console.log(`移除:${name}`)
validatorsRef.current = validatorsRef.current.filter(validator => validator.name !== name)
}, [])
const registerFieldPosition = useCallback((name: string, y: number) => {
fieldPositions.current.set(name, y)
}, [])
const validate = (): boolean => {
let firstErrorField: string | null = null
let isValid = true
// 按顺序遍历所有的验证器
for (const { name, validator } of validatorsRef.current) {
const ok = validator()
if (!ok && !firstErrorField) {
// 如果遇到第一个验证失败的字段,记录下来
firstErrorField = name
isValid = false
}
}
if (isValid)
return true
// 滚动到第一个错误字段
if (firstErrorField) {
scrollToField(firstErrorField)
}
return false
}
const scrollToField = (name: string) => {
if (scrollRef.current) {
const y = fieldPositions.current.get(name)
if (y !== undefined && scrollRef.current) {
scrollRef.current.scrollTo({ y, animated: true })
}
}
}
useImperativeHandle(ref, () => ({ validate, scrollToField }))
return (
<FormContext.Provider
value={{
form,
registerValidator,
unregisterValidator,
registerFieldPosition,
}}
>
{children}
</FormContext.Provider>
)
})
export default Form
@@ -0,0 +1,85 @@
import React, { useContext, useEffect, useState } from 'react'
import { View } from 'react-native'
import {
FormControl,
FormControlError,
FormControlErrorIcon,
FormControlErrorText,
FormControlLabel,
FormControlLabelText,
} from '@/components/ui/form-control'
import { AlertCircleIcon } from '@/components/ui/icon'
import { FormContext } from './Form'
interface FormItemProps<T> {
name: keyof T
label?: string
rules?: ((val: any) => string | null)[]
children: React.ReactNode
isRequired?: boolean
}
function FormItem<T extends Record<string, any>>({
name,
label,
rules = [],
children,
isRequired = false,
}: FormItemProps<T>) {
const context = useContext(FormContext)
const [error, setError] = useState('')
useEffect(() => {
if (!context)
return
const { registerValidator, unregisterValidator } = context
const validator = () => {
const value = context.form[name]
if (isRequired && (value === undefined || value === null || value === '' || value.length === 0)) {
setError(`${String(label || name)} 是必填项`)
return false
}
for (const rule of rules) {
const msg = rule(value)
if (msg) {
setError(msg)
return false
}
}
setError('')
return true
}
registerValidator(String(name), validator)
return () => unregisterValidator(String(name))
}, [name])
const handleLayout = (event: any) => {
const { y } = event.nativeEvent.layout
context?.registerFieldPosition(String(name), y)
}
return (
<FormControl isInvalid={!!error} isRequired={isRequired} className="w-full mb-4" onLayout={handleLayout}>
{label && (
<FormControlLabel>
<FormControlLabelText>{label}</FormControlLabelText>
</FormControlLabel>
)}
{children}
{error && (
<FormControlError>
<FormControlErrorIcon as={AlertCircleIcon} className="text-red-500" />
<FormControlErrorText className="text-red-500">{error}</FormControlErrorText>
</FormControlError>
)}
</FormControl>
)
}
export default FormItem
@@ -0,0 +1,76 @@
import type { ComponentType } from 'react'
import type { StyleProp, ViewStyle } from 'react-native'
import { useState } from 'react'
import { EyeIcon, EyeOffIcon } from '@/components/ui/icon'
import { InputField, InputIcon, InputSlot, Input as NativeInput } from '@/components/ui/input'
export interface AInputProps {
value: string
onChange?: (text: string) => void
isPassword?: boolean
isDisabled?: boolean
isReadOnly?: boolean
isInvalid?: boolean
isHovered?: boolean
isFocused?: boolean
variant?: 'outline' | 'rounded' | 'underlined'
size?: 'sm' | 'md' | 'lg' | 'xl'
placeholder?: string
leftIcon?: ComponentType
className?: string
style?: StyleProp<ViewStyle>
}
export default function Input({
value,
onChange,
isPassword = false,
isDisabled = false,
isReadOnly = false,
isInvalid = false,
isHovered = false,
isFocused = false,
variant = 'outline',
size = 'md',
placeholder,
leftIcon,
className,
style,
...rest
}: AInputProps) {
const [showPassword, setShowPassword] = useState(false)
return (
<NativeInput
variant={variant}
size={size}
isDisabled={isDisabled}
isReadOnly={isReadOnly}
isInvalid={isInvalid}
isHovered={isHovered}
isFocused={isFocused}
className={className}
style={style}
>
{leftIcon
? (
<InputSlot className="pl-3">
<InputIcon as={leftIcon} />
</InputSlot>
)
: null}
<InputField
value={value}
onChangeText={onChange}
placeholder={placeholder}
type={isPassword ? 'password' : 'text'}
{...rest}
/>
{isPassword && (
<InputSlot className="pr-3" onPress={() => setShowPassword(prev => !prev)}>
<InputIcon as={showPassword ? EyeIcon : EyeOffIcon} />
</InputSlot>
)}
</NativeInput>
)
}
@@ -0,0 +1,10 @@
import Form, { FormRef } from './Form'
import FormItem from './FormItem'
import Input from './Input'
export {
Form as AForm,
FormItem as AFormItem,
FormRef as AFormRef,
Input as AInput,
}
@@ -65,14 +65,7 @@ export default function StepLayout({ steps, children, showHearder = true }: Step
</View>
)}
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={{ flex: 1 }}>{children}</View>
</ScrollView>
<View style={{ flex: 1 }}>{children}</View>
</Animated.View>
</SafeAreaView>
)
@@ -1,24 +1,21 @@
'use client';
import React from 'react';
import { createInput } from '@gluestack-ui/core/input/creator';
import { View, Pressable, TextInput } from 'react-native';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
'use client'
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator'
import { createInput } from '@gluestack-ui/core/input/creator'
import { tva, useStyleContext, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'
const SCOPE = 'INPUT';
import { cssInterop } from 'nativewind'
import React from 'react'
import { Pressable, TextInput, View } from 'react-native'
const SCOPE = 'INPUT'
const UIInput = createInput({
Root: withStyleContext(View, SCOPE),
Icon: UIIcon,
Slot: Pressable,
Input: TextInput,
});
})
cssInterop(PrimitiveIcon, {
className: {
@@ -31,7 +28,7 @@ cssInterop(PrimitiveIcon, {
stroke: true,
},
},
});
})
const inputStyle = tva({
base: 'border-background-300 flex-row overflow-hidden content-center data-[hover=true]:border-outline-400 data-[focus=true]:border-primary-700 data-[focus=true]:hover:border-primary-700 data-[disabled=true]:opacity-40 data-[disabled=true]:hover:border-background-300 items-center',
@@ -55,7 +52,7 @@ const inputStyle = tva({
'rounded-full border data-[invalid=true]:border-error-700 data-[invalid=true]:hover:border-error-700 data-[invalid=true]:data-[focus=true]:border-error-700 data-[invalid=true]:data-[focus=true]:hover:border-error-700 data-[invalid=true]:data-[disabled=true]:hover:border-error-700 data-[focus=true]:web:ring-1 data-[focus=true]:web:ring-inset data-[focus=true]:web:ring-indicator-primary data-[invalid=true]:web:ring-1 data-[invalid=true]:web:ring-inset data-[invalid=true]:web:ring-indicator-error data-[invalid=true]:data-[focus=true]:hover:web:ring-1 data-[invalid=true]:data-[focus=true]:hover:web:ring-inset data-[invalid=true]:data-[focus=true]:hover:web:ring-indicator-error data-[invalid=true]:data-[disabled=true]:hover:web:ring-1 data-[invalid=true]:data-[disabled=true]:hover:web:ring-inset data-[invalid=true]:data-[disabled=true]:hover:web:ring-indicator-error',
},
},
});
})
const inputIconStyle = tva({
base: 'justify-center items-center text-typography-400 fill-none',
@@ -69,11 +66,11 @@ const inputIconStyle = tva({
'xl': 'h-6 w-6',
},
},
});
})
const inputSlotStyle = tva({
base: 'justify-center items-center web:disabled:cursor-not-allowed',
});
})
const inputFieldStyle = tva({
base: 'flex-1 text-typography-900 py-0 px-3 placeholder:text-typography-500 h-full ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed',
@@ -99,15 +96,15 @@ const inputFieldStyle = tva({
'6xl': 'text-6xl',
},
},
});
})
type IInputProps = React.ComponentProps<typeof UIInput> &
VariantProps<typeof inputStyle> & { className?: string };
type IInputProps = React.ComponentProps<typeof UIInput>
& VariantProps<typeof inputStyle> & { className?: string }
const Input = React.forwardRef<React.ComponentRef<typeof UIInput>, IInputProps>(
function Input(
(
{ className, variant = 'outline', size = 'md', ...props },
ref
) {
ref,
) => {
return (
<UIInput
ref={ref}
@@ -115,22 +112,22 @@ const Input = React.forwardRef<React.ComponentRef<typeof UIInput>, IInputProps>(
className={inputStyle({ variant, size, class: className })}
context={{ variant, size }}
/>
);
}
);
)
},
)
type IInputIconProps = React.ComponentProps<typeof UIInput.Icon> &
VariantProps<typeof inputIconStyle> & {
className?: string;
height?: number;
width?: number;
};
type IInputIconProps = React.ComponentProps<typeof UIInput.Icon>
& VariantProps<typeof inputIconStyle> & {
className?: string
height?: number
width?: number
}
const InputIcon = React.forwardRef<
React.ComponentRef<typeof UIInput.Icon>,
IInputIconProps
>(function InputIcon({ className, size, ...props }, ref) {
const { size: parentSize } = useStyleContext(SCOPE);
>(({ className, size, ...props }, ref) => {
const { size: parentSize } = useStyleContext(SCOPE)
if (typeof size === 'number') {
return (
@@ -140,10 +137,11 @@ const InputIcon = React.forwardRef<
className={inputIconStyle({ class: className })}
size={size}
/>
);
} else if (
(props.height !== undefined || props.width !== undefined) &&
size === undefined
)
}
else if (
(props.height !== undefined || props.width !== undefined)
&& size === undefined
) {
return (
<UIInput.Icon
@@ -151,7 +149,7 @@ const InputIcon = React.forwardRef<
{...props}
className={inputIconStyle({ class: className })}
/>
);
)
}
return (
<UIInput.Icon
@@ -164,16 +162,16 @@ const InputIcon = React.forwardRef<
class: className,
})}
/>
);
});
)
})
type IInputSlotProps = React.ComponentProps<typeof UIInput.Slot> &
VariantProps<typeof inputSlotStyle> & { className?: string };
type IInputSlotProps = React.ComponentProps<typeof UIInput.Slot>
& VariantProps<typeof inputSlotStyle> & { className?: string }
const InputSlot = React.forwardRef<
React.ComponentRef<typeof UIInput.Slot>,
IInputSlotProps
>(function InputSlot({ className, ...props }, ref) {
>(({ className, ...props }, ref) => {
return (
<UIInput.Slot
ref={ref}
@@ -182,17 +180,17 @@ const InputSlot = React.forwardRef<
class: className,
})}
/>
);
});
)
})
type IInputFieldProps = React.ComponentProps<typeof UIInput.Input> &
VariantProps<typeof inputFieldStyle> & { className?: string };
type IInputFieldProps = React.ComponentProps<typeof UIInput.Input>
& VariantProps<typeof inputFieldStyle> & { className?: string }
const InputField = React.forwardRef<
React.ComponentRef<typeof UIInput.Input>,
IInputFieldProps
>(function InputField({ className, ...props }, ref) {
const { variant: parentVariant, size: parentSize } = useStyleContext(SCOPE);
>(({ className, ...props }, ref) => {
const { variant: parentVariant, size: parentSize } = useStyleContext(SCOPE)
return (
<UIInput.Input
@@ -206,12 +204,12 @@ const InputField = React.forwardRef<
class: className,
})}
/>
);
});
)
})
Input.displayName = 'Input';
InputIcon.displayName = 'InputIcon';
InputSlot.displayName = 'InputSlot';
InputField.displayName = 'InputField';
Input.displayName = 'Input'
InputIcon.displayName = 'InputIcon'
InputSlot.displayName = 'InputSlot'
InputField.displayName = 'InputField'
export { Input, InputField, InputIcon, InputSlot };
export { Input, InputField, InputIcon, InputSlot }