Context Menu
Displays a menu to the user — such as a set of actions or functions — triggered by a right click.
Usage
Example
ContextMenu do ContextMenuTrigger(class: 'flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') { "Right click here" } ContextMenuContent(class: 'w-64') do ContextMenuItem(href: '#', shortcut: "⌘[") { "Back" } ContextMenuItem(href: '#', shortcut: "⌘]", disabled: true) { "Forward" } ContextMenuItem(href: '#', shortcut: "⌘R") { "Reload" } ContextMenuSeparator ContextMenuItem(href: '#', shortcut: "⌘⇧B", checked: true) { "Show Bookmarks Bar" } ContextMenuItem(href: '#') { "Show Full URLs" } ContextMenuSeparator ContextMenuLabel(inset: true) { "More Tools" } ContextMenuSeparator ContextMenuItem(href: '#') { "Developer Tools" } ContextMenuItem(href: '#') { "Task Manager" } ContextMenuItem(href: '#') { "Extensions" } end end
Placement
div(class: 'space-y-4') do ContextMenu(options: { placement: 'right' }) do ContextMenuTrigger(class: 'flex flex-col items-center gap-y-2 h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') do plain "Right click here" Badge(variant: :primary) { "right" } end ContextMenuContent(class: 'w-64') do ContextMenuItem(href: '#', shortcut: "⌘[") { "Back" } ContextMenuItem(href: '#', shortcut: "⌘]", disabled: true) { "Forward" } ContextMenuItem(href: '#', shortcut: "⌘R") { "Reload" } ContextMenuSeparator ContextMenuItem(href: '#', shortcut: "⌘⇧B", checked: true) { "Show Bookmarks Bar" } ContextMenuItem(href: '#') { "Show Full URLs" } ContextMenuSeparator ContextMenuLabel(inset: true) { "More Tools" } ContextMenuSeparator ContextMenuItem(href: '#') { "Developer Tools" } ContextMenuItem(href: '#') { "Task Manager" } ContextMenuItem(href: '#') { "Extensions" } end end ContextMenu(options: { placement: 'left' }) do ContextMenuTrigger(class: 'flex flex-col items-center gap-y-2 h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed text-sm') do plain "Right click here" Badge(variant: :primary) { "left" } end ContextMenuContent(class: 'w-64') do ContextMenuItem(href: '#', shortcut: "⌘[") { "Back" } ContextMenuItem(href: '#', shortcut: "⌘]", disabled: true) { "Forward" } ContextMenuItem(href: '#', shortcut: "⌘R") { "Reload" } ContextMenuSeparator ContextMenuItem(href: '#', shortcut: "⌘⇧B", checked: true) { "Show Bookmarks Bar" } ContextMenuItem(href: '#') { "Show Full URLs" } ContextMenuSeparator ContextMenuLabel(inset: true) { "More Tools" } ContextMenuSeparator ContextMenuItem(href: '#') { "Developer Tools" } ContextMenuItem(href: '#') { "Task Manager" } ContextMenuItem(href: '#') { "Extensions" } end end end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component ContextMenu
Manual installation
1
Add RubyUI::ContextMenu
to app/components/ruby_ui/context_menu.rb
# frozen_string_literal: true module RubyUI class ContextMenu < Base def initialize(options: {}, **attrs) @options = options @options[:trigger] ||= "manual" super(**attrs) end def view_template(&) div(**attrs, &) end private def default_attrs { data: { controller: "ruby-ui--context-menu", popover_options_value: @options.to_json } } end end end
2
Add RubyUI::ContextMenuContent
to app/components/ruby_ui/context_menu/context_menu_content.rb
# frozen_string_literal: true module RubyUI class ContextMenuContent < Base def view_template(&block) template(data: {ruby_ui__context_menu_target: "content"}) do div(**attrs, &block) end end private def default_attrs { role: "menu", aria_orientation: "vertical", data_state: "open", class: "z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", tabindex: "-1", data_orientation: "vertical" } end end end
3
Add RubyUI::ContextMenuItem
to app/components/ruby_ui/context_menu/context_menu_item.rb
# frozen_string_literal: true module RubyUI class ContextMenuItem < Base def initialize(href: "#", checked: false, shortcut: nil, disabled: false, **attrs) @href = href @checked = checked @shortcut = shortcut @disabled = disabled super(**attrs) end def view_template(&block) a(**attrs) do render_checkmark if @checked yield render_shortcut if @shortcut end end private def render_checkmark span(class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center") do span(data_state: "checked") do svg( width: "15", height: "15", viewbox: "0 0 15 15", fill: "none", xmlns: "http://www.w3.org/2000/svg", class: "h-4 w-4" ) do |s| s.path( d: "M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z", fill: "currentColor", fill_rule: "evenodd", clip_rule: "evenodd" ) end end end end def render_shortcut span(class: "ml-auto text-xs tracking-widest text-muted-foreground") { @shortcut } end def default_attrs { href: @href, role: "menuitem", class: "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 pl-8", tabindex: "-1", data_orientation: "vertical", data_action: "click->ruby-ui--context-menu#close", data_ruby_ui__context_menu_target: "menuItem", data_disabled: @disabled, disabled: @disabled } end end end
4
Add RubyUI::ContextMenuLabel
to app/components/ruby_ui/context_menu/context_menu_label.rb
# frozen_string_literal: true module RubyUI class ContextMenuLabel < Base def initialize(inset: false, **attrs) @inset = inset super(**attrs) end def view_template(&) div(**attrs, &) end private def inset? = @inset def default_attrs { class: ["px-2 py-1.5 text-sm font-semibold text-foreground", inset?: "pl-8"] } end end end
5
Add RubyUI::ContextMenuSeparator
to app/components/ruby_ui/context_menu/context_menu_separator.rb
# frozen_string_literal: true module RubyUI class ContextMenuSeparator < Base def view_template div(**attrs) end private def default_attrs { role: "separator", aria_orientation: "horizontal", class: "-mx-1 my-1 h-px bg-border" } end end end
6
Add RubyUI::ContextMenuTrigger
to app/components/ruby_ui/context_menu/context_menu_trigger.rb
# frozen_string_literal: true module RubyUI class ContextMenuTrigger < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: { ruby_ui__context_menu_target: "trigger", action: "contextmenu->ruby-ui--context-menu#handleContextMenu" } } end end end
7
Add context_menu_controller.js
to app/javascript/controllers/ruby_ui/context_menu_controller.js
import { Controller } from "@hotwired/stimulus"; import tippy from "tippy.js"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; static values = { options: { type: Object, default: {}, }, // make content width of the trigger element (true/false) matchWidth: { type: Boolean, default: false, } } connect() { this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later this.initializeTippy(); this.selectedIndex = -1; } disconnect() { this.destroyTippy(); } initializeTippy() { const defaultOptions = { content: this.contentTarget.innerHTML, allowHTML: true, interactive: true, onShow: (instance) => { this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width this.addEventListeners(); }, onHide: () => { this.removeEventListeners(); this.deselectAll(); }, popperOptions: { modifiers: [ { name: "offset", options: { offset: [0, 4] }, }, ], } }; const mergedOptions = { ...this.optionsValue, ...defaultOptions }; this.tippy = tippy(this.triggerTarget, mergedOptions); } destroyTippy() { if (this.tippy) { this.tippy.destroy(); } } setContentWidth(instance) { // box-sizing: border-box const content = instance.popper.querySelector('.tippy-content'); if (content) { content.style.width = `${instance.reference.offsetWidth}px`; } } handleContextMenu(event) { event.preventDefault(); this.open(); } open() { this.tippy.show(); } close() { this.tippy.hide(); } handleKeydown(e) { // return if no menu items (one line fix for) if (this.menuItemTargets.length === 0) { return; } if (e.key === 'ArrowDown') { e.preventDefault(); this.updateSelectedItem(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.updateSelectedItem(-1); } else if (e.key === 'Enter' && this.selectedIndex !== -1) { e.preventDefault(); this.menuItemTargets[this.selectedIndex].click(); } } updateSelectedItem(direction) { // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index this.menuItemTargets.forEach((item, index) => { if (item.getAttribute('aria-selected') === 'true') { this.selectedIndex = index; } }); if (this.selectedIndex >= 0) { this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false); } this.selectedIndex += direction; if (this.selectedIndex < 0) { this.selectedIndex = this.menuItemTargets.length - 1; } else if (this.selectedIndex >= this.menuItemTargets.length) { this.selectedIndex = 0; } this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true); } toggleAriaSelected(element, isSelected) { // Add or remove attribute if (isSelected) { element.setAttribute('aria-selected', 'true'); } else { element.removeAttribute('aria-selected'); } } deselectAll() { this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false)); this.selectedIndex = -1; } addEventListeners() { document.addEventListener('keydown', this.boundHandleKeydown); } removeEventListeners() { document.removeEventListener('keydown', this.boundHandleKeydown); } }
8
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
9
Install tippy.js
Javascript dependency
// with yarn yarn add tippy.js // with npm npm install tippy.js // with importmaps // Add to your config/importmap.rb pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm" pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"
Components
Component | Built using | Source |
---|---|---|
ContextMenu | Phlex | |
ContextMenuContent | Phlex | |
ContextMenuItem | Phlex | |
ContextMenuLabel | Phlex | |
ContextMenuSeparator | Phlex | |
ContextMenuTrigger | Phlex | |
ContextMenuController | Stimulus JS |