feat: implement client-side state management hooks

- Created useSearch hook for search state and debouncing
- Created useFilters hook for filter state management
- Created useOptimisticUpdate hook for optimistic UI updates
- Created useScrollPosition hook for scroll preservation
- Updated SearchBar, GlobalSearch, and ListDetailView to use new hooks
- Added comprehensive documentation for all hooks

Validates: Requirements 6.4, 7.3, 7.4, 8.3
Task: 9. Implement client-side state management
This commit is contained in:
2025-12-01 20:11:41 +00:00
parent 0143097e69
commit e2e3581d06
10 changed files with 821 additions and 52 deletions

View File

@@ -120,7 +120,7 @@
- **Property 14: Overdue task identification** - **Property 14: Overdue task identification**
- **Validates: Requirements 5.5** - **Validates: Requirements 5.5**
- [-] 8. Add search and filter UI components - [x] 8. Add search and filter UI components
- Create SearchBar component with debounced input - Create SearchBar component with debounced input
- Build FilterPanel component with controls for priority, completion, tags, and due date - Build FilterPanel component with controls for priority, completion, tags, and due date
- Add sort controls (field and direction) - Add sort controls (field and direction)
@@ -136,7 +136,7 @@
- **Property 23: Search clear restoration** - **Property 23: Search clear restoration**
- **Validates: Requirements 8.3** - **Validates: Requirements 8.3**
- [ ] 9. Implement client-side state management - [x] 9. Implement client-side state management
- Create useSearch hook for search state and debouncing - Create useSearch hook for search state and debouncing
- Create useFilters hook for filter state management - Create useFilters hook for filter state management
- Create useOptimisticUpdate hook for optimistic UI updates - Create useOptimisticUpdate hook for optimistic UI updates

View File

@@ -4,6 +4,7 @@ import { useState, useCallback } from 'react'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
import SearchResults from './SearchResults' import SearchResults from './SearchResults'
import { searchItems } from '@/lib/actions' import { searchItems } from '@/lib/actions'
import { useSearch } from '@/hooks'
interface ItemWithList { interface ItemWithList {
id: string id: string
@@ -22,13 +23,10 @@ interface ItemWithList {
} }
export default function GlobalSearch() { export default function GlobalSearch() {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ItemWithList[]>([]) const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const handleSearch = useCallback(async (query: string) => { const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query)
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]) setSearchResults([])
setIsSearching(false) setIsSearching(false)
@@ -47,10 +45,14 @@ export default function GlobalSearch() {
} }
}, []) }, [])
const { debouncedQuery, clearSearch } = useSearch({
onSearch: handleSearch
})
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSearchQuery('') clearSearch()
setSearchResults([]) setSearchResults([])
}, []) }, [clearSearch])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -59,10 +61,10 @@ export default function GlobalSearch() {
placeholder="Search all tasks..." placeholder="Search all tasks..."
/> />
{searchQuery.trim() && ( {debouncedQuery.trim() && (
<SearchResults <SearchResults
results={searchResults} results={searchResults}
query={searchQuery} query={debouncedQuery}
onClose={handleClose} onClose={handleClose}
/> />
)} )}

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useState, useCallback, useMemo } from 'react' import { useState, useCallback, useMemo, useEffect } from 'react'
import ItemCard from './ItemCard' import ItemCard from './ItemCard'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
import FilterPanel, { FilterState } from './FilterPanel' import FilterPanel from './FilterPanel'
import SearchResults from './SearchResults' import SearchResults from './SearchResults'
import { searchItems, getItemsByFilter } from '@/lib/actions' import { searchItems, getItemsByFilter } from '@/lib/actions'
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
interface Item { interface Item {
id: string id: string
@@ -35,21 +36,13 @@ interface ListDetailViewProps {
} }
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) { export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ItemWithList[]>([]) const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const [filters, setFilters] = useState<FilterState>({
priority: null,
completed: null,
tags: [],
dueDateRange: { start: null, end: null },
sortBy: 'createdAt',
sortOrder: 'desc'
})
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems) const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
// Use scroll position preservation hook
useScrollPosition({ key: `list-${listId}` })
// Extract all unique tags from items // Extract all unique tags from items
const availableTags = useMemo(() => { const availableTags = useMemo(() => {
const tagSet = new Set<string>() const tagSet = new Set<string>()
@@ -64,10 +57,8 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
return Array.from(tagSet).sort() return Array.from(tagSet).sort()
}, [initialItems]) }, [initialItems])
// Handle search // Use search hook
const handleSearch = useCallback(async (query: string) => { const handleSearchCallback = useCallback(async (query: string) => {
setSearchQuery(query)
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]) setSearchResults([])
setIsSearching(false) setIsSearching(false)
@@ -81,13 +72,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
} catch (error) { } catch (error) {
console.error('Search error:', error) console.error('Search error:', error)
setSearchResults([]) setSearchResults([])
} finally {
setIsSearching(false)
} }
}, [listId]) }, [listId])
// Handle filter changes const { debouncedQuery } = useSearch({
const handleFiltersChange = useCallback(async (newFilters: FilterState) => { onSearch: handleSearchCallback
setFilters(newFilters) })
// Use filters hook
const handleFiltersCallback = useCallback(async (newFilters: any) => {
try { try {
const results = await getItemsByFilter(listId, { const results = await getItemsByFilter(listId, {
priority: newFilters.priority, priority: newFilters.priority,
@@ -104,8 +99,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
} }
}, [listId, initialItems]) }, [listId, initialItems])
const { filters, setFilters } = useFilters({
onFiltersChange: handleFiltersCallback
})
// Apply initial filters on mount
useEffect(() => {
handleFiltersCallback(filters)
}, []) // Only run on mount
// Determine which items to display // Determine which items to display
const displayItems = searchQuery.trim() ? searchResults : filteredItems const displayItems = debouncedQuery.trim() ? searchResults : filteredItems
const completedItems = displayItems.filter(item => item.completed) const completedItems = displayItems.filter(item => item.completed)
const incompleteItems = displayItems.filter(item => !item.completed) const incompleteItems = displayItems.filter(item => !item.completed)
@@ -113,22 +117,22 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
<div className="space-y-6"> <div className="space-y-6">
{/* Search Bar */} {/* Search Bar */}
<SearchBar <SearchBar
onSearch={handleSearch} onSearch={handleSearchCallback}
placeholder="Search tasks in this list..." placeholder="Search tasks in this list..."
/> />
{/* Filter Panel */} {/* Filter Panel */}
<FilterPanel <FilterPanel
filters={filters} filters={filters}
onFiltersChange={handleFiltersChange} onFiltersChange={setFilters}
availableTags={availableTags} availableTags={availableTags}
/> />
{/* Results */} {/* Results */}
{searchQuery.trim() ? ( {debouncedQuery.trim() ? (
<SearchResults <SearchResults
results={searchResults} results={searchResults}
query={searchQuery} query={debouncedQuery}
/> />
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react'
import { Search, X } from 'lucide-react' import { Search, X } from 'lucide-react'
import { useSearch } from '@/hooks'
interface SearchBarProps { interface SearchBarProps {
onSearch: (query: string) => void onSearch: (query: string) => void
@@ -16,21 +16,10 @@ export default function SearchBar({
debounceMs = 300, debounceMs = 300,
initialValue = '' initialValue = ''
}: SearchBarProps) { }: SearchBarProps) {
const [query, setQuery] = useState(initialValue) const { query, setQuery, clearSearch, isDebouncing } = useSearch({
debounceMs,
// Debounced search effect onSearch
useEffect(() => { })
const timer = setTimeout(() => {
onSearch(query)
}, debounceMs)
return () => clearTimeout(timer)
}, [query, debounceMs, onSearch])
const handleClear = useCallback(() => {
setQuery('')
onSearch('')
}, [onSearch])
return ( return (
<div className="relative"> <div className="relative">
@@ -48,13 +37,18 @@ export default function SearchBar({
/> />
{query && ( {query && (
<button <button
onClick={handleClear} onClick={clearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Clear search" aria-label="Clear search"
> >
<X size={20} /> <X size={20} />
</button> </button>
)} )}
{isDebouncing && (
<div className="absolute right-10 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
</div> </div>
</div> </div>
) )

232
hooks/README.md Normal file
View File

@@ -0,0 +1,232 @@
# Custom Hooks Documentation
This directory contains custom React hooks for client-side state management in the Ultimate Lists App.
## Hooks Overview
### `useSearch`
Manages search state with debouncing functionality.
**Features:**
- Automatic debouncing of search queries
- Immediate clear functionality
- Tracks debouncing state
**Usage:**
```typescript
import { useSearch } from '@/hooks'
function MyComponent() {
const { query, setQuery, debouncedQuery, isDebouncing, clearSearch } = useSearch({
debounceMs: 300,
onSearch: async (query) => {
// Perform search with debounced query
const results = await searchItems(query)
}
})
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
)
}
```
### `useFilters`
Manages filter state for sorting and filtering items.
**Features:**
- Centralized filter state management
- Detects active filters
- Easy filter clearing
- Individual filter updates
**Usage:**
```typescript
import { useFilters } from '@/hooks'
function MyComponent() {
const { filters, setFilters, updateFilter, clearFilters, hasActiveFilters } = useFilters({
initialFilters: { sortBy: 'createdAt' },
onFiltersChange: async (filters) => {
// Apply filters
const results = await getItemsByFilter(listId, filters)
}
})
return (
<div>
<button onClick={() => updateFilter('priority', 'high')}>
High Priority
</button>
{hasActiveFilters && (
<button onClick={clearFilters}>Clear Filters</button>
)}
</div>
)
}
```
### `useOptimisticUpdate`
Provides optimistic UI updates for better perceived performance.
**Features:**
- Immediate UI feedback
- Automatic rollback on error
- Pending state tracking
- Error handling
**Usage:**
```typescript
import { useOptimisticUpdate } from '@/hooks'
function MyComponent() {
const { optimisticData, isPending, error, performUpdate } = useOptimisticUpdate({
onUpdate: async (data) => {
await updateItem(data.id, data)
},
onError: (error) => {
console.error('Update failed:', error)
}
})
const handleToggle = async () => {
await performUpdate({ id: '123', completed: true })
}
return (
<button onClick={handleToggle} disabled={isPending}>
{isPending ? 'Updating...' : 'Toggle'}
</button>
)
}
```
### `useOptimisticList`
Specialized hook for managing lists with optimistic updates.
**Usage:**
```typescript
import { useOptimisticList } from '@/hooks'
function MyComponent({ initialItems }) {
const { optimisticList, isPending, addItem, updateItem, removeItem } = useOptimisticList(
initialItems,
async (updatedList) => {
// Sync with server
await syncList(updatedList)
}
)
return (
<div>
{optimisticList.map(item => (
<div key={item.id}>
{item.title}
<button onClick={() => removeItem(item.id)}>Delete</button>
</div>
))}
</div>
)
}
```
### `useScrollPosition`
Preserves scroll position across navigation for better UX.
**Features:**
- Automatic scroll position saving
- Restoration on component mount
- Debounced save operations
- SessionStorage-based persistence
**Usage:**
```typescript
import { useScrollPosition } from '@/hooks'
function MyComponent({ listId }) {
// Automatically saves and restores scroll position
useScrollPosition({
key: `list-${listId}`,
enabled: true,
debounceMs: 100
})
return (
<div>
{/* Your scrollable content */}
</div>
)
}
```
### `useContainerScrollPosition`
Similar to `useScrollPosition` but for specific container elements.
**Usage:**
```typescript
import { useContainerScrollPosition } from '@/hooks'
function MyComponent({ listId }) {
const { containerRef } = useContainerScrollPosition({
key: `container-${listId}`
})
return (
<div ref={containerRef} style={{ height: '400px', overflow: 'auto' }}>
{/* Your scrollable content */}
</div>
)
}
```
## Requirements Validation
These hooks implement the following requirements from the specification:
- **Requirement 6.4**: Filter state management and clearing
- **Requirement 7.3**: Visual feedback for user actions (optimistic updates)
- **Requirement 7.4**: Scroll position preservation
- **Requirement 8.3**: Search clearing and restoration
## Best Practices
1. **Search Debouncing**: Use appropriate debounce times (300ms is a good default)
2. **Filter State**: Keep filter state in sync with URL params for shareable links
3. **Optimistic Updates**: Always provide rollback on error
4. **Scroll Position**: Use unique keys for different views to avoid conflicts
5. **Error Handling**: Always handle errors in async callbacks
## Testing
These hooks are designed to be testable with React Testing Library:
```typescript
import { renderHook, act } from '@testing-library/react'
import { useSearch } from '@/hooks'
test('useSearch debounces queries', async () => {
const onSearch = jest.fn()
const { result } = renderHook(() => useSearch({ onSearch, debounceMs: 100 }))
act(() => {
result.current.setQuery('test')
})
expect(onSearch).not.toHaveBeenCalled()
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 150))
})
expect(onSearch).toHaveBeenCalledWith('test')
})
```

10
hooks/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Custom hooks for client-side state management
* Implements task 9 from the Ultimate Lists App specification
*/
export { useSearch } from './useSearch'
export { useFilters } from './useFilters'
export type { FilterState } from './useFilters'
export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'

97
hooks/useFilters.ts Normal file
View File

@@ -0,0 +1,97 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
export interface FilterState {
priority: 'low' | 'medium' | 'high' | null
completed: boolean | null
tags: string[]
dueDateRange: { start: Date | null; end: Date | null }
sortBy: 'createdAt' | 'dueDate' | 'priority' | 'title'
sortOrder: 'asc' | 'desc'
}
interface UseFiltersOptions {
initialFilters?: Partial<FilterState>
onFiltersChange?: (filters: FilterState) => void | Promise<void>
}
interface UseFiltersReturn {
filters: FilterState
setFilters: (filters: FilterState) => void
updateFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void
clearFilters: () => void
hasActiveFilters: boolean
}
const DEFAULT_FILTERS: FilterState = {
priority: null,
completed: null,
tags: [],
dueDateRange: { start: null, end: null },
sortBy: 'createdAt',
sortOrder: 'desc'
}
/**
* Custom hook for managing filter state
* Validates: Requirements 6.4
*/
export function useFilters(options: UseFiltersOptions = {}): UseFiltersReturn {
const { initialFilters = {}, onFiltersChange } = options
const [filters, setFiltersState] = useState<FilterState>({
...DEFAULT_FILTERS,
...initialFilters
})
// Check if any filters are active (excluding sort options)
const hasActiveFilters = useMemo(() => {
return (
filters.priority !== null ||
filters.completed !== null ||
filters.tags.length > 0 ||
filters.dueDateRange.start !== null ||
filters.dueDateRange.end !== null
)
}, [filters])
// Set filters with callback
const setFilters = useCallback((newFilters: FilterState) => {
setFiltersState(newFilters)
if (onFiltersChange) {
onFiltersChange(newFilters)
}
}, [onFiltersChange])
// Update a single filter property
const updateFilter = useCallback(<K extends keyof FilterState>(
key: K,
value: FilterState[K]
) => {
setFiltersState(prev => {
const newFilters = { ...prev, [key]: value }
if (onFiltersChange) {
onFiltersChange(newFilters)
}
return newFilters
})
}, [onFiltersChange])
// Clear all filters (reset to defaults)
const clearFilters = useCallback(() => {
const clearedFilters = { ...DEFAULT_FILTERS }
setFiltersState(clearedFilters)
if (onFiltersChange) {
onFiltersChange(clearedFilters)
}
}, [onFiltersChange])
return {
filters,
setFilters,
updateFilter,
clearFilters,
hasActiveFilters
}
}

View File

@@ -0,0 +1,138 @@
'use client'
import { useState, useCallback, useTransition } from 'react'
interface UseOptimisticUpdateOptions<T> {
onUpdate: (data: T) => Promise<void>
onError?: (error: Error) => void
}
interface UseOptimisticUpdateReturn<T> {
optimisticData: T | null
isPending: boolean
error: Error | null
performUpdate: (data: T) => Promise<void>
reset: () => void
}
/**
* Custom hook for optimistic UI updates
* Provides immediate UI feedback while server action is processing
* Validates: Requirements 7.3
*/
export function useOptimisticUpdate<T>(
options: UseOptimisticUpdateOptions<T>
): UseOptimisticUpdateReturn<T> {
const { onUpdate, onError } = options
const [isPending, startTransition] = useTransition()
const [optimisticData, setOptimisticData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const performUpdate = useCallback(async (data: T) => {
// Clear any previous errors
setError(null)
// Set optimistic data immediately for instant UI feedback
setOptimisticData(data)
// Perform the actual update in a transition
startTransition(async () => {
try {
await onUpdate(data)
// Clear optimistic data after successful update
setOptimisticData(null)
} catch (err) {
const error = err instanceof Error ? err : new Error('Update failed')
setError(error)
// Clear optimistic data on error
setOptimisticData(null)
if (onError) {
onError(error)
}
}
})
}, [onUpdate, onError])
const reset = useCallback(() => {
setOptimisticData(null)
setError(null)
}, [])
return {
optimisticData,
isPending,
error,
performUpdate,
reset
}
}
/**
* Simplified hook for optimistic list updates
* Useful for managing lists of items with optimistic updates
*/
export function useOptimisticList<T extends { id: string }>(
initialData: T[],
onUpdate: (data: T[]) => Promise<void>
) {
const [optimisticList, setOptimisticList] = useState<T[]>(initialData)
const [isPending, startTransition] = useTransition()
const addItem = useCallback((item: T) => {
setOptimisticList(prev => [...prev, item])
startTransition(async () => {
try {
await onUpdate([...optimisticList, item])
} catch (error) {
// Rollback on error
setOptimisticList(initialData)
console.error('Failed to add item:', error)
}
})
}, [optimisticList, initialData, onUpdate])
const updateItem = useCallback((id: string, updates: Partial<T>) => {
setOptimisticList(prev =>
prev.map(item => (item.id === id ? { ...item, ...updates } : item))
)
startTransition(async () => {
try {
const updatedList = optimisticList.map(item =>
item.id === id ? { ...item, ...updates } : item
)
await onUpdate(updatedList)
} catch (error) {
// Rollback on error
setOptimisticList(initialData)
console.error('Failed to update item:', error)
}
})
}, [optimisticList, initialData, onUpdate])
const removeItem = useCallback((id: string) => {
setOptimisticList(prev => prev.filter(item => item.id !== id))
startTransition(async () => {
try {
const updatedList = optimisticList.filter(item => item.id !== id)
await onUpdate(updatedList)
} catch (error) {
// Rollback on error
setOptimisticList(initialData)
console.error('Failed to remove item:', error)
}
})
}, [optimisticList, initialData, onUpdate])
return {
optimisticList,
isPending,
addItem,
updateItem,
removeItem
}
}

208
hooks/useScrollPosition.ts Normal file
View File

@@ -0,0 +1,208 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
interface UseScrollPositionOptions {
key: string
enabled?: boolean
debounceMs?: number
}
/**
* Custom hook for preserving scroll position across navigation
* Validates: Requirements 7.4
*/
export function useScrollPosition(options: UseScrollPositionOptions) {
const { key, enabled = true, debounceMs = 100 } = options
const scrollPositionRef = useRef<number>(0)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isRestoringRef = useRef(false)
// Save scroll position to sessionStorage
const saveScrollPosition = useCallback((position: number) => {
if (!enabled) return
try {
sessionStorage.setItem(`scroll-${key}`, position.toString())
scrollPositionRef.current = position
} catch (error) {
console.warn('Failed to save scroll position:', error)
}
}, [key, enabled])
// Restore scroll position from sessionStorage
const restoreScrollPosition = useCallback(() => {
if (!enabled) return
try {
const savedPosition = sessionStorage.getItem(`scroll-${key}`)
if (savedPosition !== null) {
const position = parseInt(savedPosition, 10)
if (!isNaN(position)) {
isRestoringRef.current = true
window.scrollTo(0, position)
scrollPositionRef.current = position
// Reset flag after a short delay
setTimeout(() => {
isRestoringRef.current = false
}, 100)
}
}
} catch (error) {
console.warn('Failed to restore scroll position:', error)
}
}, [key, enabled])
// Clear saved scroll position
const clearScrollPosition = useCallback(() => {
try {
sessionStorage.removeItem(`scroll-${key}`)
scrollPositionRef.current = 0
} catch (error) {
console.warn('Failed to clear scroll position:', error)
}
}, [key])
// Handle scroll events with debouncing
useEffect(() => {
if (!enabled) return
const handleScroll = () => {
// Don't save position if we're currently restoring
if (isRestoringRef.current) return
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Debounce the save operation
timeoutRef.current = setTimeout(() => {
const position = window.scrollY || window.pageYOffset
saveScrollPosition(position)
}, debounceMs)
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
window.removeEventListener('scroll', handleScroll)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [enabled, debounceMs, saveScrollPosition])
// Restore scroll position on mount
useEffect(() => {
if (enabled) {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
restoreScrollPosition()
})
}
}, [enabled, restoreScrollPosition])
return {
saveScrollPosition,
restoreScrollPosition,
clearScrollPosition,
currentPosition: scrollPositionRef.current
}
}
/**
* Hook for preserving scroll position within a specific container element
*/
export function useContainerScrollPosition(options: UseScrollPositionOptions) {
const { key, enabled = true, debounceMs = 100 } = options
const containerRef = useRef<HTMLElement | null>(null)
const scrollPositionRef = useRef<number>(0)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isRestoringRef = useRef(false)
// Save scroll position
const saveScrollPosition = useCallback((position: number) => {
if (!enabled) return
try {
sessionStorage.setItem(`container-scroll-${key}`, position.toString())
scrollPositionRef.current = position
} catch (error) {
console.warn('Failed to save container scroll position:', error)
}
}, [key, enabled])
// Restore scroll position
const restoreScrollPosition = useCallback(() => {
if (!enabled || !containerRef.current) return
try {
const savedPosition = sessionStorage.getItem(`container-scroll-${key}`)
if (savedPosition !== null) {
const position = parseInt(savedPosition, 10)
if (!isNaN(position)) {
isRestoringRef.current = true
containerRef.current.scrollTop = position
scrollPositionRef.current = position
setTimeout(() => {
isRestoringRef.current = false
}, 100)
}
}
} catch (error) {
console.warn('Failed to restore container scroll position:', error)
}
}, [key, enabled])
// Clear saved scroll position
const clearScrollPosition = useCallback(() => {
try {
sessionStorage.removeItem(`container-scroll-${key}`)
scrollPositionRef.current = 0
} catch (error) {
console.warn('Failed to clear container scroll position:', error)
}
}, [key])
// Set up scroll listener on container
useEffect(() => {
if (!enabled || !containerRef.current) return
const container = containerRef.current
const handleScroll = () => {
if (isRestoringRef.current) return
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
const position = container.scrollTop
saveScrollPosition(position)
}, debounceMs)
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => {
container.removeEventListener('scroll', handleScroll)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [enabled, debounceMs, saveScrollPosition])
return {
containerRef,
saveScrollPosition,
restoreScrollPosition,
clearScrollPosition,
currentPosition: scrollPositionRef.current
}
}

84
hooks/useSearch.ts Normal file
View File

@@ -0,0 +1,84 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface UseSearchOptions {
debounceMs?: number
onSearch?: (query: string) => void | Promise<void>
}
interface UseSearchReturn {
query: string
setQuery: (query: string) => void
debouncedQuery: string
isDebouncing: boolean
clearSearch: () => void
}
/**
* Custom hook for managing search state with debouncing
* Validates: Requirements 6.4, 8.3
*/
export function useSearch(options: UseSearchOptions = {}): UseSearchReturn {
const { debounceMs = 300, onSearch } = options
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [isDebouncing, setIsDebouncing] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// Debounce the query
useEffect(() => {
// If query is empty, update immediately
if (!query.trim()) {
setDebouncedQuery('')
setIsDebouncing(false)
return
}
setIsDebouncing(true)
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
setDebouncedQuery(query)
setIsDebouncing(false)
}, debounceMs)
// Cleanup
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [query, debounceMs])
// Call onSearch callback when debounced query changes
useEffect(() => {
if (onSearch) {
onSearch(debouncedQuery)
}
}, [debouncedQuery, onSearch])
// Clear search function
const clearSearch = useCallback(() => {
setQuery('')
setDebouncedQuery('')
setIsDebouncing(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}, [])
return {
query,
setQuery,
debouncedQuery,
isDebouncing,
clearSearch
}
}