diff --git a/.changeset/ui-field-layout.md b/.changeset/ui-field-layout.md new file mode 100644 index 00000000..855a3ccc --- /dev/null +++ b/.changeset/ui-field-layout.md @@ -0,0 +1,10 @@ +--- +"@buildnbuzz/form-core": patch +--- + +Add support for the new UI field layout type and unregistered custom field types. + +- Add `UiField` layout field type for rendering inline dynamic HTML or React markup. +- Allow defining custom, unregistered field types inside `defineSchema` with automatic typings. +- Downgrade the unrecognized field type check from an error to a warning in the schema validator. +- Add `defineField` and `defineFields` helper functions for programmatic field generation. diff --git a/.changeset/ui-field-react.md b/.changeset/ui-field-react.md new file mode 100644 index 00000000..23990672 --- /dev/null +++ b/.changeset/ui-field-react.md @@ -0,0 +1,10 @@ +--- +"@buildnbuzz/form-react": patch +--- + +Add support for rendering UI layout fields and exporting new layout types. + +- Export `UiField`, `FormSchemaInput`, `FieldInput`, and `CustomFieldInput` from `packages/form-react`. +- Include `"ui"` layout type in nested error count collections. +- Export `defineField`, `defineFields` for programmatic field generation. + diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts index b5f9f7cc..04b3d710 100644 --- a/apps/web/.source/browser.ts +++ b/apps/web/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"conditional-logic.mdx": () => import("../content/docs/conditional-logic.mdx?collection=docs"), "custom-fields.mdx": () => import("../content/docs/custom-fields.mdx?collection=docs"), "developer-guide.mdx": () => import("../content/docs/developer-guide.mdx?collection=docs"), "dynamic-values.mdx": () => import("../content/docs/dynamic-values.mdx?collection=docs"), "field-hooks.mdx": () => import("../content/docs/field-hooks.mdx?collection=docs"), "field-wrappers.mdx": () => import("../content/docs/field-wrappers.mdx?collection=docs"), "form-component.mdx": () => import("../content/docs/form-component.mdx?collection=docs"), "form-provider.mdx": () => import("../content/docs/form-provider.mdx?collection=docs"), "index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "migration.mdx": () => import("../content/docs/migration.mdx?collection=docs"), "option-resolvers.mdx": () => import("../content/docs/option-resolvers.mdx?collection=docs"), "output-transformation.mdx": () => import("../content/docs/output-transformation.mdx?collection=docs"), "quick-start.mdx": () => import("../content/docs/quick-start.mdx?collection=docs"), "render-fields.mdx": () => import("../content/docs/render-fields.mdx?collection=docs"), "schema.mdx": () => import("../content/docs/schema.mdx?collection=docs"), "use-form.mdx": () => import("../content/docs/use-form.mdx?collection=docs"), "validation.mdx": () => import("../content/docs/validation.mdx?collection=docs"), "fields/types.mdx": () => import("../content/docs/fields/types.mdx?collection=docs"), "fields/layout/array.mdx": () => import("../content/docs/fields/layout/array.mdx?collection=docs"), "fields/layout/collapsible.mdx": () => import("../content/docs/fields/layout/collapsible.mdx?collection=docs"), "fields/layout/group.mdx": () => import("../content/docs/fields/layout/group.mdx?collection=docs"), "fields/layout/row.mdx": () => import("../content/docs/fields/layout/row.mdx?collection=docs"), "fields/layout/tabs.mdx": () => import("../content/docs/fields/layout/tabs.mdx?collection=docs"), "fields/data/checkbox.mdx": () => import("../content/docs/fields/data/checkbox.mdx?collection=docs"), "fields/data/date.mdx": () => import("../content/docs/fields/data/date.mdx?collection=docs"), "fields/data/email.mdx": () => import("../content/docs/fields/data/email.mdx?collection=docs"), "fields/data/number.mdx": () => import("../content/docs/fields/data/number.mdx?collection=docs"), "fields/data/password.mdx": () => import("../content/docs/fields/data/password.mdx?collection=docs"), "fields/data/radio.mdx": () => import("../content/docs/fields/data/radio.mdx?collection=docs"), "fields/data/select.mdx": () => import("../content/docs/fields/data/select.mdx?collection=docs"), "fields/data/switch.mdx": () => import("../content/docs/fields/data/switch.mdx?collection=docs"), "fields/data/tags.mdx": () => import("../content/docs/fields/data/tags.mdx?collection=docs"), "fields/data/text.mdx": () => import("../content/docs/fields/data/text.mdx?collection=docs"), "fields/data/textarea.mdx": () => import("../content/docs/fields/data/textarea.mdx?collection=docs"), "fields/data/upload.mdx": () => import("../content/docs/fields/data/upload.mdx?collection=docs"), }), + docs: create.doc("docs", {"conditional-logic.mdx": () => import("../content/docs/conditional-logic.mdx?collection=docs"), "custom-fields.mdx": () => import("../content/docs/custom-fields.mdx?collection=docs"), "developer-guide.mdx": () => import("../content/docs/developer-guide.mdx?collection=docs"), "dynamic-values.mdx": () => import("../content/docs/dynamic-values.mdx?collection=docs"), "field-hooks.mdx": () => import("../content/docs/field-hooks.mdx?collection=docs"), "field-wrappers.mdx": () => import("../content/docs/field-wrappers.mdx?collection=docs"), "form-component.mdx": () => import("../content/docs/form-component.mdx?collection=docs"), "form-provider.mdx": () => import("../content/docs/form-provider.mdx?collection=docs"), "index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "migration.mdx": () => import("../content/docs/migration.mdx?collection=docs"), "option-resolvers.mdx": () => import("../content/docs/option-resolvers.mdx?collection=docs"), "output-transformation.mdx": () => import("../content/docs/output-transformation.mdx?collection=docs"), "quick-start.mdx": () => import("../content/docs/quick-start.mdx?collection=docs"), "render-fields.mdx": () => import("../content/docs/render-fields.mdx?collection=docs"), "schema.mdx": () => import("../content/docs/schema.mdx?collection=docs"), "use-form.mdx": () => import("../content/docs/use-form.mdx?collection=docs"), "validation.mdx": () => import("../content/docs/validation.mdx?collection=docs"), "fields/types.mdx": () => import("../content/docs/fields/types.mdx?collection=docs"), "fields/data/checkbox.mdx": () => import("../content/docs/fields/data/checkbox.mdx?collection=docs"), "fields/data/date.mdx": () => import("../content/docs/fields/data/date.mdx?collection=docs"), "fields/data/email.mdx": () => import("../content/docs/fields/data/email.mdx?collection=docs"), "fields/data/number.mdx": () => import("../content/docs/fields/data/number.mdx?collection=docs"), "fields/data/password.mdx": () => import("../content/docs/fields/data/password.mdx?collection=docs"), "fields/data/radio.mdx": () => import("../content/docs/fields/data/radio.mdx?collection=docs"), "fields/data/select.mdx": () => import("../content/docs/fields/data/select.mdx?collection=docs"), "fields/data/switch.mdx": () => import("../content/docs/fields/data/switch.mdx?collection=docs"), "fields/data/tags.mdx": () => import("../content/docs/fields/data/tags.mdx?collection=docs"), "fields/data/text.mdx": () => import("../content/docs/fields/data/text.mdx?collection=docs"), "fields/data/textarea.mdx": () => import("../content/docs/fields/data/textarea.mdx?collection=docs"), "fields/data/upload.mdx": () => import("../content/docs/fields/data/upload.mdx?collection=docs"), "fields/layout/array.mdx": () => import("../content/docs/fields/layout/array.mdx?collection=docs"), "fields/layout/collapsible.mdx": () => import("../content/docs/fields/layout/collapsible.mdx?collection=docs"), "fields/layout/group.mdx": () => import("../content/docs/fields/layout/group.mdx?collection=docs"), "fields/layout/row.mdx": () => import("../content/docs/fields/layout/row.mdx?collection=docs"), "fields/layout/tabs.mdx": () => import("../content/docs/fields/layout/tabs.mdx?collection=docs"), "fields/layout/ui.mdx": () => import("../content/docs/fields/layout/ui.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts index 4d96bd8b..7a3b0c06 100644 --- a/apps/web/.source/server.ts +++ b/apps/web/.source/server.ts @@ -1,21 +1,22 @@ // @ts-nocheck -import * as __fd_glob_39 from "../content/docs/fields/data/upload.mdx?collection=docs" -import * as __fd_glob_38 from "../content/docs/fields/data/textarea.mdx?collection=docs" -import * as __fd_glob_37 from "../content/docs/fields/data/text.mdx?collection=docs" -import * as __fd_glob_36 from "../content/docs/fields/data/tags.mdx?collection=docs" -import * as __fd_glob_35 from "../content/docs/fields/data/switch.mdx?collection=docs" -import * as __fd_glob_34 from "../content/docs/fields/data/select.mdx?collection=docs" -import * as __fd_glob_33 from "../content/docs/fields/data/radio.mdx?collection=docs" -import * as __fd_glob_32 from "../content/docs/fields/data/password.mdx?collection=docs" -import * as __fd_glob_31 from "../content/docs/fields/data/number.mdx?collection=docs" -import * as __fd_glob_30 from "../content/docs/fields/data/email.mdx?collection=docs" -import * as __fd_glob_29 from "../content/docs/fields/data/date.mdx?collection=docs" -import * as __fd_glob_28 from "../content/docs/fields/data/checkbox.mdx?collection=docs" -import * as __fd_glob_27 from "../content/docs/fields/layout/tabs.mdx?collection=docs" -import * as __fd_glob_26 from "../content/docs/fields/layout/row.mdx?collection=docs" -import * as __fd_glob_25 from "../content/docs/fields/layout/group.mdx?collection=docs" -import * as __fd_glob_24 from "../content/docs/fields/layout/collapsible.mdx?collection=docs" -import * as __fd_glob_23 from "../content/docs/fields/layout/array.mdx?collection=docs" +import * as __fd_glob_40 from "../content/docs/fields/layout/ui.mdx?collection=docs" +import * as __fd_glob_39 from "../content/docs/fields/layout/tabs.mdx?collection=docs" +import * as __fd_glob_38 from "../content/docs/fields/layout/row.mdx?collection=docs" +import * as __fd_glob_37 from "../content/docs/fields/layout/group.mdx?collection=docs" +import * as __fd_glob_36 from "../content/docs/fields/layout/collapsible.mdx?collection=docs" +import * as __fd_glob_35 from "../content/docs/fields/layout/array.mdx?collection=docs" +import * as __fd_glob_34 from "../content/docs/fields/data/upload.mdx?collection=docs" +import * as __fd_glob_33 from "../content/docs/fields/data/textarea.mdx?collection=docs" +import * as __fd_glob_32 from "../content/docs/fields/data/text.mdx?collection=docs" +import * as __fd_glob_31 from "../content/docs/fields/data/tags.mdx?collection=docs" +import * as __fd_glob_30 from "../content/docs/fields/data/switch.mdx?collection=docs" +import * as __fd_glob_29 from "../content/docs/fields/data/select.mdx?collection=docs" +import * as __fd_glob_28 from "../content/docs/fields/data/radio.mdx?collection=docs" +import * as __fd_glob_27 from "../content/docs/fields/data/password.mdx?collection=docs" +import * as __fd_glob_26 from "../content/docs/fields/data/number.mdx?collection=docs" +import * as __fd_glob_25 from "../content/docs/fields/data/email.mdx?collection=docs" +import * as __fd_glob_24 from "../content/docs/fields/data/date.mdx?collection=docs" +import * as __fd_glob_23 from "../content/docs/fields/data/checkbox.mdx?collection=docs" import * as __fd_glob_22 from "../content/docs/fields/types.mdx?collection=docs" import * as __fd_glob_21 from "../content/docs/validation.mdx?collection=docs" import * as __fd_glob_20 from "../content/docs/use-form.mdx?collection=docs" @@ -47,4 +48,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "fields/meta.json": __fd_glob_1, "fields/data/meta.json": __fd_glob_2, "fields/layout/meta.json": __fd_glob_3, }, {"conditional-logic.mdx": __fd_glob_4, "custom-fields.mdx": __fd_glob_5, "developer-guide.mdx": __fd_glob_6, "dynamic-values.mdx": __fd_glob_7, "field-hooks.mdx": __fd_glob_8, "field-wrappers.mdx": __fd_glob_9, "form-component.mdx": __fd_glob_10, "form-provider.mdx": __fd_glob_11, "index.mdx": __fd_glob_12, "installation.mdx": __fd_glob_13, "migration.mdx": __fd_glob_14, "option-resolvers.mdx": __fd_glob_15, "output-transformation.mdx": __fd_glob_16, "quick-start.mdx": __fd_glob_17, "render-fields.mdx": __fd_glob_18, "schema.mdx": __fd_glob_19, "use-form.mdx": __fd_glob_20, "validation.mdx": __fd_glob_21, "fields/types.mdx": __fd_glob_22, "fields/layout/array.mdx": __fd_glob_23, "fields/layout/collapsible.mdx": __fd_glob_24, "fields/layout/group.mdx": __fd_glob_25, "fields/layout/row.mdx": __fd_glob_26, "fields/layout/tabs.mdx": __fd_glob_27, "fields/data/checkbox.mdx": __fd_glob_28, "fields/data/date.mdx": __fd_glob_29, "fields/data/email.mdx": __fd_glob_30, "fields/data/number.mdx": __fd_glob_31, "fields/data/password.mdx": __fd_glob_32, "fields/data/radio.mdx": __fd_glob_33, "fields/data/select.mdx": __fd_glob_34, "fields/data/switch.mdx": __fd_glob_35, "fields/data/tags.mdx": __fd_glob_36, "fields/data/text.mdx": __fd_glob_37, "fields/data/textarea.mdx": __fd_glob_38, "fields/data/upload.mdx": __fd_glob_39, }); \ No newline at end of file +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "fields/meta.json": __fd_glob_1, "fields/data/meta.json": __fd_glob_2, "fields/layout/meta.json": __fd_glob_3, }, {"conditional-logic.mdx": __fd_glob_4, "custom-fields.mdx": __fd_glob_5, "developer-guide.mdx": __fd_glob_6, "dynamic-values.mdx": __fd_glob_7, "field-hooks.mdx": __fd_glob_8, "field-wrappers.mdx": __fd_glob_9, "form-component.mdx": __fd_glob_10, "form-provider.mdx": __fd_glob_11, "index.mdx": __fd_glob_12, "installation.mdx": __fd_glob_13, "migration.mdx": __fd_glob_14, "option-resolvers.mdx": __fd_glob_15, "output-transformation.mdx": __fd_glob_16, "quick-start.mdx": __fd_glob_17, "render-fields.mdx": __fd_glob_18, "schema.mdx": __fd_glob_19, "use-form.mdx": __fd_glob_20, "validation.mdx": __fd_glob_21, "fields/types.mdx": __fd_glob_22, "fields/data/checkbox.mdx": __fd_glob_23, "fields/data/date.mdx": __fd_glob_24, "fields/data/email.mdx": __fd_glob_25, "fields/data/number.mdx": __fd_glob_26, "fields/data/password.mdx": __fd_glob_27, "fields/data/radio.mdx": __fd_glob_28, "fields/data/select.mdx": __fd_glob_29, "fields/data/switch.mdx": __fd_glob_30, "fields/data/tags.mdx": __fd_glob_31, "fields/data/text.mdx": __fd_glob_32, "fields/data/textarea.mdx": __fd_glob_33, "fields/data/upload.mdx": __fd_glob_34, "fields/layout/array.mdx": __fd_glob_35, "fields/layout/collapsible.mdx": __fd_glob_36, "fields/layout/group.mdx": __fd_glob_37, "fields/layout/row.mdx": __fd_glob_38, "fields/layout/tabs.mdx": __fd_glob_39, "fields/layout/ui.mdx": __fd_glob_40, }); \ No newline at end of file diff --git a/apps/web/components/docs/first-form-demos.tsx b/apps/web/components/docs/first-form-demos.tsx deleted file mode 100755 index 468c7f85..00000000 --- a/apps/web/components/docs/first-form-demos.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { defineSchema } from "@buildnbuzz/form-react"; -import { useForm, Form, FormProvider } from "@buildnbuzz/form-react"; -import { registry as shadcnRegistry } from "@/registry/shadcn/registry"; -import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { ToastCodeBlock } from "@/components/ui/toast-code-block"; -import { toast } from "sonner"; - -// Step 1: Basic form -const basicSchema = defineSchema({ - fields: [ - { type: "text", name: "name", label: "Name", required: true }, - { type: "email", name: "email", label: "Email", required: true }, - ], -}); - -const basicCode = `import { defineSchema, type InferType } from "@buildnbuzz/form-react"; -import { useForm, Form } from "@buildnbuzz/form-react"; - -const schema = defineSchema({ - fields: [ - { type: "text", name: "name", label: "Name", required: true }, - { type: "email", name: "email", label: "Email", required: true }, - ], -}); - -type FormData = InferType; - -export function ContactForm() { - const form = useForm({ - schema, - onSubmit: ({ value }) => { - console.log(value); // { name: string, email: string } - }, - }); - - return ( -
- s.canSubmit}> - {(canSubmit) => ( - - )} - -
- ); -}`; - -// Step 2: With validation -const validationSchema = defineSchema({ - fields: [ - { - type: "text", - name: "username", - label: "Username", - required: true, - minLength: 3, - }, - { type: "email", name: "email", label: "Email", required: true }, - { - type: "password", - name: "password", - label: "Password", - required: true, - minLength: 8, - }, - ], -}); - -const validationCode = `import { defineSchema } from "@buildnbuzz/form-react"; -import { useForm, Form } from "@buildnbuzz/form-react"; - -const schema = defineSchema({ - fields: [ - { - type: "text", - name: "username", - label: "Username", - required: true, - minLength: 3 // Auto-validates minimum length - }, - { - type: "email", - name: "email", - label: "Email", - required: true // Auto-validates email format - }, - { - type: "password", - name: "password", - label: "Password", - required: true, - minLength: 8 // Auto-validates minimum 8 characters - }, - ], -}); - -const form = useForm({ schema }); - -// Validation runs automatically on submit -// Set derivedValidationMode: "onBlur" to run on blur`; - -// Step 3: With more fields -const fullSchema = defineSchema({ - fields: [ - { type: "text", name: "name", label: "Full Name", required: true }, - { type: "email", name: "email", label: "Email", required: true }, - { - type: "select", - name: "role", - label: "Role", - options: [ - { label: "Developer", value: "dev" }, - { label: "Designer", value: "design" }, - { label: "Product Manager", value: "pm" }, - ], - required: true, - }, - { type: "checkbox", name: "newsletter", label: "Subscribe to newsletter" }, - ], -}); - -const fullCode = `import { defineSchema, type InferType } from "@buildnbuzz/form-react"; -import { useForm, Form } from "@buildnbuzz/form-react"; - -const schema = defineSchema({ - fields: [ - { type: "text", name: "name", label: "Full Name", required: true }, - { type: "email", name: "email", label: "Email", required: true }, - { - type: "select", - name: "role", - label: "Role", - options: [ - { label: "Developer", value: "dev" }, - { label: "Designer", value: "design" }, - { label: "Product Manager", value: "pm" }, - ], - required: true, - }, - { type: "checkbox", name: "newsletter", label: "Subscribe to newsletter" }, - ], -}); - -type FormData = InferType; - -export function SignUpForm() { - const form = useForm({ - schema, - onSubmit: ({ value }) => { - // value is typed: { name: string, email: string, role: string, newsletter: boolean } - console.log(value); - }, - }); - - return ( -
- s.canSubmit}> - {(canSubmit) => ( - - )} - -
- ); -}`; - -function DemoWrapper({ - schema, - code, - submitLabel = "Submit", -}: { - schema: ReturnType; - code: string; - submitLabel?: string; -}) { - return ( -
- - - Code - Preview - - -
- -
-
- -
- - - -
-
-
-
- ); -} - -function FormPreview({ - schema, - submitLabel, -}: { - schema: ReturnType; - submitLabel: string; -}) { - const form = useForm({ - schema, - onSubmit: ({ value }) => { - toast("Form Submitted!", { - description: , - }); - }, - }); - - return ( -
- s.canSubmit}> - {(canSubmit) => ( - - )} - -
- ); -} - -export function BasicFormDemo() { - return ( - - ); -} - -export function ValidationFormDemo() { - return ( - - ); -} - -export function FullFormDemo() { - return ( - - ); -} diff --git a/apps/web/components/examples/index.ts b/apps/web/components/examples/index.ts index 24dfb88a..472f108a 100755 --- a/apps/web/components/examples/index.ts +++ b/apps/web/components/examples/index.ts @@ -22,4 +22,4 @@ export { default as CountryStateForm } from "../../registry/shadcn/examples/coun export { default as DocumentUploadForm } from "../../registry/shadcn/examples/document-upload-form"; export { default as GalleryUploadForm } from "../../registry/shadcn/examples/gallery-upload-form"; export { default as ProfileUploadForm } from "../../registry/shadcn/examples/profile-upload-form"; - +export { default as UiAndCustomFieldsExample } from "../../registry/shadcn/examples/ui-and-custom-fields-form"; diff --git a/apps/web/components/mdx-components.tsx b/apps/web/components/mdx-components.tsx index 9735dbf8..b3e96d37 100755 --- a/apps/web/components/mdx-components.tsx +++ b/apps/web/components/mdx-components.tsx @@ -8,11 +8,6 @@ import { Tab, Tabs } from "fumadocs-ui/components/tabs"; import { Steps, Step } from "fumadocs-ui/components/steps"; import { TypeTable } from "fumadocs-ui/components/type-table"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; -import { - BasicFormDemo, - ValidationFormDemo, - FullFormDemo, -} from "@/components/docs/first-form-demos"; export function getMDXComponents(components?: MDXComponents): MDXComponents { return { @@ -31,9 +26,6 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { Step, Accordion, Accordions, - BasicFormDemo, - ValidationFormDemo, - FullFormDemo, ...components, }; } diff --git a/apps/web/content/docs/custom-fields.mdx b/apps/web/content/docs/custom-fields.mdx index aa2db033..ee220e5d 100644 --- a/apps/web/content/docs/custom-fields.mdx +++ b/apps/web/content/docs/custom-fields.mdx @@ -7,8 +7,8 @@ description: Build your own field component UI using any UI library and wire it BuzzForm's registry pattern lets you provide your own component for any supported field type. The built-in shadcn components are just one implementation — you can build your own using any UI library (Radix, Mantine, Chakra, plain HTML, etc.) and register them in the field registry. - - This guide covers building **custom UI components** for BuzzForm's existing field types (text, email, select, etc.). The set of supported field types is defined by `@buildnbuzz/form-react` and cannot be extended at this time. + + **New in v0.1:** In addition to overriding standard fields, you can now define completely **custom unregistered field types** (e.g. `color-picker`, `rich-editor`, `signature`) directly in your schemas with automatic type inference! ## How It Works @@ -366,6 +366,67 @@ export const registry: FieldRegistry = { }; ``` +## Unregistered Custom Field Types + +Sometimes, a custom component doesn't fit into the standard built-in data types (like `text`, `select`, etc.). BuzzForm allows you to declare arbitrary, unregistered field types directly in your schemas: + +### 1. Declare in the Schema +Any unrecognized `type` string will be allowed by `defineSchema`. It will trigger a lightweight schema validation warning instead of a hard crash: + +```tsx +export const themeSchema = defineSchema({ + fields: [ + { type: "text", name: "themeName", label: "Theme Name", required: true }, + { + type: "color-picker", // Custom/unregistered field type + name: "primaryColor", + label: "Primary Color", + defaultValue: "#000000", + }, + ], +}); +``` + +### 2. Implement the Component +Create a custom component. Since `color-picker` is unregistered, we do not have a built-in TypeScript interface for it. Simply call `useDataField` without a generic type parameter (or write your own custom interface extending `CustomFieldInput`): + +```tsx title="components/my-form/fields/color-picker.tsx" +"use client"; + +import { useDataField } from "@buildnbuzz/form-react"; + +export function ColorPickerField() { + const { fieldApi, label, handleChange } = useDataField(); + const value = (fieldApi.state.value as string) ?? "#000000"; + + return ( +
+ {label && } + handleChange(e.target.value)} + className="h-8 w-8 rounded cursor-pointer border-0" + /> +
+ ); +} +``` + +### 3. Register the Custom Type +Register your component with the matching `type` string in the component registry: + +```ts title="components/my-form/registry.ts" +import { ColorPickerField } from "./fields/color-picker"; + +export const registry = { + // ... standard overrides + "color-picker": ColorPickerField, // Wire the unregistered type +}; +``` + +--- + ## Related - [Field Hooks](/docs/field-hooks) diff --git a/apps/web/content/docs/fields/layout/meta.json b/apps/web/content/docs/fields/layout/meta.json old mode 100644 new mode 100755 index aaceae72..ccff98a6 --- a/apps/web/content/docs/fields/layout/meta.json +++ b/apps/web/content/docs/fields/layout/meta.json @@ -1,4 +1,4 @@ { "title": "Layout Fields", - "pages": ["row", "group", "collapsible", "tabs", "array"] + "pages": ["row", "group", "collapsible", "tabs", "array", "ui"] } diff --git a/apps/web/content/docs/fields/layout/ui.mdx b/apps/web/content/docs/fields/layout/ui.mdx new file mode 100644 index 00000000..e038cf79 --- /dev/null +++ b/apps/web/content/docs/fields/layout/ui.mdx @@ -0,0 +1,125 @@ +--- +title: UI Layout Field +description: Render custom inline HTML or React components directly in your form layouts. +--- + +# UI Layout Field + +The `ui` field is a special layout component that lets you render static markup, custom HTML elements, or arbitrary React components (like headers, dividers, informative cards, or custom banners) directly inside a form layout. + +Unlike standard data-bearing fields, a UI layout field **does not capture data** and is not present in the inferred schema type or submitted form data. + +--- + +## Installation + +If you are using the Shadcn UI adapter, you can install the `ui` field component automatically via the Shadcn CLI: + +```bash +npx shadcn@latest add @buzzform/ui +``` + +This will download the field component to your local registry (`components/buzzform/fields/ui.tsx`) and set up its configuration. + +--- + +## Example Schema + +To render custom content, provide static elements, inline markup, or React nodes inside the `content` property. + +```tsx +import { defineSchema } from "@buildnbuzz/form-react"; +import { Separator } from "@/components/ui/separator"; + +export const profileSchema = defineSchema({ + fields: [ + { type: "text", name: "username", label: "Username", required: true }, + + // Renders an inline divider layout + { + type: "ui", + content: ( +
+ +

+ Personal Information +

+
+ ), + }, + + { type: "text", name: "firstName", label: "First Name" }, + { type: "text", name: "lastName", label: "Last Name" }, + ], +}); +``` + +--- + +## Registry Component + +In your custom field component registry, the `ui` renderer fetches the dynamic or static content using `resolveExpr` or renders `field.content` directly: + +```tsx title="components/my-form/fields/ui.tsx" +"use client"; + +import type { ReactNode } from "react"; +import { useLayoutField, resolveExpr } from "@buildnbuzz/form-react"; +import type { UiField as UiFieldDef } from "@buildnbuzz/form-react"; + +export function UiFieldComponent() { + const { field, formData, contextData, registries } = useLayoutField(); + + // Resolve expressions if you pass dynamic logic, or render directly + const resolvedContent = resolveExpr( + field.content, + { data: formData, context: contextData }, + registries?.fns + ); + + return ( +
+ {resolvedContent} +
+ ); +} +``` + +Then register it: +```ts title="components/my-form/registry.ts" +export const registry = { + // ... other fields + ui: UiFieldComponent, +}; +``` + +--- + +## Properties + +The `ui` layout field accepts all base layout configurations: + + + +--- + +## Dynamic Visibility + +Like all layout components, the `ui` field can be conditionally displayed based on other form field values using the `condition` property: + +```tsx +const schema = defineSchema({ + fields: [ + { type: "checkbox", name: "showTips", label: "Show helpful tips" }, + { + type: "ui", + condition: { $eq: [{ $data: "showTips" }, true] }, + content: ( +
+ 💡 Tip: Complete all sections for a stronger profile! +
+ ), + }, + ], +}); +``` diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx index 98bf9764..1d2958ab 100755 --- a/apps/web/content/docs/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -55,6 +55,7 @@ npx shadcn@latest add @buzzform/init npx shadcn@latest add @buzzform/text npx shadcn@latest add @buzzform/email npx shadcn@latest add @buzzform/select +npx shadcn@latest add @buzzform/ui # ... etc ``` diff --git a/apps/web/lib/examples.ts b/apps/web/lib/examples.ts index 756df379..ab4a47f9 100644 --- a/apps/web/lib/examples.ts +++ b/apps/web/lib/examples.ts @@ -197,6 +197,13 @@ export const exampleCategories: ExampleCategory[] = [ description: "Cascading dropdowns with real REST API data.", file: "country-state-form.tsx", }, + { + slug: "ui-and-custom-fields-form", + id: "UiAndCustomFieldsExample", + name: "UI Layout & Custom Fields", + description: "Mix layout fields and custom input types.", + file: "ui-and-custom-fields-form.tsx", + }, ], }, { diff --git a/apps/web/public/r/all.json b/apps/web/public/r/all.json index 0b16e8c5..e11bdc5a 100644 --- a/apps/web/public/r/all.json +++ b/apps/web/public/r/all.json @@ -22,12 +22,13 @@ "@buzzform/array", "@buzzform/date", "@buzzform/tags", - "@buzzform/upload" + "@buzzform/upload", + "@buzzform/ui" ], "files": [ { "path": "registry/shadcn/registry.ts", - "content": "\"use client\";\n\nimport type { FieldRegistry } from \"@buildnbuzz/form-react\";\n\nimport { TextField } from \"./fields/text\";\nimport { EmailField } from \"./fields/email\";\nimport { PasswordField } from \"./fields/password\";\nimport { RowField } from \"./fields/row\";\nimport { TextareaField } from \"./fields/textarea\";\nimport { NumberField } from \"./fields/number\";\nimport { SelectField } from \"./fields/select\";\nimport { CheckboxField } from \"./fields/checkbox\";\nimport { SwitchField } from \"./fields/switch\";\nimport { RadioField } from \"./fields/radio\";\nimport { GroupField } from \"./fields/group\";\nimport { ArrayField } from \"./fields/array\";\nimport { TabsField } from \"./fields/tabs\";\nimport { CollapsibleField } from \"./fields/collapsible\";\nimport { DateField } from \"./fields/date\";\nimport { TagsField } from \"./fields/tags\";\nimport { UploadField } from \"./fields/upload\";\n\nexport const registry: FieldRegistry = {\n text: TextField,\n email: EmailField,\n password: PasswordField,\n row: RowField,\n textarea: TextareaField,\n number: NumberField,\n select: SelectField,\n checkbox: CheckboxField,\n switch: SwitchField,\n radio: RadioField,\n group: GroupField,\n array: ArrayField,\n tabs: TabsField,\n collapsible: CollapsibleField,\n date: DateField,\n tags: TagsField,\n upload: UploadField,\n};\n", + "content": "\"use client\";\n\nimport type { FieldRegistry } from \"@buildnbuzz/form-react\";\n\nimport { TextField } from \"./fields/text\";\nimport { EmailField } from \"./fields/email\";\nimport { PasswordField } from \"./fields/password\";\nimport { RowField } from \"./fields/row\";\nimport { TextareaField } from \"./fields/textarea\";\nimport { NumberField } from \"./fields/number\";\nimport { SelectField } from \"./fields/select\";\nimport { CheckboxField } from \"./fields/checkbox\";\nimport { SwitchField } from \"./fields/switch\";\nimport { RadioField } from \"./fields/radio\";\nimport { GroupField } from \"./fields/group\";\nimport { ArrayField } from \"./fields/array\";\nimport { TabsField } from \"./fields/tabs\";\nimport { CollapsibleField } from \"./fields/collapsible\";\nimport { DateField } from \"./fields/date\";\nimport { TagsField } from \"./fields/tags\";\nimport { UploadField } from \"./fields/upload\";\nimport { UiField } from \"./fields/ui\";\n\nexport const registry: FieldRegistry = {\n text: TextField,\n email: EmailField,\n password: PasswordField,\n row: RowField,\n textarea: TextareaField,\n number: NumberField,\n select: SelectField,\n checkbox: CheckboxField,\n switch: SwitchField,\n radio: RadioField,\n group: GroupField,\n array: ArrayField,\n tabs: TabsField,\n collapsible: CollapsibleField,\n date: DateField,\n tags: TagsField,\n upload: UploadField,\n ui: UiField,\n};\n", "type": "registry:component", "target": "components/buzzform/registry.ts" } diff --git a/apps/web/public/r/form-builder.json b/apps/web/public/r/form-builder.json index bb68a721..cea4c4ad 100644 --- a/apps/web/public/r/form-builder.json +++ b/apps/web/public/r/form-builder.json @@ -125,7 +125,7 @@ }, { "path": "registry/shadcn/builder/header/form-manager-saved-panel.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { IconPlaceholder } from \"@/components/icon-placeholder\";\nimport type {\n FormSummary,\n BuilderStorageProvider,\n} from \"@buildnbuzz/form-builder-core\";\nimport { toSafeFileName, fieldsToBuilderState } from \"@buildnbuzz/form-builder-core\";\nimport { useBuilderStore, downloadTextFile } from \"@buildnbuzz/form-builder-react\";\nimport { toast } from \"sonner\";\n\nconst dateFormatter = new Intl.DateTimeFormat(undefined, {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n});\n\ntype FormManagerSavedPanelProps = {\n onDone: () => void;\n storageProvider: BuilderStorageProvider | null;\n};\n\nexport function FormManagerSavedPanel({\n onDone,\n storageProvider,\n}: FormManagerSavedPanelProps) {\n const [forms, setForms] = React.useState([]);\n const [isLoading, setIsLoading] = React.useState(false);\n const [loadError, setLoadError] = React.useState(null);\n const [openingFormId, setOpeningFormId] = React.useState(null);\n const [removingFormId, setRemovingFormId] = React.useState(\n null,\n );\n const [downloadingFormId, setDownloadingFormId] = React.useState<\n string | null\n >(null);\n\n const loadDocumentState = useBuilderStore((state) => state.loadDocumentState);\n const currentFormId = useBuilderStore((state) => state.formId);\n\n const loadForms = React.useCallback(async () => {\n setIsLoading(true);\n setLoadError(null);\n\n try {\n if (!storageProvider) return;\n const items = await storageProvider.list();\n setForms(items);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to load forms.\";\n setLoadError(message);\n } finally {\n setIsLoading(false);\n }\n }, [storageProvider]);\n\n React.useEffect(() => {\n void loadForms();\n }, [loadForms]);\n\n const handleOpenForm = async (formId: string) => {\n setOpeningFormId(formId);\n\n try {\n if (!storageProvider) return;\n const document = await storageProvider.load(formId);\n if (!document) throw new Error(\"Document not found\");\n const { nodes, rootIds } = fieldsToBuilderState(document.fields);\n\n loadDocumentState({\n nodes,\n rootIds,\n formId: document.id || formId,\n formName: document.title || \"Untitled\",\n });\n onDone();\n toast.success(`Opened \"${document.title || \"Untitled\"}\"`);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to open form.\";\n toast.error(message);\n } finally {\n setOpeningFormId(null);\n }\n };\n\n const handleRemoveForm = async (form: FormSummary) => {\n if (\n !window.confirm(\n `Remove \"${form.formName}\" from local saved forms? This does not affect the currently open editor until you switch forms.`,\n )\n ) {\n return;\n }\n\n setRemovingFormId(form.formId);\n\n try {\n if (!storageProvider) return;\n await storageProvider.remove(form.formId);\n setForms((prev) => prev.filter((item) => item.formId !== form.formId));\n toast.success(`Removed \"${form.formName}\"`);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to remove form.\";\n toast.error(message);\n } finally {\n setRemovingFormId(null);\n }\n };\n\n const handleDownloadForm = async (form: FormSummary) => {\n setDownloadingFormId(form.formId);\n\n try {\n if (!storageProvider) return;\n const document = await storageProvider.load(form.formId);\n if (!document) throw new Error(\"Document not found\");\n const fileName = `${toSafeFileName(document.title || \"Untitled\")}.json`;\n\n downloadTextFile(\n JSON.stringify(document, null, 2),\n fileName,\n \"application/json\",\n );\n toast.success(`Downloaded \"${document.title || \"Untitled\"}\"`);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to download form.\";\n toast.error(message);\n } finally {\n setDownloadingFormId(null);\n }\n };\n\n return (\n
\n {isLoading ? (\n
\n \n Loading saved forms...\n
\n ) : loadError ? (\n
\n

{loadError}

\n \n
\n ) : forms.length === 0 ? (\n \n \n \n \n \n No saved forms yet\n \n Forms appear here after you edit and save them locally.\n \n \n \n ) : (\n <>\n
\n \n
\n {forms.map((form) => {\n const isOpening = openingFormId === form.formId;\n const isRemoving = removingFormId === form.formId;\n const isDownloading = downloadingFormId === form.formId;\n const isBusy = isOpening || isRemoving || isDownloading;\n const isCurrent = currentFormId === form.formId;\n\n return (\n \n
\n \n
\n\n
\n
\n

\n {form.formName}\n

\n {isCurrent && (\n Current\n )}\n
\n\n
\n \n {dateFormatter.format(form.updatedAt)}\n
\n
\n\n
\n handleOpenForm(form.formId)}\n >\n {isOpening ? (\n \n ) : (\n \n )}\n Open\n \n\n \n handleDownloadForm(form)}\n className=\"text-muted-foreground\"\n >\n {isDownloading ? (\n \n ) : (\n \n )}\n \n Download saved form\n \n \n }\n />\n Download\n \n\n \n handleRemoveForm(form)}\n className=\"text-muted-foreground hover:text-destructive\"\n >\n {isRemoving ? (\n \n ) : (\n \n )}\n \n Remove saved form\n \n \n }\n />\n Delete\n \n
\n
\n );\n })}\n
\n \n
\n\n
\n \n \n Refresh\n \n
\n \n )}\n \n );\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { IconPlaceholder } from \"@/components/icon-placeholder\";\nimport type {\n FormSummary,\n BuilderStorageProvider,\n} from \"@buildnbuzz/form-builder-core\";\nimport { toSafeFileName, fieldsToBuilderState } from \"@buildnbuzz/form-builder-core\";\nimport { useBuilderStore, downloadTextFile } from \"@buildnbuzz/form-builder-react\";\nimport type { Field } from \"@buildnbuzz/form-core\";\nimport { toast } from \"sonner\";\n\nconst dateFormatter = new Intl.DateTimeFormat(undefined, {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n});\n\ntype FormManagerSavedPanelProps = {\n onDone: () => void;\n storageProvider: BuilderStorageProvider | null;\n};\n\nexport function FormManagerSavedPanel({\n onDone,\n storageProvider,\n}: FormManagerSavedPanelProps) {\n const [forms, setForms] = React.useState([]);\n const [isLoading, setIsLoading] = React.useState(false);\n const [loadError, setLoadError] = React.useState(null);\n const [openingFormId, setOpeningFormId] = React.useState(null);\n const [removingFormId, setRemovingFormId] = React.useState(\n null,\n );\n const [downloadingFormId, setDownloadingFormId] = React.useState<\n string | null\n >(null);\n\n const loadDocumentState = useBuilderStore((state) => state.loadDocumentState);\n const currentFormId = useBuilderStore((state) => state.formId);\n\n const loadForms = React.useCallback(async () => {\n setIsLoading(true);\n setLoadError(null);\n\n try {\n if (!storageProvider) return;\n const items = await storageProvider.list();\n setForms(items);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to load forms.\";\n setLoadError(message);\n } finally {\n setIsLoading(false);\n }\n }, [storageProvider]);\n\n React.useEffect(() => {\n void loadForms();\n }, [loadForms]);\n\n const handleOpenForm = async (formId: string) => {\n setOpeningFormId(formId);\n\n try {\n if (!storageProvider) return;\n const document = await storageProvider.load(formId);\n if (!document) throw new Error(\"Document not found\");\n const { nodes, rootIds } = fieldsToBuilderState(\n document.fields as readonly Field[],\n );\n\n loadDocumentState({\n nodes,\n rootIds,\n formId: document.id || formId,\n formName: document.title || \"Untitled\",\n });\n onDone();\n toast.success(`Opened \"${document.title || \"Untitled\"}\"`);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to open form.\";\n toast.error(message);\n } finally {\n setOpeningFormId(null);\n }\n };\n\n const handleRemoveForm = async (form: FormSummary) => {\n if (\n !window.confirm(\n `Remove \"${form.formName}\" from local saved forms? This does not affect the currently open editor until you switch forms.`,\n )\n ) {\n return;\n }\n\n setRemovingFormId(form.formId);\n\n try {\n if (!storageProvider) return;\n await storageProvider.remove(form.formId);\n setForms((prev) => prev.filter((item) => item.formId !== form.formId));\n toast.success(`Removed \"${form.formName}\"`);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to remove form.\";\n toast.error(message);\n } finally {\n setRemovingFormId(null);\n }\n };\n\n const handleDownloadForm = async (form: FormSummary) => {\n setDownloadingFormId(form.formId);\n\n try {\n if (!storageProvider) return;\n const document = await storageProvider.load(form.formId);\n if (!document) throw new Error(\"Document not found\");\n const fileName = `${toSafeFileName(document.title || \"Untitled\")}.json`;\n\n downloadTextFile(\n JSON.stringify(document, null, 2),\n fileName,\n \"application/json\",\n );\n toast.success(`Downloaded \"${document.title || \"Untitled\"}\"`);\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"Failed to download form.\";\n toast.error(message);\n } finally {\n setDownloadingFormId(null);\n }\n };\n\n return (\n
\n {isLoading ? (\n
\n \n Loading saved forms...\n
\n ) : loadError ? (\n
\n

{loadError}

\n \n
\n ) : forms.length === 0 ? (\n \n \n \n \n \n No saved forms yet\n \n Forms appear here after you edit and save them locally.\n \n \n \n ) : (\n <>\n
\n \n
\n {forms.map((form) => {\n const isOpening = openingFormId === form.formId;\n const isRemoving = removingFormId === form.formId;\n const isDownloading = downloadingFormId === form.formId;\n const isBusy = isOpening || isRemoving || isDownloading;\n const isCurrent = currentFormId === form.formId;\n\n return (\n \n
\n \n
\n\n
\n
\n

\n {form.formName}\n

\n {isCurrent && (\n Current\n )}\n
\n\n
\n \n {dateFormatter.format(form.updatedAt)}\n
\n
\n\n
\n handleOpenForm(form.formId)}\n >\n {isOpening ? (\n \n ) : (\n \n )}\n Open\n \n\n \n handleDownloadForm(form)}\n className=\"text-muted-foreground\"\n >\n {isDownloading ? (\n \n ) : (\n \n )}\n \n Download saved form\n \n \n }\n />\n Download\n \n\n \n handleRemoveForm(form)}\n className=\"text-muted-foreground hover:text-destructive\"\n >\n {isRemoving ? (\n \n ) : (\n \n )}\n \n Remove saved form\n \n \n }\n />\n Delete\n \n
\n
\n );\n })}\n
\n \n
\n\n
\n \n \n Refresh\n \n
\n \n )}\n \n );\n}\n", "type": "registry:component", "target": "components/buzzform/builder/header/form-manager-saved-panel.tsx" }, diff --git a/apps/web/public/r/registry.json b/apps/web/public/r/registry.json index ae75b6dc..2ec27e1c 100644 --- a/apps/web/public/r/registry.json +++ b/apps/web/public/r/registry.json @@ -374,6 +374,22 @@ } ] }, + { + "name": "ui", + "type": "registry:ui", + "title": "UiField", + "description": "UI layout field for BuzzForm v2.", + "registryDependencies": [ + "@buzzform/init" + ], + "files": [ + { + "path": "registry/shadcn/fields/ui.tsx", + "type": "registry:component", + "target": "components/buzzform/fields/ui.tsx" + } + ] + }, { "name": "form-builder", "type": "registry:ui", @@ -576,7 +592,8 @@ "@buzzform/array", "@buzzform/date", "@buzzform/tags", - "@buzzform/upload" + "@buzzform/upload", + "@buzzform/ui" ], "files": [ { @@ -1052,6 +1069,26 @@ "target": "components/buzzform/examples/document-upload-form.tsx" } ] + }, + { + "name": "ui-and-custom-fields-form", + "type": "registry:block", + "title": "UI Layout & Custom Fields", + "description": "Mixing inline UI components with completely custom registered fields.", + "registryDependencies": [ + "@buzzform/ui", + "card", + "field", + "input", + "sonner" + ], + "files": [ + { + "path": "registry/shadcn/examples/ui-and-custom-fields-form.tsx", + "type": "registry:component", + "target": "components/buzzform/examples/ui-and-custom-fields-form.tsx" + } + ] } ] } diff --git a/apps/web/public/r/ui-and-custom-fields-form.json b/apps/web/public/r/ui-and-custom-fields-form.json new file mode 100644 index 00000000..c1229410 --- /dev/null +++ b/apps/web/public/r/ui-and-custom-fields-form.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "ui-and-custom-fields-form", + "title": "UI Layout & Custom Fields", + "description": "Mixing inline UI components with completely custom registered fields.", + "registryDependencies": [ + "@buzzform/ui", + "card", + "field", + "input", + "sonner" + ], + "files": [ + { + "path": "registry/shadcn/examples/ui-and-custom-fields-form.tsx", + "content": "\"use client\";\n\nimport { toast } from \"sonner\";\nimport { defineSchema, type InferType, useDataField } from \"@buildnbuzz/form-react\";\nimport {\n Form,\n FormContent,\n FormFields,\n FormSubmit,\n FormActions,\n} from \"@/registry/shadcn/form\";\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\nimport {\n Field,\n FieldContent,\n FieldDescription,\n FieldError,\n FieldGroup,\n FieldLabel,\n} from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\n\n// 1. Define our custom field component.\n// It uses `useDataField` to easily hook into the headless form state.\nfunction ColorPickerField() {\n const {\n fieldApi,\n isDisabled,\n isReadOnly,\n isRequired,\n label,\n description,\n errors,\n isInvalid,\n descriptionId,\n errorId,\n handleChange,\n handleBlur,\n } = useDataField();\n\n const value = (fieldApi.state.value as string) || \"#000000\";\n\n return (\n \n \n {label && (\n \n {label}\n {isRequired ? * : null}\n \n )}\n\n \n
\n handleChange(e.target.value)}\n onBlur={handleBlur}\n disabled={isDisabled}\n readOnly={isReadOnly}\n aria-invalid={isInvalid}\n className=\"h-8 w-8 shrink-0 cursor-pointer border-0 p-0 rounded-md overflow-hidden shadow-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-moz-color-swatch]:border-none\" />\n handleChange(e.target.value)}\n onBlur={handleBlur}\n disabled={isDisabled}\n readOnly={isReadOnly}\n aria-invalid={isInvalid}\n placeholder=\"#000000\"\n className=\"flex-1 font-mono uppercase\"\n />\n
\n
\n\n {description && !isInvalid && (\n {description}\n )}\n\n {isInvalid && }\n
\n
\n );\n}\n\n// 2. Define the schema, mixing standard fields, layout \"ui\" fields,\n// and our custom \"color-picker\" field.\nconst customSchema = defineSchema({\n fields: [\n {\n type: \"ui\",\n content: (\n
\n

Theme Builder

\n

\n Configure your application's appearance by selecting custom colors below.\n

\n
\n ),\n },\n {\n type: \"text\",\n name: \"themeName\",\n label: \"Theme Name\",\n placeholder: \"e.g., Midnight Blue\",\n required: true,\n },\n {\n type: \"ui\",\n content:

Brand Colors

,\n },\n {\n type: \"color-picker\",\n name: \"primaryColor\",\n label: \"Primary Color\",\n description: \"Main color used for buttons and primary actions.\",\n defaultValue: \"#0f172a\",\n required: true,\n },\n {\n type: \"color-picker\",\n name: \"secondaryColor\",\n label: \"Secondary Color\",\n description: \"Color used for secondary elements and subtle backgrounds.\",\n defaultValue: \"#f1f5f9\",\n },\n {\n type: \"color-picker\",\n name: \"accentColor\",\n label: \"Accent Color\",\n description: \"Color used for active states and highlights.\",\n defaultValue: \"#3b82f6\",\n },\n ],\n});\n\nexport default function UiAndCustomFieldsExample() {\n const handleSubmit = async ({ value }: { value: unknown }) => {\n // value is inferred properly. Custom fields without explicitly defined type interfaces\n // fall back to `unknown`. Layout fields don't show up here at all.\n const data = value as InferType;\n\n toast(\"Saved Custom Form\", {\n description: (\n
\n          {JSON.stringify(data, null, 2)}\n        
\n ),\n });\n };\n\n return (\n \n \n UI Layout & Custom Fields\n \n Mixing inline UI components with completely custom registered fields.\n \n \n \n {/* We pass our custom fields inside the `registries.fields` prop to register them */}\n \n \n \n \n Save Theme\n \n \n \n \n \n );\n}\n", + "type": "registry:component", + "target": "components/buzzform/examples/ui-and-custom-fields-form.tsx" + } + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/web/public/r/ui.json b/apps/web/public/r/ui.json new file mode 100644 index 00000000..3f95cdc6 --- /dev/null +++ b/apps/web/public/r/ui.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "ui", + "title": "UiField", + "description": "UI layout field for BuzzForm v2.", + "registryDependencies": [ + "@buzzform/init" + ], + "files": [ + { + "path": "registry/shadcn/fields/ui.tsx", + "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport type { UiField as UiFieldDef } from \"@buildnbuzz/form-react\";\nimport { useLayoutField, resolveExpr } from \"@buildnbuzz/form-react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface UiUi {\n className?: string;\n}\n\n/**\n * Shadcn UI/markup layout field.\n * Renders inline text, HTML, or React elements dynamically.\n */\nexport function UiField() {\n const { field, formData, contextData, registries } = useLayoutField();\n\n const ui = field.ui as UiUi | undefined;\n\n const resolvedContent = resolveExpr(\n field.content,\n { data: formData, context: contextData },\n registries?.fns\n );\n\n return (\n
\n {resolvedContent}\n
\n );\n}\n", + "type": "registry:component", + "target": "components/buzzform/fields/ui.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/apps/web/registry.json b/apps/web/registry.json index ae75b6dc..2ec27e1c 100644 --- a/apps/web/registry.json +++ b/apps/web/registry.json @@ -374,6 +374,22 @@ } ] }, + { + "name": "ui", + "type": "registry:ui", + "title": "UiField", + "description": "UI layout field for BuzzForm v2.", + "registryDependencies": [ + "@buzzform/init" + ], + "files": [ + { + "path": "registry/shadcn/fields/ui.tsx", + "type": "registry:component", + "target": "components/buzzform/fields/ui.tsx" + } + ] + }, { "name": "form-builder", "type": "registry:ui", @@ -576,7 +592,8 @@ "@buzzform/array", "@buzzform/date", "@buzzform/tags", - "@buzzform/upload" + "@buzzform/upload", + "@buzzform/ui" ], "files": [ { @@ -1052,6 +1069,26 @@ "target": "components/buzzform/examples/document-upload-form.tsx" } ] + }, + { + "name": "ui-and-custom-fields-form", + "type": "registry:block", + "title": "UI Layout & Custom Fields", + "description": "Mixing inline UI components with completely custom registered fields.", + "registryDependencies": [ + "@buzzform/ui", + "card", + "field", + "input", + "sonner" + ], + "files": [ + { + "path": "registry/shadcn/examples/ui-and-custom-fields-form.tsx", + "type": "registry:component", + "target": "components/buzzform/examples/ui-and-custom-fields-form.tsx" + } + ] } ] } diff --git a/apps/web/registry/shadcn/builder/header/form-manager-saved-panel.tsx b/apps/web/registry/shadcn/builder/header/form-manager-saved-panel.tsx index 837fff77..b42952d5 100644 --- a/apps/web/registry/shadcn/builder/header/form-manager-saved-panel.tsx +++ b/apps/web/registry/shadcn/builder/header/form-manager-saved-panel.tsx @@ -20,6 +20,7 @@ import type { } from "@buildnbuzz/form-builder-core"; import { toSafeFileName, fieldsToBuilderState } from "@buildnbuzz/form-builder-core"; import { useBuilderStore, downloadTextFile } from "@buildnbuzz/form-builder-react"; +import type { Field } from "@buildnbuzz/form-core"; import { toast } from "sonner"; const dateFormatter = new Intl.DateTimeFormat(undefined, { @@ -78,7 +79,9 @@ export function FormManagerSavedPanel({ if (!storageProvider) return; const document = await storageProvider.load(formId); if (!document) throw new Error("Document not found"); - const { nodes, rootIds } = fieldsToBuilderState(document.fields); + const { nodes, rootIds } = fieldsToBuilderState( + document.fields as readonly Field[], + ); loadDocumentState({ nodes, diff --git a/apps/web/registry/shadcn/examples/ui-and-custom-fields-form.tsx b/apps/web/registry/shadcn/examples/ui-and-custom-fields-form.tsx new file mode 100644 index 00000000..5c216902 --- /dev/null +++ b/apps/web/registry/shadcn/examples/ui-and-custom-fields-form.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { toast } from "sonner"; +import { defineSchema, type InferType, useDataField } from "@buildnbuzz/form-react"; +import { + Form, + FormContent, + FormFields, + FormSubmit, + FormActions, +} from "@/registry/shadcn/form"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; + +// 1. Define our custom field component. +// It uses `useDataField` to easily hook into the headless form state. +function ColorPickerField() { + const { + fieldApi, + isDisabled, + isReadOnly, + isRequired, + label, + description, + errors, + isInvalid, + descriptionId, + errorId, + handleChange, + handleBlur, + } = useDataField(); + + const value = (fieldApi.state.value as string) || "#000000"; + + return ( + + + {label && ( + + {label} + {isRequired ? * : null} + + )} + + +
+ handleChange(e.target.value)} + onBlur={handleBlur} + disabled={isDisabled} + readOnly={isReadOnly} + aria-invalid={isInvalid} + className="h-8 w-8 shrink-0 cursor-pointer border-0 p-0 rounded-md overflow-hidden shadow-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-moz-color-swatch]:border-none" /> + handleChange(e.target.value)} + onBlur={handleBlur} + disabled={isDisabled} + readOnly={isReadOnly} + aria-invalid={isInvalid} + placeholder="#000000" + className="flex-1 font-mono uppercase" + /> +
+
+ + {description && !isInvalid && ( + {description} + )} + + {isInvalid && } +
+
+ ); +} + +// 2. Define the schema, mixing standard fields, layout "ui" fields, +// and our custom "color-picker" field. +const customSchema = defineSchema({ + fields: [ + { + type: "ui", + content: ( +
+

Theme Builder

+

+ Configure your application's appearance by selecting custom colors below. +

+
+ ), + }, + { + type: "text", + name: "themeName", + label: "Theme Name", + placeholder: "e.g., Midnight Blue", + required: true, + }, + { + type: "ui", + content:

Brand Colors

, + }, + { + type: "color-picker", + name: "primaryColor", + label: "Primary Color", + description: "Main color used for buttons and primary actions.", + defaultValue: "#0f172a", + required: true, + }, + { + type: "color-picker", + name: "secondaryColor", + label: "Secondary Color", + description: "Color used for secondary elements and subtle backgrounds.", + defaultValue: "#f1f5f9", + }, + { + type: "color-picker", + name: "accentColor", + label: "Accent Color", + description: "Color used for active states and highlights.", + defaultValue: "#3b82f6", + }, + ], +}); + +export default function UiAndCustomFieldsExample() { + const handleSubmit = async ({ value }: { value: unknown }) => { + // value is inferred properly. Custom fields without explicitly defined type interfaces + // fall back to `unknown`. Layout fields don't show up here at all. + const data = value as InferType; + + toast("Saved Custom Form", { + description: ( +
+          {JSON.stringify(data, null, 2)}
+        
+ ), + }); + }; + + return ( + + + UI Layout & Custom Fields + + Mixing inline UI components with completely custom registered fields. + + + + {/* We pass our custom fields inside the `registries.fields` prop to register them */} +
+ + + + Save Theme + + +
+
+
+ ); +} diff --git a/apps/web/registry/shadcn/fields/ui.tsx b/apps/web/registry/shadcn/fields/ui.tsx new file mode 100644 index 00000000..b5400cde --- /dev/null +++ b/apps/web/registry/shadcn/fields/ui.tsx @@ -0,0 +1,32 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { UiField as UiFieldDef } from "@buildnbuzz/form-react"; +import { useLayoutField, resolveExpr } from "@buildnbuzz/form-react"; +import { cn } from "@/lib/utils"; + +interface UiUi { + className?: string; +} + +/** + * Shadcn UI/markup layout field. + * Renders inline text, HTML, or React elements dynamically. + */ +export function UiField() { + const { field, formData, contextData, registries } = useLayoutField(); + + const ui = field.ui as UiUi | undefined; + + const resolvedContent = resolveExpr( + field.content, + { data: formData, context: contextData }, + registries?.fns + ); + + return ( +
+ {resolvedContent} +
+ ); +} diff --git a/apps/web/registry/shadcn/registry.ts b/apps/web/registry/shadcn/registry.ts index 2b504681..724d2fe5 100644 --- a/apps/web/registry/shadcn/registry.ts +++ b/apps/web/registry/shadcn/registry.ts @@ -19,6 +19,7 @@ import { CollapsibleField } from "./fields/collapsible"; import { DateField } from "./fields/date"; import { TagsField } from "./fields/tags"; import { UploadField } from "./fields/upload"; +import { UiField } from "./fields/ui"; export const registry: FieldRegistry = { text: TextField, @@ -38,4 +39,5 @@ export const registry: FieldRegistry = { date: DateField, tags: TagsField, upload: UploadField, + ui: UiField, }; diff --git a/packages/form-builder-react/src/registry.test.ts b/packages/form-builder-react/src/registry.test.ts index da39a2e8..c77edbbd 100644 --- a/packages/form-builder-react/src/registry.test.ts +++ b/packages/form-builder-react/src/registry.test.ts @@ -42,7 +42,6 @@ describe("Registry Helpers", () => { }); it("returns undefined for missing entry", () => { - // @ts-expect-error - testing invalid field type const entry = getRegistryEntry(mockRegistry, "not-a-field"); expect(entry).toBeUndefined(); }); diff --git a/packages/form-core/src/field-meta.ts b/packages/form-core/src/field-meta.ts index 6384dda8..8558fa74 100644 --- a/packages/form-core/src/field-meta.ts +++ b/packages/form-core/src/field-meta.ts @@ -1,4 +1,4 @@ -import type { FieldType, BuiltInValidatorName } from "./types"; +import type { FieldType, BuiltInFieldType, BuiltInValidatorName } from "./types"; /** Broad category for UI grouping and organization */ export type FieldCategory = "input" | "choice" | "container" | "layout"; @@ -27,7 +27,7 @@ export interface FieldTypeMeta { * Registry of metadata for all supported field types. * @internal */ -export const FIELD_TYPE_META: Record = { +export const FIELD_TYPE_META: Record = { text: { type: "text", category: "input", @@ -185,6 +185,15 @@ export const FIELD_TYPE_META: Record = { applicableValidators: [], example: { type: "collapsible", label: "Advanced", fields: [] }, hasChildren: true + }, + ui: { + type: "ui", + category: "layout", + description: "Inline UI/markup layout element", + requiredProps: ["type", "content"], + optionalProps: ["hidden", "condition"], + applicableValidators: [], + example: { type: "ui", content: "Custom UI" } } }; @@ -192,7 +201,7 @@ export const FIELD_TYPE_META: Record = { * Retrieves metadata for a specific field type. */ export function getFieldMeta(type: string): FieldTypeMeta | undefined { - return FIELD_TYPE_META[type as FieldType]; + return FIELD_TYPE_META[type as BuiltInFieldType]; } /** diff --git a/packages/form-core/src/schema-validator.ts b/packages/form-core/src/schema-validator.ts index e91f364e..1e95447d 100644 --- a/packages/form-core/src/schema-validator.ts +++ b/packages/form-core/src/schema-validator.ts @@ -56,13 +56,13 @@ export function validateSchema(schema: SerializableFormSchema): SchemaValidation if (!field || typeof field !== "object") return; const path = pathPrefix ? `${pathPrefix}[${index}]` : `fields[${index}]`; - const isLayout = ["row", "tabs", "collapsible"].includes(field.type); + const isLayout = ["row", "tabs", "collapsible", "ui"].includes(field.type); const meta = getFieldMeta(field.type); if (!meta) { issues.push({ code: "invalid_field_type", - severity: "error", + severity: "warning", path, message: `Unrecognized field type '${field.type}'.` }); diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index 26d9d524..feb68f45 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -703,8 +703,15 @@ export interface CollapsibleField extends BaseLayoutField { collapsed?: Expr; } +/** UI/markup layout field for inline HTML/React elements. */ +export interface UiField extends BaseLayoutField { + type: "ui"; + /** The content to render (ReactNode/custom node format). */ + content: ExprText; +} + /** Union of all layout-only fields. */ -export type LayoutField = RowField | TabsField | CollapsibleField; +export type LayoutField = RowField | TabsField | CollapsibleField | UiField; // ============================================================================ // 7. FIELD + FORM SCHEMA @@ -714,7 +721,8 @@ export type LayoutField = RowField | TabsField | CollapsibleField; export type Field = DataField | LayoutField; /** Union of all field type string literals. */ -export type FieldType = Field["type"]; +export type BuiltInFieldType = Field["type"]; +export type FieldType = BuiltInFieldType | (string & {}); /** * Returns `true` if the given field type can hold child fields. @@ -741,7 +749,7 @@ export interface FormSchema { /** Human-readable schema description. */ description?: string; /** Field list. */ - fields: readonly Field[]; + fields: readonly FieldInput[]; /** Form-level validation rules. */ validate?: ValidationConfig; /** Optional schema metadata. */ @@ -750,7 +758,12 @@ export interface FormSchema { /** Type guard to test if a Field is a LayoutField */ export function isLayoutField(field: Field): field is LayoutField { - return field.type === "row" || field.type === "tabs" || field.type === "collapsible"; + return ( + field.type === "row" || + field.type === "tabs" || + field.type === "collapsible" || + field.type === "ui" + ); } /** Type guard to test if a Field is a DataField */ @@ -831,17 +844,46 @@ interface FieldValueMap { : never; } +/** Input type for a custom/unregistered field. */ +export interface CustomFieldInput { + type: string; + name?: string; + required?: boolean | ExprBoolean; + [key: string]: unknown; +} + +/** Union of all supported field input types (built-in or custom). */ +export type FieldInput = Field | CustomFieldInput; + +/** Root schema input container. */ +export interface FormSchemaInput { + /** Optional stable schema identifier. */ + id?: string; + /** Human-readable schema title. */ + title?: string; + /** Human-readable schema description. */ + description?: string; + /** Field list. */ + fields: readonly FieldInput[]; + /** Form-level validation rules. */ + validate?: ValidationConfig; + /** Optional schema metadata. */ + meta?: UnknownData; +} + /** Resolve the inferred value type for a data field. */ -type FieldValue = - TField["type"] extends keyof FieldValueMap - ? FieldValueMap[TField["type"]] - : never; +type FieldValue = + TField extends AnyDataField + ? TField["type"] extends keyof FieldValueMap + ? FieldValueMap[TField["type"]] + : unknown + : unknown; /** Resolve the data shape contribution of a single field (data or layout). */ -type FieldDataShape = TField extends DataField - ? TField["required"] extends true - ? { [K in TField["name"]]: FieldValue } - : { [K in TField["name"]]?: FieldValue } +type FieldDataShape = TField extends { name: infer TName extends string } + ? TField extends { required: true } + ? { [K in TName]: FieldValue } + : { [K in TName]?: FieldValue } : TField extends RowField ? InferType : TField extends TabsField @@ -858,11 +900,11 @@ type FieldDataShape = TField extends DataField * Fields with `required: true` (literal) produce required keys; all others are optional. * Dynamic conditions cannot be resolved at compile time and default to optional. */ -export type InferType = +export type InferType = TFields extends readonly [infer Head, ...infer Tail] ? Simplify< - FieldDataShape> & - InferType + FieldDataShape> & + InferType > : [TFields[number]] extends [never] // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- intersection identity @@ -880,6 +922,37 @@ export type InferType = * // { email: string } * ``` */ -export function defineSchema(schema: T): T { +export function defineSchema(schema: T): T { return schema; } + +/** + * Identity function that narrows a single field to its literal type. + * Useful for defining reusable or conditional fields outside of `defineSchema`. + * + * @example + * ```ts + * const email = defineField({ type: "text", name: "email", required: true }); + * const schema = defineSchema({ fields: [email] }); + * ``` + */ +export function defineField(field: T): T { + return field; +} + +/** + * Identity function that narrows an array of fields to its literal type. + * Useful for conditional spreads or splitting a schema into reusable fragments. + * + * @example + * ```ts + * const addressFields = defineFields([ + * { type: "text", name: "street" }, + * { type: "text", name: "city" }, + * ]); + * const schema = defineSchema({ fields: [...addressFields] }); + * ``` + */ +export function defineFields(fields: T): T { + return fields; +} diff --git a/packages/form-core/src/validation/index.ts b/packages/form-core/src/validation/index.ts index 7a554cfd..d1daa676 100644 --- a/packages/form-core/src/validation/index.ts +++ b/packages/form-core/src/validation/index.ts @@ -501,7 +501,7 @@ export async function validateSchema( formData: Record, options?: ValidateFieldsOptions, ): Promise { - const result = await validateFields(schema.fields, formData, options); + const result = await validateFields(schema.fields as Field[], formData, options); const run = options?.run ?? "submit"; const ctx: ValidationContext = { diff --git a/packages/form-core/tests/define-helpers.test.ts b/packages/form-core/tests/define-helpers.test.ts new file mode 100644 index 00000000..731fef41 --- /dev/null +++ b/packages/form-core/tests/define-helpers.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { defineSchema, defineField, defineFields } from "../src"; +import type { InferType, FieldInput, TextField, NumberField } from "../src"; + +describe("defineField", () => { + it("returns the same field at runtime", () => { + const input = { type: "text" as const, name: "email" }; + const result = defineField(input); + expect(result).toBe(input); + }); + + it("narrows literal type on a text field", () => { + const field = defineField({ type: "text", name: "email", required: true }); + + expectTypeOf(field.type).toEqualTypeOf<"text">(); + expectTypeOf(field.name).toEqualTypeOf<"email">(); + expectTypeOf(field.required).toEqualTypeOf(); + }); + + it("narrows literal type on a number field", () => { + const field = defineField({ type: "number", name: "age" }); + + expectTypeOf(field.type).toEqualTypeOf<"number">(); + expectTypeOf(field.name).toEqualTypeOf<"age">(); + }); + + it("accepts custom field types", () => { + const field = defineField({ type: "myCustom", name: "custom" }); + + expectTypeOf(field.type).toEqualTypeOf<"myCustom">(); + expectTypeOf(field.name).toEqualTypeOf<"custom">(); + }); + + it("is assignable to FieldInput", () => { + const field = defineField({ type: "text", name: "email" }); + expectTypeOf(field).toMatchTypeOf(); + }); +}); + +describe("defineFields", () => { + it("returns the same array at runtime", () => { + const input = [{ type: "text" as const, name: "email" }] as const; + const result = defineFields(input); + expect(result).toBe(input); + }); + + it("narrows literal types for all elements", () => { + const fields = defineFields([ + { type: "text", name: "email", required: true }, + { type: "number", name: "age" }, + ]); + + expectTypeOf(fields[0].type).toEqualTypeOf<"text">(); + expectTypeOf(fields[0].name).toEqualTypeOf<"email">(); + expectTypeOf(fields[1].type).toEqualTypeOf<"number">(); + expectTypeOf(fields[1].name).toEqualTypeOf<"age">(); + }); + + it("preserves InferType when spread into defineSchema", () => { + const extra = defineFields([ + { type: "text", name: "city" }, + { type: "text", name: "zip" }, + ]); + + const schema = defineSchema({ + fields: [ + { type: "text", name: "name", required: true }, + ...extra, + ], + }); + + expect(schema.fields).toHaveLength(3); + + type Shape = InferType; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("works with conditional spreads (the discussion #151 use case)", () => { + const addAddress = true; + + const addressFields = addAddress + ? defineFields([ + { type: "text", name: "street" }, + { type: "text", name: "city" }, + ]) + : defineFields([]); + + const schema = defineSchema({ + fields: [ + { type: "text", name: "name", required: true }, + ...addressFields, + ], + }); + + expect(schema.fields.length).toBeGreaterThanOrEqual(1); + }); + + it("works with defineField inside a conditional spread", () => { + const addEmail = true; + + const schema = defineSchema({ + fields: [ + defineField({ type: "text", name: "name", required: true }), + ...(addEmail + ? [defineField({ type: "email", name: "email" })] + : []), + ], + }); + + expect(schema.fields).toHaveLength(2); + + type Shape = InferType; + expectTypeOf().toEqualTypeOf(); + }); + + it("accepts an empty array", () => { + const fields = defineFields([]); + expect(fields).toHaveLength(0); + expectTypeOf(fields).toEqualTypeOf(); + }); +}); diff --git a/packages/form-core/tests/field-meta.test.ts b/packages/form-core/tests/field-meta.test.ts index eeb17bb7..b1a7a383 100644 --- a/packages/form-core/tests/field-meta.test.ts +++ b/packages/form-core/tests/field-meta.test.ts @@ -59,14 +59,18 @@ describe("field-meta", () => { describe("container and layout categories", () => { const containerTypes: FieldType[] = ["group", "array"]; - const layoutTypes: FieldType[] = ["row", "tabs", "collapsible"]; + const layoutTypesWithChildren: FieldType[] = ["row", "tabs", "collapsible"]; + const layoutTypes: FieldType[] = [...layoutTypesWithChildren, "ui"]; it("has metadata for all container and layout types", () => { - [...containerTypes, ...layoutTypes].forEach((type) => { + [...containerTypes, ...layoutTypesWithChildren].forEach((type) => { const meta = getFieldMeta(type); expect(meta).toBeDefined(); expect(meta?.hasChildren).toBe(true); }); + const uiMeta = getFieldMeta("ui"); + expect(uiMeta).toBeDefined(); + expect(uiMeta?.hasChildren).toBeUndefined(); }); it("getFieldsByCategory works for container and layout", () => { @@ -88,7 +92,7 @@ describe("field-meta", () => { it("every FieldType has an entry", () => { const allTypes: FieldType[] = [ "text", "email", "password", "textarea", "number", "select", "date", "tags", - "checkbox", "switch", "radio", "group", "array", "row", "tabs", "collapsible", "upload" + "checkbox", "switch", "radio", "group", "array", "row", "tabs", "collapsible", "upload", "ui" ]; allTypes.forEach((type) => { expect(getFieldMeta(type)).toBeDefined(); diff --git a/packages/form-core/tests/schema-validator.test.ts b/packages/form-core/tests/schema-validator.test.ts index a0669c07..ffa5c7a1 100644 --- a/packages/form-core/tests/schema-validator.test.ts +++ b/packages/form-core/tests/schema-validator.test.ts @@ -148,16 +148,17 @@ describe("schema-validator", () => { }); describe("type checks", () => { - it("flags invalid field types", () => { + it("flags invalid field types as warnings", () => { const schema: SerializableFormSchema = { fields: [ { type: "magic", name: "foo" } as unknown as SerializableField ] }; const result = validateSchema(schema); - expect(result.valid).toBe(false); + expect(result.valid).toBe(true); const issues = result.issues.filter(i => i.code === "invalid_field_type"); expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("warning"); }); it("still traverses invalid fields if they have a fields array", () => { @@ -172,8 +173,12 @@ describe("schema-validator", () => { }; const result = validateSchema(schema); expect(result.valid).toBe(false); - expect(result.issues.filter(i => i.code === "invalid_field_type")).toHaveLength(1); - expect(result.issues.filter(i => i.code === "missing_name")).toHaveLength(1); + const invalidTypeIssues = result.issues.filter(i => i.code === "invalid_field_type"); + expect(invalidTypeIssues).toHaveLength(1); + expect(invalidTypeIssues[0].severity).toBe("warning"); + const missingNameIssues = result.issues.filter(i => i.code === "missing_name"); + expect(missingNameIssues).toHaveLength(1); + expect(missingNameIssues[0].severity).toBe("error"); }); it("flags name on layout fields", () => { diff --git a/packages/form-core/tests/types.test.ts b/packages/form-core/tests/types.test.ts index ce3c7c75..e1b02992 100644 --- a/packages/form-core/tests/types.test.ts +++ b/packages/form-core/tests/types.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, expectTypeOf } from "vitest"; import { defineSchema } from "../src"; -import type { ArrayFieldDef, Field, FormSchema, InferType, PrimitiveArrayField, UnknownData } from "../src"; +import type { ArrayFieldDef, Field, FormSchema, InferType, PrimitiveArrayField, UnknownData, ExprText, BuiltInFieldType } from "../src"; describe("form-core types", () => { it("infers basic data shape from fields", () => { @@ -67,9 +67,7 @@ describe("form-core types", () => { fields: [{ type: "text", name: "email" }], }; - expectTypeOf(schema.fields[0]!.type).toEqualTypeOf< - "text" | "email" | "password" | "textarea" | "number" | "select" | "date" | "tags" | "checkbox" | "switch" | "radio" | "group" | "array" | "row" | "tabs" | "collapsible" - >(); + expectTypeOf(schema.fields[0]!.type).toEqualTypeOf(); }); it("accepts schema metadata fields", () => { @@ -213,4 +211,35 @@ describe("form-core types", () => { }; expectTypeOf(_valid).toBeObject(); }); + + it("supports UiField layout component and its typing", () => { + const schema = defineSchema({ + fields: [ + { type: "ui", content: "Hello layout" } + ] + }); + expectTypeOf(schema.fields[0].type).toEqualTypeOf<"ui">(); + expectTypeOf(schema.fields[0].content).toMatchTypeOf(); + + type Shape = InferType; + // UiField carries no data, so it should infer to {} + expectTypeOf().toEqualTypeOf<{}>(); + }); + + it("supports custom field types and their type inference", () => { + const schema = defineSchema({ + fields: [ + { type: "myCustomSelect", name: "favoriteColor", required: true }, + { type: "separator" }, // custom layout field without name + { type: "customDatePicker", name: "optionalDate" } // custom data field + ] + }); + + type Shape = InferType; + // custom fields with name should infer to unknown + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // custom layout field without name should not appear in the shape + expectTypeOf().not.toHaveProperty("separator"); + }); }); diff --git a/packages/form-react/src/contexts/hooks/use-nested-error-count.ts b/packages/form-react/src/contexts/hooks/use-nested-error-count.ts index 3b15210f..e1938170 100644 --- a/packages/form-react/src/contexts/hooks/use-nested-error-count.ts +++ b/packages/form-react/src/contexts/hooks/use-nested-error-count.ts @@ -25,7 +25,10 @@ export function collectDataFieldNames( const names: string[] = []; const isLayoutField = (field: Field) => - field.type === "row" || field.type === "tabs" || field.type === "collapsible"; + field.type === "row" || + field.type === "tabs" || + field.type === "collapsible" || + field.type === "ui"; walkFields(fields, (field, ctx) => { if (isLayoutField(field)) return; diff --git a/packages/form-react/src/index.ts b/packages/form-react/src/index.ts index a9a5c621..19eaab71 100644 --- a/packages/form-react/src/index.ts +++ b/packages/form-react/src/index.ts @@ -3,6 +3,8 @@ import { } from "react"; import { defineSchema, + defineField, + defineFields, isDataField, isContainerType, walkFields, @@ -29,6 +31,8 @@ import { export { defineSchema, + defineField, + defineFields, isDataField, isContainerType, walkFields, @@ -81,6 +85,10 @@ export type { TextareaField, TristateCheckboxField, CollapsibleField, + UiField, + FormSchemaInput, + FieldInput, + CustomFieldInput, ExprText, ExprString, ExprBoolean, diff --git a/packages/form-react/src/use-form.ts b/packages/form-react/src/use-form.ts index d118e771..ec378627 100644 --- a/packages/form-react/src/use-form.ts +++ b/packages/form-react/src/use-form.ts @@ -3,7 +3,7 @@ import { useForm as useTanstackForm, type StandardSchemaV1, } from "@tanstack/react-form"; -import type { FormSchema } from "@buildnbuzz/form-core"; +import type { FormSchema, Field as CoreField } from "@buildnbuzz/form-core"; import { extractDefaults, transformFormOutput } from "@buildnbuzz/form-core"; import type { FormRegistries } from "@buildnbuzz/form-core"; import type { @@ -83,7 +83,7 @@ export function useForm( const mergedDefaultValues = useMemo( () => ({ - ...extractDefaults(schema.fields, contextData, mergedRegistries.fns), + ...extractDefaults(schema.fields as CoreField[], contextData, mergedRegistries.fns), ...defaultValues, }) as TFormData, [schema.fields, contextData, mergedRegistries.fns, defaultValues], diff --git a/packages/form-react/src/validator.ts b/packages/form-react/src/validator.ts index 35cbe74e..2493879f 100644 --- a/packages/form-react/src/validator.ts +++ b/packages/form-react/src/validator.ts @@ -20,6 +20,7 @@ import type { ValidationCheck, ValidationRegistry, ValidationRun, + Field as CoreField, } from "@buildnbuzz/form-core"; import type { UnknownData } from "./types"; @@ -48,7 +49,7 @@ export function buildStandardSchemaValidator( checks: ValidationCheck[]; }> = []; - walkFields(schema.fields, (field, ctx) => { + walkFields(schema.fields as CoreField[], (field, ctx) => { if (!isDataField(field)) return; const checks = collectFieldValidationChecks(field, "submit", { includeDerived: true, // "change", "blur", and "submit" all require derived checks to pass on submit