Files
ionic-framework/core/src/utils/tap-click/index.ts
Liam DeBeasi 28bd4ba720 fix(tap-click): do not error in document-less environment (#27972)
Issue number: N/A

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

While working on getting our starter app tests running on CI, I ran into
the following error:

```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
ReferenceError: document is not defined
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
 ❯ Module.startTapClick node_modules/@ionic/core/components/index9.js:133:15
    131|   };
    132|   const doc = document;
    133|   doc.addEventListener('ionGestureCaptured', cancelActive);
       |               ^
    134|   doc.addEventListener('touchstart', onTouchStart, true);
    135|   doc.addEventListener('touchcancel', onTouchEnd, true);
 ❯ node_modules/@ionic/core/components/ion-app.js:21:113

This error originated in "src/App.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:
```

We are referencing `document` without any "document defined" checks.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Tap Click is only enabled if the `document` is available since we set
event listeners on the document.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-08-10 18:48:09 +00:00

217 lines
5.7 KiB
TypeScript

import { doc } from '@utils/browser';
import type { Config } from '../../interface';
import { now, pointerCoord } from '../helpers';
export const startTapClick = (config: Config) => {
if (doc === undefined) {
return;
}
let lastTouch = -MOUSE_WAIT * 10;
let lastActivated = 0;
let activatableEle: HTMLElement | undefined;
let activeRipple: Promise<() => void> | undefined;
let activeDefer: ReturnType<typeof setTimeout> | undefined;
const useRippleEffect = config.getBoolean('animated', true) && config.getBoolean('rippleEffect', true);
const clearDefers = new WeakMap<HTMLElement, any>();
// Touch Events
const onTouchStart = (ev: TouchEvent) => {
lastTouch = now(ev);
pointerDown(ev);
};
const onTouchEnd = (ev: TouchEvent) => {
lastTouch = now(ev);
pointerUp(ev);
};
const onMouseDown = (ev: MouseEvent) => {
// Ignore right clicks
if (ev.button === 2) {
return;
}
const t = now(ev) - MOUSE_WAIT;
if (lastTouch < t) {
pointerDown(ev);
}
};
const onMouseUp = (ev: MouseEvent) => {
const t = now(ev) - MOUSE_WAIT;
if (lastTouch < t) {
pointerUp(ev);
}
};
const cancelActive = () => {
if (activeDefer) clearTimeout(activeDefer);
activeDefer = undefined;
if (activatableEle) {
removeActivated(false);
activatableEle = undefined;
}
};
const pointerDown = (ev: UIEvent) => {
if (activatableEle) {
return;
}
setActivatedElement(getActivatableTarget(ev), ev);
};
const pointerUp = (ev: UIEvent) => {
setActivatedElement(undefined, ev);
};
const setActivatedElement = (el: HTMLElement | undefined, ev: UIEvent) => {
// do nothing
if (el && el === activatableEle) {
return;
}
if (activeDefer) clearTimeout(activeDefer);
activeDefer = undefined;
const { x, y } = pointerCoord(ev);
// deactivate selected
if (activatableEle) {
if (clearDefers.has(activatableEle)) {
throw new Error('internal error');
}
if (!activatableEle.classList.contains(ACTIVATED)) {
addActivated(activatableEle, x, y);
}
removeActivated(true);
}
// activate
if (el) {
const deferId = clearDefers.get(el);
if (deferId) {
clearTimeout(deferId);
clearDefers.delete(el);
}
el.classList.remove(ACTIVATED);
const callback = () => {
addActivated(el, x, y);
activeDefer = undefined;
};
if (isInstant(el)) {
callback();
} else {
activeDefer = setTimeout(callback, ADD_ACTIVATED_DEFERS);
}
}
activatableEle = el;
};
const addActivated = (el: HTMLElement, x: number, y: number) => {
lastActivated = Date.now();
el.classList.add(ACTIVATED);
if (!useRippleEffect) return;
const rippleEffect = getRippleEffect(el);
if (rippleEffect !== null) {
removeRipple();
activeRipple = rippleEffect.addRipple(x, y);
}
};
const removeRipple = () => {
if (activeRipple !== undefined) {
activeRipple.then((remove) => remove());
activeRipple = undefined;
}
};
const removeActivated = (smooth: boolean) => {
removeRipple();
const active = activatableEle;
if (!active) {
return;
}
const time = CLEAR_STATE_DEFERS - Date.now() + lastActivated;
if (smooth && time > 0 && !isInstant(active)) {
const deferId = setTimeout(() => {
active.classList.remove(ACTIVATED);
clearDefers.delete(active);
}, CLEAR_STATE_DEFERS);
clearDefers.set(active, deferId);
} else {
active.classList.remove(ACTIVATED);
}
};
doc.addEventListener('ionGestureCaptured', cancelActive);
doc.addEventListener('touchstart', onTouchStart, true);
doc.addEventListener('touchcancel', onTouchEnd, true);
doc.addEventListener('touchend', onTouchEnd, true);
/**
* Tap click effects such as the ripple effect should
* not happen when scrolling. For example, if a user scrolls
* the page but also happens to do a touchstart on a button
* as part of the scroll, the ripple effect should not
* be dispatched. The ripple effect should only happen
* if the button is activated and the page is not scrolling.
*
* pointercancel is dispatched on a gesture when scrolling
* starts, so this lets us avoid having to listen for
* ion-content's scroll events.
*/
doc.addEventListener('pointercancel', cancelActive, true);
doc.addEventListener('mousedown', onMouseDown, true);
doc.addEventListener('mouseup', onMouseUp, true);
};
// TODO(FW-2832): type
const getActivatableTarget = (ev: UIEvent): any => {
if (ev.composedPath !== undefined) {
/**
* composedPath returns EventTarget[]. However,
* objects other than Element can be targets too.
* For example, AudioContext can be a target. In this
* case, we know that the event is a UIEvent so we
* can assume that the path will contain either Element
* or ShadowRoot.
*/
const path = ev.composedPath() as Element[] | ShadowRoot[];
for (let i = 0; i < path.length - 2; i++) {
const el = path[i];
if (!(el instanceof ShadowRoot) && el.classList.contains('ion-activatable')) {
return el;
}
}
} else {
return (ev.target as Element).closest('.ion-activatable');
}
};
const isInstant = (el: HTMLElement) => {
return el.classList.contains('ion-activatable-instant');
};
const getRippleEffect = (el: HTMLElement) => {
if (el.shadowRoot) {
const ripple = el.shadowRoot.querySelector('ion-ripple-effect');
if (ripple) {
return ripple;
}
}
return el.querySelector('ion-ripple-effect');
};
const ACTIVATED = 'ion-activated';
const ADD_ACTIVATED_DEFERS = 100;
const CLEAR_STATE_DEFERS = 150;
const MOUSE_WAIT = 2500;