Font picker fix round: one search field, honest empty-enumeration fallback, tooltip only on the closed trigger

- FontMenuList: the separate custom-family input is gone; the search
  field absorbed its job — a no-match query renders a 'Use <query>'
  action row (customFamilyFor, pure, pinned), Enter picks it
- openFontMenu: an empty-but-successful queryLocalFonts() now falls back
  to the curated list (Electron 44 grants local-fonts and still
  enumerates zero fonts — reproduced with a hidden-Window probe); the
  footer split states WHY: 'could not be read' vs 'needs the desktop app'
- The Hint tooltip wraps the trigger button only and unmounts while the
  menu is open — it no longer pops over the dropdown
This commit is contained in:
amosblomqvist
2026-09-25 20:12:57 +03:00
parent ad61301324
commit ac1c1a2818
3 changed files with 148 additions and 104 deletions
+42 -54
View File
@@ -296,12 +296,23 @@ export function fontMenuResults(query: string, families: string[]): Array<string
return scored;
}
/** The reading-font dropdown body — search + list + the custom-family
* escape (the old free-text input lives here as the last resort: any
* family name the enumeration didn't surface). `families ===
* undefined` is the enumerating state (desktop's queryLocalFonts); the
* curated fallback list arrives as plain families with a footer that
* says so honestly. */
/** The custom-family action row's decision (pure, 2026-09-26): a typed
* query that matches NO family becomes the "Use '<query>'" custom pick —
* a matching family is the better pick, so the action row only exists
* when there is nothing else to pick. Exported for tests. */
export function customFamilyFor(query: string, families: string[]): string | null {
const trimmed = query.trim();
if (trimmed === "") return null;
return fontMenuResults(query, families).length === 0 ? trimmed : null;
}
/** The reading-font dropdown body — ONE search field (owner ask
* 2026-09-26: "why two?"): the fuzzy machine filters the families, and
* when nothing matches, a "Use '<query>'" action row commits the typed
* text as a custom family — the old separate custom input is gone, the
* search field absorbed its job. `families === undefined` is the
* enumerating state; the curated fallback arrives as plain families with
* a footer that says so honestly. */
export function FontMenuList({
families,
effectiveFamily,
@@ -320,7 +331,6 @@ export function FontMenuList({
}) {
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const [custom, setCustom] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
@@ -334,12 +344,10 @@ export function FontMenuList({
}, [menuIdx]);
const results = fontMenuResults(query, families ?? []);
const commitCustom = () => {
const value = custom.trim();
if (value === "") return;
onPick(value);
};
// The custom-family action row: only when a typed query matches
// nothing (a matching family is the better pick). Enter picks it.
const customFamily = customFamilyFor(query, families ?? []);
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
return (
<>
@@ -355,18 +363,18 @@ export function FontMenuList({
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
if (results.length === 0) return;
if (walkable.length === 0) return;
setMenuIdx((i) =>
e.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0),
? Math.min((i ?? -1) + 1, walkable.length - 1)
: Math.max((i ?? walkable.length) - 1, 0),
);
} else if (e.key === "Enter") {
const chosen = results[menuIdx ?? 0];
const chosen = walkable[menuIdx ?? 0];
if (chosen !== undefined) {
e.preventDefault();
e.stopPropagation();
onPick(chosen);
onPick(chosen === "__custom__" ? customFamily : chosen);
}
}
}}
@@ -374,9 +382,6 @@ export function FontMenuList({
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{!families && <div className="px-2 py-3 text-center text-muted-foreground">Loading fonts…</div>}
{families && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No fonts match — type a family below.</div>
)}
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{families &&
results.map((family, i) => {
@@ -399,40 +404,23 @@ export function FontMenuList({
</li>
);
})}
{families && customFamily !== null && (
<li>
<button
type="button"
data-testid="font-option-custom"
data-idx={0}
onClick={() => onPick(customFamily)}
className={`flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}`}
>
<span className="truncate text-card-foreground">Use “{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">custom</span>
</button>
</li>
)}
</ul>
{/* The manual escape path — the old free-text input, living
inside the dropdown as its last row. Any family name the
enumeration didn't surface commits verbatim. */}
<div className="mt-1 flex items-center gap-1.5 border-t px-1 pt-1.5">
<input
data-testid="font-custom-input"
value={custom}
onChange={(e) => setCustom(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
commitCustom();
}
}}
placeholder="Type a family name…"
aria-label="Custom font family"
className="lh-input min-w-0 flex-1 rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="button"
data-testid="font-custom-set"
onClick={commitCustom}
disabled={custom.trim() === ""}
className={`shrink-0 rounded-lg px-2 py-1.5 text-xs underline-offset-2 ${
custom.trim() === ""
? "cursor-not-allowed text-muted-foreground/40"
: "text-foreground hover:underline"
}`}
>
Set
</button>
</div>
{footer && <div className="px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>}
</>
);