mirror of
https://github.com/we-promise/sure.git
synced 2026-07-24 10:45:23 +00:00
* 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>
387 lines
13 KiB
JavaScript
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`
|
|
}
|
|
}
|
|
}
|