diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb index 8f1ffe3e4..6c06d83f1 100644 --- a/app/components/DS/select.html.erb +++ b/app/components/DS/select.html.erb @@ -3,7 +3,7 @@
form-dropdown" data-select-menu-placement-value="<%= menu_placement %>" data-action="dropdown:select->form-dropdown#onSelect">
- <%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %> + <%= form.label method, options[:label], class: "form-field__label", id: "#{method}_label" if options[:label].present? %> <%= form.hidden_field method, value: @selected_value, data: { @@ -11,13 +11,22 @@ "auto-submit-target": "auto", **(options.dig(:html_options, :data) || {}) } %> + <%# `aria-expanded` reflects MENU open/closed state — managed by the + select controller's openMenu/close. Init as "false"; previously + this incorrectly mirrored whether a value was selected. + + `aria-labelledby` points at BOTH the visible label and the + trigger button itself so AT users hear "
@@ -28,9 +37,10 @@ " autocomplete="off" + aria-label="<%= t("helpers.select.search_placeholder") %>" class="bg-container text-primary text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0" data-list-filter-target="input" - data-action="list-filter#filter"> + data-action="input->list-filter#filter input->select#syncTabindex"> <%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
<% end %> @@ -40,10 +50,14 @@ <% 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. %>
" role="option" - tabindex="0" + tabindex="<%= is_selected ? "0" : "-1" %>" aria-selected="<%= is_selected %>" + data-select-target="option" data-action="click->select#select" data-value="<%= item[:value] %>" data-filter-name="<%= item[:label] %>"> diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js index e4b07bb3b..19b44bb64 100644 --- a/app/javascript/controllers/select_controller.js +++ b/app/javascript/controllers/select_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus" import { autoUpdate } from "@floating-ui/dom" export default class extends Controller { - static targets = ["button", "menu", "input", "content"] + static targets = ["button", "menu", "input", "content", "option"] static values = { menuPlacement: { type: String, default: "auto" }, offset: { type: Number, default: 6 } @@ -70,12 +70,14 @@ 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") @@ -130,8 +132,66 @@ export default class extends Controller { handleKeydown(event) { if (!this.isOpen) return - if (event.key === "Escape") { this.close(); this.buttonTarget.focus() } - if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() } + 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() }