Dropdown Menu
Stable Stimulus:senren--dropdown-menu
contextual actions on rows
Usage example #
Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.
app/views/.../dropdown_menu_example.html.erb
<div class="flex w-full justify-center">
<%= render Senren::DropdownMenuComponent.new do |menu| %>
<% menu.with_trigger do %>
<%= render Senren::ButtonComponent.new(type: :button, variant: :secondary) do %>
<span>Open menu</span>
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="ml-2 h-4 w-4 transition-transform duration-150 data-[state=open]:rotate-180" data-state="closed" data-senren-chevron>
<path d="m5 8 5 5 5-5"></path>
</svg>
<% end %>
<% end %>
<% menu.with_item(href: "#") { "Profile" } %>
<% menu.with_item(href: "#") { "Settings" } %>
<% menu.with_item(href: "#") { "Billing" } %>
<% menu.with_item(href: "#", destructive: true) { "Sign out" } %>
<% end %>
</div>
Install this component #
Copy the official component into your app
This component requires Stimulus. Keep --client so the controller is copied with the ViewComponent.
Terminal
bin/rails senren:add dropdown_menu --client
Create a custom component with the same conventions
Use this when you need an app-specific component that follows Senren's ViewComponent and Stimulus structure. --client is required for this behavior.
Terminal
bin/rails generate senren:component dropdown_menu --client
Dependencies are resolved by senren:add:
button.
At a glance #
Category
Overlays
Class name
Senren::DropdownMenuComponent
Stimulus
senren--dropdown-menu
Variants
default
Depends on
button
Pairs with
button, table, top_nav
Source #
app/components/senren/dropdown_menu_component.html.erb
<%# Root goes through root_attrs so a caller's class: and data: reach it.
Hand-writing these attributes dropped both -- which is why a Stimulus
value could not be set from the server. %>
<%= tag.div(**wrapper_attrs("relative inline-block", data: { controller: "senren--dropdown-menu", state: "closed" })) do %>
<div class="inline-flex cursor-pointer" data-state="closed" aria-haspopup="menu" aria-expanded="false" data-senren--dropdown-menu-target="trigger" data-action="click->senren--dropdown-menu#toggle keydown->senren--dropdown-menu#onTriggerKey">
<%= trigger %>
</div>
<div data-senren--dropdown-menu-target="menu" role="menu" hidden
class="<%= panel_class("absolute right-0 z-50 mt-2 w-56 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-1 shadow-md") %>">
<% items.each do |it| %>
<%= it %>
<% end %>
</div>
<% end %>
app/components/senren/dropdown_menu_component.rb
# frozen_string_literal: true
module Senren
# A trigger and an absolutely positioned menu.
#
# The menu is taller than the trigger and escapes it, so an ancestor with
# `overflow-hidden` will clip it -- usually a card, table wrapper, or list
# container that has the class only to make its border radius clip children.
# Symptom: the menu opens but is cut off at the container's edge. Remove the
# overflow, or round the first and last rows instead of the wrapper.
class DropdownMenuComponent < BaseComponent
renders_one :trigger
renders_many :items, 'ItemTag'
VARIANTS = { default: '' }.freeze
SIZES = { md: '' }.freeze
def initialize(class_name: nil, **html)
super(variant: :default, size: :md, class_name: class_name, **html)
end
class ItemTag < BaseComponent
VARIANTS = { default: '' }.freeze
SIZES = { md: '' }.freeze
ITEM_ACTION = 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey'
BASE_CLASSES = 'block w-full text-left px-3 py-2 text-sm rounded-sm ' \
'hover:bg-[hsl(var(--senren-accent))] focus:bg-[hsl(var(--senren-accent))] ' \
'outline-none cursor-pointer'
def initialize(href: nil, method: nil, destructive: false, class_name: nil, **)
super(variant: :default, size: :md, class_name: class_name, **)
@href = href
@method = method
@destructive = destructive
end
def call
if @href
link_to(content, safe_url(@href), role: 'menuitem', class: item_classes, **item_attrs)
else
tag.button(content, type: 'button', role: 'menuitem', class: item_classes, **item_attrs)
end
end
private
# `class:` is merged, not substituted.
#
# It used to reach `call` only through **html_attrs, which the tag helper
# splatted over the computed class -- so `class: "font-bold"` produced an
# item wearing nothing but font-bold. Losing the hover style is cosmetic;
# losing `focus:bg-` is not, because it is the only indication a keyboard
# user has of where they are while arrowing through the menu.
#
# Same defect `data:` had, and the fix for `data:` was not extended here.
def item_classes
[
BASE_CLASSES,
('text-[hsl(var(--senren-destructive))]' if @destructive),
class_name.presence,
caller_class.presence
].compact.join(' ')
end
# Two defects lived in the old one-line version of `call`.
#
# It wrote `data: { action: ITEM_ACTION }` and then splatted html_attrs
# after it, so any caller passing `data:` replaced the whole hash and
# silently lost close-on-click and keyboard handling. This is the same
# defect `root_attrs` had for `data:`, fixed there and never here --
# merging is the fix in both places.
#
# And `method:` was passed to link_to, which was a rails-ujs option.
# Rails 7 dropped rails-ujs, so it rendered a `method` attribute on an
# `<a>` that does nothing at all: a documented parameter that had been
# inert since the library targeted Rails 7.1. Turbo reads
# data-turbo-method -- and only on a link, which is why `method:` without
# `href:` emits nothing rather than an attribute a <button> cannot act on.
#
# A caller's own action is appended rather than dropped: an item that
# tracks a click still has to close its menu.
def item_attrs
data = merge_data(caller_data, { action: ITEM_ACTION })
data = data.merge(turbo_method: @method) if @method && @href
html_attrs_without_class_and_data.merge(data: data)
end
end
end
end
AI agent rules #
Use for
- +contextual actions on rows
- +account menus
Avoid
- -primary navigation - use top_nav or sidebar
Accessibility #
- role=menu with role=menuitem children.
- Arrow keys to navigate; Escape to close; type-ahead optional.