336 lines
9.2 KiB
TypeScript
336 lines
9.2 KiB
TypeScript
'use client'
|
||
|
||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog'
|
||
import {
|
||
Drawer,
|
||
DrawerContent,
|
||
DrawerHeader,
|
||
DrawerTitle,
|
||
DrawerDescription,
|
||
} from '@/components/ui/drawer'
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from '@/components/ui/form'
|
||
import { useIsMobile } from '@/hooks/use-mobile'
|
||
import { cn } from '@/lib/utils'
|
||
import { Loader2 } from 'lucide-react'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
||
// FormDialog Context
|
||
export interface FormDialogContextValue {
|
||
form: UseFormReturn<any>
|
||
close: () => void
|
||
fields: FormFieldConfig[]
|
||
isLoading?: boolean
|
||
}
|
||
|
||
const FormDialogContext = createContext<FormDialogContextValue | null>(null)
|
||
|
||
export function useFormDialogContext() {
|
||
const context = useContext(FormDialogContext)
|
||
if (!context) {
|
||
throw new Error('useFormDialogContext must be used within FormDialog')
|
||
}
|
||
return context
|
||
}
|
||
|
||
// 字段配置类型定义
|
||
export interface FormFieldConfig {
|
||
name: string
|
||
label: string
|
||
required?: boolean
|
||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||
className?: string // 允许为单个字段指定自定义样式
|
||
}
|
||
|
||
// 取消按钮组件
|
||
export interface FormCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||
children?: React.ReactNode
|
||
onCancel?: () => void
|
||
}
|
||
|
||
export function FormCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: FormCancelActionProps) {
|
||
const { close } = useFormDialogContext()
|
||
|
||
const handleClick = () => {
|
||
if (onCancel) {
|
||
onCancel()
|
||
} else {
|
||
close()
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
|
||
{children}
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
// 重置按钮组件
|
||
export interface FormResetActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||
children?: React.ReactNode
|
||
onReset?: () => void
|
||
confirmTitle?: string
|
||
confirmDescription?: string
|
||
}
|
||
|
||
export function FormResetAction({
|
||
children = '重置',
|
||
variant = 'outline',
|
||
onReset,
|
||
confirmTitle = '确认重置',
|
||
confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。',
|
||
...props
|
||
}: FormResetActionProps) {
|
||
const { form, isLoading } = useFormDialogContext()
|
||
const [showConfirm, setShowConfirm] = useState(false)
|
||
|
||
const handleConfirm = () => {
|
||
if (onReset) {
|
||
onReset()
|
||
} else {
|
||
form.reset()
|
||
}
|
||
setShowConfirm(false)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} disabled={isLoading || props.disabled} {...props}>
|
||
{children}
|
||
</Button>
|
||
|
||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
|
||
<AlertDialogDescription>{confirmDescription}</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleConfirm}>确认</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// 提交按钮组件
|
||
export interface FormSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||
onSubmit: (data: any) => Promise<void> | void
|
||
children?: React.ReactNode
|
||
disabled?: boolean
|
||
isSubmitting?: boolean
|
||
showSpinningLoader?: boolean
|
||
}
|
||
|
||
export function FormSubmitAction({
|
||
onSubmit,
|
||
children = '提交',
|
||
disabled = false,
|
||
isSubmitting = false,
|
||
showSpinningLoader = true,
|
||
variant = 'default',
|
||
...props
|
||
}: FormSubmitActionProps) {
|
||
const { form, isLoading } = useFormDialogContext()
|
||
|
||
return (
|
||
<Button
|
||
type="button"
|
||
variant={variant}
|
||
onClick={form.handleSubmit(onSubmit)}
|
||
disabled={isSubmitting || disabled || isLoading}
|
||
{...props}
|
||
>
|
||
{children}
|
||
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
// 操作按钮栏组件
|
||
export interface FormActionBarProps {
|
||
children?: React.ReactNode
|
||
}
|
||
|
||
export function FormActionBar({ children }: FormActionBarProps) {
|
||
return (
|
||
<div className="flex justify-end space-x-2">
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// FormGridContent 组件
|
||
export interface FormGridContentProps {
|
||
className?: string
|
||
}
|
||
|
||
export function FormGridContent({ className = 'grid grid-cols-1 gap-4' }: FormGridContentProps) {
|
||
const { form, fields, isLoading } = useFormDialogContext()
|
||
|
||
// 如果正在加载,显示骨架屏
|
||
if (isLoading) {
|
||
return (
|
||
<div className={cn("p-1", className)}>
|
||
{fields.map((fieldConfig) => (
|
||
<div key={fieldConfig.name} className={cn("space-y-2", fieldConfig.className || '')}>
|
||
<Skeleton className="h-4 w-20" />
|
||
<Skeleton className="h-10 w-full" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={cn("p-1", className)}>
|
||
{fields.map((fieldConfig) => (
|
||
<FormField
|
||
key={fieldConfig.name}
|
||
control={form.control}
|
||
name={fieldConfig.name}
|
||
render={({ field }) => (
|
||
<FormItem className={fieldConfig.className || ''}>
|
||
<FormLabel className="flex items-center gap-1">
|
||
{fieldConfig.label}
|
||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||
</FormLabel>
|
||
<FormControl>
|
||
{fieldConfig.render({ field })}
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export interface FormDialogProps {
|
||
isOpen: boolean
|
||
title: string
|
||
description: string
|
||
form: UseFormReturn<any>
|
||
fields: FormFieldConfig[]
|
||
onClose: () => void
|
||
className?: string // 允许自定义对话框内容样式,可控制宽度
|
||
formClassName?: string // 允许自定义表格样式
|
||
children: React.ReactNode // 操作按钮区域内容
|
||
isLoading?: boolean // 是否正在加载数据
|
||
}
|
||
|
||
export function FormDialog({
|
||
isOpen,
|
||
title,
|
||
description,
|
||
form,
|
||
fields,
|
||
onClose,
|
||
className = 'max-w-md',
|
||
formClassName,
|
||
children,
|
||
isLoading = false,
|
||
}: FormDialogProps) {
|
||
const isMobile = useIsMobile()
|
||
const formRef = useRef<HTMLFormElement>(null)
|
||
|
||
// 当对话框打开时,自动聚焦到第一个表单输入控件
|
||
useEffect(() => {
|
||
if (isOpen && !isLoading) {
|
||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||
(document.activeElement as HTMLElement)?.blur();
|
||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||
const timer = setTimeout(() => {
|
||
if (formRef.current) {
|
||
// 查找第一个可聚焦的输入元素
|
||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||
)
|
||
if (firstInput) {
|
||
firstInput.focus()
|
||
}
|
||
}
|
||
}, 100)
|
||
|
||
return () => clearTimeout(timer)
|
||
}
|
||
}, [isOpen, isLoading])
|
||
|
||
const close = () => {
|
||
onClose()
|
||
}
|
||
|
||
// Context 值
|
||
const contextValue: FormDialogContextValue = {
|
||
form,
|
||
close,
|
||
fields,
|
||
isLoading
|
||
}
|
||
|
||
// 表单内容组件,在 Dialog 和 Drawer 中复用
|
||
const formContent = (
|
||
<FormDialogContext.Provider value={contextValue}>
|
||
<Form {...form}>
|
||
<form ref={formRef} className={cn("space-y-6", formClassName)}>
|
||
{children}
|
||
</form>
|
||
</Form>
|
||
</FormDialogContext.Provider>
|
||
)
|
||
|
||
// 根据设备类型渲染不同的组件
|
||
if (isMobile) {
|
||
return (
|
||
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
|
||
<DrawerContent className={className}>
|
||
<DrawerHeader>
|
||
<DrawerTitle>{title}</DrawerTitle>
|
||
<DrawerDescription>{description}</DrawerDescription>
|
||
</DrawerHeader>
|
||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||
{formContent}
|
||
</div>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
||
<DialogContent className={className} showCloseButton={false}>
|
||
<DialogHeader>
|
||
<DialogTitle>{title}</DialogTitle>
|
||
<DialogDescription>{description}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className='overflow-y-auto max-h-[70vh]'>
|
||
{formContent}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|