reblend-ui
Version:
Utilities for creating robust overlay components
163 lines (137 loc) • 5.14 kB
text/typescript
import contains from 'dom-helpers/contains';
import listen from 'dom-helpers/listen';
import ownerDocument from 'dom-helpers/ownerDocument';
import { useCallback, useEffect, useRef } from 'reblendjs';
import { useEventCallback } from 'reblend-hooks';
import warning from 'warning';
const noop = () => {};
export type MouseEvents = {
[K in keyof GlobalEventHandlersEventMap]: GlobalEventHandlersEventMap[K] extends MouseEvent
? K
: never;
}[keyof GlobalEventHandlersEventMap];
function isLeftClickEvent(event: MouseEvent) {
return event.button === 0;
}
function isModifiedEvent(event: MouseEvent) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
export const getRefTarget = (
ref: Reblend.Ref<Element> | Element | null | undefined,
) => ref && ('current' in ref ? ref.current : ref);
export interface ClickOutsideOptions {
disabled?: boolean;
clickTrigger?: MouseEvents;
}
const InitialTriggerEvents: Partial<Record<MouseEvents, MouseEvents>> = {
click: 'mousedown',
mouseup: 'mousedown',
pointerup: 'pointerdown',
};
/**
* The `useClickOutside` hook registers your callback on the document that fires
* when a pointer event is registered outside of the provided ref or element.
*
* @param {Ref<HTMLElement>| HTMLElement} ref The element boundary
* @param {function} onClickOutside
* @param {object=} options
* @param {boolean=} options.disabled
* @param {string=} options.clickTrigger The DOM event name (click, mousedown, etc) to attach listeners on
*/
function useClickOutside(
ref: Reblend.Ref<Element> | Element | null | undefined,
onClickOutside: (e: Event) => void = noop,
{ disabled, clickTrigger = 'click' }: ClickOutsideOptions = {},
) {
const preventMouseClickOutsideRef = useRef(false);
const waitingForTrigger = useRef(false);
const handleMouseCapture = useCallback(
(e: any) => {
const currentTarget = getRefTarget(ref);
warning(
!!currentTarget,
'ClickOutside captured a close event but does not have a ref to compare it to. ' +
'useClickOutside(), should be passed a ref that resolves to a DOM node',
);
preventMouseClickOutsideRef.current =
!currentTarget ||
isModifiedEvent(e) ||
!isLeftClickEvent(e) ||
!!contains(currentTarget, e.target) ||
waitingForTrigger.current;
waitingForTrigger.current = false;
},
);
const handleInitialMouse = useEventCallback((e: MouseEvent) => {
const currentTarget = getRefTarget(ref);
if (currentTarget && contains(currentTarget, e.target as any)) {
waitingForTrigger.current = true;
} else {
// When clicking on scrollbars within current target, click events are not triggered, so this ref
// is never reset inside `handleMouseCapture`. This would cause a bug where it requires 2 clicks
// to close the overlay.
waitingForTrigger.current = false;
}
});
const handleMouse = useEventCallback((e: MouseEvent) => {
if (!preventMouseClickOutsideRef.current) {
onClickOutside(e);
}
});
useEffect(() => {
if (disabled || ref == null) return undefined;
const doc = ownerDocument(getRefTarget(ref)!);
const ownerWindow = doc.defaultView || window;
// Store the current event to avoid triggering handlers immediately
// For things rendered in an iframe, the event might originate on the parent window
// so we should fall back to that global event if the local one doesn't exist
// https://github.com/facebook/react/issues/20074
let currentEvent = ownerWindow.event ?? ownerWindow.parent?.event;
let removeInitialTriggerListener: (() => void) | null = null;
if (InitialTriggerEvents[clickTrigger]) {
removeInitialTriggerListener = listen(
doc as any,
InitialTriggerEvents[clickTrigger]!,
handleInitialMouse,
true,
);
}
// Use capture for this listener so it fires before React's listener, to
// avoid false positives in the contains() check below if the target DOM
// element is removed in the React mouse callback.
const removeMouseCaptureListener = listen(
doc as any,
clickTrigger,
handleMouseCapture,
true,
);
const removeMouseListener = listen(doc as any, clickTrigger, (e) => {
// skip if this event is the same as the one running when we added the handlers
if (e === currentEvent) {
currentEvent = undefined;
return;
}
handleMouse(e);
});
let mobileSafariHackListeners = [] as Array<() => void>;
if ('ontouchstart' in doc.documentElement) {
mobileSafariHackListeners = [].slice
.call(doc.body.children)
.map((el) => listen(el, 'mousemove', noop));
}
return () => {
removeInitialTriggerListener?.();
removeMouseCaptureListener();
removeMouseListener();
mobileSafariHackListeners.forEach((remove) => remove());
};
}, [
ref,
disabled,
clickTrigger,
handleMouseCapture,
handleInitialMouse,
handleMouse,
]);
}
export default useClickOutside;