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:
208
hooks/useScrollPosition.ts
Normal file
208
hooks/useScrollPosition.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user