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

This commit is contained in:
2026-01-23 05:50:16 +00:00
committed by GitHub
parent 8016d1190b
commit 4c2cb4b1ce
32 changed files with 2202 additions and 1 deletions

227
LIBRARIES.md Normal file
View File

@@ -0,0 +1,227 @@
# WorkForce Pro - Developer Libraries
## Overview
This document provides an overview of the expanded custom hook library and UI component library available in the WorkForce Pro application. These libraries provide production-ready, reusable building blocks for rapid feature development.
## Custom Hook Library
The application includes **40+ custom React hooks** organized into the following categories:
### State Management (8 hooks)
- `useToggle` - Boolean state toggle
- `usePrevious` - Access previous value
- `useLocalStorage` - Persist to localStorage
- `useDisclosure` - Modal/drawer state
- `useUndo` - Undo/redo functionality
- `useFormState` - Form state with validation
- `useArray` - Array manipulation utilities
- `useMap` - Map data structure utilities
- `useSet` - Set data structure utilities
### Async Operations (5 hooks)
- `useAsync` - Async operation handling
- `useDebounce` - Debounce values
- `useThrottle` - Throttle function calls
- `useInterval` - Declarative intervals
- `useTimeout` - Declarative timeouts
### UI & Interaction (12 hooks)
- `useMediaQuery` - Media query matching
- `useIsMobile` - Mobile detection
- `useWindowSize` - Window dimensions
- `useScrollPosition` - Scroll tracking
- `useOnClickOutside` - Outside click detection
- `useIntersectionObserver` - Visibility detection
- `useKeyboardShortcut` - Keyboard shortcuts
- `useIdleTimer` - Idle state detection
- `useCopyToClipboard` - Copy to clipboard
- `useClipboard` - Enhanced clipboard
- `useFocusTrap` - Focus management
- `useCountdown` - Countdown timer
### Data Management (5 hooks)
- `useFilter` - Array filtering
- `useSort` - Array sorting
- `usePagination` - Data pagination
- `useSelection` - Multi-select management
- `useTable` - Complete table utilities
### Forms & Validation (4 hooks)
- `useFormValidation` - Form validation
- `useWizard` - Multi-step wizards
- `useMultiStepForm` - Advanced multi-step forms
- `useSteps` - Step navigation
- `useConfirmation` - Confirmation dialogs
### Browser & Navigation (2 hooks)
- `useQueryParams` - URL query params
- `useDownload` - File downloads
### Application-Specific (2 hooks)
- `useNotifications` - Notification system
- `useSampleData` - Sample data initialization
## UI Component Library
The application includes **70+ UI components** from shadcn v4, plus custom components:
### Display Components
- `EmptyState` - Empty state placeholder
- `StatusBadge` - Status indicators
- `StatCard` - Simple metric cards
- `MetricCard` - Advanced metric cards with trends
- `DataList` - Key-value pair lists
- `DataTable` - Generic data tables
- `Timeline` - Event timelines
- `List` / `ListItem` - Structured lists
- `Chip` - Tag chips
- `Badge` - Status badges
- `Avatar` - User avatars
- `Card` - Content cards
- `Alert` - Alert messages
### Layout Components
- `PageHeader` / `PageTitle` / `PageDescription` / `PageActions` - Page headers
- `Section` / `SectionHeader` / `SectionTitle` / `SectionContent` - Content sections
- `Stack` - Flex containers with spacing
- `Grid` - Responsive grid layouts
- `Separator` - Dividers
- `Divider` - Enhanced dividers with labels
- `ScrollArea` - Scrollable containers
- `Resizable` - Resizable panels
### Input Components
- `Input` - Text inputs
- `Textarea` - Multi-line text
- `Select` - Dropdowns
- `Checkbox` - Checkboxes
- `RadioGroup` - Radio buttons
- `Switch` - Toggle switches
- `Slider` - Range sliders
- `Calendar` - Date picker
- `SearchInput` - Search with clear
- `FileUpload` - File upload with drag-drop
- `InputOTP` - OTP inputs
- `Form` - Form components
### Navigation Components
- `Tabs` - Tab navigation
- `Breadcrumb` - Breadcrumb navigation
- `Pagination` - Full pagination
- `QuickPagination` - Simple pagination
- `Stepper` - Multi-step indicator
- `NavigationMenu` - Navigation menus
- `Menubar` - Menu bars
- `Sidebar` - Application sidebar
### Modal & Dialog Components
- `Dialog` - Standard dialogs
- `AlertDialog` - Confirmation dialogs
- `Modal` / `ModalHeader` / `ModalBody` / `ModalFooter` - Custom modals
- `Sheet` - Side panels
- `Drawer` - Drawer panels
- `Popover` - Popovers
- `HoverCard` - Hover cards
- `Tooltip` - Tooltips
- `ContextMenu` - Context menus
- `DropdownMenu` - Dropdown menus
### Filter & Search Components
- `FilterBar` / `FilterGroup` - Filter controls
- `Tag` / `TagGroup` - Removable tags
- `Command` - Command palette
- `Combobox` - Searchable selects
### Utility Components
- `LoadingSpinner` - Loading indicators
- `LoadingOverlay` - Full-screen loading
- `Progress` - Progress bars
- `Skeleton` - Loading skeletons
- `CopyButton` - Copy to clipboard
- `CodeBlock` - Code display
- `InfoBox` - Info messages
- `Kbd` - Keyboard shortcuts display
- `SortableHeader` - Sortable table headers
### Chart Components
- `Chart` - Recharts wrapper with themes
## Best Practices
### When to Use Hooks vs Components
**Use Hooks When:**
- Managing state or side effects
- Sharing logic between components
- Accessing browser APIs
- Managing complex interactions
**Use Components When:**
- Creating reusable UI elements
- Defining visual structures
- Composing layouts
- Building forms
### Performance Considerations
1. **Memoization**: Most hooks use `useCallback` and `useMemo` internally
2. **Lazy Loading**: Import only what you need
3. **State Colocation**: Keep state close to where it's used
4. **Pagination**: Use `useTable` or `usePagination` for large datasets
### Composition Patterns
**Hooks Composition:**
```tsx
function useTimesheetForm() {
const { values, errors, handleChange } = useFormState(initialData)
const { isOpen, open, close } = useDisclosure()
const { copy } = useClipboard()
return { values, errors, handleChange, isOpen, open, close, copy }
}
```
**Component Composition:**
```tsx
<PageHeader>
<PageHeaderRow>
<PageTitle>Title</PageTitle>
<PageActions>
<Button>Action</Button>
</PageActions>
</PageHeaderRow>
</PageHeader>
```
## Quick Reference
### Most Commonly Used Hooks
1. `useKV` - Data persistence (Spark SDK)
2. `useTable` - Table management
3. `useDisclosure` - Modal state
4. `useFormState` - Form handling
5. `useDebounce` - Search optimization
### Most Commonly Used Components
1. `Button` - Primary actions
2. `Card` - Content containers
3. `Dialog` - Modals and confirmations
4. `Input` / `Select` - Form fields
5. `Table` - Data display
6. `Badge` - Status indicators
7. `Skeleton` - Loading states
## Documentation
- **Hooks**: See `/src/hooks/README.md` for detailed examples
- **Components**: See `/src/components/ui/README.md` for usage guides
- **Types**: All hooks and components are fully typed with TypeScript
## Support
For questions or issues with the libraries, please refer to:
1. Individual hook/component files for implementation details
2. README files in respective directories
3. TypeScript type definitions for API contracts

View File

@@ -38,6 +38,23 @@ Metric display card with optional trend indicator.
/>
```
#### MetricCard
Flexible metric card with composable parts.
```tsx
<MetricCard>
<MetricCardHeader>
<MetricCardTitle>Active Workers</MetricCardTitle>
<MetricCardIcon><Users /></MetricCardIcon>
</MetricCardHeader>
<MetricCardContent>
<MetricCardValue>1,234</MetricCardValue>
<MetricCardDescription>+12% from last month</MetricCardDescription>
<MetricCardTrend trend="up"> 12%</MetricCardTrend>
</MetricCardContent>
</MetricCard>
```
#### DataList
Key-value pair display list.
@@ -52,6 +69,22 @@ Key-value pair display list.
/>
```
#### DataTable
Generic data table with custom column rendering.
```tsx
<DataTable
columns={[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'status', header: 'Status', render: (val) => <StatusBadge status={val} /> },
{ key: 'amount', header: 'Amount', width: '120px' }
]}
data={timesheets}
onRowClick={(row) => viewDetails(row)}
emptyMessage="No data found"
/>
```
#### Timeline
Chronological event timeline with completion states.
@@ -65,6 +98,80 @@ Chronological event timeline with completion states.
/>
```
#### List
Composable list component for structured data.
```tsx
<List variant="bordered">
<ListItem interactive onClick={handleClick}>
<ListItemTitle>John Smith</ListItemTitle>
<ListItemDescription>Software Engineer</ListItemDescription>
</ListItem>
</List>
```
### Layout Components
#### PageHeader
Page header with title, description, and actions.
```tsx
<PageHeader>
<PageHeaderRow>
<div>
<PageTitle>Timesheets</PageTitle>
<PageDescription>Manage and approve worker timesheets</PageDescription>
</div>
<PageActions>
<Button>Export</Button>
<Button variant="primary">Create</Button>
</PageActions>
</PageHeaderRow>
</PageHeader>
```
#### Section
Content section with header.
```tsx
<Section>
<SectionHeader>
<SectionTitle>Recent Activity</SectionTitle>
<SectionDescription>Your latest updates and changes</SectionDescription>
</SectionHeader>
<SectionContent>
{/* content */}
</SectionContent>
</Section>
```
#### Stack
Flexible container for arranging items with consistent spacing.
```tsx
<Stack direction="vertical" spacing="md" align="center">
<Button>Item 1</Button>
<Button>Item 2</Button>
<Button>Item 3</Button>
</Stack>
<Stack direction="horizontal" spacing="sm" justify="between">
<span>Left</span>
<span>Right</span>
</Stack>
```
#### Grid
Responsive grid layout.
```tsx
<Grid cols={3} gap="lg">
<Card>Card 1</Card>
<Card>Card 2</Card>
<Card>Card 3</Card>
</Grid>
```
### Input Components
#### SearchInput
@@ -91,6 +198,36 @@ Drag-and-drop file upload area.
/>
```
#### FilterBar
Container for filter controls.
```tsx
<FilterBar>
<FilterGroup label="Status">
<Select value={status} onValueChange={setStatus}>
<option value="all">All</option>
<option value="pending">Pending</option>
</Select>
</FilterGroup>
<FilterGroup label="Date Range">
<DatePicker />
</FilterGroup>
</FilterBar>
```
#### Tag
Removable tag component with variants.
```tsx
<TagGroup>
<Tag variant="primary" onRemove={() => removeTag('js')}>
JavaScript
</Tag>
<Tag variant="success">Active</Tag>
<Tag variant="warning">Pending</Tag>
</TagGroup>
```
### Navigation Components
#### Stepper
@@ -108,6 +245,37 @@ Multi-step progress indicator.
/>
```
#### QuickPagination
Simple pagination controls.
```tsx
<QuickPagination
currentPage={page}
totalPages={10}
onPageChange={setPage}
/>
```
### Modal Components
#### Modal
Flexible modal dialog with composable parts.
```tsx
<Modal isOpen={isOpen} onClose={close} size="lg">
<ModalHeader onClose={close}>
<ModalTitle>Edit Timesheet</ModalTitle>
</ModalHeader>
<ModalBody>
{/* form content */}
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={close}>Cancel</Button>
<Button variant="primary" onClick={save}>Save</Button>
</ModalFooter>
</Modal>
```
### Utility Components
#### LoadingSpinner

View File

@@ -0,0 +1,77 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
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 function DataTable<T extends Record<string, any>>({
columns,
data,
onRowClick,
emptyMessage = 'No data available',
className,
...props
}: 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>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface FilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
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>
)
}
export interface FilterGroupProps extends React.HTMLAttributes<HTMLDivElement> {
label?: string
children: React.ReactNode
}
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>
)
}

View File

@@ -0,0 +1,39 @@
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'
}
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>
)
}

View File

@@ -0,0 +1,67 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface ListProps extends React.HTMLAttributes<HTMLUListElement> {
children: React.ReactNode
variant?: 'default' | 'bordered' | 'divided'
}
export function List({ children, variant = 'default', className, ...props }: ListProps) {
return (
<ul
className={cn(
'space-y-0',
variant === 'bordered' && 'border border-border rounded-lg overflow-hidden',
variant === 'divided' && 'divide-y divide-border',
className
)}
{...props}
>
{children}
</ul>
)
}
export interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
children: React.ReactNode
interactive?: boolean
}
export function ListItem({ children, interactive, className, ...props }: ListItemProps) {
return (
<li
className={cn(
'px-4 py-3 bg-card',
interactive && 'hover:bg-muted/50 cursor-pointer transition-colors',
className
)}
{...props}
>
{children}
</li>
)
}
export interface ListItemTitleProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function ListItemTitle({ children, className, ...props }: ListItemTitleProps) {
return (
<div className={cn('font-medium text-foreground', className)} {...props}>
{children}
</div>
)
}
export interface ListItemDescriptionProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function ListItemDescription({ children, className, ...props }: ListItemDescriptionProps) {
return (
<div className={cn('text-sm text-muted-foreground mt-1', className)} {...props}>
{children}
</div>
)
}

View File

@@ -0,0 +1,114 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
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}
/>
))
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
}

110
src/components/ui/modal.tsx Normal file
View File

@@ -0,0 +1,110 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import { X } from '@phosphor-icons/react'
export interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
className?: string
}
export function Modal({
isOpen,
onClose,
children,
size = 'md',
className
}: ModalProps) {
if (!isOpen) return null
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
)}
>
{children}
</div>
</div>
)
}
export interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
onClose?: () => void
}
export function ModalHeader({ children, onClose, className, ...props }: ModalHeaderProps) {
return (
<div
className={cn(
'flex items-center justify-between px-6 py-4 border-b border-border',
className
)}
{...props}
>
<div className="flex-1">{children}</div>
{onClose && (
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
</div>
)
}
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>
)
}

View File

@@ -0,0 +1,62 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function PageHeader({ children, className, ...props }: PageHeaderProps) {
return (
<div className={cn('space-y-2 mb-6', className)} {...props}>
{children}
</div>
)
}
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>
)
}

View File

@@ -0,0 +1,49 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import { CaretLeft, CaretRight } from '@phosphor-icons/react'
export interface QuickPaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
className?: string
}
export function QuickPagination({
currentPage,
totalPages,
onPageChange,
className
}: QuickPaginationProps) {
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}
>
<CaretLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
>
Next
<CaretRight className="h-4 w-4" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode
}
export function Section({ children, className, ...props }: SectionProps) {
return (
<section className={cn('space-y-4', className)} {...props}>
{children}
</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>
)
}

View File

@@ -0,0 +1,47 @@
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'
}
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>
)
}

56
src/components/ui/tag.tsx Normal file
View File

@@ -0,0 +1,56 @@
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
onRemove?: () => void
variant?: 'default' | 'primary' | 'success' | 'warning' | 'destructive'
}
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>
)
}
export interface TagGroupProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
}
export function TagGroup({ children, className, ...props }: TagGroupProps) {
return (
<div className={cn('flex flex-wrap items-center gap-2', className)} {...props}>
{children}
</div>
)
}

View File

@@ -8,11 +8,15 @@ A comprehensive collection of React hooks for the WorkForce Pro platform.
- **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
- **useFormState** - Form state with validation and dirty tracking
### Async Operations
- **useAsync** - Handle async operations with loading/error states
- **useDebounce** - Debounce rapidly changing values
- **useThrottle** - Throttle function calls
- **useInterval** - Declarative setInterval hook
### UI & Interaction
- **useMediaQuery** - Responsive media query matching
@@ -24,16 +28,25 @@ A comprehensive collection of React hooks for the WorkForce Pro platform.
- **useKeyboardShortcut** - Global keyboard shortcuts
- **useIdleTimer** - Detect user idle state
- **useCopyToClipboard** - Copy text to clipboard
- **useClipboard** - Enhanced clipboard with timeout
- **useFocusTrap** - Trap focus within element
### Data Management
- **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
- **useFormValidation** - Form validation with error handling
- **useWizard** - Multi-step form/wizard state
- **useMultiStepForm** - Advanced multi-step form with validation
- **useConfirmation** - Confirmation dialog state
### Browser & Navigation
- **useQueryParams** - URL query parameter management
- **useDownload** - File download utilities (JSON, CSV, etc.)
### Application-Specific
- **useNotifications** - Notification system state
@@ -41,6 +54,151 @@ A comprehensive collection of React hooks for the WorkForce Pro platform.
## Usage Examples
### useTable
```tsx
import { useTable } from '@/hooks'
const {
data,
page,
totalPages,
sortKey,
sortDirection,
handleSort,
handleFilter,
nextPage,
prevPage
} = useTable({
data: allTimesheets,
pageSize: 10,
initialSort: { key: 'date', direction: 'desc' }
})
```
### useMultiStepForm
```tsx
import { useMultiStepForm } from '@/hooks'
const {
currentStep,
formData,
errors,
isLastStep,
updateData,
nextStep,
prevStep,
handleSubmit
} = useMultiStepForm({
initialData: { name: '', email: '', address: '' },
steps: ['Personal', 'Contact', 'Review'],
onComplete: async (data) => await saveData(data)
})
```
### useConfirmation
```tsx
import { useConfirmation } from '@/hooks'
const { isOpen, confirm, onConfirm, onCancel, title, message } = useConfirmation()
const handleDelete = async () => {
const confirmed = await confirm({
title: 'Delete Item',
message: 'Are you sure? This cannot be undone.',
variant: 'destructive'
})
if (confirmed) {
await deleteItem()
}
}
```
### useDownload
```tsx
import { useDownload } from '@/hooks'
const { isDownloading, downloadJSON, downloadCSV } = useDownload()
const exportData = () => {
downloadCSV(timesheets, 'timesheets.csv')
}
```
### useUndo
```tsx
import { useUndo } from '@/hooks'
const {
state,
setState,
undo,
redo,
canUndo,
canRedo
} = useUndo({ text: '' })
```
### useQueryParams
```tsx
import { useQueryParams } from '@/hooks'
const { params, updateParams, clearParams } = useQueryParams<{
search: string
status: string
}>()
updateParams({ search: 'john', status: 'active' })
```
### useClipboard
```tsx
import { useClipboard } from '@/hooks'
const { isCopied, copy } = useClipboard()
<Button onClick={() => copy('Text to copy')}>
{isCopied ? 'Copied!' : 'Copy'}
</Button>
```
### useDisclosure
```tsx
import { useDisclosure } from '@/hooks'
const { isOpen, open, close, toggle } = useDisclosure()
<Button onClick={open}>Open Modal</Button>
<Dialog open={isOpen} onOpenChange={close}>
...
</Dialog>
```
### useFocusTrap
```tsx
import { useFocusTrap } from '@/hooks'
const ref = useFocusTrap<HTMLDivElement>(isModalOpen)
<div ref={ref}>
{/* Focus will be trapped within this element */}
</div>
```
### useFormState
```tsx
import { useFormState } from '@/hooks'
const {
values,
errors,
touched,
isDirty,
setValue,
handleChange,
handleBlur
} = useFormState({ name: '', email: '' })
```
### useDebounce
```tsx
import { useDebounce } from '@/hooks'
@@ -49,7 +207,6 @@ const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 500)
useEffect(() => {
// API call with debounced value
searchAPI(debouncedSearch)
}, [debouncedSearch])
```
@@ -119,3 +276,12 @@ useKeyboardShortcut(
() => saveDocument()
)
```
### useInterval
```tsx
import { useInterval } from '@/hooks'
useInterval(() => {
checkForUpdates()
}, 5000)
```

View File

@@ -21,9 +21,32 @@ export { useThrottle } from './use-throttle'
export { useToggle } from './use-toggle'
export { useWindowSize } from './use-window-size'
export { useWizard } from './use-wizard'
export { useTable } from './use-table'
export { useMultiStepForm } from './use-multi-step-form'
export { useConfirmation } from './use-confirmation'
export { useInterval } from './use-interval'
export { useClipboard } from './use-clipboard'
export { useDownload } from './use-download'
export { useUndo } from './use-undo'
export { useQueryParams } from './use-query-params'
export { useFocusTrap } from './use-focus-trap'
export { useDisclosure } from './use-disclosure'
export { useFormState } from './use-form-state'
export { useCountdown } from './use-countdown'
export { useSteps } from './use-steps'
export { useArray } from './use-array'
export { useTimeout } from './use-timeout'
export { useMap } from './use-map'
export { useSet } from './use-set'
export type { AsyncState } from './use-async'
export type { FormErrors } from './use-form-validation'
export type { IntersectionObserverOptions } from './use-intersection-observer'
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'

59
src/hooks/use-array.ts Normal file
View File

@@ -0,0 +1,59 @@
import { useState, useCallback } from 'react'
export function useArray<T>(initialValue: T[] = []) {
const [array, setArray] = useState<T[]>(initialValue)
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)))
}, [])
const clear = useCallback(() => {
setArray([])
}, [])
const set = useCallback((newArray: T[]) => {
setArray(newArray)
}, [])
const insert = useCallback((index: number, element: T) => {
setArray((prev) => {
const newArray = [...prev]
newArray.splice(index, 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
return newArray
})
}, [])
return {
array,
set,
push,
remove,
filter,
update,
clear,
insert,
swap
}
}

View File

@@ -0,0 +1,29 @@
import { useState, useCallback } from 'react'
export function useClipboard(timeout = 2000) {
const [isCopied, setIsCopied] = useState(false)
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)
setTimeout(() => {
setIsCopied(false)
}, timeout)
return true
} catch (error) {
console.warn('Copy failed', error)
setIsCopied(false)
return false
}
}, [timeout])
return { isCopied, copy }
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback } from 'react'
export interface ConfirmationOptions {
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
variant?: 'default' | 'destructive'
}
export interface ConfirmationState extends ConfirmationOptions {
isOpen: boolean
onConfirm: (() => void) | null
onCancel: (() => void) | null
}
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'
})
const confirm = useCallback((options?: ConfirmationOptions) => {
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)
}
})
})
}, [])
const close = useCallback(() => {
if (state.onCancel) {
state.onCancel()
}
}, [state])
return {
...state,
confirm,
close
}
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react'
export function useCountdown(targetDate: Date | string | number) {
const calculateTimeLeft = () => {
const target = new Date(targetDate).getTime()
const now = new Date().getTime()
const difference = target - now
if (difference <= 0) {
return {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
isComplete: true
}
}
return {
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
seconds: Math.floor((difference % (1000 * 60)) / 1000),
isComplete: false
}
}
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft())
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(calculateTimeLeft())
}, 1000)
return () => clearInterval(timer)
}, [targetDate])
return timeLeft
}

View File

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

65
src/hooks/use-download.ts Normal file
View File

@@ -0,0 +1,65 @@
import { useCallback, useState } from 'react'
export function useDownload() {
const [isDownloading, setIsDownloading] = useState(false)
const downloadFile = useCallback(async (
data: string | Blob,
filename: string,
type?: string
) => {
setIsDownloading(true)
try {
const blob = typeof data === 'string'
? new Blob([data], { type: type || 'text/plain' })
: data
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
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 downloadCSV = useCallback((data: any[], filename: string) => {
if (data.length === 0) return Promise.resolve(false)
const headers = Object.keys(data[0])
const csv = [
headers.join(','),
...data.map(row =>
headers.map(header => {
const value = row[header]
const stringValue = String(value ?? '')
return stringValue.includes(',') ? `"${stringValue}"` : stringValue
}).join(',')
)
].join('\n')
return downloadFile(csv, filename, 'text/csv')
}, [downloadFile])
return {
isDownloading,
downloadFile,
downloadJSON,
downloadCSV
}
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react'
export function useFocusTrap<T extends HTMLElement = HTMLElement>(active = true) {
const ref = useRef<T>(null)
useEffect(() => {
if (!active || !ref.current) return
const element = ref.current
const focusableElements = element.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement?.focus()
e.preventDefault()
}
} else {
if (document.activeElement === lastElement) {
firstElement?.focus()
e.preventDefault()
}
}
}
element.addEventListener('keydown', handleTabKey)
firstElement?.focus()
return () => {
element.removeEventListener('keydown', handleTabKey)
}
}, [active])
return ref
}

View File

@@ -0,0 +1,72 @@
import { useState, useCallback } from 'react'
export function useFormState<T extends Record<string, any>>(initialState: T) {
const [values, setValues] = useState<T>(initialState)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
const [isDirty, setIsDirty] = useState(false)
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }))
setIsDirty(true)
}, [])
const setFieldError = useCallback(<K extends keyof T>(field: K, error: string) => {
setErrors(prev => ({ ...prev, [field]: error }))
}, [])
const clearFieldError = useCallback(<K extends keyof T>(field: K) => {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}, [])
const touchField = useCallback(<K extends keyof T>(field: K) => {
setTouched(prev => ({ ...prev, [field]: true }))
}, [])
const handleChange = useCallback(<K extends keyof T>(field: K) => {
return (value: T[K]) => {
setValue(field, value)
clearFieldError(field)
}
}, [setValue, clearFieldError])
const handleBlur = useCallback(<K extends keyof T>(field: K) => {
return () => {
touchField(field)
}
}, [touchField])
const reset = useCallback(() => {
setValues(initialState)
setErrors({})
setTouched({})
setIsDirty(false)
}, [initialState])
const setAllValues = useCallback((newValues: T) => {
setValues(newValues)
setIsDirty(true)
}, [])
const hasErrors = Object.keys(errors).length > 0
return {
values,
errors,
touched,
isDirty,
hasErrors,
setValue,
setFieldError,
clearFieldError,
touchField,
handleChange,
handleBlur,
reset,
setAllValues
}
}

16
src/hooks/use-interval.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react'
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (delay === null) return
const id = setInterval(() => savedCallback.current(), delay)
return () => clearInterval(id)
}, [delay])
}

48
src/hooks/use-map.ts Normal file
View File

@@ -0,0 +1,48 @@
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())
const set = useCallback((key: K, value: V) => {
setMap((prev) => {
const newMap = new Map(prev)
newMap.set(key, value)
return newMap
})
}, [])
const remove = useCallback((key: K) => {
setMap((prev) => {
const newMap = new Map(prev)
newMap.delete(key)
return newMap
})
}, [])
const clear = useCallback(() => {
setMap(new Map())
}, [])
const setAll = useCallback((entries: [K, V][]) => {
setMap(new Map(entries))
}, [])
return [
map,
{
set,
remove,
clear,
setAll
}
]
}

View File

@@ -0,0 +1,98 @@
import { useState, useCallback } from 'react'
export interface UseMultiStepFormOptions<T> {
initialData: T
steps: string[]
onComplete?: (data: T) => void | Promise<void>
}
export function useMultiStepForm<T extends Record<string, any>>({
initialData,
steps,
onComplete
}: UseMultiStepFormOptions<T>) {
const [currentStep, setCurrentStep] = useState(0)
const [formData, setFormData] = useState<T>(initialData)
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const updateData = useCallback((updates: Partial<T>) => {
setFormData(prev => ({ ...prev, ...updates }))
}, [])
const setFieldError = useCallback((field: keyof T, error: string) => {
setErrors(prev => ({ ...prev, [field]: error }))
}, [])
const clearFieldError = useCallback((field: keyof T) => {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}, [])
const clearAllErrors = useCallback(() => {
setErrors({})
}, [])
const nextStep = useCallback(() => {
if (currentStep < steps.length - 1) {
setCurrentStep(prev => prev + 1)
clearAllErrors()
}
}, [currentStep, steps.length, clearAllErrors])
const prevStep = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1)
clearAllErrors()
}
}, [currentStep, clearAllErrors])
const goToStep = useCallback((step: number) => {
if (step >= 0 && step < steps.length) {
setCurrentStep(step)
clearAllErrors()
}
}, [steps.length, clearAllErrors])
const handleSubmit = useCallback(async () => {
if (!onComplete) return
setIsSubmitting(true)
try {
await onComplete(formData)
} finally {
setIsSubmitting(false)
}
}, [formData, onComplete])
const reset = useCallback(() => {
setCurrentStep(0)
setFormData(initialData)
setErrors({})
setIsSubmitting(false)
}, [initialData])
return {
currentStep,
currentStepName: steps[currentStep],
formData,
errors,
isSubmitting,
isFirstStep: currentStep === 0,
isLastStep: currentStep === steps.length - 1,
totalSteps: steps.length,
progress: ((currentStep + 1) / steps.length) * 100,
updateData,
setFieldError,
clearFieldError,
clearAllErrors,
nextStep,
prevStep,
goToStep,
handleSubmit,
reset
}
}

View File

@@ -0,0 +1,64 @@
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]
})
return result
})
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]
})
setParams(result)
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
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
}
}

58
src/hooks/use-set.ts Normal file
View File

@@ -0,0 +1,58 @@
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>(
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)
return newSet
})
}, [])
const toggle = useCallback((item: T) => {
setSet((prev) => {
const newSet = new Set(prev)
if (newSet.has(item)) {
newSet.delete(item)
} else {
newSet.add(item)
}
return newSet
})
}, [])
const clear = useCallback(() => {
setSet(new Set())
}, [])
const has = useCallback((item: T) => {
return set.has(item)
}, [set])
return [
set,
{
add,
remove,
toggle,
clear,
has
}
]
}

40
src/hooks/use-steps.ts Normal file
View File

@@ -0,0 +1,40 @@
import { useState, useCallback } from 'react'
export interface UseStepsOptions {
initialStep?: number
totalSteps: number
}
export function useSteps({ initialStep = 0, totalSteps }: UseStepsOptions) {
const [currentStep, setCurrentStep] = useState(initialStep)
const nextStep = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1))
}, [totalSteps])
const previousStep = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0))
}, [])
const goToStep = useCallback((step: number) => {
if (step >= 0 && step < totalSteps) {
setCurrentStep(step)
}
}, [totalSteps])
const reset = useCallback(() => {
setCurrentStep(initialStep)
}, [initialStep])
return {
currentStep,
isFirstStep: currentStep === 0,
isLastStep: currentStep === totalSteps - 1,
progress: ((currentStep + 1) / totalSteps) * 100,
nextStep,
previousStep,
goToStep,
reset,
setStep: setCurrentStep
}
}

106
src/hooks/use-table.ts Normal file
View File

@@ -0,0 +1,106 @@
import { useState, useMemo } from 'react'
export interface UseTableOptions<T> {
data: T[]
pageSize?: number
initialSort?: {
key: keyof T
direction: 'asc' | 'desc'
}
}
export function useTable<T extends Record<string, any>>({
data,
pageSize = 10,
initialSort
}: UseTableOptions<T>) {
const [page, setPage] = useState(1)
const [sortKey, setSortKey] = useState<keyof T | null>(initialSort?.key || null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSort?.direction || 'asc')
const [filters, setFilters] = useState<Partial<Record<keyof T, any>>>({})
const filteredData = useMemo(() => {
return data.filter(item => {
return Object.entries(filters).every(([key, value]) => {
if (!value) return true
const itemValue = item[key as keyof T]
if (typeof value === 'string') {
return String(itemValue).toLowerCase().includes(value.toLowerCase())
}
return itemValue === value
})
})
}, [data, filters])
const sortedData = useMemo(() => {
if (!sortKey) return filteredData
return [...filteredData].sort((a, b) => {
const aVal = a[sortKey]
const bVal = b[sortKey]
if (aVal === bVal) return 0
let comparison = 0
if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal
} else {
comparison = String(aVal).localeCompare(String(bVal))
}
return sortDirection === 'asc' ? comparison : -comparison
})
}, [filteredData, sortKey, sortDirection])
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize
return sortedData.slice(start, start + pageSize)
}, [sortedData, page, pageSize])
const totalPages = Math.ceil(sortedData.length / pageSize)
const handleSort = (key: keyof T) => {
if (sortKey === key) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDirection('asc')
}
setPage(1)
}
const handleFilter = (key: keyof T, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }))
setPage(1)
}
const clearFilters = () => {
setFilters({})
setPage(1)
}
const goToPage = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage)
}
}
return {
data: paginatedData,
page,
totalPages,
totalItems: sortedData.length,
pageSize,
sortKey,
sortDirection,
filters,
handleSort,
handleFilter,
clearFilters,
goToPage,
nextPage: () => goToPage(page + 1),
prevPage: () => goToPage(page - 1),
goToFirstPage: () => goToPage(1),
goToLastPage: () => goToPage(totalPages)
}
}

16
src/hooks/use-timeout.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react'
export function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (delay === null) return
const id = setTimeout(() => savedCallback.current(), delay)
return () => clearTimeout(id)
}, [delay])
}

66
src/hooks/use-undo.ts Normal file
View File

@@ -0,0 +1,66 @@
import { useState, useCallback } from 'react'
export function useUndo<T>(initialState: T, maxHistory = 50) {
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 setState = useCallback((newState: T | ((prev: T) => T)) => {
setHistory(prevHistory => {
const currentState = prevHistory[currentIndex]
const nextState = typeof newState === 'function'
? (newState as (prev: T) => T)(currentState)
: newState
const newHistory = prevHistory.slice(0, currentIndex + 1)
newHistory.push(nextState)
if (newHistory.length > maxHistory) {
newHistory.shift()
setCurrentIndex(currentIndex)
} else {
setCurrentIndex(currentIndex + 1)
}
return newHistory
})
}, [currentIndex, maxHistory])
const undo = useCallback(() => {
if (canUndo) {
setCurrentIndex(prev => prev - 1)
}
}, [canUndo])
const redo = useCallback(() => {
if (canRedo) {
setCurrentIndex(prev => prev + 1)
}
}, [canRedo])
const reset = useCallback(() => {
setHistory([initialState])
setCurrentIndex(0)
}, [initialState])
const clear = useCallback(() => {
setHistory([current])
setCurrentIndex(0)
}, [current])
return {
state: current,
setState,
undo,
redo,
canUndo,
canRedo,
reset,
clear,
history: history.length,
currentIndex
}
}