Files
sure/app/javascript/controllers/select_controller.js
Rubén Rodríguez 5f16a36b06 Fix keyboard navigation in DS::Select dropdowns (#2689)
* Fix keyboard navigation in DS::Select dropdowns.

Open the listbox on Tab focus and arrow/space/enter from the trigger so
account and category selectors in the transaction form are usable without
a mouse, and reset option tabindex when the menu closes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Tab skipping DS::Select triggers in transaction form.

Keep focus on the trigger when Tab opens the menu, keep listbox options
at tabindex -1 with inert on the closed menu, and move focus into options
only via arrow keys so Tab no longer jumps to amount or date.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix search input losing focus during DS::Select filtering.

Only repoint focus in syncTabindex when a listbox option was focused and
became hidden; skip while the user is typing in the search field or when
clearSearch runs on menu open.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Escape closing the parent dialog from an open DS::Select.

Stop Escape propagation so only the listbox closes, and suppress
handleButtonFocus briefly after close+refocus so keyboard modality
does not reopen the menu via :focus-visible.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Tab leaving DS::Select after suppressReopenOnFocus change.

Focus the trigger before closing on Tab and advance to the next dialog
field manually so inert on the menu no longer drops focus to body.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 06:50:58 +02:00

387 lines
13 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
import { autoUpdate } from "@floating-ui/dom"
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled]):not([type=hidden])",
"select:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ")
export default class extends Controller {
static targets = ["button", "menu", "input", "content", "option"]
static values = {
menuPlacement: { type: String, default: "auto" },
offset: { type: Number, default: 6 }
}
connect() {
this.isOpen = false
this.suppressReopenOnFocus = false
this.boundOutsideClick = this.handleOutsideClick.bind(this)
this.boundKeydown = this.handleKeydown.bind(this)
this.boundTurboLoad = this.handleTurboLoad.bind(this)
this.boundFocusOut = this.handleFocusOut.bind(this)
document.addEventListener("click", this.boundOutsideClick)
document.addEventListener("turbo:load", this.boundTurboLoad)
this.element.addEventListener("keydown", this.boundKeydown)
this.element.addEventListener("focusout", this.boundFocusOut)
this.resetOptionTabindex()
this.setMenuInert(true)
this.observeMenuResize()
}
disconnect() {
document.removeEventListener("click", this.boundOutsideClick)
document.removeEventListener("turbo:load", this.boundTurboLoad)
this.element.removeEventListener("keydown", this.boundKeydown)
this.element.removeEventListener("focusout", this.boundFocusOut)
this.stopAutoUpdate()
if (this.resizeObserver) this.resizeObserver.disconnect()
}
toggle = () => {
this.isOpen ? this.close() : this.openMenu()
}
// Tab lands on the trigger — open the menu but keep focus here so the
// browser doesn't continue Tab into the listbox and skip to the next field.
// Skip when we just closed via Escape/Enter and re-focused the trigger:
// keyboard modality keeps :focus-visible, which would otherwise reopen.
handleButtonFocus() {
if (this.isOpen) return
if (this.suppressReopenOnFocus) return
if (!this.buttonTarget.matches(":focus-visible")) return
this.openMenu()
}
focusTriggerWithoutReopening() {
this.suppressReopenOnFocus = true
this.buttonTarget.focus()
requestAnimationFrame(() => { this.suppressReopenOnFocus = false })
}
// Move focus to the next/previous tab stop after the trigger. Used when Tab
// closes the listbox: options are tabindex="-1" and close() sets inert on
// the menu, so the browser can't reliably continue native Tab navigation.
focusAdjacentTabStop(reverse = false) {
const scope = this.element.closest("dialog") || document
const focusables = this.#focusablesIn(scope)
const index = focusables.indexOf(this.buttonTarget)
if (index === -1) return
const next = focusables[index + (reverse ? -1 : 1)]
next?.focus()
}
#focusablesIn(scope) {
return Array.from(scope.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => {
if (el.closest("[inert]")) return false
return el.offsetParent !== null || el === document.activeElement
})
}
// Arrow / Space / Enter from a closed trigger — open and move focus in.
openAndFocusMenu() {
this.openMenu()
requestAnimationFrame(() => {
if (this.focusSearch()) return
this.focusSelectedOrFirstOption()
})
}
focusSelectedOrFirstOption() {
const visible = this.visibleOptions()
if (visible.length === 0) return
const selected = visible.find(opt => opt.getAttribute("aria-selected") === "true")
this.focusOption(selected || visible[0])
}
openMenu() {
this.isOpen = true
this.setMenuInert(false)
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")
this.resetOptionTabindex()
this.setMenuInert(true)
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.focusTriggerWithoutReopening()
this.close()
}
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) return
const container = this.hasContentTarget ? this.contentTarget : this.menuTarget
const containerRect = container.getBoundingClientRect()
const selectedRect = selected.getBoundingClientRect()
const delta = selectedRect.top - containerRect.top - (container.clientHeight - selectedRect.height) / 2
const nextScrollTop = container.scrollTop + delta
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
container.scrollTop = this.clamp(nextScrollTop, 0, maxScrollTop)
}
clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
placementMode() {
const mode = (this.menuPlacementValue || "auto").toLowerCase()
return ["auto", "down", "up"].includes(mode) ? mode : "auto"
}
handleOutsideClick(event) {
if (this.isOpen && !this.element.contains(event.target)) this.close()
}
handleFocusOut(event) {
if (!this.isOpen) return
const related = event.relatedTarget
if (related && this.element.contains(related)) return
this.close()
}
handleKeydown(event) {
if (!this.isOpen && event.target === this.buttonTarget) {
if (["ArrowDown", "ArrowUp", " ", "Enter"].includes(event.key)) {
event.preventDefault()
this.openAndFocusMenu()
}
return
}
if (!this.isOpen) return
if (event.key === "Tab") {
const reverse = event.shiftKey
event.preventDefault()
// Focus the trigger before close() sets inert on the menu — otherwise
// focus is still on an option and the UA drops it to <body>.
this.focusTriggerWithoutReopening()
this.close()
this.focusAdjacentTabStop(reverse)
return
}
// Stop Escape from reaching DS::Dialog's esc hotkey so only the
// listbox closes; a second Escape can still dismiss the modal.
if (event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
this.focusTriggerWithoutReopening()
this.close()
return
}
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click(); return }
// WAI-ARIA APG listbox keyboard pattern: ArrowUp/Down moves focus
// between options, Home/End jump to first/last. Options stay at
// tabindex="-1" so they never appear in the Tab sequence — focus moves
// programmatically only.
// From the search input, ArrowDown/Up bridge into the visible options.
const fromSearch = event.target.matches('input[type="search"]')
const fromButton = event.target === this.buttonTarget
const visibleOptions = this.visibleOptions()
if (fromSearch) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
if (visibleOptions.length === 0) return
event.preventDefault()
const targetIndex = event.key === "ArrowDown" ? 0 : visibleOptions.length - 1
this.focusOption(visibleOptions[targetIndex])
return
}
if (fromButton && event.key === "ArrowDown" && this.focusSearch()) {
event.preventDefault()
return
}
if (visibleOptions.length === 0) return
const currentIndex = visibleOptions.indexOf(event.target)
let nextIndex = null
switch (event.key) {
case "ArrowDown": nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % visibleOptions.length; break
case "ArrowUp": nextIndex = currentIndex < 0 ? visibleOptions.length - 1 : (currentIndex - 1 + visibleOptions.length) % visibleOptions.length; break
case "Home": nextIndex = 0; break
case "End": nextIndex = visibleOptions.length - 1; break
default: return
}
event.preventDefault()
this.focusOption(visibleOptions[nextIndex])
}
focusOption(option) {
if (!option) return
option.focus({ preventScroll: true })
option.scrollIntoView({ block: "nearest" })
}
resetOptionTabindex() {
if (!this.hasOptionTarget) return
this.optionTargets.forEach(opt => opt.setAttribute("tabindex", "-1"))
}
setMenuInert(inert) {
if (!this.hasMenuTarget) return
if (inert) {
this.menuTarget.setAttribute("inert", "")
} else {
this.menuTarget.removeAttribute("inert")
}
}
// Options the user can currently see — list-filter hides non-matches
// by setting `style.display = "none"`. Inline check keeps it cheap.
visibleOptions() {
const options = this.hasOptionTarget ? this.optionTargets : []
return options.filter(opt => opt.style.display !== "none")
}
// After list-filter#filter runs, a keyboard-focused option may be hidden.
// Repoint focus to the first visible match. Leave the search input and
// trigger alone — this handler also runs on every search keystroke.
syncTabindex() {
const visible = this.visibleOptions()
if (visible.length === 0) return
const active = document.activeElement
if (active.matches('input[type="search"]') && this.menuTarget.contains(active)) return
if (!this.hasOptionTarget || !this.optionTargets.includes(active)) return
if (visible.includes(active)) return
this.focusOption(visible[0])
}
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 placement = this.placementMode()
const shouldOpenUp = placement === "up" || (placement === "auto" && 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`
}
}
}