forge42dev / remix-hook-form

Open source wrapper for react-hook-form aimed at Remix.run
MIT License
332 stars 27 forks source link

Missing Controller, type ControllerProps, type FieldPath, type FieldValues, #38

Closed lucgagan closed 10 months ago

lucgagan commented 10 months ago

I am trying to migrate from react-hook-form as it was used with https://shadow-panda.dev/, and I am missing a few elements to piece it together. This is what I have so far.

import { Label } from '@/components/ui/label';
import { css, cx } from '@/styled-system/css';
import { styled } from '@/styled-system/jsx';
import {
  formControl,
  formDescription,
  formItem,
  formLabel,
  formMessage,
} from '@/styled-system/recipes';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { createContext, forwardRef, useContext, useId, useMemo } from 'react';
import {
  Controller,
  type ControllerProps,
  type FieldPath,
  type FieldValues,
  useRemixFormContext,
} from 'remix-hook-form';

type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
  name: TName;
};

type FormItemContextValue = {
  id: string;
};

const FormFieldContext = createContext<FormFieldContextValue>(
  {} as FormFieldContextValue,
);
const FormItemContext = createContext<FormItemContextValue>(
  {} as FormItemContextValue,
);

export const useFormField = () => {
  const fieldContext = useContext(FormFieldContext);
  const itemContext = useContext(FormItemContext);
  const { formState, getFieldState } = useRemixFormContext();

  const fieldState = getFieldState(fieldContext.name, formState);

  if (!fieldContext) {
    throw new Error('useFormField should be used within <FormField>');
  }

  const { id } = itemContext;

  return {
    formDescriptionId: `${id}-form-item-description`,
    formItemId: `${id}-form-item`,
    formMessageId: `${id}-form-item-message`,
    id,
    name: fieldContext.name,
    ...fieldState,
  };
};

const BaseFormField = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
  ...props
}: ControllerProps<TFieldValues, TName>) => {
  const context = useMemo(() => {
    return {
      name: props.name,
    };
  }, [props.name]);

  return (
    <FormFieldContext.Provider value={context}>
      <Controller {...props} />
    </FormFieldContext.Provider>
  );
};

const BaseFormItem = forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>((props, ref) => {
  const id = useId();

  const context = useMemo(() => {
    return {
      id,
    };
  }, [id]);

  return (
    <FormItemContext.Provider value={context}>
      <div
        ref={ref}
        {...props}
      />
    </FormItemContext.Provider>
  );
});
BaseFormItem.displayName = 'FormItem';

const BaseFormLabel = forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof Label>
>(({ className, ...props }, ref) => {
  const { error, formItemId } = useFormField();

  return (
    <Label
      className={cx(error && css({ color: 'destructive' }), className)}
      htmlFor={formItemId}
      ref={ref}
      {...props}
    />
  );
});
BaseFormLabel.displayName = 'FormLabel';

const BaseFormControl = forwardRef<
  React.ElementRef<typeof Slot>,
  React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
  const { error, formDescriptionId, formItemId, formMessageId } =
    useFormField();

  return (
    <Slot
      aria-describedby={
        error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`
      }
      aria-invalid={Boolean(error)}
      id={formItemId}
      ref={ref}
      {...props}
    />
  );
});
BaseFormControl.displayName = 'FormControl';

const BaseFormDescription = forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>((props, ref) => {
  const { formDescriptionId } = useFormField();

  return (
    <p
      id={formDescriptionId}
      ref={ref}
      {...props}
    />
  );
});
BaseFormDescription.displayName = 'FormDescription';

const BaseFormMessage = forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ children, ...props }, ref) => {
  const { error, formMessageId } = useFormField();
  const body = error ? String(error?.message) : children;

  if (!body) {
    return null;
  }

  return (
    <p
      id={formMessageId}
      ref={ref}
      {...props}
    >
      {body}
    </p>
  );
});
BaseFormMessage.displayName = 'FormMessage';

export const FormField = BaseFormField;
export const FormLabel = styled(BaseFormLabel, formLabel);
export const FormItem = styled(BaseFormItem, formItem);
export const FormControl = styled(BaseFormControl, formControl);
export const FormDescription = styled(BaseFormDescription, formDescription);
export const FormMessage = styled(BaseFormMessage, formMessage);

export { Form } from '@remix-run/react';
lucgagan commented 10 months ago

I've hacked something together that seems to work.

import { Label } from '@/components/ui/label';
import { css, cx } from '@/styled-system/css';
import { styled } from '@/styled-system/jsx';
import {
  formControl,
  formDescription,
  formItem,
  formLabel,
  formMessage,
} from '@/styled-system/recipes';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { createContext, forwardRef, useContext, useId, useMemo } from 'react';
import {
  type FieldPath,
  type FieldValues,
  type UseFormRegisterReturn,
} from 'react-hook-form';
import { useRemixFormContext } from 'remix-hook-form';

type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
  name: TName;
};

type FormItemContextValue = {
  id: string;
};

const FormFieldContext = createContext<FormFieldContextValue>(
  {} as FormFieldContextValue,
);
const FormItemContext = createContext<FormItemContextValue>(
  {} as FormItemContextValue,
);

export const useFormField = () => {
  const fieldContext = useContext(FormFieldContext);
  const itemContext = useContext(FormItemContext);
  const { formState, getFieldState } = useRemixFormContext();

  const fieldState = getFieldState(fieldContext.name, formState);

  if (!fieldContext) {
    throw new Error('useFormField should be used within <FormField>');
  }

  const { id } = itemContext;

  return {
    formDescriptionId: `${id}-form-item-description`,
    formItemId: `${id}-form-item`,
    formMessageId: `${id}-form-item-message`,
    id,
    name: fieldContext.name,
    ...fieldState,
  };
};

type ControllerProps = {
  readonly name: string;
  readonly render: (props: {
    readonly field: UseFormRegisterReturn;
  }) => JSX.Element;
};

const Controller = (props: ControllerProps) => {
  const { name, render } = props;
  const { register } = useRemixFormContext();

  const field = register(name);

  return render({
    field,
  });
};

const BaseFormField = (props: ControllerProps) => {
  const context = useMemo(() => {
    return {
      name: props.name,
    };
  }, [props.name]);

  return (
    <FormFieldContext.Provider value={context}>
      <Controller {...props} />
    </FormFieldContext.Provider>
  );
};

const BaseFormItem = forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>((props, ref) => {
  const id = useId();

  const context = useMemo(() => {
    return {
      id,
    };
  }, [id]);

  return (
    <FormItemContext.Provider value={context}>
      <div
        ref={ref}
        {...props}
      />
    </FormItemContext.Provider>
  );
});
BaseFormItem.displayName = 'FormItem';

const BaseFormLabel = forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof Label>
>(({ className, ...props }, ref) => {
  const { error, formItemId } = useFormField();

  return (
    <Label
      className={cx(error && css({ color: 'destructive' }), className)}
      htmlFor={formItemId}
      ref={ref}
      {...props}
    />
  );
});
BaseFormLabel.displayName = 'FormLabel';

const BaseFormControl = forwardRef<
  React.ElementRef<typeof Slot>,
  React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
  const { error, formDescriptionId, formItemId, formMessageId } =
    useFormField();

  return (
    <Slot
      aria-describedby={
        error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`
      }
      aria-invalid={Boolean(error)}
      id={formItemId}
      ref={ref}
      {...props}
    />
  );
});
BaseFormControl.displayName = 'FormControl';

const BaseFormDescription = forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>((props, ref) => {
  const { formDescriptionId } = useFormField();

  return (
    <p
      id={formDescriptionId}
      ref={ref}
      {...props}
    />
  );
});
BaseFormDescription.displayName = 'FormDescription';

const BaseFormMessage = forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ children, ...props }, ref) => {
  const { error, formMessageId } = useFormField();
  const body = error ? String(error?.message) : children;

  if (!body) {
    return null;
  }

  return (
    <p
      id={formMessageId}
      ref={ref}
      {...props}
    >
      {body}
    </p>
  );
});
BaseFormMessage.displayName = 'FormMessage';

export const FormField = BaseFormField;
export const FormLabel = styled(BaseFormLabel, formLabel);
export const FormItem = styled(BaseFormItem, formItem);
export const FormControl = styled(BaseFormControl, formControl);
export const FormDescription = styled(BaseFormDescription, formDescription);
export const FormMessage = styled(BaseFormMessage, formMessage);

export { Form } from '@remix-run/react';
AlemTuzlak commented 10 months ago

@lucgagan so this is not really an issue with the library because of the philosophy I take with the library itself, the library is a wrapper around react-hook-form for usage in Remix, it is way easier to use than react-hook-form but it's not feature rich as the latter, the idea is that you install both and whatever you want to use from react-hook-form from types to whatever else you can, this allows me to not have to maintain types exported from their library as much + it allows you the developer to pick the specific version of react-hook-form you want and use those types without my library complaning that the types are wrong, hence why it has a peer dependency with react-hook-form and this is 100% the way to do it, you use the types and everything you need from there + you use the custom stuff remix-hook-form provides so this is the approach

lucgagan commented 10 months ago

Excellent. Thank you for the explanation.