From fb41df49e916b9a7ff30a713b226f02382fe2f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 18:09:35 +0200 Subject: [PATCH] fix(mood): search the icon picker with the headers it just rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid draws its group headers in the reader's language and then searched only the English Lucide names and the English keyword aids. A German reader who typed back the header they had just read — "Gesundheit" — got an empty grid and "no icons found", with nothing to say the query was fine and the language was not. The filter now also matches the group header the picker is currently rendering, folded on both sides so an accent or a capital cannot make someone miss a word they can see. The labels come from the bundle at the call site, so a seventh language needs no edit here. This is a partial close and the test says so. The per-icon aids stay English, so "Herz" and "kawa" still find nothing; translating roughly eighty icons times three aids times six languages is a content decision, not a filter one. --- .../__tests__/mood-tag-icon-picker.test.tsx | 59 +++++++++++++++++++ src/components/mood/mood-tag-icon-picker.tsx | 47 +++++++++++++-- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx b/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx index cbdbe6c36..e16ce4265 100644 --- a/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx +++ b/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx @@ -28,6 +28,65 @@ describe("filterIconCatalog", () => { expect(hits.map((entry) => entry.name)).toContain("Dumbbell"); }); + /** + * The only searchable text used to be the English Lucide name and the + * English keyword aids, while the grid renders its group headers in the + * reader's language. A German reader who typed back the header the picker + * had just shown them got an empty grid and "no icons found" — an empty + * table with nothing to say the query was fine and the language was not. + */ + describe("group headers in the reader's language", () => { + /** + * The header the picker renders above the health group, per shipped + * locale, as `mood.manage.iconGroupHealth` spells it. Derived at the call + * site in the component; pinned here so the property is a test, not a + * transcription that can drift from the bundle. + */ + const HEALTH_HEADER: Record = { + en: "Health", + de: "Gesundheit", + fr: "Santé", + es: "Salud", + it: "Salute", + pl: "Zdrowie", + }; + + it.each(Object.entries(HEALTH_HEADER))( + "finds the health icons from the %s header", + (locale, header) => { + const labels = { health: header }; + const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, header, labels); + const names = hits.map((entry) => entry.name); + // Every drawable icon under the header the reader just read. (An + // English "Health" also hits the "healthy" keyword aid on a food icon, + // which is a union match and fine — the assertion is on coverage of + // the group, not on the absence of other hits.) + for (const entry of MOOD_TAG_ICON_CATALOG) { + if (entry.group !== "health") continue; + if (!isMoodTagIconName(entry.name)) continue; + expect(names).toContain(entry.name); + } + void locale; + }, + ); + + it("ignores accents and case in the header match", () => { + const labels = { weather: "Météo" }; + const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "meteo", labels); + expect(hits.length).toBeGreaterThan(0); + expect(hits.every((entry) => entry.group === "weather")).toBe(true); + }); + + it("still finds nothing for a word no header and no keyword carries", () => { + // The per-icon aids stay English. This is the stated remaining limit, + // pinned so a later change to it is a deliberate one. + const labels = { health: "Gesundheit" }; + expect( + filterIconCatalog(MOOD_TAG_ICON_CATALOG, "herz", labels), + ).toHaveLength(0); + }); + }); + it("matches on keyword aids", () => { const byKeyword = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "happy"); expect(byKeyword.length).toBeGreaterThan(0); diff --git a/src/components/mood/mood-tag-icon-picker.tsx b/src/components/mood/mood-tag-icon-picker.tsx index 08ba30015..031100ded 100644 --- a/src/components/mood/mood-tag-icon-picker.tsx +++ b/src/components/mood/mood-tag-icon-picker.tsx @@ -6,6 +6,7 @@ import { Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { MOOD_TAG_ICON_CATALOG, @@ -36,21 +37,42 @@ const ICON_GROUP_LABEL_KEYS: Record = { }; /** - * Pure search filter — name + keyword substring match, case-insensitive. + * Pure search filter — name, keyword, and translated group-header match. * Exported for unit tests. Entries the client bundle cannot draw are * excluded up front so the picker never offers a fallback-glyph tile. + * + * `groupLabels` maps a catalog group to the header the picker is CURRENTLY + * rendering above it, so the words on screen in the reader's language are also + * the words the box searches. Without it the only searchable text was the + * English Lucide name and the English keyword aids, and a German reader who + * typed what the picker itself had just shown them ("Gesundheit") got an empty + * grid and "no icons found" — an empty table, with nothing to say it was the + * language rather than the query. + * + * That is a partial close, stated rather than papered over: the per-icon + * keywords ("happy", "gym", "coffee") stay English, so searching for "Herz" or + * "kawa" still finds nothing. Translating them is ~80 icons times three aids + * times six languages of new content, which is a decision about content rather + * than about this filter. Omit the argument and the behaviour is the old one. + * + * Both sides go through `foldForMatch`, so accents and hyphens cannot make a + * reader miss a header they can see. */ export function filterIconCatalog( catalog: readonly MoodTagIconCatalogEntry[], query: string, + groupLabels: Readonly> = {}, ): MoodTagIconCatalogEntry[] { const drawable = catalog.filter((entry) => isMoodTagIconName(entry.name)); - const needle = query.trim().toLowerCase(); + const needle = foldForMatch(query); if (needle.length === 0) return drawable; return drawable.filter( (entry) => - entry.name.toLowerCase().includes(needle) || - entry.keywords.some((keyword) => keyword.toLowerCase().includes(needle)), + foldForMatch(entry.name).includes(needle) || + entry.keywords.some((keyword) => + foldForMatch(keyword).includes(needle), + ) || + foldForMatch(groupLabels[entry.group] ?? "").includes(needle), ); } @@ -65,9 +87,22 @@ export function MoodTagIconPicker({ value, onChange }: MoodTagIconPickerProps) { const [query, setQuery] = useState(""); const groupLabelId = useId(); + // The headers the grid is about to render, so the search box matches the + // words the reader can see rather than only their English source. + const groupLabels = useMemo( + () => + Object.fromEntries( + Object.entries(ICON_GROUP_LABEL_KEYS).map(([group, key]) => [ + group, + t(key), + ]), + ), + [t], + ); + const filtered = useMemo( - () => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query), - [query], + () => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query, groupLabels), + [query, groupLabels], ); // One flat roving radiogroup across the (filtered) grid; the group