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"); } }