import React, { AllHTMLAttributes, Component, ElementType, RefCallback } from 'react';
import Touch, { TouchEvent, TouchEventHandler, TouchProps } from '../Touch/Touch';
import TouchRootContext from '../Touch/TouchContext';
import { classNames } from '../../lib/classNames';
import { getClassName } from '../../helpers/getClassName';
import { ANDROID } from '../../lib/platform';
import { getOffsetRect } from '../../lib/offset';
import { coordX, coordY, VKUITouchEvent, VKUITouchEventHander } from '../../lib/touch';
import { HasPlatform, HasRootRef, Ref } from '../../types';
import { withPlatform } from '../../hoc/withPlatform';
import { hasHover } from '@vkontakte/vkjs/lib/InputUtils';
import { setRef } from '../../lib/utils';
import { withAdaptivity, AdaptivityProps } from '../../hoc/withAdaptivity';
export interface TappableProps extends AllHTMLAttributes, HasRootRef, HasPlatform, AdaptivityProps {
Component?: ElementType;
activeEffectDelay?: number;
stopPropagation?: boolean;
hasHover?: boolean;
hasActive?: boolean;
}
export interface TappableState {
clicks?: {
[index: string]: {
x: number;
y: number;
};
};
hovered?: boolean;
active?: boolean;
ts?: number;
hasHover?: boolean;
hasActive?: boolean;
}
export interface RootComponentProps extends TouchProps {
ref?: Ref;
}
export interface StorageItem {
activeTimeout: number;
timeout?: number;
stop(): void;
}
export interface Storage {
[index: string]: StorageItem;
}
export type GetStorage = () => StorageItem;
const ts = () => +Date.now();
export const ACTIVE_DELAY = 70;
export const ACTIVE_EFFECT_DELAY = 600;
let storage: Storage = {};
/*
* Очищает таймауты и хранилище для всех экземпляров компонента, кроме переданного
*/
function deactivateOtherInstances(exclude?: string) {
Object.keys(storage).filter((id: string) => id !== exclude).forEach((id: string) => {
clearTimeout(storage[id].activeTimeout);
clearTimeout(storage[id].timeout);
storage[id].stop();
delete storage[id];
});
}
const TappableContext = React.createContext<{ insideTappable?: boolean; onEnter?: VoidFunction; onLeave?: VoidFunction }>({ insideTappable: false });
class Tappable extends Component {
constructor(props: TappableProps) {
super(props);
this.id = Math.round(Math.random() * 1e8).toString(16);
this.state = {
clicks: {},
active: false,
ts: null,
hasHover: props.hasHover,
hasActive: props.hasActive,
};
this.isSlide = false;
}
id: string;
isSlide: boolean;
insideTouchRoot: boolean;
container: HTMLElement;
timeout: number;
wavesTimeout: number;
static defaultProps = {
Component: 'div',
role: 'button',
stopPropagation: false,
disabled: false,
hasHover,
hasActive: true,
activeEffectDelay: ACTIVE_EFFECT_DELAY,
};
/*
* Обрабатывает событие touchstart
*/
onStart: TouchEventHandler = ({ originalEvent }: TouchEvent) => {
!this.insideTouchRoot && this.props.stopPropagation && originalEvent.stopPropagation();
if (this.state.hasActive) {
if (originalEvent.touches && originalEvent.touches.length > 1) {
deactivateOtherInstances();
return;
}
if (this.props.platform === ANDROID) {
this.onDown(originalEvent);
}
storage[this.id] = {
stop: this.stop,
activeTimeout: window.setTimeout(this.start, ACTIVE_DELAY),
};
}
};
/*
* Обрабатывает событие touchmove
*/
onMove: TouchEventHandler = ({ originalEvent, shiftXAbs, shiftYAbs }: TouchEvent) => {
!this.insideTouchRoot && this.props.stopPropagation && originalEvent.stopPropagation();
if (shiftXAbs > 20 || shiftYAbs > 20) {
this.isSlide = true;
this.stop();
}
};
/*
* Обрабатывает событие touchend
*/
onEnd: TouchEventHandler = ({ originalEvent }: TouchEvent) => {
!this.insideTouchRoot && this.props.stopPropagation && originalEvent.stopPropagation();
const now = ts();
if (originalEvent.touches && originalEvent.touches.length > 0) {
this.isSlide = false;
this.stop();
return;
}
if (this.state.active) {
if (now - this.state.ts >= 100) {
// Долгий тап, выключаем подсветку
this.stop();
} else {
// Короткий тап, оставляем подсветку
const timeout = window.setTimeout(this.stop, this.props.activeEffectDelay - now + this.state.ts);
const store = this.getStorage();
if (store) {
store.timeout = timeout;
}
}
} else if (!this.isSlide) {
// Очень короткий тап, включаем подсветку
this.start();
const timeout = window.setTimeout(this.stop, this.props.activeEffectDelay);
if (this.getStorage()) {
clearTimeout(this.getStorage().activeTimeout);
this.getStorage().timeout = timeout;
} else {
this.timeout = timeout;
}
}
this.isSlide = false;
};
/*
* Реализует эффект при тапе для Андроида
*/
onDown: VKUITouchEventHander = (e: VKUITouchEvent) => {
if (this.props.platform === ANDROID) {
const { top, left } = getOffsetRect(this.container);
const x = coordX(e) - left;
const y = coordY(e) - top;
const key = 'wave' + Date.now().toString();
this.setState((state: TappableState): TappableState => {
return {
clicks: {
...state.clicks,
[key]: {
x,
y,
},
},
};
});
this.wavesTimeout = window.setTimeout(() => {
this.setState((state: TappableState): TappableState => {
let clicks = { ...state.clicks };
delete clicks[key];
return { clicks };
});
}, 225);
}
};
onEnter = () => {
this.setState({ hovered: true });
};
onLeave = () => {
this.setState({ hovered: false });
};
/*
* Устанавливает активное выделение
*/
start: VoidFunction = () => {
if (!this.state.active && this.state.hasActive) {
this.setState({
active: true,
ts: ts(),
});
}
deactivateOtherInstances(this.id);
};
/*
* Снимает активное выделение
*/
stop: VoidFunction = () => {
if (this.state.active) {
this.setState({
active: false,
ts: null,
});
}
if (this.getStorage()) {
clearTimeout(this.getStorage().activeTimeout);
delete storage[this.id];
}
};
/*
* Возвращает хранилище для экземпляра компонента
*/
getStorage: GetStorage = () => {
return storage[this.id];
};
/*
* Берет ref на DOM-ноду из экземпляра Touch
*/
getRef: RefCallback = (container) => {
this.container = container;
setRef(container, this.props.getRootRef);
};
containerHasTransparentBackground = (): boolean => {
if (!this.container) {
return true;
}
if (!this.container.style.backgroundColor) {
return true;
}
if (this.container.style.backgroundColor === 'transparent') {
return true;
}
return false;
};
componentWillUnmount() {
if (storage[this.id]) {
clearTimeout(storage[this.id].timeout);
clearTimeout(storage[this.id].activeTimeout);
delete storage[this.id];
}
clearTimeout(this.wavesTimeout);
}
componentDidUpdate(prevProps: TappableProps) {
if (prevProps.hasHover !== this.props.hasHover || prevProps.hasActive !== this.props.hasActive) {
this.setState({ hasHover: this.props.hasHover, hasActive: this.props.hasActive });
}
}
render() {
const { clicks, active, hovered, hasHover, hasActive } = this.state;
const { children, className, Component, activeEffectDelay,
stopPropagation, getRootRef, platform, sizeX, hasMouse, hasHover: propsHasHover, hasActive: propsHasActive, ...restProps } = this.props;
const hoverClassModificator = this.containerHasTransparentBackground()
? 'shadowHovered'
: 'opacityHovered';
const classes = classNames(
getClassName('Tappable', platform),
className,
`Tappable--sizeX-${sizeX}`,
{
'Tappable--active': hasActive && active,
'Tappable--inactive': !active,
'Tappable--mouse': hasMouse,
[`Tappable--${hoverClassModificator}`]: hasHover && hovered,
});
const RootComponent = restProps.disabled
? Component
: Touch;
let props: RootComponentProps = {};
if (!restProps.disabled) {
props.Component = Component;
/* eslint-disable */
props.onStart = this.onStart;
props.onMove = this.onMove;
props.onEnd = this.onEnd;
/* eslint-enable */
props.getRootRef = this.getRef;
} else {
props.ref = this.getRef;
}
return (
{({ insideTappable, onEnter, onLeave }) => {
return (
{(insideTouchRoot: boolean) => {
this.insideTouchRoot = insideTouchRoot;
const touchProps = restProps.disabled ? {} : {
onEnter: () => {
insideTappable && onEnter();
!restProps.disabled && this.onEnter();
},
onLeave: () => {
insideTappable && onLeave();
!restProps.disabled && this.onLeave();
},
};
return (
this.setState({ hasHover: false, hasActive: false }),
onLeave: () => this.setState({ hasHover: propsHasHover, hasActive: propsHasActive }),
}}
>
{children}
{platform === ANDROID && !hasMouse &&
{Object.keys(clicks).map((k: string) => {
return (
);
})}
}
{hasHover && }
);
}}
);
}}
);
}
}
export default withAdaptivity(withPlatform(Tappable), { sizeX: true, hasMouse: true });