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