diff options
Diffstat (limited to 'frontend/src/components/ui')
-rw-r--r-- | frontend/src/components/ui/button.jsx | 55 | ||||
-rw-r--r-- | frontend/src/components/ui/form.jsx | 144 | ||||
-rw-r--r-- | frontend/src/components/ui/input.jsx | 24 | ||||
-rw-r--r-- | frontend/src/components/ui/label.jsx | 23 | ||||
-rw-r--r-- | frontend/src/components/ui/sonner.jsx | 26 |
5 files changed, 272 insertions, 0 deletions
diff --git a/frontend/src/components/ui/button.jsx b/frontend/src/components/ui/button.jsx new file mode 100644 index 0000000..69ad71f --- /dev/null +++ b/frontend/src/components/ui/button.jsx @@ -0,0 +1,55 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}) { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + data-slot="button" + className={cn(buttonVariants({ variant, size, className }))} + {...props} /> + ); +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/form.jsx b/frontend/src/components/ui/form.jsx new file mode 100644 index 0000000..728ea87 --- /dev/null +++ b/frontend/src/components/ui/form.jsx @@ -0,0 +1,144 @@ +"use client"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { Controller, FormProvider, useFormContext, useFormState } from "react-hook-form"; + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +const FormFieldContext = React.createContext({}) + +const FormField = ( + { + ...props + } +) => { + 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 } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + 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, + } +} + +const FormItemContext = React.createContext({}) + +function FormItem({ + className, + ...props +}) { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div data-slot="form-item" className={cn("grid gap-2", className)} {...props} /> + </FormItemContext.Provider> + ); +} + +function FormLabel({ + className, + ...props +}) { + const { error, formItemId } = useFormField() + + return ( + <Label + data-slot="form-label" + data-error={!!error} + className={cn("data-[error=true]:text-destructive", className)} + htmlFor={formItemId} + {...props} /> + ); +} + +function FormControl({ + ...props +}) { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + data-slot="form-control" + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} /> + ); +} + +function FormDescription({ + className, + ...props +}) { + const { formDescriptionId } = useFormField() + + return ( + <p + data-slot="form-description" + id={formDescriptionId} + className={cn("text-muted-foreground text-sm", className)} + {...props} /> + ); +} + +function FormMessage({ + className, + ...props +}) { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message ?? "") : props.children + + if (!body) { + return null + } + + return ( + <p + data-slot="form-message" + id={formMessageId} + className={cn("text-destructive text-sm", className)} + {...props}> + {body} + </p> + ); +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/frontend/src/components/ui/input.jsx b/frontend/src/components/ui/input.jsx new file mode 100644 index 0000000..1e9bbd1 --- /dev/null +++ b/frontend/src/components/ui/input.jsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ + className, + type, + ...props +}) { + return ( + <input + type={type} + data-slot="input" + className={cn( + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + className + )} + {...props} /> + ); +} + +export { Input } diff --git a/frontend/src/components/ui/label.jsx b/frontend/src/components/ui/label.jsx new file mode 100644 index 0000000..1002a4f --- /dev/null +++ b/frontend/src/components/ui/label.jsx @@ -0,0 +1,23 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className + )} + {...props} /> + ); +} + +export { Label } diff --git a/frontend/src/components/ui/sonner.jsx b/frontend/src/components/ui/sonner.jsx new file mode 100644 index 0000000..8079d58 --- /dev/null +++ b/frontend/src/components/ui/sonner.jsx @@ -0,0 +1,26 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner"; + +const Toaster = ({ + ...props +}) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme} + className="toaster group" + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)" + } + } + {...props} /> + ); +} + +export { Toaster } |