218 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|