Dropdown Menu
Displays a menu to the user — such as a set of actions or functions — triggered by a button.
Usage
Basic
Click the button, or focus it and press Enter, Space, ArrowDown or ArrowUp: the menu opens and takes focus. Arrow keys, Home/End and typing a letter move through the items; Enter or Space picks one; Esc closes it and focus goes back to the button; Tab closes it and moves on to the next control, Shift+Tab stops on the button. While it is open the page behind is inert and doesn't scroll.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuGroup do
DropdownMenuLabel { "My Account" }
DropdownMenuItem { "Profile" }
DropdownMenuItem { "Billing" }
DropdownMenuItem { "Settings" }
end
DropdownMenuSeparator()
DropdownMenuItem { "GitHub" }
DropdownMenuItem { "Support" }
DropdownMenuItem(disabled: true) { "API" }
end
end
With shortcuts
DropdownMenuShortcut shows the key combination. It only displays the keys: binding them is up to you.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-56") do
DropdownMenuGroup do
DropdownMenuLabel { "My Account" }
DropdownMenuItem do
plain "Profile"
DropdownMenuShortcut { "⇧⌘P" }
end
DropdownMenuItem do
plain "Billing"
DropdownMenuShortcut { "⌘B" }
end
DropdownMenuItem do
plain "Settings"
DropdownMenuShortcut { "⌘S" }
end
end
DropdownMenuSeparator()
DropdownMenuItem do
plain "Log out"
DropdownMenuShortcut { "⇧⌘Q" }
end
end
end
Checkboxes
DropdownMenuCheckboxItem toggles aria-checked and keeps the menu open. Listen to ruby-ui--dropdown-menu:change; event.detail.checked has the new state.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-40") do
DropdownMenuGroup do
DropdownMenuLabel { "Appearance" }
DropdownMenuCheckboxItem(checked: true) { "Status Bar" }
DropdownMenuCheckboxItem(disabled: true) { "Activity Bar" }
DropdownMenuCheckboxItem { "Panel" }
end
end
end
Radio group
DropdownMenuRadioItems in a DropdownMenuRadioGroup: checking one unchecks the rest. In ruby-ui--dropdown-menu:change, event.detail.value carries the item's value: argument.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-32") do
DropdownMenuRadioGroup do
DropdownMenuLabel { "Panel Position" }
DropdownMenuRadioItem(value: "top") { "Top" }
DropdownMenuRadioItem(value: "bottom", checked: true) { "Bottom" }
DropdownMenuRadioItem(value: "right") { "Right" }
end
end
end
Destructive
variant: :destructive marks an item that deletes or can't be undone.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Actions" }
end
DropdownMenuContent do
DropdownMenuGroup do
DropdownMenuItem { "Edit" }
DropdownMenuItem { "Share" }
end
DropdownMenuSeparator()
DropdownMenuItem(variant: :destructive) { "Delete" }
end
end
Inset
inset: indents an item or label (pl-8), e.g. to line it up with items that show an indicator.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-44") do
DropdownMenuGroup do
DropdownMenuLabel(inset: true) { "Edit" }
DropdownMenuItem(inset: true) { "Copy" }
DropdownMenuItem(inset: true) { "Cut" }
end
DropdownMenuSeparator()
DropdownMenuCheckboxItem(checked: true) { "Word Wrap" }
end
end
Links
Items render as divs; href: turns one into a link that navigates when picked.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuItem(href: "#links") { "Documentation" }
DropdownMenuItem(href: "#links") { "Changelog" }
end
end
Opening a dialog
An item can host its own interactive element, such as a DialogTrigger. Picking it opens the dialog and closes the menu.
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuItem { "Edit" }
DropdownMenuItem(variant: :destructive) do
Dialog do
DialogTrigger(class: "w-full text-left") { "Delete" }
DialogContent do
DialogHeader do
DialogTitle { "Are you absolutely sure?" }
DialogDescription { "This action cannot be undone." }
end
DialogFooter do
Button(variant: :destructive, data: {action: "click->ruby-ui--dialog#dismiss"}) { "Delete" }
end
end
end
end
end
end
Side and align
side: (top, right, bottom, left) and align: (start, center, end) place the menu next to the button; side_offset: and align_offset: set the gap. The defaults are bottom, start, 4 and 0. The menu flips and shifts to stay on screen, and is as wide as the button (at least 8rem).
div(class: "grid grid-cols-2 gap-4 sm:grid-cols-4") do
%i[top right bottom left].each do |side|
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline, class: "w-full") { side.to_s }
end
DropdownMenuContent(side: side, align: :center) do
DropdownMenuItem { "Profile" }
DropdownMenuItem { "Billing" }
DropdownMenuItem { "Settings" }
end
end
end
end
Non-modal
modal: false leaves the page scrollable and usable while the menu is open; a click outside still closes it.
DropdownMenu(modal: false) do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuItem { "Profile" }
DropdownMenuItem { "Billing" }
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/dropdown_menu.rb
# frozen_string_literal: true
module RubyUI
# The root. open: renders it open; modal: false keeps the page usable while
# it is open (no scroll lock, nothing made inert). Placement lives on
# DropdownMenuContent (side:, align:, side_offset:, align_offset:).
# options: {placement: "bottom-start"} still works as a fallback for side:
# and align:; options[:strategy] is ignored (the menu is a top-layer popover).
class DropdownMenu < Base
def initialize(open: false, modal: true, options: {}, **attrs)
@open = open
@modal = modal
@options = options
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "dropdown-menu",
controller: "ruby-ui--dropdown-menu",
ruby_ui__dropdown_menu_open_value: @open.to_s,
ruby_ui__dropdown_menu_modal_value: @modal.to_s,
ruby_ui__dropdown_menu_options_value: @options.to_json
}
}
end
end
end
2
Add RubyUI::DropdownMenuCheckboxItem to app/components/ruby_ui/dropdown_menu/dropdown_menu_checkbox_item.rb
# frozen_string_literal: true
module RubyUI
# A toggle in the menu (role menuitemcheckbox). Activating it flips
# aria-checked, keeps the menu open and fires ruby-ui--dropdown-menu:change
# with {checked} in the detail.
class DropdownMenuCheckboxItem < Base
ITEM = [
"group/dropdown-menu-checkbox-item relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
].freeze
def initialize(checked: false, inset: false, disabled: false, **)
@checked = checked
@inset = inset
@disabled = disabled
super(**)
end
def view_template(&block)
div(**attrs) do
render DropdownMenuItemIndicator.new(kind: :checkbox)
yield if block
end
end
private
def default_attrs
{
role: "menuitemcheckbox",
tabindex: "-1",
aria: {checked: @checked.to_s, disabled: (@disabled ? "true" : nil)},
data: {
slot: "dropdown-menu-checkbox-item",
inset: @inset,
disabled: @disabled,
action: "click->ruby-ui--dropdown-menu#activate pointermove->ruby-ui--dropdown-menu#hover pointerleave->ruby-ui--dropdown-menu#leave"
},
class: ITEM
}
end
end
end
3
Add RubyUI::DropdownMenuContent to app/components/ruby_ui/dropdown_menu/dropdown_menu_content.rb
# frozen_string_literal: true
module RubyUI
# The menu. A native popover (top layer) placed by Floating UI next to the
# trigger, as wide as the trigger (at least min-w-32). side:/align: pick the
# placement, side_offset:/align_offset: the gap, with the defaults of
# shadcn's DropdownMenuContent (bottom, start, 4, 0).
class DropdownMenuContent < Base
SIDES = %i[top right bottom left].freeze
ALIGNS = %i[start center end].freeze
def initialize(side: nil, align: nil, side_offset: 4, align_offset: 0, **)
@side = (side && SIDES.include?(side.to_sym)) ? side.to_sym : nil
@align = (align && ALIGNS.include?(align.to_sym)) ? align.to_sym : nil
@side_offset = side_offset
@align_offset = align_offset
super(**)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
role: "menu",
tabindex: "-1",
popover: "manual",
data: {
slot: "dropdown-menu-content",
# Kept in sync with data-open/data-closed for CSS that targets data-[state=...].
state: "closed",
closed: "",
ruby_ui__dropdown_menu_target: "content",
action: "keydown->ruby-ui--dropdown-menu#keydown",
preferred_side: @side,
align: @align,
side_offset: @side_offset,
align_offset: @align_offset
},
class: [
"fixed inset-auto m-0 border-0 z-50 w-(--anchor-width) min-w-32 max-h-(--available-height) origin-(--transform-origin)",
"overflow-x-hidden overflow-y-auto outline-none",
"bg-popover text-popover-foreground ring-1 ring-foreground/10 rounded-md p-1 shadow-md duration-100",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:fill-mode-forwards",
"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"
]
}
end
end
end
4
Add RubyUI::DropdownMenuDocs to app/components/ruby_ui/dropdown_menu/dropdown_menu_docs.rb
# frozen_string_literal: true
class Views::Docs::DropdownMenu < Views::Base
def view_template
component = "DropdownMenu"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Dropdown Menu", description: "Displays a menu to the user — such as a set of actions or functions — triggered by a button.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Basic", description: "Click the button, or focus it and press Enter, Space, ArrowDown or ArrowUp: the menu opens and takes focus. Arrow keys, Home/End and typing a letter move through the items; Enter or Space picks one; Esc closes it and focus goes back to the button; Tab closes it and moves on to the next control, Shift+Tab stops on the button. While it is open the page behind is inert and doesn't scroll.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuGroup do
DropdownMenuLabel { "My Account" }
DropdownMenuItem { "Profile" }
DropdownMenuItem { "Billing" }
DropdownMenuItem { "Settings" }
end
DropdownMenuSeparator()
DropdownMenuItem { "GitHub" }
DropdownMenuItem { "Support" }
DropdownMenuItem(disabled: true) { "API" }
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "With shortcuts", description: "DropdownMenuShortcut shows the key combination. It only displays the keys: binding them is up to you.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-56") do
DropdownMenuGroup do
DropdownMenuLabel { "My Account" }
DropdownMenuItem do
plain "Profile"
DropdownMenuShortcut { "⇧⌘P" }
end
DropdownMenuItem do
plain "Billing"
DropdownMenuShortcut { "⌘B" }
end
DropdownMenuItem do
plain "Settings"
DropdownMenuShortcut { "⌘S" }
end
end
DropdownMenuSeparator()
DropdownMenuItem do
plain "Log out"
DropdownMenuShortcut { "⇧⌘Q" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Submenu", description: "DropdownMenuSub pairs a DropdownMenuSubTrigger with its DropdownMenuSubContent. The submenu opens on hover, click, Enter, Space or ArrowRight; ArrowLeft or Esc closes it and goes back to its trigger.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuGroup do
DropdownMenuItem { "Team" }
DropdownMenuSub do
DropdownMenuSubTrigger { "Invite users" }
DropdownMenuSubContent do
DropdownMenuItem { "Email" }
DropdownMenuItem { "Message" }
DropdownMenuSeparator()
DropdownMenuItem { "More..." }
end
end
DropdownMenuItem { "New Team" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Checkboxes", description: "DropdownMenuCheckboxItem toggles aria-checked and keeps the menu open. Listen to ruby-ui--dropdown-menu:change; event.detail.checked has the new state.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-40") do
DropdownMenuGroup do
DropdownMenuLabel { "Appearance" }
DropdownMenuCheckboxItem(checked: true) { "Status Bar" }
DropdownMenuCheckboxItem(disabled: true) { "Activity Bar" }
DropdownMenuCheckboxItem { "Panel" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Radio group", description: "DropdownMenuRadioItems in a DropdownMenuRadioGroup: checking one unchecks the rest. In ruby-ui--dropdown-menu:change, event.detail.value carries the item's value: argument.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-32") do
DropdownMenuRadioGroup do
DropdownMenuLabel { "Panel Position" }
DropdownMenuRadioItem(value: "top") { "Top" }
DropdownMenuRadioItem(value: "bottom", checked: true) { "Bottom" }
DropdownMenuRadioItem(value: "right") { "Right" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Destructive", description: "variant: :destructive marks an item that deletes or can't be undone.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Actions" }
end
DropdownMenuContent do
DropdownMenuGroup do
DropdownMenuItem { "Edit" }
DropdownMenuItem { "Share" }
end
DropdownMenuSeparator()
DropdownMenuItem(variant: :destructive) { "Delete" }
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Inset", description: "inset: indents an item or label (pl-8), e.g. to line it up with items that show an indicator.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent(class: "w-44") do
DropdownMenuGroup do
DropdownMenuLabel(inset: true) { "Edit" }
DropdownMenuItem(inset: true) { "Copy" }
DropdownMenuItem(inset: true) { "Cut" }
end
DropdownMenuSeparator()
DropdownMenuCheckboxItem(checked: true) { "Word Wrap" }
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Links", description: "Items render as divs; href: turns one into a link that navigates when picked.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuItem(href: "#links") { "Documentation" }
DropdownMenuItem(href: "#links") { "Changelog" }
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Opening a dialog", description: "An item can host its own interactive element, such as a DialogTrigger. Picking it opens the dialog and closes the menu.", context: self) do
<<~RUBY
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuItem { "Edit" }
DropdownMenuItem(variant: :destructive) do
Dialog do
DialogTrigger(class: "w-full text-left") { "Delete" }
DialogContent do
DialogHeader do
DialogTitle { "Are you absolutely sure?" }
DialogDescription { "This action cannot be undone." }
end
DialogFooter do
Button(variant: :destructive, data: {action: "click->ruby-ui--dialog#dismiss"}) { "Delete" }
end
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Side and align", description: "side: (top, right, bottom, left) and align: (start, center, end) place the menu next to the button; side_offset: and align_offset: set the gap. The defaults are bottom, start, 4 and 0. The menu flips and shifts to stay on screen, and is as wide as the button (at least 8rem).", context: self) do
<<~RUBY
div(class: "grid grid-cols-2 gap-4 sm:grid-cols-4") do
%i[top right bottom left].each do |side|
DropdownMenu do
DropdownMenuTrigger do
Button(variant: :outline, class: "w-full") { side.to_s }
end
DropdownMenuContent(side: side, align: :center) do
DropdownMenuItem { "Profile" }
DropdownMenuItem { "Billing" }
DropdownMenuItem { "Settings" }
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Non-modal", description: "modal: false leaves the page scrollable and usable while the menu is open; a click outside still closes it.", context: self) do
<<~RUBY
DropdownMenu(modal: false) do
DropdownMenuTrigger do
Button(variant: :outline) { "Open" }
end
DropdownMenuContent do
DropdownMenuItem { "Profile" }
DropdownMenuItem { "Billing" }
end
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
5
Add RubyUI::DropdownMenuGroup to app/components/ruby_ui/dropdown_menu/dropdown_menu_group.rb
# frozen_string_literal: true
require "securerandom"
module RubyUI
# A group of items (role group), named by the DropdownMenuLabel inside it.
class DropdownMenuGroup < Base
def initialize(id: nil, **)
@id = id || "dropdown-menu-group-#{SecureRandom.hex(4)}"
super(id: @id, **)
end
# The children are captured first, so aria-labelledby only points at a
# DropdownMenuLabel that actually rendered.
def view_template(&block)
previous = context[:ruby_ui_dropdown_menu_group]
names = context[:ruby_ui_dropdown_menu_group] = {label_id: "#{@id}-label"}
body = block ? capture(&block) : ""
group_attrs = names[:labelledby] ? mix({aria: {labelledby: names[:labelledby]}}, attrs) : attrs
div(**group_attrs) { raw(safe(body)) }
ensure
context[:ruby_ui_dropdown_menu_group] = previous
end
private
def default_attrs
{
role: "group",
data: {slot: "dropdown-menu-group"}
}
end
end
end
6
Add RubyUI::DropdownMenuItem to app/components/ruby_ui/dropdown_menu/dropdown_menu_item.rb
# frozen_string_literal: true
module RubyUI
# An action. A div by default; href: makes it a link. variant: :destructive
# paints it red, inset: lines it up with items that have an indicator,
# disabled: keeps it focusable but never activated.
# as: :div (kept for compatibility) renders a div even with href:.
class DropdownMenuItem < Base
ITEM = [
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10",
"dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive",
"data-[variant=destructive]:*:[svg]:text-destructive",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
].freeze
def initialize(href: nil, as: nil, variant: :default, inset: false, disabled: false, **)
@href = href
@as = as&.to_sym
@variant = (variant.to_sym == :destructive) ? :destructive : :default
@inset = inset
@disabled = disabled
super(**)
end
def view_template(&)
link? ? a(**attrs, &) : div(**attrs, &)
end
private
def link?
return false if @as == :div
!@href.nil? || @as == :a
end
def default_attrs
{
href: (link? ? @href : nil),
role: "menuitem",
tabindex: "-1",
aria: {disabled: (@disabled ? "true" : nil)},
data: {
slot: "dropdown-menu-item",
variant: @variant,
inset: @inset,
disabled: @disabled,
action: "click->ruby-ui--dropdown-menu#activate pointermove->ruby-ui--dropdown-menu#hover pointerleave->ruby-ui--dropdown-menu#leave"
},
class: ITEM
}
end
end
end
7
Add RubyUI::DropdownMenuItemIndicator to app/components/ruby_ui/dropdown_menu/dropdown_menu_item_indicator.rb
# frozen_string_literal: true
module RubyUI
# The check of a DropdownMenuCheckboxItem / DropdownMenuRadioItem, shown only
# while the item is aria-checked="true". Rendered by those items.
class DropdownMenuItemIndicator < Base
# Full class names, so Tailwind finds them when it scans the source.
SHOW_WHEN_CHECKED = {
checkbox: "group-aria-checked/dropdown-menu-checkbox-item:flex",
radio: "group-aria-checked/dropdown-menu-radio-item:flex"
}.freeze
SLOTS = {
checkbox: "dropdown-menu-checkbox-item-indicator",
radio: "dropdown-menu-radio-item-indicator"
}.freeze
def initialize(kind:, **)
@kind = kind
super(**)
end
def view_template
span(**attrs) do
svg(
xmlns: "http://www.w3.org/2000/svg",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true"
) { |s| s.path(d: "M20 6 9 17l-5-5") }
end
end
private
def default_attrs
{
aria: {hidden: "true"},
data: {slot: SLOTS.fetch(@kind)},
class: ["pointer-events-none absolute right-2 hidden items-center justify-center", SHOW_WHEN_CHECKED.fetch(@kind)]
}
end
end
end
8
Add RubyUI::DropdownMenuLabel to app/components/ruby_ui/dropdown_menu/dropdown_menu_label.rb
# frozen_string_literal: true
module RubyUI
# A heading. Inside a DropdownMenuGroup the first label names the group
# (aria-labelledby) and its own text is hidden from the accessibility tree.
class DropdownMenuLabel < Base
def initialize(inset: false, **)
@inset = inset
super(**)
end
def view_template(&)
names = context[:ruby_ui_dropdown_menu_group]
# Only the first label names the group; later ones stay plain text.
return div(**attrs, &) if names.nil? || names[:labelledby]
id = attrs[:id] || names[:label_id]
names[:labelledby] ||= id
div(**mix({aria: {hidden: "true"}}, attrs.merge(id: id)), &)
end
private
def default_attrs
{
data: {slot: "dropdown-menu-label", inset: @inset},
class: "text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8"
}
end
end
end
9
Add RubyUI::DropdownMenuRadioGroup to app/components/ruby_ui/dropdown_menu/dropdown_menu_radio_group.rb
# frozen_string_literal: true
module RubyUI
# Groups DropdownMenuRadioItems: checking one unchecks the others. Like
# DropdownMenuGroup, a DropdownMenuLabel inside it names the group.
class DropdownMenuRadioGroup < DropdownMenuGroup
private
def default_attrs
{
role: "group",
data: {slot: "dropdown-menu-radio-group"}
}
end
end
end
10
Add RubyUI::DropdownMenuRadioItem to app/components/ruby_ui/dropdown_menu/dropdown_menu_radio_item.rb
# frozen_string_literal: true
module RubyUI
# One choice of a DropdownMenuRadioGroup (role menuitemradio). Activating it
# checks it, unchecks its siblings, keeps the menu open and fires
# ruby-ui--dropdown-menu:change with {checked, value} in the detail.
class DropdownMenuRadioItem < Base
ITEM = [
"group/dropdown-menu-radio-item relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
].freeze
def initialize(value: nil, checked: false, inset: false, disabled: false, **)
@value = value
@checked = checked
@inset = inset
@disabled = disabled
super(**)
end
def view_template(&block)
div(**attrs) do
render DropdownMenuItemIndicator.new(kind: :radio)
yield if block
end
end
private
def default_attrs
{
role: "menuitemradio",
tabindex: "-1",
aria: {checked: @checked.to_s, disabled: (@disabled ? "true" : nil)},
data: {
slot: "dropdown-menu-radio-item",
value: @value,
inset: @inset,
disabled: @disabled,
action: "click->ruby-ui--dropdown-menu#activate pointermove->ruby-ui--dropdown-menu#hover pointerleave->ruby-ui--dropdown-menu#leave"
},
class: ITEM
}
end
end
end
11
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",
data: {slot: "dropdown-menu-separator", orientation: "horizontal"},
class: "bg-border -mx-1 my-1 h-px"
}
end
end
end
12
Add RubyUI::DropdownMenuShortcut to app/components/ruby_ui/dropdown_menu/dropdown_menu_shortcut.rb
# frozen_string_literal: true
module RubyUI
# Shows an item's key combination. It only displays the keys: binding them is up to you.
class DropdownMenuShortcut < Base
def view_template(&)
span(**attrs, &)
end
private
def default_attrs
{
data: {slot: "dropdown-menu-shortcut"},
class: "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground"
}
end
end
end
13
Add RubyUI::DropdownMenuSub to app/components/ruby_ui/dropdown_menu/dropdown_menu_sub.rb
# frozen_string_literal: true
require "securerandom"
module RubyUI
# A submenu: one DropdownMenuSubTrigger and its DropdownMenuSubContent,
# tied together by ids (aria-controls / aria-labelledby).
class DropdownMenuSub < Base
def initialize(id: nil, **)
@id = id || "dropdown-menu-sub-#{SecureRandom.hex(4)}"
super(**)
end
def view_template(&)
previous = context[:ruby_ui_dropdown_menu_sub]
context[:ruby_ui_dropdown_menu_sub] = @id
div(**attrs, &)
ensure
context[:ruby_ui_dropdown_menu_sub] = previous
end
private
def default_attrs
{data: {slot: "dropdown-menu-sub"}}
end
end
end
14
Add RubyUI::DropdownMenuSubContent to app/components/ruby_ui/dropdown_menu/dropdown_menu_sub_content.rb
# frozen_string_literal: true
module RubyUI
# The submenu. A native popover placed next to its DropdownMenuSubTrigger,
# with the defaults of shadcn's DropdownMenuSubContent (right, start, 0, -3).
class DropdownMenuSubContent < Base
SIDES = DropdownMenuContent::SIDES
ALIGNS = DropdownMenuContent::ALIGNS
def initialize(side: :right, align: :start, side_offset: 0, align_offset: -3, **)
@side = SIDES.include?(side.to_sym) ? side.to_sym : :right
@align = ALIGNS.include?(align.to_sym) ? align.to_sym : :start
@side_offset = side_offset
@align_offset = align_offset
super(**)
end
def view_template(&)
div(**with_sub_ids(attrs), &)
end
private
# The ids come from the DropdownMenuSub around it, known only while
# rendering. They win over a caller's id:, so the trigger's
# aria-controls always points here.
def with_sub_ids(given)
id = context[:ruby_ui_dropdown_menu_sub]
return given unless id
given.merge(id: "#{id}-content", aria: (given[:aria] || {}).merge(labelledby: "#{id}-trigger"))
end
def default_attrs
{
role: "menu",
tabindex: "-1",
popover: "manual",
data: {
slot: "dropdown-menu-sub-content",
state: "closed",
closed: "",
preferred_side: @side,
align: @align,
side_offset: @side_offset,
align_offset: @align_offset
},
class: [
"fixed inset-auto m-0 border-0 z-50 w-auto min-w-[96px] max-h-(--available-height) origin-(--transform-origin)",
"overflow-x-hidden overflow-y-auto outline-none",
"bg-popover text-popover-foreground ring-1 ring-foreground/10 rounded-md p-1 shadow-lg duration-100",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:fill-mode-forwards",
"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"
]
}
end
end
end
15
Add RubyUI::DropdownMenuSubTrigger to app/components/ruby_ui/dropdown_menu/dropdown_menu_sub_trigger.rb
# frozen_string_literal: true
module RubyUI
# The item that opens a submenu: on hover, click, Enter, Space or ArrowRight.
class DropdownMenuSubTrigger < Base
def initialize(inset: false, disabled: false, **)
@inset = inset
@disabled = disabled
super(**)
end
def view_template(&block)
div(**with_sub_ids(attrs)) do
yield if block
chevron_right_icon
end
end
private
# lucide chevron-right
def chevron_right_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
class: "ml-auto rtl:rotate-180"
) { |s| s.path(d: "m9 18 6-6-6-6") }
end
# The ids come from the DropdownMenuSub around it, known only while
# rendering. They win over a caller's id:, so the submenu's
# aria-labelledby always points here.
def with_sub_ids(given)
id = context[:ruby_ui_dropdown_menu_sub]
return given unless id
given.merge(id: "#{id}-trigger", aria: (given[:aria] || {}).merge(controls: "#{id}-content"))
end
def default_attrs
{
role: "menuitem",
tabindex: "-1",
aria: {
haspopup: "menu",
expanded: "false",
disabled: (@disabled ? "true" : nil)
},
data: {
slot: "dropdown-menu-sub-trigger",
inset: @inset,
disabled: @disabled,
action: "click->ruby-ui--dropdown-menu#toggleSub pointermove->ruby-ui--dropdown-menu#hover pointerleave->ruby-ui--dropdown-menu#leave"
},
class: [
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
"data-popup-open:bg-accent data-popup-open:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
]
}
end
end
end
16
Add RubyUI::DropdownMenuTrigger to app/components/ruby_ui/dropdown_menu/dropdown_menu_trigger.rb
# frozen_string_literal: true
module RubyUI
# Wraps the element that opens the menu (usually a Button). The wrapper is
# display: contents; the controller puts aria-haspopup, aria-expanded,
# aria-controls and data-slot="dropdown-menu-trigger" on the button inside,
# the element shadcn's trigger renders.
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: "contents"
}
end
end
end
17
Add dropdown_menu_controller.js to app/javascript/controllers/ruby_ui/dropdown_menu_controller.js
import { Controller } from "@hotwired/stimulus";
import { computePosition, autoUpdate, offset, flip, shift, size } from "@floating-ui/dom";
// Connects to data-controller="ruby-ui--dropdown-menu"
//
// Behaves like Base UI's Menu (shadcn's DropdownMenu): a button opens the
// menu next to itself, focus moves into it (roving focus) and between items on
// arrow keys, Home/End and typeahead; Esc, picking an item or a click outside
// close it and focus goes back to the button, and Tab closes it and moves on
// to the control after the button. The menu and its
// submenus are native popovers (top layer). While open it is modal by
// default: the page scroll is locked and everything outside is inert.
const ITEM_SELECTOR = "[role=menuitem], [role=menuitemcheckbox], [role=menuitemradio]";
const CONTROL_SELECTOR = "button, a[href], [role=button]";
const OPEN_MENU_SELECTOR = "[data-slot=dropdown-menu-content][data-open], [data-slot=context-menu-content][data-open]";
// Elements whose Enter/Space already fire a click.
const NATIVE_CLICK_SELECTOR = "button, a[href], input, summary";
// Controls inside custom menu content that handle their own keys.
const FORM_CONTROL_SELECTOR = "input, textarea, select, button, a[href], [contenteditable]:not([contenteditable=false]), [role=textbox], [role=combobox]";
const TYPEAHEAD_RESET_MS = 500;
let uid = 0;
export default class extends Controller {
static targets = ["trigger", "content"];
static values = {
open: { type: Boolean, default: false },
modal: { type: Boolean, default: true },
// Legacy: {placement: "bottom-start"} stands in for side:/align: on the content.
options: { type: Object, default: {} },
};
initialize() {
this.openContent = null;
this.returnFocus = null;
this.inerted = [];
this.addedScrollLock = false;
this.typeahead = "";
this.positionCleanups = new Map();
this.controls = new Map();
this.exitAnimationNames = new WeakMap();
}
connect() {
this.element.addEventListener("keydown", this.handleTriggerKeydown);
this.element.addEventListener("focusin", this.handleFocusIn);
this.element.addEventListener("focusout", this.handleFocusOut);
// A morph or a Turbo restore can bring back an open menu: settle on closed.
this.element.querySelectorAll("[data-slot$=content][data-open]").forEach((menu) => {
menu.removeAttribute("data-open");
this.settleExit(menu);
});
document.addEventListener("turbo:before-cache", this.handleBeforeCache);
this.connected = true;
if (this.openValue) this.open({ focus: null });
}
disconnect() {
this.connected = false;
document.removeEventListener("turbo:before-cache", this.handleBeforeCache);
document.removeEventListener("mousedown", this.preventFocusSteal, { capture: true });
this.element.removeEventListener("keydown", this.handleTriggerKeydown);
this.element.removeEventListener("focusin", this.handleFocusIn);
this.element.removeEventListener("focusout", this.handleFocusOut);
this.close({ restoreFocus: false, animate: false });
}
// data-highlighted marks the item with focus, as Base UI does (styles can target either).
handleFocusIn = (event) => {
if (event.target.matches?.(ITEM_SELECTOR)) event.target.setAttribute("data-highlighted", "");
};
handleFocusOut = (event) => {
if (event.target.matches?.(ITEM_SELECTOR)) event.target.removeAttribute("data-highlighted");
};
openValueChanged(open) {
if (!this.connected) return;
if (open && !this.isOpen) this.open({ focus: null });
if (!open && this.isOpen) this.close();
}
// --- Trigger ----------------------------------------------------------------
triggerTargetConnected(trigger) {
this.setupControl(trigger);
}
triggerTargetDisconnected(trigger) {
this.controls.delete(trigger);
}
// Gives the trigger's control its ARIA and slot, matching the current state.
setupControl(trigger) {
const control = this.controlFor(trigger);
this.controls.set(trigger, control);
control.id ||= `dropdown-menu-trigger-${++uid}`;
control.setAttribute("aria-haspopup", "menu");
control.setAttribute("aria-expanded", String(this.isOpen));
if (this.isOpen) {
control.setAttribute("aria-controls", this.openContent.id);
control.setAttribute("data-popup-open", "");
}
// Keep a slot the element already has for its own styling (e.g. a sidebar action).
if (!control.dataset.slot || control.dataset.slot === "button") control.dataset.slot = "dropdown-menu-trigger";
return control;
}
// The element that carries the trigger's role: the target itself when it is
// a button, the button inside the wrapper otherwise. A wrapper with nothing
// focusable inside becomes the button.
controlFor(trigger) {
if (trigger.matches(CONTROL_SELECTOR)) return trigger;
const inner = trigger.querySelector(CONTROL_SELECTOR);
if (inner) return inner;
trigger.classList.remove("contents");
trigger.classList.add("inline-block");
trigger.setAttribute("role", "button");
if (!trigger.hasAttribute("tabindex")) trigger.tabIndex = 0;
return trigger;
}
// Resolved again when a morph swapped the button inside the wrapper.
get control() {
if (!this.hasTriggerTarget) return null;
const trigger = this.triggerTarget;
const control = this.controls.get(trigger);
if (control?.isConnected && (control === trigger || trigger.contains(control))) return control;
return this.setupControl(trigger);
}
// A real click focuses the menu; Enter/Space (a click with detail 0) focus the first item.
toggle(event) {
if (this.isOpen) {
this.close();
} else {
this.open({ focus: event?.detail === 0 ? "first" : "menu" });
}
}
// Delegated from the root, so a button swapped by a morph keeps working.
handleTriggerKeydown = (event) => {
const control = this.control;
if (!control || event.target !== control) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const focus = event.key === "ArrowDown" ? "first" : "last";
if (this.isOpen) this.focusEdge(this.openContent, focus);
else this.open({ focus });
} else if ((event.key === "Enter" || event.key === " ") && !control.matches(NATIVE_CLICK_SELECTOR)) {
// A non-native button (e.g. a wrapper with only text) gets no click from these keys.
event.preventDefault();
if (this.isOpen) this.close();
else this.open({ focus: "first" });
}
};
// --- Opening and closing ----------------------------------------------------
get isOpen() {
return this.openContent !== null;
}
open({ focus = "menu" } = {}) {
if (!this.hasContentTarget || this.isOpen) return;
const content = (this.openContent = this.contentTarget);
const control = this.control;
content.id ||= `dropdown-menu-content-${++uid}`;
if (control) {
control.setAttribute("aria-expanded", "true");
control.setAttribute("aria-controls", content.id);
control.setAttribute("data-popup-open", "");
content.setAttribute("aria-labelledby", control.id);
}
this.returnFocus = control;
this.show(content, control || this.element, { ...this.placementOf(content), matchWidth: true });
if (this.modalValue) this.makeModal();
if (focus === "first" || focus === "last") this.focusEdge(content, focus);
else if (focus === "menu") content.focus({ preventScroll: true });
document.addEventListener("pointerdown", this.handleOutsidePointerDown, true);
this.openValue = true;
this.dispatch("open-change", { detail: { open: true } });
}
close({ restoreFocus = true, animate = true } = {}) {
const content = this.openContent;
if (!content) return;
const focusWasInside = content.contains(document.activeElement);
this.openSubTriggersIn(content).forEach((trigger) => this.closeSub(trigger, { animate }));
this.teardown();
this.hide(content, { animate });
this.resetControl(content);
// Leave the menu for the button, unless something else took focus on
// purpose (an item that opened a modal dialog, a click outside).
const focusLeft = focusWasInside || document.activeElement === document.body;
// Only a dialog opened from inside this menu; a dialog that contains the menu doesn't count.
const dialogTookFocus = [...document.querySelectorAll("dialog:modal")].some((dialog) => content.contains(dialog));
if (dialogTookFocus) {
// Leave focus where the dialog put it.
} else if (restoreFocus && focusLeft && this.returnFocus?.isConnected) {
this.returnFocus.focus({ preventScroll: true });
} else if (focusWasInside && content.contains(document.activeElement)) {
document.activeElement.blur();
}
this.returnFocus = null;
this.openValue = false;
this.dispatch("open-change", { detail: { open: false } });
}
// Everything open() took from the page: listeners, inert, scroll lock.
teardown() {
document.removeEventListener("pointerdown", this.handleOutsidePointerDown, true);
// Every autoUpdate still running (menu and submenus), even for elements that left the DOM.
this.positionCleanups.forEach((cleanup) => cleanup());
this.positionCleanups.clear();
clearTimeout(this.typeaheadTimeout);
this.typeahead = "";
this.releaseModal();
this.openContent = null;
}
resetControl(content) {
const control = this.control;
if (!control) return;
control.setAttribute("aria-expanded", "false");
control.removeAttribute("data-popup-open");
if (control.getAttribute("aria-controls") === content.id) control.removeAttribute("aria-controls");
}
// The open menu was removed on its own (e.g. a Turbo Stream replaced it):
// release the page and the listeners, and tell the button it is closed.
contentTargetDisconnected(content) {
if (content !== this.openContent) return;
this.teardown();
this.resetControl(content);
this.returnFocus = null;
this.openValue = false;
this.dispatch("open-change", { detail: { open: false } });
}
handleOutsidePointerDown = (event) => {
if (this.openContent?.contains(event.target)) return;
// The button toggles on its own click.
if (this.control?.contains(event.target)) return;
// Modal: the page is inert, so the press must not move focus to the body
// after it went back to the button.
// The mousedown comes in the same burst as this pointerdown; the next task
// drops the guard if it never came (pen, touch without compat events).
if (this.modalValue) {
document.addEventListener("mousedown", this.preventFocusSteal, { capture: true, once: true });
setTimeout(() => document.removeEventListener("mousedown", this.preventFocusSteal, { capture: true }));
}
this.close();
};
preventFocusSteal = (event) => event.preventDefault();
// Turbo snapshots the page right after this event: never cache an open menu.
handleBeforeCache = () => {
this.close({ restoreFocus: false, animate: false });
};
show(menu, anchor, placement) {
menu.style.setProperty("--transform-origin", this.transformOrigin(placement.side, placement.align));
menu.dataset.side = placement.side;
menu.removeAttribute("data-closed");
menu.setAttribute("data-open", "");
menu.dataset.state = "open";
menu.inert = false;
if (!menu.matches(":popover-open")) menu.showPopover();
this.stopPositioning(menu);
this.positionCleanups.set(
menu,
autoUpdate(anchor, menu, () => this.position(menu, anchor, placement))
);
}
hide(menu, { animate }) {
this.stopPositioning(menu);
menu.removeAttribute("data-open");
menu.setAttribute("data-closed", "");
menu.dataset.state = "closed";
animate ? this.hideAfterExitAnimation(menu) : this.settleExit(menu);
}
stopPositioning(menu) {
this.positionCleanups.get(menu)?.();
this.positionCleanups.delete(menu);
}
// --- Modal: scroll lock + inert page ----------------------------------------
makeModal() {
// A lock someone else already holds stays theirs: only one this menu added is released.
if (!document.body.classList.contains("overflow-hidden")) {
document.body.classList.add("overflow-hidden");
this.addedScrollLock = true;
}
// Everything but the menu's own branch of the tree (trigger and menu).
for (let node = this.element; node && node !== document.body; node = node.parentElement) {
const parent = node.parentElement;
if (!parent) break;
for (const sibling of parent.children) {
if (sibling === node || sibling.inert || sibling.tagName === "SCRIPT") continue;
sibling.inert = true;
this.inerted.push(sibling);
}
}
}
// Only undo what this menu did, and keep the lock while another modal is open.
releaseModal() {
this.inerted.forEach((element) => (element.inert = false));
this.inerted = [];
if (!this.addedScrollLock) return;
this.addedScrollLock = false;
const otherMenuOpen = [...document.querySelectorAll(OPEN_MENU_SELECTOR)].some((menu) => menu !== this.openContent);
if (!otherMenuOpen && !document.querySelector("dialog:modal")) document.body.classList.remove("overflow-hidden");
}
// --- Items ----------------------------------------------------------------
activate(event) {
const item = event.currentTarget;
if (this.isDisabled(item)) {
event.preventDefault();
return;
}
const role = item.getAttribute("role");
if (role === "menuitemcheckbox") {
const checked = item.getAttribute("aria-checked") !== "true";
item.setAttribute("aria-checked", String(checked));
this.dispatch("change", { target: item, detail: { checked } });
} else if (role === "menuitemradio") {
const group = item.closest("[data-slot=dropdown-menu-radio-group], [role=menu]");
group.querySelectorAll("[role=menuitemradio]").forEach((radio) => radio.setAttribute("aria-checked", String(radio === item)));
this.dispatch("change", { target: item, detail: { checked: true, value: item.dataset.value } });
} else {
// Plain items act and close; a link keeps its default navigation.
this.close();
}
}
hover(event) {
const item = event.currentTarget;
if (this.isDisabled(item)) return;
// Pointing at another item of a menu closes the submenu open from it.
const menu = item.closest("[role=menu]");
this.openSubTriggersIn(menu, { direct: true })
.filter((trigger) => trigger !== item)
.forEach((trigger) => this.closeSub(trigger));
if (this.isSubTrigger(item)) this.openSub(item, { focus: false });
if (document.activeElement !== item) item.focus({ preventScroll: true });
}
// Leaving an item drops the highlight back to its menu.
leave(event) {
const item = event.currentTarget;
if (document.activeElement === item) item.closest("[role=menu]")?.focus({ preventScroll: true });
}
// --- Submenus -------------------------------------------------------------
isSubTrigger(item) {
return item.dataset.slot === "dropdown-menu-sub-trigger";
}
// Looked up inside the root, so it is found even after the root left the DOM.
subOf(trigger) {
const id = trigger.getAttribute("aria-controls");
return id ? this.element.querySelector(`#${CSS.escape(id)}`) : null;
}
triggerOfSub(sub) {
return this.element.querySelector(`[data-slot=dropdown-menu-sub-trigger][aria-controls="${sub.id}"]`);
}
// Sub triggers with an open submenu inside a menu; direct: only its own items.
openSubTriggersIn(menu, { direct = false } = {}) {
if (!menu) return [];
return [...menu.querySelectorAll("[data-slot=dropdown-menu-sub-trigger][aria-expanded=true]")].filter(
(trigger) => !direct || trigger.closest("[role=menu]") === menu
);
}
// A click opens the submenu (pointer: focus stays; keyboard: first item).
toggleSub(event) {
const trigger = event.currentTarget;
if (this.isDisabled(trigger)) return;
this.openSub(trigger, { focus: event.detail === 0 });
}
openSub(trigger, { focus }) {
const sub = this.subOf(trigger);
if (!sub) return;
if (trigger.getAttribute("aria-expanded") !== "true") {
trigger.setAttribute("aria-expanded", "true");
trigger.setAttribute("data-popup-open", "");
this.show(sub, trigger, {
side: sub.dataset.preferredSide || "right",
align: sub.dataset.align || "start",
sideOffset: Number(sub.dataset.sideOffset ?? 0),
alignOffset: Number(sub.dataset.alignOffset ?? -3),
});
}
if (focus) this.focusEdge(sub, "first");
}
closeSub(trigger, { animate = true, focusTrigger = false } = {}) {
const sub = this.subOf(trigger);
trigger.setAttribute("aria-expanded", "false");
trigger.removeAttribute("data-popup-open");
if (sub) {
this.openSubTriggersIn(sub).forEach((nested) => this.closeSub(nested, { animate }));
if (sub.hasAttribute("data-open")) this.hide(sub, { animate });
}
if (focusTrigger) trigger.focus({ preventScroll: true });
}
// --- Keyboard -------------------------------------------------------------
// Keys reach the root menu from its submenus too (they live inside it).
keydown(event) {
if (!this.isOpen) return;
const target = event.target;
// A form control in custom content (an input, a checkbox) keeps its own
// keys; only Esc still closes the menu from there.
if (!target.matches(ITEM_SELECTOR) && target.matches(FORM_CONTROL_SELECTOR) && event.key !== "Escape") return;
const menu = target.closest("[role=menu]") || this.openContent;
const items = this.itemsOf(menu);
const current = items.indexOf(document.activeElement);
const item = items[current];
switch (event.key) {
case "ArrowDown":
event.preventDefault();
this.focusItem(items, current === -1 ? 0 : (current + 1) % items.length);
break;
case "ArrowUp":
event.preventDefault();
this.focusItem(items, current === -1 ? items.length - 1 : (current - 1 + items.length) % items.length);
break;
case "Home":
event.preventDefault();
this.focusItem(items, 0);
break;
case "End":
event.preventDefault();
this.focusItem(items, items.length - 1);
break;
case "ArrowRight":
if (item && this.isSubTrigger(item) && !this.isDisabled(item)) {
event.preventDefault();
this.openSub(item, { focus: true });
}
break;
case "ArrowLeft":
if (menu !== this.openContent) {
event.preventDefault();
const trigger = this.triggerOfSub(menu);
if (trigger) this.closeSub(trigger, { focusTrigger: true });
}
break;
case "Enter":
case " ":
event.preventDefault();
if (!item || this.isDisabled(item)) return;
// Disabled items take focus (APG) but are never activated.
if (this.isSubTrigger(item)) this.openSub(item, { focus: true });
// An item hosting its own control (e.g. a DialogTrigger) passes the key on to it.
else (item.querySelector(CONTROL_SELECTOR + ", [data-action*='click->']") || item).click();
break;
case "Escape": {
event.preventDefault();
event.stopPropagation();
const trigger = menu !== this.openContent && this.triggerOfSub(menu);
if (trigger) this.closeSub(trigger, { focusTrigger: true });
else this.close();
break;
}
case "Tab": {
// Like Base UI's menu, Tab closes it. close() puts focus back on the
// button first, so Tab carries on to the element after the button and
// Shift+Tab stops on the button itself. The menu stays in the page
// during its exit animation; inert keeps that Tab from landing on a
// control inside it.
if (event.shiftKey) event.preventDefault();
const content = this.openContent;
this.close();
if (content?.matches(":popover-open")) content.inert = true;
break;
}
default:
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
this.typeaheadTo(items, current, event.key);
}
}
}
// Only a menu's own items that render: a submenu's items and hidden ones are skipped.
itemsOf(menu) {
if (!menu) return [];
return [...menu.querySelectorAll(ITEM_SELECTOR)].filter(
(item) => item.closest("[role=menu]") === menu && item.getClientRects().length > 0
);
}
focusEdge(menu, edge) {
const items = this.itemsOf(menu);
// A menu with no items (only custom content) takes focus itself.
const target = items.length ? (edge === "last" ? items[items.length - 1] : items[0]) : menu;
target.focus({ preventScroll: true });
}
focusItem(items, index) {
items[index]?.focus({ preventScroll: false });
}
// Characters typed in quick succession build a prefix; a single repeated
// character cycles through the items that start with it.
typeaheadTo(items, current, key) {
clearTimeout(this.typeaheadTimeout);
this.typeaheadTimeout = setTimeout(() => (this.typeahead = ""), TYPEAHEAD_RESET_MS);
this.typeahead += key.toLowerCase();
const repeated = [...this.typeahead].every((char) => char === this.typeahead[0]);
const prefix = repeated ? this.typeahead[0] : this.typeahead;
const start = repeated || current === -1 ? current + 1 : current;
const ordered = [...items.slice(start), ...items.slice(0, start)];
const match = ordered.find((item) => this.labelOf(item).toLowerCase().startsWith(prefix));
if (match) match.focus({ preventScroll: false });
}
// The item's own text, without its shortcut or indicator. Comment nodes
// (e.g. Rails' annotate_rendered_view_with_filenames) are not text.
labelOf(item) {
return [...item.childNodes]
.filter((node) => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && !node.matches("[data-slot$=-shortcut], [data-slot$=-indicator], svg")))
.map((node) => node.textContent)
.join("")
.trim();
}
isDisabled(item) {
return item.getAttribute("aria-disabled") === "true" || item.hasAttribute("data-disabled");
}
// --- Positioning ----------------------------------------------------------
// side:/align: on the content win; the legacy options[:placement] fills in.
placementOf(content) {
const [legacySide, legacyAlign] = (this.optionsValue.placement || "").split("-");
const side = content.dataset.preferredSide || legacySide || "bottom";
const align = content.dataset.align || (legacySide ? legacyAlign || "center" : "start");
return {
side,
align,
sideOffset: Number(content.dataset.sideOffset ?? 4),
alignOffset: Number(content.dataset.alignOffset ?? 0),
};
}
position(menu, anchor, { side, align, sideOffset = 4, alignOffset = 0, matchWidth = false }) {
const placement = align === "center" ? side : `${side}-${align}`;
computePosition(anchor, menu, {
strategy: "fixed",
placement,
middleware: [
// alignmentAxis flips the offset for align: :end, as Base UI does.
offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),
flip(),
shift({ padding: 5 }),
size({
padding: 5,
apply({ availableHeight, rects }) {
menu.style.setProperty("--available-height", `${availableHeight}px`);
if (matchWidth) menu.style.setProperty("--anchor-width", `${rects.reference.width}px`);
},
}),
],
}).then(({ x, y, placement: resolved }) => {
if (!menu.isConnected || !menu.hasAttribute("data-open")) return;
const [resolvedSide, resolvedAlign = "center"] = resolved.split("-");
Object.assign(menu.style, { left: `${x}px`, top: `${y}px` });
menu.dataset.side = resolvedSide;
menu.style.setProperty("--transform-origin", this.transformOrigin(resolvedSide, resolvedAlign));
});
}
// The menu grows out of the trigger's edge.
transformOrigin(side, align) {
const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" }[side];
const vertical = side === "top" || side === "bottom";
const cross = { start: vertical ? "left" : "top", end: vertical ? "right" : "bottom" }[align] || "center";
return vertical ? `${cross} ${opposite}` : `${opposite} ${cross}`;
}
// --- Exit animation (same block as the other overlay controllers) --------
hideAfterExitAnimation(animated) {
const exitAnimations = animated.getAnimations().filter((animation) => animation instanceof CSSAnimation);
// No exit animation, or no box to run it in: animationend would never fire.
if (exitAnimations.length === 0) {
this.settleExit(animated);
return;
}
this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
animated.addEventListener("animationend", this.handleExitAnimationEnd);
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
}
handleExitAnimationEnd = (event) => {
// animationend bubbles — an animated child must not hide its container.
if (event.target !== event.currentTarget) return;
// Closing mid-open cancels the enter animation; only the exit run settles this.
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;
this.settleExit(event.currentTarget);
};
settleExit(animated) {
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
// Reopened mid-exit: it is on its way back in, leave it showing.
if (animated.hasAttribute("data-open")) return;
animated.setAttribute("data-closed", "");
animated.dataset.state = "closed";
// An item opened a modal dialog that lives inside the menu: hiding the
// popover would hide the dialog too. The exit animation already left the
// menu transparent; hide it once the dialog closes.
const dialog = animated.querySelector("dialog[open]");
if (dialog) {
dialog.addEventListener("close", () => this.settleAfterDialog(animated), { once: true });
return;
}
if (animated.matches(":popover-open")) animated.hidePopover();
}
settleAfterDialog(menu) {
if (menu.hasAttribute("data-open")) return;
const focusWasInside = menu.contains(document.activeElement) || document.activeElement === document.body;
if (menu.matches(":popover-open")) menu.hidePopover();
// The dialog returned focus to its trigger inside the menu: move it to the menu's button.
if (focusWasInside && menu === this.contentTarget) this.control?.focus({ preventScroll: true });
}
}
18
Update the Stimulus controllers manifest file
rake stimulus:manifest:update19
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 | |
DropdownMenuCheckboxItem | Phlex | |
DropdownMenuContent | Phlex | |
DropdownMenuDocs | Phlex | |
DropdownMenuGroup | Phlex | |
DropdownMenuItem | Phlex | |
DropdownMenuItemIndicator | Phlex | |
DropdownMenuLabel | Phlex | |
DropdownMenuRadioGroup | Phlex | |
DropdownMenuRadioItem | Phlex | |
DropdownMenuSeparator | Phlex | |
DropdownMenuShortcut | Phlex | |
DropdownMenuSub | Phlex | |
DropdownMenuSubContent | Phlex | |
DropdownMenuSubTrigger | Phlex | |
DropdownMenuTrigger | Phlex | |
DropdownMenuController | Stimulus JS |