Skip to content

Instantly share code, notes, and snippets.

@KMurphs
Created February 21, 2021 08:18
Show Gist options
  • Save KMurphs/0d3e7a888a851efc7b5b6089aa93c6e4 to your computer and use it in GitHub Desktop.
Save KMurphs/0d3e7a888a851efc7b5b6089aa93c6e4 to your computer and use it in GitHub Desktop.
ContextMenu
<ul class="container">
<li><span>Some List Item</span><span class="btn with-menu"><i class="fas fa-ellipsis-v"></i></span></li>
<li><span>Some List Item</span><span class="btn with-menu"><i class="fas fa-ellipsis-v"></i></span></li>
<li><span>Some List Item</span><span class="btn with-menu"><i class="fas fa-ellipsis-v"></i></span></li>
<li><span>Some List Item</span><span class="btn with-menu"><i class="fas fa-ellipsis-v"></i></span></li>
<li><span>Some List Item</span><span class="btn with-menu"><i class="fas fa-ellipsis-v"></i></span></li>
<li><span>Some List Item</span><span class="btn with-menu"><i class="fas fa-ellipsis-v"></i></span></li>
</ul>
<nav class="context-menu">
<ul>
<li>Some Menu Item</li>
<li>Some Menu Item</li>
<li>Some Menu Item</li>
<li>Some Menu Item</li>
</ul>
</nav>
(function() {
"use strict";
const getMousePosition = evt => {
const [posX, posY] = (function(evt){
if (evt.pageX || evt.pageY) return [evt.pageX, evt.pageY];
if (evt.clientX || evt.clientY) return [
evt.clientX + document.body.scrollLeft + document.documentElement.scrollLeft,
evt.clientY + document.body.scrollTop + document.documentElement.scrollTop
];
return [0, 0];
})(evt || window.event)
return { x: posX, y: posY }
}
const moveNodeTo = (node, x, y) => {
// Increase width and height of node by a small margin
// Then compute the maximum coords x and y can be as to prevent overflowing
const margin = 4;
const maxX = windowWidth - (menu.offsetWidth + margin);
const maxY = windowHeight - (menu.offsetHeight + margin);
// Obtain window width and height
const {innerWidth: windowWidth, innerHeight: windowHeight} = window;
// Move the node to [x, y] unless overflow (in which case don't go behond max coords)
node.style.left = `${x < maxX ? x : maxX}px`;
node.style.top = `${y < maxY ? y : maxY}px`;
}
const bubbleToClassOwner = (evt, className) => {
// From the origin of the event, climb up the chain
// until we either find a dom element with class "className" (then return said element)
// or we get to the root of all dom elements (the window - in which case return null)
let currNode = evt.srcElement || evt.target;
while(currNode){
if(currNode.classList.contains(className)) return currNode;
currNode = currNode.parentNode;
}
return null;
}
const bringUpMenu = (x, y)=>{
// Manufacture a menu "nav.context-menu > ul > li"
const menu = document.createElement("nav");
menu.classList.add("context-menu");
menu.appendChild(document.createElement("ul"));
document.body.append(menu);
// Move menu to some position using appropriate algorithm
// to prevent overflows ...
moveNodeTo(menu, x, y);
// Return menu, and function to cleanup/remove it from DOM
return [menu, ()=>document.body.removeChild(menu)];
}
function setupCustomContextMenuListener(menuOwnerClass="with-menu", menuChoiceClass="is-menu-item", cb) {
/* Closures to limit scopes of menu and cleanup function here */
let cleanupMenu = null;
let menu = null;
// Setup a global event listener for right clicks, work up whether
// an event must just be ignored or needs the menu to come on, or be removed
document.addEventListener("contextmenu", function(evt) {
// We got a right click, does it eventually bubble up to an element
// that is configured for a custom menu?
const menuOwner = bubbleToClassOwner(evt, menuOwnerClass);
// If we have an ancestor configured with the custom menu, bring up the menu
if(menuOwner) {
evt.preventDefault();
[menu, cleanupMenu] = bringUpMenu(...getMousePosition(evt));
// The click was outside of any interesting elements, bring down the menu
} else {
cleanupMenu && cleanupMenu();
}
});
// Handle some edge cases
// Window is resized while menu is on screen, remove it
window.onresize = () => cleanupMenu && cleanupMenu();
// On "esc" remove menu
window.onkeyup = (evt) => cleanupMenu && (evt.keyCode === 27) && cleanupMenu();
// Handle click in menu (callback, bring down menu) and out of menu (bring down menu)
document.addEventListener("click", evt => {
// We got a left click, does it eventually bubble up to an item in the menu?
const menuChoice = bubbleToClassOwner(evt, menuChoiceClass);
// We left clicked on a menu item, process it, close menu
menuChoice && evt.preventDefault();
menuChoice && cb && cb(menuChoice, evt);
menuChoice && cleanupMenu && cleanupMenu();
// We left clicked outside of the menu, we should remove menu.
// Except, in Firefox where a right click (contextmenu event)
// also causes a click event with right click code (mouseButton = 2)
// We need to make sure to remove the menu only
// when left clicking (mouseButton = 1) outside of the menu
// Otherwise, menu will appear and disappear in some instances
const button = evt.which || evt.button;
!menuChoice && (mouseButton === 1) && cleanupMenu && cleanupMenu();
})
}
})();
.container{
width: min(500px, 90vw);
margin: auto;
margin-top: 100px;
border: 1px solid #f4f4f4;
padding: 1rem;
}
.container li{
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ddd;
padding: .25rem .5rem;
}
.container li:hover{
background-color: rgba(0,0,0,0.02);
}
/* button */
.btn{
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
width: 1.7rem;
height: 1.7rem;
border-radius: 50%;
}
.btn:hover{
background-color: rgba(0,0,0,0.06);
}
.context-menu{
position: absolute;
bottom: 0;
right: 0;
border: 1px solid #aaa;
}
.context-menu li{
border-bottom: 1px solid #ddd;
padding: .5rem 1rem;
}
.context-menu li:hover{
background-color: rgba(0,0,0,0.02);
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment