返回 AiToEarn
form.tsx
1 /**
2 * Form - shadcn/ui 表单组件
3 * 基于 react-hook-form 封装,提供表单上下文、字段关联和验证消息
4 */
5
6 'use client'
7
8 import type * as LabelPrimitive from '@radix-ui/react-label'
9 import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'
10 import { Slot } from '@radix-ui/react-slot'
11 import * as React from 'react'
12 import {
13 Controller,
14 FormProvider,
15 useFormContext,
16 } from 'react-hook-form'
17
18 import { Label } from '@/components/ui/label'
19 import { cn } from '@/utils/className'
20
21 const Form = FormProvider
22
23 interface FormFieldContextValue<
24 TFieldValues extends FieldValues = FieldValues,
25 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
26 > {
27 name: TName
28 }
29
30 const FormFieldContext = React.createContext<FormFieldContextValue>(
31 {} as FormFieldContextValue,
32 )
33
34 function FormField<
35 TFieldValues extends FieldValues = FieldValues,
36 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
37 >({ ...props }: ControllerProps<TFieldValues, TName>) {
38 return (
39 <FormFieldContext.Provider value={{ name: props.name }}>
40 <Controller {...props} />
41 </FormFieldContext.Provider>
42 )
43 }
44
45 function useFormField() {
46 const fieldContext = React.useContext(FormFieldContext)
47 const itemContext = React.useContext(FormItemContext)
48 const { getFieldState, formState } = useFormContext()
49
50 const fieldState = getFieldState(fieldContext.name, formState)
51
52 if (!fieldContext) {
53 throw new Error('useFormField should be used within <FormField>')
54 }
55
56 const { id } = itemContext
57
58 return {
59 id,
60 name: fieldContext.name,
61 formItemId: `${id}-form-item`,
62 formDescriptionId: `${id}-form-item-description`,
63 formMessageId: `${id}-form-item-message`,
64 ...fieldState,
65 }
66 }
67
68 interface FormItemContextValue {
69 id: string
70 }
71
72 const FormItemContext = React.createContext<FormItemContextValue>(
73 {} as FormItemContextValue,
74 )
75
76 const FormItem = React.forwardRef<
77 HTMLDivElement,
78 React.HTMLAttributes<HTMLDivElement>
79 >(({ className, ...props }, ref) => {
80 const id = React.useId()
81
82 return (
83 <FormItemContext.Provider value={{ id }}>
84 <div ref={ref} className={cn('space-y-2', className)} {...props} />
85 </FormItemContext.Provider>
86 )
87 })
88 FormItem.displayName = 'FormItem'
89
90 const FormLabel = React.forwardRef<
91 React.ElementRef<typeof LabelPrimitive.Root>,
92 React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
93 >(({ className, ...props }, ref) => {
94 const { error, formItemId } = useFormField()
95
96 return (
97 <Label
98 ref={ref}
99 className={cn(error && 'text-destructive', className)}
100 htmlFor={formItemId}
101 {...props}
102 />
103 )
104 })
105 FormLabel.displayName = 'FormLabel'
106
107 const FormControl = React.forwardRef<
108 React.ElementRef<typeof Slot>,
109 React.ComponentPropsWithoutRef<typeof Slot>
110 >(({ ...props }, ref) => {
111 const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
112
113 return (
114 <Slot
115 ref={ref}
116 id={formItemId}
117 aria-describedby={
118 !error
119 ? `${formDescriptionId}`
120 : `${formDescriptionId} ${formMessageId}`
121 }
122 aria-invalid={!!error}
123 {...props}
124 />
125 )
126 })
127 FormControl.displayName = 'FormControl'
128
129 const FormDescription = React.forwardRef<
130 HTMLParagraphElement,
131 React.HTMLAttributes<HTMLParagraphElement>
132 >(({ className, ...props }, ref) => {
133 const { formDescriptionId } = useFormField()
134
135 return (
136 <p
137 ref={ref}
138 id={formDescriptionId}
139 className={cn('text-[0.8rem] text-muted-foreground', className)}
140 {...props}
141 />
142 )
143 })
144 FormDescription.displayName = 'FormDescription'
145
146 const FormMessage = React.forwardRef<
147 HTMLParagraphElement,
148 React.HTMLAttributes<HTMLParagraphElement>
149 >(({ className, children, ...props }, ref) => {
150 const { error, formMessageId } = useFormField()
151 const body = error ? String(error?.message) : children
152
153 if (!body) {
154 return null
155 }
156
157 return (
158 <p
159 ref={ref}
160 id={formMessageId}
161 className={cn('text-[0.8rem] font-medium text-destructive', className)}
162 {...props}
163 >
164 {body}
165 </p>
166 )
167 })
168 FormMessage.displayName = 'FormMessage'
169
170 export {
171 Form,
172 FormControl,
173 FormDescription,
174 FormField,
175 FormItem,
176 FormLabel,
177 FormMessage,
178 useFormField,
179 }
180
180 lines Plain Text