diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb index db6231925..23d9e027c 100644 --- a/app/components/DS/select.html.erb +++ b/app/components/DS/select.html.erb @@ -23,9 +23,10 @@ id="<%= method %>_trigger" class="form-field__input w-full" data-select-target="button" - data-action="click->select#toggle" + data-action="click->select#toggle focus->select#handleButtonFocus" aria-haspopup="listbox" aria-expanded="false" + tabindex="0" <%= "aria-labelledby=\"#{method}_label #{method}_trigger\"".html_safe if options[:label].present? %>> <%= selected_item&.dig(:label) || @placeholder %> @@ -50,12 +51,12 @@ <% is_selected = item[:value] == selected_value %> <% obj = item[:object] %> - <%# Roving tabindex: selected option is in tab order (`0`); others - are reachable only via ArrowUp/Down (`-1`). WAI-ARIA APG - listbox keyboard pattern. %> + <%# Options use tabindex="-1" always — keyboard focus is managed + programmatically so listbox items never appear in the Tab + sequence and skip past the trigger to the next form field. %>
" role="option" - tabindex="<%= is_selected ? "0" : "-1" %>" + tabindex="-1" aria-selected="<%= is_selected %>" data-select-target="option" data-action="click->select#select" diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js index 19b44bb64..b803cc23d 100644 --- a/app/javascript/controllers/select_controller.js +++ b/app/javascript/controllers/select_controller.js @@ -1,6 +1,15 @@ 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 = { @@ -10,14 +19,19 @@ export default class extends Controller { 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() } @@ -25,6 +39,7 @@ export default class extends Controller { 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() } @@ -33,8 +48,63 @@ export default class extends Controller { 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() @@ -53,6 +123,8 @@ export default class extends Controller { 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) } @@ -70,14 +142,12 @@ export default class extends Controller { 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") @@ -87,8 +157,8 @@ export default class extends Controller { bubbles: true })) + this.focusTriggerWithoutReopening() this.close() - this.buttonTarget.focus() } focusSearch() { @@ -130,24 +200,66 @@ export default class extends Controller { if (this.isOpen && !this.element.contains(event.target)) this.close() } - handleKeydown(event) { + handleFocusOut(event) { if (!this.isOpen) return - if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); 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 . + 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 (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. + // 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.rovingFocus(visibleOptions, targetIndex) + this.focusOption(visibleOptions[targetIndex]) + return + } + + if (fromButton && event.key === "ArrowDown" && this.focusSearch()) { + event.preventDefault() return } @@ -162,16 +274,27 @@ export default class extends Controller { default: return } event.preventDefault() - this.rovingFocus(visibleOptions, nextIndex) + this.focusOption(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() + 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 @@ -181,17 +304,19 @@ export default class extends Controller { 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. + // 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 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") + + 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() } @@ -258,4 +383,4 @@ export default class extends Controller { this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px` } } -} \ No newline at end of file +}