From 281197f91809ba57a2cc668ba6f50276487afc99 Mon Sep 17 00:00:00 2001 From: Mike Lloyd <49411532+mike-lloyd03@users.noreply.github.com> Date: Sat, 4 Apr 2026 04:29:43 -0700 Subject: [PATCH] Fix opacity for excluded transactions and implement keyboard navigation (#1332) * Make category selection menus opaque for excluded transactions * Allow keyboard navigation in category selection menu * Fix category transparency on mobile * Make checkbox opaque * Remove text-secondary from amount container * Submit form directly * Handle aria labels --- .../controllers/list_filter_controller.js | 79 +++++++++++++++++++ app/views/category/dropdowns/_row.html.erb | 3 + app/views/category/dropdowns/show.html.erb | 8 +- app/views/transactions/_transaction.html.erb | 23 +++--- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/app/javascript/controllers/list_filter_controller.js b/app/javascript/controllers/list_filter_controller.js index 279d8b47b..c6938a417 100644 --- a/app/javascript/controllers/list_filter_controller.js +++ b/app/javascript/controllers/list_filter_controller.js @@ -6,6 +6,8 @@ export default class extends Controller { connect() { this.inputTarget.focus(); + this.highlightedIndex = -1; + this.updateAriaActiveDescendant(); } filter() { @@ -30,5 +32,82 @@ export default class extends Controller { if (noMatchFound && this.hasEmptyMessageTarget) { this.emptyMessageTarget.classList.remove("hidden"); } + + this.highlightedIndex = -1; + this.clearHighlights(); + this.updateAriaActiveDescendant(); + } + + handleKeydown(event) { + if (event.key === "ArrowDown") { + event.preventDefault(); + this.highlightNext(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.highlightPrevious(); + } else if (event.key === "Enter") { + event.preventDefault(); + this.selectHighlighted(); + } + } + + highlightNext() { + const items = this.visibleItems; + if (items.length === 0) return; + + this.clearHighlights(); + this.highlightedIndex = Math.min(this.highlightedIndex + 1, items.length - 1); + this.highlightItem(items[this.highlightedIndex]); + this.updateAriaActiveDescendant(); + } + + highlightPrevious() { + const items = this.visibleItems; + if (items.length === 0) return; + + this.clearHighlights(); + this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0); + this.highlightItem(items[this.highlightedIndex]); + this.updateAriaActiveDescendant(); + } + + highlightItem(item) { + item.classList.add("bg-container-inset-hover"); + item.setAttribute("aria-selected", "true"); + item.scrollIntoView({ block: "nearest" }); + } + + clearHighlights() { + this.listTarget.querySelectorAll(".filterable-item").forEach((item) => { + item.classList.remove("bg-container-inset-hover"); + item.setAttribute("aria-selected", "false"); + }); + } + + selectHighlighted() { + const items = this.visibleItems; + if (this.highlightedIndex < 0 || this.highlightedIndex >= items.length) return; + + const item = items[this.highlightedIndex]; + const form = item.querySelector("form"); + if (form) { + form.requestSubmit(); + } + } + + updateAriaActiveDescendant() { + const items = this.visibleItems; + if (this.highlightedIndex >= 0 && this.highlightedIndex < items.length) { + const item = items[this.highlightedIndex]; + this.inputTarget.setAttribute("aria-activedescendant", item.id); + } else { + this.inputTarget.removeAttribute("aria-activedescendant"); + } + } + + get visibleItems() { + return Array.from(this.listTarget.querySelectorAll(".filterable-item")).filter( + (item) => item.style.display !== "none" + ); } } diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb index 0a4927dcd..fbf6cf421 100644 --- a/app/views/category/dropdowns/_row.html.erb +++ b/app/views/category/dropdowns/_row.html.erb @@ -2,6 +2,9 @@ <% is_selected = category.id === @selected_category&.id %> <%= content_tag :div, + id: dom_id(category, "category_option"), + role: "option", + aria_selected: is_selected.to_s, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-container-inset-hover", { "bg-container-inset": is_selected }], data: { filter_name: category.name } do %> diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index bdfc387eb..234c31463 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -6,13 +6,17 @@ placeholder="<%= t(".search_placeholder") %>" autocomplete="nope" type="search" + role="combobox" + aria-label="<%= t(".search_placeholder") %>" + aria-expanded="false" + aria-autocomplete="list" class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative 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="list-filter#filter keydown->list-filter#handleKeydown"> <%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %> -