1import { CheckIcon, ChevronDownIcon } from "lucide-react";
2import { useMemo, useState } from "react";
3
4import {
5 CODE_BLOCK_LANGUAGES,
6 type CodeBlockLanguage,
7 codeLanguageEntry,
8 codeLanguageLabel,
9} from "~/composer-code-languages";
10import { syntheticFileNameForLanguageId } from "~/pierre-icons";
11
12import { Button } from "../ui/button";
13import {
14 Combobox,
15 ComboboxEmpty,
16 ComboboxItem,
17 ComboboxList,
18 ComboboxPopup,
19 ComboboxSearchInput,
20 ComboboxTrigger,
21} from "../ui/combobox";
22import { 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 */
29function 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 */
42export 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}