目录结构与表单组件结构调整
This commit is contained in:
@@ -1,143 +1,145 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import {Slot} from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
ControllerProps,
|
||||
SubmitHandler,
|
||||
FieldValues, useFormContext, FieldPath, UseFormReturn, ControllerRenderProps,
|
||||
ControllerFieldState, UseFormStateReturn, FieldError,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { merge } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {merge} from '@/lib/utils'
|
||||
import {Label} from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
import {ComponentProps, createContext, ReactNode, useContext, useId} from 'react'
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
type FormProps<T extends FieldValues> = {
|
||||
form: UseFormReturn<T>
|
||||
onSubmit: SubmitHandler<T>
|
||||
} & Omit<ComponentProps<'form'>, 'onSubmit'>
|
||||
|
||||
function Form<T extends FieldValues>(rawProps: FormProps<T>) {
|
||||
|
||||
const {children, onSubmit, ...props} = rawProps
|
||||
const form = props.form
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form {...props} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
type FormFieldProps<
|
||||
V extends FieldValues = FieldValues,
|
||||
N extends FieldPath<V> = FieldPath<V>,
|
||||
> = {
|
||||
label?: ReactNode
|
||||
className?: string
|
||||
children: (props: {
|
||||
id: string
|
||||
field: ControllerRenderProps<V, N>
|
||||
fieldState: ControllerFieldState
|
||||
formState: UseFormStateReturn<V>
|
||||
}) => ReactNode
|
||||
} & Omit<ControllerProps<V, N>, 'control' | 'render'>
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
type FormFieldContext = {
|
||||
id: string
|
||||
error?: FieldError
|
||||
}
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContext | null>(null)
|
||||
|
||||
function FormField<
|
||||
V extends FieldValues = FieldValues,
|
||||
N extends FieldPath<V> = FieldPath<V>,
|
||||
>(props: FormFieldProps<V, N>) {
|
||||
const form = useFormContext<V>()
|
||||
const id = useId()
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
<Controller<V, N> name={props.name} control={form.control} render={({field, fieldState, formState}) => (
|
||||
<div data-slot="form-field" className={merge('grid gap-2', props.className)}>
|
||||
<FormFieldContext value={{id: id, error: fieldState.error}}>
|
||||
{!!props.label &&
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!fieldState.error}
|
||||
className={merge('data-[error=true]:text-destructive')}
|
||||
htmlFor={id}>
|
||||
{props.label}
|
||||
</Label>
|
||||
}
|
||||
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
aria-invalid={!!fieldState.error}
|
||||
aria-describedby={
|
||||
!!fieldState.error
|
||||
? `${id}-description`
|
||||
: `${id}-description ${id}-message`
|
||||
}>
|
||||
{props.children({id, field, fieldState, formState})}
|
||||
</Slot>
|
||||
|
||||
{!fieldState.error ? null : (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
className={merge('text-destructive text-sm')}>
|
||||
{fieldState.error?.message}
|
||||
</p>
|
||||
)}
|
||||
</FormFieldContext>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
const context = useContext(FormFieldContext)
|
||||
if (!context) {
|
||||
throw new Error('FormField components must be used within a FormField component')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={merge("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
function FormLabel({className, ...props}: ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const {id, error} = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={merge("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
className={merge('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={id}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
function FormDescription({className, ...props}: ComponentProps<'p'>) {
|
||||
const {id} = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={merge("text-muted-foreground text-sm", className)}
|
||||
id={`${id}-description`}
|
||||
className={merge('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
function FormMessage({className, ...props}: ComponentProps<'p'>) {
|
||||
const {id, error} = useFormField()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
@@ -146,8 +148,8 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={merge("text-destructive text-sm", className)}
|
||||
id={`${id}-message`}
|
||||
className={merge('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
@@ -156,12 +158,9 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormField,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import {CheckIcon, ChevronDownIcon, ChevronUpIcon} from 'lucide-react'
|
||||
|
||||
import { merge } from "@/lib/utils"
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
function Select({
|
||||
...props
|
||||
@@ -26,25 +26,25 @@ function SelectValue({
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: 'sm' | 'default'
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={merge(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
<ChevronDownIcon className="size-4 opacity-50"/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
@@ -53,7 +53,7 @@ function SelectTrigger({
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
@@ -61,25 +61,25 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={merge(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectScrollUpButton/>
|
||||
<SelectPrimitive.Viewport
|
||||
className={merge(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
<SelectScrollDownButton/>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
@@ -92,7 +92,7 @@ function SelectLabel({
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={merge("px-2 py-1.5 text-sm", className)}
|
||||
className={merge('px-2 py-1.5 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -107,14 +107,14 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={merge(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
@@ -129,7 +129,7 @@ function SelectSeparator({
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={merge("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
className={merge('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -143,12 +143,12 @@ function SelectScrollUpButton({
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={merge(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
<ChevronUpIcon className="size-4"/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
@@ -161,12 +161,12 @@ function SelectScrollDownButton({
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={merge(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
<ChevronDownIcon className="size-4"/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user