@viacast/react-contexify
Version:
Add contextmenu to your react component with ease
829 lines (703 loc) • 24.9 kB
JavaScript
import React, { useContext, createContext, useRef, useEffect, Children, cloneElement, useReducer, useState } from 'react';
import cx from 'clsx';
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
function _objectWithoutPropertiesLoose(source, excluded) {
if (source == null) return {};
var target = {};
var sourceKeys = Object.keys(source);
var key, i;
for (i = 0; i < sourceKeys.length; i++) {
key = sourceKeys[i];
if (excluded.indexOf(key) >= 0) continue;
target[key] = source[key];
}
return target;
}
var Context = /*#__PURE__*/createContext({});
/**
* Access parent ref tracker.
*/
function useRefTrackerContext() {
return useContext(Context);
}
var RefTrackerProvider = function RefTrackerProvider(props) {
return React.createElement(Context.Provider, {
value: props.refTracker
}, props.children);
};
function createEventManager() {
var eventList = new Map();
return {
on: function on(event, handler) {
var _eventList$get;
//eslint-disable-next-line @typescript-eslint/no-unused-expressions
eventList.has(event) ? (_eventList$get = eventList.get(event)) == null ? void 0 : _eventList$get.add(handler) : eventList.set(event, new Set([handler]));
return this;
},
off: function off(event, handler) {
handler ? eventList.get(event)["delete"](handler) : eventList["delete"](event);
return this;
},
emit: function emit(event, args) {
if (process.env.NODE !== 'production') {
var currentEv = event;
if (!eventList.has(event) && currentEv !== 0
/* HIDE_ALL */
) {
console.error("It seems that the menu you are trying to display is not renderer or you have a menu id mismatch.", "You used the menu id: " + event);
}
}
eventList.has(event) && eventList.get(event).forEach(function (handler) {
handler(args);
});
return this;
}
};
}
var eventManager = /*#__PURE__*/createEventManager();
function usePrevious(value) {
var ref = useRef();
useEffect(function () {
ref.current = value;
}, [value]);
return ref.current;
}
/**
* Used to store children refs
*/
function useRefTracker() {
return useRef(new Map()).current;
}
var contextMenu = {
show: function show(_ref) {
var id = _ref.id,
event = _ref.event,
props = _ref.props,
position = _ref.position;
if (event.preventDefault) event.preventDefault();
eventManager.emit(0
/* HIDE_ALL */
).emit(id, {
event: event.nativeEvent || event,
props: props,
position: position
});
},
hideAll: function hideAll() {
eventManager.emit(0
/* HIDE_ALL */
);
}
};
function useContextMenu(props) {
return {
show: function show(event, params) {
if (process.env.NODE_ENV === 'development') {
if (!(props == null ? void 0 : props.id) && !(params == null ? void 0 : params.id)) console.error("You need to provide an id when initializing the hook `useContextMenu({ id: 'your id' })` or when you display the menu `show(e, { id: 'your id' })`. The later is used to override the one defined during initialization.");
}
contextMenu.show({
id: (params == null ? void 0 : params.id) || (props == null ? void 0 : props.id),
props: (params == null ? void 0 : params.props) || (props == null ? void 0 : props.props),
event: event,
position: params == null ? void 0 : params.position
});
},
hideAll: function hideAll() {
contextMenu.hideAll();
}
};
}
/*
* css classes mapping
* */
var STYLE;
(function (STYLE) {
STYLE["menu"] = "react-contexify";
STYLE["submenu"] = "react-contexify react-contexify__submenu";
STYLE["submenuArrow"] = "react-contexify__submenu-arrow";
STYLE["submenuOpen"] = "react-contexify__submenu--is-open";
STYLE["separator"] = "react-contexify__separator";
STYLE["item"] = "react-contexify__item";
STYLE["itemDisabled"] = "react-contexify__item--disabled";
STYLE["itemContent"] = "react-contexify__item__content";
STYLE["theme"] = "react-contexify__theme--";
STYLE["animationWillEnter"] = "react-contexify__will-enter--";
STYLE["animationWillLeave"] = "react-contexify__will-leave--";
})(STYLE || (STYLE = {}));
var EVENT;
(function (EVENT) {
EVENT[EVENT["HIDE_ALL"] = 0] = "HIDE_ALL";
})(EVENT || (EVENT = {}));
var theme = {
light: 'light',
dark: 'dark'
};
var animation = {
fade: 'fade',
flip: 'flip',
scale: 'scale',
slide: 'slide'
};
var NOOP = function NOOP() {};
/**
* Used to control keyboard navigation
*/
function createMenuController() {
var menuList = new Map();
var focusedIndex;
var parentNode;
var isAtRoot;
var currentItems;
var forceCloseSubmenu = false;
function init(rootMenu) {
currentItems = rootMenu;
focusedIndex = -1;
isAtRoot = true;
}
function focusSelectedItem() {
var _currentItems$focused;
(_currentItems$focused = currentItems[focusedIndex]) == null ? void 0 : _currentItems$focused.node.focus();
}
function isSubmenuFocused() {
var _currentItems$focused2;
return focusedIndex >= 0 && ((_currentItems$focused2 = currentItems[focusedIndex]) == null ? void 0 : _currentItems$focused2.isSubmenu);
}
function getSubmenuItems() {
var _currentItems$focused3;
return Array.from((_currentItems$focused3 = currentItems[focusedIndex]) == null ? void 0 : _currentItems$focused3.submenuRefTracker.values());
}
function isFocused() {
if (focusedIndex === -1) {
// focus first item
moveDown();
return false;
}
return true;
}
function moveDown() {
if (focusedIndex + 1 < currentItems.length) {
focusedIndex++;
} else if (focusedIndex + 1 === currentItems.length) {
focusedIndex = 0;
}
if (forceCloseSubmenu) closeSubmenu();
focusSelectedItem();
}
function moveUp() {
if (focusedIndex === -1 || focusedIndex === 0) {
focusedIndex = currentItems.length - 1;
} else if (focusedIndex - 1 < currentItems.length) {
focusedIndex--;
}
if (forceCloseSubmenu) closeSubmenu();
focusSelectedItem();
}
function openSubmenu() {
if (isFocused() && isSubmenuFocused()) {
var _currentItems$focused4;
var submenuItems = getSubmenuItems();
var currentNode = (_currentItems$focused4 = currentItems[focusedIndex]) == null ? void 0 : _currentItems$focused4.node;
menuList.set(currentNode, {
isRoot: isAtRoot,
focusedIndex: focusedIndex,
parentNode: parentNode || currentNode,
items: currentItems
});
currentNode.classList.add(STYLE.submenuOpen);
parentNode = currentNode;
if (submenuItems.length > 0) {
focusedIndex = 0;
currentItems = submenuItems;
} else {
forceCloseSubmenu = true;
}
isAtRoot = false;
focusSelectedItem();
return true;
}
return false;
}
function closeSubmenu() {
if (isFocused() && !isAtRoot) {
var _menuList$get = menuList.get(parentNode),
isRoot = _menuList$get.isRoot,
items = _menuList$get.items,
parentFocusedIndex = _menuList$get.focusedIndex,
menuParentNode = _menuList$get.parentNode;
parentNode.classList.remove(STYLE.submenuOpen);
currentItems = items;
parentNode = menuParentNode;
if (isRoot) {
isAtRoot = true;
menuList.clear();
}
if (!forceCloseSubmenu) {
focusedIndex = parentFocusedIndex;
focusSelectedItem();
}
}
}
return {
init: init,
moveDown: moveDown,
moveUp: moveUp,
openSubmenu: openSubmenu,
closeSubmenu: closeSubmenu
};
}
function isFn(v) {
return typeof v === 'function';
}
function isStr(v) {
return typeof v === 'string';
}
function isTouchEvent(e) {
return e.type === 'touchend';
}
function cloneItems(children, props) {
return Children.map( // remove null item
Children.toArray(children).filter(Boolean), function (item) {
return cloneElement(item, props);
});
}
function getMousePosition(e) {
var pos = {
x: 0,
y: 0
};
if (isTouchEvent(e) && e.changedTouches && e.changedTouches.length > 0) {
pos.x = e.changedTouches[0].clientX;
pos.y = e.changedTouches[0].clientY;
} else {
pos.x = e.clientX;
pos.y = e.clientY;
}
if (!pos.x || pos.x < 0) pos.x = 0;
if (!pos.y || pos.y < 0) pos.y = 0;
return pos;
}
function getPredicateValue(predicate, payload) {
return isFn(predicate) ? predicate(payload) : predicate;
}
function hasExitAnimation(animation) {
return !!(animation && (isStr(animation) || 'exit' in animation && animation.exit));
}
function reducer(state, payload) {
return isFn(payload) ? _extends({}, state, payload(state)) : _extends({}, state, payload);
}
var Menu = function Menu(_ref) {
var _cx3;
var id = _ref.id,
theme = _ref.theme,
style = _ref.style,
className = _ref.className,
children = _ref.children,
_ref$animation = _ref.animation,
animation = _ref$animation === void 0 ? 'scale' : _ref$animation,
_ref$onHidden = _ref.onHidden,
onHidden = _ref$onHidden === void 0 ? NOOP : _ref$onHidden,
_ref$onShown = _ref.onShown,
onShown = _ref$onShown === void 0 ? NOOP : _ref$onShown,
_ref$hideOnMouseLeave = _ref.hideOnMouseLeave,
hideOnMouseLeave = _ref$hideOnMouseLeave === void 0 ? false : _ref$hideOnMouseLeave,
_ref$ignoreBounds = _ref.ignoreBounds,
ignoreBounds = _ref$ignoreBounds === void 0 ? false : _ref$ignoreBounds,
_ref$ignoreKeyboard = _ref.ignoreKeyboard,
ignoreKeyboard = _ref$ignoreKeyboard === void 0 ? false : _ref$ignoreKeyboard,
_ref$ignoreClickOutsi = _ref.ignoreClickOutside,
ignoreClickOutside = _ref$ignoreClickOutsi === void 0 ? false : _ref$ignoreClickOutsi,
rest = _objectWithoutPropertiesLoose(_ref, ["id", "theme", "style", "className", "children", "animation", "onHidden", "onShown", "hideOnMouseLeave", "ignoreBounds", "ignoreKeyboard", "ignoreClickOutside"]);
var _useReducer = useReducer(reducer, {
x: 0,
y: 0,
willShow: false,
visible: false,
triggerEvent: {},
propsFromTrigger: null,
willLeave: false
}),
state = _useReducer[0],
setState = _useReducer[1];
var nodeRef = useRef(null);
var didMount = useRef(false);
var wasVisible = usePrevious(state.visible);
var refTracker = useRefTracker();
var _useState = useState(function () {
return createMenuController();
}),
menuController = _useState[0]; // subscribe event manager
useEffect(function () {
didMount.current = true;
eventManager.on(id, show).on(EVENT.HIDE_ALL, hide);
return function () {
eventManager.off(id, show).off(EVENT.HIDE_ALL, hide);
}; // hide rely on setState(dispatch), which is guaranted to be the same across render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); // handle show/ hide callback
useEffect(function () {
if (didMount.current && state.visible !== wasVisible) {
state.visible ? onShown() : onHidden();
} // wasWisible is a ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.visible, onHidden, onShown]); // collect menu items for keyboard navigation
useEffect(function () {
if (!state.visible) {
refTracker.clear();
} else {
menuController.init(Array.from(refTracker.values()));
}
}, [state.visible, menuController, refTracker]); // compute menu position
useEffect(function () {
if (state.visible) {
var _window = window,
windowWidth = _window.innerWidth,
windowHeight = _window.innerHeight;
var _nodeRef$current = nodeRef.current,
menuWidth = _nodeRef$current.offsetWidth,
menuHeight = _nodeRef$current.offsetHeight;
if (ignoreBounds) {
return;
}
var _x = state.x,
_y = state.y;
if (_x + menuWidth > windowWidth) {
_x = windowWidth - menuWidth;
}
if (_y + menuHeight > windowHeight) {
_y = Math.max(0, _y - menuHeight);
}
setState({
x: _x,
y: _y
});
} // state.visible and state{x,y} are updated together
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.visible]); // subscribe dom events
useEffect(function () {
function handleKeyboard(e) {
var preventDefault = true;
switch (e.key) {
case 'Enter':
if (!menuController.openSubmenu()) hide();
break;
case 'Escape':
hide();
break;
case 'ArrowUp':
menuController.moveUp();
break;
case 'ArrowDown':
menuController.moveDown();
break;
case 'ArrowRight':
menuController.openSubmenu();
break;
case 'ArrowLeft':
menuController.closeSubmenu();
break;
default:
preventDefault = false;
break;
}
if (preventDefault) {
e.preventDefault();
}
}
if (state.visible) {
window.addEventListener('resize', hide);
window.addEventListener('contextmenu', hide);
window.addEventListener('click', ignoreClickOutside ? NOOP : hide);
window.addEventListener('scroll', hide);
window.addEventListener('keydown', ignoreKeyboard ? NOOP : handleKeyboard); // This let us debug the menu in the console in dev mode
if (process.env.NODE_ENV !== 'development') {
window.addEventListener('blur', hide);
}
}
return function () {
window.removeEventListener('resize', hide);
window.removeEventListener('contextmenu', hide);
window.removeEventListener('click', hide);
window.removeEventListener('scroll', hide);
window.removeEventListener('keydown', ignoreKeyboard ? NOOP : handleKeyboard);
if (process.env.NODE_ENV !== 'development') {
window.removeEventListener('blur', hide);
}
}; // state.visible will let us get the right reference to `hide`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.visible, menuController, ignoreKeyboard]);
function show(_ref2) {
var event = _ref2.event,
props = _ref2.props,
position = _ref2.position;
event.stopPropagation();
var _ref3 = position || getMousePosition(event),
x = _ref3.x,
y = _ref3.y; // prevent react from batching the state update
// if the menu is already visible we have to recompute bounding rect based on position
setTimeout(function () {
setState({
visible: true,
willLeave: false,
x: x,
y: y,
triggerEvent: event,
propsFromTrigger: props
});
}, 0);
setTimeout(function () {
setState({
willShow: true
});
}, 0);
}
function hide(event) {
// Safari trigger a click event when you ctrl + trackpad
// Firefox: trigger a click event when right click occur
var e = event;
if (typeof e !== 'undefined' && (e.button === 2 || e.ctrlKey === true) && e.type !== 'contextmenu') {
return;
}
hasExitAnimation(animation) ? setState(function (state) {
return {
willLeave: state.visible
};
}) : setState(function (state) {
return {
visible: state.visible ? false : state.visible,
willShow: state.visible ? false : state.visible
};
});
}
function handleAnimationEnd() {
if (state.willLeave && state.visible) {
setState({
visible: false,
willLeave: false,
willShow: false
});
}
}
function computeAnimationClasses() {
if (!animation) return null;
if (isStr(animation)) {
var _cx;
return cx((_cx = {}, _cx["" + STYLE.animationWillEnter + animation] = animation && visible && !willLeave, _cx["" + STYLE.animationWillLeave + animation + " " + STYLE.animationWillLeave + "'disabled'"] = animation && visible && willLeave, _cx));
} else if ('enter' in animation && 'exit' in animation) {
var _cx2;
return cx((_cx2 = {}, _cx2["" + STYLE.animationWillEnter + animation.enter] = animation.enter && visible && !willLeave, _cx2["" + STYLE.animationWillLeave + animation.exit + " " + STYLE.animationWillLeave + "'disabled'"] = animation.exit && visible && willLeave, _cx2));
}
return null;
}
var visible = state.visible,
triggerEvent = state.triggerEvent,
propsFromTrigger = state.propsFromTrigger,
x = state.x,
y = state.y,
willLeave = state.willLeave;
var cssClasses = cx(STYLE.menu, className, (_cx3 = {}, _cx3["" + STYLE.theme + theme] = theme, _cx3), computeAnimationClasses());
var menuStyle = _extends({}, style, {
left: x,
top: y,
opacity: 1
});
return React.createElement(RefTrackerProvider, {
refTracker: refTracker
}, visible && React.createElement("div", Object.assign({}, rest, {
className: cssClasses,
onAnimationEnd: handleAnimationEnd,
style: _extends({}, menuStyle, {
opacity: state.willShow ? 1 : 0
}),
ref: nodeRef,
role: "menu",
onMouseLeave: function onMouseLeave() {
if (hideOnMouseLeave) {
hide();
}
}
}), cloneItems(children, {
propsFromTrigger: propsFromTrigger,
triggerEvent: triggerEvent
})));
};
var Item = function Item(_ref) {
var _propsFromTrigger$dis, _propsFromTrigger$dis2, _propsFromTrigger$hid, _propsFromTrigger$hid2, _cx;
var _ref$id = _ref.id,
id = _ref$id === void 0 ? '' : _ref$id,
children = _ref.children,
className = _ref.className,
style = _ref.style,
triggerEvent = _ref.triggerEvent,
data = _ref.data,
propsFromTrigger = _ref.propsFromTrigger,
_ref$onClick = _ref.onClick,
onClick = _ref$onClick === void 0 ? null : _ref$onClick,
_ref$disabled = _ref.disabled,
disabled = _ref$disabled === void 0 ? false : _ref$disabled,
_ref$hidden = _ref.hidden,
hidden = _ref$hidden === void 0 ? false : _ref$hidden,
_ref$render = _ref.render,
render = _ref$render === void 0 ? null : _ref$render,
rest = _objectWithoutPropertiesLoose(_ref, ["id", "children", "className", "style", "triggerEvent", "data", "propsFromTrigger", "onClick", "disabled", "hidden", "render"]);
var refTracker = useRefTrackerContext();
var handlerParams = {
id: id,
data: data,
props: propsFromTrigger,
triggerEvent: triggerEvent
};
var isDisabled = getPredicateValue(disabled, handlerParams) || (propsFromTrigger == null ? void 0 : (_propsFromTrigger$dis = propsFromTrigger.disabledPredicates) == null ? void 0 : (_propsFromTrigger$dis2 = _propsFromTrigger$dis[id]) == null ? void 0 : _propsFromTrigger$dis2.call(_propsFromTrigger$dis, handlerParams)) || false;
var isHidden = getPredicateValue(hidden, handlerParams) || (propsFromTrigger == null ? void 0 : (_propsFromTrigger$hid = propsFromTrigger.hiddenPredicates) == null ? void 0 : (_propsFromTrigger$hid2 = _propsFromTrigger$hid[id]) == null ? void 0 : _propsFromTrigger$hid2.call(_propsFromTrigger$hid, handlerParams)) || false;
function handleClick(e) {
handlerParams.event = e;
if (isDisabled) {
e.stopPropagation();
} else if (onClick) {
onClick == null ? void 0 : onClick(handlerParams);
} else {
var _propsFromTrigger$onC, _propsFromTrigger$onC2;
propsFromTrigger == null ? void 0 : (_propsFromTrigger$onC = propsFromTrigger.onClickHandlers) == null ? void 0 : (_propsFromTrigger$onC2 = _propsFromTrigger$onC[id]) == null ? void 0 : _propsFromTrigger$onC2.call(_propsFromTrigger$onC, handlerParams);
}
}
function trackRef(node) {
if (node && !isDisabled) refTracker.set(node, {
node: node,
isSubmenu: false
});
}
function handleKeyDown(e) {
if (e.key === 'Enter') {
handlerParams.event = e;
onClick == null ? void 0 : onClick(handlerParams);
}
}
if (isHidden) return null;
var cssClasses = cx(STYLE.item, className, (_cx = {}, _cx["" + STYLE.itemDisabled] = isDisabled, _cx));
return React.createElement("div", Object.assign({}, rest, {
className: cssClasses,
style: style,
onClick: handleClick,
onKeyDown: handleKeyDown,
ref: trackRef,
tabIndex: -1,
role: "menuitem",
"aria-disabled": isDisabled
}), React.createElement("div", {
className: STYLE.itemContent
}, render ? render(handlerParams) : children));
};
function Separator() {
return React.createElement("div", {
className: "react-contexify__separator"
/* separator */
});
}
var Submenu = function Submenu(_ref) {
var _propsFromTrigger$dis, _propsFromTrigger$dis2, _propsFromTrigger$hid, _propsFromTrigger$hid2, _cx;
var _ref$id = _ref.id,
id = _ref$id === void 0 ? '' : _ref$id,
_ref$arrow = _ref.arrow,
arrow = _ref$arrow === void 0 ? '▶' : _ref$arrow,
children = _ref.children,
_ref$disabled = _ref.disabled,
disabled = _ref$disabled === void 0 ? false : _ref$disabled,
_ref$hidden = _ref.hidden,
hidden = _ref$hidden === void 0 ? false : _ref$hidden,
label = _ref.label,
className = _ref.className,
triggerEvent = _ref.triggerEvent,
propsFromTrigger = _ref.propsFromTrigger,
style = _ref.style,
rest = _objectWithoutPropertiesLoose(_ref, ["id", "arrow", "children", "disabled", "hidden", "label", "className", "triggerEvent", "propsFromTrigger", "style"]);
var menuRefTracker = useRefTrackerContext();
var refTracker = useRefTracker();
var nodeRef = useRef(null);
var _useState = useState({
left: '100%',
top: 0,
bottom: 'initial'
}),
position = _useState[0],
setPosition = _useState[1];
var handlerParams = {
id: id,
triggerEvent: triggerEvent,
props: propsFromTrigger
};
var isDisabled = getPredicateValue(disabled, handlerParams) || (propsFromTrigger == null ? void 0 : (_propsFromTrigger$dis = propsFromTrigger.disabledPredicates) == null ? void 0 : (_propsFromTrigger$dis2 = _propsFromTrigger$dis[id]) == null ? void 0 : _propsFromTrigger$dis2.call(_propsFromTrigger$dis, handlerParams)) || false;
var isHidden = getPredicateValue(hidden, handlerParams) || (propsFromTrigger == null ? void 0 : (_propsFromTrigger$hid = propsFromTrigger.hiddenPredicates) == null ? void 0 : (_propsFromTrigger$hid2 = _propsFromTrigger$hid[id]) == null ? void 0 : _propsFromTrigger$hid2.call(_propsFromTrigger$hid, handlerParams)) || false;
useEffect(function () {
if (nodeRef.current) {
var _window = window,
innerWidth = _window.innerWidth,
innerHeight = _window.innerHeight;
var rect = nodeRef.current.getBoundingClientRect();
var _style = {};
if (rect.right < innerWidth) {
_style.left = '100%';
_style.right = undefined;
} else {
_style.right = '100%';
_style.left = undefined;
}
if (rect.bottom > innerHeight) {
_style.bottom = 0;
_style.top = 'initial';
} else {
_style.bottom = 'initial';
}
setPosition(_style);
}
}, []);
function handleClick(e) {
e.stopPropagation();
}
function trackRef(node) {
if (node && !isDisabled) menuRefTracker.set(node, {
node: node,
isSubmenu: true,
submenuRefTracker: refTracker
});
}
if (isHidden) return null;
var cssClasses = cx(STYLE.item, className, (_cx = {}, _cx["" + STYLE.itemDisabled] = isDisabled, _cx));
var submenuStyle = _extends({}, style, position);
return React.createElement(RefTrackerProvider, {
refTracker: refTracker
}, React.createElement("div", Object.assign({}, rest, {
className: cssClasses,
ref: trackRef,
tabIndex: -1,
role: "menuitem",
"aria-haspopup": true,
"aria-disabled": isDisabled
}), React.createElement("div", {
className: STYLE.itemContent,
onClick: handleClick
}, label, React.createElement("span", {
className: STYLE.submenuArrow
}, arrow)), React.createElement("div", {
className: STYLE.submenu,
ref: nodeRef,
style: submenuStyle
}, cloneItems(children, {
propsFromTrigger: propsFromTrigger,
// injected by the parent
triggerEvent: triggerEvent
}))));
};
export { Item, Menu, Separator, Submenu, animation, contextMenu, theme, useContextMenu };
//# sourceMappingURL=react-contexify.esm.js.map