| 1 | import { CheckIcon, ChevronDownIcon } from "lucide-react"; |
| 2 | import { useMemo, useState } from "react"; |
| 3 | |
| 4 | import { |
| 5 | CODE_BLOCK_LANGUAGES, |
| 6 | type CodeBlockLanguage, |
| 7 | codeLanguageEntry, |
| 8 | codeLanguageLabel, |
| 9 | } from "~/composer-code-languages"; |
| 10 | import { syntheticFileNameForLanguageId } from "~/pierre-icons"; |
| 11 | |
| 12 | import { Button } from "../ui/button"; |
| 13 | import { |
| 14 | Combobox, |
| 15 | ComboboxEmpty, |
| 16 | ComboboxItem, |
| 17 | ComboboxList, |
| 18 | ComboboxPopup, |
| 19 | ComboboxSearchInput, |
| 20 | ComboboxTrigger, |
| 21 | } from "../ui/combobox"; |
| 22 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 23 | |
| 24 | /** |
| 25 | * The language's file icon: plain text gets the text-file icon, and a |
| 26 | * language the icon set does not cover gets the default file icon, so every |
| 27 | * entry has one and the labels line up. |
| 28 | */ |
| 29 | function LanguageIcon(props: { language: string; theme: "light" | "dark" }) { |
| 30 | const fileName = props.language ? syntheticFileNameForLanguageId(props.language) : "file.txt"; |
| 31 | return ( |
| 32 | <PierreEntryIcon pathValue={fileName} kind="file" theme={props.theme} className="size-3.5" /> |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * The language control in a composer fence's header: shows what the fence |
| 38 | * declares and switches it from a searchable list. A language typed on the |
| 39 | * fence line that the list does not carry is offered as its own entry, so |
| 40 | * the current choice is always visible and checked. |
| 41 | */ |
| 42 | export function ComposerCodeBlockLanguagePicker(props: { |
| 43 | language: string; |
| 44 | theme: "light" | "dark"; |
| 45 | disabled: boolean; |
| 46 | onChange: (language: string) => void; |
| 47 | }) { |
| 48 | const [query, setQuery] = useState(""); |
| 49 | const current = codeLanguageEntry(props.language)?.id ?? props.language; |
| 50 | const items = useMemo<CodeBlockLanguage[]>( |
| 51 | () => |
| 52 | CODE_BLOCK_LANGUAGES.some((entry) => entry.id === current) |
| 53 | ? [...CODE_BLOCK_LANGUAGES] |
| 54 | : [{ id: current, label: current }, ...CODE_BLOCK_LANGUAGES], |
| 55 | [current], |
| 56 | ); |
| 57 | const filteredItems = useMemo(() => { |
| 58 | const needle = query.trim().toLowerCase(); |
| 59 | if (!needle) return items; |
| 60 | return items.filter( |
| 61 | (entry) => entry.label.toLowerCase().includes(needle) || entry.id.includes(needle), |
| 62 | ); |
| 63 | }, [items, query]); |
| 64 | const selected = items.find((entry) => entry.id === current) ?? null; |
| 65 | |
| 66 | return ( |
| 67 | // The trigger's pill pads its content by 8px (padding plus border) and the |
| 68 | // button pulls a leading icon back 2px; offsetting the difference lines |
| 69 | // the icon up with the code below while keeping the hover pill. |
| 70 | <span className="-ml-1.5 flex min-w-0"> |
| 71 | <Combobox |
| 72 | items={items} |
| 73 | filteredItems={filteredItems} |
| 74 | autoHighlight |
| 75 | itemToStringLabel={(entry) => entry.label} |
| 76 | isItemEqualToValue={(a, b) => a.id === b.id} |
| 77 | value={selected} |
| 78 | onOpenChange={(open) => { |
| 79 | if (!open) setQuery(""); |
| 80 | }} |
| 81 | onValueChange={(entry) => { |
| 82 | if (entry && entry.id !== current) props.onChange(entry.id); |
| 83 | }} |
| 84 | > |
| 85 | <ComboboxTrigger |
| 86 | render={ |
| 87 | <Button |
| 88 | type="button" |
| 89 | variant="ghost-muted" |
| 90 | size="xs" |
| 91 | disabled={props.disabled} |
| 92 | aria-label={`Code language: ${codeLanguageLabel(props.language)}`} |
| 93 | /> |
| 94 | } |
| 95 | > |
| 96 | <LanguageIcon language={current} theme={props.theme} /> |
| 97 | <span className="max-w-40 truncate">{codeLanguageLabel(props.language)}</span> |
| 98 | <ChevronDownIcon className="opacity-60" /> |
| 99 | </ComboboxTrigger> |
| 100 | <ComboboxPopup align="start" className="w-56"> |
| 101 | <ComboboxSearchInput |
| 102 | aria-label="Search languages" |
| 103 | placeholder="Search languages" |
| 104 | value={query} |
| 105 | onChange={(event) => setQuery(event.target.value)} |
| 106 | /> |
| 107 | <ComboboxEmpty>No matching languages.</ComboboxEmpty> |
| 108 | <ComboboxList> |
| 109 | {(entry: CodeBlockLanguage) => ( |
| 110 | <ComboboxItem key={entry.id || "plain"} value={entry}> |
| 111 | <LanguageIcon language={entry.id} theme={props.theme} /> |
| 112 | <span className="min-w-0 flex-1 truncate">{entry.label}</span> |
| 113 | {entry.id === current ? <CheckIcon className="ml-auto" /> : null} |
| 114 | </ComboboxItem> |
| 115 | )} |
| 116 | </ComboboxList> |
| 117 | </ComboboxPopup> |
| 118 | </Combobox> |
| 119 | </span> |
| 120 | ); |
| 121 | } |