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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createDictationLanguageStore(request: typeof fetch, saved: () =>
|
||||
function load(refresh = false): Promise<DictationLanguage> {
|
||||
if (writing) return getLanguage();
|
||||
if (reading && !refresh) return reading;
|
||||
if (!refresh && !needsRead && snapshot.language !== undefined) return Promise.resolve(snapshot.language);
|
||||
const version = ++revision;
|
||||
needsRead = true;
|
||||
reading = Promise.resolve().then(async () => {
|
||||
@@ -118,7 +119,9 @@ function subscribe(listener: () => void) {
|
||||
stopSignals = onSettingsSaved((section) => {
|
||||
if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {});
|
||||
});
|
||||
if (!store.getSnapshot().saving) void store.load(true).catch(() => {});
|
||||
// A confirmed preference lives for the window, not the page. Navigation
|
||||
// and reopening Settings must not add a new network failure point.
|
||||
void store.getLanguage().catch(() => {});
|
||||
}
|
||||
return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } };
|
||||
}
|
||||
|
||||
+12
-12
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
|
||||
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||
import { setDictationActive } from "@/components/dictation-state";
|
||||
@@ -676,11 +675,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<Hint label={error ?? "Dictate"} action={error ? undefined : "toggle-dictation"} side="top">
|
||||
<Button
|
||||
<Hint
|
||||
// The error line truncates in the button; its tooltip reveals it.
|
||||
label={error ?? "Dictate"}
|
||||
action={error ? undefined : "toggle-dictation"}
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (keyless) {
|
||||
clearError();
|
||||
@@ -691,11 +693,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
}}
|
||||
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
||||
data-testid="dictation-toggle"
|
||||
className="h-7 w-full min-w-0 gap-1.5 overflow-hidden border-border/50 bg-card/50 font-normal text-muted-foreground/70 shadow-lg backdrop-blur hover:border-border hover:bg-card/50 hover:text-muted-foreground dark:bg-card/50 dark:hover:bg-card/50"
|
||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
||||
>
|
||||
<MicIcon className="size-3.5" />
|
||||
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
||||
</Button>
|
||||
</button>
|
||||
</Hint>
|
||||
);
|
||||
}
|
||||
@@ -703,19 +705,17 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||
<Hint label="Stop dictation (Esc)" side="top">
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={toggle}
|
||||
disabled={phase === "stopping"}
|
||||
aria-label="Stop dictation"
|
||||
data-testid="dictation-toggle"
|
||||
className="h-7 gap-1.5 bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
||||
>
|
||||
{phase === "stopping" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
|
||||
Stop
|
||||
</Button>
|
||||
</button>
|
||||
</Hint>
|
||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
||||
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||
import { ProviderMenuList } from "./model-menu.tsx";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
import { DictationLanguageSetting } from "@/components/dictation-language";
|
||||
|
||||
/** One pi-ai provider with plain API-key auth (the Platform seam —
|
||||
* derived from pi-ai's own env-key table; no drift possible). */
|
||||
@@ -350,6 +351,9 @@ export function KeysSection({
|
||||
if (openRow === null && adding === null) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// A language dropdown above this editor in the dismissal stack owns
|
||||
// the first Escape; do not swallow it at document capture phase.
|
||||
if (document.querySelector('[data-testid="dictation-language-menu"]')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenRow(null);
|
||||
@@ -486,6 +490,7 @@ export function KeysSection({
|
||||
testid={`key-${status.id.toLowerCase()}`}
|
||||
/>
|
||||
))}
|
||||
{label === "Dictation" && <DictationLanguageSetting />}
|
||||
</SettingsGroup>)}
|
||||
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
import { SearchMenuList } from "./search-menu-list";
|
||||
|
||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
||||
@@ -53,90 +54,34 @@ export function ModelMenuList({
|
||||
/** Quiet footer note under the list (context-specific). */
|
||||
footer?: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Keep the arrow-walked result visible in the scrolling list.
|
||||
useEffect(() => {
|
||||
if (menuIdx === null) return;
|
||||
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [menuIdx]);
|
||||
|
||||
// The filtered, ranked model list.
|
||||
const results = (models ?? [])
|
||||
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
||||
.filter((r) => r.s !== null)
|
||||
.sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id))
|
||||
.map((r) => r.m);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={searchRef}
|
||||
data-testid="model-search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setMenuIdx(null); // new query → the walk restarts on Enter/↓
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (results.length === 0) return;
|
||||
setMenuIdx((i) =>
|
||||
e.key === "ArrowDown"
|
||||
? Math.min((i ?? -1) + 1, results.length - 1)
|
||||
: Math.max((i ?? results.length) - 1, 0),
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
const chosen = results[menuIdx ?? 0];
|
||||
if (chosen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPick(chosen);
|
||||
}
|
||||
}
|
||||
}}
|
||||
<SearchMenuList
|
||||
items={models}
|
||||
itemKey={(m) => `${m.provider}/${m.id}`}
|
||||
searchTerms={(m) => [m.provider, m.id, m.name]}
|
||||
compare={(a, b) => a.id.localeCompare(b.id)}
|
||||
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
||||
onPick={onPick}
|
||||
searchTestid="model-search"
|
||||
listTestid="model-list"
|
||||
optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||
placeholder="Search models…"
|
||||
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"
|
||||
loading="Loading models…"
|
||||
empty="No models match."
|
||||
renderItem={(m) => <>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
|
||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</>}
|
||||
/>
|
||||
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
|
||||
{models && results.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
|
||||
)}
|
||||
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||
{results.map((m, i) => (
|
||||
<li key={`${m.provider}/${m.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||
data-idx={i}
|
||||
onClick={() => onPick(m)}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||
}${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
|
||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{footer && (
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { fuzzyScore } from "@/src/core";
|
||||
|
||||
/** The model picker's search, arrow-walk, row styling and scrolling machine.
|
||||
* Models and dictation languages supply only their catalog and row content.
|
||||
* The caller owns the shared MenuShell and dismissal behavior. */
|
||||
export function SearchMenuList<T>({
|
||||
items, itemKey, searchTerms, compare, isSelected, onPick, renderItem,
|
||||
searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty,
|
||||
disabled = false,
|
||||
}: {
|
||||
items: readonly T[] | undefined;
|
||||
itemKey: (item: T) => string;
|
||||
searchTerms: (item: T) => string[];
|
||||
compare?: (a: T, b: T) => number;
|
||||
isSelected: (item: T) => boolean;
|
||||
onPick: (item: T) => void;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
searchTestid: string;
|
||||
listTestid: string;
|
||||
optionTestid: (item: T) => string;
|
||||
placeholder: string;
|
||||
ariaLabel?: string;
|
||||
loading?: string;
|
||||
empty: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => { searchRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
if (menuIdx === null) return;
|
||||
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [menuIdx]);
|
||||
|
||||
const results = (items ?? [])
|
||||
.map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) }))
|
||||
.filter((result) => result.score !== null)
|
||||
.sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0))
|
||||
.map(({ item }) => item);
|
||||
|
||||
return <>
|
||||
<input
|
||||
type="text"
|
||||
ref={searchRef}
|
||||
data-testid={searchTestid}
|
||||
aria-label={ariaLabel}
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setQuery(event.target.value); setMenuIdx(null); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (disabled || results.length === 0) return;
|
||||
setMenuIdx((i) => event.key === "ArrowDown"
|
||||
? Math.min((i ?? -1) + 1, results.length - 1)
|
||||
: Math.max((i ?? results.length) - 1, 0));
|
||||
} else if (event.key === "Enter") {
|
||||
const chosen = results[menuIdx ?? 0];
|
||||
if (!disabled && chosen !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onPick(chosen);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
{!items && loading && <div className="px-2 py-3 text-center text-muted-foreground">{loading}</div>}
|
||||
{items && results.length === 0 && <div className="px-2 py-3 text-center text-muted-foreground">{empty}</div>}
|
||||
<ul ref={listRef} data-testid={listTestid} className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||
{results.map((item, i) => <li key={itemKey(item)}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={optionTestid(item)}
|
||||
data-idx={i}
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(item)}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||
}${isSelected(item) && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||
>{renderItem(item)}</button>
|
||||
</li>)}
|
||||
</ul>
|
||||
</>;
|
||||
}
|
||||
Reference in New Issue
Block a user