初始化模版工程
This commit is contained in:
53
components/ui/avatar.tsx
Normal file
53
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||
'ring-2 ring-transparent',
|
||||
'transition-all duration-200',
|
||||
'hover:ring-primary/20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full',
|
||||
'bg-muted text-muted-foreground',
|
||||
'dark:bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
59
components/ui/badge.tsx
Normal file
59
components/ui/badge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'border-transparent bg-primary text-primary-foreground',
|
||||
'hover:bg-primary/80',
|
||||
],
|
||||
secondary: [
|
||||
'border-transparent bg-secondary text-secondary-foreground',
|
||||
'hover:bg-secondary/80',
|
||||
],
|
||||
destructive: [
|
||||
'border-transparent bg-destructive text-white',
|
||||
'hover:bg-destructive/80',
|
||||
],
|
||||
outline: [
|
||||
'text-foreground',
|
||||
'dark:border-white/[0.15]',
|
||||
],
|
||||
// Modern tech style variants
|
||||
ghost: [
|
||||
'border-transparent bg-muted text-muted-foreground',
|
||||
'hover:bg-muted/80',
|
||||
],
|
||||
glow: [
|
||||
'border-transparent bg-primary/10 text-primary',
|
||||
'dark:bg-primary/20 dark:text-primary-foreground',
|
||||
'ring-1 ring-primary/20 dark:ring-primary/30',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
70
components/ui/button.test.tsx
Normal file
70
components/ui/button.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Button } from './button'
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders with default variant', () => {
|
||||
render(<Button>Click me</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Click me' })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as disabled', () => {
|
||||
render(<Button disabled>Disabled</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Disabled' })
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('handles click events', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
render(<Button onClick={onClick}>Click</Button>)
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fire click when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
render(<Button disabled onClick={onClick}>Click</Button>)
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders with destructive variant', () => {
|
||||
render(<Button variant="destructive">Delete</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Delete' })
|
||||
expect(button.className).toContain('bg-destructive')
|
||||
})
|
||||
|
||||
it('renders with outline variant', () => {
|
||||
render(<Button variant="outline">Outline</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Outline' })
|
||||
expect(button.className).toContain('border')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<Button size="sm">Small</Button>)
|
||||
expect(screen.getByRole('button').className).toContain('h-9')
|
||||
|
||||
rerender(<Button size="lg">Large</Button>)
|
||||
expect(screen.getByRole('button').className).toContain('h-11')
|
||||
})
|
||||
|
||||
it('renders as child with asChild prop', () => {
|
||||
render(
|
||||
<Button asChild>
|
||||
<a href="/test">Link</a>
|
||||
</Button>
|
||||
)
|
||||
const link = screen.getByRole('link', { name: 'Link' })
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link.tagName).toBe('A')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Button className="custom-class">Custom</Button>)
|
||||
expect(screen.getByRole('button').className).toContain('custom-class')
|
||||
})
|
||||
})
|
||||
92
components/ui/button.tsx
Normal file
92
components/ui/button.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium',
|
||||
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
// Modern animations
|
||||
'transition-all duration-200 ease-out',
|
||||
'active:scale-[0.98]',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'bg-primary text-primary-foreground',
|
||||
'hover:bg-primary/90 hover:shadow-md',
|
||||
'dark:hover:shadow-lg dark:hover:shadow-primary/5',
|
||||
],
|
||||
destructive: [
|
||||
'bg-destructive text-white',
|
||||
'hover:bg-destructive/90 hover:shadow-md',
|
||||
],
|
||||
outline: [
|
||||
'border border-input bg-background',
|
||||
'hover:bg-accent hover:text-accent-foreground hover:border-accent-foreground/20',
|
||||
'dark:hover:bg-accent/50',
|
||||
],
|
||||
secondary: [
|
||||
'bg-secondary text-secondary-foreground',
|
||||
'hover:bg-secondary/80 hover:shadow-sm',
|
||||
],
|
||||
ghost: [
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'dark:hover:bg-accent/50',
|
||||
],
|
||||
link: [
|
||||
'text-primary underline-offset-4',
|
||||
'hover:underline',
|
||||
],
|
||||
// Modern tech style variants
|
||||
glow: [
|
||||
'bg-primary text-primary-foreground',
|
||||
'hover:shadow-lg hover:shadow-primary/25',
|
||||
'dark:hover:shadow-primary/10',
|
||||
],
|
||||
gradient: [
|
||||
'bg-gradient-to-r from-primary to-primary/80 text-primary-foreground',
|
||||
'hover:from-primary/90 hover:to-primary/70 hover:shadow-md',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
xl: 'h-12 rounded-lg px-10 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
96
components/ui/card.tsx
Normal file
96
components/ui/card.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
}
|
||||
>(({ className, hover = false, glow = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground',
|
||||
// Subtle border glow in dark mode
|
||||
'dark:border-white/[0.08]',
|
||||
// Transition for hover effects
|
||||
'transition-all duration-200 ease-out',
|
||||
// Optional hover lift effect
|
||||
hover && [
|
||||
'hover:-translate-y-0.5',
|
||||
'hover:border-border/80',
|
||||
'dark:hover:border-white/[0.12]',
|
||||
],
|
||||
// Optional glow effect
|
||||
glow && [
|
||||
'ring-1 ring-primary/20 dark:ring-primary/30',
|
||||
],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
123
components/ui/dialog.tsx
Normal file
123
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Button } from './button'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 origin-center data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className='absolute right-4 top-2 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
|
||||
<Button variant='outline' size='icon' className='outline-none hover:bg-primary hover:text-white'>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
<span className='sr-only'>Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
255
components/ui/dropdown-menu.tsx
Normal file
255
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
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(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
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(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
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(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
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 text-sm font-medium 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-xs tracking-widest text-muted-foreground',
|
||||
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 items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-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(
|
||||
'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-[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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
68
components/ui/image-preview.tsx
Normal file
68
components/ui/image-preview.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react'
|
||||
import { Dialog, DialogContent } from './dialog'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface ImagePreviewProps {
|
||||
/** 图片 URL */
|
||||
src: string
|
||||
/** 图片描述 */
|
||||
alt?: string
|
||||
/** 是否打开 */
|
||||
open?: boolean
|
||||
/** 打开/关闭回调 */
|
||||
onOpenChange?: (open: boolean) => void
|
||||
/** 子元素(通常是 Image 组件) */
|
||||
children?: React.ReactNode
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ImagePreview 组件 - 图片预览对话框
|
||||
*/
|
||||
export function ImagePreview({
|
||||
src,
|
||||
alt = '图片预览',
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
className,
|
||||
}: ImagePreviewProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||
const open = controlledOpen ?? internalOpen
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setInternalOpen(value)
|
||||
}
|
||||
onOpenChange?.(value)
|
||||
},
|
||||
[controlledOpen, onOpenChange]
|
||||
)
|
||||
|
||||
const handleChildClick = React.useCallback(() => {
|
||||
setOpen(true)
|
||||
}, [setOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
<div onClick={handleChildClick} className={cn('cursor-pointer', className)}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[90vw] max-h-[90vh] p-0 bg-transparent border-0 shadow-none z-50">
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-w-full max-h-[90vh] object-contain rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
47
components/ui/image.tsx
Normal file
47
components/ui/image.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
/** 图片源 */
|
||||
src: string
|
||||
/** 替代文本 */
|
||||
alt: string
|
||||
/** 是否可点击预览 */
|
||||
preview?: boolean
|
||||
/** 点击回调 */
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Image 组件 - 基于 shadcn 风格的图片组件
|
||||
*/
|
||||
const Image = React.forwardRef<HTMLImageElement, ImageProps>(
|
||||
({ className, src, alt, preview, onClick, ...props }, ref) => {
|
||||
const handleClick = () => {
|
||||
if (preview || onClick) {
|
||||
onClick?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'max-w-full object-contain',
|
||||
(preview || onClick) && 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Image.displayName = 'Image'
|
||||
|
||||
export { Image }
|
||||
|
||||
12
components/ui/index.ts
Normal file
12
components/ui/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Modern Tech Theme UI Components
|
||||
// Export all components from a single entry point
|
||||
|
||||
export { Button, buttonVariants } from './button'
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card'
|
||||
export { Input } from './input'
|
||||
export { Textarea } from './textarea'
|
||||
export { Badge, badgeVariants } from './badge'
|
||||
export { Separator } from './separator'
|
||||
export { Skeleton } from './skeleton'
|
||||
export { Label } from './label'
|
||||
export { Avatar, AvatarImage, AvatarFallback } from './avatar'
|
||||
46
components/ui/input.test.tsx
Normal file
46
components/ui/input.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Input } from './input'
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders input element', () => {
|
||||
render(<Input placeholder="Enter text" />)
|
||||
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles text input', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Input placeholder="Type here" />)
|
||||
const input = screen.getByPlaceholderText('Type here')
|
||||
await user.type(input, 'hello')
|
||||
expect(input).toHaveValue('hello')
|
||||
})
|
||||
|
||||
it('renders with type password', () => {
|
||||
render(<Input type="password" placeholder="Password" />)
|
||||
expect(screen.getByPlaceholderText('Password')).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('renders as disabled', () => {
|
||||
render(<Input disabled placeholder="Disabled" />)
|
||||
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onChange handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<Input onChange={onChange} placeholder="Input" />)
|
||||
await user.type(screen.getByPlaceholderText('Input'), 'a')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Input className="my-custom-class" placeholder="Custom" />)
|
||||
expect(screen.getByPlaceholderText('Custom').className).toContain('my-custom-class')
|
||||
})
|
||||
|
||||
it('has correct displayName', () => {
|
||||
expect(Input.displayName).toBe('Input')
|
||||
})
|
||||
})
|
||||
35
components/ui/input.tsx
Normal file
35
components/ui/input.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
// Focus states with modern glow
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'focus-visible:border-primary/50',
|
||||
// Dark mode enhancements
|
||||
'dark:bg-secondary/30 dark:border-white/[0.08]',
|
||||
'dark:focus-visible:border-primary/35 dark:focus-visible:ring-ring',
|
||||
// Transitions
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disabled state
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
87
components/ui/popover.tsx
Normal file
87
components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react'
|
||||
import { Popover as PopoverPrimitive } from 'radix-ui'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn('flex flex-col gap-1 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn('font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn('text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
53
components/ui/scroll-area.tsx
Normal file
53
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollAreaProps
|
||||
>(({ className, children, onScroll, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className="h-full w-full rounded-[inherit] [&>div]:!block"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
30
components/ui/separator.tsx
Normal file
30
components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
'dark:bg-white/[0.08]',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
19
components/ui/skeleton.tsx
Normal file
19
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-muted',
|
||||
'dark:bg-white/[0.08]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
40
components/ui/sonner.tsx
Normal file
40
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react'
|
||||
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"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
114
components/ui/table.tsx
Normal file
114
components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
36
components/ui/textarea.tsx
Normal file
36
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background',
|
||||
'placeholder:text-muted-foreground',
|
||||
// Focus states with modern glow
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'focus-visible:border-primary/50',
|
||||
// Dark mode enhancements
|
||||
'dark:bg-secondary/30 dark:border-white/[0.08]',
|
||||
'dark:focus-visible:border-primary/35 dark:focus-visible:ring-ring',
|
||||
// Transitions
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disabled state
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
36
components/ui/tooltip.tsx
Normal file
36
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-3 text-xs text-popover-foreground shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
Reference in New Issue
Block a user