mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge branch 'fix/language-matchers-rest' into release/v1.37.27
This commit is contained in:
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);
|
||||
|
||||
@@ -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