Dropdown Menu
Displays a menu to the user — such as a set of actions or functions — triggered by a button.
Usage
Example
DropdownMenu do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline) { "Open" } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end
Placement
If the DropdownMenu conflicts with edge, it will auto-adjust it's placement
div(class: 'grid grid-cols-1 sm:grid-cols-3 gap-4') do # -- TOP -- DropdownMenu(options: { placement: 'top' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'top' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'top-start' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'top-start' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'top-end' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'top-end' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end # -- BOTTOM -- DropdownMenu(options: { placement: 'bottom' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'bottom' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'bottom-start' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'bottom-start' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'bottom-end' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'bottom-end' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end # -- LEFT -- DropdownMenu(options: { placement: 'left' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'left' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'left-start' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'left-start' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'left-end' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'left-end' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end # -- RIGHT -- DropdownMenu(options: { placement: 'right' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'right' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'right-start' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'right-start' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end DropdownMenu(options: { placement: 'right-end' }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline, class: 'w-full justify-center') { 'right-end' } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end end
Open on hover
DropdownMenu(options: { trigger: "mouseenter focus" }) do DropdownMenuTrigger(class: 'w-full') do Button(variant: :outline) { "Open" } end DropdownMenuContent do DropdownMenuLabel { "My Account" } DropdownMenuSeparator DropdownMenuItem(href: '#') { "Profile" } DropdownMenuItem(href: '#') { "Billing" } DropdownMenuItem(href: '#') { "Team" } DropdownMenuItem(href: '#') { "Subscription" } end end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component DropdownMenu
Manual installation
1
Add RubyUI::DropdownMenu
to app/components/ruby_ui/dropdown_menu.rb
# frozen_string_literal: true module RubyUI class DropdownMenu < Base def initialize(options: {}, **attrs) @options = options super(**attrs) end def view_template(&) div(**attrs, &) end private def default_attrs { data: { controller: "ruby-ui--dropdown-menu", action: "click@window->ruby-ui--dropdown-menu#onClickOutside", ruby_ui__dropdown_menu_options_value: @options.to_json } } end end end
2
Add RubyUI::DropdownMenuContent
to app/components/ruby_ui/dropdown_menu/dropdown_menu_content.rb
# frozen_string_literal: true module RubyUI class DropdownMenuContent < Base def view_template(&block) div(data: {ruby_ui__dropdown_menu_target: "content"}, class: "hidden", style: "width: max-content; position: absolute; top: 0; left: 0;") do div(**attrs, &block) end end private def default_attrs { data: { state: :open }, class: "z-50 min-w-[8rem] 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 w-56" } end end end
3
Add RubyUI::DropdownMenuItem
to app/components/ruby_ui/dropdown_menu/dropdown_menu_item.rb
# frozen_string_literal: true module RubyUI class DropdownMenuItem < Base def initialize(href: "#", **attrs) @href = href super(**attrs) end def view_template(&) a(**attrs, &) end private 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 transition-colors 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", data_action: "click->ruby-ui--dropdown-menu#close", data_ruby_ui__dropdown_menu_target: "menuItem", tabindex: "-1", data_orientation: "vertical" } end end end
4
Add RubyUI::DropdownMenuLabel
to app/components/ruby_ui/dropdown_menu/dropdown_menu_label.rb
# frozen_string_literal: true module RubyUI class DropdownMenuLabel < Base def view_template(&) h3(**attrs, &) end private def default_attrs { class: "px-2 py-1.5 text-sm font-semibold" } end end end
5
Add RubyUI::DropdownMenuSeparator
to app/components/ruby_ui/dropdown_menu/dropdown_menu_separator.rb
# frozen_string_literal: true module RubyUI class DropdownMenuSeparator < Base def view_template div(**attrs) end private def default_attrs { role: "separator", aria_orientation: "horizontal", class: "-mx-1 my-1 h-px bg-muted" } end end end
6
Add RubyUI::DropdownMenuTrigger
to app/components/ruby_ui/dropdown_menu/dropdown_menu_trigger.rb
# frozen_string_literal: true module RubyUI class DropdownMenuTrigger < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: {ruby_ui__dropdown_menu_target: "trigger", action: "click->ruby-ui--dropdown-menu#toggle"}, class: "inline-block" } end end end
7
Add dropdown_menu_controller.js
to app/javascript/controllers/ruby_ui/dropdown_menu_controller.js
import { Controller } from "@hotwired/stimulus"; import { computePosition, flip, shift, offset } from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; static values = { open: { type: Boolean, default: false, }, options: { type: Object, default: {}, }, } connect() { this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later this.selectedIndex = -1; } #computeTooltip() { computePosition(this.triggerTarget, this.contentTarget, { placement: this.optionsValue.placement || "top", middleware: [flip(), shift(), offset(8)], }).then(({ x, y }) => { Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px`, }); }); } onClickOutside(event) { if (!this.openValue) return; if (this.element.contains(event.target)) return; event.preventDefault(); this.close(); } toggle() { this.contentTarget.classList.contains("hidden") ? this.#open() : this.close(); } #open() { this.openValue = true; this.#deselectAll(); this.#addEventListeners(); this.#computeTooltip() this.contentTarget.classList.remove("hidden"); } close() { this.openValue = false; this.#removeEventListeners(); this.contentTarget.classList.add("hidden"); } #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 @floating-ui/dom
Javascript dependency
// with yarn yarn add @floating-ui/dom // with npm npm install @floating-ui/dom // with importmaps bin/importmap pin @floating-ui/dom
Components
Component | Built using | Source |
---|---|---|
DropdownMenu | Phlex | |
DropdownMenuContent | Phlex | |
DropdownMenuItem | Phlex | |
DropdownMenuLabel | Phlex | |
DropdownMenuSeparator | Phlex | |
DropdownMenuTrigger | Phlex | |
DropdownMenuController | Stimulus JS |