@alsocoder/apna-form
v0.1.2
Published
React form layer with React Hook Form, Zod validation, and connected Apna field components.
Maintainers
Readme
@alsocoder/apna-form
React form layer built on React Hook Form with optional Zod validation and connected Apna field components.
Features
ApnaFormwith Zod schema validation- Connected fields: Input, Textarea, OTP, Select, DatePicker, DateRange, DateTime, Editor, Upload, Media (optional)
- Field arrays (
ApnaFormFieldArray) - Conditional fields (
ApnaFormWatch,showWhen) - Multi-step forms (
ApnaFormSteps) - Auto-save drafts (
enableAutoSave+onAutoSave) - Dirty state (
ApnaFormStatus,useApnaForm) - Async validation (Zod
.refine()) - JSON form builder (
ApnaFormBuilder) with smart responsive grid - Native HTML
<form>submit support - React Hook Form compatible (
useApnaForm,ApnaFormField)
Install
npm install @alsocoder/apna-form react-hook-form zod @hookform/resolversPeer dependencies: react, react-dom, react-hook-form.
@alsocoder/apna-form bundles these as dependencies — you do not install them separately:
@alsocoder/apna-input@alsocoder/apna-select@alsocoder/apna-date-picker@alsocoder/apna-editor@alsocoder/apna-upload
Optional: media picker
For ApnaFormMediaPicker or builder type: "media":
npm install @alsocoder/apna-media-librarySetup
import "@alsocoder/apna-form/styles.css"This bundles Apna Input, Select, DatePicker, Editor, Upload, and form layout styles.
Important: Import this after your global CSS. Avoid generic rules like button[type="button"] { padding; background; } — they override Select/Editor/DatePicker icon buttons. The bundle includes a button guard layer for common Apna UI buttons.
With media picker
import "@alsocoder/apna-form/styles.css"
import "@alsocoder/apna-media-library/styles.css"
import { ApnaMediaProvider } from "@alsocoder/apna-media-library"
export function App() {
return (
<ApnaMediaProvider adapter={yourAdapter}>
<YourForm />
</ApnaMediaProvider>
)
}Basic usage
import { z } from "zod"
import {
ApnaForm,
ApnaFormInput,
ApnaFormSelect,
ApnaFormDatePicker,
} from "@alsocoder/apna-form"
import "@alsocoder/apna-form/styles.css"
const schema = z.object({
name: z.string().min(2),
country: z.string(),
dob: z.string(),
})
export function ProfileForm() {
return (
<ApnaForm
schema={schema}
defaultValues={{ name: "", country: "", dob: "" }}
onSubmit={(values) => console.log(values)}
>
<ApnaFormInput name="name" label="Name" required />
<ApnaFormSelect name="country" options={countries} label="Country" />
<ApnaFormDatePicker name="dob" label="Date of birth" />
<button type="submit">Save</button>
</ApnaForm>
)
}Conditional fields
<ApnaFormWatch names={["country"]}>
{(country) =>
country === "IN" ? (
<ApnaFormInput name="pincode" label="PIN code" format="digits" />
) : null
}
</ApnaFormWatch>Or declarative:
<ApnaFormInput name="gstin" showWhen={(values) => values.country === "IN"} />Multi-step
<ApnaForm schema={schema} onSubmit={save}>
<ApnaFormSteps>
<ApnaFormStep id="personal" title="Personal" fields={["name", "email"]}>
<ApnaFormInput name="name" label="Name" />
<ApnaFormInput name="email" label="Email" format="email" />
</ApnaFormStep>
<ApnaFormStep id="address" title="Address" fields={["country"]}>
<ApnaFormSelect name="country" options={countries} label="Country" />
</ApnaFormStep>
<ApnaFormStepActions />
</ApnaFormSteps>
</ApnaForm>Field arrays
<ApnaFormFieldArray name="phones">
{(fields, { append, remove }) => (
<>
{fields.map((field, index) => (
<ApnaFormInput
key={field.id}
name={`phones.${index}.number`}
label={`Phone ${index + 1}`}
/>
))}
<button type="button" onClick={() => append({ number: "" })}>
Add phone
</button>
</>
)}
</ApnaFormFieldArray>Auto-save
<ApnaForm
schema={schema}
enableAutoSave
autoSaveDebounceMs={800}
onAutoSave={(values) => saveDraft(values)}
onSubmit={publish}
>
...
</ApnaForm>Form builder
ApnaFormBuilder renders fields from a JSON config on a 12-column responsive grid.
| Prop | Purpose |
|------|---------|
| columns | Default fields per row per breakpoint (span = 12 / columns) |
| field.colSpan | Per-field override (1–12 columns) |
| field.showWhen | Conditionally show/hide fields; hidden fields are removed from the grid (no empty cells) |
| type: "editor" | Always full width (12 columns) on every breakpoint |
| type: "upload" | File upload — tall field group (full width like textarea); pass upload on ApnaForm / ApnaFormBuilder (or per-field props.upload) |
| type: "media" | Lazy-loaded media picker — same grid behavior as input/select (optional peer) |
| type: "textarea" | Tall field group — only pairs with other tall fields (e.g. upload), otherwise full width |
Smart layout
The builder groups fields by layout type:
- compact — input, select, date, dateRange, datetime, otp, media
- tall — textarea, upload
- full — editor
Within each group, fields auto-size per row: 3 → one-third each, 2 → half each, 1 → full width. Mixed types (e.g. input + textarea) never share a row.
Breakpoints: default (mobile), sm 640px, md 768px, lg 1024px, xl 1280px.
const config = [
{ type: "input", name: "title", label: "Title", colSpan: { md: 8 } },
{ type: "select", name: "role", label: "Role", colSpan: { md: 4 }, props: { options: roles } },
{ type: "date", name: "startDate", label: "Start date" },
{ type: "editor", name: "description", label: "Description" },
{
type: "input",
name: "pincode",
label: "PIN code",
showWhen: (values) => values.country === "IN",
props: { format: "digits", maxLength: 6 },
},
] as const
<ApnaFormBuilder
columns={{ default: 1, md: 2, lg: 3 }}
config={config}
schema={schema}
onSubmit={save}
/>Editor, upload, and media field types are lazy-loaded in the builder — their JS chunks load only when used in config.
File upload (built-in)
Pass an upload function on ApnaForm or ApnaFormBuilder. Upload fields inherit it unless overridden in field props:
import type { ApnaUploadFn } from "@alsocoder/apna-upload"
const uploadFile: ApnaUploadFn = async (file, { signal, onProgress }) => {
onProgress(50)
const res = await fetch("/api/upload", { method: "POST", body: formData, signal })
onProgress(100)
return res.json() // e.g. { file: { path: "/uploads/..." } }
}
<ApnaFormBuilder
upload={uploadFile}
transformUploadResult={(result) => result.file.path}
defaultUploadProps={{
accept: "image/*",
maxSize: 5 * 1024 * 1024,
resultMode: "url",
beforeUpload: async (file) => {
// compress, crop, watermark, etc.
return file
},
}}
config={[
{ type: "upload", name: "resume", label: "Resume", props: { accept: ".pdf" } },
{ type: "upload", name: "avatar", label: "Avatar", props: { upload: otherHandler } },
]}
schema={schema}
onSubmit={save}
/><ApnaFormUpload name="resume" label="Resume" accept=".pdf" />Value shape depends on resultMode:
| resultMode | Single field | Multiple field |
|--------------|--------------|----------------|
| "url" (default) | string | string[] |
| "file" | File | File[] |
Match your Zod schema to the chosen resultMode.
Media picker (optional)
npm install @alsocoder/apna-media-libraryWrap your app with ApnaMediaProvider, import media styles, then use in builder or as a connected field:
{ type: "media", name: "cover", label: "Cover", props: { folder: "blog/covers" } }<ApnaFormMediaPicker name="avatar" label="Avatar" folder="misc" />For editor image insertion via media library, pass imageUpload.pick in editor props.
Advanced: custom field
<ApnaFormField
name="email"
render={({ field, fieldState }) => (
<ApnaInput
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>Yup (optional)
import { createYupResolver } from "@alsocoder/apna-form"
const resolver = await createYupResolver(yupSchema)
<ApnaForm resolver={resolver} onSubmit={save}>...</ApnaForm>Development
npm install
npm run buildLicense
MIT
