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"
Terminal
npm install @floating-ui/dom
Terminal
yarn add @floating-ui/dom

Examples

Basic context menu

A simple context menu with basic actions and keyboard shortcuts.

Long press to open the context menu
<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".

Long press to open the context menu
<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.

Long press to open the context menu
<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.

Long press to open the context menu
<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" and role="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>

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;

Table of contents