diff options
Diffstat (limited to 'src/components/ui')
| -rw-r--r-- | src/components/ui/accordion.tsx | 66 | ||||
| -rw-r--r-- | src/components/ui/alert.tsx | 66 | ||||
| -rw-r--r-- | src/components/ui/avatar.tsx | 53 | ||||
| -rw-r--r-- | src/components/ui/button.tsx | 59 | ||||
| -rw-r--r-- | src/components/ui/card.tsx | 92 | ||||
| -rw-r--r-- | src/components/ui/dropdown-menu.tsx | 257 | ||||
| -rw-r--r-- | src/components/ui/form.tsx | 168 | ||||
| -rw-r--r-- | src/components/ui/input.tsx | 21 | ||||
| -rw-r--r-- | src/components/ui/label.tsx | 24 | ||||
| -rw-r--r-- | src/components/ui/pagination.tsx | 126 | ||||
| -rw-r--r-- | src/components/ui/skeleton.tsx | 13 | ||||
| -rw-r--r-- | src/components/ui/sonner.tsx | 25 |
12 files changed, 970 insertions, 0 deletions
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..ab7f6a8 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client'; + +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDownIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Accordion({ + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Root>) { + return <AccordionPrimitive.Root data-slot='accordion' {...props} />; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Item>) { + return ( + <AccordionPrimitive.Item + data-slot='accordion-item' + className={cn('border-b last:border-b-0', className)} + {...props} + /> + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) { + return ( + <AccordionPrimitive.Header className='flex'> + <AccordionPrimitive.Trigger + data-slot='accordion-trigger' + className={cn( + 'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180', + className, + )} + {...props} + > + {children} + <ChevronDownIcon className='pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200' /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Content>) { + return ( + <AccordionPrimitive.Content + data-slot='accordion-content' + className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down' + {...props} + > + <div className={cn('pt-0 pb-4', className)}>{children}</div> + </AccordionPrimitive.Content> + ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..8a3c55a --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import { type VariantProps, cva } from 'class-variance-authority'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) { + return ( + <div + data-slot='alert' + role='alert' + className={cn(alertVariants({ variant }), className)} + {...props} + /> + ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='alert-title' + className={cn( + 'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot='alert-description' + className={cn( + 'col-start-2 grid justify-items-start gap-1 text-muted-foreground text-sm [&_p]:leading-relaxed', + className, + )} + {...props} + /> + ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..d1c9aef --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Avatar({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Root>) { + return ( + <AvatarPrimitive.Root + data-slot='avatar' + className={cn( + 'relative flex size-8 shrink-0 overflow-hidden rounded-full', + className, + )} + {...props} + /> + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Image>) { + return ( + <AvatarPrimitive.Image + data-slot='avatar-image' + className={cn('aspect-square size-full', className)} + {...props} + /> + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { + return ( + <AvatarPrimitive.Fallback + data-slot='avatar-fallback' + className={cn( + 'flex size-full items-center justify-center rounded-full bg-muted', + className, + )} + {...props} + /> + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..ec19e56 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import { Slot } from '@radix-ui/react-slot'; +import { type VariantProps, cva } from 'class-variance-authority'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + 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:bg-destructive/60 dark:focus-visible:ring-destructive/40', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-border dark:bg-input/30 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 gap-1.5 rounded-md 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 +}: React.ComponentProps<'button'> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + <Comp + data-slot='button' + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ); +} + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..c839cf9 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card' + className={cn( + 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm', + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-header' + className={cn( + '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', + className, + )} + {...props} + /> + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-title' + className={cn('font-semibold leading-none', className)} + {...props} + /> + ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-description' + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-action' + className={cn( + 'col-start-2 row-span-2 row-start-1 self-start justify-self-end', + className, + )} + {...props} + /> + ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-content' + className={cn('px-6', className)} + {...props} + /> + ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-footer' + className={cn('flex items-center px-6 [.border-t]:pt-6', className)} + {...props} + /> + ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..eb4601e --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +'use client'; + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function DropdownMenu({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { + return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { + return ( + <DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} /> + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { + return ( + <DropdownMenuPrimitive.Trigger + data-slot='dropdown-menu-trigger' + {...props} + /> + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { + return ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + data-slot='dropdown-menu-content' + sideOffset={sideOffset} + className={cn( + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in', + className, + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { + return ( + <DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} /> + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + <DropdownMenuPrimitive.Item + data-slot='dropdown-menu-item' + data-inset={inset} + data-variant={variant} + className={cn( + "data-[variant=destructive]:*:[svg]:!text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20'size-'])]:size-4 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden [&_svg:not([class*= focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + /> + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { + return ( + <DropdownMenuPrimitive.CheckboxItem + data-slot='dropdown-menu-checkbox-item' + className={cn( + "data-[disabled]:opacity-50'size-'])]:size-4 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden [&_svg:not([class*= focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + checked={checked} + {...props} + > + <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'> + <DropdownMenuPrimitive.ItemIndicator> + <CheckIcon className='size-4' /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { + return ( + <DropdownMenuPrimitive.RadioGroup + data-slot='dropdown-menu-radio-group' + {...props} + /> + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { + return ( + <DropdownMenuPrimitive.RadioItem + data-slot='dropdown-menu-radio-item' + className={cn( + "data-[disabled]:opacity-50'size-'])]:size-4 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden [&_svg:not([class*= focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + > + <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'> + <DropdownMenuPrimitive.ItemIndicator> + <CircleIcon className='size-2 fill-current' /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; +}) { + return ( + <DropdownMenuPrimitive.Label + data-slot='dropdown-menu-label' + data-inset={inset} + className={cn( + 'px-2 py-1.5 font-medium text-sm data-[inset]:pl-8', + className, + )} + {...props} + /> + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { + return ( + <DropdownMenuPrimitive.Separator + data-slot='dropdown-menu-separator' + className={cn('-mx-1 my-1 h-px bg-border', className)} + {...props} + /> + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + data-slot='dropdown-menu-shortcut' + className={cn( + 'ml-auto text-muted-foreground text-xs tracking-widest', + className, + )} + {...props} + /> + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { + return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; +}) { + return ( + <DropdownMenuPrimitive.SubTrigger + data-slot='dropdown-menu-sub-trigger' + data-inset={inset} + className={cn( + 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground', + className, + )} + {...props} + > + {children} + <ChevronRightIcon className='ml-auto size-4' /> + </DropdownMenuPrimitive.SubTrigger> + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { + return ( + <DropdownMenuPrimitive.SubContent + data-slot='dropdown-menu-sub-content' + className={cn( + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in', + className, + )} + {...props} + /> + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..5e54b0b --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,168 @@ +'use client'; + +import type * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, + useFormState, +} from 'react-hook-form'; + +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; + +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 } = 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, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue, +); + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + 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 +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + 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 }: React.ComponentProps<typeof Slot>) { + 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 }: React.ComponentProps<'p'>) { + const { formDescriptionId } = useFormField(); + + return ( + <p + data-slot='form-description' + id={formDescriptionId} + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} + +function FormMessage({ className, ...props }: React.ComponentProps<'p'>) { + 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/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..27bc18a --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + <input + type={type} + data-slot='input' + className={cn( + 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', + 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', + 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', + className, + )} + {...props} + /> + ); +} + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..0553fbc --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as LabelPrimitive from '@radix-ui/react-label'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Label({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot='label' + className={cn( + 'flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50', + className, + )} + {...props} + /> + ); +} + +export { Label }; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..e1bee31 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,126 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from 'lucide-react'; +import type * as React from 'react'; + +import { type Button, buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { + return ( + <nav + aria-label='pagination' + data-slot='pagination' + className={cn('mx-auto flex w-full justify-center', className)} + {...props} + /> + ); +} + +function PaginationContent({ + className, + ...props +}: React.ComponentProps<'ul'>) { + return ( + <ul + data-slot='pagination-content' + className={cn('flex flex-row items-center gap-1', className)} + {...props} + /> + ); +} + +function PaginationItem({ ...props }: React.ComponentProps<'li'>) { + return <li data-slot='pagination-item' {...props} />; +} + +type PaginationLinkProps = { + isActive?: boolean; +} & Pick<React.ComponentProps<typeof Button>, 'size'> & + React.ComponentProps<'a'>; + +function PaginationLink({ + className, + isActive, + size = 'icon', + ...props +}: PaginationLinkProps) { + return ( + <a + aria-current={isActive ? 'page' : undefined} + data-slot='pagination-link' + data-active={isActive} + className={cn( + buttonVariants({ + variant: isActive ? 'outline' : 'ghost', + size, + }), + className, + )} + {...props} + /> + ); +} + +function PaginationPrevious({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) { + return ( + <PaginationLink + aria-label='Go to previous page' + size='default' + className={cn('gap-1 px-2.5 sm:pl-2.5', className)} + {...props} + > + <ChevronLeftIcon /> + <span className='hidden sm:block'>Previous</span> + </PaginationLink> + ); +} + +function PaginationNext({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) { + return ( + <PaginationLink + aria-label='Go to next page' + size='default' + className={cn('gap-1 px-2.5 sm:pr-2.5', className)} + {...props} + > + <span className='hidden sm:block'>Next</span> + <ChevronRightIcon /> + </PaginationLink> + ); +} + +function PaginationEllipsis({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + aria-hidden + data-slot='pagination-ellipsis' + className={cn('flex size-9 items-center justify-center', className)} + {...props} + > + <MoreHorizontalIcon className='size-4' /> + <span className='sr-only'>More pages</span> + </span> + ); +} + +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +}; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..24d6b2f --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='skeleton' + className={cn('animate-pulse rounded-md bg-accent', className)} + {...props} + /> + ); +} + +export { Skeleton }; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..cde1bcb --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, type ToasterProps } from 'sonner'; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps['theme']} + className='toaster group' + style={ + { + '--normal-bg': 'var(--color-fd-popover)', + '--normal-text': 'var(--color-fd-popover-foreground)', + '--normal-border': 'var(--color-fd-border)', + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; |
