mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(mood): search the icon picker with the headers it just rendered
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.
This commit is contained in:
1 parent
2d26a6fdfe
commit
fb41df49e9
2 files changed
+100
-6
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user