mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
* fix(design-system): DS::Select a11y — fix aria-expanded, listbox keyboard nav, label binding Closes #1744. Several concrete bugs from the savings-goals audit: 1. **`aria-expanded` wired to the wrong state.** The template had `aria-expanded="<%= @selected_value.present? ? "true" : "false" %>"`, which is "has a value been chosen", not "is the menu open". AT users heard a misleading signal on every page load. Init to `"false"`; the Stimulus controller's openMenu/close already correctly maintains the attribute after that. 2. **`aria-labelledby` referenced a nonexistent id.** The trigger pointed at `"#{method}_label"`, but the rendered `<label>` had no id at all — the binding silently failed. Add `id: "#{method}_label"` to `form.label` so the reference actually resolves to the label text. Only emit `aria-labelledby` when there *is* a visible label. 3. **`tabindex="0"` on every option.** Listbox options should use roving tabindex (only the selected option is in tab order; the rest are reachable via ArrowUp/Down). Set `tabindex="0"` on the selected option only; `"-1"` on the rest. The select controller's `select()` handler keeps the roving invariant on user interaction. 4. **No keyboard navigation between options.** Add ArrowDown/Up (cycle), Home (first), End (last). The existing Enter/Escape handlers stay. ArrowUp/Down inside the search input is left alone so the input's caret behavior isn't hijacked. 5. **Search input had no accessible name.** Add an explicit `aria-label` matching the placeholder copy so AT users hear "search" when focus enters the field. API unchanged. Builder-level routing fix in `StyledFormBuilder#select` (calling DS::Select for `f.select(...)` the same way `f.collection_select` already does) is intentionally out of scope — it's a separate translation pass for the choices format. Documented as a follow-up. * fix(review): bridge search input to visible options in DS::Select ArrowDown/Up from the search input now focus the first/last visible option, and keyboard navigation operates on visible options only. After typing a search query, the controller promotes the first visible option to tabindex="0" so Tab can land on it even when the previously tab-eligible option is filtered out. Addresses Codex review on PR #1848 (issue #1744). * fix(review): include trigger in DS::Select aria-labelledby Codex P2 follow-up on #1848: \`aria-labelledby=\"#{method}_label\"\` makes the trigger button's accessible name come solely from the external form label — that overrides the button's own text node (\`selected_item[:label]\` / placeholder). Screen readers therefore announce only "Currency" without ever hearing the selected "USD" unless the user opens the listbox. Give the trigger \`id=\"#{method}_trigger\"\` and reference both ids: \`aria-labelledby=\"#{method}_label #{method}_trigger\"\`. The accessible-name algorithm concatenates the two, so AT users now hear \"<Label> <selected value>\" while \`aria-expanded\` / \`aria-haspopup\` continue to convey the dropdown state.
261 lines
9.6 KiB
JavaScript
261 lines
9.6 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import { autoUpdate } from "@floating-ui/dom"
|
|
|
|
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.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.setAttribute("tabindex", "-1")
|
|
previousSelected.classList.remove("bg-container-inset")
|
|
const prevIcon = previousSelected.querySelector(".check-icon")
|
|
if (prevIcon) prevIcon.classList.add("hidden")
|
|
}
|
|
|
|
selectedElement.setAttribute("aria-selected", "true")
|
|
selectedElement.setAttribute("tabindex", "0")
|
|
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) 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()
|
|
}
|
|
|
|
handleKeydown(event) {
|
|
if (!this.isOpen) return
|
|
if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); 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 (roving tabindex), Home/End jump to first/last.
|
|
// From the search input, ArrowDown/Up bridge into the visible
|
|
// options so users can reach the filtered matches; other keys
|
|
// (typing, caret movement) stay with the input.
|
|
const fromSearch = event.target.matches('input[type="search"]')
|
|
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.rovingFocus(visibleOptions, targetIndex)
|
|
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.rovingFocus(visibleOptions, nextIndex)
|
|
}
|
|
|
|
// Roving tabindex helper: makes the target option tabbable (and
|
|
// focuses it), clears tabindex on every other option in the listbox.
|
|
rovingFocus(visibleOptions, index) {
|
|
const all = this.hasOptionTarget ? this.optionTargets : []
|
|
const target = visibleOptions[index]
|
|
all.forEach(opt => opt.setAttribute("tabindex", opt === target ? "0" : "-1"))
|
|
target.focus()
|
|
}
|
|
|
|
// 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, the option holding tabindex="0" may
|
|
// be hidden. Promote the first visible option so Tab from the search
|
|
// input still lands somewhere reachable; if none match, no-op.
|
|
syncTabindex() {
|
|
const visible = this.visibleOptions()
|
|
if (visible.length === 0) return
|
|
const tabbable = visible.find(opt => opt.getAttribute("tabindex") === "0")
|
|
if (tabbable) return
|
|
const all = this.hasOptionTarget ? this.optionTargets : []
|
|
all.forEach(opt => opt.setAttribute("tabindex", "-1"))
|
|
visible[0].setAttribute("tabindex", "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`
|
|
}
|
|
}
|
|
} |