Senren UI
Components / Dropdown Menu

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.