mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
6
web/components/ui/ToastProvider.tsx
Normal file
6
web/components/ui/ToastProvider.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// Re-exports for backwards compatibility.
|
||||
// The notification system lives in web/components/notifications/ and web/lib/notifications.ts.
|
||||
export { ToastProvider } from "@/components/notifications/ToastProvider";
|
||||
export { useToast } from "@/hooks/useToast";
|
||||
82
web/components/ui/avatar.tsx
Normal file
82
web/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const avatarVariants = cva(
|
||||
'relative inline-flex items-center justify-center rounded-full overflow-hidden font-medium select-none flex-shrink-0',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'h-6 w-6 text-[10px]',
|
||||
sm: 'h-8 w-8 text-xs',
|
||||
md: 'h-10 w-10 text-sm',
|
||||
lg: 'h-12 w-12 text-base',
|
||||
xl: 'h-16 w-16 text-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: 'md' },
|
||||
}
|
||||
)
|
||||
|
||||
export interface AvatarProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof avatarVariants> {
|
||||
src?: string
|
||||
alt?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0].toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Deterministic colour from name
|
||||
function getAvatarColor(name: string): string {
|
||||
const colours = [
|
||||
'bg-brand-700 text-brand-200',
|
||||
'bg-violet-800 text-violet-200',
|
||||
'bg-indigo-800 text-indigo-200',
|
||||
'bg-blue-800 text-blue-200',
|
||||
'bg-cyan-800 text-cyan-200',
|
||||
'bg-teal-800 text-teal-200',
|
||||
'bg-emerald-800 text-emerald-200',
|
||||
'bg-amber-800 text-amber-200',
|
||||
'bg-rose-800 text-rose-200',
|
||||
]
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) & 0xffffffff
|
||||
}
|
||||
return colours[Math.abs(hash) % colours.length]
|
||||
}
|
||||
|
||||
function Avatar({ className, size, src, alt, name, ...props }: AvatarProps) {
|
||||
const [imgError, setImgError] = React.useState(false)
|
||||
const showImage = src && !imgError
|
||||
const initials = name ? getInitials(name) : '?'
|
||||
const colorClass = name ? getAvatarColor(name) : 'bg-surface-700 text-surface-300'
|
||||
|
||||
return (
|
||||
<span className={cn(avatarVariants({ size, className }))} {...props}>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? name ?? 'Avatar'}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('flex h-full w-full items-center justify-center', colorClass)} aria-label={name}>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, avatarVariants }
|
||||
56
web/components/ui/badge.tsx
Normal file
56
web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface-800 text-surface-300 border border-surface-700',
|
||||
success: 'bg-success-bg text-success border border-success/20',
|
||||
error: 'bg-error-bg text-error border border-error/20',
|
||||
warning: 'bg-warning-bg text-warning border border-warning/20',
|
||||
info: 'bg-info-bg text-info border border-info/20',
|
||||
brand: 'bg-brand-500/15 text-brand-300 border border-brand-500/25',
|
||||
outline: 'border border-surface-600 text-surface-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
dot?: boolean
|
||||
}
|
||||
|
||||
function Badge({ className, variant, dot = false, children, ...props }: BadgeProps) {
|
||||
const dotColors: Record<string, string> = {
|
||||
default: 'bg-surface-400',
|
||||
success: 'bg-success',
|
||||
error: 'bg-error',
|
||||
warning: 'bg-warning',
|
||||
info: 'bg-info',
|
||||
brand: 'bg-brand-400',
|
||||
outline: 'bg-surface-500',
|
||||
}
|
||||
const dotColor = dotColors[variant ?? 'default'] ?? dotColors.default
|
||||
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant, className }))} {...props}>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', dotColor)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
73
web/components/ui/button.tsx
Normal file
73
web/components/ui/button.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors duration-[var(--transition-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
|
||||
secondary: 'bg-surface-800 text-surface-100 border border-surface-700 hover:bg-surface-700',
|
||||
ghost: 'text-surface-400 hover:bg-surface-800 hover:text-surface-100',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
|
||||
// Legacy aliases
|
||||
default: 'bg-brand-600 text-white hover:bg-brand-700',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-surface-700 bg-transparent hover:bg-surface-800 text-surface-200',
|
||||
link: 'text-brand-400 underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-7 px-3 text-xs rounded',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
// Legacy aliases
|
||||
default: 'h-9 px-4 py-2',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
const spinnerSize = size === 'sm' ? 12 : size === 'lg' ? 18 : 14
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<Loader2
|
||||
className="animate-spin flex-shrink-0"
|
||||
size={spinnerSize}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
131
web/components/ui/dialog.tsx
Normal file
131
web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixDialog from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const dialogContentVariants = cva(
|
||||
[
|
||||
'relative z-50 grid w-full gap-4 rounded-lg border border-surface-700',
|
||||
'bg-surface-900 p-6 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-4rem)]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Dialog = RadixDialog.Root
|
||||
const DialogTrigger = RadixDialog.Trigger
|
||||
const DialogPortal = RadixDialog.Portal
|
||||
const DialogClose = RadixDialog.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDialog.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = RadixDialog.Overlay.displayName
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof RadixDialog.Content>,
|
||||
VariantProps<typeof dialogContentVariants> {
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, size, showClose = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<RadixDialog.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
dialogContentVariants({ size, className })
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm text-surface-500 hover:text-surface-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
)}
|
||||
</RadixDialog.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = RadixDialog.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col gap-1.5', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDialog.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-surface-50', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = RadixDialog.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDialog.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-surface-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = RadixDialog.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
191
web/components/ui/dropdown-menu.tsx
Normal file
191
web/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = RadixDropdownMenu.Root
|
||||
const DropdownMenuTrigger = RadixDropdownMenu.Trigger
|
||||
const DropdownMenuGroup = RadixDropdownMenu.Group
|
||||
const DropdownMenuPortal = RadixDropdownMenu.Portal
|
||||
const DropdownMenuSub = RadixDropdownMenu.Sub
|
||||
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<RadixDropdownMenu.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm',
|
||||
'text-surface-200 outline-none focus:bg-surface-800 data-[state=open]:bg-surface-800',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4 text-surface-500" aria-hidden="true" />
|
||||
</RadixDropdownMenu.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = RadixDropdownMenu.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDropdownMenu.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-850 p-1 text-surface-100 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = RadixDropdownMenu.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Portal>
|
||||
<RadixDropdownMenu.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-850 p-1 text-surface-100 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RadixDropdownMenu.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & { inset?: boolean; destructive?: boolean }
|
||||
>(({ className, inset, destructive, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
|
||||
'outline-none transition-colors focus:bg-surface-800',
|
||||
destructive
|
||||
? 'text-red-400 focus:text-red-300'
|
||||
: 'text-surface-200 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<RadixDropdownMenu.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
|
||||
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixDropdownMenu.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
</RadixDropdownMenu.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</RadixDropdownMenu.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = RadixDropdownMenu.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<RadixDropdownMenu.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
|
||||
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixDropdownMenu.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-brand-400 text-brand-400" aria-hidden="true" />
|
||||
</RadixDropdownMenu.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</RadixDropdownMenu.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = RadixDropdownMenu.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = RadixDropdownMenu.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-surface-500', className)} {...props} />
|
||||
)
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
79
web/components/ui/input.tsx
Normal file
79
web/components/ui/input.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helper?: string
|
||||
variant?: 'default' | 'search'
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, helper, variant = 'default', id, ...props }, ref) => {
|
||||
const inputId = id ?? React.useId()
|
||||
const errorId = `${inputId}-error`
|
||||
const helperId = `${inputId}-helper`
|
||||
|
||||
const describedBy = [
|
||||
error ? errorId : null,
|
||||
helper ? helperId : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm font-medium text-surface-200"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{variant === 'search' && (
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 pointer-events-none"
|
||||
size={15}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
ref={ref}
|
||||
aria-describedby={describedBy || undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border bg-surface-900 px-3 py-1 text-sm text-surface-100',
|
||||
'border-surface-700 placeholder:text-surface-500',
|
||||
'transition-colors duration-[var(--transition-fast)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variant === 'search' && 'pl-9',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={errorId} className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && (
|
||||
<p id={helperId} className="text-xs text-surface-500">
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
161
web/components/ui/select.tsx
Normal file
161
web/components/ui/select.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixSelect from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = RadixSelect.Root
|
||||
const SelectGroup = RadixSelect.Group
|
||||
const SelectValue = RadixSelect.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<RadixSelect.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-surface-700',
|
||||
'bg-surface-900 px-3 py-2 text-sm text-surface-100',
|
||||
'placeholder:text-surface-500',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<RadixSelect.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-surface-500 flex-shrink-0" aria-hidden="true" />
|
||||
</RadixSelect.Icon>
|
||||
</RadixSelect.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = RadixSelect.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 text-surface-500" aria-hidden="true" />
|
||||
</RadixSelect.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = RadixSelect.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-surface-500" aria-hidden="true" />
|
||||
</RadixSelect.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = RadixSelect.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<RadixSelect.Portal>
|
||||
<RadixSelect.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-850 text-surface-100 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
position === 'popper' && [
|
||||
'data-[side=bottom]:translate-y-1',
|
||||
'data-[side=top]:-translate-y-1',
|
||||
],
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<RadixSelect.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</RadixSelect.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</RadixSelect.Content>
|
||||
</RadixSelect.Portal>
|
||||
))
|
||||
SelectContent.displayName = RadixSelect.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = RadixSelect.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<RadixSelect.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
|
||||
'text-surface-200 outline-none',
|
||||
'focus:bg-surface-800 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixSelect.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</span>
|
||||
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
|
||||
</RadixSelect.Item>
|
||||
))
|
||||
SelectItem.displayName = RadixSelect.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = RadixSelect.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
64
web/components/ui/tabs.tsx
Normal file
64
web/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixTabs from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = RadixTabs.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTabs.List>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTabs.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixTabs.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative inline-flex items-center border-b border-surface-800 w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = RadixTabs.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTabs.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTabs.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixTabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap',
|
||||
'px-4 py-2.5 text-sm font-medium',
|
||||
'text-surface-500 transition-colors duration-[var(--transition-fast)]',
|
||||
'hover:text-surface-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-inset',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
// Animated underline via pseudo-element
|
||||
'after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full',
|
||||
'after:scale-x-0 after:bg-brand-500 after:transition-transform after:duration-[var(--transition-normal)]',
|
||||
'data-[state=active]:text-surface-50 data-[state=active]:after:scale-x-100',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = RadixTabs.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTabs.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTabs.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixTabs.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'data-[state=active]:animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = RadixTabs.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
105
web/components/ui/textarea.tsx
Normal file
105
web/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helper?: string
|
||||
maxCount?: number
|
||||
autoGrow?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, label, error, helper, maxCount, autoGrow = false, id, onChange, value, ...props }, ref) => {
|
||||
const textareaId = id ?? React.useId()
|
||||
const errorId = `${textareaId}-error`
|
||||
const helperId = `${textareaId}-helper`
|
||||
const internalRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef
|
||||
|
||||
const [charCount, setCharCount] = React.useState(
|
||||
typeof value === 'string' ? value.length : 0
|
||||
)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCharCount(e.target.value.length)
|
||||
if (autoGrow && resolvedRef.current) {
|
||||
resolvedRef.current.style.height = 'auto'
|
||||
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
|
||||
}
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoGrow && resolvedRef.current) {
|
||||
resolvedRef.current.style.height = 'auto'
|
||||
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [value, autoGrow, resolvedRef])
|
||||
|
||||
const describedBy = [error ? errorId : null, helper ? helperId : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const isOverLimit = maxCount !== undefined && charCount > maxCount
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={textareaId} className="text-sm font-medium text-surface-200">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={textareaId}
|
||||
ref={resolvedRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-describedby={describedBy || undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border bg-surface-900 px-3 py-2 text-sm text-surface-100',
|
||||
'border-surface-700 placeholder:text-surface-500',
|
||||
'transition-colors duration-[var(--transition-fast)] resize-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
autoGrow && 'overflow-hidden',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
{error && (
|
||||
<p id={errorId} className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && (
|
||||
<p id={helperId} className="text-xs text-surface-500">
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{maxCount !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs tabular-nums ml-auto flex-shrink-0',
|
||||
isOverLimit ? 'text-red-400' : 'text-surface-500'
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{charCount}/{maxCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
168
web/components/ui/toast.tsx
Normal file
168
web/components/ui/toast.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixToast from '@radix-ui/react-toast'
|
||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface ToastData {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
duration?: number
|
||||
}
|
||||
|
||||
// ── Store (singleton for imperative toasts) ───────────────────────────────────
|
||||
|
||||
type Listener = (toasts: ToastData[]) => void
|
||||
|
||||
let toastList: ToastData[] = []
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((fn) => fn([...toastList]))
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
show(data: Omit<ToastData, 'id'>) {
|
||||
const id = Math.random().toString(36).slice(2, 9)
|
||||
toastList = [...toastList, { id, ...data }]
|
||||
emit()
|
||||
return id
|
||||
},
|
||||
success(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'success' })
|
||||
},
|
||||
error(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'error' })
|
||||
},
|
||||
warning(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'warning' })
|
||||
},
|
||||
info(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'info' })
|
||||
},
|
||||
dismiss(id: string) {
|
||||
toastList = toastList.filter((t) => t.id !== id)
|
||||
emit()
|
||||
},
|
||||
}
|
||||
|
||||
function useToastStore() {
|
||||
const [toasts, setToasts] = React.useState<ToastData[]>([])
|
||||
React.useEffect(() => {
|
||||
setToasts([...toastList])
|
||||
listeners.add(setToasts)
|
||||
return () => { listeners.delete(setToasts) }
|
||||
}, [])
|
||||
return toasts
|
||||
}
|
||||
|
||||
// ── Style variants ────────────────────────────────────────────────────────────
|
||||
|
||||
const toastVariants = cva(
|
||||
[
|
||||
'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden',
|
||||
'rounded-lg border p-4 shadow-lg transition-all',
|
||||
'data-[state=open]:animate-slide-up data-[state=closed]:animate-slide-down-out',
|
||||
'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]',
|
||||
'data-[swipe=cancel]:translate-x-0',
|
||||
'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=end]:animate-fade-out',
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface-800 border-surface-700 text-surface-100',
|
||||
success: 'bg-surface-800 border-green-800/60 text-surface-100',
|
||||
error: 'bg-surface-800 border-red-800/60 text-surface-100',
|
||||
warning: 'bg-surface-800 border-yellow-800/60 text-surface-100',
|
||||
info: 'bg-surface-800 border-blue-800/60 text-surface-100',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
}
|
||||
)
|
||||
|
||||
const variantIcons: Record<ToastVariant, React.ReactNode> = {
|
||||
default: null,
|
||||
success: <CheckCircle2 className="h-4 w-4 text-green-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
error: <AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
warning: <AlertTriangle className="h-4 w-4 text-yellow-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
info: <Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
}
|
||||
|
||||
// ── Single toast item ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ToastItemProps extends VariantProps<typeof toastVariants> {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
function ToastItem({ id, title, description, variant = 'default', duration = 5000 }: ToastItemProps) {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
const icon = variantIcons[variant ?? 'default']
|
||||
|
||||
return (
|
||||
<RadixToast.Root
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
setOpen(o)
|
||||
if (!o) setTimeout(() => toast.dismiss(id), 300)
|
||||
}}
|
||||
duration={duration}
|
||||
className={cn(toastVariants({ variant }))}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex-1 min-w-0">
|
||||
<RadixToast.Title className="text-sm font-medium leading-snug">{title}</RadixToast.Title>
|
||||
{description && (
|
||||
<RadixToast.Description className="mt-0.5 text-xs text-surface-400 leading-relaxed">
|
||||
{description}
|
||||
</RadixToast.Description>
|
||||
)}
|
||||
</div>
|
||||
<RadixToast.Close
|
||||
className="flex-shrink-0 text-surface-500 hover:text-surface-200 transition-colors rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</RadixToast.Close>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-current opacity-20"
|
||||
style={{ animation: `progress ${duration}ms linear forwards` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</RadixToast.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Provider (mount once in layout) ──────────────────────────────────────────
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const toasts = useToastStore()
|
||||
|
||||
return (
|
||||
<RadixToast.Provider swipeDirection="right">
|
||||
{children}
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} {...t} />
|
||||
))}
|
||||
<RadixToast.Viewport className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-80 focus:outline-none" />
|
||||
</RadixToast.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hook (alternative to imperative API) ─────────────────────────────────────
|
||||
|
||||
export function useToast() {
|
||||
return toast
|
||||
}
|
||||
57
web/components/ui/tooltip.tsx
Normal file
57
web/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixTooltip from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = RadixTooltip.Provider
|
||||
const Tooltip = RadixTooltip.Root
|
||||
const TooltipTrigger = RadixTooltip.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTooltip.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTooltip.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<RadixTooltip.Portal>
|
||||
<RadixTooltip.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-800 px-3 py-1.5 text-xs text-surface-100 shadow-md',
|
||||
'animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RadixTooltip.Portal>
|
||||
))
|
||||
TooltipContent.displayName = RadixTooltip.Content.displayName
|
||||
|
||||
// Convenience wrapper
|
||||
interface SimpleTooltipProps {
|
||||
content: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
delayDuration?: number
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function SimpleTooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
delayDuration = 400,
|
||||
asChild = false,
|
||||
}: SimpleTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side}>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip }
|
||||
Reference in New Issue
Block a user