mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Expand custom hook library, expand ui component library
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user