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

This commit is contained in:
2026-01-23 05:57:45 +00:00
committed by GitHub
parent 4c2cb4b1ce
commit 70b204e83e
26 changed files with 1626 additions and 1074 deletions

View File

@@ -6,70 +6,107 @@ A comprehensive custom hook library and extended UI component collection has bee
## What Was Built
### 🎣 Custom Hooks Library (22 Hooks)
### 🎣 Custom Hooks Library (32 Hooks)
#### State Management
#### State Management (7 hooks)
1. **useToggle** - Boolean state management with toggle function
2. **usePrevious** - Access previous value of any state
3. **useLocalStorage** - Persist state in browser localStorage
4. **useArray** - Advanced array manipulation (push, filter, update, remove, move, swap)
5. **useMap** - Map data structure with reactive updates
6. **useSet** - Set data structure with reactive updates
7. **useUndo** - Undo/redo functionality with history management
#### Async & Performance
4. **useAsync** - Async operation handling with loading/error states
5. **useDebounce** - Delay rapid value changes (search optimization)
6. **useThrottle** - Limit function execution frequency
#### Async & Performance (3 hooks)
8. **useAsync** - Async operation handling with loading/error states
9. **useDebounce** - Delay rapid value changes (search optimization)
10. **useThrottle** - Limit function execution frequency
#### UI & Interaction
7. **useMediaQuery** - Responsive breakpoint detection
8. **useIsMobile** - Mobile device detection (existing, documented)
9. **useWindowSize** - Window dimension tracking
10. **useScrollPosition** - Scroll position monitoring
11. **useOnClickOutside** - Outside click detection for dropdowns/modals
12. **useIntersectionObserver** - Element visibility detection (lazy loading)
13. **useKeyboardShortcut** - Global keyboard shortcut handling
14. **useIdleTimer** - User idle state detection
15. **useCopyToClipboard** - Copy text to clipboard with feedback
#### UI & Interaction (10 hooks)
11. **useMediaQuery** - Responsive breakpoint detection
12. **useIsMobile** - Mobile device detection
13. **useWindowSize** - Window dimension tracking
14. **useScrollPosition** - Scroll position monitoring
15. **useOnClickOutside** - Outside click detection for dropdowns/modals
16. **useIntersectionObserver** - Element visibility detection (lazy loading)
17. **useKeyboardShortcut** - Global keyboard shortcut handling
18. **useIdleTimer** - User idle state detection
19. **useDisclosure** - Open/close state management for modals/drawers
20. **useFocusTrap** - Focus management within elements
#### Data Management
16. **useFilter** - Array filtering with automatic debouncing
17. **useSort** - Array sorting with direction control
18. **usePagination** - Complete pagination logic with navigation
19. **useSelection** - Multi-item selection with bulk operations
#### Data Management (5 hooks)
21. **useFilter** - Array filtering with automatic debouncing
22. **useSort** - Array sorting with direction control
23. **usePagination** - Complete pagination logic with navigation
24. **useSelection** - Multi-item selection with bulk operations
25. **useTable** - Complete data table state management
#### Forms & Workflows
20. **useFormValidation** - Form validation with error handling
21. **useWizard** - Multi-step form/wizard state management
#### Forms & Workflows (5 hooks)
26. **useFormValidation** - Form validation with error handling
27. **useFormState** - Form state management
28. **useWizard** - Multi-step form/wizard state management
29. **useSteps** - Step-by-step navigation
30. **useMultiStepForm** - Multi-step form with validation
#### Application-Specific
22. **useNotifications** - Notification system (existing, documented)
#### Utilities (7 hooks)
31. **useCopyToClipboard** - Copy text to clipboard with feedback
32. **useClipboard** - Enhanced clipboard operations
33. **useConfirmation** - Confirmation dialog state management
34. **useDownload** - File download utilities (CSV, JSON, TXT)
35. **useQueryParams** - URL query parameter management
36. **useInterval** - Interval timer with controls
37. **useCountdown** - Countdown timer with start/pause/reset
38. **useTimeout** - Timeout with cleanup
### 🎨 Extended UI Components (17 New Components)
#### Application-Specific (2 hooks)
39. **useNotifications** - Notification system
40. **useSampleData** - Sample data generation
#### Display Components
### 🎨 Extended UI Components (27 New Components)
#### Display Components (7)
1. **EmptyState** - Empty state placeholder with icon, title, description, action
2. **StatusBadge** - Status indicator with 6 variants (success, error, warning, info, pending, neutral)
3. **StatCard** - Metric display card with optional trend indicator and icon
4. **DataList** - Key-value pair display (vertical/horizontal orientations)
5. **Timeline** - Chronological event timeline with completion states
2. **StatusBadge** - Status indicator with 6 variants
3. **StatCard** - Metric display card with trend indicator
4. **MetricCard** - Enhanced metric card with icon and change tracking
5. **DataList** - Key-value pair display
6. **Timeline** - Chronological event timeline
7. **Tag** - Tag/label component with variants and removable option
#### Input Components
6. **SearchInput** - Search field with clear button and debounce support
7. **FileUpload** - Drag-and-drop file upload with validation
#### Layout Components (4)
8. **Grid** - Responsive grid layout with GridItem
9. **Stack** - Flexible stack layout (horizontal/vertical)
10. **Section** - Page section with header and action area
11. **PageHeader** - Page header with title, description, breadcrumbs, actions
#### Navigation Components
8. **Stepper** - Multi-step progress indicator with click navigation
#### Input Components (2)
12. **SearchInput** - Search field with clear button
13. **FileUpload** - Drag-and-drop file upload
#### Feedback Components
9. **LoadingSpinner** - Animated spinner (sm, md, lg, xl sizes)
10. **LoadingOverlay** - Full overlay loading state with optional text
11. **InfoBox** - Contextual information box (info, warning, success, error variants)
#### Navigation Components (3)
14. **Stepper** - Multi-step progress indicator
15. **QuickPagination** - Simplified pagination controls
16. **FilterBar** - Active filter display with remove actions
#### Utility Components
12. **Chip** - Tag/chip component with remove capability
13. **CopyButton** - Copy-to-clipboard button with success feedback
14. **CodeBlock** - Code display block with language indicator
15. **Divider** - Section divider (horizontal/vertical with optional label)
16. **Kbd** - Keyboard shortcut display (e.g., Ctrl+K)
17. **SortableHeader** - Table header with sort direction indicators
#### Feedback Components (3)
17. **LoadingSpinner** - Animated spinner
18. **LoadingOverlay** - Full overlay loading state
19. **InfoBox** - Contextual information box
#### Utility Components (5)
20. **Chip** - Tag/chip component with remove
21. **CopyButton** - Copy-to-clipboard button
22. **CodeBlock** - Code display block
23. **Divider** - Section divider
24. **Kbd** - Keyboard shortcut display
25. **SortableHeader** - Table header with sort indicators
#### Dialog/Modal Components (2)
26. **Modal** - Customizable modal dialog
27. **ConfirmModal** - Confirmation dialog
#### Complex Components (1)
28. **DataTable** - Full-featured data table with sorting, filtering, selection, pagination
### 📚 Documentation
@@ -80,13 +117,7 @@ A comprehensive custom hook library and extended UI component collection has bee
### 🎯 Live Demonstration
**ComponentShowcase** - Interactive demonstration page accessible via sidebar showing:
- All new hooks in action (debounce, pagination, selection, wizard)
- All new UI components with variants
- Real-world usage patterns
- Integration examples
Access via: **Navigation Menu → Component Library**
**ComponentShowcase** - Interactive demonstration page accessible via sidebar
## Key Features
@@ -111,236 +142,75 @@ Access via: **Navigation Menu → Component Library**
- Screen reader friendly
### Composability
Hooks designed to work together:
```tsx
// Example: Full-featured data table
const debouncedSearch = useDebounce(searchQuery, 300)
const filtered = useFilter(items, debouncedSearch, filterFn)
const sorted = useSort(filtered, sortKey, sortDirection)
const { paginatedItems, ...pagination } = usePagination(sorted, 10)
const { selectedIds, ...selection } = useSelection(paginatedItems)
```
## Usage Examples
### Quick Search with Debouncing
```tsx
import { useDebounce } from '@/hooks'
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
useEffect(() => {
fetchResults(debouncedSearch)
}, [debouncedSearch])
```
### Data Table with Pagination
```tsx
import { usePagination, SearchInput, EmptyState } from '@/hooks'
const { paginatedItems, currentPage, totalPages, nextPage, previousPage } =
usePagination(items, 10)
return (
<div>
<SearchInput value={search} onChange={e => setSearch(e.target.value)} />
{paginatedItems.length === 0 ? (
<EmptyState title="No results" />
) : (
<Table items={paginatedItems} />
)}
</div>
)
```
### Multi-Step Wizard
```tsx
import { useWizard, Stepper } from '@/hooks'
const steps = [
{ id: '1', title: 'Personal Info' },
{ id: '2', title: 'Review' },
{ id: '3', title: 'Complete' }
]
const { currentStep, goToNextStep, isLastStep } = useWizard(steps)
return (
<div>
<Stepper steps={steps} currentStep={currentStepIndex} />
{/* Step content */}
<Button onClick={goToNextStep} disabled={isLastStep}>
{isLastStep ? 'Complete' : 'Next'}
</Button>
</div>
)
```
### Status Display
```tsx
import { StatusBadge } from '@/components/ui/status-badge'
<StatusBadge status="success" label="Approved" />
<StatusBadge status="pending" label="Under Review" />
<StatusBadge status="error" label="Rejected" />
```
### Form Validation
```tsx
import { useFormValidation } from '@/hooks'
const { values, errors, handleChange, validateAll } = useFormValidation(
{ email: '', password: '' },
{
email: val => !val.includes('@') ? 'Invalid email' : undefined,
password: val => val.length < 8 ? 'Too short' : undefined
}
)
```
## Integration with Existing Code
All hooks and components are:
- ✅ Compatible with existing codebase
- ✅ Follow established patterns
- ✅ Use existing theme variables
- ✅ Work with shadcn components
- ✅ Support Tailwind styling
## File Structure
```
src/
├── hooks/
│ ├── index.ts # Hook exports
│ ├── README.md # Hook documentation
│ ├── use-async.ts
│ ├── use-copy-to-clipboard.ts
│ ├── use-debounce.ts
│ ├── use-filter.ts
│ ├── use-form-validation.ts
│ ├── use-idle-timer.ts
│ ├── use-intersection-observer.ts
│ ├── use-keyboard-shortcut.ts
│ ├── use-local-storage.ts
│ ├── use-media-query.ts
│ ├── use-mobile.ts # Existing
│ ├── use-notifications.ts # Existing
│ ├── use-on-click-outside.ts
│ ├── use-pagination.ts
│ ├── use-previous.ts
│ ├── use-sample-data.ts # Existing
│ ├── use-scroll-position.ts
│ ├── use-selection.ts
│ ├── use-sort.ts
│ ├── use-throttle.ts
│ ├── use-toggle.ts
│ ├── use-window-size.ts
│ └── use-wizard.ts
├── components/
│ ├── ComponentShowcase.tsx # Live demo
│ └── ui/
│ ├── README.md # Component docs
│ ├── chip.tsx
│ ├── code-block.tsx
│ ├── copy-button.tsx
│ ├── data-list.tsx
│ ├── divider.tsx
│ ├── empty-state.tsx
│ ├── file-upload.tsx
│ ├── info-box.tsx
│ ├── kbd.tsx
│ ├── loading-overlay.tsx
│ ├── loading-spinner.tsx
│ ├── search-input.tsx
│ ├── sortable-header.tsx
│ ├── stat-card.tsx
│ ├── status-badge.tsx
│ ├── stepper.tsx
│ └── timeline.tsx
└── COMPONENT_LIBRARY.md # This file
```
## Benefits
### For Developers
- 🚀 Faster feature development
- 🔄 Reusable logic and UI patterns
- 📝 Less boilerplate code
- 🎯 Consistent behavior across app
- 📚 Comprehensive documentation
### For Users
- ⚡ Better performance (debouncing, throttling)
- 🎨 Consistent UI/UX
- ♿ Improved accessibility
- 📱 Responsive design
- ⌨️ Keyboard shortcuts
### For Codebase
- 📦 Modular architecture
- 🧪 Easier testing
- 🛠️ Maintainable code
- 📈 Scalable patterns
- 🎨 Themeable components
## Next Steps
### Recommended Usage
1. Browse ComponentShowcase for live examples
2. Check hook/component READMEs for detailed docs
3. Import and use in your components
4. Extend/customize as needed
### Future Enhancements
- Add unit tests for all hooks
- Add Storybook for component documentation
- Create more specialized hooks (useAPI, useWebSocket, etc.)
- Add more complex components (DataGrid, Calendar, etc.)
- Performance benchmarking
Hooks designed to work together for complex features like data tables with full filtering, sorting, pagination, and selection.
## Total Additions
- **22 Custom Hooks** (20 new, 2 documented existing)
- **17 New UI Components**
- **40 Custom Hooks** (30+ new, 2 documented existing)
- **27 New UI Components**
- **4 Documentation Files**
- **1 Interactive Showcase**
- **63 Total UI Components** (17 new + 46 existing shadcn)
- **73+ Total UI Components** (27 new + 46 existing shadcn)
## Import Reference
```tsx
// Hooks - all from single import
import {
useArray,
useAsync,
useClipboard,
useConfirmation,
useCopyToClipboard,
useCountdown,
useDebounce,
useDisclosure,
useDownload,
useFilter,
useFocusTrap,
useFormState,
useFormValidation,
useIdleTimer,
useIntersectionObserver,
useInterval,
useKeyboardShortcut,
useLocalStorage,
useMap,
useMediaQuery,
useIsMobile,
useMultiStepForm,
useNotifications,
useOnClickOutside,
usePagination,
usePrevious,
useQueryParams,
useSampleData,
useScrollPosition,
useSelection,
useSet,
useSort,
useSteps,
useTable,
useThrottle,
useTimeout,
useToggle,
useUndo,
useWindowSize,
useWizard
} from '@/hooks'
// UI Components - individual imports
import { DataTable } from '@/components/ui/data-table'
import { EmptyState } from '@/components/ui/empty-state'
import { FilterBar } from '@/components/ui/filter-bar'
import { Grid, GridItem } from '@/components/ui/grid'
import { MetricCard } from '@/components/ui/metric-card'
import { Modal, ConfirmModal } from '@/components/ui/modal'
import { PageHeader } from '@/components/ui/page-header'
import { QuickPagination } from '@/components/ui/quick-pagination'
import { Section } from '@/components/ui/section'
import { Stack } from '@/components/ui/stack'
import { StatusBadge } from '@/components/ui/status-badge'
import { SearchInput } from '@/components/ui/search-input'
import { Tag } from '@/components/ui/tag'
// ... etc
```

View File

@@ -365,6 +365,196 @@ Table header with sort indicators.
## Component Props
### New Advanced Components
#### Grid & GridItem
Responsive grid layout system.
```tsx
<Grid cols={3} gap={4} responsive>
<GridItem colSpan={2}>Main content</GridItem>
<GridItem>Sidebar</GridItem>
<GridItem colSpan="full">Footer</GridItem>
</Grid>
```
#### Stack
Flexible stack layout (horizontal/vertical).
```tsx
<Stack direction="horizontal" spacing={4} align="center" justify="between">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</Stack>
```
#### Section
Page section with header and action area.
```tsx
<Section
title="Timesheets"
description="Manage worker timesheets"
action={<Button>Create New</Button>}
>
<TimesheetList />
</Section>
```
#### PageHeader
Full-featured page header.
```tsx
<PageHeader
title="Dashboard"
description="Overview of your workforce operations"
breadcrumbs={<Breadcrumb items={breadcrumbItems} />}
backButton={<Button variant="ghost" size="sm"><ArrowLeft /></Button>}
actions={
<>
<Button variant="outline">Export</Button>
<Button>Create New</Button>
</>
}
/>
```
#### MetricCard (Enhanced)
Metric card with change tracking.
```tsx
<MetricCard
label="Active Workers"
value={1234}
change={{ value: 12, trend: 'up' }}
icon={<Users size={20} />}
description="Compared to last month"
loading={false}
/>
```
#### FilterBar (Enhanced)
Active filter display with removal.
```tsx
<FilterBar
activeFilters={[
{ key: 'status', label: 'Status', value: 'Active' },
{ key: 'date', label: 'Date Range', value: 'Last 30 days' }
]}
onRemoveFilter={(key) => removeFilter(key)}
onClearAll={() => clearFilters()}
onOpenFilters={() => setShowFilters(true)}
showFilterButton
/>
```
#### QuickPagination (Enhanced)
Pagination with item count display.
```tsx
<QuickPagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
itemsPerPage={10}
totalItems={totalItems}
showInfo
/>
```
#### Modal & ConfirmModal
Dialog components with proper structure.
```tsx
<Modal
open={isOpen}
onOpenChange={setIsOpen}
title="Edit Timesheet"
description="Update timesheet details"
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</>
}
>
<TimesheetForm />
</Modal>
<ConfirmModal
open={isConfirmOpen}
onOpenChange={setIsConfirmOpen}
title="Delete Timesheet"
description="This action cannot be undone."
confirmLabel="Delete"
cancelLabel="Cancel"
onConfirm={handleDelete}
variant="destructive"
loading={isDeleting}
/>
```
#### Tag
Enhanced tag component with variants.
```tsx
<Tag variant="primary" size="md">Featured</Tag>
<Tag variant="success" onRemove={() => removeTag('active')}>Active</Tag>
<Tag variant="destructive" size="sm">Urgent</Tag>
```
#### DataTable (Full-Featured)
Complete data table with all features.
```tsx
<DataTable
data={items}
columns={[
{
key: 'name',
header: 'Name',
sortable: true,
accessor: (item) => item.name
},
{
key: 'status',
header: 'Status',
render: (item) => <StatusBadge status={item.status} />
},
{
key: 'actions',
header: 'Actions',
render: (item) => (
<Button size="sm" onClick={() => edit(item)}>Edit</Button>
)
}
]}
loading={isLoading}
emptyMessage="No data found"
emptyIcon={<FileX size={48} />}
onSort={(key, direction) => handleSort(key, direction)}
sortKey={sortKey}
sortDirection={sortDirection}
selectable
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
getRowId={(item) => item.id}
onRowClick={(item) => viewDetails(item)}
pagination={{
currentPage: page,
totalPages: totalPages,
onPageChange: setPage,
itemsPerPage: 10,
totalItems: totalItems
}}
/>
```
## Component Props
All components support standard HTML attributes and can be styled using Tailwind classes via the `className` prop.
## Accessibility
@@ -374,3 +564,13 @@ All components are built with accessibility in mind:
- ARIA labels where appropriate
- Keyboard navigation support
- Focus management
## Usage Tips
1. **Layout Components**: Use `Grid`, `Stack`, and `Section` for consistent layouts
2. **Data Display**: Combine `DataTable` with `FilterBar` and `QuickPagination` for full-featured tables
3. **Metrics**: Use `MetricCard` for dashboard KPIs
4. **Navigation**: Use `PageHeader` with breadcrumbs and actions on all main pages
5. **Modals**: Use `ConfirmModal` for destructive actions, `Modal` for forms
6. **Tags**: Use `Tag` component for filters, categories, and status indicators
7. **Feedback**: Always provide `EmptyState` when data is unavailable

View File

@@ -1,77 +1,205 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './table'
import { Button } from './button'
import { EmptyState } from './empty-state'
import { LoadingSpinner } from './loading-spinner'
import { QuickPagination } from './quick-pagination'
import { SortableHeader } from './sortable-header'
import { Checkbox } from './checkbox'
export interface DataTableProps<T> extends React.HTMLAttributes<HTMLDivElement> {
columns: Array<{
key: keyof T
header: string
width?: string
sortable?: boolean
render?: (value: any, row: T) => React.ReactNode
}>
data: T[]
onRowClick?: (row: T) => void
emptyMessage?: string
export interface Column<T> {
key: string
header: string
sortable?: boolean
render?: (item: T) => React.ReactNode
accessor?: (item: T) => any
className?: string
headerClassName?: string
}
export function DataTable<T extends Record<string, any>>({
columns,
export interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
loading?: boolean
emptyMessage?: string
emptyIcon?: React.ReactNode
onSort?: (key: string, direction: 'asc' | 'desc') => void
sortKey?: string
sortDirection?: 'asc' | 'desc'
selectable?: boolean
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
getRowId?: (item: T) => string
onRowClick?: (item: T) => void
pagination?: {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
itemsPerPage?: number
totalItems?: number
}
className?: string
}
function DataTable<T>({
data,
onRowClick,
columns,
loading = false,
emptyMessage = 'No data available',
className,
...props
emptyIcon,
onSort,
sortKey,
sortDirection,
selectable = false,
selectedIds = [],
onSelectionChange,
getRowId,
onRowClick,
pagination,
className
}: DataTableProps<T>) {
return (
<div className={cn('rounded-md border border-border overflow-hidden', className)} {...props}>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
{columns.map((column) => (
<th
key={String(column.key)}
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"
style={{ width: column.width }}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-sm text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr
key={rowIndex}
onClick={() => onRowClick?.(row)}
className={cn(
'bg-card hover:bg-muted/50 transition-colors',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((column) => (
<td key={String(column.key)} className="px-4 py-3 text-sm">
{column.render
? column.render(row[column.key], row)
: String(row[column.key] ?? '')}
</td>
))}
</tr>
))
)}
</tbody>
</table>
const allSelected = data.length > 0 && selectedIds.length === data.length
const someSelected = selectedIds.length > 0 && selectedIds.length < data.length
const handleSelectAll = () => {
if (!onSelectionChange || !getRowId) return
if (allSelected) {
onSelectionChange([])
} else {
onSelectionChange(data.map(getRowId))
}
}
const handleSelectRow = (id: string) => {
if (!onSelectionChange) return
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter(selectedId => selectedId !== id))
} else {
onSelectionChange([...selectedIds, id])
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
)
}
if (data.length === 0) {
return (
<EmptyState
icon={emptyIcon}
title={emptyMessage}
description="Try adjusting your search or filters"
/>
)
}
return (
<div className={cn('space-y-4', className)}>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{selectable && (
<TableHead className="w-12">
<Checkbox
checked={allSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all"
className={someSelected ? 'opacity-50' : ''}
/>
</TableHead>
)}
{columns.map((column) => (
<TableHead
key={column.key}
className={cn(column.headerClassName)}
>
{column.sortable && onSort ? (
<SortableHeader
label={column.header}
active={sortKey === column.key}
direction={sortKey === column.key ? sortDirection : undefined}
onClick={() => {
const newDirection = sortKey === column.key && sortDirection === 'asc' ? 'desc' : 'asc'
onSort(column.key, newDirection)
}}
/>
) : (
column.header
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => {
const rowId = getRowId ? getRowId(item) : String(index)
const isSelected = selectedIds.includes(rowId)
return (
<TableRow
key={rowId}
className={cn(
onRowClick && 'cursor-pointer',
isSelected && 'bg-muted/50'
)}
onClick={() => onRowClick?.(item)}
>
{selectable && (
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleSelectRow(rowId)}
aria-label={`Select row ${rowId}`}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
{columns.map((column) => (
<TableCell
key={column.key}
className={cn(column.className)}
>
{column.render
? column.render(item)
: column.accessor
? column.accessor(item)
: (item as any)[column.key]}
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{pagination && (
<QuickPagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
itemsPerPage={pagination.itemsPerPage}
totalItems={pagination.totalItems}
showInfo
/>
)}
</div>
)
}
export { DataTable }

View File

@@ -1,38 +1,95 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import { Funnel, X } from '@phosphor-icons/react'
import { Badge } from './badge'
export interface FilterOption {
key: string
label: string
value: any
}
export interface FilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
activeFilters: FilterOption[]
onRemoveFilter: (key: string) => void
onClearAll: () => void
onOpenFilters?: () => void
showFilterButton?: boolean
}
export function FilterBar({ children, className, ...props }: FilterBarProps) {
return (
<div
className={cn(
'flex flex-wrap items-center gap-3 p-4 bg-muted/50 rounded-lg border border-border',
className
)}
{...props}
>
{children}
</div>
)
}
const FilterBar = React.forwardRef<HTMLDivElement, FilterBarProps>(
({
className,
activeFilters,
onRemoveFilter,
onClearAll,
onOpenFilters,
showFilterButton = true,
...props
}, ref) => {
if (activeFilters.length === 0 && !showFilterButton) {
return null
}
export interface FilterGroupProps extends React.HTMLAttributes<HTMLDivElement> {
label?: string
children: React.ReactNode
}
return (
<div
ref={ref}
className={cn(
'flex items-center gap-2 flex-wrap',
className
)}
{...props}
>
{showFilterButton && onOpenFilters && (
<Button
variant="outline"
size="sm"
onClick={onOpenFilters}
className="gap-2"
>
<Funnel className="h-4 w-4" />
Filters
{activeFilters.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 h-5 min-w-5 rounded-full">
{activeFilters.length}
</Badge>
)}
</Button>
)}
export function FilterGroup({ label, children, className, ...props }: FilterGroupProps) {
return (
<div className={cn('flex flex-col gap-1.5', className)} {...props}>
{label && (
<label className="text-xs font-medium text-muted-foreground">
{label}
</label>
)}
{children}
</div>
)
}
{activeFilters.map((filter) => (
<Badge
key={filter.key}
variant="secondary"
className="gap-2 pr-1 pl-3 py-1.5"
>
<span className="text-xs">
{filter.label}: <span className="font-semibold">{filter.value}</span>
</span>
<button
onClick={() => onRemoveFilter(filter.key)}
className="hover:bg-secondary-foreground/10 rounded-full p-0.5 transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{activeFilters.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-8 px-2 text-xs"
>
Clear all
</Button>
)}
</div>
)
}
)
FilterBar.displayName = 'FilterBar'
export { FilterBar }

View File

@@ -2,38 +2,57 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
export interface GridProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
cols?: 1 | 2 | 3 | 4 | 5 | 6
gap?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12
gap?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12
responsive?: boolean
}
export function Grid({
children,
cols = 1,
gap = 'md',
className,
...props
}: GridProps) {
return (
<div
className={cn(
'grid',
cols === 1 && 'grid-cols-1',
cols === 2 && 'grid-cols-1 md:grid-cols-2',
cols === 3 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
cols === 4 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
cols === 5 && 'grid-cols-1 md:grid-cols-3 lg:grid-cols-5',
cols === 6 && 'grid-cols-1 md:grid-cols-3 lg:grid-cols-6',
gap === 'none' && 'gap-0',
gap === 'sm' && 'gap-2',
gap === 'md' && 'gap-4',
gap === 'lg' && 'gap-6',
gap === 'xl' && 'gap-8',
className
)}
{...props}
>
{children}
</div>
)
const Grid = React.forwardRef<HTMLDivElement, GridProps>(
({ className, cols = 1, gap = 4, responsive = true, children, ...props }, ref) => {
const gridColsClass = responsive
? `grid-cols-1 ${cols >= 2 ? 'sm:grid-cols-2' : ''} ${cols >= 3 ? 'md:grid-cols-3' : ''} ${cols === 4 ? 'lg:grid-cols-4' : ''} ${cols >= 5 ? `lg:grid-cols-${cols}` : ''}`
: `grid-cols-${cols}`
return (
<div
ref={ref}
className={cn(
'grid',
gridColsClass,
`gap-${gap}`,
className
)}
{...props}
>
{children}
</div>
)
}
)
Grid.displayName = 'Grid'
export interface GridItemProps extends React.HTMLAttributes<HTMLDivElement> {
colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 12 | 'full'
rowSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 'full'
}
const GridItem = React.forwardRef<HTMLDivElement, GridItemProps>(
({ className, colSpan, rowSpan, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
colSpan && (colSpan === 'full' ? 'col-span-full' : `col-span-${colSpan}`),
rowSpan && (rowSpan === 'full' ? 'row-span-full' : `row-span-${rowSpan}`),
className
)}
{...props}
>
{children}
</div>
)
}
)
GridItem.displayName = 'GridItem'
export { Grid, GridItem }

View File

@@ -1,114 +1,64 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Card } from './card'
const MetricCard = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-border bg-card p-6 shadow-sm',
className
)}
{...props}
/>
))
export interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string
value: string | number
change?: {
value: number
trend: 'up' | 'down' | 'neutral'
}
icon?: React.ReactNode
description?: string
loading?: boolean
}
const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
({ className, label, value, change, icon, description, loading = false, ...props }, ref) => {
const trendColor = change?.trend === 'up'
? 'text-success'
: change?.trend === 'down'
? 'text-destructive'
: 'text-muted-foreground'
const trendSign = change?.trend === 'up' ? '+' : change?.trend === 'down' ? '-' : ''
return (
<Card ref={ref} className={cn('p-6', className)} {...props}>
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm text-muted-foreground font-medium">
{label}
</p>
{loading ? (
<div className="h-8 w-24 bg-muted animate-pulse rounded" />
) : (
<p className="text-3xl font-bold tracking-tight">
{value}
</p>
)}
{change && (
<p className={cn('text-sm font-medium', trendColor)}>
{trendSign}{Math.abs(change.value)}%
</p>
)}
{description && (
<p className="text-xs text-muted-foreground">
{description}
</p>
)}
</div>
{icon && (
<div className="flex-shrink-0 text-muted-foreground">
{icon}
</div>
)}
</div>
</Card>
)
}
)
MetricCard.displayName = 'MetricCard'
const MetricCardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center justify-between mb-2', className)}
{...props}
/>
))
MetricCardHeader.displayName = 'MetricCardHeader'
const MetricCardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm font-medium text-muted-foreground', className)}
{...props}
/>
))
MetricCardTitle.displayName = 'MetricCardTitle'
const MetricCardIcon = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-muted-foreground', className)}
{...props}
/>
))
MetricCardIcon.displayName = 'MetricCardIcon'
const MetricCardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('', className)} {...props} />
))
MetricCardContent.displayName = 'MetricCardContent'
const MetricCardValue = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-3xl font-bold text-foreground', className)}
{...props}
/>
))
MetricCardValue.displayName = 'MetricCardValue'
const MetricCardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-xs text-muted-foreground mt-1', className)}
{...props}
/>
))
MetricCardDescription.displayName = 'MetricCardDescription'
const MetricCardTrend = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { trend?: 'up' | 'down' | 'neutral' }
>(({ className, trend = 'neutral', ...props }, ref) => (
<div
ref={ref}
className={cn(
'inline-flex items-center gap-1 text-xs font-medium mt-2',
trend === 'up' && 'text-success',
trend === 'down' && 'text-destructive',
trend === 'neutral' && 'text-muted-foreground',
className
)}
{...props}
/>
))
MetricCardTrend.displayName = 'MetricCardTrend'
export {
MetricCard,
MetricCardHeader,
MetricCardTitle,
MetricCardIcon,
MetricCardContent,
MetricCardValue,
MetricCardDescription,
MetricCardTrend
}
export { MetricCard }

View File

@@ -1,110 +1,128 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog'
import { Button } from './button'
import { X } from '@phosphor-icons/react'
export interface ModalProps {
isOpen: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: React.ReactNode
footer?: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
className?: string
showCloseButton?: boolean
}
export function Modal({
isOpen,
onClose,
const Modal: React.FC<ModalProps> = ({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
className
}: ModalProps) {
if (!isOpen) return null
showCloseButton = true
}) => {
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
full: 'max-w-7xl'
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div
className={cn(
'relative z-10 bg-card rounded-lg shadow-lg max-h-[90vh] overflow-auto',
size === 'sm' && 'w-full max-w-sm',
size === 'md' && 'w-full max-w-md',
size === 'lg' && 'w-full max-w-lg',
size === 'xl' && 'w-full max-w-xl',
size === 'full' && 'w-[calc(100%-2rem)] max-w-6xl',
className
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={sizeClasses[size]}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="py-4">
{children}
</div>
{footer && (
<DialogFooter>
{footer}
</DialogFooter>
)}
>
{children}
</div>
</div>
</DialogContent>
</Dialog>
)
}
Modal.displayName = 'Modal'
export interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
onClose?: () => void
export interface ConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
onCancel?: () => void
variant?: 'default' | 'destructive'
loading?: boolean
}
export function ModalHeader({ children, onClose, className, ...props }: ModalHeaderProps) {
const ConfirmModal: React.FC<ConfirmModalProps> = ({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
onCancel,
variant = 'default',
loading = false
}) => {
const handleCancel = () => {
onOpenChange(false)
onCancel?.()
}
const handleConfirm = () => {
onConfirm()
if (!loading) {
onOpenChange(false)
}
}
return (
<div
className={cn(
'flex items-center justify-between px-6 py-4 border-b border-border',
className
)}
{...props}
<Modal
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
size="sm"
footer={
<>
<Button
variant="outline"
onClick={handleCancel}
disabled={loading}
>
{cancelLabel}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={loading}
>
{loading ? 'Loading...' : confirmLabel}
</Button>
</>
}
>
<div className="flex-1">{children}</div>
{onClose && (
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
{description && (
<p className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</Modal>
)
}
ConfirmModal.displayName = 'ConfirmModal'
export interface ModalTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode
}
export function ModalTitle({ children, className, ...props }: ModalTitleProps) {
return (
<h2 className={cn('text-xl font-semibold text-foreground', className)} {...props}>
{children}
</h2>
)
}
export interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function ModalBody({ children, className, ...props }: ModalBodyProps) {
return (
<div className={cn('px-6 py-4', className)} {...props}>
{children}
</div>
)
}
export interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function ModalFooter({ children, className, ...props }: ModalFooterProps) {
return (
<div
className={cn(
'flex items-center justify-end gap-2 px-6 py-4 border-t border-border',
className
)}
{...props}
>
{children}
</div>
)
}
export { Modal, ConfirmModal }

View File

@@ -2,61 +2,50 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
title: string
description?: string
actions?: React.ReactNode
breadcrumbs?: React.ReactNode
backButton?: React.ReactNode
}
export function PageHeader({ children, className, ...props }: PageHeaderProps) {
return (
<div className={cn('space-y-2 mb-6', className)} {...props}>
{children}
</div>
)
}
const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
({ className, title, description, actions, breadcrumbs, backButton, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('space-y-4 pb-6 border-b border-border', className)}
{...props}
>
{breadcrumbs && (
<div className="text-sm text-muted-foreground">
{breadcrumbs}
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-3">
{backButton}
<h1 className="text-3xl font-bold tracking-tight">
{title}
</h1>
</div>
{description && (
<p className="text-muted-foreground text-lg">
{description}
</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2 flex-shrink-0">
{actions}
</div>
)}
</div>
</div>
)
}
)
PageHeader.displayName = 'PageHeader'
export interface PageTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode
}
export function PageTitle({ children, className, ...props }: PageTitleProps) {
return (
<h1 className={cn('text-3xl font-bold text-foreground', className)} {...props}>
{children}
</h1>
)
}
export interface PageDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode
}
export function PageDescription({ children, className, ...props }: PageDescriptionProps) {
return (
<p className={cn('text-muted-foreground', className)} {...props}>
{children}
</p>
)
}
export interface PageActionsProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function PageActions({ children, className, ...props }: PageActionsProps) {
return (
<div className={cn('flex items-center gap-2', className)} {...props}>
{children}
</div>
)
}
export interface PageHeaderRowProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function PageHeaderRow({ children, className, ...props }: PageHeaderRowProps) {
return (
<div className={cn('flex items-start justify-between gap-4', className)} {...props}>
{children}
</div>
)
}
export { PageHeader }

View File

@@ -7,43 +7,76 @@ export interface QuickPaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
itemsPerPage?: number
totalItems?: number
showInfo?: boolean
className?: string
}
export function QuickPagination({
currentPage,
totalPages,
onPageChange,
className
}: QuickPaginationProps) {
const canGoPrevious = currentPage > 1
const canGoNext = currentPage < totalPages
const QuickPagination = React.forwardRef<HTMLDivElement, QuickPaginationProps>(
({
currentPage,
totalPages,
onPageChange,
itemsPerPage,
totalItems,
showInfo = false,
className
}, ref) => {
const canGoPrevious = currentPage > 1
const canGoNext = currentPage < totalPages
return (
<div className={cn('flex items-center justify-between gap-2', className)}>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!canGoPrevious}
const startItem = itemsPerPage ? (currentPage - 1) * itemsPerPage + 1 : null
const endItem = itemsPerPage && totalItems
? Math.min(currentPage * itemsPerPage, totalItems)
: null
return (
<div
ref={ref}
className={cn('flex items-center justify-between gap-4', className)}
>
<CaretLeft className="h-4 w-4" />
Previous
</Button>
{showInfo && startItem && endItem && totalItems ? (
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium">{startItem}</span> to{' '}
<span className="font-medium">{endItem}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
) : (
<div />
)}
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!canGoPrevious}
>
<CaretLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
>
Next
<CaretRight className="h-4 w-4" />
</Button>
</div>
)
}
<div className="flex items-center gap-1 px-2">
<span className="text-sm font-medium">{currentPage}</span>
<span className="text-sm text-muted-foreground">of</span>
<span className="text-sm font-medium">{totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
>
Next
<CaretRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
)
QuickPagination.displayName = 'QuickPagination'
export { QuickPagination }

View File

@@ -2,61 +2,46 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode
title?: string
description?: string
action?: React.ReactNode
noPadding?: boolean
}
export function Section({ children, className, ...props }: SectionProps) {
return (
<section className={cn('space-y-4', className)} {...props}>
{children}
</section>
)
}
const Section = React.forwardRef<HTMLElement, SectionProps>(
({ className, title, description, action, noPadding = false, children, ...props }, ref) => {
return (
<section
ref={ref}
className={cn('space-y-6', !noPadding && 'py-6', className)}
{...props}
>
{(title || description || action) && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
{title && (
<h2 className="text-2xl font-semibold tracking-tight">
{title}
</h2>
)}
{description && (
<p className="text-muted-foreground">
{description}
</p>
)}
</div>
{action && (
<div className="flex-shrink-0">
{action}
</div>
)}
</div>
)}
{children}
</section>
)
}
)
Section.displayName = 'Section'
export interface SectionHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function SectionHeader({ children, className, ...props }: SectionHeaderProps) {
return (
<div className={cn('', className)} {...props}>
{children}
</div>
)
}
export interface SectionTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode
}
export function SectionTitle({ children, className, ...props }: SectionTitleProps) {
return (
<h2 className={cn('text-lg font-semibold text-foreground', className)} {...props}>
{children}
</h2>
)
}
export interface SectionDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode
}
export function SectionDescription({ children, className, ...props }: SectionDescriptionProps) {
return (
<p className={cn('text-sm text-muted-foreground mt-1', className)} {...props}>
{children}
</p>
)
}
export interface SectionContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function SectionContent({ children, className, ...props }: SectionContentProps) {
return (
<div className={cn('', className)} {...props}>
{children}
</div>
)
}
export { Section }

View File

@@ -2,46 +2,62 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
export interface StackProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
direction?: 'horizontal' | 'vertical'
spacing?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
align?: 'start' | 'center' | 'end' | 'stretch'
justify?: 'start' | 'center' | 'end' | 'between' | 'around'
spacing?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'
wrap?: boolean
}
export function Stack({
children,
direction = 'vertical',
spacing = 'md',
align = 'stretch',
justify = 'start',
className,
...props
}: StackProps) {
return (
<div
className={cn(
'flex',
direction === 'horizontal' ? 'flex-row' : 'flex-col',
spacing === 'none' && 'gap-0',
spacing === 'sm' && 'gap-2',
spacing === 'md' && 'gap-4',
spacing === 'lg' && 'gap-6',
spacing === 'xl' && 'gap-8',
align === 'start' && 'items-start',
align === 'center' && 'items-center',
align === 'end' && 'items-end',
align === 'stretch' && 'items-stretch',
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'between' && 'justify-between',
justify === 'around' && 'justify-around',
className
)}
{...props}
>
{children}
</div>
)
}
const Stack = React.forwardRef<HTMLDivElement, StackProps>(
({
className,
direction = 'vertical',
spacing = 4,
align = 'stretch',
justify = 'start',
wrap = false,
children,
...props
}, ref) => {
const isHorizontal = direction === 'horizontal'
const alignmentClass = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
baseline: 'items-baseline'
}[align]
const justifyClass = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly'
}[justify]
return (
<div
ref={ref}
className={cn(
'flex',
isHorizontal ? 'flex-row' : 'flex-col',
`gap-${spacing}`,
alignmentClass,
justifyClass,
wrap && 'flex-wrap',
className
)}
{...props}
>
{children}
</div>
)
}
)
Stack.displayName = 'Stack'
export { Stack }

View File

@@ -1,56 +1,77 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import { X } from '@phosphor-icons/react'
export interface TagProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
export interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'info'
size?: 'sm' | 'md' | 'lg'
onRemove?: () => void
variant?: 'default' | 'primary' | 'success' | 'warning' | 'destructive'
removable?: boolean
}
export function Tag({
children,
onRemove,
variant = 'default',
className,
...props
}: TagProps) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium',
variant === 'default' && 'bg-muted text-foreground',
variant === 'primary' && 'bg-primary/10 text-primary',
variant === 'success' && 'bg-success/10 text-success',
variant === 'warning' && 'bg-warning/10 text-warning',
variant === 'destructive' && 'bg-destructive/10 text-destructive',
className
)}
{...props}
>
<span>{children}</span>
{onRemove && (
<button
onClick={onRemove}
className="hover:opacity-70 transition-opacity"
aria-label="Remove"
>
<X className="h-3 w-3" />
</button>
)}
</div>
)
}
const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
({
className,
variant = 'default',
size = 'md',
onRemove,
removable = false,
children,
...props
}, ref) => {
const variantClasses = {
default: 'bg-muted text-muted-foreground hover:bg-muted/80',
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
success: 'bg-success/10 text-success hover:bg-success/20',
warning: 'bg-warning/10 text-warning hover:bg-warning/20',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
info: 'bg-info/10 text-info hover:bg-info/20'
}
export interface TagGroupProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
const sizeClasses = {
sm: 'text-xs px-2 py-0.5',
md: 'text-sm px-2.5 py-1',
lg: 'text-base px-3 py-1.5'
}
export function TagGroup({ children, className, ...props }: TagGroupProps) {
return (
<div className={cn('flex flex-wrap items-center gap-2', className)} {...props}>
{children}
</div>
)
}
return (
<span
ref={ref}
className={cn(
'inline-flex items-center gap-1.5 rounded-full font-medium transition-colors',
variantClasses[variant],
sizeClasses[size],
(onRemove || removable) && 'pr-1',
className
)}
{...props}
>
{children}
{(onRemove || removable) && (
<button
type="button"
onClick={onRemove}
className="hover:bg-current/10 rounded-full p-0.5 transition-colors"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</span>
)
}
)
Tag.displayName = 'Tag'
export { Tag }

View File

@@ -1,24 +1,29 @@
# Custom Hook Library
A comprehensive collection of React hooks for the WorkForce Pro platform.
A comprehensive collection of 40+ React hooks for the WorkForce Pro platform.
## Available Hooks
### State Management
### State Management (7 hooks)
- **useToggle** - Boolean state toggle with setter
- **usePrevious** - Access previous value of state
- **useLocalStorage** - Persist state in localStorage
- **useDisclosure** - Modal/drawer open/close state
- **useUndo** - Undo/redo state management
- **useUndo** - Undo/redo state management with history
- **useFormState** - Form state with validation and dirty tracking
- **useArray** - Array manipulation (push, filter, update, remove, move, swap)
- **useMap** - Map data structure with reactive updates
- **useSet** - Set data structure with reactive updates
### Async Operations
### Async Operations (4 hooks)
- **useAsync** - Handle async operations with loading/error states
- **useDebounce** - Debounce rapidly changing values
- **useThrottle** - Throttle function calls
- **useInterval** - Declarative setInterval hook
- **useTimeout** - Declarative setTimeout hook
- **useCountdown** - Countdown timer with start/pause/reset
### UI & Interaction
### UI & Interaction (10 hooks)
- **useMediaQuery** - Responsive media query matching
- **useIsMobile** - Mobile device detection
- **useWindowSize** - Track window dimensions
@@ -31,20 +36,21 @@ A comprehensive collection of React hooks for the WorkForce Pro platform.
- **useClipboard** - Enhanced clipboard with timeout
- **useFocusTrap** - Trap focus within element
### Data Management
### Data Management (5 hooks)
- **useFilter** - Filter arrays with debouncing
- **useSort** - Sort arrays by key and direction
- **usePagination** - Paginate large datasets
- **useSelection** - Multi-select management
- **useTable** - Complete table with sort/filter/pagination
### Forms & Validation
### Forms & Validation (5 hooks)
- **useFormValidation** - Form validation with error handling
- **useWizard** - Multi-step form/wizard state
- **useMultiStepForm** - Advanced multi-step form with validation
- **useConfirmation** - Confirmation dialog state
- **useSteps** - Step navigation with progress tracking
### Browser & Navigation
### Browser & Navigation (3 hooks)
- **useQueryParams** - URL query parameter management
- **useDownload** - File download utilities (JSON, CSV, etc.)
@@ -283,5 +289,122 @@ import { useInterval } from '@/hooks'
useInterval(() => {
checkForUpdates()
}, 5000)
}, 5000, { enabled: true, immediate: false })
```
### useCountdown
```tsx
import { useCountdown } from '@/hooks'
const { seconds, minutes, remainingSeconds, start, pause, reset, isFinished } = useCountdown(60)
<div>
<p>{minutes}:{remainingSeconds.toString().padStart(2, '0')}</p>
<Button onClick={start}>Start</Button>
<Button onClick={pause}>Pause</Button>
<Button onClick={() => reset(30)}>Reset to 30s</Button>
</div>
```
### useTimeout
```tsx
import { useTimeout } from '@/hooks'
useTimeout(() => {
showNotification()
}, 3000)
```
### useArray
```tsx
import { useArray } from '@/hooks'
const { array, push, remove, update, filter, clear, move, swap } = useArray([1, 2, 3])
push(4)
remove(0)
update(1, 99)
filter((item) => item > 2)
move(0, 2)
swap(0, 1)
```
### useMap
```tsx
import { useMap } from '@/hooks'
const { map, set, remove, clear, has, get, values, keys } = useMap<string, User>()
set('user-1', { id: '1', name: 'John' })
const user = get('user-1')
remove('user-1')
```
### useSet
```tsx
import { useSet } from '@/hooks'
const { set, add, remove, toggle, has, clear, values, size } = useSet<string>()
add('item-1')
toggle('item-2')
has('item-1')
```
### useSteps
```tsx
import { useSteps } from '@/hooks'
const {
currentStep,
nextStep,
previousStep,
goToStep,
canGoNext,
canGoPrevious,
isFirstStep,
isLastStep,
progress
} = useSteps(5, 0)
<div>
<Progress value={progress} />
<Button onClick={previousStep} disabled={!canGoPrevious}>Back</Button>
<Button onClick={nextStep} disabled={!canGoNext}>Next</Button>
</div>
```
## Composing Hooks
Hooks are designed to work together for complex functionality:
```tsx
// Full-featured data table
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const filtered = useFilter(items, debouncedSearch, (item, query) =>
item.name.toLowerCase().includes(query.toLowerCase())
)
const { sortedItems, sortKey, sortDirection, toggleSort } = useSort(
filtered,
'date',
'desc'
)
const { paginatedItems, currentPage, totalPages, nextPage, previousPage } =
usePagination(sortedItems, 10)
const { selectedIds, toggleSelection, selectAll, clearSelection } =
useSelection(paginatedItems)
```
## Best Practices
1. **Performance**: Use `useDebounce` for search inputs and expensive operations
2. **Data Management**: Combine `useFilter`, `useSort`, and `usePagination` for tables
3. **Forms**: Use `useFormValidation` or `useFormState` for form management
4. **Workflows**: Use `useWizard` or `useSteps` for multi-step processes
5. **State Persistence**: Use `useLocalStorage` for data that should survive page refreshes
6. **Complex State**: Use `useArray`, `useMap`, or `useSet` for advanced data structures

View File

@@ -46,7 +46,11 @@ export type { SortDirection } from './use-sort'
export type { Step } from './use-wizard'
export type { UseTableOptions } from './use-table'
export type { UseMultiStepFormOptions } from './use-multi-step-form'
export type { ConfirmationOptions, ConfirmationState } from './use-confirmation'
export type { UseStepsOptions } from './use-steps'
export type { UseMapActions } from './use-map'
export type { UseSetActions } from './use-set'
export type { UseConfirmationOptions, UseConfirmationReturn } from './use-confirmation'
export type { UseStepsReturn } from './use-steps'
export type { UseArrayReturn } from './use-array'
export type { UseUndoReturn } from './use-undo'
export type { UseDisclosureReturn } from './use-disclosure'
export type { UseClipboardOptions } from './use-clipboard'
export type { UseDownloadReturn, DownloadFormat } from './use-download'
export type { UseIntervalOptions } from './use-interval'

View File

@@ -1,32 +1,49 @@
import { useState, useCallback } from 'react'
export function useArray<T>(initialValue: T[] = []) {
const [array, setArray] = useState<T[]>(initialValue)
export interface UseArrayReturn<T> {
array: T[]
set: (newArray: T[]) => void
push: (element: T) => void
filter: (callback: (item: T, index: number) => boolean) => void
update: (index: number, newElement: T) => void
remove: (index: number) => void
clear: () => void
insert: (index: number, element: T) => void
move: (fromIndex: number, toIndex: number) => void
swap: (indexA: number, indexB: number) => void
}
export function useArray<T>(initialArray: T[] = []): UseArrayReturn<T> {
const [array, setArray] = useState<T[]>(initialArray)
const set = useCallback((newArray: T[]) => {
setArray(newArray)
}, [])
const push = useCallback((element: T) => {
setArray((prev) => [...prev, element])
}, [])
const remove = useCallback((index: number) => {
setArray((prev) => prev.filter((_, i) => i !== index))
}, [])
const filter = useCallback((callback: (item: T, index: number) => boolean) => {
setArray((prev) => prev.filter(callback))
}, [])
const update = useCallback((index: number, newElement: T) => {
setArray((prev) => prev.map((item, i) => (i === index ? newElement : item)))
setArray((prev) => {
const newArray = [...prev]
newArray[index] = newElement
return newArray
})
}, [])
const remove = useCallback((index: number) => {
setArray((prev) => prev.filter((_, i) => i !== index))
}, [])
const clear = useCallback(() => {
setArray([])
}, [])
const set = useCallback((newArray: T[]) => {
setArray(newArray)
}, [])
const insert = useCallback((index: number, element: T) => {
setArray((prev) => {
const newArray = [...prev]
@@ -35,12 +52,19 @@ export function useArray<T>(initialValue: T[] = []) {
})
}, [])
const move = useCallback((fromIndex: number, toIndex: number) => {
setArray((prev) => {
const newArray = [...prev]
const [element] = newArray.splice(fromIndex, 1)
newArray.splice(toIndex, 0, element)
return newArray
})
}, [])
const swap = useCallback((indexA: number, indexB: number) => {
setArray((prev) => {
const newArray = [...prev]
const temp = newArray[indexA]
newArray[indexA] = newArray[indexB]
newArray[indexB] = temp
;[newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]]
return newArray
})
}, [])
@@ -49,11 +73,12 @@ export function useArray<T>(initialValue: T[] = []) {
array,
set,
push,
remove,
filter,
update,
remove,
clear,
insert,
move,
swap
}
}

View File

@@ -1,29 +1,41 @@
import { useState, useCallback } from 'react'
export function useClipboard(timeout = 2000) {
export interface UseClipboardOptions {
timeout?: number
}
export function useClipboard(options: UseClipboardOptions = {}) {
const { timeout = 2000 } = options
const [isCopied, setIsCopied] = useState(false)
const [error, setError] = useState<Error | null>(null)
const copy = useCallback(async (text: string) => {
if (!navigator?.clipboard) {
console.warn('Clipboard API not available')
return false
}
try {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setError(null)
setTimeout(() => {
setIsCopied(false)
}, timeout)
return true
} catch (error) {
console.warn('Copy failed', error)
} catch (err) {
setError(err as Error)
setIsCopied(false)
return false
}
}, [timeout])
return { isCopied, copy }
const reset = useCallback(() => {
setIsCopied(false)
setError(null)
}, [])
return {
isCopied,
error,
copy,
reset
}
}

View File

@@ -1,61 +1,54 @@
import { useState, useCallback } from 'react'
export interface ConfirmationOptions {
export interface UseConfirmationOptions {
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
variant?: 'default' | 'destructive'
}
export interface ConfirmationState extends ConfirmationOptions {
export interface UseConfirmationReturn {
isOpen: boolean
onConfirm: (() => void) | null
onCancel: (() => void) | null
data: UseConfirmationOptions
confirm: (options?: UseConfirmationOptions) => Promise<boolean>
handleConfirm: () => void
handleCancel: () => void
}
export function useConfirmation() {
const [state, setState] = useState<ConfirmationState>({
isOpen: false,
onConfirm: null,
onCancel: null,
title: 'Are you sure?',
message: 'This action cannot be undone.',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
variant: 'default'
})
export function useConfirmation(): UseConfirmationReturn {
const [isOpen, setIsOpen] = useState(false)
const [data, setData] = useState<UseConfirmationOptions>({})
const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null)
const confirm = useCallback((options?: ConfirmationOptions) => {
const confirm = useCallback((options: UseConfirmationOptions = {}) => {
return new Promise<boolean>((resolve) => {
setState({
isOpen: true,
title: options?.title || 'Are you sure?',
message: options?.message || 'This action cannot be undone.',
confirmLabel: options?.confirmLabel || 'Confirm',
cancelLabel: options?.cancelLabel || 'Cancel',
variant: options?.variant || 'default',
onConfirm: () => {
setState(prev => ({ ...prev, isOpen: false }))
resolve(true)
},
onCancel: () => {
setState(prev => ({ ...prev, isOpen: false }))
resolve(false)
}
})
setData(options)
setIsOpen(true)
setResolveRef(() => resolve)
})
}, [])
const close = useCallback(() => {
if (state.onCancel) {
state.onCancel()
const handleConfirm = useCallback(() => {
if (resolveRef) {
resolveRef(true)
}
}, [state])
setIsOpen(false)
setResolveRef(null)
}, [resolveRef])
const handleCancel = useCallback(() => {
if (resolveRef) {
resolveRef(false)
}
setIsOpen(false)
setResolveRef(null)
}, [resolveRef])
return {
...state,
isOpen,
data,
confirm,
close
handleConfirm,
handleCancel
}
}

View File

@@ -1,17 +1,31 @@
import { useState, useCallback } from 'react'
export function useDisclosure(initialState = false) {
export interface UseDisclosureReturn {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
export function useDisclosure(initialState = false): UseDisclosureReturn {
const [isOpen, setIsOpen] = useState(initialState)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen(prev => !prev), [])
const open = useCallback(() => {
setIsOpen(true)
}, [])
const close = useCallback(() => {
setIsOpen(false)
}, [])
const toggle = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
return {
isOpen,
open,
close,
toggle,
onOpenChange: setIsOpen
toggle
}
}

View File

@@ -1,20 +1,28 @@
import { useCallback, useState } from 'react'
import { useState, useCallback } from 'react'
export function useDownload() {
export type DownloadFormat = 'csv' | 'json' | 'txt'
export interface UseDownloadReturn {
download: (data: string, filename: string, format?: DownloadFormat) => void
downloadJSON: (data: any, filename: string) => void
downloadCSV: (data: any[], filename: string) => void
isDownloading: boolean
}
export function useDownload(): UseDownloadReturn {
const [isDownloading, setIsDownloading] = useState(false)
const downloadFile = useCallback(async (
data: string | Blob,
filename: string,
type?: string
) => {
const download = useCallback((data: string, filename: string, format: DownloadFormat = 'txt') => {
setIsDownloading(true)
try {
const blob = typeof data === 'string'
? new Blob([data], { type: type || 'text/plain' })
: data
try {
const mimeTypes = {
csv: 'text/csv',
json: 'application/json',
txt: 'text/plain'
}
const blob = new Blob([data], { type: mimeTypes[format] })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@@ -23,43 +31,39 @@ export function useDownload() {
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return true
} catch (error) {
console.error('Download failed:', error)
return false
} finally {
setIsDownloading(false)
}
}, [])
const downloadJSON = useCallback((data: any, filename: string) => {
const json = JSON.stringify(data, null, 2)
return downloadFile(json, filename, 'application/json')
}, [downloadFile])
const jsonString = JSON.stringify(data, null, 2)
download(jsonString, `${filename}.json`, 'json')
}, [download])
const downloadCSV = useCallback((data: any[], filename: string) => {
if (data.length === 0) return Promise.resolve(false)
if (data.length === 0) return
const headers = Object.keys(data[0])
const csv = [
const csvRows = [
headers.join(','),
...data.map(row =>
...data.map(row =>
headers.map(header => {
const value = row[header]
const stringValue = String(value ?? '')
return stringValue.includes(',') ? `"${stringValue}"` : stringValue
const escaped = String(value).replace(/"/g, '""')
return `"${escaped}"`
}).join(',')
)
].join('\n')
return downloadFile(csv, filename, 'text/csv')
}, [downloadFile])
]
const csvString = csvRows.join('\n')
download(csvString, `${filename}.csv`, 'csv')
}, [download])
return {
isDownloading,
downloadFile,
download,
downloadJSON,
downloadCSV
downloadCSV,
isDownloading
}
}

View File

@@ -1,6 +1,16 @@
import { useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
export function useInterval(callback: () => void, delay: number | null) {
export interface UseIntervalOptions {
enabled?: boolean
immediate?: boolean
}
export function useInterval(
callback: () => void,
delay: number | null,
options: UseIntervalOptions = {}
) {
const { enabled = true, immediate = false } = options
const savedCallback = useRef(callback)
useEffect(() => {
@@ -8,9 +18,60 @@ export function useInterval(callback: () => void, delay: number | null) {
}, [callback])
useEffect(() => {
if (delay === null) return
if (!enabled || delay === null) {
return
}
if (immediate) {
savedCallback.current()
}
const id = setInterval(() => {
savedCallback.current()
}, delay)
const id = setInterval(() => savedCallback.current(), delay)
return () => clearInterval(id)
}, [delay])
}, [delay, enabled, immediate])
}
export function useCountdown(initialSeconds: number) {
const [seconds, setSeconds] = useState(initialSeconds)
const [isRunning, setIsRunning] = useState(false)
useInterval(
() => {
setSeconds((prev) => {
if (prev <= 0) {
setIsRunning(false)
return 0
}
return prev - 1
})
},
isRunning ? 1000 : null
)
const start = () => {
setIsRunning(true)
}
const pause = () => {
setIsRunning(false)
}
const reset = (newSeconds?: number) => {
setSeconds(newSeconds ?? initialSeconds)
setIsRunning(false)
}
return {
seconds,
isRunning,
start,
pause,
reset,
minutes: Math.floor(seconds / 60),
remainingSeconds: seconds % 60,
isFinished: seconds === 0
}
}

View File

@@ -1,28 +1,19 @@
import { useState, useCallback } from 'react'
export interface UseMapActions<K, V> {
set: (key: K, value: V) => void
remove: (key: K) => void
clear: () => void
setAll: (entries: [K, V][]) => void
}
export function useMap<K, V>(
initialValue?: Map<K, V>
): [Map<K, V>, UseMapActions<K, V>] {
const [map, setMap] = useState<Map<K, V>>(initialValue || new Map())
export function useMap<K, V>(initialMap?: Map<K, V>) {
const [map, setMap] = useState<Map<K, V>>(initialMap || new Map())
const set = useCallback((key: K, value: V) => {
setMap((prev) => {
const newMap = new Map(prev)
setMap((prevMap) => {
const newMap = new Map(prevMap)
newMap.set(key, value)
return newMap
})
}, [])
const remove = useCallback((key: K) => {
setMap((prev) => {
const newMap = new Map(prev)
setMap((prevMap) => {
const newMap = new Map(prevMap)
newMap.delete(key)
return newMap
})
@@ -32,17 +23,29 @@ export function useMap<K, V>(
setMap(new Map())
}, [])
const has = useCallback((key: K) => {
return map.has(key)
}, [map])
const get = useCallback((key: K) => {
return map.get(key)
}, [map])
const setAll = useCallback((entries: [K, V][]) => {
setMap(new Map(entries))
}, [])
return [
return {
map,
{
set,
remove,
clear,
setAll
}
]
set,
remove,
clear,
has,
get,
setAll,
size: map.size,
values: Array.from(map.values()),
keys: Array.from(map.keys()),
entries: Array.from(map.entries())
}
}

View File

@@ -1,64 +1,74 @@
import { useState, useCallback, useEffect } from 'react'
export function useQueryParams<T extends Record<string, string>>() {
const [params, setParams] = useState<T>(() => {
const searchParams = new URLSearchParams(window.location.search)
const result = {} as T
searchParams.forEach((value, key) => {
result[key as keyof T] = value as T[keyof T]
export function useQueryParams<T extends Record<string, string | undefined>>(
initialParams: T
): [T, (key: keyof T, value: string | undefined) => void, (params: Partial<T>) => void] {
const [params, setParamsState] = useState<T>(() => {
if (typeof window === 'undefined') return initialParams
const urlParams = new URLSearchParams(window.location.search)
const result = { ...initialParams }
Object.keys(initialParams).forEach((key) => {
const value = urlParams.get(key)
if (value !== null) {
;(result as any)[key] = value
}
})
return result
})
const updateURL = useCallback((newParams: T) => {
const urlParams = new URLSearchParams()
Object.entries(newParams).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
urlParams.set(key, value)
}
})
const newURL = urlParams.toString()
? `${window.location.pathname}?${urlParams.toString()}`
: window.location.pathname
window.history.replaceState({}, '', newURL)
}, [])
const setParam = useCallback((key: keyof T, value: string | undefined) => {
setParamsState((prev) => {
const newParams = { ...prev, [key]: value }
updateURL(newParams)
return newParams
})
}, [updateURL])
const setParams = useCallback((newParams: Partial<T>) => {
setParamsState((prev) => {
const merged = { ...prev, ...newParams }
updateURL(merged)
return merged
})
}, [updateURL])
useEffect(() => {
const handlePopState = () => {
const searchParams = new URLSearchParams(window.location.search)
const result = {} as T
searchParams.forEach((value, key) => {
result[key as keyof T] = value as T[keyof T]
const urlParams = new URLSearchParams(window.location.search)
const newParams = { ...initialParams }
Object.keys(initialParams).forEach((key) => {
const value = urlParams.get(key)
if (value !== null) {
;(newParams as any)[key] = value
}
})
setParams(result)
setParamsState(newParams)
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
}, [initialParams])
const updateParams = useCallback((updates: Partial<T>) => {
const searchParams = new URLSearchParams(window.location.search)
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
searchParams.delete(key)
} else {
searchParams.set(key, String(value))
}
})
const newUrl = `${window.location.pathname}?${searchParams.toString()}`
window.history.pushState({}, '', newUrl)
setParams(prev => {
const newParams = { ...prev }
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
delete newParams[key as keyof T]
} else {
newParams[key as keyof T] = value as T[keyof T]
}
})
return newParams
})
}, [])
const clearParams = useCallback(() => {
window.history.pushState({}, '', window.location.pathname)
setParams({} as T)
}, [])
return {
params,
updateParams,
clearParams
}
return [params, setParam, setParams]
}

View File

@@ -1,37 +1,31 @@
import { useState, useCallback } from 'react'
export interface UseSetActions<T> {
add: (item: T) => void
remove: (item: T) => void
toggle: (item: T) => void
clear: () => void
has: (item: T) => boolean
}
export function useSet<T>(initialSet?: Set<T>) {
const [set, setSet] = useState<Set<T>>(initialSet || new Set())
export function useSet<T>(
initialValue?: Set<T>
): [Set<T>, UseSetActions<T>] {
const [set, setSet] = useState<Set<T>>(initialValue || new Set())
const add = useCallback((item: T) => {
setSet((prev) => new Set(prev).add(item))
}, [])
const remove = useCallback((item: T) => {
setSet((prev) => {
const newSet = new Set(prev)
newSet.delete(item)
const add = useCallback((value: T) => {
setSet((prevSet) => {
const newSet = new Set(prevSet)
newSet.add(value)
return newSet
})
}, [])
const toggle = useCallback((item: T) => {
setSet((prev) => {
const newSet = new Set(prev)
if (newSet.has(item)) {
newSet.delete(item)
const remove = useCallback((value: T) => {
setSet((prevSet) => {
const newSet = new Set(prevSet)
newSet.delete(value)
return newSet
})
}, [])
const toggle = useCallback((value: T) => {
setSet((prevSet) => {
const newSet = new Set(prevSet)
if (newSet.has(value)) {
newSet.delete(value)
} else {
newSet.add(item)
newSet.add(value)
}
return newSet
})
@@ -41,18 +35,23 @@ export function useSet<T>(
setSet(new Set())
}, [])
const has = useCallback((item: T) => {
return set.has(item)
const has = useCallback((value: T) => {
return set.has(value)
}, [set])
return [
const reset = useCallback(() => {
setSet(initialSet || new Set())
}, [initialSet])
return {
set,
{
add,
remove,
toggle,
clear,
has
}
]
add,
remove,
toggle,
clear,
has,
reset,
size: set.size,
values: Array.from(set)
}
}

View File

@@ -1,11 +1,19 @@
import { useState, useCallback } from 'react'
export interface UseStepsOptions {
initialStep?: number
totalSteps: number
export interface UseStepsReturn {
currentStep: number
nextStep: () => void
previousStep: () => void
goToStep: (step: number) => void
reset: () => void
canGoNext: boolean
canGoPrevious: boolean
isFirstStep: boolean
isLastStep: boolean
progress: number
}
export function useSteps({ initialStep = 0, totalSteps }: UseStepsOptions) {
export function useSteps(totalSteps: number, initialStep = 0): UseStepsReturn {
const [currentStep, setCurrentStep] = useState(initialStep)
const nextStep = useCallback(() => {
@@ -17,9 +25,7 @@ export function useSteps({ initialStep = 0, totalSteps }: UseStepsOptions) {
}, [])
const goToStep = useCallback((step: number) => {
if (step >= 0 && step < totalSteps) {
setCurrentStep(step)
}
setCurrentStep(Math.max(0, Math.min(step, totalSteps - 1)))
}, [totalSteps])
const reset = useCallback(() => {
@@ -28,13 +34,14 @@ export function useSteps({ initialStep = 0, totalSteps }: UseStepsOptions) {
return {
currentStep,
isFirstStep: currentStep === 0,
isLastStep: currentStep === totalSteps - 1,
progress: ((currentStep + 1) / totalSteps) * 100,
nextStep,
previousStep,
goToStep,
reset,
setStep: setCurrentStep
canGoNext: currentStep < totalSteps - 1,
canGoPrevious: currentStep > 0,
isFirstStep: currentStep === 0,
isLastStep: currentStep === totalSteps - 1,
progress: ((currentStep + 1) / totalSteps) * 100
}
}

View File

@@ -8,9 +8,14 @@ export function useTimeout(callback: () => void, delay: number | null) {
}, [callback])
useEffect(() => {
if (delay === null) return
if (delay === null) {
return
}
const id = setTimeout(() => {
savedCallback.current()
}, delay)
const id = setTimeout(() => savedCallback.current(), delay)
return () => clearTimeout(id)
}, [delay])
}

View File

@@ -1,28 +1,36 @@
import { useState, useCallback } from 'react'
export function useUndo<T>(initialState: T, maxHistory = 50) {
export interface UseUndoReturn<T> {
state: T
set: (newState: T | ((prev: T) => T)) => void
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
clear: () => void
reset: () => void
}
export function useUndo<T>(initialState: T, maxHistory = 50): UseUndoReturn<T> {
const [history, setHistory] = useState<T[]>([initialState])
const [currentIndex, setCurrentIndex] = useState(0)
const current = history[currentIndex]
const canUndo = currentIndex > 0
const canRedo = currentIndex < history.length - 1
const state = history[currentIndex]
const setState = useCallback((newState: T | ((prev: T) => T)) => {
setHistory(prevHistory => {
const currentState = prevHistory[currentIndex]
const nextState = typeof newState === 'function'
? (newState as (prev: T) => T)(currentState)
const set = useCallback((newState: T | ((prev: T) => T)) => {
setHistory((prev) => {
const nextState = typeof newState === 'function'
? (newState as (prev: T) => T)(prev[currentIndex])
: newState
const newHistory = prevHistory.slice(0, currentIndex + 1)
const newHistory = prev.slice(0, currentIndex + 1)
newHistory.push(nextState)
if (newHistory.length > maxHistory) {
newHistory.shift()
setCurrentIndex(currentIndex)
setCurrentIndex(maxHistory - 1)
} else {
setCurrentIndex(currentIndex + 1)
setCurrentIndex(newHistory.length - 1)
}
return newHistory
@@ -30,37 +38,35 @@ export function useUndo<T>(initialState: T, maxHistory = 50) {
}, [currentIndex, maxHistory])
const undo = useCallback(() => {
if (canUndo) {
setCurrentIndex(prev => prev - 1)
if (currentIndex > 0) {
setCurrentIndex((prev) => prev - 1)
}
}, [canUndo])
}, [currentIndex])
const redo = useCallback(() => {
if (canRedo) {
setCurrentIndex(prev => prev + 1)
if (currentIndex < history.length - 1) {
setCurrentIndex((prev) => prev + 1)
}
}, [canRedo])
}, [currentIndex, history.length])
const clear = useCallback(() => {
setHistory([state])
setCurrentIndex(0)
}, [state])
const reset = useCallback(() => {
setHistory([initialState])
setCurrentIndex(0)
}, [initialState])
const clear = useCallback(() => {
setHistory([current])
setCurrentIndex(0)
}, [current])
return {
state: current,
setState,
state,
set,
undo,
redo,
canUndo,
canRedo,
reset,
canUndo: currentIndex > 0,
canRedo: currentIndex < history.length - 1,
clear,
history: history.length,
currentIndex
reset
}
}