From 982e2f90569a2ce698798bf7887ca79250a91377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Wed, 12 Nov 2025 22:13:56 +0100 Subject: [PATCH 1/2] feat(rhf): new form abstraction early proposal --- package.json | 2 + pnpm-lock.yaml | 88 +++++++ src/components/new-form/_field-components.ts | 14 + src/components/new-form/docs.stories.tsx | 73 ++++++ .../new-form/field-select/index.tsx | 45 ++++ src/components/new-form/field-text/index.tsx | 35 +++ src/components/new-form/form-field-label.tsx | 10 + .../new-form/form-field/context.tsx | 26 ++ src/components/new-form/form-field/index.tsx | 82 ++++++ src/components/new-form/form.tsx | 54 ++++ src/components/new-form/index.ts | 4 + src/components/ui/field.tsx | 247 ++++++++++++++++++ src/components/ui/input.tsx | 2 +- src/components/ui/label.tsx | 22 ++ src/components/ui/select.tsx | 2 +- src/lib/react-hook-form/index.tsx | 37 +++ src/types/utilities.d.ts | 5 + 17 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 src/components/new-form/_field-components.ts create mode 100644 src/components/new-form/docs.stories.tsx create mode 100644 src/components/new-form/field-select/index.tsx create mode 100644 src/components/new-form/field-text/index.tsx create mode 100644 src/components/new-form/form-field-label.tsx create mode 100644 src/components/new-form/form-field/context.tsx create mode 100644 src/components/new-form/form-field/index.tsx create mode 100644 src/components/new-form/form.tsx create mode 100644 src/components/new-form/index.ts create mode 100644 src/components/ui/field.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/lib/react-hook-form/index.tsx diff --git a/package.json b/package.json index 886c8ce7a..b2fc12c99 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "@orpc/server": "1.9.3", "@orpc/zod": "1.9.3", "@prisma/client": "6.17.0", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-separator": "1.1.8", "@react-email/components": "0.5.6", "@react-email/render": "1.3.2", "@t3-oss/env-core": "0.13.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75678fea9..15b4ad8d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: '@prisma/client': specifier: 6.17.0 version: 6.17.0(prisma@6.17.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@radix-ui/react-label': + specifier: 2.1.8 + version: 2.1.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-separator': + specifier: 1.1.8 + version: 1.1.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@react-email/components': specifier: 0.5.6 version: 0.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -2333,6 +2339,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -2515,6 +2534,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.7': resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} peerDependencies: @@ -2593,6 +2625,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slider@1.3.6': resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} peerDependencies: @@ -2633,6 +2678,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.6': resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: @@ -11622,6 +11676,15 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -11832,6 +11895,15 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -11932,6 +12004,15 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -11972,6 +12053,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-slot@1.2.4(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/src/components/new-form/_field-components.ts b/src/components/new-form/_field-components.ts new file mode 100644 index 000000000..4f3cb375a --- /dev/null +++ b/src/components/new-form/_field-components.ts @@ -0,0 +1,14 @@ +import { FieldSelect } from '@/components/new-form/field-select'; +import { FieldText } from '@/components/new-form/field-text'; +import { FormFieldLabel } from '@/components/new-form/form-field-label'; + +export const fieldComponents = { + Label: FormFieldLabel, + Text: FieldText, + Select: FieldSelect, + /** + * Add new fields to include in the FormField render props. + */ +} as const; + +export type FieldComponents = typeof fieldComponents; diff --git a/src/components/new-form/docs.stories.tsx b/src/components/new-form/docs.stories.tsx new file mode 100644 index 000000000..949dd2638 --- /dev/null +++ b/src/components/new-form/docs.stories.tsx @@ -0,0 +1,73 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Meta } from '@storybook/react-vite'; +import { z } from 'zod'; + +import { useForm } from '@/lib/react-hook-form'; +import { zu } from '@/lib/zod/zod-utils'; + +import { onSubmit } from '@/components/form/docs.utils'; +import { Form } from '@/components/new-form'; +import { Button } from '@/components/ui/button'; +import { + FieldDescription, + FieldError, + FieldLabel, +} from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +export default { + title: 'NewForm/Form', +} satisfies Meta; + +const zFormSchema = () => + z.object({ + name: zu.fieldText.required(), + other: zu.fieldText.nullish(), + }); + +export const Default = () => { + const form = useForm({ + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), + defaultValues: { + name: '', + other: '', + }, + }); + + return ( +
+
+ ( + <> + Name + + This is an helper text + + )} + /> + ( + <> + Name + + {state.invalid && } + + )} + /> +
+ +
+
+
+ ); +}; diff --git a/src/components/new-form/field-select/index.tsx b/src/components/new-form/field-select/index.tsx new file mode 100644 index 000000000..3cd430733 --- /dev/null +++ b/src/components/new-form/field-select/index.tsx @@ -0,0 +1,45 @@ +import { useFormField } from '@/components/new-form/form-field/context'; +import { FieldError } from '@/components/ui/field'; +import type { TValueBase } from '@/components/ui/select'; +import { Select } from '@/components/ui/select'; + +export const FieldSelect = ({ + options, + ...rest +}: React.ComponentProps>) => { + const { field, fieldState } = useFormField(); + + const descriptionId = `${field.name}-desc`; + const errorId = `${field.name}-error`; + + return ( + <> + { + field.onChange(e); + props.onChange?.(e); + }} + onBlur={(e) => { + field.onBlur(); + props.onBlur?.(e); + }} + /> + {fieldState.invalid && ( + + )} + + ); +} diff --git a/src/components/new-form/form-field-label.tsx b/src/components/new-form/form-field-label.tsx new file mode 100644 index 000000000..0538a14fc --- /dev/null +++ b/src/components/new-form/form-field-label.tsx @@ -0,0 +1,10 @@ +import { useFormField } from '@/components/new-form/form-field/context'; +import { FieldLabel } from '@/components/ui/field'; + +export function FormFieldLabel(props: React.ComponentProps) { + const { field } = useFormField(); + + return ( + + ); +} diff --git a/src/components/new-form/form-field/context.tsx b/src/components/new-form/form-field/context.tsx new file mode 100644 index 000000000..4a52d31b5 --- /dev/null +++ b/src/components/new-form/form-field/context.tsx @@ -0,0 +1,26 @@ +import { createContext, use } from 'react'; +import type { + ControllerFieldState, + ControllerRenderProps, + FieldValues, +} from 'react-hook-form'; + +export type FormFieldSize = 'sm' | 'default' | 'lg'; + +export type FormFieldContextValue = { + size: FormFieldSize; + field: ControllerRenderProps; + fieldState: ControllerFieldState; +}; + +export const FormFieldContext = createContext( + null +); + +export const useFormField = () => { + const context = use(FormFieldContext); + + if (!context) throw new Error('Missing parent component.'); + + return context; +}; diff --git a/src/components/new-form/form-field/index.tsx b/src/components/new-form/form-field/index.tsx new file mode 100644 index 000000000..52a208713 --- /dev/null +++ b/src/components/new-form/form-field/index.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import type { + ControllerFieldState, + ControllerRenderProps, + FieldPath, + FieldValues, + UseControllerProps, +} from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +import { + FieldComponents, + fieldComponents, +} from '@/components/new-form/_field-components'; +import { + FormFieldContext, + FormFieldContextValue, + FormFieldSize, +} from '@/components/new-form/form-field/context'; +import { Field } from '@/components/ui/field'; + +export type FormFieldProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, + TTransformedValues = TFieldValues, +> = WithRequired< + UseControllerProps, + 'name' +> & { + size?: FormFieldSize; + children: ( + field: { + props: ControllerRenderProps; + state: ControllerFieldState; + } & FieldComponents + ) => React.ReactNode; +}; + +/** + * Inspired by from @tanstack/react-form + * + * @see https://github.com/TanStack/form/blob/main/packages/react-form/src/createFormHook.tsx + */ +export function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, + TTransformedValues = TFieldValues, +>({ + children, + size, + ...controllerProps +}: FormFieldProps) { + return ( + { + // We are inside a render function so it's fine + // eslint-disable-next-line react-hooks/rules-of-hooks + const fieldCtx = useMemo( + () => ({ + field, + fieldState, + size, + }), + [field, fieldState] + ) as FormFieldContextValue; + + return ( + + + {children({ + props: field, + ...fieldComponents, + state: fieldState, + })} + + + ); + }} + /> + ); +} diff --git a/src/components/new-form/form.tsx b/src/components/new-form/form.tsx new file mode 100644 index 000000000..d3dc9ad41 --- /dev/null +++ b/src/components/new-form/form.tsx @@ -0,0 +1,54 @@ +import { + FieldValues, + FormProvider, + FormProviderProps, + SubmitHandler, +} from 'react-hook-form'; + +import { cn } from '@/lib/tailwind/utils'; + +type FormProps< + TFieldValues extends FieldValues = FieldValues, + TContext = ExplicitAny, + TTransformedValues = TFieldValues, +> = StrictUnion< + | (FormProviderProps & { + noHtmlForm?: false; + onSubmit?: SubmitHandler; + className?: string; + }) + | (FormProviderProps & { + noHtmlForm: true; + }) +>; + +export const Form = ({ + noHtmlForm = false, + className, + ...props +}: FormProps) => { + if (noHtmlForm) { + return ; + } + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + + if (props.onSubmit) { + props.handleSubmit(props.onSubmit)(e); + } else { + console.warn('Missing onSubmit method on '); + } + }} + className={cn('flex flex-1 flex-col', className)} + > + {props.children} +
+
+ ); +}; diff --git a/src/components/new-form/index.ts b/src/components/new-form/index.ts new file mode 100644 index 000000000..3a951cddb --- /dev/null +++ b/src/components/new-form/index.ts @@ -0,0 +1,4 @@ +import { Form } from '@/components/new-form/form'; +import { FormField } from '@/components/new-form/form-field/index'; + +export { Form, FormField }; diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 000000000..dc90790f7 --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,247 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { useMemo } from 'react'; + +import { cn } from '@/lib/tailwind/utils'; + +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = 'legend', + ...props +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
[data-slot=field-group]]:gap-4', + className + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + 'group/field flex w-full gap-3 data-[invalid=true]:text-destructive', + { + variants: { + orientation: { + vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'], + horizontal: [ + 'flex-row items-center', + '[&>[data-slot=field-label]]:flex-auto', + 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + ], + responsive: [ + 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto', + '@md/field-group:[&>[data-slot=field-label]]:flex-auto', + '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + ], + }, + }, + defaultVariants: { + orientation: 'vertical', + }, + } +); + +function Field({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +