diff --git a/docs/package.json b/docs/package.json index 068b55782b3..8efe4c928e7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,6 +18,7 @@ "dependencies": { "@base-ui/react": "workspace:*", "@base-ui/utils": "workspace:*", + "@formisch/react": "^1.0.0-rc.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@mui/internal-docs-infra": "0.12.1-canary.26", @@ -53,6 +54,7 @@ "remark-typography": "0.7.3", "scroll-into-view-if-needed": "3.1.0", "server-only": "^0.0.1", + "valibot": "^1.4.1", "unist-util-visit-parents": "^6.0.2" }, "devDependencies": { diff --git a/docs/src/app/(docs)/react/handbook/forms/demos/formisch/index.ts b/docs/src/app/(docs)/react/handbook/forms/demos/formisch/index.ts new file mode 100644 index 00000000000..caf3d28b01f --- /dev/null +++ b/docs/src/app/(docs)/react/handbook/forms/demos/formisch/index.ts @@ -0,0 +1,4 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import Tailwind from './tailwind'; + +export const DemoFormisch = createDemoWithVariants(import.meta.url, { Tailwind }); diff --git a/docs/src/app/(docs)/react/handbook/forms/demos/formisch/tailwind/index.tsx b/docs/src/app/(docs)/react/handbook/forms/demos/formisch/tailwind/index.tsx new file mode 100644 index 00000000000..500da9c7770 --- /dev/null +++ b/docs/src/app/(docs)/react/handbook/forms/demos/formisch/tailwind/index.tsx @@ -0,0 +1,501 @@ +'use client'; +import * as React from 'react'; +import type { SubmitHandler } from '@formisch/react'; +import { Field as FormischField, Form as FormischForm, useForm } from '@formisch/react'; +import * as v from 'valibot'; +import { Button } from '../../components/button'; +import { CheckboxGroup } from '../../components/checkbox-group'; +import { RadioGroup } from '../../components/radio-group'; +import { ToastProvider, useToastManager } from '../../components/toast'; +import * as Autocomplete from '../../components/autocomplete'; +import * as Checkbox from '../../components/checkbox'; +import * as Combobox from '../../components/combobox'; +import * as Field from '../../components/field'; +import * as Fieldset from '../../components/fieldset'; +import * as NumberField from '../../components/number-field'; +import * as Radio from '../../components/radio'; +import * as Select from '../../components/select'; +import * as Slider from '../../components/slider'; +import * as Switch from '../../components/switch'; + +const FormSchema = v.object({ + serverName: v.pipe( + v.string(), + v.nonEmpty('This is a required field.'), + v.minLength(3, 'At least 3 characters.'), + ), + region: v.pipe( + v.nullable(v.string()), + v.check((value) => value !== null, 'This is a required field.'), + ), + containerImage: v.pipe(v.string(), v.nonEmpty('This is a required field.')), + serverType: v.pipe( + v.nullable(v.string()), + v.check((value) => value !== null, 'This is a required field.'), + ), + numOfInstances: v.pipe( + v.nullable(v.number()), + v.check((value) => value !== null, 'This is a required field.'), + ), + scalingThreshold: v.array(v.number()), + storageType: v.picklist(['ssd', 'hdd']), + restartOnFailure: v.boolean(), + allowedNetworkProtocols: v.array(v.string()), +}); + +function Formisch() { + const toastManager = useToastManager(); + + const form = useForm({ + schema: FormSchema, + initialInput: { + serverName: '', + region: null, + containerImage: '', + serverType: null, + numOfInstances: null, + scalingThreshold: [0.2, 0.8], + storageType: 'ssd', + restartOnFailure: true, + allowedNetworkProtocols: [], + }, + }); + + const submitForm: SubmitHandler = (output) => { + toastManager.add({ + title: 'Form submitted', + description: 'The form contains these values:', + data: output, + }); + }; + + return ( + + + {(field) => ( + + Server name + + Must be 3 or more characters long + {field.errors?.[0]} + + )} + + + + {(field) => ( + + +
+ Region + + +
+ + + + +
+
+
+ + + + No matches + + {(region: string) => { + return ( + + + + + {region} + + ); + }} + + + + +
+ {field.errors?.[0]} +
+ )} +
+ + + {(field) => ( + + itemValue.url} + value={field.input} + onValueChange={field.onChange} + > + Container image + + Enter a registry URL with optional tags + + + + + {(image: Image) => { + return ( + + {image.name} + + {image.url} + + + ); + }} + + + + + + {field.errors?.[0]} + + )} + + + + {(field) => ( + + +
+ Server type + + + + + + +
+ + + + + + {SERVER_TYPES.map(({ label, value }) => { + return ( + + + + + {label} + + ); + })} + + + + + +
+ {field.errors?.[0]} +
+ )} +
+ + + {(field) => ( + + + Number of instances + + + + + + + + + + + {field.errors?.[0]} + + )} + + + + {(field) => ( + + + } + > + Scaling threshold + + + + + + + + + + {field.errors?.[0]} + + )} + + + + {(field) => ( + + + } + > + Storage type + {['ssd', 'hdd'].map((radioValue) => ( + + + + + + {radioValue} + + + ))} + + {field.errors?.[0]} + + )} + + + + {(field) => ( + + + Restart on failure + + + + + + )} + + + + {(field) => ( + + } + > + Allowed network protocols +
+ {['http', 'https', 'ssh'].map((checkboxValue) => { + return ( + + + + + + + + {checkboxValue} + + + ); + })} +
+
+ {field.errors?.[0]} +
+ )} +
+ + +
+ ); +} + +export default function App() { + return ( + + + + ); +} + +function CaretUpDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function CheckIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function PlusIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function MinusIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function cartesian(...arrays: T): string[][] { + return arrays.reduce( + (acc, curr) => acc.flatMap((a) => curr.map((b) => [...a, b])), + [[]], + ); +} + +const REGIONS = cartesian(['us', 'eu', 'ap'], ['central', 'east', 'west'], ['1', '2', '3']).map( + (part) => part.join('-'), +); + +interface Image { + url: string; + name: string; +} +/* prettier-ignore */ +const IMAGES: Image[] = ['nginx:1.29-alpine', 'node:22-slim', 'postgres:18', 'redis:8.2.2-alpine'].map((name) => ({ + url: `docker.io/library/${name}`, + name, +})); + +const SERVER_TYPES = [ + { label: 'Select server type', value: null }, + ...cartesian(['t', 'm'], ['1', '2'], ['small', 'medium', 'large']).map((part) => { + const value = part.join('.').replace('.', ''); + return { label: value, value }; + }), +]; diff --git a/docs/src/app/(docs)/react/handbook/forms/page.mdx b/docs/src/app/(docs)/react/handbook/forms/page.mdx index 253cdc6ee9e..cc8a8a9a591 100644 --- a/docs/src/app/(docs)/react/handbook/forms/page.mdx +++ b/docs/src/app/(docs)/react/handbook/forms/page.mdx @@ -3,7 +3,7 @@ A guide to building forms with Base UI components. -Base UI form control components extend the native [constraint validation API](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api) so you can build forms for collecting user input or providing control over an interface. They also integrate seamlessly with third-party libraries like [React Hook Form](#react-hook-form) and [TanStack Form](#tanstack-form). +Base UI form control components extend the native [constraint validation API](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api) so you can build forms for collecting user input or providing control over an interface. They also integrate seamlessly with third-party libraries like [React Hook Form](#react-hook-form), [TanStack Form](#tanstack-form), and [Formisch](#formisch). import { DemoBaseUIForm } from './demos/hero'; @@ -715,6 +715,140 @@ const form = useForm({ ; ``` +## Formisch + +[Formisch](https://formisch.dev) is a schema-based, headless form library that can be integrated with Base UI. It validates with a [Valibot](https://valibot.dev) schema and infers the types of all field values and paths directly from it. + +import { DemoFormisch } from './demos/formisch'; + + + +### Initialize the form + +Create a form instance with the `useForm` hook, passing your Valibot schema and the initial value of each field in the `initialInput` parameter. The schema is the single source of truth for validation and types — there is no resolver step: + +```tsx title="Initialize a form instance" +import { useForm } from '@formisch/react'; +import * as v from 'valibot'; + +const FormSchema = v.object({ + username: v.pipe(v.string(), v.minLength(3, 'At least 3 characters.')), + email: v.pipe(v.string(), v.email('Invalid email address.')), +}); + +{/* @highlight-start */} +/* useForm returns the store of the form */ +const form = useForm({ +{/* @highlight-end */} + schema: FormSchema, + initialInput: { + username: '', + email: '', + }, +}); +``` + +### Integrate components + +Use the `` component from Formisch to integrate with Base UI components, forwarding the field store provided by its render prop to the appropriate part. Since Base UI also exports a `Field` component, import the Formisch one under an alias: + +```tsx title="Integrating Formisch with Base UI components" +import { Field as FormischField, useForm } from '@formisch/react'; {/* @highlight-text "FormischField" */} +import { Field } from '@base-ui/react/field'; + +const form = useForm(/* schema, initialInput */); + +{/* @highlight-start */} + + {(field) => ( + + Username + + + {/* @highlight-start */} + {/* @highlight-text "field.errors" */} + {/* @highlight-end */} + {field.errors?.[0]} + + + )} + +``` + +The Base UI `
` component is not needed when using Formisch, as Formisch ships its own `` component that wraps a native `` element. + +### Form validation + +Validation is defined entirely by the Valibot schema passed to `useForm`. Use the `validate` and `revalidate` parameters to configure when the first validation and subsequent revalidations are performed: + +```tsx title="Configuring validation modes" +import { useForm } from '@formisch/react'; +import * as v from 'valibot'; + +const FormSchema = v.object({ + username: v.pipe(v.string(), v.minLength(3, 'At least 3 characters.')), + email: v.pipe(v.string(), v.email('Invalid email address.')), +}); + +const form = useForm({ + schema: FormSchema, + {/* @highlight-start */} + validate: 'submit', {/* @highlight-text "validate" */} + revalidate: 'input', {/* @highlight-text "revalidate" */} + {/* @highlight-end */} +}); +``` + +This validates all fields when the first submission is attempted, and revalidates any invalid fields when their values change again. This behavior is the default. + +### Submitting data + +Pass a submit handler to the `onSubmit` prop of Formisch's `` component. It is only called when validation succeeds, and receives the validated values typed according to the output of your schema: + +```tsx title="Form submission handler" +import { Form, useForm } from '@formisch/react'; +import type { SubmitHandler } from '@formisch/react'; + +const form = useForm(/* schema, initialInput */); + +{ + /* @highlight-start */ +} +const submitForm: SubmitHandler = async (output) => { + /* prettier-ignore */ + await fetch(/* POST the validated `output` to an external API */); +}; +{ + /* @highlight-end */ +} + +{ + /* @highlight-text "submitForm" */ +} + + {/* form fields */} + +; +``` + export const metadata = { keywords: [ 'Base UI Forms', @@ -722,6 +856,7 @@ export const metadata = { 'Field Validation Handbook', 'React Hook Form Integration', 'TanStack Form Guide', + 'Formisch Integration', 'Constraint Validation API', 'Handbook Forms', 'Form State', diff --git a/docs/src/app/(docs)/react/handbook/page.mdx b/docs/src/app/(docs)/react/handbook/page.mdx index 71cd280f686..7ab9a469bbf 100644 --- a/docs/src/app/(docs)/react/handbook/page.mdx +++ b/docs/src/app/(docs)/react/handbook/page.mdx @@ -104,7 +104,7 @@ A guide to building forms with Base UI components. Outline -- Keywords: Base UI Forms, React Accessible Forms, Field Validation Handbook, React Hook Form Integration, TanStack Form Guide, Constraint Validation API, Handbook Forms, Form State, Form Library, Form Handling, Input Validation, Error Messages, Form Submission, Accessible Forms, ARIA Forms +- Keywords: Base UI Forms, React Accessible Forms, Field Validation Handbook, React Hook Form Integration, TanStack Form Guide, Formisch Integration, Constraint Validation API, Handbook Forms, Form State, Form Library, Form Handling, Input Validation, Error Messages, Form Submission, Accessible Forms, ARIA Forms - Sections: - Naming form controls - Input controls @@ -129,6 +129,11 @@ A guide to building forms with Base UI components. - Form validation - Field validation - Submitting data + - Formisch + - Initialize the form + - Integrate components + - Form validation + - Submitting data diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e38cc73c534..b18f3d255eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@base-ui/utils': specifier: workspace:* version: link:../packages/utils/build + '@formisch/react': + specifier: ^1.0.0-rc.0 + version: 1.0.0-rc.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3)) '@mdx-js/loader': specifier: ^3.1.1 version: 3.1.1(supports-color@10.2.2)(webpack@5.106.2(esbuild@0.27.7)(uglify-js@3.19.3)) @@ -254,6 +257,9 @@ importers: unist-util-visit-parents: specifier: ^6.0.2 version: 6.0.2 + valibot: + specifier: ^1.4.1 + version: 1.4.2(typescript@6.0.3) devDependencies: '@mdx-js/mdx': specifier: 3.1.1 @@ -1592,6 +1598,17 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@formisch/react@1.0.0-rc.0': + resolution: {integrity: sha512-KLYvwpg1MGubT3JutG/3S+iy7zktkDoLmI1Gl991ZKkKMTttyZBJC+gQet5UwOLTB7XDiuxQ3AaTQqs1o34a4w==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + typescript: '>=5' + valibot: ^1.4.1 + peerDependenciesMeta: + typescript: + optional: true + '@gar/promise-retry@1.0.3': resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -9504,6 +9521,14 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -11065,6 +11090,14 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@formisch/react@1.0.0-rc.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)(valibot@1.4.2(typescript@6.0.3))': + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + valibot: 1.4.2(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@gar/promise-retry@1.0.3': {} '@html-validate/stylish@6.0.0': {} @@ -20232,6 +20265,10 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.20 + valibot@1.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0