Dropdown component for ViewComponent and Tailwind CSS
Rails Designer’s Dropdown Component is an extensible ViewComponent with Tailwind CSS and a small Stimulus controller. Next to items you can set, you can also add leader and trailer content. The dropdown comes in two themes: light (default) and dark. By using the Floating UI library, the dropdown will never bleed off the viewport. Transitioning in and out can be customised too. This code preview may have been simplified for demonstration purposes. When you generate this component using Rails Designer it uses your settings.
- Includes Keyboard Navigation
class DropdownComponent < ViewComponent::Base
POSITIONS = %w[top top-start top-end right right-start right-end bottom bottom-start bottom-end left left-start left-end]
renders_one :button
renders_one :leader
renders_many :items, ->(css: nil, &block) do
content_tag :li, role: "presentation", class: class_names("*:block *:px-3 *:py-1.5 *:truncate", {"text-gray-700 hover:[&>a]:bg-gray-50": @theme.light?, "text-gray-200 hover:[&>a]:bg-gray-900": @theme.dark?}, css), &block
end
renders_one :trailer
def initialize(theme: "light", position: "bottom-start", offset: 2, padding: nil, data_transitions: {}, container_css: nil)
@theme = theme.inquiry
@position = position
@offset = offset
@padding = padding
@data_transitions = data_transitions
@container_css = container_css
raise StandardError.new("Incorrect position. Should be one of: #{POSITIONS.to_sentence(last_word_connector: " or ")}") if POSITIONS.exclude? position
end
def content_min_max_width
"min-w-[8rem] max-w-[16rem]"
end
def container_css
@container_css.presence || "relative"
end
def menu_data
{turbo_temporary: "", dropdown_target: "menu"}.merge(data_transitions)
end
def menu_css
class_names(
"absolute mt-1 text-sm shadow-xl overflow-x-hidden rounded-lg z-10",
content_min_max_width,
"max-h-60 overflow-y-auto", # max height: 240px
@padding,
{
"bg-white ring-1 ring-inset ring-gray-100/50 backdrop-blur-md": @theme.light?,
"bg-gray-800": @theme.dark?
}
)
end
private
def data_transitions
@data_transitions.presence || {
transition_enter: "transition ease-out duration-100", # Base classes for “showing” the menu
transition_enter_start: "opacity-0 -translate-y-2", # The initial classes before “showing” the menu
transition_enter_end: "opacity-100 translate-y-0", # The final classes before “showing” the menu
transition_leave: "transition ease-in duration-75", # Base classes for “hiding” the menu
transition_leave_start: "opacity-100 translate-y-0", # The initial class before “hiding” the menu
transition_leave_end: "opacity-0 -translate-y-2" # The final classes before “hiding” the menu
}
end
<%= tag.div data: {controller: "dropdown", dropdown_position_value: @position, dropdown_offset_value: @offset, action: "keyup->dropdown#hideWithKey click@window->dropdown#hide"}, class: container_css do %>
<%= tag.button button, data: {dropdown_target: "button", action: "dropdown#toggle"}, aria: {haspopup: true, controls: "menu" }, class: "flex" %>
<%= tag.div tabindex: 0, role: "menu", data: menu_data, hidden: true, class: menu_css do %>
<%= tag.div leader, class: class_names("border-b px-3 py-2", {"text-gray-700 border-gray-100": @theme.light?, "text-gray-200 border-gray-700": @theme.dark?}) if leader? %>
<%= tag.ul do %>
<% items.each do |item| %>
<%= item %>
<% end %>
<% end if items? %>
<%= tag.div trailer, class: class_names("border-t px-3 py-2", {"text-gray-700 border-gray-200/50": @theme.light?, "text-gray-200 border-gray-700": @theme.dark?}) if trailer? %>
<% end %>
<% end %>
import { Controller } from "@hotwired/stimulus";
import { enter, leave } from "helpers/transitions";
import { computePosition } from "floating-ui";
import { verticalNavigation } from "helpers/keyboard_navigation";
export default class extends Controller {
static targets = ["button", "menu"];
static values = {
open: { type: Boolean, default: false }
};
disconnect() {
this.openValue = false;
}
toggle() {
this.openValue = !this.openValue;
}
hide(event) {
if (!this.openValue) return;
if (this.element.contains(event.target) === false) {
this.openValue = false;
}
}
hideWithKey(event) {
if (!this.openValue) return;
if (event.key === this.keyValue) {
this.openValue = false;
}
}
navigate() {
verticalNavigation(this.menuTarget);
}
// private
openValueChanged() {
if (this.openValue) {
enter(this.menuTarget);
this.computePosition();
} else {
leave(this.menuTarget);
}
}
}
UI components Library for Ruby on Rails apps
$
99
one-time
payment
View components