39948-vm/frontend/src/hooks/useElementEffects.ts
2026-05-28 07:19:36 +00:00

218 lines
6.3 KiB
TypeScript

/**
* 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 (
* <div style={{ ...baseStyle, ...effectStyle }} {...eventHandlers}>
* {content}
* </div>
* );
*/
export function useElementEffects(
effects: Partial<ElementEffectProperties>,
options?: UseElementEffectsOptions,
): UseElementEffectsResult {
const { resetKey, appearAnimationCompleted = true } = options ?? {};
const [state, setState] = useState<ElementEffectState>({
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,
};
}