Skip to content

Commit

Permalink
Added Form
Browse files Browse the repository at this point in the history
  • Loading branch information
dreadhalor committed Jan 31, 2024
1 parent e909696 commit 88f935c
Show file tree
Hide file tree
Showing 6 changed files with 307 additions and 3 deletions.
38 changes: 37 additions & 1 deletion 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 @@ -89,9 +89,11 @@
"vite": "^4.3.9",
"vite-plugin-dts": "^2.3.0",
"vite-plugin-svgr": "^3.2.0",
"vite-tsconfig-paths": "^4.2.0"
"vite-tsconfig-paths": "^4.2.0",
"zod": "^3.22.4"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-avatar": "^1.0.3",
Expand All @@ -118,6 +120,7 @@
"lucide-react": "^0.245.0",
"react-data-table-component": "^7.5.4",
"react-day-picker": "^8.8.0",
"react-hook-form": "^7.49.3",
"react-icons": "^4.10.1",
"react-resizable-panels": "^1.0.9",
"react-resize-detector": "^10.0.1",
Expand Down
87 changes: 87 additions & 0 deletions src/components/form/form.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './form';
import { Button, Input, Toaster, toast } from '@src/index';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';

const meta: Meta<typeof Form> = {
component: Form,
title: 'Components/Form',
decorators: [
(Story) => (
<>
<Story />
<Toaster />
</>
),
],
};

export default meta;
type Story = StoryObj<typeof meta>;

const FormSchema = z.object({
username: z.string().min(2, {
message: 'Username must be at least 2 characters.',
}),
});

const FormDemoComponent = (_) => {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
username: '',
},
});

function onSubmit(data: z.infer<typeof FormSchema>) {
toast({
title: 'You submitted the following values:',
description: (
<pre className='~mt-2 ~w-[340px] ~rounded-md ~bg-slate-950 ~p-4'>
<code className='~text-white'>{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='~w-2/3 ~space-y-6'
>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder='shadcn' {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Submit</Button>
</form>
</Form>
);
};

export const Demo: Story = {
render: FormDemoComponent,
};
177 changes: 177 additions & 0 deletions src/components/form/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';

import { cn } from '@src/lib/utils';
import { Label } from '@src/index';

const Form = FormProvider;

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

const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);

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>
);
};

const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.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,
};
};

type FormItemContextValue = {
id: string;
};

const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);

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

return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('tw-reset', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';

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

return (
<Label
ref={ref}
className={cn(error && '~text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';

const FormControl = React.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';

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

return (
<p
ref={ref}
id={formDescriptionId}
className={cn('~text-[0.8em] ~text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';

const FormMessage = React.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}
className={cn('~text-[0.8em] ~font-medium ~text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';

export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
2 changes: 1 addition & 1 deletion src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
'tw-reset ~flex ~h-9 ~w-full ~rounded-md ~border ~border-border ~bg-background ~px-3 ~py-1 ~shadow-sm ~transition-colors',
'placeholder:~text-input',
'focus-visible:~outline-none focus-visible:~ring-1 focus-visible:~ring-ring',
'disabled:~cursor-not-allowed disabled:~bg-muted',
'disabled:~cursor-not-allowed disabled:~bg-muted disabled:~text-muted-foreground',
className
)}
ref={ref}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from '@components/command/command';
export * from '@components/date-picker/date-picker';
export * from '@components/dialog/dialog';
export * from '@components/dropdown-menu/dropdown-menu';
export * from '@components/form/form';
export * from '@components/input/input';
export * from '@components/label/label';
export * from '@components/popover/popover';
Expand Down

0 comments on commit 88f935c

Please sign in to comment.