-
Notifications
You must be signed in to change notification settings - Fork 441
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
64ac97b
commit da54c40
Showing
9 changed files
with
353 additions
and
2 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"][], | ||
}, | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
Oops, something went wrong.