Context Menu Rails Components
Right-click context menus that appear at cursor position with keyboard navigation, mobile support, and nested submenus. Perfect for file managers, content editors, and interactive applications.
Installation
1. Stimulus Controller Setup
Start by adding the following 3 stimulus controllers to your project:
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
export default class extends Controller {
static targets = ["menu"];
connect() {
// Add context menu event listener
this.element.addEventListener("contextmenu", this.showMenu.bind(this));
// Add touch press & hold listeners for mobile with passive option
this.element.addEventListener("touchstart", this.handleTouchStart.bind(this), { passive: true });
this.element.addEventListener("touchend", this.handleTouchEnd.bind(this), { passive: true });
this.element.addEventListener("touchmove", this.handleTouchMove.bind(this), { passive: true });
this.element.addEventListener("touchcancel", this.handleTouchCancel.bind(this), { passive: true });
// Add click outside listener to close menu
document.addEventListener("click", this.closeMenu.bind(this));
// Add escape key listener
document.addEventListener("keydown", this.handleKeydown.bind(this));
// Add menu-item-clicked event listener
this.menuTarget.addEventListener("menu-item-clicked", this.handleMenuItemClick.bind(this));
// Track scroll lock state
this.isScrollLocked = false;
// Touch press & hold state
this.touchTimer = null;
this.touchStartPos = null;
this.longPressDelay = 500; // 500ms for long press
this.maxTouchMovement = 10; // Max pixels allowed for movement during long press
}
disconnect() {
// Make sure to unlock scroll when controller disconnects
if (this.isScrollLocked) {
this.unlockScroll();
}
// Clear any pending touch timer
if (this.touchTimer) {
clearTimeout(this.touchTimer);
}
document.removeEventListener("click", this.closeMenu.bind(this));
document.removeEventListener("keydown", this.handleKeydown.bind(this));
this.menuTarget.removeEventListener("menu-item-clicked", this.handleMenuItemClick.bind(this));
}
handleTouchStart(event) {
// Only handle single finger touches
if (event.touches.length !== 1) {
this.cancelTouchTimer();
return;
}
// Store the initial touch position
const touch = event.touches[0];
this.touchStartPos = {
x: touch.clientX,
y: touch.clientY,
};
// Start the long press timer
this.touchTimer = setTimeout(() => {
// Show our custom context menu
this.showMenuAtPosition(touch.clientX, touch.clientY);
this.touchTimer = null;
}, this.longPressDelay);
}
handleTouchMove(event) {
// If we don't have a timer running, ignore
if (!this.touchTimer || !this.touchStartPos) {
return;
}
// Check if the finger has moved too far
const touch = event.touches[0];
const deltaX = Math.abs(touch.clientX - this.touchStartPos.x);
const deltaY = Math.abs(touch.clientY - this.touchStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// Cancel long press if moved too far
if (distance > this.maxTouchMovement) {
this.cancelTouchTimer();
}
}
handleTouchEnd(event) {
this.cancelTouchTimer();
}
handleTouchCancel(event) {
this.cancelTouchTimer();
}
cancelTouchTimer() {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}
this.touchStartPos = null;
}
showMenu(event) {
event.preventDefault();
this.showMenuAtPosition(event.clientX, event.clientY);
}
async showMenuAtPosition(clientX, clientY) {
// Close any other open context menus first
const openContextMenus = document.querySelectorAll('dialog[data-context-menu-target="menu"][open]');
openContextMenus.forEach((menu) => {
if (menu !== this.menuTarget) {
const controller = this.application.getControllerForElementAndIdentifier(
menu.closest('[data-controller="context-menu"]'),
"context-menu"
);
if (controller) {
controller.closeMenu({ target: document.body });
}
}
});
// Close all open dropdown popovers
document.querySelectorAll('[data-controller="dropdown-popover"] dialog[open]').forEach((dialog) => {
const dropdown = dialog.closest('[data-controller="dropdown-popover"]');
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller) {
controller.close();
}
});
// Lock scroll when showing menu
this.lockScroll();
// Create a virtual element at the cursor/touch position for Floating UI
const virtualElement = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: clientX,
y: clientY,
top: clientY,
left: clientX,
right: clientX,
bottom: clientY,
};
},
};
// Show the menu first (but positioned off-screen) so we can measure it
this.menuTarget.style.left = "-9999px";
this.menuTarget.style.top = "-9999px";
this.menuTarget.show();
// Use Floating UI to compute the optimal position
const { x, y } = await computePosition(virtualElement, this.menuTarget, {
placement: "bottom-start",
middleware: [
offset(4), // Small offset from cursor
flip({
fallbackPlacements: ["top-start", "bottom-end", "top-end"],
}),
shift({ padding: 8 }), // Keep 8px padding from window edges
],
});
// Apply the computed position
this.menuTarget.style.left = `${x}px`;
this.menuTarget.style.top = `${y}px`;
// Add animation classes after a frame
requestAnimationFrame(() => {
this.menuTarget.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100");
// Focus the menu element itself instead of any items
this.menuTarget.focus();
});
}
closeMenu(event) {
if (!this.menuTarget.contains(event.target)) {
// Unlock scroll when closing menu
this.unlockScroll();
// Reset focus states in the menu before closing
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
menuController.reset();
}
// Close all nested dropdowns first and remove background classes
const nestedDropdowns = this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]');
nestedDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller.menuTarget.open) {
// Remove background classes from nested dropdown buttons
controller.buttonTarget.classList.remove("!bg-neutral-400/20");
controller.close();
}
});
// Then close the main context menu
this.menuTarget.classList.remove("[&[open]]:scale-100", "[&[open]]:opacity-100");
setTimeout(() => {
this.menuTarget.close();
}, 100);
}
}
handleKeydown(event) {
if (event.key === "Escape" && this.menuTarget.open) {
this.unlockScroll();
// Reset focus states in the menu
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
menuController.reset();
}
// Close all nested dropdowns first and remove background classes
const nestedDropdowns = this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]');
nestedDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller.menuTarget.open) {
// Remove background classes from nested dropdown buttons
controller.buttonTarget.classList.remove("!bg-neutral-400/20");
controller.close();
}
});
// Then close the main context menu
this.menuTarget.classList.remove("[&[open]]:scale-100", "[&[open]]:opacity-100");
setTimeout(() => {
this.menuTarget.close();
}, 100);
}
}
handleMenuItemClick(event) {
this.unlockScroll();
// Reset focus states in the menu
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
menuController.reset();
}
// Close all nested dropdowns first and remove background classes
const nestedDropdowns = this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]');
nestedDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller.menuTarget.open) {
// Remove background classes from nested dropdown buttons
controller.buttonTarget.classList.remove("!bg-neutral-400/20");
controller.close();
}
});
// Then close the main context menu
this.menuTarget.classList.remove("[&[open]]:scale-100", "[&[open]]:opacity-100");
setTimeout(() => {
this.menuTarget.close();
}, 100);
}
// Public method to force close the menu (can be called via data-action)
close() {
this.unlockScroll();
// Reset focus states in the menu
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
menuController.reset();
}
// Close all nested dropdowns first and remove background classes
const nestedDropdowns = this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]');
nestedDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller.menuTarget.open) {
// Remove background classes from nested dropdown buttons
controller.buttonTarget.classList.remove("!bg-neutral-400/20");
controller.close();
}
});
// Then close the main context menu
this.menuTarget.classList.remove("[&[open]]:scale-100", "[&[open]]:opacity-100");
setTimeout(() => {
this.menuTarget.close();
}, 100);
}
// Add these new methods for scroll locking
lockScroll() {
if (this.isScrollLocked) return;
// Store current scroll position
this.scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
// Store current body styles
this.previousBodyPosition = document.body.style.position;
this.previousBodyTop = document.body.style.top;
this.previousBodyWidth = document.body.style.width;
this.previousBodyPointerEvents = document.body.style.pointerEvents;
// Lock scroll by fixing the body position
document.body.style.position = "fixed";
document.body.style.top = `-${this.scrollPosition}px`;
document.body.style.width = "100%";
// Disable pointer events on the body (but not the menu)
document.body.style.pointerEvents = "none";
this.menuTarget.style.pointerEvents = "auto";
this.isScrollLocked = true;
}
unlockScroll() {
if (!this.isScrollLocked) return;
// Restore previous body styles
document.body.style.position = this.previousBodyPosition;
document.body.style.top = this.previousBodyTop;
document.body.style.width = this.previousBodyWidth;
document.body.style.pointerEvents = this.previousBodyPointerEvents;
// Restore scroll position
window.scrollTo(0, this.scrollPosition);
this.isScrollLocked = false;
}
}
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["item"]; // Targets for the menu items
static values = { index: Number }; // Index of the current item
connect() {
this.indexValue = -1;
this.searchString = "";
this.#updateTabstops();
document.addEventListener("keydown", (event) => {
if (document.activeElement.tagName === "INPUT") return;
if (this.element.open && event.key.length === 1 && event.key.match(/[a-z]/i)) {
event.preventDefault();
event.stopPropagation();
this.#handleTextSearch(event.key.toLowerCase());
}
});
this.itemTargets.forEach((item) => {
item.addEventListener("click", () => {
const isNestedDropdownButton = item.hasAttribute("data-dropdown-popover-target");
if (!isNestedDropdownButton) {
const parentDropdown = this.element.closest('[data-controller="dropdown-popover"]');
const parentContextMenu = this.element.closest('[data-controller="context-menu"]');
let shouldAutoClose = true;
if (parentDropdown) {
shouldAutoClose = parentDropdown.getAttribute("data-dropdown-popover-auto-close-value") !== "false";
} else if (parentContextMenu) {
shouldAutoClose = parentContextMenu.getAttribute("data-context-menu-auto-close-value") !== "false";
}
if (shouldAutoClose) {
this.element.dispatchEvent(new Event("menu-item-clicked", { bubbles: true }));
}
}
});
item.addEventListener("mouseenter", (event) => {
if (!this.element.open) return;
event.preventDefault();
// Only handle mouse enter for visible items
const currentItem = event.currentTarget;
if (
currentItem.disabled ||
currentItem.classList.contains("disabled") ||
currentItem.classList.contains("hidden") ||
currentItem.style.display === "none"
) {
return;
}
// Check computed styles for actual visibility (handles Tailwind responsive classes)
const computedStyle = window.getComputedStyle(currentItem);
if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
return;
}
// Close any sibling nested dropdowns with a delay
const siblingDropdowns = this.element.querySelectorAll(
'[data-controller="dropdown-popover"][data-dropdown-popover-nested-value="true"]'
);
siblingDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
const isHoverEnabled = controller?.hoverValue;
const isCurrentItemNestedDropdown = event.currentTarget.hasAttribute("data-dropdown-popover-target");
// Only close if hover is enabled and current item is not a nested dropdown
if (
controller &&
controller.menuTarget.open &&
!dropdown.contains(event.currentTarget) &&
isHoverEnabled &&
!isCurrentItemNestedDropdown
) {
setTimeout(() => {
controller.close();
}, 100);
}
});
this.indexValue = this.itemTargets.indexOf(event.currentTarget);
this.#updateTabstops();
this.#focusCurrentItem();
});
item.addEventListener("mouseleave", (event) => {
if (!this.element.open) return;
const isContextMenu = this.element.closest('[data-controller="context-menu"]');
const parentDropdown = this.element.closest('[data-controller="dropdown-popover"]');
// Find all open nested dropdown controllers within this menu
const openNestedControllers = Array.from(this.element.querySelectorAll('[data-controller="dropdown-popover"]'))
.map((dropdown) => this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"))
.filter((controller) => controller?.menuTarget?.open);
if (openNestedControllers.length > 0) {
// Focus the last (deepest) open nested controller's button
const deepestController = openNestedControllers[openNestedControllers.length - 1];
const parentMenu = deepestController.buttonTarget.closest('[data-controller="menu"]');
const parentMenuController = this.application.getControllerForElementAndIdentifier(parentMenu, "menu");
// Update tabindex state in the parent menu using public method
const buttonIndex = parentMenuController.itemTargets.indexOf(deepestController.buttonTarget);
parentMenuController.updateTabstopsWithIndex(buttonIndex);
deepestController.buttonTarget.focus();
} else if (this.indexValue === this.itemTargets.indexOf(event.currentTarget)) {
if (isContextMenu) {
this.element.focus();
} else if (parentDropdown) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
dropdownController.buttonTarget.focus();
}
// Remove background classes when leaving item
event.currentTarget.classList.remove("!bg-neutral-400/20");
this.indexValue = -1;
this.#updateTabstops();
}
});
item.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
const input = item.querySelector('input[type="checkbox"], input[type="radio"]');
if (input) {
event.preventDefault();
input.checked = !input.checked;
const parentDropdown = this.element.closest('[data-controller="dropdown-popover"]');
const parentContextMenu = this.element.closest('[data-controller="context-menu"]');
let shouldAutoClose = true;
if (parentDropdown) {
shouldAutoClose = parentDropdown.getAttribute("data-dropdown-popover-auto-close-value") !== "false";
} else if (parentContextMenu) {
shouldAutoClose = parentContextMenu.getAttribute("data-context-menu-auto-close-value") !== "false";
}
if (shouldAutoClose) {
this.element.dispatchEvent(new Event("menu-item-clicked"));
}
}
} else if (event.key === "ArrowRight") {
const isNestedDropdownButton = item.hasAttribute("data-dropdown-popover-target");
if (isNestedDropdownButton) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
item.closest('[data-controller="dropdown-popover"]'),
"dropdown-popover"
);
dropdownController.show();
// Add background classes when opening nested dropdown
if (dropdownController.nestedValue) {
item.classList.add("!bg-neutral-400/20");
}
}
} else if (event.key === "ArrowLeft") {
const parentDropdown = this.element.closest('[data-dropdown-popover-nested-value="true"]');
if (parentDropdown) {
event.stopPropagation();
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
// Remove background classes when closing nested dropdown
dropdownController.buttonTarget.classList.remove("!bg-neutral-400/20");
dropdownController.close();
dropdownController.buttonTarget.focus();
}
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.element.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
});
const isNestedDropdownButton = item.hasAttribute("data-dropdown-popover-target");
if (isNestedDropdownButton) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
item.closest('[data-controller="dropdown-popover"]'),
"dropdown-popover"
);
if (dropdownController?.menuTarget?.open) {
event.preventDefault();
event.stopPropagation();
const childMenuController = this.application.getControllerForElementAndIdentifier(
dropdownController.menuTarget,
"menu"
);
// Add active path class and background classes to parent item
item.classList.add("active-menu-path");
item.classList.add("!bg-neutral-400/20");
if (event.key === "ArrowDown") {
childMenuController.selectFirst();
} else {
childMenuController.selectLast();
}
return;
}
}
}
});
});
this.element.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") {
const parentDropdown = this.element.closest('[data-dropdown-popover-nested-value="true"]');
if (parentDropdown) {
event.stopPropagation();
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
// Remove background classes when closing nested dropdown
dropdownController.buttonTarget.classList.remove("!bg-neutral-400/20");
dropdownController.close();
dropdownController.buttonTarget.focus();
}
}
});
this.element.addEventListener("mouseleave", (event) => {
if (!this.element.open) return;
// Check all nested dropdown controllers for open state
const hasOpenNested = Array.from(this.element.querySelectorAll('[data-controller="dropdown-popover"]')).some(
(dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
return controller?.menuTarget?.open;
}
);
if (!hasOpenNested) {
const parentDropdown = event.target.closest('[data-controller="dropdown-popover"]');
if (parentDropdown) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
if (dropdownController?.buttonTarget) {
dropdownController.buttonTarget.focus();
}
}
}
});
}
reset() {
this.indexValue = -1;
this.searchString = "";
this.#updateTabstops();
}
// Helper method to get only visible and enabled items
#getVisibleItems() {
return this.itemTargets.filter((item) => {
// Check if item is disabled
if (item.disabled || item.classList.contains("disabled")) {
return false;
}
// Check if item is explicitly hidden
if (item.classList.contains("hidden")) {
return false;
}
// Check if item has inline style display none
if (item.style.display === "none") {
return false;
}
// Check computed styles for actual visibility (handles Tailwind responsive classes)
const computedStyle = window.getComputedStyle(item);
if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
return false;
}
return true;
});
}
prev() {
this.element.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
el.classList.remove("!bg-neutral-400/20");
});
// Get only visible and enabled items
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
const currentVisibleIndex = visibleItems.indexOf(this.itemTargets[this.indexValue]);
if (currentVisibleIndex === -1) {
this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
} else if (currentVisibleIndex > 0) {
this.indexValue = this.itemTargets.indexOf(visibleItems[currentVisibleIndex - 1]);
} else {
this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
}
this.#updateTabstops();
this.#focusCurrentItem();
}
next() {
this.element.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
el.classList.remove("!bg-neutral-400/20");
});
// Get only visible and enabled items
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
const currentVisibleIndex = visibleItems.indexOf(this.itemTargets[this.indexValue]);
if (currentVisibleIndex === -1) {
this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
} else if (currentVisibleIndex < visibleItems.length - 1) {
this.indexValue = this.itemTargets.indexOf(visibleItems[currentVisibleIndex + 1]);
} else {
this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
}
this.#updateTabstops();
this.#focusCurrentItem();
}
preventScroll(event) {
event.preventDefault();
}
#updateTabstops() {
this.itemTargets.forEach((element, index) => {
element.tabIndex = index === this.indexValue && this.indexValue !== -1 ? 0 : -1;
});
}
#focusCurrentItem() {
this.itemTargets[this.indexValue].focus();
}
get #lastIndex() {
return this.itemTargets.length - 1;
}
selectFirst() {
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
this.#updateTabstops();
this.#focusCurrentItem();
}
selectLast() {
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
this.#updateTabstops();
this.#focusCurrentItem();
}
#handleTextSearch(key) {
clearTimeout(this.searchTimeout);
this.searchString += key;
const searchStr = this.searchString;
const visibleItems = this.#getVisibleItems();
const matchedItem = visibleItems.find((item) => {
const textElement = item.querySelector(".menu-item-text") || item;
return textElement.textContent.trim().toLowerCase().startsWith(searchStr);
});
if (matchedItem) {
this.indexValue = this.itemTargets.indexOf(matchedItem);
this.#updateTabstops();
this.#focusCurrentItem();
}
this.searchTimeout = setTimeout(() => {
this.searchString = "";
}, 500);
}
// Add public method for external access
updateTabstopsWithIndex(newIndex) {
this.indexValue = newIndex;
this.#updateTabstops();
}
}
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
export default class extends Controller {
static targets = ["button", "menu", "template"];
static classes = ["flip"];
static values = {
autoClose: { type: Boolean, default: true }, // Whether to close the menu when clicking on an item
nested: { type: Boolean, default: false }, // Whether the menu is nested
hover: { type: Boolean, default: false }, // Whether to show the menu on hover
autoPosition: { type: Boolean, default: true }, // Whether to automatically position the menu
lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the menu content
placement: { type: String, default: "bottom-start" }, // The placement(s) of the menu - can be multiple separated by spaces
dialogMode: { type: Boolean, default: true }, // Whether to use dialog mode
turboFrameSrc: { type: String, default: "" }, // URL for Turbo Frame lazy loading
};
connect() {
// Initialize non-dialog menu if dialogMode is false
if (!this.dialogModeValue && !this.menuTarget.hasAttribute("role")) {
this.menuTarget.setAttribute("role", "menu");
this.menuTarget.setAttribute("aria-modal", "false");
this.menuTarget.setAttribute("tabindex", "-1");
}
this.menuTarget.addEventListener("menu-item-clicked", () => {
if (this.autoCloseValue) {
this.close();
let parent = this.element.closest('[data-controller="dropdown-popover"]');
while (parent) {
const parentController = this.application.getControllerForElementAndIdentifier(parent, "dropdown-popover");
if (parentController && parentController.autoCloseValue) {
parentController.close();
}
parent = parent.parentElement.closest('[data-controller="dropdown-popover"]');
}
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && this.isOpen) {
this.close();
}
});
this.buttonTarget.addEventListener("keydown", (event) => {
if (!this.isOpen) return;
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (event.key === "ArrowDown") {
event.preventDefault();
menuController.selectFirst();
} else if (event.key === "ArrowUp") {
event.preventDefault();
menuController.selectLast();
}
});
// Add scroll listener to update position
this.scrollHandler = () => {
if (this.isOpen && this.autoPositionValue) {
this.#updatePosition();
}
};
window.addEventListener("scroll", this.scrollHandler, true);
// Update position when window is resized or menu content changes
this.resizeObserver = new ResizeObserver(() => {
if (this.isOpen) {
this.#updatePosition();
}
});
// Observe both document.body and the menuTarget element
this.resizeObserver.observe(document.body);
this.resizeObserver.observe(this.menuTarget);
// Add MutationObserver to detect content changes inside the menu
this.mutationObserver = new MutationObserver(() => {
if (this.isOpen) {
// Slight delay to allow DOM changes to complete
setTimeout(() => this.#updatePosition(), 10);
}
});
this.mutationObserver.observe(this.menuTarget, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
// Add hover functionality for hover-enabled dropdowns
if (this.hoverValue) {
let hoverTimeout;
this.buttonTarget.addEventListener("mouseenter", () => {
clearTimeout(hoverTimeout);
this.show();
});
this.element.addEventListener("mouseleave", (event) => {
const toElement = event.relatedTarget;
const isMovingToNestedMenu =
toElement &&
(toElement.closest('[data-dropdown-popover-target="menu"]') ||
toElement.closest('[data-dropdown-popover-target="button"]') ||
// Check if moving to a child menu
toElement.closest('[data-controller="dropdown-popover"][data-dropdown-popover-nested-value="true"]'));
if (!isMovingToNestedMenu) {
hoverTimeout = setTimeout(() => {
// Check if any child menu is being hovered before closing
const hasHoveredChild = Array.from(
this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]')
).some(
(dropdown) =>
dropdown.matches(":hover") ||
dropdown.querySelector("[data-dropdown-popover-target='menu']").matches(":hover")
);
if (!hasHoveredChild) {
this.close();
// Reset focus state when menu is closed with hover
if (this.nestedValue) {
this.buttonTarget.classList.remove("active-menu-path");
this.buttonTarget.classList.remove("!bg-neutral-400/20");
// Reset the tabindex in the parent menu if this is a nested dropdown
const parentMenu = this.element.closest('[data-controller="menu"]');
if (parentMenu) {
const menuController = this.application.getControllerForElementAndIdentifier(parentMenu, "menu");
if (menuController) {
menuController.reset();
}
}
}
}
}, 200);
}
});
this.menuTarget.addEventListener("mouseenter", () => {
clearTimeout(hoverTimeout);
});
}
}
disconnect() {
this.resizeObserver.disconnect();
// Disconnect mutation observer
this.mutationObserver.disconnect();
// Remove scroll listener
window.removeEventListener("scroll", this.scrollHandler, true);
}
get isOpen() {
if (this.dialogModeValue) {
return this.menuTarget.open;
} else {
return this.menuTarget.classList.contains("hidden") === false;
}
}
async show() {
// Close all other open dropdowns that aren't in the same hierarchy
const allDropdowns = this.application.controllers.filter(
(c) => c.identifier === "dropdown-popover" && c !== this && c.isOpen
);
allDropdowns.forEach((controller) => {
if (!this.element.contains(controller.element) && !controller.element.contains(this.element)) {
controller.close();
}
});
// Close any sibling nested dropdowns first
if (this.nestedValue) {
const parentMenu = this.element.closest('[data-controller="menu"]');
if (parentMenu) {
const siblingDropdowns = parentMenu.querySelectorAll(
'[data-controller="dropdown-popover"][data-dropdown-popover-nested-value="true"]'
);
siblingDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller !== this && controller.isOpen) {
controller.close();
}
});
}
}
// If we have lazy loading enabled, load the content now
if (this.lazyLoadValue && !this.contentLoaded) {
await this.#loadTemplateContent();
this.contentLoaded = true;
}
if (this.dialogModeValue) {
this.menuTarget.show();
} else {
this.menuTarget.classList.remove("hidden");
}
this.#updateExpanded();
this.#updatePosition();
// Add active-menu-path class and background classes to the button when showing the dropdown
if (this.nestedValue) {
this.buttonTarget.classList.add("active-menu-path");
this.buttonTarget.classList.add("!bg-neutral-400/20");
}
requestAnimationFrame(() => {
// Add appropriate classes based on placement
if (this.placementValue.startsWith("top")) {
this.menuTarget.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100", "scale-100", "opacity-100");
} else {
this.menuTarget.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100", "scale-100", "opacity-100");
}
// Check for autofocus elements inside the menu
const autofocusElement = this.menuTarget.querySelector('[autofocus="true"], [autofocus]');
if (autofocusElement) {
// Focus the autofocus element
setTimeout(() => {
autofocusElement.focus();
// If it's an input or textarea, position cursor at the end
if (autofocusElement.tagName === "INPUT" || autofocusElement.tagName === "TEXTAREA") {
const length = autofocusElement.value.length;
autofocusElement.setSelectionRange(length, length);
}
}, 0);
} else if (this.nestedValue) {
this.menuTarget.focus();
} else {
this.buttonTarget.focus();
}
});
}
close() {
// Reset focus states in the menu before closing
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
menuController.reset();
}
// Close any child dropdowns first
const childDropdowns = this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]');
childDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller.isOpen) {
controller.close();
}
});
// Remove all active-menu-path classes within this menu
this.menuTarget.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
});
// Also remove from the button that triggered this dropdown and remove background classes
this.buttonTarget.classList.remove("active-menu-path");
this.buttonTarget.classList.remove("!bg-neutral-400/20");
// If this is a hover-enabled dropdown, ensure we blur any focused elements
if (this.hoverValue) {
const focusedElement = this.element.querySelector(":focus");
if (focusedElement) {
focusedElement.blur();
}
}
this.menuTarget.classList.remove("scale-100", "opacity-100", "[&[open]]:scale-100", "[&[open]]:opacity-100");
setTimeout(() => {
if (this.dialogModeValue) {
this.menuTarget.close();
} else {
this.menuTarget.classList.add("hidden");
}
this.#updateExpanded();
}, 100);
}
toggle() {
this.isOpen ? this.close() : this.show();
}
closeOnClickOutside({ target }) {
const isClickInNestedDropdown = target.closest('[data-dropdown-popover-nested-value="true"]');
if (isClickInNestedDropdown) return;
if (!this.element.contains(target)) {
this.close();
}
}
// Prevent close method to stop click propagation
preventClose(event) {
// Stop propagation to prevent the closeOnClickOutside handler from being triggered
event.stopPropagation();
}
// Reset method to handle dialog close events
reset() {
// This method is called when the dialog is closed
// It's responsible for any cleanup needed after closing
// If we have a menu controller, reset its state
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController && typeof menuController.reset === "function") {
menuController.reset();
}
// Dispatch a custom event that can be listened for by other controllers
const resetEvent = new CustomEvent("dropdown-reset", {
bubbles: true,
detail: { controller: this },
});
this.element.dispatchEvent(resetEvent);
}
#updateExpanded() {
this.buttonTarget.ariaExpanded = this.isOpen;
}
async #loadTemplateContent() {
// Find the container in the menu to append content to
const container = this.menuTarget.querySelector("[data-dropdown-popover-content]") || this.menuTarget;
// Check if we should use Turbo Frame lazy loading
if (this.turboFrameSrcValue) {
// Look for a turbo-frame in the container
let turboFrame = container.querySelector("turbo-frame");
if (!turboFrame) {
// Create a turbo-frame if it doesn't exist
turboFrame = document.createElement("turbo-frame");
turboFrame.id = "dropdown-lazy-content";
// Clear any loading indicators or placeholder content
container.innerHTML = "";
container.appendChild(turboFrame);
}
// Set the src to trigger the lazy load
turboFrame.src = this.turboFrameSrcValue;
// Wait for the turbo-frame to load
return new Promise((resolve) => {
const handleLoad = () => {
turboFrame.removeEventListener("turbo:frame-load", handleLoad);
this.#refreshMenuController();
resolve();
};
turboFrame.addEventListener("turbo:frame-load", handleLoad);
// Fallback timeout in case the frame doesn't load
setTimeout(() => {
turboFrame.removeEventListener("turbo:frame-load", handleLoad);
this.#refreshMenuController();
resolve();
}, 5000);
});
} else if (this.hasTemplateTarget) {
// Use template-based lazy loading (existing behavior)
const templateContent = this.templateTarget.content.cloneNode(true);
// Clear any loading indicators or placeholder content
container.innerHTML = "";
// Append the template content
container.appendChild(templateContent);
this.#refreshMenuController();
}
}
#refreshMenuController() {
// Refresh the menu controller to pick up new targets from the loaded content
setTimeout(() => {
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
// Disconnect and reconnect the menu controller to refresh its targets
menuController.disconnect();
menuController.connect();
}
}, 10);
}
async #updatePosition() {
// Parse placement value to support multiple placements
const placements = this.placementValue.split(/[\s,]+/).filter(Boolean);
const primaryPlacement = placements[0] || "bottom-start";
const fallbackPlacements = placements.slice(1);
const middleware = [
offset(this.nestedValue ? -4 : 4), // 0 offset for nested, 4px for regular
flip({
fallbackPlacements: fallbackPlacements.length > 0 ? fallbackPlacements : ["top-start", "bottom-start"],
}),
shift({ padding: 8 }), // Keep 8px padding from window edges
];
if (this.nestedValue) {
// For nested dropdowns, position to the right of the button
const { x, y } = await computePosition(this.buttonTarget, this.menuTarget, {
placement: "right-start",
middleware,
});
Object.assign(this.menuTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
} else {
// Use the primary placement from the controller
const { x, y } = await computePosition(this.buttonTarget, this.menuTarget, {
placement: primaryPlacement,
middleware: this.autoPositionValue ? middleware : [offset(this.nestedValue ? -4 : 4)],
});
Object.assign(this.menuTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
}
}
}
2. Floating UI Installation
The context menu component relies on Floating UI for intelligent tooltip positioning. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]/+esm"
npm install @floating-ui/dom
yarn add @floating-ui/dom
Examples
Basic context menu
A simple context menu with basic actions and keyboard shortcuts.
<div data-controller="context-menu">
<div class="cursor-context-menu select-none py-8 px-4 lg:px-8 border-2 border-dashed border-neutral-200 dark:border-neutral-700 text-xs lg:text-sm text-center text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 rounded-md">
<span class="hidden lg:inline">Right click to open the context menu</span>
<span class="inline lg:hidden">Long press to open the context menu</span>
</div>
<dialog
class="outline-none fixed z-50 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
style="margin: 0;"
data-context-menu-target="menu"
data-controller="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Cut
<span class="ml-auto text-xs text-neutral-500">⌘X</span>
</button>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Copy
<span class="ml-auto text-xs text-neutral-500">⌘C</span>
</button>
<button disabled class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Duplicate
<span class="ml-auto text-xs text-neutral-500">⌘D</span>
</button>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Paste
<span class="ml-auto text-xs text-neutral-500">⌘V</span>
</button>
</div>
</dialog>
</div>
Context menu with nested submenus on hover
A context menu where nested submenus open on hover for faster navigation. Uses data-dropdown-popover-hover-value="true"
.
<div data-controller="context-menu">
<div class="cursor-context-menu select-none py-8 px-4 lg:px-8 border-2 border-dashed border-neutral-200 dark:border-neutral-700 text-xs lg:text-sm text-center text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 rounded-md">
<span class="hidden lg:inline">Right click to open the context menu</span>
<span class="inline lg:hidden">Long press to open the context menu</span>
</div>
<dialog
class="outline-none fixed z-50 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
style="margin: 0;"
data-context-menu-target="menu"
data-controller="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Cut
<span class="ml-auto text-xs text-neutral-500">⌘X</span>
</button>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Copy
<span class="ml-auto text-xs text-neutral-500">⌘C</span>
</button>
<button disabled class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Duplicate
<span class="ml-auto text-xs text-neutral-500">⌘D</span>
</button>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Paste
<span class="ml-auto text-xs text-neutral-500">⌘V</span>
</button>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-hover-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
Share
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<title>open-nested-menu</title>
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Email link
</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Messages
</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Notes
</button>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-hover-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
Social Media
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<title>open-nested-menu</title>
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Twitter
</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Facebook
</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
LinkedIn
</button>
</div>
</dialog>
</div>
</div>
</div>
</dialog>
</div>
</div>
</div>
</dialog>
</div>
Context menu with nested submenus on click
A context menu with nested submenus that open on click.
<div data-controller="context-menu">
<div class="cursor-context-menu rounded-md border-2 border-dashed border-neutral-200 bg-neutral-50 px-4 py-8 text-center text-xs text-neutral-500 select-none lg:px-8 lg:text-sm dark:border-neutral-700 dark:bg-neutral-800/50">
<span class="hidden lg:inline">Right click to open the context menu</span>
<span class="inline lg:hidden">Long press to open the context menu</span>
</div>
<dialog
class="outline-none fixed z-50 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
style="margin: 0;"
data-context-menu-target="menu"
data-controller="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Open</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Rename</button>
<div class="my-1 h-px bg-neutral-200 dark:bg-neutral-700"></div>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
<div class="flex items-center gap-2">New</div>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<title>open-nested-menu</title>
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">File</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Folder</button>
</div>
</dialog>
</div>
</div>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
<div class="flex items-center gap-2">More</div>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<title>open-nested-menu</title>
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Properties</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Settings</button>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
<div class="flex items-center gap-2">Even more!</div>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<title>open-nested-menu</title>
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Advanced properties</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Advanced settings</button>
</div>
</dialog>
</div>
</div>
</div>
</dialog>
</div>
</div>
<div class="my-1 h-px bg-neutral-200 dark:bg-neutral-700"></div>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-red-600 focus:bg-red-50 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:focus:bg-red-400/10" data-menu-target="item" role="menuitem">Delete</button>
</div>
</dialog>
</div>
Don't close context menu when clicking on an item
A context menu that does not close when clicking on an item. Note that this example uses the Clipboard component.
<div data-controller="context-menu" data-context-menu-auto-close-value="false">
<div class="cursor-context-menu select-none py-8 px-4 lg:px-8 border-2 border-dashed border-neutral-200 dark:border-neutral-700 text-xs lg:text-sm text-center text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 rounded-md">
<span class="hidden lg:inline">Right click to open the context menu</span>
<span class="inline lg:hidden">Long press to open the context menu</span>
</div>
<dialog
class="outline-none fixed z-50 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
style="margin: 0;"
data-context-menu-target="menu"
data-controller="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<div class="px-2 py-1 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none mx-auto">railsblocks.com</div>
<div class="h-px bg-neutral-200 dark:bg-neutral-700 my-1"></div>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed"
data-menu-target="item"
role="menuitem"
data-controller="clipboard"
data-clipboard-text="https://railsblocks.com"
data-clipboard-show-tooltip-value="false"
data-clipboard-success-message-value="Description copied!">
<div data-clipboard-target="copyContent" class="flex items-center gap-1.5">
<span>Copy to clipboard</span>
</div>
<div data-clipboard-target="copiedContent" class="hidden flex items-center gap-1.5 ">
<span>Copied!</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4 text-green-500" width="18" height="18" viewBox="0 0 18 18">
<g fill="currentColor">
<path d="M6.75,15h-.002c-.227,0-.442-.104-.583-.281L2.165,9.719c-.259-.324-.207-.795,.117-1.054,.325-.259,.796-.206,1.054,.117l3.418,4.272L14.667,3.278c.261-.322,.732-.373,1.055-.111,.322,.261,.372,.733,.111,1.055L7.333,14.722c-.143,.176-.357,.278-.583,.278Z"></path>
</g>
</svg>
</div>
<span class="ml-auto text-xs text-neutral-500">⌘C</span>
</button>
<a href="javascript:void(0)" class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Open in new tab
<span class="ml-auto text-xs text-neutral-500">⌘N</span>
</a>
<button disabled class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Duplicate
<span class="ml-auto text-xs text-neutral-500">⌘D</span>
</button>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Paste
<span class="ml-auto text-xs text-neutral-500">⌘V</span>
</button>
<div class="flex flex-col" role="menu">
<div class="relative w-full"
data-controller="dropdown-popover"
data-dropdown-popover-nested-value="true"
data-dropdown-popover-auto-close-value="false"
data-dropdown-popover-hover-value="true"
data-dropdown-popover-flip-class="translate-y-full">
<button class="text-left w-full text-sm flex items-center justify-between px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50"
data-dropdown-popover-target="button"
data-action="dropdown-popover#toggle"
data-menu-target="item"
role="menuitem">
Options
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12"><title>open-nested-menu</title><g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g></svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1.5" role="listbox">
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox">
<span class="text-sm font-normal">Option 1</span>
</label>
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox">
<span class="text-sm font-normal">Option 2</span>
</label>
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox">
<span class="text-sm font-normal">Option 3</span>
</label>
</div>
</dialog>
</div>
</div>
<button class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-red-600 focus:bg-red-50 focus:outline-none dark:text-red-400 dark:focus:bg-red-400/10 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Delete
<span class="ml-auto text-xs text-red-600/75 dark:text-red-400/75">⌫</span>
</button>
<div class="h-px bg-neutral-200 dark:bg-neutral-700 my-1"></div>
<button data-action="context-menu#close" class="text-left text-sm flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50 disabled:opacity-50 disabled:cursor-not-allowed" data-menu-target="item" role="menuitem">
Force close menu
</button>
</div>
</dialog>
</div>
Configuration
Basic Setup
To create a context menu, wrap your trigger element with the context-menu controller and add a dialog element as the menu:
<div data-controller="context-menu">
<div class="trigger-area">
Right click to open menu
</div>
<dialog
data-context-menu-target="menu"
data-controller="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button data-menu-target="item" role="menuitem">Action 1</button>
<button data-menu-target="item" role="menuitem">Action 2</button>
</div>
</dialog>
</div>
Targets
Target | Controller | Description |
---|---|---|
menu
|
context-menu
|
The dialog element that contains the context menu |
item
|
menu
|
Individual menu items for keyboard navigation |
Features
The context menu includes the following features:
- Right-click detection: Shows menu on right-click events
- Mobile support: Long press (500ms) to trigger on touch devices
- Smart positioning: Automatically adjusts position to stay within viewport
- Keyboard navigation: Arrow keys, Enter, and Escape support
- Nested submenus: Support for multi-level dropdown menus
- Scroll lock: Prevents background scrolling when menu is open
- Auto-close: Closes when clicking outside or pressing Escape
- Text search: Type to search and focus menu items
Accessibility
The context menu is built with accessibility in mind:
- ARIA support: Proper
role="menu"
androle="menuitem"
attributes - Screen reader friendly: Semantic HTML structure
- Keyboard navigation: Full keyboard accessibility
Mobile Behavior
On mobile devices, the context menu responds to touch gestures:
- Long press: Hold for 500ms to trigger the context menu
- Movement tolerance: Up to 10px movement allowed during long press
- Touch cancellation: Moving too far or lifting finger cancels the action
Nested Submenus
Create nested submenus using the dropdown-popover controller:
<div class="relative w-full"
data-controller="dropdown-popover"
data-dropdown-popover-nested-value="true"
data-dropdown-popover-flip-class="translate-y-full">
<button data-dropdown-popover-target="button"
data-action="dropdown-popover#toggle"
data-menu-target="item"
role="menuitem">
Submenu
<svg>...</svg>
</button>
<dialog data-controller="menu"
data-dropdown-popover-target="menu">
<div class="flex flex-col p-1" role="menu">
<button data-menu-target="item" role="menuitem">Nested Item 1</button>
<button data-menu-target="item" role="menuitem">Nested Item 2</button>
</div>
</dialog>
</div>
Dropdown Popover Options
Configure nested submenus with these dropdown-popover options:
Option | Type | Default | Description |
---|---|---|---|
nested
|
Boolean
|
false
|
Enable nested dropdown behavior |
hover
|
Boolean
|
false
|
Open submenu on hover instead of click |
flipClass
|
String
|
""
|
CSS class to apply when flipping position |
autoClose
|
Boolean
|
true
|
Whether to close the menu when clicking on an item |
Styling tip
You can use these classes for a cleaner codebase:
/* Base menu styles */
.context-menu-dialog {
@apply outline-none fixed z-50 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50;
}
.nested-menu-dialog {
@apply absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100;
}
/* Menu items */
.menu-item {
@apply flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50;
}
.delete-menu-item {
@apply flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-red-600 focus:bg-red-50 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:focus:bg-red-400/10;
}
Events
The context menu dispatches custom events:
Event | When | Detail |
---|---|---|
menu-item-clicked
|
Menu item is clicked | Contains the clicked element |
JavaScript API
Access the controller programmatically:
// Get the controller instance
const element = document.querySelector('[data-controller="context-menu"]');
const controller = application.getControllerForElementAndIdentifier(element, 'context-menu');
// Show menu at specific position
controller.showMenuAtPosition(100, 200);
// Close the menu
controller.closeMenu({ target: document.body });
// Check if menu is open
const isOpen = controller.menuTarget.open;