Skip to content

Commit

Permalink
feat(components): add form component and related atoms
Browse files Browse the repository at this point in the history
  • Loading branch information
Callenowy committed Jan 22, 2024
2 parents cb7580a + ce701c4 commit 0d4f800
Show file tree
Hide file tree
Showing 14 changed files with 296 additions and 3 deletions.
30 changes: 28 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"db:seed": "tsx ./src/db/seed.ts"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
Expand All @@ -37,7 +38,9 @@
"next": "14.0.4",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.2.0"
"react-hook-form": "^7.49.3",
"tailwind-merge": "^2.2.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@commitlint/cli": "^18.4.4",
Expand Down
30 changes: 30 additions & 0 deletions src/components/form/atoms/formControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { forwardRef } from 'react';
import { Slot } from '@radix-ui/react-slot';

import { useFormField } from '../hooks/useFormField.hook';

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

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

FormControl.displayName = 'FormControl';

export { FormControl };
25 changes: 25 additions & 0 deletions src/components/form/atoms/formDescription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { forwardRef } from 'react';

import { cn } from '@/utils/cn';

import { useFormField } from '../hooks/useFormField.hook';

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

return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
});

FormDescription.displayName = 'FormDescription';

export { FormDescription };
21 changes: 21 additions & 0 deletions src/components/form/atoms/formField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';

import { FormFieldContext } from '../context/formField.context';

export const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
26 changes: 26 additions & 0 deletions src/components/form/atoms/formItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { forwardRef, useId } from 'react';

import { cn } from '@/utils/cn';

import { FormItemContext } from '../context/formItem.context';

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

return (
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
className={cn('flex flex-col gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
});

FormItem.displayName = 'FormItem';

export { FormItem };
27 changes: 27 additions & 0 deletions src/components/form/atoms/formLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { forwardRef } from 'react';
import type * as LabelPrimitive from '@radix-ui/react-label';

import { Label } from '@/components/label';
import { cn } from '@/utils/cn';

import { useFormField } from '../hooks/useFormField.hook';

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

return (
<Label
ref={ref}
className={cn(className)}
htmlFor={formItemId}
{...props}
/>
);
});

FormLabel.displayName = 'FormLabel';

export { FormLabel };
42 changes: 42 additions & 0 deletions src/components/form/atoms/formMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { forwardRef } from 'react';

import { cn } from '@/utils/cn';
import { useFormField } from '../hooks/useFormField.hook';
import { Icon } from '@/components/icon';

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

if (!body) {
return null;
}

return (
<p
ref={ref}
id={formMessageId}
role="status"
className={cn(
'flex items-center gap-2 text-xs font-medium text-red-500',
className
)}
{...props}
>
<Icon
id={`icon-error`}
sprite="/svg-sprites/icons.svg"
aria-hidden="true"
className="h-auto w-4"
/>
{body}
</p>
);
});

FormMessage.displayName = 'FormMessage';

export { FormMessage };
6 changes: 6 additions & 0 deletions src/components/form/atoms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './formControl';
export * from './formDescription';
export * from './formField';
export * from './formItem';
export * from './formLabel';
export * from './formMessage';
13 changes: 13 additions & 0 deletions src/components/form/context/formField.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext } from 'react';
import type { FieldPath, FieldValues } from 'react-hook-form';

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

export const FormFieldContext = createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
9 changes: 9 additions & 0 deletions src/components/form/context/formItem.context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContext } from 'react';

type FormItemContextValue = {
id: string;
};

export const FormItemContext = createContext<FormItemContextValue>(
{} as FormItemContextValue
);
35 changes: 35 additions & 0 deletions src/components/form/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { memo, useCallback } from 'react';
import {
type FieldValues,
FormProvider,
type UseFormReturn,
} from 'react-hook-form';

type FormProps<T extends FieldValues> = {
onSubmit: (values: T) => void;
form: UseFormReturn<T>;
} & Omit<React.HTMLProps<HTMLFormElement>, 'onSubmit' | 'form'>;

export const Form = <T extends FieldValues>({
children,
onSubmit,
form,
...formConfig
}: React.PropsWithChildren<FormProps<T>>) => {
const handleSubmit = useCallback<React.FormEventHandler<HTMLFormElement>>(
(e: React.SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
void form.handleSubmit(data => onSubmit(data))(e);
},
[form, onSubmit]
);

return (
<FormProvider {...form}>
<form onSubmit={handleSubmit} noValidate {...formConfig}>
{children}
</form>
</FormProvider>
);
};

export default memo(Form) as typeof Form;
28 changes: 28 additions & 0 deletions src/components/form/hooks/useFormField.hook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useContext } from 'react';
import { useFormContext } from 'react-hook-form';

import { FormFieldContext } from '../context/formField.context';
import { FormItemContext } from '../context/formItem.context';

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

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

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

const { id } = itemContext;

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
2 changes: 2 additions & 0 deletions src/components/form/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './form';
export * from './atoms';

0 comments on commit 0d4f800

Please sign in to comment.