Dropdowns
MediumContext menus and select dropdowns with keyboard navigation
Updated 3 days ago
Tested & Responsive
Live Demo
Interactive demo coming soon
<!-- ERB template not found for interactive/dropdowns -->
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dropdown"
export default class extends Controller {
static targets = ["menu", "trigger"]
static classes = ["open", "closed"]
static values = {
open: Boolean,
placement: { type: String, default: "bottom-start" }
}
connect() {
this.updateMenuState()
// Close dropdown when clicking outside
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this)
}
disconnect() {
document.removeEventListener("click", this.boundHandleOutsideClick)
}
// Toggle dropdown open/closed
toggle() {
this.openValue = !this.openValue
}
// Open dropdown
open() {
this.openValue = true
}
// Close dropdown
close() {
this.openValue = false
}
// Handle value changes
openValueChanged() {
this.updateMenuState()
if (this.openValue) {
document.addEventListener("click", this.boundHandleOutsideClick)
this.positionMenu()
// Dispatch opened event
this.dispatch("opened")
} else {
document.removeEventListener("click", this.boundHandleOutsideClick)
// Dispatch closed event
this.dispatch("closed")
}
}
// Update menu visibility and classes
updateMenuState() {
if (!this.hasMenuTarget) return
if (this.openValue) {
this.menuTarget.classList.remove("hidden")
this.menuTarget.classList.add(...this.openClasses)
this.menuTarget.classList.remove(...this.closedClasses)
this.menuTarget.setAttribute("aria-hidden", "false")
} else {
this.menuTarget.classList.add("hidden")
this.menuTarget.classList.remove(...this.openClasses)
this.menuTarget.classList.add(...this.closedClasses)
this.menuTarget.setAttribute("aria-hidden", "true")
}
}
// Position dropdown menu
positionMenu() {
if (!this.hasMenuTarget || !this.hasTriggerTarget) return
const trigger = this.triggerTarget
const menu = this.menuTarget
const triggerRect = trigger.getBoundingClientRect()
// Reset any previous positioning
menu.style.position = "absolute"
menu.style.zIndex = "50"
// Position based on placement value
switch (this.placementValue) {
case "bottom-start":
menu.style.top = `${triggerRect.height + 4}px`
menu.style.left = "0"
break
case "bottom-end":
menu.style.top = `${triggerRect.height + 4}px`
menu.style.right = "0"
break
case "top-start":
menu.style.bottom = `${triggerRect.height + 4}px`
menu.style.left = "0"
break
default:
menu.style.top = `${triggerRect.height + 4}px`
menu.style.left = "0"
}
}
// Handle clicks outside the dropdown
handleOutsideClick(event) {
if (!this.element.contains(event.target)) {
this.close()
}
}
// Handle keyboard navigation
keydown(event) {
switch (event.key) {
case "Escape":
this.close()
if (this.hasTriggerTarget) {
this.triggerTarget.focus()
}
break
case "ArrowDown":
event.preventDefault()
this.focusNextItem()
break
case "ArrowUp":
event.preventDefault()
this.focusPreviousItem()
break
}
}
// Focus management for keyboard navigation
focusNextItem() {
const items = this.menuTarget.querySelectorAll('[role="menuitem"]')
const currentIndex = Array.from(items).findIndex(item => item === document.activeElement)
const nextIndex = (currentIndex + 1) % items.length
items[nextIndex]?.focus()
}
focusPreviousItem() {
const items = this.menuTarget.querySelectorAll('[role="menuitem"]')
const currentIndex = Array.from(items).findIndex(item => item === document.activeElement)
const previousIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1
items[previousIndex]?.focus()
}
// Handle menu item selection
selectItem(event) {
const item = event.currentTarget
const value = item.dataset.value || item.textContent.trim()
// Dispatch selection event
this.dispatch("selected", {
detail: {
value: value,
item: item,
element: this.element
}
})
// Close dropdown after selection
this.close()
}
}
Installation & Usage
1
Copy the ERB template
Copy the ERB code from the template tab above and paste it into your Rails view file.
2
Add the Stimulus controller
Create the Stimulus controller file in your JavaScript controllers directory.