Skip to content

Commit

Permalink
feat: Form component (#1358)
Browse files Browse the repository at this point in the history
* feat: add `Input` component

* feat: add form & related sub-components

* feat(Input): add size variants

* feat: add `Input` stories

* fix: revert `FormDescription` rename
  • Loading branch information
BrickheadJohnny authored Jul 5, 2024
1 parent 64ac97b commit da54c40
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 2 deletions.
23 changes: 23 additions & 0 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@
"@lexical/selection": "^0.12.0",
"@lexical/utils": "^0.12.0",
"@nouns/assets": "^0.4.2",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-focus-scope": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@phosphor-icons/react": "^2.1.7",
"@snyk/protect": "latest",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.26.3",
Expand Down
58 changes: 58 additions & 0 deletions src/app/playground/_components/FormExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client"
import { Button } from "@/components/ui/Button"
import {
Form,
FormControl,
FormDescription,
FormErrorMessage,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/Form"
import { Input } from "@/components/ui/Input"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

const formSchema = z.object({
username: z.string().min(2).max(50),
})

export function FormExample() {
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
})

// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values)
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<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>
<FormErrorMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
5 changes: 5 additions & 0 deletions src/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Metadata } from "next"
import { PropsWithChildren } from "react"
import { ThemeToggle } from "../../v2/components/ThemeToggle"
import { DialogExample } from "./_components/DialogExample"
import { FormExample } from "./_components/FormExample"

export const metadata: Metadata = {
title: "Playground",
Expand Down Expand Up @@ -33,6 +34,10 @@ export default function Page() {
<Section title="Modal">
<DialogExample />
</Section>

<Section title="Form">
<FormExample />
</Section>
</div>
</div>
)
Expand Down
2 changes: 1 addition & 1 deletion src/v2/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={buttonVariants({ variant, size, className })}
ref={ref}
{...props}
disabled={isLoading || disabled}
Expand Down
176 changes: 176 additions & 0 deletions src/v2/components/ui/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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 { Label } from "@/components/ui/Label"
import { cn } from "@/lib/utils"
import {
ComponentPropsWithoutRef,
createContext,
ElementRef,
forwardRef,
HTMLAttributes,
useContext,
useId,
} from "react"

const Form = FormProvider

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

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

const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)

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,
}
}

type FormItemContextValue = {
id: string
}

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

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

return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
}
)
FormItem.displayName = "FormItem"

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

return <Label ref={ref} htmlFor={formItemId} {...props} />
})
FormLabel.displayName = "FormLabel"

const FormControl = forwardRef<
ElementRef<typeof Slot>,
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 = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()

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

const FormErrorMessage = forwardRef<
HTMLParagraphElement,
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}
// TODO: not sure if it is a good idea to use "destructive-ghost-foreground" here? Should we add a completely new CSS variable instead?
className={cn(
"text-[0.8rem] font-medium text-destructive-ghost-foreground",
className
)}
{...props}
>
{body}
</p>
)
})
FormErrorMessage.displayName = "FormErrorMessage"

export {
Form,
FormControl,
FormDescription,
FormErrorMessage,
FormField,
FormItem,
FormLabel,
useFormField,
}
25 changes: 25 additions & 0 deletions src/v2/components/ui/Input.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Input, InputProps } from "./Input"

const meta: Meta<typeof Input> = {
title: "Design system/Input",
component: Input,
}

export default meta

type Story = StoryObj<typeof Input>

export const Default: Story = {
args: {
size: "md",
placeholder: "Default input",
},
argTypes: {
size: {
type: "string",
control: "radio",
options: ["xs", "sm", "md", "lg"] satisfies InputProps["size"][],
},
},
}
37 changes: 37 additions & 0 deletions src/v2/components/ui/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { cva, VariantProps } from "class-variance-authority"
import { forwardRef, InputHTMLAttributes } from "react"

const inputVariants = cva(
"flex w-full border border-input bg-muted px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
size: {
xs: "h-6 rounded-md",
sm: "h-8 rounded-lg",
md: "h-11 rounded-lg",
lg: "h-12 rounded-xl",
},
},
defaultVariants: {
size: "md",
},
}
)

export interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size">,
VariantProps<typeof inputVariants> {}

const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, size, ...props }, ref) => (
<input
type={type}
className={inputVariants({ size, className })}
ref={ref}
{...props}
/>
)
)
Input.displayName = "Input"

export { Input }
Loading

0 comments on commit da54c40

Please sign in to comment.