UNPKG

@form-kit/form-kit

Version:

Build dynamic forms based on zod schema or JSON

436 lines (421 loc) 14.2 kB
// src/form-kit.tsx import { useMemo } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm as useForm2 } from "react-hook-form"; // src/components/form.tsx import * as React2 from "react"; import "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, FormProvider, useFormContext, useFormState } from "react-hook-form"; // utils/cn.ts import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; function cn(...inputs) { return twMerge(clsx(inputs)); } // src/components/label.tsx import "react"; import * as LabelPrimitive from "@radix-ui/react-label"; import { jsx } from "react/jsx-runtime"; function Label({ className, ...props }) { return /* @__PURE__ */ jsx( LabelPrimitive.Root, { "data-slot": "label", className: cn( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className ), ...props } ); } // src/components/form.tsx import { jsx as jsx2 } from "react/jsx-runtime"; var Form = FormProvider; var FormFieldContext = React2.createContext({}); var FormField = ({ ...props }) => { return /* @__PURE__ */ jsx2(FormFieldContext.Provider, { value: { name: props.name }, children: /* @__PURE__ */ jsx2(Controller, { ...props }) }); }; var useFormField = () => { const fieldContext = React2.useContext(FormFieldContext); const itemContext = React2.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 }; }; var FormItemContext = React2.createContext({}); function FormItem({ className, ...props }) { const id = React2.useId(); return /* @__PURE__ */ jsx2(FormItemContext.Provider, { value: { id }, children: /* @__PURE__ */ jsx2("div", { "data-slot": "form-item", className: cn("grid gap-2", className), ...props }) }); } function FormLabel({ className, ...props }) { const { error, formItemId } = useFormField(); return /* @__PURE__ */ jsx2( Label, { "data-slot": "form-label", "data-error": !!error, className: cn("data-[error=true]:text-destructive", className), htmlFor: formItemId, ...props } ); } function FormControl({ ...props }) { const { error, formItemId, formDescriptionId, formMessageId } = useFormField(); return /* @__PURE__ */ jsx2( Slot, { "data-slot": "form-control", id: formItemId, "aria-describedby": !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`, "aria-invalid": !!error, ...props } ); } function FormDescription({ className, ...props }) { const { formDescriptionId } = useFormField(); return /* @__PURE__ */ jsx2( "p", { "data-slot": "form-description", id: formDescriptionId, className: cn("text-muted-foreground text-sm", className), ...props } ); } function FormMessage({ className, ...props }) { const { error, formMessageId } = useFormField(); const body = error ? String(error?.message ?? "") : props.children; if (!body) { return null; } return /* @__PURE__ */ jsx2( "p", { "data-slot": "form-message", id: formMessageId, className: cn("text-destructive text-sm", className), ...props, children: body } ); } // src/form-field.tsx import { useForm } from "react-hook-form"; // src/fields/input.tsx import { jsx as jsx3 } from "react/jsx-runtime"; var Input = (props) => { return /* @__PURE__ */ jsx3("input", { type: "text", className: "h-10 bg-primary", ...props }); }; var input_default = Input; // src/fields/date.tsx import { jsx as jsx4 } from "react/jsx-runtime"; var DateComponent = (props) => { return /* @__PURE__ */ jsx4("input", { type: "date", ...props }); }; var date_default = DateComponent; // src/fields/index.ts var BaseInputComponents = { text: input_default, date: date_default }; var fields_default = BaseInputComponents; // src/components/form-context.tsx import { createContext as createContext2, useContext as useContext2 } from "react"; var FormComponentsContext = createContext2(void 0); var FormComponentsProvider = FormComponentsContext.Provider; var useFormComponents = () => { const ctx = useContext2(FormComponentsContext); if (!ctx) throw new Error("FormComponentsContext not found"); return ctx; }; // src/field.tsx import { jsx as jsx5 } from "react/jsx-runtime"; var Field = ({ inputType, ...props }) => { const components = useFormComponents(); const Component = { ...fields_default, ...components }[inputType]; if (!Component) return null; return /* @__PURE__ */ jsx5(Component, { ...props }); }; var field_default = Field; // src/form-field.tsx import { jsx as jsx6, jsxs } from "react/jsx-runtime"; var FormField2 = ({ metadata }) => { const { control } = useForm(); return /* @__PURE__ */ jsx6( FormField, { control, name: metadata.name, render: ({ field }) => /* @__PURE__ */ jsxs(FormItem, { children: [ metadata.label && /* @__PURE__ */ jsx6(FormLabel, { children: metadata.label }), /* @__PURE__ */ jsx6(FormControl, { children: metadata.element ? metadata.element : /* @__PURE__ */ jsx6( field_default, { inputType: metadata.type, placeholder: metadata.placeholder, ...metadata.fieldProps, ...field } ) }), metadata.description && /* @__PURE__ */ jsx6(FormDescription, { children: metadata.description }), /* @__PURE__ */ jsx6(FormMessage, {}) ] }) } ); }; var form_field_default = FormField2; // src/components/button.tsx import "react"; import { Slot as Slot2 } from "@radix-ui/react-slot"; import { cva } from "class-variance-authority"; import { jsx as jsx7 } from "react/jsx-runtime"; var buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline" }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9" } }, defaultVariants: { variant: "default", size: "default" } } ); function Button({ className, variant, size, asChild = false, ...props }) { const Comp = asChild ? Slot2 : "button"; return /* @__PURE__ */ jsx7( Comp, { "data-slot": "button", className: cn(buttonVariants({ variant, size, className })), ...props } ); } // src/form-kit.tsx import "zod"; // utils/form.ts import { z } from "zod"; function generateGrid(inputs) { const GRID_WIDTH = 12; const result = []; let currentRow = []; let currentWidth = 0; const createPlaceholder = (size) => ({ name: `placeholder_${Math.random().toString(36).slice(2)}`, type: "hidden", size, label: "" }); for (const item of inputs) { const itemWidth = item.size || 12; if (itemWidth < 1 || itemWidth > 12) { throw new Error(`Invalid size ${itemWidth} for field ${item.name}`); } if (currentWidth + itemWidth > GRID_WIDTH || currentRow.length === 0 && itemWidth === GRID_WIDTH) { if (currentRow.length > 0) { while (currentWidth < GRID_WIDTH) { const remaining = GRID_WIDTH - currentWidth; currentRow.push(createPlaceholder(remaining)); currentWidth += remaining; } result.push(currentRow); } currentRow = []; currentWidth = 0; } currentRow.push({ ...item, size: itemWidth }); currentWidth += itemWidth; if (currentWidth === GRID_WIDTH) { result.push(currentRow); currentRow = []; currentWidth = 0; } } if (currentRow.length > 0) { while (currentWidth < GRID_WIDTH) { const remaining = GRID_WIDTH - currentWidth; currentRow.push(createPlaceholder(remaining)); currentWidth += remaining; } result.push(currentRow); } result.forEach((row, index) => { const rowWidth = row.reduce((sum, item) => sum + (item.size || 12), 0); if (rowWidth !== GRID_WIDTH) { throw new Error(`Row ${index} has invalid width: ${rowWidth}`); } }); return result; } function getDefaultValues(schema) { const shape = schema.shape; const defaultValues = {}; for (const item of Object.entries(shape)) { const [key, value] = item; const zodType = value; if ("_def" in zodType && "defaultValue" in zodType._def) { defaultValues[key] = zodType._def.defaultValue(); continue; } if (zodType instanceof z.ZodString) { defaultValues[key] = ""; } else if (zodType instanceof z.ZodNumber) { defaultValues[key] = 0; } else if (zodType instanceof z.ZodBoolean) { defaultValues[key] = false; } else if (zodType instanceof z.ZodArray) { defaultValues[key] = []; } else if (zodType instanceof z.ZodObject) { defaultValues[key] = getDefaultValues(zodType); } else if (zodType instanceof z.ZodOptional || zodType instanceof z.ZodNullable) { defaultValues[key] = zodType instanceof z.ZodOptional ? void 0 : null; } else { defaultValues[key] = void 0; } } return defaultValues; } function generateFields(schema, fieldTransformer) { const schemaShape = schema.shape; const defaultFields = Object.keys(schemaShape).map( (key) => ({ name: key, size: 12, type: "text", label: key }) ); if (!fieldTransformer) return defaultFields; const getTransformResult = (field, transformer) => { const { name } = field; const privateValues = { name }; if (typeof transformer === "function") { const transformResult = transformer(field); if (transformResult) { return { ...field, ...transformResult, ...privateValues }; } else return field; } else if (typeof transformer === "object") { const transformResult = transformer[name]; if (!transformResult) return field; return { ...field, ...typeof transformResult === "function" ? transformResult(field) : transformResult, ...privateValues }; } return field; }; return defaultFields.map((field) => { return getTransformResult(field, fieldTransformer); }); } // src/form-kit.tsx import { jsx as jsx8, jsxs as jsxs2 } from "react/jsx-runtime"; var FormKit = (props) => { const { schema, initialValues, fields = [], onSubmit, onCancel, fieldTransformer, components = {} } = props; const initialFormValues = useMemo(() => { if (initialValues) return initialValues; if (!schema) return {}; return getDefaultValues(schema); }, [initialValues, schema]); const form = useForm2({ resolver: zodResolver(schema), values: initialFormValues }); const isSubmitting = form.formState.isSubmitting; const formFields = useMemo(() => { if (fields.length > 0) return fields; if (!schema) return []; return generateFields(schema, fieldTransformer); }, [fields, schema, fieldTransformer]); const rows = useMemo(() => generateGrid(formFields), [formFields]); const handleCancel = () => { form.reset(); onCancel?.(); }; const handleSubmit = async (data) => { await onSubmit?.(data); }; return /* @__PURE__ */ jsx8(FormComponentsProvider, { value: components, children: /* @__PURE__ */ jsx8(Form, { ...form, children: /* @__PURE__ */ jsxs2("form", { onSubmit: form.handleSubmit(handleSubmit), className: "flex flex-col gap-6", children: [ /* @__PURE__ */ jsx8("div", { className: "flex flex-col gap-4", children: rows.map((row, index) => /* @__PURE__ */ jsx8("div", { className: "grid grid-cols-12 gap-4", children: row.map((col) => /* @__PURE__ */ jsx8( "div", { className: "w-full", style: { gridColumn: `span ${col.size} / span ${col.size}` }, children: col.type !== "hidden" && /* @__PURE__ */ jsx8(form_field_default, { metadata: col }) }, col.name )) }, index)) }), /* @__PURE__ */ jsxs2("div", { className: "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", children: [ /* @__PURE__ */ jsx8(Button, { type: "button", variant: "outline", onClick: handleCancel, disabled: isSubmitting, children: "Cancel" }), /* @__PURE__ */ jsx8(Button, { type: "submit", disabled: isSubmitting, children: isSubmitting ? "Saving..." : "Save Changes" }) ] }) ] }) }) }); }; export { FormKit }; //# sourceMappingURL=index.js.map