From 7907a27a55d6338ff77aa5fe2f241871183be3c0 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 4 Aug 2026 23:20:55 +0200 Subject: [PATCH] fix(list-filter): match accented text regardless of typed diacritics (#2814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(list-filter): match accented text regardless of typed diacritics "prestecs" didn't match a category literally named "Prèstecs" — the filter only lowercased before comparing, and lowercasing doesn't strip diacritics ("è" and "e" are different codepoints). Normalizes both the typed value and each item's data-filter-name via NFD decomposition + \p{Diacritic} stripping before comparing, so a search matches regardless of which side has the accent. This is one shared Stimulus controller behind every filterable list in the app (category dropdown, transaction search filters for category/ merchant/tag/account, DS::Select, DS::tag_select, settings preferences, family merchants merge, split category select) — fixing it here covers all of them, not just categories. Verified live: typing "prestecs" and "prèstecs" both correctly filter down to a category named "Prèstecs" in the transaction categorize dropdown, out of ~20 sibling categories. * fix(list-filter): use \p{Mark} instead of \p{Diacritic} Codex review on this PR: \p{Diacritic} is broader than combining marks — it also covers standalone characters (ASCII ^ and backtick, the middle dot, modifier letters like the Hawaiian ʻokina). Since this controller filters arbitrary user-defined names, a query consisting of just one of those normalizes to "", and "".includes() matches everything, so that search silently shows every row instead of filtering. Verified in node against the exact characters raised: \p{Mark} leaves "^", "`", "·", and "ʻ" untouched while still stripping real combining diacritics (café → cafe, prèstecs → prestecs). * fix(list-filter): don't match everything when query normalizes to empty A non-empty query that normalizes away to "" (e.g. a lone combining mark) previously matched every row, since "".includes("") is true. Only a genuinely empty raw input should mean "show everything" now. --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- .../controllers/list_filter_controller.js | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/app/javascript/controllers/list_filter_controller.js b/app/javascript/controllers/list_filter_controller.js index b012f26e5..b7362232a 100644 --- a/app/javascript/controllers/list_filter_controller.js +++ b/app/javascript/controllers/list_filter_controller.js @@ -11,7 +11,12 @@ export default class extends Controller { } filter() { - const filterValue = this.inputTarget.value.toLowerCase(); + // Only a genuinely empty query means "show everything". A non-empty + // query that normalizes away to "" (e.g. a lone combining mark) must + // still match nothing — "".includes("") is true, so without this split + // it would show every row instead of filtering. + const rawFilterValue = this.inputTarget.value; + const filterValue = this.normalize(rawFilterValue); const items = this.listTarget.querySelectorAll(".filterable-item"); let noMatchFound = true; @@ -33,13 +38,10 @@ export default class extends Controller { } items.forEach((item) => { - if (filterValue.length > 0 && recentItems.has(item)) { - item.style.display = "none"; - return; - } - - const text = item.getAttribute("data-filter-name").toLowerCase(); - const shouldDisplay = text.includes(filterValue); + const text = this.normalize(item.getAttribute("data-filter-name")); + const shouldDisplay = + rawFilterValue.length === 0 || + (filterValue.length > 0 && text.includes(filterValue)); item.style.display = shouldDisplay ? "" : "none"; if (shouldDisplay) { @@ -56,6 +58,21 @@ export default class extends Controller { this.updateAriaActiveDescendant(); } + // Case- and diacritic-insensitive: "prestecs" should match "Prèstecs". + // NFD splits each accented char into base char + combining mark, then + // \p{Mark} strips the marks, so both sides compare on bare base characters. + // Not \p{Diacritic}: that property is broader than combining marks — it + // also covers standalone characters like "^", "`", the middot, and + // modifier letters (e.g. the Hawaiian ʻokina) — so a query of just one of + // those would normalize to "", and "".includes() matches everything, + // silently showing every row instead of filtering. + normalize(value) { + return value + .normalize("NFD") + .replace(/\p{Mark}/gu, "") + .toLowerCase(); + } + handleKeydown(event) { if (event.key === "ArrowDown") { event.preventDefault(); @@ -74,7 +91,10 @@ export default class extends Controller { if (items.length === 0) return; this.clearHighlights(); - this.highlightedIndex = Math.min(this.highlightedIndex + 1, items.length - 1); + this.highlightedIndex = Math.min( + this.highlightedIndex + 1, + items.length - 1, + ); this.highlightItem(items[this.highlightedIndex]); this.updateAriaActiveDescendant(); } @@ -104,7 +124,8 @@ export default class extends Controller { selectHighlighted() { const items = this.visibleItems; - if (this.highlightedIndex < 0 || this.highlightedIndex >= items.length) return; + if (this.highlightedIndex < 0 || this.highlightedIndex >= items.length) + return; const item = items[this.highlightedIndex]; const form = item.querySelector("form"); @@ -124,8 +145,8 @@ export default class extends Controller { } get visibleItems() { - return Array.from(this.listTarget.querySelectorAll(".filterable-item")).filter( - (item) => item.style.display !== "none" - ); + return Array.from( + this.listTarget.querySelectorAll(".filterable-item"), + ).filter((item) => item.style.display !== "none"); } }