diff --git a/apps/docs/src/content/04-components/react-hook-form/field/examples/custom-field.tsx b/apps/docs/src/content/04-components/react-hook-form/field/examples/custom-field.tsx new file mode 100644 index 0000000000..53528662d5 --- /dev/null +++ b/apps/docs/src/content/04-components/react-hook-form/field/examples/custom-field.tsx @@ -0,0 +1,91 @@ +import { + type FieldPropsComponent, + Form, + SubmitButton, + typedField, + useFieldProps, +} from "@mittwald/flow-react-components/react-hook-form"; +import { + ActionGroup, + Label, + Section, +} from "@mittwald/flow-react-components"; +import { type FC, useMemo } from "react"; +import { useForm } from "react-hook-form"; + +export default () => { + const CustomFieldComponent: FC = ( + props, + ) => { + const { + ref, + children, + value, + defaultValue, + onChange, + onBlur, + form, + name, + disabled, + isReadOnly, + fieldComponents: { + FieldErrorView, + FieldComponentContainer, + FieldChildrenContainer, + }, + } = useFieldProps(props); + + return ( + + + {children} + + + + + ); + }; + + // only necessary because CustomFieldComponent is an inline component in this demo page + const MemoCustomFieldComponent = useMemo( + () => CustomFieldComponent, + [], + ); + + interface Values { + myCustomField: string; + } + const form = useForm(); + const Field = typedField(form); + + return ( +
+
+ + + + + + + Speichern + +
+
+ ); +}; diff --git a/apps/docs/src/content/04-components/react-hook-form/field/overview.mdx b/apps/docs/src/content/04-components/react-hook-form/field/overview.mdx index 5c2f744381..dc7bbb84b7 100644 --- a/apps/docs/src/content/04-components/react-hook-form/field/overview.mdx +++ b/apps/docs/src/content/04-components/react-hook-form/field/overview.mdx @@ -1,4 +1,6 @@ -Die Field-Component nutzt dieselben Properties wie die [Controller-Component](https://www.react-hook-form.com/api/usecontroller/controller/) von React Hook Form – mit Ausnahme von `render`. +Die Field-Component nutzt dieselben Properties wie die +[Controller-Component](https://www.react-hook-form.com/api/usecontroller/controller/) +von React Hook Form – mit Ausnahme von `render`. # Playground @@ -8,6 +10,16 @@ Die Field-Component nutzt dieselben Properties wie die [Controller-Component](ht # typedField -Die `typedField(form)`-Factory erzeugt eine Field-Component, die an den Typ des jeweiligen Forms angepasst ist. Dadurch führen beispielsweise unbekannte Field-Namen zu einem TypeScript-Fehler. +Die `typedField(form)`-Factory erzeugt eine Field-Component, die an den Typ des +jeweiligen Forms angepasst ist. Dadurch führen beispielsweise unbekannte +Field-Namen zu einem TypeScript-Fehler. + +--- + +# custom field components + +asd + + diff --git a/apps/remote-dom-demo/src/app/remote/react-hook-form/page.tsx b/apps/remote-dom-demo/src/app/remote/react-hook-form/page.tsx index 03c0ba44cc..015a18d155 100644 --- a/apps/remote-dom-demo/src/app/remote/react-hook-form/page.tsx +++ b/apps/remote-dom-demo/src/app/remote/react-hook-form/page.tsx @@ -2,34 +2,37 @@ import { ActionGroup, + Autocomplete, Button, - CopyButton, + Checkbox, + CheckboxGroup, ComboBox, + CopyButton, FileField, Label, - Option, MarkdownEditor, + Option, + PasswordCreationField, Section, Select, TextArea, TextField, - PasswordCreationField, - Autocomplete, - CheckboxGroup, - Checkbox, } from "@mittwald/flow-remote-react-components"; import { - Form, Field, - SubmitButton, + type FieldPropsComponent, + Form, ResetButton, + SubmitButton, + useFieldProps, } from "@mittwald/flow-remote-react-components/react-hook-form"; import { - Policy, generatePasswordCreationFieldValidation, + Policy, RuleType, } from "@mittwald/flow-react-components/mittwald-password-tools-js"; import { useForm } from "react-hook-form"; +import type { FC } from "react"; const customPolicy = Policy.fromDeclaration({ minComplexity: 1, @@ -42,7 +45,37 @@ const customPolicy = Policy.fromDeclaration({ ], }); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const CustomFieldComponent: FC = (props) => { + const { + children, + value, + onBlur, + fieldComponents: { + FieldErrorView, + FieldComponentContainer, + FieldChildrenContainer, + }, + } = useFieldProps(props); + + return ( + + + console.log("change", v)} + > + Lesen + Schreiben + + {children} + + + + ); +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export default function Page() { const form = useForm({ @@ -59,6 +92,7 @@ export default function Page() { password: "", permissions: [], agreeTerms: false, + email: "asd@asd.de", }, }); @@ -149,12 +183,10 @@ export default function Page() { - - + + - Lesen - Schreiben - + diff --git a/packages/components/src/integrations/react-hook-form/components/Field/Field.tsx b/packages/components/src/integrations/react-hook-form/components/Field/Field.tsx index 0ca0732bbb..dec98b0230 100644 --- a/packages/components/src/integrations/react-hook-form/components/Field/Field.tsx +++ b/packages/components/src/integrations/react-hook-form/components/Field/Field.tsx @@ -1,9 +1,14 @@ import { useFormContext } from "@/integrations/react-hook-form/components/FormContextProvider/FormContextProvider"; -import { dynamic, type PropsContext } from "@/lib/propsContext"; -import { PropsContextProvider } from "@/lib/propsContext"; +import { + dynamic, + type PropsContext, + PropsContextProvider, +} from "@/lib/propsContext"; import { type PropsWithChildren } from "react"; import { type ControllerProps, + type ControllerRenderProps, + type FieldPath, type FieldValues, useController, type UseFormReturn, @@ -13,11 +18,29 @@ import { useLocalizedStringFormatter } from "react-aria"; import locales from "./locales/*.locale.json"; import FieldErrorView from "@/views/FieldErrorView"; import { useUpdateFormDefaultValue } from "@/integrations/react-hook-form/components/Field/hooks/useUpdateFormDefaultValue"; +import { FieldPropsContext } from "@/integrations/react-hook-form/components/Field/hooks/useFieldProps"; + +export interface FieldProps> + extends Omit, "render">, PropsWithChildren {} -export interface FieldProps - extends Omit, "render">, PropsWithChildren {} +export interface ForwardedFieldProps< + T extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> extends Omit, "name"> { + form?: string; + name: string; + isRequired?: boolean; + defaultValue?: ControllerRenderProps["value"]; + isReadOnly?: boolean; + isInvalid?: boolean; + validationBehavior: "aria"; + children?: ReturnType | undefined; +} -export function Field(props: FieldProps) { +export function Field< + T extends FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldProps) { const { children, name, defaultValue, ...rest } = props; const stringFormatter = useLocalizedStringFormatter(locales); @@ -89,7 +112,7 @@ export function Field(props: FieldProps) { ); }), - }; + } satisfies ForwardedFieldProps; const propsContext: PropsContext = { Autocomplete: { @@ -146,7 +169,7 @@ export function Field(props: FieldProps) { formContext.isReadOnly, ]} > - {children} + {children} ); } diff --git a/packages/components/src/integrations/react-hook-form/components/Field/hooks/useFieldProps.tsx b/packages/components/src/integrations/react-hook-form/components/Field/hooks/useFieldProps.tsx new file mode 100644 index 0000000000..54ba033790 --- /dev/null +++ b/packages/components/src/integrations/react-hook-form/components/Field/hooks/useFieldProps.tsx @@ -0,0 +1,79 @@ +import { + createContext, + type FC, + type PropsWithChildren, + useContext, + useMemo, +} from "react"; +import type { ForwardedFieldProps } from "@/integrations/react-hook-form/components/Field/Field"; +import { useFieldComponent } from "@/lib/hooks/useFieldComponent"; +import { useControlledHostValueProps } from "@/lib/remote/useControlledHostValueProps"; +import { mergeProps } from "@react-aria/utils"; +import resolveDynamicProps from "@/lib/propsContext/dynamicProps/resolveDynamicProps"; +import type { FieldPath, FieldValues } from "react-hook-form"; +import { PropsContextProvider } from "@/lib/propsContext"; +import DivView from "@/views/DivView"; + +export const FieldPropsContext = createContext< + ForwardedFieldProps +>({} as never); + +export interface FieldPropsComponent< + T extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> + extends + Omit>, "children">, + PropsWithChildren {} + +export const useFieldProps = ( + props: PropsWithChildren, + isTextValueComponent = false, +) => { + const contextProps = useContext(FieldPropsContext); + const controlledProps = useControlledHostValueProps(contextProps); + const finalPropsFromContext = isTextValueComponent + ? controlledProps + : contextProps; + + const mergedProps = mergeProps( + props, + finalPropsFromContext, + resolveDynamicProps(finalPropsFromContext, props), + ) as T & ForwardedFieldProps; + const { + FieldErrorView, + FieldErrorCaptureContext, + fieldPropsContext, + fieldProps, + } = useFieldComponent(mergedProps); + + const FieldComponentContainer: FC = useMemo( + () => + ({ children, ...rest }) => ( + + {children} + + ), + [fieldProps], + ); + + const FieldChildrenContainer: FC = useMemo( + () => + ({ children }) => ( + + {children} + + ), + [fieldPropsContext], + ); + + return { + ...mergedProps, + fieldComponents: { + FieldComponentContainer, + FieldChildrenContainer, + FieldErrorView, + }, + } as const; +}; diff --git a/packages/components/src/integrations/react-hook-form/components/Field/hooks/useUpdateFormDefaultValue.ts b/packages/components/src/integrations/react-hook-form/components/Field/hooks/useUpdateFormDefaultValue.ts index 665eb71c12..ffaf7b08f7 100644 --- a/packages/components/src/integrations/react-hook-form/components/Field/hooks/useUpdateFormDefaultValue.ts +++ b/packages/components/src/integrations/react-hook-form/components/Field/hooks/useUpdateFormDefaultValue.ts @@ -1,9 +1,12 @@ import type { FieldProps } from "@/integrations/react-hook-form/components/Field/Field"; import { useLayoutEffect } from "react"; -import type { FieldValues, UseFormReturn } from "react-hook-form"; +import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"; -export const useUpdateFormDefaultValue = ( - fieldProps: FieldProps, +export const useUpdateFormDefaultValue = < + T extends FieldValues, + TName extends FieldPath = FieldPath, +>( + fieldProps: FieldProps, form: UseFormReturn, ) => { const { defaultValue, name } = fieldProps; diff --git a/packages/components/src/integrations/react-hook-form/components/Field/index.ts b/packages/components/src/integrations/react-hook-form/components/Field/index.ts index 8404225253..157e1d9295 100644 --- a/packages/components/src/integrations/react-hook-form/components/Field/index.ts +++ b/packages/components/src/integrations/react-hook-form/components/Field/index.ts @@ -1,3 +1,4 @@ export { default } from "@/integrations/react-hook-form/components/Field/Field"; export { Field, type FieldProps, typedField } from "./Field"; +export { useFieldProps, type FieldPropsComponent } from "./hooks/useFieldProps"; diff --git a/packages/components/src/integrations/react-hook-form/components/Field/stories/CustomField.stories.tsx b/packages/components/src/integrations/react-hook-form/components/Field/stories/CustomField.stories.tsx new file mode 100644 index 0000000000..cd82f6870d --- /dev/null +++ b/packages/components/src/integrations/react-hook-form/components/Field/stories/CustomField.stories.tsx @@ -0,0 +1,171 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { type FC, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { action } from "storybook/actions"; +import { + Field, + Form, + ResetButton, + SubmitButton, + typedField, +} from "@/integrations/react-hook-form"; +import { Button } from "@/components/Button"; +import { Section } from "@/components/Section"; +import { ActionGroup } from "@/components/ActionGroup"; +import { sleep } from "@/lib/promises/sleep"; +import { FieldError } from "@/components/FieldError"; +import { + type FieldPropsComponent, + useFieldProps, +} from "@/integrations/react-hook-form/components/Field/hooks/useFieldProps"; +import { PropsContextProvider } from "@/lib/propsContext"; +import { Label } from "@/components/Label"; + +const submitAction = action("submit"); + +const CustomFieldComponent: FC = (props) => { + const { + ref, + children, + value, + defaultValue, + onChange, + onBlur, + form, + name, + disabled, + isReadOnly, + fieldComponentProps: { + FieldErrorView, + FieldErrorCaptureContext, + fieldPropsContext, + fieldProps, + }, + } = useFieldProps(props); + + return ( +
+ + + {children} + + + + +
+ ); +}; + +const meta: Meta = { + title: "Integrations/React Hook Form/CustomField", + component: Field, + render: () => { + interface Values { + name: string; + } + + const handleSubmit = async (values: Values) => { + await sleep(1500); + submitAction(values); + }; + + const form = useForm({ + defaultValues: { + name: "helloCustomField", + }, + }); + + const Field = typedField(form); + + return ( +
+
+ + + + + + + + Reset + Submit + +
+
+ ); + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithFieldError: Story = { + render: () => { + const form = useForm(); + useEffect(() => { + form.setError("field", { + type: "required", + message: "ErrorFromForm", + }); + }, []); + + return ( +
await sleep(2000)}> + + + + + + + + ErrorFromOuterFieldError! + +
+ ); + }, +}; + +export const WithFocus: Story = { + render: () => { + const form = useForm(); + return ( +
await sleep(2000)}> + + + + + +
+ + + Reset + Submit + + ); + }, +}; diff --git a/packages/components/src/lib/hooks/useFieldComponent.tsx b/packages/components/src/lib/hooks/useFieldComponent.tsx index 00e3f2434d..d11bd2c2b1 100644 --- a/packages/components/src/lib/hooks/useFieldComponent.tsx +++ b/packages/components/src/lib/hooks/useFieldComponent.tsx @@ -1,14 +1,14 @@ -import type { FC, PropsWithChildren } from "react"; +import { type FC, type PropsWithChildren, useMemo } from "react"; import { type PropsContext } from "@/lib/propsContext"; import formFieldStyles from "@/components/FormField/FormField.module.scss"; import { useFieldError } from "@/lib/hooks/useFieldError"; import clsx, { type ClassValue } from "clsx"; -interface FieldComponentProps { +type FieldComponentProps

= P & { className?: ClassValue; isRequired?: boolean; isDisabled?: boolean; -} +}; export interface UseFieldComponent { FieldErrorCaptureContext: FC; @@ -26,23 +26,36 @@ export const useFieldComponent = ( // setting up the props context for all components that // are part of a form control - const fieldPropsContext: PropsContext = { - Label: { - className: formFieldStyles.label, - optional: !props.isRequired, - isDisabled: !!props.isDisabled, - }, - FieldDescription: { - className: formFieldStyles.fieldDescription, - }, - }; + const fieldPropsContext: PropsContext = useMemo( + () => ({ + Label: { + className: formFieldStyles.label, + optional: !props.isRequired, + isDisabled: !!props.isDisabled, + }, + FieldDescription: { + className: formFieldStyles.fieldDescription, + }, + }), + [ + formFieldStyles.fieldDescription, + formFieldStyles.label, + props.isRequired, + props.isDisabled, + ], + ); + + const fieldProps = useMemo( + () => ({ + className: clsx(formFieldStyles.formField), + }), + [formFieldStyles.formField], + ); return { FieldErrorView, FieldErrorCaptureContext, fieldPropsContext, - fieldProps: { - className: clsx(formFieldStyles.formField), - }, + fieldProps, } as const; }; diff --git a/packages/components/src/lib/hooks/useFieldError.tsx b/packages/components/src/lib/hooks/useFieldError.tsx index 792136fd2e..41600a8b64 100644 --- a/packages/components/src/lib/hooks/useFieldError.tsx +++ b/packages/components/src/lib/hooks/useFieldError.tsx @@ -1,46 +1,48 @@ -import React, { type FC, type PropsWithChildren, useId, useMemo } from "react"; +import React, { + createContext, + type FC, + type PropsWithChildren, + useContext, + useId, +} from "react"; import { type PropsContext, PropsContextProvider } from "@/lib/propsContext"; import formFieldStyles from "@/components/FormField/FormField.module.scss"; import { TunnelExit } from "@mittwald/react-tunnel"; -import ClearPropsContext from "@/lib/propsContext/components/ClearPropsContext"; -import { useProps } from "@/lib/hooks/useProps"; -export const useFieldError = (tunnelIdFromProps?: string) => { - const id = useId(); - const currentTunnelId = useProps("FieldError", {}).tunnelId; - const tunnelId = tunnelIdFromProps ?? currentTunnelId ?? `${id}.fieldError`; +const FieldErrorContext = createContext(null); + +export const useFieldError = () => { + const id = useId() + ".fieldError"; + const tunnelIdFromContext = useContext(FieldErrorContext); + const currentTunnelId = tunnelIdFromContext ?? id; const fieldErrorCapturePropsContext: PropsContext = { FieldError: { - tunnelId, + tunnelId: currentTunnelId, className: formFieldStyles.fieldError, }, }; - const FieldErrorCaptureContext: FC = useMemo( - () => (props) => { - return ( - + const FieldErrorCaptureContext: FC = (props) => { + return ( + + {props.children} - ); - }, - [tunnelId], - ); + + ); + }; const FieldErrorView = () => { - if (currentTunnelId) { - return null; + if (tunnelIdFromContext) { + return; } return ( - + {(children) => { const childrenArray = React.Children.toArray(children); - return {childrenArray[0]}; + return childrenArray[0]; }} ); diff --git a/packages/remote-react-components/src/integrations/react-hook-form/index.ts b/packages/remote-react-components/src/integrations/react-hook-form/index.ts index 121d554b37..29521e576e 100644 --- a/packages/remote-react-components/src/integrations/react-hook-form/index.ts +++ b/packages/remote-react-components/src/integrations/react-hook-form/index.ts @@ -2,6 +2,8 @@ export * from "./Form/Form"; export { useFormContext, Field, + type FieldPropsComponent, + useFieldProps, type FieldProps, typedField, SubmitButton,