Files
hair-keeper/src/components/common/form-dialog.tsx

336 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}