Skip to content

Commit

Permalink
feat: form related components and examples
Browse files Browse the repository at this point in the history
  • Loading branch information
BrickheadJohnny committed Nov 20, 2024
1 parent 9b526c6 commit c85e8c8
Show file tree
Hide file tree
Showing 10 changed files with 427 additions and 6 deletions.
58 changes: 58 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: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-focus-scope": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
Expand All @@ -32,6 +34,7 @@
"next-themes": "^0.4.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-hook-form": "^7.53.2",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
Expand Down
31 changes: 31 additions & 0 deletions src/app/components/ui/Collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "lib/cssUtils";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
} from "react";

const Collapsible = CollapsiblePrimitive.Root;

const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;

const CollapsibleContent = forwardRef<
ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
className={cn(
"overflow-hidden data-[state=closed]:animate-collapse-closed data-[state=open]:animate-collapse-open",
className,
)}
{...props}
>
{children}
</CollapsiblePrimitive.CollapsibleContent>
));

export { Collapsible, CollapsibleContent, CollapsibleTrigger };
2 changes: 1 addition & 1 deletion src/app/components/ui/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const DialogCloseButton = forwardRef<
<DialogPrimitive.Close
ref={ref}
className={cn(
"absolute top-8 right-8 rounded-full opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-ring focus-visible:ring-4 disabled:pointer-events-none data-[state=open]:text-foreground/50 md:right-10",
"absolute top-8 right-8 rounded-full opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-ring focus-visible:ring-4 disabled:pointer-events-none data-[state=open]:text-foreground-secondary md:right-10",
className,
)}
{...props}
Expand Down
192 changes: 192 additions & 0 deletions src/app/components/ui/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { useDebouncedState } from "foxact/use-debounced-state";
import { cn } from "lib/cssUtils";
import {
type ComponentPropsWithoutRef,
type ElementRef,
type HTMLAttributes,
createContext,
forwardRef,
useContext,
useId,
} from "react";
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { Collapsible, CollapsibleContent } from "./Collapsible";
import { Label } from "./Label";

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("flex flex-col", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";

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

return (
<Label
ref={ref}
className={cn(
"group mb-2 text-md aria-disabled:text-foreground-secondary",
className,
)}
htmlFor={formItemId}
{...props}
>
{children}
<span className="ml-1 hidden select-none font-bold text-input-border-invalid group-aria-required:inline-block">
*
</span>
</Label>
);
});
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("mt-2 text-foreground-secondary text-sm", 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;
const [debouncedBody] = useDebouncedState(body, 200);

return (
<Collapsible open={!!body}>
<CollapsibleContent>
<p
ref={ref}
id={formMessageId}
// TODO: not sure if it is a good idea to use "text-input-border-invalid" here? Should we add a completely new CSS variable instead?
className={cn(
"pt-2 font-medium text-input-border-invalid text-xs",
className,
)}
{...props}
>
{body ?? debouncedBody}
</p>
</CollapsibleContent>
</Collapsible>
);
});
FormErrorMessage.displayName = "FormErrorMessage";

export {
FormControl,
FormDescription,
FormErrorMessage,
FormField,
FormItem,
FormLabel,
useFormField,
};
2 changes: 1 addition & 1 deletion src/app/components/ui/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cn } from "lib/cssUtils";
import { type InputHTMLAttributes, forwardRef } from "react";

export const inputVariants = cva(
"flex w-full border border-input-border bg-input-background px-4 py-2 transition-[border-color,_box-shadow] file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-foreground/50 hover:border-input-border-accent focus:border-input-border-accent focus:ring-input-border-accent focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-input-border-invalid aria-[invalid=true]:ring-1 aria-[invalid=true]:ring-input-border-invalid aria-[invalid=true]:focus:border-input-border-accent aria-[invalid=true]:focus:ring-input-border-accent",
"flex w-full border border-input-border bg-input-background px-4 py-2 transition-[border-color,_box-shadow] file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-foreground-secondary hover:border-input-border-accent focus:border-input-border-accent focus:ring-input-border-accent focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-input-border-invalid aria-[invalid=true]:ring-1 aria-[invalid=true]:ring-input-border-invalid aria-[invalid=true]:focus:border-input-border-accent aria-[invalid=true]:focus:ring-input-border-accent",
{
variants: {
size: {
Expand Down
7 changes: 4 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ html {
/* Semantic colors */
--background: var(--gray-100);
--foreground: var(--gray-800);
--foreground-secondary: var(--gray-500);
--primary: var(--indigo-500);
--card: var(--white);
--image: var(--gray-700);
Expand Down Expand Up @@ -143,9 +144,9 @@ html {
--button-primary-subtle: var(--indigo-300);
--button-primary-subtle-foreground: var(--indigo-500);

--button-secondary: var(--blackAlpha);
--button-secondary-hover: var(--blackAlpha-medium);
--button-secondary-active: var(--blackAlpha-hard);
--button-secondary: var(--whiteAlpha);
--button-secondary-hover: var(--whiteAlpha-medium);
--button-secondary-active: var(--whiteAlpha-hard);
--button-secondary-foreground: var(--foreground);
--button-secondary-subtle: var(--gray-400);
--button-secondary-subtle-foreground: var(--foreground);
Expand Down
Loading

0 comments on commit c85e8c8

Please sign in to comment.