/* eslint-disable jsdoc/require-jsdoc */ import * as React from 'react'; import { classNames, noop } from '@vkontakte/vkjs'; import { mergeCalls } from '../../lib/mergeCalls'; import { useStateWithDelay } from './useStateWithDelay'; export interface StateProps { /** * Указывает, должен ли компонент реагировать на `hover`-состояние. */ hasHover?: boolean; /** * Позволяет управлять `hovered`-состоянием извне. */ hovered?: boolean; /** * Позволяет управлять `activated`-состоянием извне. */ activated?: boolean; /** * Указывает, должен ли компонент реагировать на `active`-состояние. */ hasActive?: boolean; /** * Позволяет родительскому компоненту * иметь `hovered`-cостояние при наведении * на любой дочерний элемент. * По умолчанию состояние hovered у родителя сбрасывается. * * Присваивается родителькому компоненту. * * @example * * * * * */ hasHoverWithChildren?: boolean; /** * Позволяет родительскому компоненту показывать hovered-состояние при наведении * на текущий дочерний компонент. * * Присваивается дочернему компоненту. * * @example * * * * * */ unlockParentHover?: boolean; /** * Длительность показа `active`-состояния. */ activeEffectDelay?: number; /** * Стиль подсветки `active`-состояния. */ activeClassName?: string; /** * Стиль подсветки `hover`-состояния. */ hoverClassName?: string; } export const DEFAULT_ACTIVE_EFFECT_DELAY = 600; const ACTIVE_DELAY = 70; interface UseHoverProps extends Pick { /** * Блокирование активации состояний. */ lockState?: boolean; setParentStateLock?: (v: boolean) => void; } /** * Управляет наведением на компонент, игнорирует тач события. */ export function useHover({ hovered, hasHover = true, lockState = false, setParentStateLock = noop, }: UseHoverProps = {}) { const [hoveredStateLocal, setHoveredStateLocal] = React.useState(false); const prevIsHoveredRef = React.useRef(undefined); const handleHover = React.useCallback( (isHover: boolean) => { setHoveredStateLocal(isHover); const isHovered = hovered ?? calculateStateValue({ hasState: hasHover, isLocked: lockState, stateValueLocal: isHover, }); // проверка сделана чтобы реже вызывать обновление состояния // контекста родителя if (isHovered !== prevIsHoveredRef.current) { prevIsHoveredRef.current = isHovered; setParentStateLock(isHovered); } }, [setParentStateLock, hasHover, hovered, lockState], ); const onPointerEnter: React.PointerEventHandler = (e) => { if (e.pointerType === 'touch') { return; } handleHover(true); }; const onPointerLeave = () => { handleHover(false); }; const isHovered = hovered ?? calculateStateValue({ hasState: hasHover, isLocked: lockState, stateValueLocal: hoveredStateLocal, }); return { isHovered, onPointerEnter: hasHover ? onPointerEnter : noop, onPointerLeave: hasHover ? onPointerLeave : noop, }; } interface UseActiveProps extends Pick { /** * Блокирование активации состояний. */ lockStateRef: React.RefObject; setParentStateLock: (v: boolean) => void; } /** * Управляет активацией компонента. */ function useActive({ activated, activeEffectDelay, hasActive = true, lockStateRef, setParentStateLock, }: UseActiveProps) { // передаём setParentStateLock, чтобы функция вызывалась вместе с установкой стейта, // если установка отложена c помощью delay, то и вызов будет отложен const [activatedState, setActivated] = useStateWithDelay(false, 0, setParentStateLock); // Список нажатий которые не требуется отменять const pointersUp = React.useMemo(() => new Set(), []); const onPointerDown = () => { if (lockStateRef.current) { return; } setActivated(true, ACTIVE_DELAY); // намеренно выставляем lock, так как setActivated вызов отложен // а у отложенного setActivated setParentStateLock тоже вызовится отложенно // родитель сейчас тоже обработает это же событие PointerDown // если мы не залочим activatedState у родителя сейчас, то родитель выставит active состояние setParentStateLock(true); }; const onPointerCancel: React.PointerEventHandler = (e) => { if (pointersUp.has(e.pointerId)) { pointersUp.delete(e.pointerId); return; } setActivated(false); }; const onPointerUp: React.PointerEventHandler = (e) => { pointersUp.add(e.pointerId); if (lockStateRef.current) { return; } setActivated(true); setActivated(false, activeEffectDelay); }; const isActivated = activated ?? calculateStateValue({ hasState: hasActive, isLocked: lockStateRef.current, stateValueLocal: activatedState, }); return { isActivated, onPointerLeave: hasActive ? onPointerCancel : noop, onPointerDown: hasActive ? onPointerDown : noop, onPointerCancel: hasActive ? onPointerCancel : noop, onPointerUp: hasActive ? onPointerUp : noop, }; } interface ClickableLockStateContextInterface { lockHoverStateBubbling?: (v: boolean) => void; lockActiveStateBubbling?: (v: boolean) => void; } export const ClickableLockStateContext: React.Context = React.createContext({ lockHoverStateBubbling: undefined, lockActiveStateBubbling: undefined, }); /** * Блокирует стейт на всплытие. */ function useLockState( setParentStateLockBubbling: (v: boolean) => void, ): readonly [boolean, (v: boolean) => void, (...args: any[]) => void] { const [lockState, setLockState] = React.useState(false); const setStateLockBubblingImmediate = React.useCallback( (isLock: boolean) => { setLockState(isLock); setParentStateLockBubbling(isLock); }, [setParentStateLockBubbling], ); return [lockState, setParentStateLockBubbling, setStateLockBubblingImmediate] as const; } function useLockRef( setParentStateLockBubbling: (v: boolean) => void, ): readonly [React.RefObject, (v: boolean) => void, (...args: any[]) => void] { const lockStateRef = React.useRef(false); const setStateLockBubblingImmediate = React.useCallback( (isLock: boolean) => { lockStateRef.current = isLock; setParentStateLockBubbling(isLock); }, [setParentStateLockBubbling], ); return [lockStateRef, setParentStateLockBubbling, setStateLockBubblingImmediate] as const; } /** * Управляет состоянием компонента. */ export function useState({ hovered, hasHover, activated, hasActive, activeEffectDelay, unlockParentHover, hoverClassName, activeClassName, }: StateProps): { stateClassName: string; setLockHoverBubblingImmediate: (...args: any[]) => void; setLockActiveBubblingImmediate: (...args: any[]) => void; } { const { lockHoverStateBubbling = noop, lockActiveStateBubbling = noop } = React.useContext(ClickableLockStateContext); const [lockHoverState, setParentStateLockHoverBubbling, setLockHoverBubblingImmediate] = useLockState(unlockParentHover ? noop : lockHoverStateBubbling); const [lockActiveStateRef, setParentStateLockActiveBubbling, setLockActiveBubblingImmediate] = useLockRef(lockActiveStateBubbling); const { isHovered, ...hoverEvent } = useHover({ hasHover, hovered, lockState: lockHoverState, setParentStateLock: setParentStateLockHoverBubbling, }); const { isActivated, ...activeEvent } = useActive({ activated, hasActive, activeEffectDelay, lockStateRef: lockActiveStateRef, setParentStateLock: setParentStateLockActiveBubbling, }); const stateClassName = classNames(isHovered && hoverClassName, isActivated && activeClassName); const handlers = mergeCalls(hoverEvent, activeEvent); return { stateClassName, setLockHoverBubblingImmediate, setLockActiveBubblingImmediate, ...handlers, }; } // Общая функция для определения конечного состояния active/hovered function calculateStateValue({ hasState, isLocked, stateValueLocal, }: { hasState: boolean; isLocked: boolean; stateValueLocal: boolean; }): boolean { return hasState && !isLocked && stateValueLocal; }