Finish Deepgram language settings with shared searchable picker

This commit is contained in:
amosblomqvist
2026-10-27 21:51:09 +02:00
parent 88266eaaf4
commit fbafc5e53a
12 changed files with 292 additions and 170 deletions
+27 -60
View File
@@ -1,16 +1,13 @@
"use client";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { Command } from "cmdk";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import {
DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage,
} from "@/src/core";
import { ChevronDownIcon } from "lucide-react";
import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core";
import { MenuShell } from "@/components/menu-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Hint } from "@/components/ui/hint";
import { SettingsRow } from "@/components/settings/controls";
import { SearchMenuList } from "@/components/settings/search-menu-list";
import { useDictationActive } from "@/components/dictation-state";
import { useDictationLanguage } from "@/components/dictation-preference";
@@ -30,16 +27,8 @@ function DictationLanguagePicker({ preference, disabled }: {
}) {
const { language, saving, error, load, save } = preference;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const triggerRef = useRef<HTMLButtonElement>(null);
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
// Own ordering so clearing a search restores the catalog's order.
// cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks.
const results = query.trim() ? DICTATION_LANGUAGES
.map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) }))
.filter((item) => item.score !== null)
.sort((a, b) => b.score! - a.score!)
.map(({ item }) => item) : DICTATION_LANGUAGES;
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
useEffect(() => {
@@ -50,9 +39,7 @@ function DictationLanguagePicker({ preference, disabled }: {
if (!triggerRef.current?.contains(target)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
};
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const closeOnEscape = (event: ReactKeyboardEvent) => {
if (event.key !== "Escape") return;
@@ -79,13 +66,11 @@ function DictationLanguagePicker({ preference, disabled }: {
data-testid="dictation-language"
aria-label={`Dictation language: ${label}`}
aria-expanded={open && !disabled}
aria-haspopup="listbox"
disabled={disabled || saving}
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
onClick={() => {
setQuery("");
setOpen((value) => !value);
if (language === undefined || error) void load().catch(() => {});
if (language === undefined) void load().catch(() => {});
}}
className="h-8 min-w-0 max-w-[65%] px-2.5 font-normal"
>
@@ -97,47 +82,29 @@ function DictationLanguagePicker({ preference, disabled }: {
anchorRef={triggerRef}
testid="dictation-language-menu"
preferredSide="down"
menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
<Command
label="Dictation languages"
vimBindings={false}
shouldFilter={false}
onKeyDown={(event) => {
closeOnEscape(event);
// Keep menu navigation out of the composer's submit/shortcut
// handlers. cmdk owns selection, scrolling and ARIA state.
if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation();
}}
>
<Command.Input asChild disabled={saving} value={query} onValueChange={setQuery}>
<Input aria-label="Search dictation languages" placeholder="Search languages…" className="mb-1.5 h-8 bg-background px-2 text-xs md:text-xs" />
</Command.Input>
<Command.List className="max-h-72 overflow-y-auto px-1">
<Command.Empty className="px-2 py-3 text-center text-muted-foreground">No languages match.</Command.Empty>
{results.map((item) => <Command.Item
key={item.code}
value={item.code}
disabled={saving || language === undefined}
onSelect={() => { void pick(item.code); }}
asChild
>
<Button
type="button"
variant="ghost"
size="sm"
tabIndex={-1}
disabled={saving || language === undefined}
data-testid={`dictation-language-${item.code}`}
className="h-auto w-full justify-between whitespace-normal rounded-lg px-2 py-1.5 text-left text-xs font-normal text-card-foreground data-[selected=true]:bg-accent/40"
>
{item.label}
{language === item.code && <CheckIcon aria-hidden className="size-3.5" />}
</Button>
</Command.Item>)}
</Command.List>
</Command>
<div onKeyDown={(event) => {
closeOnEscape(event);
if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); }
}}>
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
<SearchMenuList
items={DICTATION_LANGUAGES}
itemKey={(item) => item.code}
searchTerms={(item) => [item.code, item.label]}
isSelected={(item) => item.code === language}
onPick={(item) => { void pick(item.code); }}
disabled={saving || language === undefined}
searchTestid="dictation-language-search"
listTestid="dictation-language-list"
optionTestid={(item) => `dictation-language-${item.code}`}
ariaLabel="Search dictation languages"
placeholder="Search languages…"
empty="No languages match."
renderItem={(item) => <span className="text-card-foreground">{item.label}</span>}
/>
</div>
</MenuShell>}
</>;
}