'use client'; import type * as React from 'react'; import { useMemo } from 'react'; import { Globe } from 'lucide-react'; import { Combobox, type ComboboxOption, Flag, } from '@djangocfg/ui-core/components'; import { cn } from '@djangocfg/ui-core/lib'; import { findSpeechLanguage } from '../../../input/SpeechRecognition'; import { WEB_SPEECH_LANGUAGES, countryFromTag, useResolvedLanguage, useSpeechPrefs, } from '../../../input/SpeechRecognition'; export interface ChatHeaderLanguageButtonProps { /** Override aria-label. Default "Speech language". */ ariaLabel?: string; /** * Subset of BCP-47 tags to offer. Default: every entry from the * Web Speech catalogue (~66 tags incl. regional variants). Pass a * tighter list when your backend STT only supports a subset. */ allowedTags?: string[]; /** Hide the globe-fallback icon when no flag resolves. */ hideFallbackIcon?: boolean; className?: string; } /** * Compact flag-button language picker for the chat header. Built on * top of the ui-core `` — searchable autocomplete with * flags, ~66 BCP-47 tags from the official Chrome Web Speech demo, and * a custom 28×28 trigger via `renderTrigger`. * * The selection persists into `useSpeechPrefs` (zustand+localStorage) * so `useSpeechRecognition` picks it up as the second-priority resolver * value (above i18n locale, below an explicit `language` prop). */ export function ChatHeaderLanguageButton({ ariaLabel = 'Speech language', allowedTags, hideFallbackIcon, className, }: ChatHeaderLanguageButtonProps): React.ReactElement { const prefs = useSpeechPrefs(); const active = useResolvedLanguage(); // Flatten every dialect into one Combobox option. Display name keeps // the native language label; we stash the English aliases + BCP-47 // tag + region in `description` purely as a hidden search index. // (We intentionally hide the description from the rendered row in // `renderOption` — it would just clutter the dropdown.) const options = useMemo(() => { const allow = allowedTags ? new Set(allowedTags) : null; const out: ComboboxOption[] = []; for (const lang of WEB_SPEECH_LANGUAGES) { for (const d of lang.dialects) { if (allow && !allow.has(d.code)) continue; out.push({ value: d.code, // "Русский" / "Español — Argentina" / "English — United States" label: lang.dialects.length === 1 ? lang.name : `${lang.name} — ${d.region}`, // Search-only index: English name, BCP-47 tag, ISO, region. // Lets users type "russian" / "ru-RU" / "ru" / "argentina" // and still find the row regardless of native script. description: `${lang.englishName} ${d.code} ${lang.iso} ${d.region}`.toLowerCase(), }); } } return out; }, [allowedTags]); return ( prefs.setLanguage(v || null)} placeholder={ariaLabel} searchPlaceholder="Search language…" filterFunction={(opt, search) => { const s = search.toLowerCase(); // Match label (native script), value (BCP-47), and our packed // search index in description (English name + tag + region). return ( opt.label.toLowerCase().includes(s) || opt.value.toLowerCase().includes(s) || (opt.description?.includes(s) ?? false) ); }} // Popover width follows the trigger by default (28px → narrow). // Force a usable width; the popover already portals into ui-core's // anchored-overlay tier, above the dock — no z-index override needed. contentClassName="w-[280px]" // Custom row: country flag + native language label + BCP-47 tag. // ui-core Combobox default rendering only shows label/description // — feeding the flag here keeps every list row instantly // identifiable. Fallback to a neutral globe glyph when the tag // has no resolvable country (rare: bn-BD etc. all have flags). renderOption={(option) => { const country = countryFromTag(option.value); return (
{country ? ( ) : ( )} {/* Native-script label only — BCP-47 subtitle removed to keep the dropdown visually quiet. Tag + region still live in `description` for search. */} {option.label}
); }} // Compact icon-only trigger — replaces the default wide outline // button. Stays the same 28×28 footprint as ChatHeaderActionButton // siblings (audio toggle, reset). renderTrigger={(selected, open) => { const tag = selected?.value ?? active; const country = countryFromTag(tag); const found = findSpeechLanguage(tag); // Tooltip copy: native language name + dialect region + tag. // Falls back to just the tag for unknown / custom-engine codes. const tooltipLabel = found ? `${found.language.name}${ found.language.dialects.length > 1 ? ` — ${found.dialect.region}` : '' } · ${tag}` : tag; // Note on tooltip: we intentionally use the native `title` // attribute instead of `` here. The button is already // wrapped by Combobox's `PopoverTrigger asChild`, and stacking // a second `TooltipTrigger asChild` on the same node makes // Radix Slot merge two competing refs/handlers — popover click // stops working. Native `title` is "good enough" for a single // status label and ships zero extra DOM. return ( ); }} /> ); }