Finish Deepgram language settings with shared searchable picker
This commit is contained in:
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user