From 25626cb39d4e05a063761f68f9d32b7a0ea78448 Mon Sep 17 00:00:00 2001 From: Diego Martinez Date: Thu, 16 Apr 2026 12:31:49 -0400 Subject: [PATCH 1/2] feat: implement form management system with TanStack Form integration and reusable form components --- bun.lock | 16 +++ package.json | 13 +++ src/components/form/FieldErrorMessage.tsx | 41 +++++++ src/components/form/Form.tsx | 105 +++++++++++++++++- src/components/form/FormField.tsx | 125 ++++++++++++++++++++++ src/components/form/FormSubmitButton.tsx | 69 ++++++++++++ src/components/form/index.ts | 5 + src/hooks/form/FormContext.ts | 42 ++++++++ src/hooks/form/createForm.ts | 109 +++++++++++++++++++ src/hooks/form/getFirstFieldError.ts | 35 ++++++ src/hooks/form/index.ts | 14 ++- src/hooks/form/useFieldNew.ts | 74 +++++++++++++ src/index.ts | 46 +++++++- src/styles/icons/generated-icons.css | 64 ++++++++++- 14 files changed, 747 insertions(+), 11 deletions(-) create mode 100644 src/components/form/FieldErrorMessage.tsx create mode 100644 src/components/form/FormField.tsx create mode 100644 src/components/form/FormSubmitButton.tsx create mode 100644 src/hooks/form/FormContext.ts create mode 100644 src/hooks/form/createForm.ts create mode 100644 src/hooks/form/getFirstFieldError.ts create mode 100644 src/hooks/form/useFieldNew.ts diff --git a/bun.lock b/bun.lock index c2e6783c..c5106596 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,8 @@ "@solid-primitives/scroll": "^2.0.20", "@solid-primitives/storage": "^2.1.1", "@solid-primitives/utils": "^6.2.1", + "@standard-schema/spec": "^1.0.0", + "@tanstack/solid-form": "^1.29.0", "@tanstack/solid-table": "^8.21.3", "@types/bun": "^1.2.12", "babel-preset-solid": "^1.9.6", @@ -269,12 +271,26 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-4/Z59nnwu4MPR//zWZmZm2yftx24jMqQ8CSd/JobL26TPfbn4Ph8GKNVJfGJWShg1QB98qObJSskqizbTvcLLA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.29.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ=="], + + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/solid-form": ["@tanstack/solid-form@1.29.0", "", { "dependencies": { "@tanstack/form-core": "1.29.0", "@tanstack/solid-store": "^0.9.1" }, "peerDependencies": { "solid-js": ">=1.9.9" } }, "sha512-t+4ZR7ZF3LACP794zyDGPT+Lk51720IOWwm/VqrK28NetHHVV09YrY4H+iWRNqc1Flc0I2Tm1BIDDYRTmdZH/A=="], + + "@tanstack/solid-store": ["@tanstack/solid-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-XThXDzwJT8zeatmxFK1UISikfzz1z76mMlpg1IBDPrJp6df6U3cpJInZRbYs3xlVsnhMziuZigaSkzMyZESK9Q=="], + "@tanstack/solid-table": ["@tanstack/solid-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "solid-js": ">=1.3" } }, "sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A=="], "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-knSNOb1ev78yaf4CnhCY+JS2NZXzp6WO/8pzI/P9GitrG6QLgBzltOl3duN8+13QkVdSMxRrUKl8mioWNlTQxA=="], + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], diff --git a/package.json b/package.json index 705186b8..51085a69 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "@solid-primitives/scroll": "^2.0.20", "@solid-primitives/storage": "^2.1.1", "@solid-primitives/utils": "^6.2.1", + "@standard-schema/spec": "^1.0.0", + "@tanstack/solid-form": "^1.29.0", "@tanstack/solid-table": "^8.21.3", "@types/bun": "^1.2.12", "babel-preset-solid": "^1.9.6", @@ -102,14 +104,25 @@ "@solid-primitives/scroll": "^2.0.20", "@solid-primitives/storage": "^2.1.1", "@solid-primitives/utils": "^6.2.1", + "@standard-schema/spec": "^1.0.0", + "@tanstack/solid-form": "^1.29.0", "@tanstack/solid-table": "^8.0.0", "popmotion": "^11.0.5", "solid-js": "^1.9", "valibot": "^1.0.0" }, "peerDependenciesMeta": { + "@felte/solid": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, "popmotion": { "optional": true + }, + "valibot": { + "optional": true } }, "scripts": { diff --git a/src/components/form/FieldErrorMessage.tsx b/src/components/form/FieldErrorMessage.tsx new file mode 100644 index 00000000..94b358dc --- /dev/null +++ b/src/components/form/FieldErrorMessage.tsx @@ -0,0 +1,41 @@ +import { Show, type Component, type JSX } from "solid-js"; +import { twMerge } from "tailwind-merge"; + +export type FieldErrorMessageProps = JSX.HTMLAttributes & { + /** Pre-normalized error string. Renders nothing when undefined or empty. */ + message?: string; + className?: string; +}; + +/** + * Displays a single field error string. + * + * Pure presentation — has no form or field awareness. Pass an already-normalized + * `message` string from `getFirstFieldError()` or `useField().error()`. + * + * Hidden (returns null) when `message` is undefined or empty. + */ +const FieldErrorMessage: Component = (props) => { + return ( + +

+ {props.message} +

+
+ ); +}; + +export default FieldErrorMessage; +export { FieldErrorMessage }; diff --git a/src/components/form/Form.tsx b/src/components/form/Form.tsx index c8ca2a02..94156b82 100644 --- a/src/components/form/Form.tsx +++ b/src/components/form/Form.tsx @@ -4,6 +4,11 @@ import { twMerge } from "tailwind-merge"; import type { IComponentBaseProps } from "../types"; import { CLASSES } from "./Form.classes"; +import { FormContext, type AnyFormApi } from "../../hooks/form/FormContext"; + +// --------------------------------------------------------------------------- +// FormRoot — original implementation, no form logic (backward compat) +// --------------------------------------------------------------------------- export type FormRootProps = JSX.FormHTMLAttributes & IComponentBaseProps; @@ -26,10 +31,102 @@ const FormRoot: Component = (props) => { ); }; -const Form = Object.assign(FormRoot, { - Root: FormRoot, -}); +// --------------------------------------------------------------------------- +// FormWithContext — new variant: wires FormContext + handles submit +// --------------------------------------------------------------------------- + +export type FormWithContextProps = Omit, "onSubmit"> & + IComponentBaseProps & { + /** + * The form API returned by `createForm()`. + * Providing this prop switches the component into context-providing mode: + * it registers the form instance for all ``, ``, + * and `useField()` calls in the subtree. + */ + form: AnyFormApi; + /** + * Optional additional onSubmit handler called *after* `form.handleSubmit()`. + * Rarely needed — prefer setting `onSubmit` on `createForm(options)` instead. + */ + onSubmit?: JSX.EventHandlerUnion; + }; + +const FormWithContext: Component = (props) => { + const [local, others] = splitProps(props, [ + "form", + "class", + "className", + "dataTheme", + "style", + "onSubmit", + "children", + ]); + + const handleSubmit: JSX.EventHandlerUnion = (e) => { + e.preventDefault(); + e.stopPropagation(); + local.form._tsForm.handleSubmit(); + // Forward to any additional onSubmit the consumer provided + if (typeof local.onSubmit === "function") { + local.onSubmit(e); + } + }; + + return ( + +
+ {local.children} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Unified Form export +// --------------------------------------------------------------------------- + +/** + * Styled form element. + * + * **Without `form` prop** — renders a plain `
` with DaisyUI base styles. + * Use this with the legacy Felte-based `useForm` + `use:form` directive pattern. + * + * **With `form` prop** — provides the form instance via context so that child + * ``, ``, and `useField()` calls resolve + * automatically without prop drilling. Also wires `onSubmit` to + * `form.handleSubmit()` automatically. + * + * ```tsx + * // New API + * const form = createForm({ defaultValues, schema, onSubmit }); + * {...} + * + * // Legacy API — unchanged + * const form = useForm({ schema, onSubmit }); + *
{...}
+ * ``` + */ +function Form(props: FormWithContextProps): JSX.Element; +function Form(props: FormRootProps): JSX.Element; +function Form(props: FormRootProps | FormWithContextProps): JSX.Element { + if ("form" in props && props.form != null) { + return ; + } + return ; +} + +// Attach sub-components for compound-component usage +(Form as unknown as Record).Root = FormRoot; +(Form as unknown as Record).WithContext = FormWithContext; export default Form; -export { Form, FormRoot }; +export { Form, FormRoot, FormWithContext }; export type { FormRootProps as FormProps }; +export type { FormWithContextProps }; diff --git a/src/components/form/FormField.tsx b/src/components/form/FormField.tsx new file mode 100644 index 00000000..1c7b7002 --- /dev/null +++ b/src/components/form/FormField.tsx @@ -0,0 +1,125 @@ +import { Show, splitProps, type Component, type JSX } from "solid-js"; +import { twMerge } from "tailwind-merge"; +import type { FieldApi } from "@tanstack/solid-form"; + +import Input from "../input"; +import type { InputFieldProps } from "../input"; +import Label from "../label"; +import { useFormContext } from "../../hooks/form/FormContext"; +import { getFirstFieldError } from "../../hooks/form/getFirstFieldError"; +import { FieldErrorMessage } from "./FieldErrorMessage"; +import type { AnyFormApi } from "../../hooks/form/FormContext"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Props accepted by ``. + * + * The component reads the form from context automatically (no `form` prop + * needed in normal usage). Pass `form` explicitly only when rendering outside + * the `
` tree, e.g. inside a Portal or a Dialog. + */ +export type FormFieldProps = { + /** Field name — must match a key in `createForm({ defaultValues })`. */ + name: string; + /** Rendered above the input. Optional — omit for unlabelled fields. */ + label?: JSX.Element; + /** + * Props forwarded to the underlying `` element. + * Use this to set `type`, `placeholder`, `autocomplete`, `startIcon`, etc. + */ + inputProps?: Omit; + /** Container class override. */ + class?: string; + className?: string; + /** + * Escape hatch: explicit form override for Portal / out-of-tree usage. + * When provided, the component does NOT read from context. + */ + form?: AnyFormApi; +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Compound field primitive: Label + Input + error message. + * + * Reads the form instance from `` context automatically. + * Pass `form` explicitly only when rendering inside a Portal. + * + * ```tsx + * + * + * + * + * ``` + */ +const FormField: Component = (props) => { + const [local, _rest] = splitProps(props, [ + "name", + "label", + "inputProps", + "class", + "className", + "form", + ]); + + // Resolve form: explicit prop takes priority over context. + const resolveForm = (): AnyFormApi => { + if (local.form != null) return local.form; + return useFormContext(); + }; + + const form = resolveForm(); + const tsForm = form._tsForm; + + return ( + FieldApi) => { + const errorMessage = () => + field().state.meta.isTouched + ? getFirstFieldError((field().state.meta.errors ?? []) as unknown[]) + : undefined; + + return ( +
+ + + + + { + field().handleChange(e.currentTarget.value as never); + }} + onBlur={() => field().handleBlur()} + aria-invalid={Boolean(errorMessage()) ? true : undefined} + isInvalid={Boolean(errorMessage())} + /> + + +
+ ); + }} + /> + ); +}; + +export default FormField; +export { FormField }; diff --git a/src/components/form/FormSubmitButton.tsx b/src/components/form/FormSubmitButton.tsx new file mode 100644 index 00000000..8f447940 --- /dev/null +++ b/src/components/form/FormSubmitButton.tsx @@ -0,0 +1,69 @@ +import { splitProps, type ParentComponent } from "solid-js"; +import Button from "../button"; +import type { ButtonProps } from "../button"; +import { useFormContext } from "../../hooks/form/FormContext"; +import type { AnyFormApi } from "../../hooks/form/FormContext"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type FormSubmitButtonProps = Omit & { + /** + * Escape hatch: explicit form override for Portal / out-of-tree usage. + * When provided, the component does NOT read from context. + */ + form?: AnyFormApi; +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Submit button that subscribes to form state and disables/shows pending + * automatically. + * + * Must be rendered inside a `
` component (or receive an + * explicit `form` prop). + * + * ```tsx + * + * ... + * Log in + *
+ * ``` + */ +const FormSubmitButton: ParentComponent = (props) => { + const [local, others] = splitProps(props, ["form", "children"]); + + const resolveForm = (): AnyFormApi => { + if (local.form != null) return local.form; + return useFormContext(); + }; + + const form = resolveForm(); + const tsForm = form._tsForm; + + return ( + ({ + canSubmit: s.canSubmit, + isSubmitting: s.isSubmitting, + })} + children={(state: () => { canSubmit: boolean; isSubmitting: boolean }) => ( + + )} + /> + ); +}; + +export default FormSubmitButton; +export { FormSubmitButton }; diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 926d79a0..52b15691 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -2,6 +2,11 @@ export { default, Form, FormRoot, + FormWithContext, type FormProps, type FormRootProps, + type FormWithContextProps, } from "./Form"; +export { FormField, type FormFieldProps } from "./FormField"; +export { FormSubmitButton, type FormSubmitButtonProps } from "./FormSubmitButton"; +export { FieldErrorMessage, type FieldErrorMessageProps } from "./FieldErrorMessage"; diff --git a/src/hooks/form/FormContext.ts b/src/hooks/form/FormContext.ts new file mode 100644 index 00000000..b8d057cc --- /dev/null +++ b/src/hooks/form/FormContext.ts @@ -0,0 +1,42 @@ +import { createContext, useContext } from "solid-js"; +import type { FormApi } from "./createForm"; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +// We use `any` here because the form's generic parameters are deeply nested +// TanStack types — the context just needs to hold the opaque FormApi wrapper. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyFormApi = FormApi; + +export const FormContext = createContext(null); + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Reads the nearest `FormApi` from the component tree. + * + * Must be called inside a `
` component. Throws a clear error + * in development if called outside a form context so the mistake is obvious + * rather than silently returning `undefined`. + * + * @example + * // Inside a component rendered as a child of + * const form = useFormContext(); + * const tsForm = form._tsForm; + */ +export const useFormContext = (): AnyFormApi => { + const ctx = useContext(FormContext); + + if (ctx === null) { + throw new Error( + "[pathscale/ui] useFormContext() was called outside of a component.\n" + + "Make sure this component is rendered as a descendant of .", + ); + } + + return ctx; +}; diff --git a/src/hooks/form/createForm.ts b/src/hooks/form/createForm.ts new file mode 100644 index 00000000..ab0cd2f8 --- /dev/null +++ b/src/hooks/form/createForm.ts @@ -0,0 +1,109 @@ +import { createForm as createTSForm } from "@tanstack/solid-form"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +type AnyValues = Record; + +export type AsyncValidatorFn = (context: { + value: TValues; +}) => Promise> | undefined>; + +export type CreateFormOptions = { + /** + * Initial values for every field. Used to infer the form's value type and + * to determine whether a field is "dirty". + */ + defaultValues: TValues; + + /** + * Any Standard Schema-compatible schema (Zod, Valibot, Arktype, …). + * Applied as `validators.onBlur` and `validators.onSubmit` by default. + */ + schema?: StandardSchemaV1; + + /** + * Additional async validators for fields that need server-side validation + * (e.g. username availability). These run *after* the synchronous schema. + */ + asyncValidators?: { + onBlur?: AsyncValidatorFn; + onSubmit?: AsyncValidatorFn; + }; + + /** + * Called when the form is submitted and all validators pass. + */ + onSubmit?: (value: TValues) => void | Promise; +}; + +/** + * The form API returned by `createForm`. + * + * Consumers should use `` to wire it into the component tree. + * For advanced use, access the raw TanStack Form instance via `api._tsForm`. + */ +export type FormApi = { + /** + * Raw TanStack Form instance. Intended as an escape hatch for cases not + * covered by the library's abstractions (custom field rendering, ``, etc.). + * Prefixed with `_` to signal that it's an internal detail. + * + * TanStack Form's type has 12 generic parameters — we intentionally erase + * them here to keep the public API simple. Use `form._tsForm` to access the + * full typed instance in advanced scenarios. + */ + // biome-ignore lint/suspicious/noExplicitAny: TanStack Form has 12 generic params; erased for public API simplicity + _tsForm: any; + /** Phantom field: preserves TValues for type inference in consuming hooks. */ + _values?: TValues; +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Creates a new form instance backed by TanStack Form. + * + * ```tsx + * const form = createForm({ + * defaultValues: { email: "", password: "" }, + * schema: loginSchema, // Zod or Valibot — both work + * onSubmit: async (values) => { await login(values); }, + * }); + * + * return ( + * + * + * + * Log in + * + * ); + * ``` + */ +export const createForm = ( + options: CreateFormOptions, +): FormApi => { + // biome-ignore lint/suspicious/noExplicitAny: TanStack Form generics are erased at our wrapper boundary + const tsForm: any = createTSForm(() => ({ + defaultValues: options.defaultValues as Record, + + validators: options.schema + ? { + // biome-ignore lint/suspicious/noExplicitAny: Standard Schema bridges Zod/Valibot/etc. + onBlur: options.schema as any, + // biome-ignore lint/suspicious/noExplicitAny: Standard Schema bridges Zod/Valibot/etc. + onSubmit: options.schema as any, + } + : undefined, + + onSubmit: async ({ value }: { value: unknown }) => { + await options.onSubmit?.(value as TValues); + }, + })); + + return { _tsForm: tsForm }; +}; diff --git a/src/hooks/form/getFirstFieldError.ts b/src/hooks/form/getFirstFieldError.ts new file mode 100644 index 00000000..a439d3b8 --- /dev/null +++ b/src/hooks/form/getFirstFieldError.ts @@ -0,0 +1,35 @@ +/** + * Normalizes a TanStack Form `ValidationError[]` into the first displayable + * error string. + * + * TanStack Form stores errors as `(string | ZodIssue | ValibotIssue | ...)[]` + * depending on the schema library in use. This function collapses that union + * to a single `string | undefined` for display in field error components. + */ +export const getFirstFieldError = ( + errors: unknown[], +): string | undefined => { + if (!errors || errors.length === 0) return undefined; + + for (const error of errors) { + if (!error) continue; + + if (typeof error === "string") { + const trimmed = error.trim(); + if (trimmed.length > 0) return trimmed; + continue; + } + + if (typeof error === "object") { + // Standard Schema / Zod / Valibot issues expose .message + const maybeMessage = (error as Record).message; + if (typeof maybeMessage === "string") { + const trimmed = maybeMessage.trim(); + if (trimmed.length > 0) return trimmed; + } + continue; + } + } + + return undefined; +}; diff --git a/src/hooks/form/index.ts b/src/hooks/form/index.ts index 4fa422ad..f3cd6d62 100644 --- a/src/hooks/form/index.ts +++ b/src/hooks/form/index.ts @@ -1,3 +1,6 @@ +// --------------------------------------------------------------------------- +// Legacy Felte-based API (deprecated — will be removed when all forms migrate) +// --------------------------------------------------------------------------- export { useForm, getFormControllerFromElement, @@ -13,5 +16,14 @@ export { type UseFieldMetaResult, } from "./useFieldMeta"; export { useFieldError } from "./useFieldError"; -export { useField, type UseFieldResult } from "./useField"; +/** @deprecated Use `useField` from the new TanStack-based API instead. */ +export { useField as useFieldLegacy, type UseFieldResult as UseFieldLegacyResult } from "./useField"; export { useFieldProps, type UseFieldPropsResult } from "./useFieldProps"; + +// --------------------------------------------------------------------------- +// New TanStack Form-based API +// --------------------------------------------------------------------------- +export { createForm, type CreateFormOptions, type FormApi } from "./createForm"; +export { FormContext, useFormContext, type AnyFormApi } from "./FormContext"; +export { useField, type UseFieldResult } from "./useFieldNew"; +export { getFirstFieldError } from "./getFirstFieldError"; diff --git a/src/hooks/form/useFieldNew.ts b/src/hooks/form/useFieldNew.ts new file mode 100644 index 00000000..27b613c1 --- /dev/null +++ b/src/hooks/form/useFieldNew.ts @@ -0,0 +1,74 @@ +import { createMemo, type Accessor } from "solid-js"; +import { useFormContext } from "./FormContext"; +import { getFirstFieldError } from "./getFirstFieldError"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type UseFieldResult = { + /** Current field value as an unknown (cast as needed). */ + value: Accessor; + /** Normalized first error string, gated by `isTouched`. `undefined` if clean. */ + error: Accessor; + /** Whether the field has been blurred at least once. */ + touched: Accessor; + /** `true` when `error()` is non-empty. */ + invalid: Accessor; + /** Call when the field value changes (accepts the new value directly). */ + handleChange: (value: unknown) => void; + /** Call when the field loses focus. */ + handleBlur: () => void; +}; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Reads live field state for `name` from the nearest `
` context. + * + * Must be called **inside** a `` descendant component. + * For fields that need fine-grained rendering, prefer using `form._tsForm.Field` + * render prop directly. + * + * ```tsx + * // Inside a child of + * const email = useField("email"); + * + * email.handleChange(e.currentTarget.value)} + * onBlur={email.handleBlur} + * aria-invalid={email.invalid()} + * /> + * ``` + */ +export const useField = (name: string): UseFieldResult => { + const form = useFormContext(); + const tsForm = form._tsForm; + + const value = createMemo(() => tsForm.getFieldValue(name as never)); + + const meta = createMemo(() => tsForm.getFieldMeta(name as never)); + + const touched = createMemo(() => Boolean(meta()?.isTouched)); + + const error = createMemo((): string | undefined => { + const m = meta(); + if (!m?.isTouched) return undefined; + return getFirstFieldError((m.errors ?? []) as unknown[]); + }); + + const invalid = createMemo(() => Boolean(error())); + + const handleChange = (value: unknown) => { + tsForm.setFieldValue(name as never, value as never); + }; + + const handleBlur = () => { + tsForm.validateField(name as never, "blur"); + }; + + return { value, error, touched, invalid, handleChange, handleBlur }; +}; diff --git a/src/index.ts b/src/index.ts index e478445f..2f878e19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -303,16 +303,37 @@ export type { FieldGroupProps, FieldsetActionsProps, } from "./components/fieldset"; -export { default as Form, FormRoot } from "./components/form"; +// --------------------------------------------------------------------------- +// Form components — legacy (Felte-based) + new (TanStack-based) +// --------------------------------------------------------------------------- +export { + default as Form, + FormRoot, + FormWithContext, +} from "./components/form"; +export { + FormField, + FormSubmitButton, + FieldErrorMessage, +} from "./components/form"; +export { useDesktop } from "./hooks/layout"; +export type { + FormProps, + FormRootProps, + FormWithContextProps, + FormFieldProps, + FormSubmitButtonProps, + FieldErrorMessageProps, +} from "./components/form"; + +// Legacy Felte-based hooks (deprecated — will be removed after migration) export { useForm, - useField, + useFieldLegacy, useFieldProps, useFieldError, useFieldMeta, } from "./hooks/form"; -export { useDesktop } from "./hooks/layout"; -export type { FormProps, FormRootProps } from "./components/form"; export type { FormController, FormDirective, @@ -321,9 +342,24 @@ export type { FieldName, UseFieldOptions, UseFieldMetaResult, - UseFieldResult, + UseFieldLegacyResult, UseFieldPropsResult, } from "./hooks/form"; + +// New TanStack Form-based API +export { + createForm, + useFormContext, + useField, + getFirstFieldError, + FormContext, +} from "./hooks/form"; +export type { + CreateFormOptions, + FormApi, + AnyFormApi, + UseFieldResult, +} from "./hooks/form"; export { default as Grid } from "./components/grid"; export { default as Header, HeaderRoot } from "./components/header"; export type { HeaderProps, HeaderRootProps } from "./components/header"; diff --git a/src/styles/icons/generated-icons.css b/src/styles/icons/generated-icons.css index 12b55e98..0b7bac23 100644 --- a/src/styles/icons/generated-icons.css +++ b/src/styles/icons/generated-icons.css @@ -1 +1,63 @@ -.iconify{background-color:currentColor;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%}.iconify,.iconify-color{display:inline-block;height:1em;width:1em}.iconify-color{background-repeat:no-repeat;background-size:100% 100%}.icon-[mdi--close]{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E")}.icon-[mdi--close].iconify{-webkit-mask-image:var(--svg);mask-image:var(--svg)}.icon-[mdi--close].iconify-color{background-image:var(--svg)}.icon-[mdi--firefox]{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M6.85 6.74q.015 0 0 0M21.28 8.6c-.43-1.05-1.32-2.18-2.01-2.54.56 1.11.89 2.22 1.02 3.04v.02c-1.13-2.82-3.05-3.96-4.62-6.44-.08-.12-.17-.25-.24-.38-.04-.07-.07-.14-.11-.21-.06-.13-.12-.26-.15-.4 0-.01-.01-.02-.02-.02h-.03c-2.22 1.3-3.15 3.59-3.38 5.04-.69.04-1.37.21-1.99.51-.12.05-.17.19-.13.31.05.14.21.21.34.15.54-.26 1.14-.41 1.74-.45h.05c.08-.01.17-.01.25-.01.5-.01.97.06 1.44.2l.06.02c.1.02.17.06.25.06.05.04.11.06.16.08l.14.06c.07.03.14.06.2.09.03.02.06.03.09.05.07.04.16.07.2.11.04.02.08.05.12.07.73.45 1.34 1.07 1.75 1.81-.53-.37-1.49-.74-2.41-.58 3.6 1.81 2.63 8-2.36 7.76-.44-.01-.88-.1-1.3-.25-.1-.03-.2-.07-.29-.12-.05-.02-.12-.05-.17-.08-1.23-.63-2.24-1.82-2.38-3.27 0 0 .5-1.73 3.33-1.73.31 0 1.17-.86 1.2-1.1 0-.09-1.74-.78-2.42-1.45-.37-.36-.54-.53-.69-.66-.08-.07-.17-.13-.26-.19a4.63 4.63 0 0 1-.03-2.45C7.6 6.12 6.8 6.86 6.22 7.5c-.4-.5-.37-2.15-.35-2.5-.01 0-.3.16-.33.18-.35.25-.68.53-.98.82-.35.37-.66.74-.94 1.14-.62.91-1.12 1.95-1.34 3.04 0 .01-.1.41-.17.92l-.03.23c-.02.17-.04.32-.08.58v.41c0 5.53 4.5 10.01 10 10.01 4.97 0 9.08-3.59 9.88-8.33.02-.11.03-.24.05-.37.2-1.72-.02-3.52-.65-5.03'/%3E%3C/svg%3E")}.icon-[mdi--firefox].iconify{-webkit-mask-image:var(--svg);mask-image:var(--svg)}.icon-[mdi--firefox].iconify-color{background-image:var(--svg)}.icon-[mdi--loading]{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8'/%3E%3C/svg%3E")}.icon-[mdi--loading].iconify{-webkit-mask-image:var(--svg);mask-image:var(--svg)}.icon-[mdi--loading].iconify-color{background-image:var(--svg)}.icon-[mdi--palette]{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5 1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5 1.5 1.5 0 0 1 14.5 5 1.5 1.5 0 0 1 16 6.5 1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5 1.5 1.5 0 0 1 9.5 5 1.5 1.5 0 0 1 11 6.5 1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5 1.5 1.5 0 0 1 6.5 9 1.5 1.5 0 0 1 8 10.5 1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9 9 9 0 0 0 9 9 1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8'/%3E%3C/svg%3E")}.icon-[mdi--palette].iconify{-webkit-mask-image:var(--svg);mask-image:var(--svg)}.icon-[mdi--palette].iconify-color{background-image:var(--svg)} \ No newline at end of file + +.iconify { + display: inline-block; + width: 1em; + height: 1em; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.iconify-color { + display: inline-block; + width: 1em; + height: 1em; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +.icon-[mdi--close] { + --svg: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20fill%3D%22currentColor%22%20d%3D%22M19%206.41%2017.59%205%2012%2010.59%206.41%205%205%206.41%2010.59%2012%205%2017.59%206.41%2019%2012%2013.41%2017.59%2019%2019%2017.59%2013.41%2012z%22%2F%3E%3C%2Fsvg%3E"); +} +.icon-[mdi--close].iconify { + -webkit-mask-image: var(--svg); + mask-image: var(--svg); +} +.icon-[mdi--close].iconify-color { + background-image: var(--svg); +} + +.icon-[mdi--firefox] { + --svg: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20fill%3D%22currentColor%22%20d%3D%22M6.85%206.74q.015%200%200%200M21.28%208.6c-.43-1.05-1.32-2.18-2.01-2.54.56%201.11.89%202.22%201.02%203.04v.02c-1.13-2.82-3.05-3.96-4.62-6.44-.08-.12-.17-.25-.24-.38-.04-.07-.07-.14-.11-.21-.06-.13-.12-.26-.15-.4%200-.01-.01-.02-.02-.02h-.03c-2.22%201.3-3.15%203.59-3.38%205.04-.69.04-1.37.21-1.99.51-.12.05-.17.19-.13.31.05.14.21.21.34.15.54-.26%201.14-.41%201.74-.45h.05c.08-.01.17-.01.25-.01.5-.01.97.06%201.44.2l.06.02c.1.02.17.06.25.06.05.04.11.06.16.08l.14.06c.07.03.14.06.2.09.03.02.06.03.09.05.07.04.16.07.2.11.04.02.08.05.12.07.73.45%201.34%201.07%201.75%201.81-.53-.37-1.49-.74-2.41-.58%203.6%201.81%202.63%208-2.36%207.76-.44-.01-.88-.1-1.3-.25-.1-.03-.2-.07-.29-.12-.05-.02-.12-.05-.17-.08-1.23-.63-2.24-1.82-2.38-3.27%200%200%20.5-1.73%203.33-1.73.31%200%201.17-.86%201.2-1.1%200-.09-1.74-.78-2.42-1.45-.37-.36-.54-.53-.69-.66-.08-.07-.17-.13-.26-.19a4.63%204.63%200%200%201-.03-2.45C7.6%206.12%206.8%206.86%206.22%207.5c-.4-.5-.37-2.15-.35-2.5-.01%200-.3.16-.33.18-.35.25-.68.53-.98.82-.35.37-.66.74-.94%201.14-.62.91-1.12%201.95-1.34%203.04%200%20.01-.1.41-.17.92l-.03.23c-.02.17-.04.32-.08.58v.41c0%205.53%204.5%2010.01%2010%2010.01%204.97%200%209.08-3.59%209.88-8.33.02-.11.03-.24.05-.37.2-1.72-.02-3.52-.65-5.03%22%2F%3E%3C%2Fsvg%3E"); +} +.icon-[mdi--firefox].iconify { + -webkit-mask-image: var(--svg); + mask-image: var(--svg); +} +.icon-[mdi--firefox].iconify-color { + background-image: var(--svg); +} + +.icon-[mdi--loading] { + --svg: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20fill%3D%22currentColor%22%20d%3D%22M12%204V2A10%2010%200%200%200%202%2012h2a8%208%200%200%201%208-8%22%2F%3E%3C%2Fsvg%3E"); +} +.icon-[mdi--loading].iconify { + -webkit-mask-image: var(--svg); + mask-image: var(--svg); +} +.icon-[mdi--loading].iconify-color { + background-image: var(--svg); +} + +.icon-[mdi--palette] { + --svg: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20fill%3D%22currentColor%22%20d%3D%22M17.5%2012a1.5%201.5%200%200%201-1.5-1.5A1.5%201.5%200%200%201%2017.5%209a1.5%201.5%200%200%201%201.5%201.5%201.5%201.5%200%200%201-1.5%201.5m-3-4A1.5%201.5%200%200%201%2013%206.5%201.5%201.5%200%200%201%2014.5%205%201.5%201.5%200%200%201%2016%206.5%201.5%201.5%200%200%201%2014.5%208m-5%200A1.5%201.5%200%200%201%208%206.5%201.5%201.5%200%200%201%209.5%205%201.5%201.5%200%200%201%2011%206.5%201.5%201.5%200%200%201%209.5%208m-3%204A1.5%201.5%200%200%201%205%2010.5%201.5%201.5%200%200%201%206.5%209%201.5%201.5%200%200%201%208%2010.5%201.5%201.5%200%200%201%206.5%2012M12%203a9%209%200%200%200-9%209%209%209%200%200%200%209%209%201.5%201.5%200%200%200%201.5-1.5c0-.39-.15-.74-.39-1-.23-.27-.38-.62-.38-1a1.5%201.5%200%200%201%201.5-1.5H16a5%205%200%200%200%205-5c0-4.42-4.03-8-9-8%22%2F%3E%3C%2Fsvg%3E"); +} +.icon-[mdi--palette].iconify { + -webkit-mask-image: var(--svg); + mask-image: var(--svg); +} +.icon-[mdi--palette].iconify-color { + background-image: var(--svg); +} From 0f0f0cf24460c91e3366d85f81f88250a9c14f10 Mon Sep 17 00:00:00 2001 From: Diego Martinez Date: Thu, 16 Apr 2026 13:30:12 -0400 Subject: [PATCH 2/2] feat: add TanStack Form integration with Zod validation and a new form playground example --- playground/bun.lock | 19 ++ playground/package.json | 5 +- playground/src/App.tsx | 2 + playground/src/examples/FormExample.tsx | 236 ++++++++++++++++++++++++ src/components/form/Form.tsx | 7 +- 5 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 playground/src/examples/FormExample.tsx diff --git a/playground/bun.lock b/playground/bun.lock index eb06668c..5d331649 100644 --- a/playground/bun.lock +++ b/playground/bun.lock @@ -5,7 +5,10 @@ "name": "playground", "dependencies": { "@iconify/tailwind4": "^1.0.6", + "@standard-schema/spec": "^1.1.0", + "@tanstack/solid-form": "^1.29.0", "popmotion": "^11.0.5", + "zod": "^4.3.6", }, "devDependencies": { "@iconify/json": "^2.2.342", @@ -165,6 +168,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.2", "", { "os": "win32", "cpu": "x64" }, "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.7", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.7" } }, "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.7", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.7", "@tailwindcss/oxide-darwin-arm64": "4.1.7", "@tailwindcss/oxide-darwin-x64": "4.1.7", "@tailwindcss/oxide-freebsd-x64": "4.1.7", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", "@tailwindcss/oxide-linux-x64-musl": "4.1.7", "@tailwindcss/oxide-wasm32-wasi": "4.1.7", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" } }, "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ=="], @@ -195,6 +200,18 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.7", "", { "dependencies": { "@tailwindcss/node": "4.1.7", "@tailwindcss/oxide": "4.1.7", "tailwindcss": "4.1.7" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-tYa2fO3zDe41I7WqijyVbRd8oWT0aEID1Eokz5hMT6wShLIHj3yvwj9XbfuloHP9glZ6H+aG2AN/+ZrxJ1Y5RQ=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.29.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ=="], + + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/solid-form": ["@tanstack/solid-form@1.29.0", "", { "dependencies": { "@tanstack/form-core": "1.29.0", "@tanstack/solid-store": "^0.9.1" }, "peerDependencies": { "solid-js": ">=1.9.9" } }, "sha512-t+4ZR7ZF3LACP794zyDGPT+Lk51720IOWwm/VqrK28NetHHVV09YrY4H+iWRNqc1Flc0I2Tm1BIDDYRTmdZH/A=="], + + "@tanstack/solid-store": ["@tanstack/solid-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-XThXDzwJT8zeatmxFK1UISikfzz1z76mMlpg1IBDPrJp6df6U3cpJInZRbYs3xlVsnhMziuZigaSkzMyZESK9Q=="], + + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -373,6 +390,8 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], diff --git a/playground/package.json b/playground/package.json index 437ea883..e5ff8845 100644 --- a/playground/package.json +++ b/playground/package.json @@ -19,6 +19,9 @@ }, "dependencies": { "@iconify/tailwind4": "^1.0.6", - "popmotion": "^11.0.5" + "@standard-schema/spec": "^1.1.0", + "@tanstack/solid-form": "^1.29.0", + "popmotion": "^11.0.5", + "zod": "^4.3.6" } } diff --git a/playground/src/App.tsx b/playground/src/App.tsx index f3605378..5e9409cf 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -97,6 +97,7 @@ import { TableExamples } from "./examples/TableExamples"; import { TableHooksExample } from "./examples/TableHooksExample"; import { TableVirtualizedExample } from "./examples/TableVirtualizedExample"; import { StreamingComposableExample } from "./examples/StreamingComposableExample"; +import { FormExample } from "./examples/FormExample"; const BADGE_COLORS = [ "default", @@ -5528,6 +5529,7 @@ export default function App() { + diff --git a/playground/src/examples/FormExample.tsx b/playground/src/examples/FormExample.tsx new file mode 100644 index 00000000..032e0a03 --- /dev/null +++ b/playground/src/examples/FormExample.tsx @@ -0,0 +1,236 @@ +/** + * FormExample.tsx — playground demonstration of the new TanStack Form API + * + * Shows two usage patterns side by side: + * 1. Standard: createForm + + + + * 2. Custom field: createForm + useField() for bespoke rendering + */ +import { createSignal, Show } from "solid-js"; +import { z } from "zod"; +import { + createForm, + Form, + FormField, + FormSubmitButton, + useFormContext, + getFirstFieldError, + Input, + Label, +} from "@pathscale/ui"; + +// --------------------------------------------------------------------------- +// Schemas — defined once at module level, never re-created on render +// --------------------------------------------------------------------------- + +const signupSchema = z.object({ + username: z + .string() + .min(1, "Username is required") + .min(3, "At least 3 characters") + .max(32, "At most 32 characters") + .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers and underscores"), + email: z.string().min(1, "Email is required").email("Enter a valid email"), + password: z + .string() + .min(1, "Password is required") + .min(8, "At least 8 characters"), +}); + +type SignupValues = z.infer; + +// --------------------------------------------------------------------------- +// Custom field — calls useField() inside a tree +// --------------------------------------------------------------------------- + +const PasswordStrengthField = () => { + const form = useFormContext(); + const tsForm = form._tsForm; + + return ( + { + const password = () => String(field().state.value ?? ""); + const strength = () => { + const p = password(); + if (p.length === 0) return 0; + if (p.length < 6) return 1; + if (p.length < 10 || !/[A-Z]/.test(p)) return 2; + return 3; + }; + const strengthLabel = () => ["", "Weak", "Fair", "Strong"][strength()]; + const strengthColors = ["", "bg-error", "bg-warning", "bg-success"]; + + const error = () => + field().state.meta.isTouched + ? getFirstFieldError((field().state.meta.errors ?? []) as unknown[]) + : undefined; + + return ( +
+ + field().handleChange(e.currentTarget.value)} + onBlur={() => field().handleBlur()} + isInvalid={Boolean(error())} + /> + {/* Strength meter */} + 0}> +
+
+ {[1, 2, 3].map((level) => ( +
= level + ? strengthColors[strength()] + : "bg-base-300" + }`} + /> + ))} +
+ {strengthLabel()} +
+ + + + +
+ ); + }} + /> + ); +}; + +// --------------------------------------------------------------------------- +// Main example component +// --------------------------------------------------------------------------- + +export const FormExample = () => { + const [submitResult, setSubmitResult] = createSignal( + null, + ); + const [apiError, setApiError] = createSignal(null); + + const form = createForm({ + defaultValues: { username: "", email: "", password: "" }, + schema: signupSchema, + onSubmit: async (values) => { + setApiError(null); + // Simulate an async call with a possible server-side error + await new Promise((r) => setTimeout(r, 800)); + if (values.username === "admin") { + setApiError("Username 'admin' is reserved. Choose another."); + return; + } + setSubmitResult(values); + }, + }); + + return ( +
+
+

New Form API (TanStack Form)

+

+ createForm + {""} + {""} + {""}. Zero + WeakMap, zero directive, Standard Schema validation (Zod). +

+
+ +
+ {/* ---------------------------------------------------------------- */} + {/* Left: the form */} + {/* ---------------------------------------------------------------- */} +
+ + {/* Standard usage */} + + + + {/* Custom field using useFormContext() + form._tsForm.Field */} + + + +

+ {apiError()} +

+
+ + Create account + + + + {(result) => ( +
+

✓ Submitted successfully

+
{JSON.stringify(result(), null, 2)}
+
+ )} +
+
+ + {/* ---------------------------------------------------------------- */} + {/* Right: API description */} + {/* ---------------------------------------------------------------- */} +
+
+

createForm()

+

+ Wraps TanStack Form. Pass any Standard Schema (Zod, Valibot, …). + Validates on blur and submit automatically. +

+
+
+

{"

"}

+

+ Context provider + native form. No use:form directive. + Fields inside find the form automatically. +

+
+
+

{""}

+

+ Label + Input + error message in one. Reads form from context. + Pass inputProps for type, placeholder, etc. +

+
+
+

{""}

+

+ Auto-disables when form is invalid, + shows pending state during submit. No manual wiring. +

+
+
+

Custom fields via useFormContext()

+

+ Access form._tsForm for full TanStack Form API. + The Password field above adds a strength meter this way. +

+
+
+

Try: username = "admin"

+

+ Server error is a plain createSignal alongside form + state — correct separation of concerns. +

+
+
+
+
+ ); +}; diff --git a/src/components/form/Form.tsx b/src/components/form/Form.tsx index 94156b82..ba14923c 100644 --- a/src/components/form/Form.tsx +++ b/src/components/form/Form.tsx @@ -113,14 +113,12 @@ const FormWithContext: Component = (props) => { * {...} * ``` */ -function Form(props: FormWithContextProps): JSX.Element; -function Form(props: FormRootProps): JSX.Element; -function Form(props: FormRootProps | FormWithContextProps): JSX.Element { +const Form = (props: FormRootProps | FormWithContextProps): JSX.Element => { if ("form" in props && props.form != null) { return ; } return ; -} +}; // Attach sub-components for compound-component usage (Form as unknown as Record).Root = FormRoot; @@ -129,4 +127,3 @@ function Form(props: FormRootProps | FormWithContextProps): JSX.Element { export default Form; export { Form, FormRoot, FormWithContext }; export type { FormRootProps as FormProps }; -export type { FormWithContextProps };