Generated by Spark: Expand custom hook library, expand ui component library

This commit is contained in:
2026-01-23 06:31:21 +00:00
committed by GitHub
parent 7e035acc7a
commit e222d7679f
20 changed files with 2921 additions and 5 deletions

375
LIBRARY_EXTENSIONS.md Normal file
View File

@@ -0,0 +1,375 @@
# Extended Hooks and Components Library
This document lists all the newly added custom hooks and UI components for the WorkForce Pro platform.
## New Custom Hooks
### useBulkOperations
**Purpose**: Handle bulk operations on multiple items with progress tracking, error handling, and batch processing.
**Features**:
- Item selection with multi-select support
- Batch processing with configurable batch size
- Progress tracking
- Error handling and retry logic
- Range selection support
**Usage**:
```typescript
const { selectedItems, processBulk, progress, errors } = useBulkOperations()
// Process selected items in batches
await processBulk(async (id) => {
await approveTimesheet(id)
}, { batchSize: 5, continueOnError: true })
```
---
### useOptimisticUpdate
**Purpose**: Apply optimistic UI updates with automatic rollback on failure.
**Features**:
- Immediate UI updates
- Automatic rollback on error
- Configurable timeout
- Track pending updates
**Usage**:
```typescript
const { executeOptimistic } = useOptimisticUpdate()
await executeOptimistic(
'timesheet-123',
currentData,
updatedData,
async () => await updateTimesheetAPI(updatedData)
)
```
---
### usePolling
**Purpose**: Poll an API endpoint at regular intervals with automatic retry and backoff.
**Features**:
- Configurable polling interval
- Automatic retry with exponential backoff
- Start/stop/refresh controls
- Error handling
**Usage**:
```typescript
const { data, start, stop, refresh } = usePolling(
fetchMissingTimesheets,
{ interval: 30000, maxRetries: 3 }
)
```
---
### useVirtualScroll
**Purpose**: Efficiently render large lists using virtual scrolling.
**Features**:
- Only renders visible items
- Configurable overscan
- Scroll-to-index functionality
- Performance optimized for 1000+ items
**Usage**:
```typescript
const { containerRef, visibleItems, scrollToIndex } = useVirtualScroll(
allTimesheets,
{ itemHeight: 60, containerHeight: 600 }
)
```
---
### useQueue
**Purpose**: Process tasks in a queue with concurrency control and retry logic.
**Features**:
- Configurable concurrency
- Priority queue support
- Automatic retry on failure
- Queue statistics
**Usage**:
```typescript
const { enqueue, stats } = useQueue(processInvoice, {
concurrency: 3,
maxRetries: 2
})
enqueue(invoiceData, priority)
```
---
### useDragAndDrop
**Purpose**: Add drag-and-drop functionality to components.
**Features**:
- Type-safe drag and drop
- Drop zone validation
- Custom drag preview
- Drag state tracking
**Usage**:
```typescript
const { getDragHandlers, getDropHandlers, isDragging } = useDragAndDrop()
<div {...getDragHandlers({ id: '1', data: item })}>Draggable</div>
<div {...getDropHandlers({ id: 'zone1' }, handleDrop)}>Drop Zone</div>
```
---
### useCache
**Purpose**: In-memory caching with TTL and size limits.
**Features**:
- Automatic expiration (TTL)
- LRU eviction when max size reached
- Hit rate tracking
- Import/export cache state
**Usage**:
```typescript
const { get, set, getOrSet, hitRate } = useCache({
ttl: 300000,
maxSize: 100
})
const data = await getOrSet('workers', fetchWorkers)
```
---
### useWebSocket
**Purpose**: WebSocket connection with automatic reconnection and heartbeat.
**Features**:
- Automatic reconnection
- Configurable heartbeat
- Connection state tracking
- JSON message support
**Usage**:
```typescript
const { sendJson, isOpen, lastMessage } = useWebSocket(
'wss://api.example.com/ws',
{ reconnectAttempts: 5, heartbeatInterval: 30000 }
)
```
---
## New UI Components
### MultiSelect
**Purpose**: Multi-selection dropdown with search and tag display.
**Features**:
- Searchable options
- Tag display with remove
- Maximum selection limit
- Clear all functionality
**Usage**:
```tsx
<MultiSelect
options={workers}
value={selectedWorkers}
onChange={setSelectedWorkers}
maxSelections={5}
searchable
/>
```
---
### Timeline (Enhanced)
**Purpose**: Display chronological events with status indicators.
**Features**:
- Vertical and horizontal orientation
- Status indicators (completed, current, upcoming, error)
- Metadata display
- Custom icons
- Clickable items
**Usage**:
```tsx
<Timeline
items={auditLogItems}
orientation="vertical"
onItemClick={(item) => console.log(item)}
/>
```
---
### ValidationIndicator & Banner
**Purpose**: Display validation rules and contextual banners.
**Features**:
- Real-time validation feedback
- Rule-based validation display
- Contextual banners (info, success, warning, error)
- Dismissible banners
**Usage**:
```tsx
<ValidationIndicator
rules={passwordRules}
value={password}
showOnlyFailed
/>
<Banner variant="warning" dismissible>
Your compliance document expires in 3 days
</Banner>
```
---
### Stepper (Enhanced)
**Purpose**: Step-by-step navigation with multiple variants.
**Features**:
- Horizontal and vertical orientation
- Multiple variants (default, compact, dots)
- Optional steps
- Click navigation
- Step status tracking
**Usage**:
```tsx
<Stepper
steps={onboardingSteps}
currentStep={currentIndex}
onStepClick={goToStep}
variant="default"
allowSkip
/>
<StepperNav
currentStep={step}
totalSteps={5}
onNext={handleNext}
onComplete={handleComplete}
/>
```
---
### ComboBox
**Purpose**: Searchable dropdown with grouping and descriptions.
**Features**:
- Searchable options
- Group support
- Option descriptions
- Empty state handling
**Usage**:
```tsx
<ComboBox
options={clientOptions}
value={selectedClient}
onChange={setSelectedClient}
showGroups
searchPlaceholder="Search clients..."
/>
```
---
### TreeView
**Purpose**: Hierarchical data display with expand/collapse.
**Features**:
- Nested data support
- Expand/collapse nodes
- Custom icons
- Selection support
- Disabled nodes
- Connection lines
**Usage**:
```tsx
<TreeView
data={organizationStructure}
selectedId={selectedNode}
onSelect={handleSelect}
expandedByDefault
showLines
/>
```
---
### DataPill & DataGroup
**Purpose**: Display tags and grouped data elements.
**Features**:
- Multiple variants (default, primary, success, warning, error, info)
- Removable pills
- Size variants (sm, md, lg)
- Optional icons
- Collapsible groups
**Usage**:
```tsx
<DataGroup label="Skills" collapsible>
<DataPill variant="primary" removable onRemove={removeSkill}>
JavaScript
</DataPill>
<DataPill variant="success">React</DataPill>
</DataGroup>
```
---
### AdvancedTable
**Purpose**: Feature-rich table with sorting, styling options, and loading states.
**Features**:
- Column sorting
- Custom cell rendering
- Sticky headers and columns
- Row selection
- Loading states
- Empty states
- Striped and bordered variants
**Usage**:
```tsx
<AdvancedTable
data={timesheets}
columns={columns}
keyExtractor={(row) => row.id}
onRowClick={handleRowClick}
hoverable
striped
stickyHeader
/>
```
---
## Integration Notes
All hooks are exported from `@/hooks` and all components are available in `@/components/ui`.
These additions significantly expand the platform's capabilities for:
- **Bulk operations** - Approve/reject multiple timesheets, invoices
- **Real-time updates** - WebSocket connections, polling for new data
- **Performance** - Virtual scrolling for large datasets, caching
- **User experience** - Drag-and-drop, multi-select, advanced steppers
- **Data display** - Tree views, timelines, advanced tables
All components follow the existing design system and are fully typed with TypeScript.

View File

@@ -67,9 +67,9 @@ export function ComponentShowcase() {
useWizard(wizardSteps)
const stepperSteps = [
{ id: '1', label: 'Start', description: 'Getting started' },
{ id: '2', label: 'Configure', description: 'Setup options' },
{ id: '3', label: 'Complete', description: 'Finish up' }
{ id: '1', label: 'Start', description: 'Getting started', status: 'completed' as const },
{ id: '2', label: 'Configure', description: 'Setup options', status: 'current' as const },
{ id: '3', label: 'Complete', description: 'Finish up', status: 'pending' as const }
]
const timelineItems = [
@@ -191,8 +191,7 @@ export function ComponentShowcase() {
<CardContent className="space-y-6">
<Stepper
steps={stepperSteps}
currentStep={currentStepIndex}
onStepClick={(index) => console.log('Go to step', index)}
orientation="horizontal"
/>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>

View File

@@ -0,0 +1,165 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface Column<T> {
key: string
header: string
render?: (row: T, index: number) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
sortable?: boolean
sticky?: boolean
}
export interface AdvancedTableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (row: T) => string
className?: string
hoverable?: boolean
striped?: boolean
bordered?: boolean
compact?: boolean
onRowClick?: (row: T, index: number) => void
emptyMessage?: string
loading?: boolean
stickyHeader?: boolean
}
export function AdvancedTable<T>({
data,
columns,
keyExtractor,
className,
hoverable = true,
striped = false,
bordered = true,
compact = false,
onRowClick,
emptyMessage = 'No data available',
loading = false,
stickyHeader = false
}: AdvancedTableProps<T>) {
const [sortKey, setSortKey] = React.useState<string | null>(null)
const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc')
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDirection('asc')
}
}
const sortedData = React.useMemo(() => {
if (!sortKey) return data
return [...data].sort((a, b) => {
const aVal = (a as any)[sortKey]
const bVal = (b as any)[sortKey]
if (aVal === bVal) return 0
const comparison = aVal > bVal ? 1 : -1
return sortDirection === 'asc' ? comparison : -comparison
})
}, [data, sortKey, sortDirection])
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)
}
return (
<div className={cn('w-full overflow-auto', className)}>
<table className="w-full border-collapse text-sm">
<thead
className={cn(
'bg-muted/50',
stickyHeader && 'sticky top-0 z-10 bg-muted'
)}
>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={cn(
'px-4 py-3 text-left font-semibold text-muted-foreground',
bordered && 'border-b border-border',
compact && 'px-2 py-2',
col.align === 'center' && 'text-center',
col.align === 'right' && 'text-right',
col.sortable && 'cursor-pointer select-none hover:text-foreground',
col.sticky && 'sticky left-0 bg-muted z-20'
)}
style={col.width ? { width: col.width } : undefined}
onClick={() => col.sortable && handleSort(col.key)}
>
<div className="flex items-center gap-2">
{col.header}
{col.sortable && sortKey === col.key && (
<svg
className={cn(
'h-4 w-4 transition-transform',
sortDirection === 'desc' && 'rotate-180'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.length > 0 ? (
sortedData.map((row, index) => (
<tr
key={keyExtractor(row)}
className={cn(
hoverable && 'hover:bg-muted/50 transition-colors',
striped && index % 2 === 1 && 'bg-muted/20',
onRowClick && 'cursor-pointer'
)}
onClick={() => onRowClick?.(row, index)}
>
{columns.map((col) => (
<td
key={col.key}
className={cn(
'px-4 py-3',
bordered && 'border-b border-border',
compact && 'px-2 py-2',
col.align === 'center' && 'text-center',
col.align === 'right' && 'text-right',
col.sticky && 'sticky left-0 bg-background'
)}
>
{col.render ? col.render(row, index) : (row as any)[col.key]}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,190 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface ComboBoxOption {
value: string
label: string
description?: string
disabled?: boolean
group?: string
}
export interface ComboBoxProps {
options: ComboBoxOption[]
value?: string
onChange: (value: string) => void
placeholder?: string
emptyMessage?: string
searchPlaceholder?: string
disabled?: boolean
className?: string
showGroups?: boolean
}
export function ComboBox({
options,
value,
onChange,
placeholder = 'Select an option...',
emptyMessage = 'No options found',
searchPlaceholder = 'Search...',
disabled = false,
showGroups = true,
className
}: ComboBoxProps) {
const [isOpen, setIsOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
const containerRef = React.useRef<HTMLDivElement>(null)
const searchInputRef = React.useRef<HTMLInputElement>(null)
const selectedOption = options.find(opt => opt.value === value)
const filteredOptions = React.useMemo(() => {
if (!searchQuery) return options
const query = searchQuery.toLowerCase()
return options.filter(opt =>
opt.label.toLowerCase().includes(query) ||
opt.value.toLowerCase().includes(query) ||
opt.description?.toLowerCase().includes(query)
)
}, [options, searchQuery])
const groupedOptions = React.useMemo(() => {
if (!showGroups) return { '': filteredOptions }
return filteredOptions.reduce((acc, opt) => {
const group = opt.group || ''
if (!acc[group]) acc[group] = []
acc[group].push(opt)
return acc
}, {} as Record<string, ComboBoxOption[]>)
}, [filteredOptions, showGroups])
const handleSelect = (optionValue: string) => {
onChange(optionValue)
setIsOpen(false)
setSearchQuery('')
}
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
setSearchQuery('')
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
React.useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [isOpen])
return (
<div ref={containerRef} className={cn('relative', className)}>
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm',
'ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<span className={selectedOption ? 'text-foreground' : 'text-muted-foreground'}>
{selectedOption?.label || placeholder}
</span>
<svg
className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && !disabled && (
<div className="absolute z-50 mt-2 w-full rounded-md border bg-popover shadow-lg">
<div className="border-b p-2">
<input
ref={searchInputRef}
type="text"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1">
{Object.keys(groupedOptions).length > 0 ? (
Object.entries(groupedOptions).map(([group, opts]) => (
<div key={group}>
{group && (
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground">
{group}
</div>
)}
{opts.map(opt => {
const isSelected = opt.value === value
return (
<button
key={opt.value}
onClick={() => !opt.disabled && handleSelect(opt.value)}
disabled={opt.disabled}
className={cn(
'flex w-full cursor-pointer flex-col items-start gap-0.5 rounded-sm px-3 py-2 text-sm outline-none transition-colors',
'hover:bg-accent hover:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground',
opt.disabled && 'cursor-not-allowed opacity-50'
)}
>
<div className="flex items-center gap-2 w-full">
{isSelected && (
<svg
className="h-4 w-4 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
<span className={!isSelected ? 'ml-6' : ''}>{opt.label}</span>
</div>
{opt.description && (
<span className={cn(
'text-xs text-muted-foreground',
isSelected ? 'ml-6' : 'ml-6'
)}>
{opt.description}
</span>
)}
</button>
)
})}
</div>
))
) : (
<div className="px-3 py-2 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { X } from '@phosphor-icons/react'
export interface DataPillProps {
children: React.ReactNode
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info'
size?: 'sm' | 'md' | 'lg'
removable?: boolean
onRemove?: () => void
icon?: React.ReactNode
className?: string
}
export function DataPill({
children,
variant = 'default',
size = 'md',
removable = false,
onRemove,
icon,
className
}: DataPillProps) {
const variantStyles = {
default: 'bg-secondary text-secondary-foreground border-secondary',
primary: 'bg-primary/10 text-primary border-primary/20',
success: 'bg-success/10 text-success border-success/20',
warning: 'bg-warning/10 text-warning border-warning/20',
error: 'bg-destructive/10 text-destructive border-destructive/20',
info: 'bg-info/10 text-info border-info/20'
}
const sizeStyles = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base'
}
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border font-medium whitespace-nowrap',
variantStyles[variant],
sizeStyles[size],
className
)}
>
{icon && <span className="flex-shrink-0">{icon}</span>}
<span>{children}</span>
{removable && onRemove && (
<button
onClick={onRemove}
className="flex-shrink-0 hover:opacity-70 transition-opacity"
aria-label="Remove"
>
<X size={size === 'sm' ? 12 : size === 'lg' ? 16 : 14} weight="bold" />
</button>
)}
</span>
)
}
export interface DataGroupProps {
label?: string
children: React.ReactNode
collapsible?: boolean
defaultCollapsed?: boolean
className?: string
}
export function DataGroup({
label,
children,
collapsible = false,
defaultCollapsed = false,
className
}: DataGroupProps) {
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed)
return (
<div className={cn('space-y-2', className)}>
{label && (
<div
className={cn(
'flex items-center gap-2 text-sm font-semibold text-muted-foreground',
collapsible && 'cursor-pointer select-none'
)}
onClick={() => collapsible && setIsCollapsed(!isCollapsed)}
>
{collapsible && (
<svg
className={cn('h-4 w-4 transition-transform', !isCollapsed && 'rotate-90')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
{label}
</div>
)}
{(!collapsible || !isCollapsed) && (
<div className="flex flex-wrap gap-2">
{children}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,186 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { X } from '@phosphor-icons/react'
export interface MultiSelectOption {
value: string
label: string
disabled?: boolean
}
export interface MultiSelectProps {
options: MultiSelectOption[]
value: string[]
onChange: (value: string[]) => void
placeholder?: string
disabled?: boolean
maxSelections?: number
searchable?: boolean
className?: string
}
export function MultiSelect({
options,
value,
onChange,
placeholder = 'Select items...',
disabled = false,
maxSelections,
searchable = true,
className
}: MultiSelectProps) {
const [isOpen, setIsOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
const containerRef = React.useRef<HTMLDivElement>(null)
const filteredOptions = React.useMemo(() => {
if (!searchQuery) return options
const query = searchQuery.toLowerCase()
return options.filter(opt =>
opt.label.toLowerCase().includes(query) ||
opt.value.toLowerCase().includes(query)
)
}, [options, searchQuery])
const selectedOptions = React.useMemo(() => {
return options.filter(opt => value.includes(opt.value))
}, [options, value])
const handleToggle = (optionValue: string) => {
if (value.includes(optionValue)) {
onChange(value.filter(v => v !== optionValue))
} else {
if (maxSelections && value.length >= maxSelections) return
onChange([...value, optionValue])
}
}
const handleRemove = (optionValue: string, e: React.MouseEvent) => {
e.stopPropagation()
onChange(value.filter(v => v !== optionValue))
}
const handleClearAll = (e: React.MouseEvent) => {
e.stopPropagation()
onChange([])
}
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
className={cn(
'flex min-h-10 w-full flex-wrap gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer',
'ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
disabled && 'cursor-not-allowed opacity-50'
)}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{selectedOptions.length > 0 ? (
<>
{selectedOptions.map(opt => (
<span
key={opt.value}
className="inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{opt.label}
<button
type="button"
onClick={(e) => handleRemove(opt.value, e)}
className="hover:text-destructive"
disabled={disabled}
>
<X size={12} weight="bold" />
</button>
</span>
))}
{selectedOptions.length > 0 && (
<button
type="button"
onClick={handleClearAll}
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
)}
</>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
{isOpen && !disabled && (
<div className="absolute z-50 mt-2 w-full rounded-md border bg-popover shadow-lg">
{searchable && (
<div className="border-b p-2">
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<div className="max-h-60 overflow-y-auto p-1">
{filteredOptions.length > 0 ? (
filteredOptions.map(opt => {
const isSelected = value.includes(opt.value)
const isDisabled = opt.disabled || (maxSelections ? value.length >= maxSelections && !isSelected : false)
return (
<div
key={opt.value}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm outline-none transition-colors',
'hover:bg-accent hover:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground',
isDisabled && 'cursor-not-allowed opacity-50'
)}
onClick={() => !isDisabled && handleToggle(opt.value)}
>
<div className={cn(
'h-4 w-4 rounded border',
isSelected ? 'border-primary bg-primary' : 'border-input'
)}>
{isSelected && (
<svg
className="h-full w-full text-primary-foreground"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 8l3 3 7-7" />
</svg>
)}
</div>
<span>{opt.label}</span>
</div>
)
})
) : (
<div className="px-3 py-2 text-center text-sm text-muted-foreground">
No options found
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,261 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { CaretLeft, CaretRight } from '@phosphor-icons/react'
export interface Step {
id: string
label: string
description?: string
optional?: boolean
}
export interface StepperProps {
steps: Step[]
currentStep: number
onStepClick?: (stepIndex: number) => void
orientation?: 'horizontal' | 'vertical'
variant?: 'default' | 'compact' | 'dots'
className?: string
allowSkip?: boolean
}
export function Stepper({
steps,
currentStep,
onStepClick,
orientation = 'horizontal',
variant = 'default',
allowSkip = false,
className
}: StepperProps) {
const isVertical = orientation === 'vertical'
const isDots = variant === 'dots'
const isCompact = variant === 'compact'
const handleStepClick = (index: number) => {
if (!onStepClick) return
if (!allowSkip && index > currentStep) return
onStepClick(index)
}
if (isDots) {
return (
<div className={cn('flex items-center gap-2', className)}>
{steps.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
const isClickable = allowSkip || index <= currentStep
return (
<button
key={step.id}
onClick={() => handleStepClick(index)}
disabled={!isClickable || !onStepClick}
className={cn(
'h-2 rounded-full transition-all',
isActive && 'w-8 bg-primary',
isCompleted && 'w-2 bg-success',
!isActive && !isCompleted && 'w-2 bg-muted',
isClickable && 'cursor-pointer hover:opacity-70',
!isClickable && 'cursor-not-allowed'
)}
aria-label={step.label}
aria-current={isActive ? 'step' : undefined}
/>
)
})}
</div>
)
}
return (
<div
className={cn(
'flex',
isVertical ? 'flex-col' : 'flex-row items-start',
isCompact && !isVertical && 'items-center',
className
)}
>
{steps.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
const isLast = index === steps.length - 1
const isClickable = allowSkip || index <= currentStep
return (
<React.Fragment key={step.id}>
<div
className={cn(
'flex',
isVertical ? 'flex-row gap-3' : 'flex-col items-center gap-2',
isCompact && 'gap-1'
)}
>
<button
onClick={() => handleStepClick(index)}
disabled={!isClickable || !onStepClick}
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full border-2 font-semibold transition-colors',
isActive && 'border-primary bg-primary text-primary-foreground',
isCompleted && 'border-success bg-success text-success-foreground',
!isActive && !isCompleted && 'border-muted bg-background text-muted-foreground',
isClickable && 'cursor-pointer hover:opacity-80',
!isClickable && 'cursor-not-allowed',
isCompact && 'h-8 w-8 text-sm'
)}
aria-label={step.label}
aria-current={isActive ? 'step' : undefined}
>
{isCompleted ? (
<svg
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<span className={isCompact ? 'text-xs' : 'text-sm'}>
{index + 1}
</span>
)}
</button>
{!isCompact && (
<div
className={cn(
'flex flex-col',
isVertical ? 'flex-1' : 'items-center text-center max-w-[120px]'
)}
>
<span
className={cn(
'text-sm font-medium',
isActive && 'text-foreground',
!isActive && 'text-muted-foreground'
)}
>
{step.label}
</span>
{step.description && (
<span className="text-xs text-muted-foreground mt-0.5">
{step.description}
</span>
)}
{step.optional && (
<span className="text-xs text-muted-foreground italic mt-0.5">
Optional
</span>
)}
</div>
)}
</div>
{!isLast && (
<div
className={cn(
'flex items-center justify-center',
isVertical ? 'h-8 w-10 ml-5' : 'flex-1 h-0.5 mx-2',
isCompact && !isVertical && 'mx-1'
)}
>
{isVertical ? (
<div className="h-full w-0.5 bg-border" />
) : (
<div className="h-full w-full bg-border" />
)}
</div>
)}
</React.Fragment>
)
})}
</div>
)
}
export interface StepperNavProps {
currentStep: number
totalSteps: number
onNext?: () => void
onPrevious?: () => void
onComplete?: () => void
nextLabel?: string
previousLabel?: string
completeLabel?: string
disableNext?: boolean
disablePrevious?: boolean
className?: string
}
export function StepperNav({
currentStep,
totalSteps,
onNext,
onPrevious,
onComplete,
nextLabel = 'Next',
previousLabel = 'Previous',
completeLabel = 'Complete',
disableNext = false,
disablePrevious = false,
className
}: StepperNavProps) {
const isFirst = currentStep === 0
const isLast = currentStep === totalSteps - 1
return (
<div className={cn('flex items-center justify-between gap-4', className)}>
<button
onClick={onPrevious}
disabled={isFirst || disablePrevious}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
'disabled:pointer-events-none disabled:opacity-50',
'transition-colors'
)}
>
<CaretLeft size={16} weight="bold" />
{previousLabel}
</button>
<div className="text-sm text-muted-foreground">
Step {currentStep + 1} of {totalSteps}
</div>
{isLast ? (
<button
onClick={onComplete}
disabled={disableNext}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md',
'bg-primary text-primary-foreground hover:bg-primary/90',
'disabled:pointer-events-none disabled:opacity-50',
'transition-colors'
)}
>
{completeLabel}
</button>
) : (
<button
onClick={onNext}
disabled={disableNext}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md',
'bg-primary text-primary-foreground hover:bg-primary/90',
'disabled:pointer-events-none disabled:opacity-50',
'transition-colors'
)}
>
{nextLabel}
<CaretRight size={16} weight="bold" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,117 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TimelineItem {
id: string
title: string
description?: string
timestamp: string | Date
status?: 'completed' | 'current' | 'upcoming' | 'error'
icon?: React.ReactNode
metadata?: Record<string, any>
}
export interface TimelineProps {
items: TimelineItem[]
orientation?: 'vertical' | 'horizontal'
className?: string
onItemClick?: (item: TimelineItem) => void
}
export function Timeline({
items,
orientation = 'vertical',
className,
onItemClick
}: TimelineProps) {
const isVertical = orientation === 'vertical'
return (
<div
className={cn(
'relative',
isVertical ? 'space-y-8' : 'flex gap-8 overflow-x-auto pb-4',
className
)}
>
{items.map((item, index) => {
const isLast = index === items.length - 1
const timestamp = typeof item.timestamp === 'string'
? item.timestamp
: item.timestamp.toLocaleString()
return (
<div
key={item.id}
className={cn(
'relative flex gap-4',
isVertical ? 'flex-row' : 'flex-col min-w-[200px]',
onItemClick && 'cursor-pointer hover:opacity-80 transition-opacity'
)}
onClick={() => onItemClick?.(item)}
>
<div className={cn(
'relative flex',
isVertical ? 'flex-col items-center' : 'flex-row items-center justify-center'
)}>
<div
className={cn(
'z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 bg-background',
item.status === 'completed' && 'border-success bg-success text-success-foreground',
item.status === 'current' && 'border-accent bg-accent text-accent-foreground',
item.status === 'upcoming' && 'border-muted-foreground bg-muted text-muted-foreground',
item.status === 'error' && 'border-destructive bg-destructive text-destructive-foreground',
!item.status && 'border-primary bg-primary text-primary-foreground'
)}
>
{item.icon || (
<span className="text-sm font-medium">{index + 1}</span>
)}
</div>
{!isLast && (
<div
className={cn(
'bg-border',
isVertical
? 'absolute top-10 h-full w-0.5 left-1/2 -translate-x-1/2'
: 'h-0.5 w-full'
)}
/>
)}
</div>
<div className={cn(
'flex-1',
isVertical ? 'pb-8' : 'pt-4'
)}>
<div className="flex items-start justify-between gap-2">
<h4 className="font-semibold text-sm">{item.title}</h4>
<time className="text-xs text-muted-foreground whitespace-nowrap">
{timestamp}
</time>
</div>
{item.description && (
<p className="mt-1 text-sm text-muted-foreground">
{item.description}
</p>
)}
{item.metadata && Object.keys(item.metadata).length > 0 && (
<div className="mt-2 space-y-1">
{Object.entries(item.metadata).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{key}:</span>
<span className="font-medium">{String(value)}</span>
</div>
))}
</div>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,117 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TreeNode {
id: string
label: string
children?: TreeNode[]
icon?: React.ReactNode
metadata?: Record<string, any>
disabled?: boolean
}
export interface TreeViewProps {
data: TreeNode[]
selectedId?: string
onSelect?: (node: TreeNode) => void
expandedByDefault?: boolean
showLines?: boolean
className?: string
}
export function TreeView({
data,
selectedId,
onSelect,
expandedByDefault = false,
showLines = true,
className
}: TreeViewProps) {
const [expandedNodes, setExpandedNodes] = React.useState<Set<string>>(
() => {
if (!expandedByDefault) return new Set()
const expanded = new Set<string>()
const collectIds = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
expanded.add(node.id)
collectIds(node.children)
}
})
}
collectIds(data)
return expanded
}
)
const toggleExpand = (nodeId: string) => {
setExpandedNodes(prev => {
const next = new Set(prev)
if (next.has(nodeId)) {
next.delete(nodeId)
} else {
next.add(nodeId)
}
return next
})
}
const renderNode = (node: TreeNode, level: number = 0) => {
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedNodes.has(node.id)
const isSelected = selectedId === node.id
return (
<div key={node.id} className="select-none">
<div
className={cn(
'flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors',
'hover:bg-accent hover:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground font-medium',
node.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => !node.disabled && onSelect?.(node)}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand(node.id)
}}
className="flex-shrink-0 p-0.5 hover:bg-accent-foreground/10 rounded"
>
<svg
className={cn('h-4 w-4 transition-transform', isExpanded && 'rotate-90')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{!hasChildren && showLines && <div className="w-4 flex-shrink-0" />}
{node.icon && <div className="flex-shrink-0">{node.icon}</div>}
<span className="text-sm flex-1">{node.label}</span>
{node.metadata && Object.keys(node.metadata).length > 0 && (
<span className="text-xs text-muted-foreground">
{Object.values(node.metadata)[0]}
</span>
)}
</div>
{hasChildren && isExpanded && (
<div className={cn(showLines && 'border-l border-border ml-3')}>
{node.children!.map(child => renderNode(child, level + 1))}
</div>
)}
</div>
)
}
return (
<div className={cn('space-y-0.5', className)}>
{data.map(node => renderNode(node))}
</div>
)
}

View File

@@ -0,0 +1,141 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Check, X, Info, Warning } from '@phosphor-icons/react'
export interface ValidationRule {
id: string
label: string
validate: (value: any) => boolean
message?: string
}
export interface ValidationIndicatorProps {
rules: ValidationRule[]
value: any
showOnlyFailed?: boolean
className?: string
}
export function ValidationIndicator({
rules,
value,
showOnlyFailed = false,
className
}: ValidationIndicatorProps) {
const results = React.useMemo(() => {
return rules.map(rule => ({
...rule,
passed: rule.validate(value)
}))
}, [rules, value])
const displayedRules = showOnlyFailed
? results.filter(r => !r.passed)
: results
if (displayedRules.length === 0 && showOnlyFailed) {
return null
}
return (
<div className={cn('space-y-2', className)}>
{displayedRules.map(result => (
<div
key={result.id}
className={cn(
'flex items-start gap-2 text-sm',
result.passed ? 'text-success' : 'text-destructive'
)}
>
{result.passed ? (
<Check size={16} weight="bold" className="mt-0.5 flex-shrink-0" />
) : (
<X size={16} weight="bold" className="mt-0.5 flex-shrink-0" />
)}
<span>{result.message || result.label}</span>
</div>
))}
</div>
)
}
export interface BannerProps {
variant?: 'info' | 'success' | 'warning' | 'error'
title?: string
children: React.ReactNode
icon?: React.ReactNode
actions?: React.ReactNode
dismissible?: boolean
onDismiss?: () => void
className?: string
}
export function Banner({
variant = 'info',
title,
children,
icon,
actions,
dismissible = false,
onDismiss,
className
}: BannerProps) {
const [isDismissed, setIsDismissed] = React.useState(false)
const handleDismiss = () => {
setIsDismissed(true)
onDismiss?.()
}
if (isDismissed) return null
const variantStyles = {
info: 'bg-info/10 border-info/20 text-info-foreground',
success: 'bg-success/10 border-success/20 text-success-foreground',
warning: 'bg-warning/10 border-warning/20 text-warning-foreground',
error: 'bg-destructive/10 border-destructive/20 text-destructive-foreground'
}
const defaultIcons = {
info: <Info size={20} weight="fill" />,
success: <Check size={20} weight="bold" />,
warning: <Warning size={20} weight="fill" />,
error: <X size={20} weight="bold" />
}
return (
<div
className={cn(
'flex items-start gap-3 rounded-lg border p-4',
variantStyles[variant],
className
)}
>
<div className="flex-shrink-0">
{icon || defaultIcons[variant]}
</div>
<div className="flex-1 space-y-1">
{title && (
<h4 className="font-semibold text-sm">{title}</h4>
)}
<div className="text-sm">{children}</div>
</div>
{(actions || dismissible) && (
<div className="flex items-center gap-2 flex-shrink-0">
{actions}
{dismissible && (
<button
onClick={handleDismiss}
className="text-current hover:opacity-70 transition-opacity"
aria-label="Dismiss"
>
<X size={16} weight="bold" />
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -49,6 +49,15 @@ export { useAutoSave } from './use-auto-save'
export { useMultiSelect } from './use-multi-select'
export { useColumnVisibility } from './use-column-visibility'
export { useValidation } from './use-validation'
export { useBulkOperations } from './use-bulk-operations'
export { useOptimisticUpdate } from './use-optimistic-update'
export { usePolling } from './use-polling'
export { useVirtualScroll } from './use-virtual-scroll'
export { useQueue } from './use-queue'
export { useDragAndDrop } from './use-drag-and-drop'
export { useCache } from './use-cache'
export { useWebSocket } from './use-websocket'
export { useEventBus } from './use-event-bus'
export type { AsyncState } from './use-async'
export type { FormErrors } from './use-form-validation'
@@ -73,4 +82,12 @@ export type { DataGridColumn, DataGridOptions } from './use-data-grid'
export type { HotkeyConfig } from './use-hotkeys'
export type { ColumnConfig } from './use-column-visibility'
export type { ValidationRule, FieldConfig } from './use-validation'
export type { BulkOperationState, BulkOperationOptions } from './use-bulk-operations'
export type { PollingOptions } from './use-polling'
export type { VirtualScrollOptions } from './use-virtual-scroll'
export type { QueueItem, QueueOptions } from './use-queue'
export type { DragItem, DropZone, DragState } from './use-drag-and-drop'
export type { CacheOptions } from './use-cache'
export type { WebSocketOptions } from './use-websocket'
export type { EventBusEvent, EventHandler } from './use-event-bus'

View File

@@ -0,0 +1,151 @@
import { useState, useCallback } from 'react'
export interface BulkOperationState<T> {
selectedItems: Set<string>
isProcessing: boolean
progress: number
errors: Array<{ id: string; error: string }>
results: Array<{ id: string; success: boolean; data?: any }>
}
export interface BulkOperationOptions {
batchSize?: number
delayBetweenBatches?: number
continueOnError?: boolean
}
export function useBulkOperations<T = any>() {
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
const [isProcessing, setIsProcessing] = useState(false)
const [progress, setProgress] = useState(0)
const [errors, setErrors] = useState<Array<{ id: string; error: string }>>([])
const [results, setResults] = useState<Array<{ id: string; success: boolean; data?: any }>>([])
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const selectAll = useCallback((ids: string[]) => {
setSelectedItems(new Set(ids))
}, [])
const clearSelection = useCallback(() => {
setSelectedItems(new Set())
}, [])
const isSelected = useCallback((id: string) => {
return selectedItems.has(id)
}, [selectedItems])
const selectRange = useCallback((startId: string, endId: string, allIds: string[]) => {
const startIndex = allIds.indexOf(startId)
const endIndex = allIds.indexOf(endId)
if (startIndex === -1 || endIndex === -1) return
const [start, end] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex]
const rangeIds = allIds.slice(start, end + 1)
setSelectedItems(prev => {
const next = new Set(prev)
rangeIds.forEach(id => next.add(id))
return next
})
}, [])
const processBulk = useCallback(async <R = any>(
operation: (id: string) => Promise<R>,
options: BulkOperationOptions = {}
): Promise<void> => {
const {
batchSize = 5,
delayBetweenBatches = 100,
continueOnError = true
} = options
setIsProcessing(true)
setProgress(0)
setErrors([])
setResults([])
const itemIds = Array.from(selectedItems)
const totalItems = itemIds.length
let processed = 0
const newErrors: Array<{ id: string; error: string }> = []
const newResults: Array<{ id: string; success: boolean; data?: any }> = []
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
const batchPromises = batch.map(async (id) => {
try {
const result = await operation(id)
newResults.push({ id, success: true, data: result })
return { id, success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
newErrors.push({ id, error: errorMessage })
newResults.push({ id, success: false })
if (!continueOnError) {
throw error
}
return { id, success: false }
}
})
try {
await Promise.all(batchPromises)
} catch (error) {
if (!continueOnError) {
setIsProcessing(false)
setErrors(newErrors)
setResults(newResults)
throw error
}
}
processed += batch.length
setProgress(Math.round((processed / totalItems) * 100))
setErrors([...newErrors])
setResults([...newResults])
if (i + batchSize < itemIds.length && delayBetweenBatches > 0) {
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches))
}
}
setIsProcessing(false)
setProgress(100)
}, [selectedItems])
const reset = useCallback(() => {
setSelectedItems(new Set())
setIsProcessing(false)
setProgress(0)
setErrors([])
setResults([])
}, [])
return {
selectedItems: Array.from(selectedItems),
selectedCount: selectedItems.size,
isProcessing,
progress,
errors,
results,
toggleSelection,
selectAll,
clearSelection,
isSelected,
selectRange,
processBulk,
reset
}
}

183
src/hooks/use-cache.ts Normal file
View File

@@ -0,0 +1,183 @@
import { useState, useCallback, useEffect } from 'react'
export interface CacheOptions<T> {
ttl?: number
maxSize?: number
serialize?: (data: T) => string
deserialize?: (data: string) => T
}
interface CacheEntry<T> {
data: T
timestamp: number
}
export function useCache<T>(options: CacheOptions<T> = {}) {
const {
ttl = 5 * 60 * 1000,
maxSize = 100,
serialize = JSON.stringify,
deserialize = JSON.parse
} = options
const [cache, setCache] = useState<Map<string, CacheEntry<T>>>(new Map())
const [hits, setHits] = useState(0)
const [misses, setMisses] = useState(0)
const isExpired = useCallback((entry: CacheEntry<T>): boolean => {
return Date.now() - entry.timestamp > ttl
}, [ttl])
const get = useCallback((key: string): T | undefined => {
const entry = cache.get(key)
if (!entry) {
setMisses(prev => prev + 1)
return undefined
}
if (isExpired(entry)) {
setCache(prev => {
const next = new Map(prev)
next.delete(key)
return next
})
setMisses(prev => prev + 1)
return undefined
}
setHits(prev => prev + 1)
return entry.data
}, [cache, isExpired])
const set = useCallback((key: string, data: T) => {
setCache(prev => {
const next = new Map(prev)
if (next.size >= maxSize && !next.has(key)) {
const oldestKey = Array.from(next.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp)[0]?.[0]
if (oldestKey) {
next.delete(oldestKey)
}
}
next.set(key, {
data,
timestamp: Date.now()
})
return next
})
}, [maxSize])
const remove = useCallback((key: string) => {
setCache(prev => {
const next = new Map(prev)
next.delete(key)
return next
})
}, [])
const clear = useCallback(() => {
setCache(new Map())
setHits(0)
setMisses(0)
}, [])
const has = useCallback((key: string): boolean => {
const entry = cache.get(key)
if (!entry) return false
if (isExpired(entry)) {
remove(key)
return false
}
return true
}, [cache, isExpired, remove])
const prune = useCallback(() => {
setCache(prev => {
const next = new Map(prev)
const now = Date.now()
for (const [key, entry] of next.entries()) {
if (now - entry.timestamp > ttl) {
next.delete(key)
}
}
return next
})
}, [ttl])
useEffect(() => {
const interval = setInterval(prune, ttl)
return () => clearInterval(interval)
}, [prune, ttl])
const getOrSet = useCallback(async (
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
const cached = get(key)
if (cached !== undefined) {
return cached
}
const data = await fetcher()
set(key, data)
return data
}, [get, set])
const exportCache = useCallback((): string => {
const entries = Array.from(cache.entries()).map(([key, entry]) => ({
key,
data: serialize(entry.data),
timestamp: entry.timestamp
}))
return JSON.stringify(entries)
}, [cache, serialize])
const importCache = useCallback((exported: string) => {
try {
const entries = JSON.parse(exported) as Array<{
key: string
data: string
timestamp: number
}>
const newCache = new Map<string, CacheEntry<T>>()
const now = Date.now()
entries.forEach(({ key, data, timestamp }) => {
if (now - timestamp <= ttl) {
newCache.set(key, {
data: deserialize(data),
timestamp
})
}
})
setCache(newCache)
} catch (error) {
console.error('Failed to import cache:', error)
}
}, [ttl, deserialize])
return {
get,
set,
remove,
clear,
has,
prune,
getOrSet,
size: cache.size,
hits,
misses,
hitRate: hits + misses > 0 ? hits / (hits + misses) : 0,
exportCache,
importCache
}
}

View File

@@ -0,0 +1,123 @@
import { useState, useCallback, useRef } from 'react'
export interface DragItem<T = any> {
id: string
data: T
type?: string
}
export interface DropZone {
id: string
accepts?: string[]
}
export interface DragState<T = any> {
isDragging: boolean
draggedItem: DragItem<T> | null
draggedOver: string | null
}
export function useDragAndDrop<T = any>() {
const [dragState, setDragState] = useState<DragState<T>>({
isDragging: false,
draggedItem: null,
draggedOver: null
})
const dragImageRef = useRef<HTMLElement | null>(null)
const startDrag = useCallback((item: DragItem<T>, event?: React.DragEvent) => {
setDragState({
isDragging: true,
draggedItem: item,
draggedOver: null
})
if (event && dragImageRef.current) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setDragImage(dragImageRef.current, 0, 0)
}
}, [])
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
draggedItem: null,
draggedOver: null
})
}, [])
const dragOver = useCallback((zoneId: string) => {
setDragState(prev => ({
...prev,
draggedOver: zoneId
}))
}, [])
const dragLeave = useCallback(() => {
setDragState(prev => ({
...prev,
draggedOver: null
}))
}, [])
const canDrop = useCallback((zone: DropZone): boolean => {
if (!dragState.draggedItem) return false
if (!zone.accepts || zone.accepts.length === 0) return true
return zone.accepts.includes(dragState.draggedItem.type || '')
}, [dragState.draggedItem])
const getDragHandlers = useCallback((item: DragItem<T>) => ({
draggable: true,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'move'
startDrag(item, e)
},
onDragEnd: () => {
endDrag()
}
}), [startDrag, endDrag])
const getDropHandlers = useCallback((zone: DropZone, onDrop: (item: DragItem<T>) => void) => ({
onDragOver: (e: React.DragEvent) => {
if (canDrop(zone)) {
e.preventDefault()
dragOver(zone.id)
}
},
onDragEnter: (e: React.DragEvent) => {
if (canDrop(zone)) {
e.preventDefault()
dragOver(zone.id)
}
},
onDragLeave: () => {
dragLeave()
},
onDrop: (e: React.DragEvent) => {
e.preventDefault()
if (dragState.draggedItem && canDrop(zone)) {
onDrop(dragState.draggedItem)
}
endDrag()
}
}), [dragState.draggedItem, canDrop, dragOver, dragLeave, endDrag])
const setDragImage = useCallback((element: HTMLElement | null) => {
dragImageRef.current = element
}, [])
const reset = useCallback(() => {
endDrag()
}, [endDrag])
return {
...dragState,
getDragHandlers,
getDropHandlers,
setDragImage,
canDrop,
reset
}
}

View File

@@ -0,0 +1,99 @@
import { useState, useCallback, useRef, useEffect } from 'react'
export interface EventBusEvent {
type: string
payload?: any
timestamp: number
}
export type EventHandler<T = any> = (payload: T) => void
export function useEventBus() {
const listenersRef = useRef<Map<string, Set<EventHandler>>>(new Map())
const [events, setEvents] = useState<EventBusEvent[]>([])
const emit = useCallback(<T = any>(type: string, payload?: T) => {
const event: EventBusEvent = {
type,
payload,
timestamp: Date.now()
}
setEvents(prev => [...prev, event])
const listeners = listenersRef.current.get(type)
if (listeners) {
listeners.forEach(handler => {
try {
handler(payload)
} catch (error) {
console.error(`Error in event handler for ${type}:`, error)
}
})
}
}, [])
const on = useCallback(<T = any>(type: string, handler: EventHandler<T>) => {
if (!listenersRef.current.has(type)) {
listenersRef.current.set(type, new Set())
}
listenersRef.current.get(type)!.add(handler as EventHandler)
return () => {
const listeners = listenersRef.current.get(type)
if (listeners) {
listeners.delete(handler as EventHandler)
if (listeners.size === 0) {
listenersRef.current.delete(type)
}
}
}
}, [])
const off = useCallback((type: string, handler?: EventHandler) => {
if (!handler) {
listenersRef.current.delete(type)
return
}
const listeners = listenersRef.current.get(type)
if (listeners) {
listeners.delete(handler)
if (listeners.size === 0) {
listenersRef.current.delete(type)
}
}
}, [])
const once = useCallback(<T = any>(type: string, handler: EventHandler<T>) => {
const wrappedHandler: EventHandler<T> = (payload) => {
handler(payload)
off(type, wrappedHandler as EventHandler)
}
return on(type, wrappedHandler)
}, [on, off])
const clear = useCallback(() => {
listenersRef.current.clear()
setEvents([])
}, [])
const getListenerCount = useCallback((type?: string): number => {
if (type) {
return listenersRef.current.get(type)?.size || 0
}
return Array.from(listenersRef.current.values())
.reduce((sum, listeners) => sum + listeners.size, 0)
}, [])
return {
emit,
on,
off,
once,
clear,
events,
getListenerCount,
eventTypes: Array.from(listenersRef.current.keys())
}
}

View File

@@ -0,0 +1,117 @@
import { useState, useCallback, useRef } from 'react'
interface OptimisticUpdate<T> {
id: string
previousValue: T
newValue: T
timestamp: number
}
export function useOptimisticUpdate<T>() {
const [pendingUpdates, setPendingUpdates] = useState<Map<string, OptimisticUpdate<T>>>(new Map())
const rollbackTimers = useRef<Map<string, NodeJS.Timeout>>(new Map())
const applyOptimistic = useCallback((id: string, previousValue: T, newValue: T) => {
setPendingUpdates(prev => {
const next = new Map(prev)
next.set(id, {
id,
previousValue,
newValue,
timestamp: Date.now()
})
return next
})
}, [])
const commitUpdate = useCallback((id: string) => {
setPendingUpdates(prev => {
const next = new Map(prev)
next.delete(id)
return next
})
const timer = rollbackTimers.current.get(id)
if (timer) {
clearTimeout(timer)
rollbackTimers.current.delete(id)
}
}, [])
const rollbackUpdate = useCallback((id: string) => {
const update = pendingUpdates.get(id)
if (!update) return null
setPendingUpdates(prev => {
const next = new Map(prev)
next.delete(id)
return next
})
const timer = rollbackTimers.current.get(id)
if (timer) {
clearTimeout(timer)
rollbackTimers.current.delete(id)
}
return update.previousValue
}, [pendingUpdates])
const executeOptimistic = useCallback(async <R = void>(
id: string,
previousValue: T,
newValue: T,
operation: () => Promise<R>,
options: { timeout?: number; onSuccess?: (result: R) => void; onError?: (error: Error) => void } = {}
): Promise<R | null> => {
const { timeout = 30000, onSuccess, onError } = options
applyOptimistic(id, previousValue, newValue)
const timeoutTimer = setTimeout(() => {
rollbackUpdate(id)
onError?.(new Error('Operation timed out'))
}, timeout)
rollbackTimers.current.set(id, timeoutTimer)
try {
const result = await operation()
clearTimeout(timeoutTimer)
commitUpdate(id)
onSuccess?.(result)
return result
} catch (error) {
clearTimeout(timeoutTimer)
rollbackUpdate(id)
onError?.(error as Error)
return null
}
}, [applyOptimistic, commitUpdate, rollbackUpdate])
const getOptimisticValue = useCallback((id: string, currentValue: T): T => {
const update = pendingUpdates.get(id)
return update ? update.newValue : currentValue
}, [pendingUpdates])
const hasPendingUpdate = useCallback((id: string): boolean => {
return pendingUpdates.has(id)
}, [pendingUpdates])
const clearAll = useCallback(() => {
rollbackTimers.current.forEach(timer => clearTimeout(timer))
rollbackTimers.current.clear()
setPendingUpdates(new Map())
}, [])
return {
applyOptimistic,
commitUpdate,
rollbackUpdate,
executeOptimistic,
getOptimisticValue,
hasPendingUpdate,
pendingCount: pendingUpdates.size,
clearAll
}
}

124
src/hooks/use-polling.ts Normal file
View File

@@ -0,0 +1,124 @@
import { useState, useEffect, useRef, useCallback } from 'react'
export interface PollingOptions<T> {
interval: number
enabled?: boolean
onSuccess?: (data: T) => void
onError?: (error: Error) => void
maxRetries?: number
backoffMultiplier?: number
shouldRetry?: (error: Error, retryCount: number) => boolean
}
export function usePolling<T>(
fetchFn: () => Promise<T>,
options: PollingOptions<T>
) {
const {
interval,
enabled = true,
onSuccess,
onError,
maxRetries = 3,
backoffMultiplier = 1.5,
shouldRetry = () => true
} = options
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [isPolling, setIsPolling] = useState(enabled)
const [retryCount, setRetryCount] = useState(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const isMountedRef = useRef(true)
const poll = useCallback(async () => {
try {
const result = await fetchFn()
if (!isMountedRef.current) return
setData(result)
setError(null)
void setRetryCount(0)
onSuccess?.(result)
if (isPolling && isMountedRef.current) {
timerRef.current = setTimeout(() => { void poll() }, interval)
}
} catch (err) {
if (!isMountedRef.current) return
const error = err instanceof Error ? err : new Error('Polling failed')
setError(error)
onError?.(error)
const currentRetry = retryCount + 1
if (currentRetry < maxRetries && shouldRetry(error, currentRetry)) {
void setRetryCount(currentRetry)
const backoffDelay = interval * Math.pow(backoffMultiplier, currentRetry)
if (isPolling && isMountedRef.current) {
timerRef.current = setTimeout(() => { void poll() }, backoffDelay)
}
} else {
setIsPolling(false)
}
}
}, [fetchFn, interval, isPolling, retryCount, maxRetries, backoffMultiplier, shouldRetry, onSuccess, onError])
const start = useCallback(() => {
setIsPolling(true)
void setRetryCount(0)
void poll()
}, [poll])
const stop = useCallback(() => {
setIsPolling(false)
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}, [])
const reset = useCallback(() => {
stop()
setData(null)
setError(null)
setRetryCount(0)
}, [stop])
const refresh = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
await poll()
}, [poll])
useEffect(() => {
if (enabled && isPolling) {
const startPolling = async () => {
await poll()
}
void startPolling()
}
return () => {
isMountedRef.current = false
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [enabled, isPolling, poll])
return {
data,
error,
isPolling,
retryCount,
start,
stop,
reset,
refresh
}
}

207
src/hooks/use-queue.ts Normal file
View File

@@ -0,0 +1,207 @@
import { useState, useCallback, useRef, useEffect } from 'react'
export interface QueueItem<T> {
id: string
data: T
priority?: number
addedAt: number
startedAt?: number
completedAt?: number
status: 'pending' | 'processing' | 'completed' | 'failed'
error?: Error
retries: number
}
export interface QueueOptions {
concurrency?: number
maxRetries?: number
retryDelay?: number
autoStart?: boolean
}
export function useQueue<T, R = void>(
processor: (data: T) => Promise<R>,
options: QueueOptions = {}
) {
const {
concurrency = 1,
maxRetries = 3,
retryDelay = 1000,
autoStart = true
} = options
const [queue, setQueue] = useState<QueueItem<T>[]>([])
const [processing, setProcessing] = useState<QueueItem<T>[]>([])
const [completed, setCompleted] = useState<QueueItem<T>[]>([])
const [failed, setFailed] = useState<QueueItem<T>[]>([])
const [isRunning, setIsRunning] = useState(autoStart)
const processingRef = useRef<Set<string>>(new Set())
const mountedRef = useRef(true)
const enqueue = useCallback((data: T, priority: number = 0) => {
const item: QueueItem<T> = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data,
priority,
addedAt: Date.now(),
status: 'pending',
retries: 0
}
setQueue(prev => {
const next = [...prev, item]
next.sort((a, b) => (b.priority || 0) - (a.priority || 0))
return next
})
return item.id
}, [])
const processItem = useCallback(async (item: QueueItem<T>) => {
if (!mountedRef.current) return
processingRef.current.add(item.id)
setQueue(prev => prev.filter(i => i.id !== item.id))
setProcessing(prev => [...prev, { ...item, status: 'processing', startedAt: Date.now() }])
try {
await processor(item.data)
if (!mountedRef.current) return
const completedItem = { ...item, status: 'completed' as const, completedAt: Date.now() }
setProcessing(prev => prev.filter(i => i.id !== item.id))
setCompleted(prev => [...prev, completedItem])
} catch (error) {
if (!mountedRef.current) return
const err = error instanceof Error ? error : new Error('Processing failed')
if (item.retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, retryDelay))
if (!mountedRef.current) return
const retryItem = { ...item, retries: item.retries + 1 }
setProcessing(prev => prev.filter(i => i.id !== item.id))
setQueue(prev => {
const next = [...prev, retryItem]
next.sort((a, b) => (b.priority || 0) - (a.priority || 0))
return next
})
} else {
const failedItem = {
...item,
status: 'failed' as const,
completedAt: Date.now(),
error: err
}
setProcessing(prev => prev.filter(i => i.id !== item.id))
setFailed(prev => [...prev, failedItem])
}
} finally {
processingRef.current.delete(item.id)
}
}, [processor, maxRetries, retryDelay])
const processNext = useCallback(async () => {
if (!isRunning || processingRef.current.size >= concurrency) return
setQueue(prev => {
if (prev.length === 0) return prev
const nextItem = prev[0]
processItem(nextItem)
return prev
})
}, [isRunning, concurrency, processItem])
useEffect(() => {
if (!isRunning) return
const interval = setInterval(() => {
if (processingRef.current.size < concurrency && queue.length > 0) {
processNext()
}
}, 100)
return () => clearInterval(interval)
}, [isRunning, concurrency, queue.length, processNext])
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
const start = useCallback(() => {
setIsRunning(true)
}, [])
const pause = useCallback(() => {
setIsRunning(false)
}, [])
const clear = useCallback(() => {
setQueue([])
}, [])
const clearCompleted = useCallback(() => {
setCompleted([])
}, [])
const clearFailed = useCallback(() => {
setFailed([])
}, [])
const retryFailed = useCallback(() => {
setFailed(prev => {
const items = prev.map(item => ({
...item,
status: 'pending' as const,
retries: 0,
error: undefined
}))
setQueue(q => {
const next = [...q, ...items]
next.sort((a, b) => (b.priority || 0) - (a.priority || 0))
return next
})
return []
})
}, [])
const remove = useCallback((id: string) => {
setQueue(prev => prev.filter(item => item.id !== id))
}, [])
return {
queue,
processing,
completed,
failed,
isRunning,
stats: {
pending: queue.length,
processing: processing.length,
completed: completed.length,
failed: failed.length,
total: queue.length + processing.length + completed.length + failed.length
},
enqueue,
start,
pause,
clear,
clearCompleted,
clearFailed,
retryFailed,
remove
}
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
export interface VirtualScrollOptions {
itemHeight: number
overscan?: number
containerHeight?: number
}
export function useVirtualScroll<T>(
items: T[],
options: VirtualScrollOptions
) {
const {
itemHeight,
overscan = 3,
containerHeight = 600
} = options
const [scrollTop, setScrollTop] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const totalHeight = items.length * itemHeight
const visibleRange = useMemo(() => {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
)
return { startIndex, endIndex }
}, [scrollTop, itemHeight, containerHeight, items.length, overscan])
const visibleItems = useMemo(() => {
return items.slice(visibleRange.startIndex, visibleRange.endIndex).map((item, index) => ({
item,
index: visibleRange.startIndex + index,
offsetTop: (visibleRange.startIndex + index) * itemHeight
}))
}, [items, visibleRange, itemHeight])
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
setScrollTop(target.scrollTop)
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll])
const scrollToIndex = useCallback((index: number, align: 'start' | 'center' | 'end' = 'start') => {
const container = containerRef.current
if (!container) return
let scrollPosition: number
switch (align) {
case 'center':
scrollPosition = index * itemHeight - containerHeight / 2 + itemHeight / 2
break
case 'end':
scrollPosition = index * itemHeight - containerHeight + itemHeight
break
default:
scrollPosition = index * itemHeight
}
container.scrollTo({
top: Math.max(0, Math.min(scrollPosition, totalHeight - containerHeight)),
behavior: 'smooth'
})
}, [itemHeight, containerHeight, totalHeight])
const scrollToTop = useCallback(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}, [])
const scrollToBottom = useCallback(() => {
containerRef.current?.scrollTo({ top: totalHeight, behavior: 'smooth' })
}, [totalHeight])
return {
containerRef,
totalHeight,
visibleItems,
visibleRange,
scrollToIndex,
scrollToTop,
scrollToBottom,
scrollTop
}
}

138
src/hooks/use-websocket.ts Normal file
View File

@@ -0,0 +1,138 @@
import { useState, useCallback, useRef, useEffect } from 'react'
export interface WebSocketOptions {
reconnect?: boolean
reconnectAttempts?: number
reconnectInterval?: number
heartbeatInterval?: number
heartbeatMessage?: string
onOpen?: (event: Event) => void
onClose?: (event: CloseEvent) => void
onError?: (event: Event) => void
onMessage?: (event: MessageEvent) => void
}
export function useWebSocket(url: string | null, options: WebSocketOptions = {}) {
const {
reconnect = true,
reconnectAttempts = 5,
reconnectInterval = 3000,
heartbeatInterval = 30000,
heartbeatMessage = 'ping',
onOpen,
onClose,
onError,
onMessage
} = options
const [readyState, setReadyState] = useState<number>(WebSocket.CONNECTING)
const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null)
const [reconnectCount, setReconnectCount] = useState(0)
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
const mountedRef = useRef(true)
const connect = useCallback(() => {
if (!url || !mountedRef.current) return
try {
const ws = new WebSocket(url)
ws.onopen = (event) => {
setReadyState(WebSocket.OPEN)
void setReconnectCount(0)
onOpen?.(event)
if (heartbeatInterval > 0) {
heartbeatIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(heartbeatMessage)
}
}, heartbeatInterval)
}
}
ws.onclose = (event) => {
setReadyState(WebSocket.CLOSED)
onClose?.(event)
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current)
}
if (reconnect && reconnectCount < reconnectAttempts && mountedRef.current) {
reconnectTimeoutRef.current = setTimeout(() => {
void setReconnectCount(prev => prev + 1)
void connect()
}, reconnectInterval)
}
}
ws.onerror = (event) => {
setReadyState(WebSocket.CLOSED)
onError?.(event)
}
ws.onmessage = (event) => {
setLastMessage(event)
onMessage?.(event)
}
wsRef.current = ws
} catch (error) {
console.error('WebSocket connection error:', error)
}
}, [url, reconnect, reconnectAttempts, reconnectInterval, reconnectCount, heartbeatInterval, heartbeatMessage, onOpen, onClose, onError, onMessage])
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current)
}
}, [])
const send = useCallback((data: string | ArrayBuffer | Blob) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(data)
return true
}
return false
}, [])
const sendJson = useCallback((data: any) => {
return send(JSON.stringify(data))
}, [send])
useEffect(() => {
void connect()
return () => {
mountedRef.current = false
disconnect()
}
}, [connect, disconnect])
return {
readyState,
lastMessage,
send,
sendJson,
connect,
disconnect,
reconnectCount,
isConnecting: readyState === WebSocket.CONNECTING,
isOpen: readyState === WebSocket.OPEN,
isClosing: readyState === WebSocket.CLOSING,
isClosed: readyState === WebSocket.CLOSED
}
}