initialize

This commit is contained in:
2026-03-26 03:49:05 +03:00
parent e9211a4aba
commit 5dc54ffd00
146 changed files with 12078 additions and 146 deletions
@@ -0,0 +1,160 @@
"use client"
import { useRef } from "react"
import type { AsyncOption, BaseFieldControlProps } from "../types"
import { Loader2 } from "lucide-react"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
const defaultGetOptionValue = (opt: any) => opt.value
const defaultGetOptionLabel = (opt: any) => opt.label
function defaultGetOptionKey(opt: any): string {
const v = defaultGetOptionValue(opt)
if (typeof v === "string" || typeof v === "number") return String(v)
return String(opt.id ?? JSON.stringify(v))
}
// ── Single-select ──
export type AsyncSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any> & {
options: TOption[]
loading?: boolean
onInputValueChange?: (value: string) => void
placeholder?: string
getOptionValue?: (option: TOption) => any
getOptionLabel?: (option: TOption) => string
getOptionKey?: (option: TOption) => string
}
export function AsyncSelectField<TOption = AsyncOption>({
value,
onChange,
onBlur,
disabled,
invalid,
options,
loading,
onInputValueChange,
placeholder = "Search...",
getOptionValue = defaultGetOptionValue,
getOptionLabel = defaultGetOptionLabel,
getOptionKey = defaultGetOptionKey,
}: AsyncSelectFieldProps<TOption>) {
const anchorRef = useRef<HTMLDivElement>(null)
return (
<div ref={anchorRef}>
<Combobox
value={value}
onValueChange={(val) => onChange(val)}
disabled={disabled}
onInputValueChange={(val, { reason }) => {
if (reason === "input-change") {
onInputValueChange?.(val)
}
}}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!value}
onBlur={onBlur}
aria-invalid={invalid || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{!loading &&
options.map((opt) => (
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
{getOptionLabel(opt)}
</ComboboxItem>
))}
{!loading && options.length === 0 && (
<ComboboxEmpty>No results found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}
// ── Multi-select ──
export type AsyncMultiSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any[]> & {
options: TOption[]
loading?: boolean
onInputValueChange?: (value: string) => void
placeholder?: string
getOptionValue?: (option: TOption) => any
getOptionLabel?: (option: TOption) => string
getOptionKey?: (option: TOption) => string
}
export function AsyncMultiSelectField<TOption = AsyncOption>({
value,
onChange,
onBlur,
disabled,
invalid,
options,
loading,
onInputValueChange,
placeholder = "Search...",
getOptionValue = defaultGetOptionValue,
getOptionLabel = defaultGetOptionLabel,
getOptionKey = defaultGetOptionKey,
}: AsyncMultiSelectFieldProps<TOption>) {
const anchorRef = useRef<HTMLDivElement>(null)
return (
<div ref={anchorRef}>
<Combobox
multiple
value={value ?? []}
onValueChange={(val) => onChange(val as any[])}
disabled={disabled}
onInputValueChange={(val, { reason }) => {
if (reason === "input-change") {
onInputValueChange?.(val)
}
}}
>
<ComboboxInput
placeholder={placeholder}
showClear={value && value.length > 0}
onBlur={onBlur}
aria-invalid={invalid || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{!loading &&
options.map((opt) => (
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
{getOptionLabel(opt)}
</ComboboxItem>
))}
{!loading && options.length === 0 && (
<ComboboxEmpty>No results found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}
@@ -0,0 +1,28 @@
"use client"
import type { BaseFieldControlProps } from "../types"
import { Checkbox } from "@/shared/components/ui/checkbox"
export type CheckboxFieldProps = BaseFieldControlProps<boolean> & {
label?: string
}
export function CheckboxField({
value,
onChange,
onBlur,
name,
disabled,
invalid,
}: CheckboxFieldProps) {
return (
<Checkbox
checked={value}
onCheckedChange={(checked) => onChange(checked === true)}
onBlur={onBlur}
name={name}
disabled={disabled}
aria-invalid={invalid || undefined}
/>
)
}
@@ -0,0 +1,28 @@
import type { BaseFieldControlProps } from "../types"
import { Input } from "@/shared/components/ui/input"
export type FileInputFieldProps = BaseFieldControlProps<File | null> & {
accept?: string
}
export function FileInputField({
// value intentionally unused — file inputs cannot be controlled
onBlur,
name,
disabled,
invalid,
accept,
onChange,
}: FileInputFieldProps) {
return (
<Input
type="file"
accept={accept}
onBlur={onBlur}
name={name}
disabled={disabled}
aria-invalid={invalid || undefined}
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
/>
)
}
@@ -0,0 +1,45 @@
"use client"
import type { BaseFieldControlProps, SelectOption } from "../types"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
export type SelectFieldProps = BaseFieldControlProps<string> & {
placeholder?: string
options: SelectOption[]
}
export function SelectField({
value,
onChange,
onBlur,
name,
disabled,
invalid,
placeholder,
options,
}: SelectFieldProps) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled} name={name}>
<SelectTrigger
className="w-full"
aria-invalid={invalid || undefined}
onBlur={onBlur}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,31 @@
import type { BaseFieldControlProps } from "../types"
import { Input } from "@/shared/components/ui/input"
export type TextInputFieldProps = BaseFieldControlProps<string> & {
placeholder?: string
type?: React.HTMLInputTypeAttribute
}
export function TextInputField({
value,
onChange,
onBlur,
name,
disabled,
invalid,
placeholder,
type = "text",
}: TextInputFieldProps) {
return (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
name={name}
disabled={disabled}
aria-invalid={invalid || undefined}
placeholder={placeholder}
type={type}
/>
)
}
@@ -0,0 +1,31 @@
import type { BaseFieldControlProps } from "../types"
import { Textarea } from "@/shared/components/ui/textarea"
export type TextareaFieldProps = BaseFieldControlProps<string> & {
placeholder?: string
rows?: number
}
export function TextareaField({
value,
onChange,
onBlur,
name,
disabled,
invalid,
placeholder,
rows,
}: TextareaFieldProps) {
return (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
name={name}
disabled={disabled}
aria-invalid={invalid || undefined}
placeholder={placeholder}
rows={rows}
/>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { FieldShellProps } from "./types"
import {
Field,
FieldLabel,
FieldError,
FieldDescription,
} from "@/shared/components/ui/field"
export function FieldShell({
label,
error,
description,
required,
children,
}: FieldShellProps) {
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && <span className="text-destructive">*</span>}
</FieldLabel>
)}
{children}
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError>{error}</FieldError>}
</Field>
)
}
@@ -0,0 +1,278 @@
"use client"
import { useState } from "react"
import type { FieldValues, FieldPath } from "react-hook-form"
import {
useFormContext,
useController,
} from "react-hook-form"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { FieldShell } from "../field-shell"
import {
AsyncSelectField,
AsyncMultiSelectField,
type AsyncSelectFieldProps,
type AsyncMultiSelectFieldProps,
} from "../controls/async-select-field"
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { PlusIcon } from "lucide-react"
// ── Inline create types ──
export type InlineCreateFormProps = {
onSuccess: (newItem?: { value: string; label: string }) => void
}
export type InlineCreateConfig = {
createForm: (props: InlineCreateFormProps) => React.ReactNode
createLabel?: string
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
if (response && typeof response === "object") {
const obj = response as Record<string, unknown>
if (obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)) {
const nested = obj.data as Record<string, unknown>
if (Array.isArray(nested.data)) return nested.data
}
if (Array.isArray(obj.data)) return obj.data
}
return []
}
// ── Props forwarded to the underlying control ──
type AsyncSelectControlProps = Omit<
AsyncSelectFieldProps,
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
>
type AsyncMultiSelectControlProps = Omit<
AsyncMultiSelectFieldProps,
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
>
// ── Shared base props ──
type BaseRhfAsyncFieldProps = {
label?: string
description?: string
required?: boolean
disabled?: boolean
queryKey: string[]
staleTime?: number
} & Partial<InlineCreateConfig>
type WithLoadOptions = {
loadOptions: () => Promise<any[]>
listFn?: never
mapOption?: never
}
type WithCrudClient<TItem> = {
loadOptions?: never
listFn: () => Promise<any>
mapOption?: (item: TItem) => any
}
type DataSource<TItem = unknown> = WithLoadOptions | WithCrudClient<TItem>
function useAsyncOptions<TItem>(
queryKey: string[],
source: DataSource<TItem>,
staleTime?: number,
) {
return useQuery<any[]>({
queryKey,
queryFn: async () => {
if ("loadOptions" in source && source.loadOptions) {
return source.loadOptions()
}
if ("listFn" in source && source.listFn) {
const response = await source.listFn()
const items = extractItems(response)
return source.mapOption ? items.map(source.mapOption) : items
}
return []
},
staleTime: staleTime ?? 5 * 60 * 1000,
})
}
// ── Single-select wrapper ──
type RhfAsyncSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TItem = unknown,
> = {
name: TName
multiple?: false
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncSelectControlProps
export function RhfAsyncSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TItem = unknown,
>(props: RhfAsyncSelectFieldProps<TValues, TName, TItem>) {
const {
name, label, description, required, disabled,
queryKey, staleTime,
loadOptions, listFn, mapOption,
createForm, createLabel,
...controlProps
} = props
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
const [inputValue, setInputValue] = useState("")
const [isCreateOpen, setIsCreateOpen] = useState(false)
const queryClient = useQueryClient()
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
const filtered = inputValue
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
: options
const handleCreateSuccess = (newItem?: { value: string; label: string }) => {
queryClient.invalidateQueries({ queryKey })
if (newItem) {
field.onChange(newItem)
}
setIsCreateOpen(false)
}
// When a createForm is provided, render a custom label row with the "+" button
if (createForm) {
return (
<Field data-invalid={!!error || undefined}>
{label && (
<div className="flex items-center justify-between">
<FieldLabel>
{label}
{required && <span className="text-destructive ms-0.5">*</span>}
</FieldLabel>
<Button
type="button"
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => setIsCreateOpen(true)}
title={`Add new ${createLabel ?? label}`}
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
<AsyncSelectField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
disabled={field.disabled}
invalid={!!error}
options={filtered}
loading={isLoading}
onInputValueChange={setInputValue}
{...controlProps}
/>
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError>{error.message}</FieldError>}
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Add {createLabel ?? label}
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
{createForm({ onSuccess: handleCreateSuccess })}
</ScrollArea>
</DialogContent>
</Dialog>
</Field>
)
}
return (
<FieldShell label={label} error={error?.message} description={description} required={required}>
<AsyncSelectField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
disabled={field.disabled}
invalid={!!error}
options={filtered}
loading={isLoading}
onInputValueChange={setInputValue}
{...controlProps}
/>
</FieldShell>
)
}
// ── Multi-select wrapper ──
type RhfAsyncMultiSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TItem = unknown,
> = {
name: TName
multiple: true
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncMultiSelectControlProps
export function RhfAsyncMultiSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TItem = unknown,
>(props: RhfAsyncMultiSelectFieldProps<TValues, TName, TItem>) {
const {
name, label, description, required, disabled,
queryKey, staleTime,
loadOptions, listFn, mapOption,
...controlProps
} = props
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
const [inputValue, setInputValue] = useState("")
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
const filtered = inputValue
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
: options
return (
<FieldShell label={label} error={error?.message} description={description} required={required}>
<AsyncMultiSelectField
value={field.value ?? []}
onChange={field.onChange}
onBlur={field.onBlur}
disabled={field.disabled}
invalid={!!error}
options={filtered}
loading={isLoading}
onInputValueChange={setInputValue}
{...controlProps}
/>
</FieldShell>
)
}
@@ -0,0 +1,24 @@
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { RhfField } from "../rhf-field"
import { CheckboxField, type CheckboxFieldProps } from "../controls/checkbox-field"
import type { BaseFieldControlProps } from "../types"
type RhfCheckboxFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
} & Omit<CheckboxFieldProps, keyof BaseFieldControlProps<boolean>>
export function RhfCheckboxField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>(props: RhfCheckboxFieldProps<TValues, TName>) {
return <RhfField {...props} component={CheckboxField} />
}
@@ -0,0 +1,24 @@
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { RhfField } from "../rhf-field"
import { FileInputField, type FileInputFieldProps } from "../controls/file-input-field"
import type { BaseFieldControlProps } from "../types"
type RhfFileFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
} & Omit<FileInputFieldProps, keyof BaseFieldControlProps<File | null>>
export function RhfFileField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>(props: RhfFileFieldProps<TValues, TName>) {
return <RhfField {...props} component={FileInputField} />
}
@@ -0,0 +1,24 @@
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { RhfField } from "../rhf-field"
import { SelectField, type SelectFieldProps } from "../controls/select-field"
import type { BaseFieldControlProps } from "../types"
type RhfSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
} & Omit<SelectFieldProps, keyof BaseFieldControlProps<string>>
export function RhfSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>(props: RhfSelectFieldProps<TValues, TName>) {
return <RhfField {...props} component={SelectField} />
}
@@ -0,0 +1,24 @@
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { RhfField } from "../rhf-field"
import { TextInputField, type TextInputFieldProps } from "../controls/text-input-field"
import type { BaseFieldControlProps } from "../types"
type RhfTextFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
} & Omit<TextInputFieldProps, keyof BaseFieldControlProps<string>>
export function RhfTextField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>(props: RhfTextFieldProps<TValues, TName>) {
return <RhfField {...props} component={TextInputField} />
}
@@ -0,0 +1,24 @@
"use client"
import type { FieldValues, FieldPath } from "react-hook-form"
import { RhfField } from "../rhf-field"
import { TextareaField, type TextareaFieldProps } from "../controls/textarea-field"
import type { BaseFieldControlProps } from "../types"
type RhfTextareaFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
} & Omit<TextareaFieldProps, keyof BaseFieldControlProps<string>>
export function RhfTextareaField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>(props: RhfTextareaFieldProps<TValues, TName>) {
return <RhfField {...props} component={TextareaField} />
}
@@ -0,0 +1,65 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField } from "@/shared/components/form"
import { toast } from "sonner"
import type { InlineCreateFormProps } from "./rhf-async-select-field"
const schema = z.object({ title: z.string().min(1, "Required") })
type FormValues = z.infer<typeof schema>
export type SimpleTitleFormProps = InlineCreateFormProps & {
placeholder?: string
submitLabel?: string
createFn: (title: string) => Promise<any>
mapResult?: (data: any) => { value: string; label: string }
}
const defaultMapResult = (data: any) => {
const item = data?.data ?? data
return {
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
}
}
export function SimpleTitleForm({
onSuccess,
placeholder = "Enter name",
submitLabel = "Create",
createFn,
mapResult = defaultMapResult,
}: SimpleTitleFormProps) {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await createFn(values.title)
toast.success(`${submitLabel}d successfully`)
form.reset()
onSuccess(mapResult(result))
} catch {
toast.error(`Failed to ${submitLabel.toLowerCase()}`)
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField name="title" label="Name" placeholder={placeholder} required />
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : submitLabel}
</Button>
</FieldGroup>
</Rhform>
)
}
+33
View File
@@ -0,0 +1,33 @@
// ── Core ──
export { Rhform } from "./rhform"
export { RhfField } from "./rhf-field"
export { FieldShell } from "./field-shell"
// ── Types ──
export type {
BaseFieldControlProps,
FieldShellProps,
RhformProps,
AsyncOption,
SelectOption,
} from "./types"
// ── Controls ──
export { TextInputField, type TextInputFieldProps } from "./controls/text-input-field"
export { TextareaField, type TextareaFieldProps } from "./controls/textarea-field"
export { CheckboxField, type CheckboxFieldProps } from "./controls/checkbox-field"
export { SelectField, type SelectFieldProps } from "./controls/select-field"
export {
AsyncSelectField, type AsyncSelectFieldProps,
AsyncMultiSelectField, type AsyncMultiSelectFieldProps,
} from "./controls/async-select-field"
export { FileInputField, type FileInputFieldProps } from "./controls/file-input-field"
// ── RHF Field Wrappers ──
export { RhfTextField } from "./fields/rhf-text-field"
export { RhfTextareaField } from "./fields/rhf-textarea-field"
export { RhfCheckboxField } from "./fields/rhf-checkbox-field"
export { RhfSelectField } from "./fields/rhf-select-field"
export { RhfFileField } from "./fields/rhf-file-field"
export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormProps, type InlineCreateConfig } from "./fields/rhf-async-select-field"
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
+62
View File
@@ -0,0 +1,62 @@
"use client"
import {
useFormContext,
useController,
type FieldValues,
type FieldPath,
} from "react-hook-form"
import { FieldShell } from "./field-shell"
import type { BaseFieldControlProps } from "./types"
type RhfFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TControlProps extends BaseFieldControlProps<any>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
component: React.ComponentType<TControlProps>
} & Omit<TControlProps, keyof BaseFieldControlProps<any>>
export function RhfField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TControlProps extends BaseFieldControlProps<any>,
>({
name,
label,
description,
required,
disabled,
component: Component,
...controlProps
}: RhfFieldProps<TValues, TName, TControlProps>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<Component
{...(controlProps as any)}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
disabled={field.disabled}
invalid={!!error}
/>
</FieldShell>
)
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import type { FieldValues } from "react-hook-form"
import { FormProvider } from "react-hook-form"
import type { RhformProps } from "./types"
export function Rhform<TValues extends FieldValues>({
form,
onSubmit,
children,
className,
}: RhformProps<TValues>) {
return (
<FormProvider {...form}>
<form
onSubmit={(e) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e) }}
noValidate
className={className}
>
{children}
</form>
</FormProvider>
)
}
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from "react"
import type {
FieldValues,
FieldPath,
UseFormReturn,
} from "react-hook-form"
// ── Base contract for all field control components ──
export type BaseFieldControlProps<TValue = string> = {
value: TValue
onChange: (value: TValue) => void
onBlur?: () => void
name?: string
disabled?: boolean
invalid?: boolean
}
// ── FieldShell props (pure UI) ──
export type FieldShellProps = {
label?: string
error?: string
description?: string
required?: boolean
children: ReactNode
}
// ── Rhform props ──
export type RhformProps<TValues extends FieldValues> = {
form: UseFormReturn<TValues>
onSubmit: (values: TValues) => void
children: ReactNode
className?: string
}
// ── RhfField props ──
export type RhfFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TControlProps extends BaseFieldControlProps<any>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
component: React.ComponentType<TControlProps>
} & Omit<TControlProps, keyof BaseFieldControlProps<any>>
// ── Option types ──
export type AsyncOption = {
value: string
label: string
}
export type SelectOption = {
value: string
label: string
}