From e2e3581d06c41dd008a3ce40aa19727f097edbd3 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Mon, 1 Dec 2025 20:11:41 +0000 Subject: [PATCH] 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 --- .kiro/specs/ultimate-lists-app/tasks.md | 4 +- components/GlobalSearch.tsx | 16 +- components/ListDetailView.tsx | 56 +++--- components/SearchBar.tsx | 28 ++- hooks/README.md | 232 ++++++++++++++++++++++++ hooks/index.ts | 10 + hooks/useFilters.ts | 97 ++++++++++ hooks/useOptimisticUpdate.ts | 138 ++++++++++++++ hooks/useScrollPosition.ts | 208 +++++++++++++++++++++ hooks/useSearch.ts | 84 +++++++++ 10 files changed, 821 insertions(+), 52 deletions(-) create mode 100644 hooks/README.md create mode 100644 hooks/index.ts create mode 100644 hooks/useFilters.ts create mode 100644 hooks/useOptimisticUpdate.ts create mode 100644 hooks/useScrollPosition.ts create mode 100644 hooks/useSearch.ts diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 14c2eae..503d6fc 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -120,7 +120,7 @@ - **Property 14: Overdue task identification** - **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 - Build FilterPanel component with controls for priority, completion, tags, and due date - Add sort controls (field and direction) @@ -136,7 +136,7 @@ - **Property 23: Search clear restoration** - **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 useFilters hook for filter state management - Create useOptimisticUpdate hook for optimistic UI updates diff --git a/components/GlobalSearch.tsx b/components/GlobalSearch.tsx index d2f2572..3273de0 100644 --- a/components/GlobalSearch.tsx +++ b/components/GlobalSearch.tsx @@ -4,6 +4,7 @@ import { useState, useCallback } from 'react' import SearchBar from './SearchBar' import SearchResults from './SearchResults' import { searchItems } from '@/lib/actions' +import { useSearch } from '@/hooks' interface ItemWithList { id: string @@ -22,13 +23,10 @@ interface ItemWithList { } export default function GlobalSearch() { - const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const handleSearch = useCallback(async (query: string) => { - setSearchQuery(query) - if (!query.trim()) { setSearchResults([]) setIsSearching(false) @@ -47,10 +45,14 @@ export default function GlobalSearch() { } }, []) + const { debouncedQuery, clearSearch } = useSearch({ + onSearch: handleSearch + }) + const handleClose = useCallback(() => { - setSearchQuery('') + clearSearch() setSearchResults([]) - }, []) + }, [clearSearch]) return (
@@ -59,10 +61,10 @@ export default function GlobalSearch() { placeholder="Search all tasks..." /> - {searchQuery.trim() && ( + {debouncedQuery.trim() && ( )} diff --git a/components/ListDetailView.tsx b/components/ListDetailView.tsx index 4ed74cf..a64067b 100644 --- a/components/ListDetailView.tsx +++ b/components/ListDetailView.tsx @@ -1,11 +1,12 @@ 'use client' -import { useState, useCallback, useMemo } from 'react' +import { useState, useCallback, useMemo, useEffect } from 'react' import ItemCard from './ItemCard' import SearchBar from './SearchBar' -import FilterPanel, { FilterState } from './FilterPanel' +import FilterPanel from './FilterPanel' import SearchResults from './SearchResults' import { searchItems, getItemsByFilter } from '@/lib/actions' +import { useSearch, useFilters, useScrollPosition } from '@/hooks' interface Item { id: string @@ -35,21 +36,13 @@ interface ListDetailViewProps { } export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) { - const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [isSearching, setIsSearching] = useState(false) - - const [filters, setFilters] = useState({ - priority: null, - completed: null, - tags: [], - dueDateRange: { start: null, end: null }, - sortBy: 'createdAt', - sortOrder: 'desc' - }) - const [filteredItems, setFilteredItems] = useState(initialItems) + // Use scroll position preservation hook + useScrollPosition({ key: `list-${listId}` }) + // Extract all unique tags from items const availableTags = useMemo(() => { const tagSet = new Set() @@ -64,10 +57,8 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP return Array.from(tagSet).sort() }, [initialItems]) - // Handle search - const handleSearch = useCallback(async (query: string) => { - setSearchQuery(query) - + // Use search hook + const handleSearchCallback = useCallback(async (query: string) => { if (!query.trim()) { setSearchResults([]) setIsSearching(false) @@ -81,13 +72,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP } catch (error) { console.error('Search error:', error) setSearchResults([]) + } finally { + setIsSearching(false) } }, [listId]) - // Handle filter changes - const handleFiltersChange = useCallback(async (newFilters: FilterState) => { - setFilters(newFilters) - + const { debouncedQuery } = useSearch({ + onSearch: handleSearchCallback + }) + + // Use filters hook + const handleFiltersCallback = useCallback(async (newFilters: any) => { try { const results = await getItemsByFilter(listId, { priority: newFilters.priority, @@ -104,8 +99,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP } }, [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 - const displayItems = searchQuery.trim() ? searchResults : filteredItems + const displayItems = debouncedQuery.trim() ? searchResults : filteredItems const completedItems = displayItems.filter(item => item.completed) const incompleteItems = displayItems.filter(item => !item.completed) @@ -113,22 +117,22 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
{/* Search Bar */} {/* Filter Panel */} {/* Results */} - {searchQuery.trim() ? ( + {debouncedQuery.trim() ? ( ) : (
diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index bae1850..de81c67 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' import { Search, X } from 'lucide-react' +import { useSearch } from '@/hooks' interface SearchBarProps { onSearch: (query: string) => void @@ -16,21 +16,10 @@ export default function SearchBar({ debounceMs = 300, initialValue = '' }: SearchBarProps) { - const [query, setQuery] = useState(initialValue) - - // Debounced search effect - useEffect(() => { - const timer = setTimeout(() => { - onSearch(query) - }, debounceMs) - - return () => clearTimeout(timer) - }, [query, debounceMs, onSearch]) - - const handleClear = useCallback(() => { - setQuery('') - onSearch('') - }, [onSearch]) + const { query, setQuery, clearSearch, isDebouncing } = useSearch({ + debounceMs, + onSearch + }) return (
@@ -48,13 +37,18 @@ export default function SearchBar({ /> {query && ( )} + {isDebouncing && ( +
+
+
+ )}
) diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..62389eb --- /dev/null +++ b/hooks/README.md @@ -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 ( + 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 ( +
+ + {hasActiveFilters && ( + + )} +
+ ) +} +``` + +### `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 ( + + ) +} +``` + +### `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 ( +
+ {optimisticList.map(item => ( +
+ {item.title} + +
+ ))} +
+ ) +} +``` + +### `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 ( +
+ {/* Your scrollable content */} +
+ ) +} +``` + +### `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 ( +
+ {/* Your scrollable content */} +
+ ) +} +``` + +## 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') +}) +``` diff --git a/hooks/index.ts b/hooks/index.ts new file mode 100644 index 0000000..8114a7d --- /dev/null +++ b/hooks/index.ts @@ -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' diff --git a/hooks/useFilters.ts b/hooks/useFilters.ts new file mode 100644 index 0000000..1726c40 --- /dev/null +++ b/hooks/useFilters.ts @@ -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 + onFiltersChange?: (filters: FilterState) => void | Promise +} + +interface UseFiltersReturn { + filters: FilterState + setFilters: (filters: FilterState) => void + updateFilter: (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({ + ...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(( + 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 + } +} diff --git a/hooks/useOptimisticUpdate.ts b/hooks/useOptimisticUpdate.ts new file mode 100644 index 0000000..ede08b9 --- /dev/null +++ b/hooks/useOptimisticUpdate.ts @@ -0,0 +1,138 @@ +'use client' + +import { useState, useCallback, useTransition } from 'react' + +interface UseOptimisticUpdateOptions { + onUpdate: (data: T) => Promise + onError?: (error: Error) => void +} + +interface UseOptimisticUpdateReturn { + optimisticData: T | null + isPending: boolean + error: Error | null + performUpdate: (data: T) => Promise + reset: () => void +} + +/** + * Custom hook for optimistic UI updates + * Provides immediate UI feedback while server action is processing + * Validates: Requirements 7.3 + */ +export function useOptimisticUpdate( + options: UseOptimisticUpdateOptions +): UseOptimisticUpdateReturn { + const { onUpdate, onError } = options + + const [isPending, startTransition] = useTransition() + const [optimisticData, setOptimisticData] = useState(null) + const [error, setError] = useState(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( + initialData: T[], + onUpdate: (data: T[]) => Promise +) { + const [optimisticList, setOptimisticList] = useState(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) => { + 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 + } +} diff --git a/hooks/useScrollPosition.ts b/hooks/useScrollPosition.ts new file mode 100644 index 0000000..9176483 --- /dev/null +++ b/hooks/useScrollPosition.ts @@ -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(0) + const timeoutRef = useRef(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(null) + const scrollPositionRef = useRef(0) + const timeoutRef = useRef(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 + } +} diff --git a/hooks/useSearch.ts b/hooks/useSearch.ts new file mode 100644 index 0000000..6c8239f --- /dev/null +++ b/hooks/useSearch.ts @@ -0,0 +1,84 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' + +interface UseSearchOptions { + debounceMs?: number + onSearch?: (query: string) => void | Promise +} + +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(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 + } +}