/** * useElementEffects Hook * * Manages element interactive effects (hover, focus, active, reveal) at runtime. * Since CSS pseudo-classes don't work with inline styles, this hook * handles state-based style application via JavaScript events. */ import { useCallback, useState, useEffect } from 'react'; import type { CSSProperties } from 'react'; import { buildHoverStyle, buildFocusStyle, buildActiveStyle, buildTransitionStyle, buildHoverRevealStyle, hasHoverEffects, hasFocusEffects, hasActiveEffects, hasHoverReveal, type ElementEffectProperties, } from '../lib/elementEffects'; interface ElementEffectState { isHovered: boolean; isFocused: boolean; isActive: boolean; isRevealed: boolean; isClickPersisted: boolean; } interface UseElementEffectsOptions { /** Key to reset reveal state (e.g., element.id or page slug) */ resetKey?: string | number; /** Whether appear animation has completed (to coordinate with reveal) */ appearAnimationCompleted?: boolean; } interface UseElementEffectsResult { /** Current effect style to merge with base element style */ effectStyle: CSSProperties; /** Event handlers to attach to the element */ eventHandlers: { onMouseEnter: () => void; onMouseLeave: () => void; onFocus: () => void; onBlur: () => void; onMouseDown: () => void; onMouseUp: () => void; onTouchStart: () => void; onTouchEnd: () => void; }; /** Call this in onClick to toggle hover persistence (if enabled) */ onPersistClick: () => void; } /** * Hook for managing element interactive effects. * * @param effects - Element effect properties * @param options - Optional configuration (resetKey, appearAnimationCompleted) * @returns Object with effectStyle and eventHandlers * * @example * const { effectStyle, eventHandlers } = useElementEffects(element, { resetKey: element.id }); * return ( *
* {content} *
* ); */ export function useElementEffects( effects: Partial, options?: UseElementEffectsOptions, ): UseElementEffectsResult { const { resetKey, appearAnimationCompleted = true } = options ?? {}; const [state, setState] = useState({ isHovered: false, isFocused: false, isActive: false, isRevealed: false, isClickPersisted: false, }); // Reset reveal state and click-persisted state when resetKey changes (e.g., page navigation) useEffect(() => { setState((prev) => ({ ...prev, isRevealed: false, isClickPersisted: false, })); }, [resetKey]); const onMouseEnter = useCallback(() => { setState((prev) => ({ ...prev, isHovered: true, isRevealed: effects.hoverReveal ? true : prev.isRevealed, })); }, [effects.hoverReveal]); const onMouseLeave = useCallback(() => { setState((prev) => ({ ...prev, isHovered: false, isActive: false })); }, []); const onFocus = useCallback(() => { setState((prev) => ({ ...prev, isFocused: true })); }, []); const onBlur = useCallback(() => { setState((prev) => ({ ...prev, isFocused: false })); }, []); const onMouseDown = useCallback(() => { setState((prev) => ({ ...prev, isActive: true })); }, []); const onMouseUp = useCallback(() => { setState((prev) => ({ ...prev, isActive: false })); }, []); // Touch handlers for mobile devices const onTouchStart = useCallback(() => { setState((prev) => ({ ...prev, isHovered: true, isRevealed: effects.hoverReveal ? true : prev.isRevealed, })); }, [effects.hoverReveal]); const onTouchEnd = useCallback(() => { // For hover reveal, keep visibility on touch (like persist mode) if (!effects.hoverReveal) { setState((prev) => ({ ...prev, isHovered: false, isActive: false })); } }, [effects.hoverReveal]); // Click handler for toggling hover persistence const onClick = useCallback(() => { if (effects.hoverPersistOnClick) { // Toggle: first click persists, second click removes, third persists, etc. setState((prev) => ({ ...prev, isClickPersisted: !prev.isClickPersisted, })); } }, [effects.hoverPersistOnClick]); // Build effective style based on current state // Priority: active > focus > hover reveal > hover > base let effectStyle: CSSProperties = {}; // Add transition for smooth effect changes if ( hasHoverEffects(effects) || hasFocusEffects(effects) || hasActiveEffects(effects) || hasHoverReveal(effects) ) { effectStyle = { ...effectStyle, ...buildTransitionStyle(effects) }; } // Apply hover reveal style (controls opacity for reveal elements) // Only apply initial opacity AFTER appear animation completes to avoid conflict if (hasHoverReveal(effects) && appearAnimationCompleted) { // With persist OR click-persisted: stays visible // Without: only visible while hovering const shouldShow = effects.hoverRevealPersist || state.isClickPersisted ? state.isRevealed || state.isClickPersisted : state.isHovered; effectStyle = { ...effectStyle, ...buildHoverRevealStyle(effects, shouldShow), }; } // Apply hover effects when hovered OR click-persisted (but not active) // Skip opacity when hoverReveal is active (reveal controls it) const shouldApplyHover = state.isHovered || state.isClickPersisted; if (shouldApplyHover && !state.isActive && hasHoverEffects(effects)) { const hoverStyle = buildHoverStyle(effects); if (hasHoverReveal(effects)) { // Exclude opacity from hover style when reveal is active const { opacity, ...restHoverStyle } = hoverStyle; effectStyle = { ...effectStyle, ...restHoverStyle }; } else { effectStyle = { ...effectStyle, ...hoverStyle }; } } // Apply focus effects when focused if (state.isFocused && hasFocusEffects(effects)) { effectStyle = { ...effectStyle, ...buildFocusStyle(effects) }; } // Apply active effects when pressed (highest priority) if (state.isActive && hasActiveEffects(effects)) { effectStyle = { ...effectStyle, ...buildActiveStyle(effects) }; } return { effectStyle, eventHandlers: { onMouseEnter, onMouseLeave, onFocus, onBlur, onMouseDown, onMouseUp, onTouchStart, onTouchEnd, }, onPersistClick: onClick, }; }