react-onblur
Version:
HOC for Blur (Unfocus) event handling of React component
277 lines (238 loc) • 9.25 kB
JavaScript
import React, { PureComponent } from 'react';
import ReactDOM from 'react-dom';
export function consoleDebug(...args) {
return console.debug('react-onblur::', ...args);
}
/**
* @param {Node} parentDomNode
* @param {Node} domNode
* @returns {Boolean}
*/
export function isDomElementChild(parentDomNode, domNode) {
if (!parentDomNode || !domNode) return false;
// return parentDomNode == domNode || parentDomNode.contains(domNode);
let el = domNode;
while (el) {
if (el === parentDomNode) return true;
el = el.parentNode;
}
return false;
}
export function validateParams(
params,
requiredParams = { onBlur: true, checkInOutside: false, getRootNode: false, once: false }
) {
const required = Object.entries(requiredParams).filter(([, v]) => v).map(([k]) => k);
for (let i = 0; i < required.length; i += 1) {
const k = required[i];
if (!params[k]) {
console.error(`\`${k}\` is required`);
return false;
}
}
const { onBlur, checkInOutside, getRootNode } = params;
if (onBlur && typeof(onBlur) !== 'function') {
console.error('`onBlur` should be callback function');
return false;
}
if (checkInOutside && typeof(checkInOutside) !== 'function') {
console.error('`checkInOutside` should be function(node)');
return false;
}
if (getRootNode && typeof(getRootNode) !== 'function') {
console.error('`getRootNode` should be function(this)');
return false;
}
return true;
}
/**
* @param {Boolean} listenClick - if true, then mousedown event for document will be added
* @param {Boolean} listenTab - if true, then keydown and keyup listener for document will be added to detect tab key press
* @param {Boolean} listenEsc - if true, then when user press Esc key the event will be called
* @param {Boolean} autoUnset - if true, then unsetBlurListener function will be called after callback
* @param {Boolean} debug - if true, all debug messages will be printed in console
* @param {Boolean} ifClick - Deprecated: replaced by listenClick
* @param {Boolean} ifKeyUpDown - Deprecated: replaced by listenTab
* @param {Boolean} ifEsc - Deprecated: replaced by listenEsc
* @returns {PureComponent}
*/
export function withOnBlur(params = {}) {
const {
ifClick = true,
ifKeyUpDown = true,
ifEsc = true,
autoUnset = false,
debug = false
} = params;
const {
listenClick = ifClick,
listenTab = ifKeyUpDown,
listenEsc = ifEsc,
} = params;
const debugLog = debug ? consoleDebug : () => undefined;
return function (WrappedComponent) {
if (!(listenClick || listenTab || listenEsc)) return WrappedComponent;
class WithOnBlur extends PureComponent {
constructor(...args) {
super(...args);
this.setWorkingParams();
}
componentWillUnmount() {
debugLog('componentWillUnmount');
this.removeExtraBlurListeners({});
}
prepareOptions = (callbackOrOptions, once = undefined) => {
return typeof(callbackOrOptions) === 'function'
? { onBlur: callbackOrOptions, once }
: { ...callbackOrOptions, once: !!(once ?? callbackOrOptions.once) };
};
setWorkingParams = (params) => {
this.blurCallback = undefined;
this.checkInOutside = undefined;
this.getRootNode = undefined;
this.isOnce = autoUnset;
this.checkedElement = Date.now();
this.listeners = {
listenClick,
listenTab,
listenEsc
};
if (params) {
this.blurCallback = params.onBlur;
this.checkInOutside = params.checkInOutside;
this.getRootNode = params.getRootNode;
this.isOnce = params.once ?? autoUnset;
this.listeners = {
listenClick: params.listenClick ?? listenClick,
listenTab: params.listenTab ?? listenTab,
listenEsc: params.listenEsc ?? listenEsc
};
}
};
setBlurListener = (callbackOrOptions, once = undefined) => {
debugLog('setBlurListener');
this.setWorkingParams();
if (!callbackOrOptions || !['function', 'object'].includes(typeof(callbackOrOptions))) {
console.error('First param for `setBlurListener` should be callback function or object of options');
return false;
}
const options = this.prepareOptions(callbackOrOptions, once);
if (!validateParams(options)) {
return false;
}
this.setWorkingParams(options);
// remove listeners that shouldn't be active
this.removeExtraBlurListeners(this.listeners);
this.addDocumentListeners(this.listeners);
return true;
};
unsetListeners = () => {
debugLog('unsetListeners');
this.removeDocumentListeners(this.listeners);
};
addDocumentListeners = (listenersToAdd) => {
debugLog('addDocumentListeners', listenersToAdd);
if (listenersToAdd.listenClick) document.addEventListener('mousedown', this.onDocumentClick, true);
if (listenersToAdd.listenEsc) document.addEventListener('keydown', this.onDocumentEsc, true);
if (listenersToAdd.listenTab) {
document.addEventListener('keyup', this.onDocumentKeyUp, true);
document.addEventListener('keydown', this.onDocumentKeyDown, true);
}
};
removeDocumentListeners = (listenersToRemove) => {
debugLog('removeDocumentListeners', listenersToRemove);
if (listenersToRemove.listenClick) document.removeEventListener('mousedown', this.onDocumentClick, true);
if (listenersToRemove.listenEsc) document.removeEventListener('keydown', this.onDocumentEsc, true);
if (listenersToRemove.listenTab) {
document.removeEventListener('keyup', this.onDocumentKeyUp, true);
document.removeEventListener('keydown', this.onDocumentKeyDown, true);
}
};
removeExtraBlurListeners = (listeners = {}) => {
this.removeDocumentListeners({
listenClick: !listeners.listenClick,
listenEsc: !listeners.listenEsc,
listenTab: !listeners.listenTab,
});
};
onDocumentClick = e => {
debugLog('document mousedown', e);
if (e.target === this.checkedElement) {
debugLog('document mousedown event. Ignore because Element was checked');
} else {
this.checkAndBlur(e.target, e);
this.checkedElement = e.target;
}
};
onDocumentKeyDown = e => {
debugLog('document keyDown event', e);
if (e.target === this.checkedElement) {
debugLog('document keyDown event. Ignore because Element was checked');
} else {
this.checkAndBlur(e.target, e);
this.checkedElement = e.target;
}
};
onDocumentKeyUp = e => {
debugLog('document keyUp event', e);
if (e.target === this.checkedElement) {
debugLog('document keyUp event. Ignore because Element was checked');
} else {
if (String(e.key).toLowerCase() === 'tab' || String(e.code).toLowerCase() === 'tab' || e.keyCode === 9) {
this.checkAndBlur(e.target, e);
this.checkedElement = e.target;
}
}
};
onDocumentEsc = e => {
if (String(e.key).toLowerCase() === 'escape' || String(e.code).toLowerCase() === 'escape' || e.keyCode === 27) {
debugLog('document ESC event', e);
this.blur(e);
this.checkedElement = e.target;
}
};
checkAndBlur = (element, e) => {
debugLog('check and blur');
if (!this.blurCallback && !this.isOnce) {
return false;
}
if (this.inOutside(element)) {
this.blur(e);
}
};
blur = (e) => {
if (this.blurCallback) {
debugLog('blur callback');
this.blurCallback(e);
}
if (this.isOnce) {
debugLog('blur auto unset');
this.unsetListeners();
}
};
inOutside = domNode => {
const isOutside = !this.inArea(domNode);
return typeof(this.checkInOutside) === 'function'
? !!this.checkInOutside(domNode, isOutside)
: isOutside;
};
inArea = domNode => {
return isDomElementChild(this.getParentNode(), domNode);
};
getParentNode = () => this.getRootNode ? this.getRootNode(this) : ReactDOM.findDOMNode(this);
render() {
return (
<WrappedComponent
{...this.props}
setBlurListener={this.setBlurListener}
unsetBlurListener={this.unsetListeners}
/>
);
}
}
WithOnBlur.displayName = `WithOnBlur(${WrappedComponent.displayName || WrappedComponent.name || 'withOnBlur'})`;
WithOnBlur.WrappedComponent = WrappedComponent;
return WithOnBlur;
};
}
export default withOnBlur;