Merge branch 'fix/language-matchers-rest' into release/v1.37.27

This commit is contained in:
Marc-André Bombeck committed 2026-08-23 19:38:45 +02:00
commit b154ec52cb
13 files changed
+1291 -82

No files matched your search

@@ -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<string, string> = {
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);
+41 -6
View File
@@ -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<string, string> = {
};
/**
* 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<Record<string, string>> = {},
): 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