@form-kit/form-kit
Version:
Build dynamic forms based on zod schema or JSON
436 lines (421 loc) • 14.2 kB
JavaScript
// 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