New select component (#1071)

* feat: add new UI component to display dropdown select with filter

* feat: use new dropdown componet for category selection in transactions

* feat: improve dropdown controller

* feat: Add checkbox indicator to highlight selected element in list

* feat: add possibility to define dropdown without search

* feat: initial implementation of variants

* feat: Add default color for dropdown menu

* feat: add "icon" variant for dropdown

* refactor: component + controller refactoring

* refactor: view + component

* fix: adjust min width in selection for mobile

* feat: refactor collection_select method to use new filter dropdown component

* fix: compute fixed position for dropdown

* feat: controller improvements

* lint issues

* feat: add dot color if no icon is available

* refactor: controller refactor + update naming for variant from icon to logo

* fix: set width to 100% for select dropdown

* feat: add variant to collection_select in new transaction form

* fix: typo in placeholder value

* fix: add back include_blank property

* refactor: rename component from FilterDropdown to Select

* fix: translate placeholder and keep value_method and text_method

* fix: remove duplicate variable assignment

* fix: translate placeholder

* fix: verify color format

* fix: use right autocomplete value

* fix: selection issue + controller adjustments

* fix: move calls to startAutoUpdate and stopAutoUpdate

* Update app/javascript/controllers/select_controller.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>

* fix: add aria-labels

* fix: pass html_options to DS::Select

* fix: unnecessary closing tag

* fix: use offsetvalue for position checks

* fix: use right classes for dropdown transitions

* include options[:prompt] in placeholder init

* fix: remove unused locale key

* fix: Emit a native change event after updating the input value.

* fix: Guard against negative maxHeight in constrained layouts.

* fix: Update test

* fix: lint issues

* Update test/system/transfers_test.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>

* Update test/system/transfers_test.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>

* refactor: move CSS class for button select form in maybe-design-system.css

---------

Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Alessio Cappa
2026-03-06 10:16:14 +01:00
committed by GitHub
parent 3b2d630d99
commit 0f78f54f90
10 changed files with 431 additions and 14 deletions

View File

@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
onSelect(event) {
this.inputTarget.value = event.detail.value
const inputEvent = new Event("input", { bubbles: true })
this.inputTarget.dispatchEvent(inputEvent)
const form = this.element.closest("form")
const controllers = (form?.dataset.controller || "").split(/\s+/)
if (form && controllers.includes("auto-submit-form")) {
form.requestSubmit()
}
}
}

View File

@@ -0,0 +1,182 @@
import { Controller } from "@hotwired/stimulus"
import { autoUpdate } from "@floating-ui/dom"
export default class extends Controller {
static targets = ["button", "menu", "input"]
static values = {
placement: { type: String, default: "bottom-start" },
offset: { type: Number, default: 6 }
}
connect() {
this.isOpen = false
this.boundOutsideClick = this.handleOutsideClick.bind(this)
this.boundKeydown = this.handleKeydown.bind(this)
this.boundTurboLoad = this.handleTurboLoad.bind(this)
document.addEventListener("click", this.boundOutsideClick)
document.addEventListener("turbo:load", this.boundTurboLoad)
this.element.addEventListener("keydown", this.boundKeydown)
this.observeMenuResize()
}
disconnect() {
document.removeEventListener("click", this.boundOutsideClick)
document.removeEventListener("turbo:load", this.boundTurboLoad)
this.element.removeEventListener("keydown", this.boundKeydown)
this.stopAutoUpdate()
if (this.resizeObserver) this.resizeObserver.disconnect()
}
toggle = () => {
this.isOpen ? this.close() : this.openMenu()
}
openMenu() {
this.isOpen = true
this.menuTarget.classList.remove("hidden")
this.buttonTarget.setAttribute("aria-expanded", "true")
this.startAutoUpdate()
this.clearSearch()
requestAnimationFrame(() => {
this.menuTarget.classList.remove("opacity-0", "-translate-y-1", "pointer-events-none")
this.menuTarget.classList.add("opacity-100", "translate-y-0")
this.updatePosition()
this.scrollToSelected()
})
}
close() {
this.isOpen = false
this.stopAutoUpdate()
this.menuTarget.classList.remove("opacity-100", "translate-y-0")
this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none")
this.buttonTarget.setAttribute("aria-expanded", "false")
setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150)
}
select(event) {
const selectedElement = event.currentTarget
const value = selectedElement.dataset.value
const label = selectedElement.dataset.filterName || selectedElement.textContent.trim()
this.buttonTarget.textContent = label
if (this.hasInputTarget) {
this.inputTarget.value = value
this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
}
const previousSelected = this.menuTarget.querySelector("[aria-selected='true']")
if (previousSelected) {
previousSelected.setAttribute("aria-selected", "false")
previousSelected.classList.remove("bg-container-inset")
const prevIcon = previousSelected.querySelector(".check-icon")
if (prevIcon) prevIcon.classList.add("hidden")
}
selectedElement.setAttribute("aria-selected", "true")
selectedElement.classList.add("bg-container-inset")
const selectedIcon = selectedElement.querySelector(".check-icon")
if (selectedIcon) selectedIcon.classList.remove("hidden")
this.element.dispatchEvent(new CustomEvent("dropdown:select", {
detail: { value, label },
bubbles: true
}))
this.close()
this.buttonTarget.focus()
}
focusSearch() {
const input = this.menuTarget.querySelector('input[type="search"]')
if (input) { input.focus({ preventScroll: true }); return true }
return false
}
focusFirstElement() {
const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
const el = this.menuTarget.querySelector(selector)
if (el) el.focus({ preventScroll: true })
}
scrollToSelected() {
const selected = this.menuTarget.querySelector(".bg-container-inset")
if (selected) selected.scrollIntoView({ block: "center" })
}
handleOutsideClick(event) {
if (this.isOpen && !this.element.contains(event.target)) this.close()
}
handleKeydown(event) {
if (!this.isOpen) return
if (event.key === "Escape") { this.close(); this.buttonTarget.focus() }
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() }
}
handleTurboLoad() { if (this.isOpen) this.close() }
clearSearch() {
const input = this.menuTarget.querySelector('input[type="search"]')
if (!input) return
input.value = ""
input.dispatchEvent(new Event("input", { bubbles: true }))
}
startAutoUpdate() {
if (!this._cleanup && this.buttonTarget && this.menuTarget) {
this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => this.updatePosition())
}
}
stopAutoUpdate() {
if (this._cleanup) { this._cleanup(); this._cleanup = null }
}
observeMenuResize() {
this.resizeObserver = new ResizeObserver(() => {
if (this.isOpen) requestAnimationFrame(() => this.updatePosition())
})
this.resizeObserver.observe(this.menuTarget)
}
getScrollParent(element) {
let parent = element.parentElement
while (parent) {
const style = getComputedStyle(parent)
const overflowY = style.overflowY
if (overflowY === "auto" || overflowY === "scroll") return parent
parent = parent.parentElement
}
return document.documentElement
}
updatePosition() {
if (!this.buttonTarget || !this.menuTarget || !this.isOpen) return
const container = this.getScrollParent(this.element)
const containerRect = container.getBoundingClientRect()
const buttonRect = this.buttonTarget.getBoundingClientRect()
const menuHeight = this.menuTarget.scrollHeight
const spaceBelow = containerRect.bottom - buttonRect.bottom
const spaceAbove = buttonRect.top - containerRect.top
const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
this.menuTarget.style.left = "0"
this.menuTarget.style.width = "100%"
this.menuTarget.style.top = ""
this.menuTarget.style.bottom = ""
this.menuTarget.style.overflowY = "auto"
if (shouldOpenUp) {
this.menuTarget.style.bottom = "100%"
this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`
} else {
this.menuTarget.style.top = "100%"
this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`
}
}
}