fix(list-filter): match accented text regardless of typed diacritics (#2814)

* 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 <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Guillem Arias Fauste
2026-08-04 23:20:55 +02:00
committed by GitHub
co-authored by Juan José Mata
parent 93ba2b8c08
commit 7907a27a55
@@ -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");
}
}