Files
sure/app/javascript/controllers/select_controller.js
T
Brandon WolfandClaude Fable 5 5accc90a81 fix(splits): let category selector size to its content instead of clipping names (#2962)
* fix(splits): let category selector size to its content instead of clipping names

- Replace the fixed md:w-44 column with flex-initial + md:min-w-44/md:max-w-72
  so the button grows with the selected category name
- Truncate the badge label in an inner span with a native title tooltip and a
  max-w-64 cap, keeping the color dot from shrinking
- Widen the dropdown to md:w-80 so option badges stay readable
- select_controller: stop forcing the menu width to 100% inline (the markup's
  width classes now decide) and anchor the menu to the button's right edge
  when it would overflow the scroll container

Fixes #2934

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(splits): render category badges via DS::Pill

- Options render the canonical categories/badge partial; the button renders
  the same DS::Pill directly (a button only allows phrasing content, so the
  partial's div wrapper stays out). category_badge_select's clone selector
  follows the pill markup.
- The client-side row template in split_transaction_controller gets the same
  md:min-w-28 name-field floor as the server-rendered rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 02:10:24 +02:00

396 lines
14 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.right = ""
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`
}
// A menu wider than its anchor (e.g. the split dialog's category select)
// can spill past the scroll container's right edge and force a horizontal
// scrollbar; anchor it to the right edge of the button instead.
const menuRect = this.menuTarget.getBoundingClientRect()
if (menuRect.right > containerRect.right) {
this.menuTarget.style.left = "auto"
this.menuTarget.style.right = "0"
}
}
}