a tool for shared writing and social publishing
1import { useEffect, useRef, useState } from "react";
2import * as Popover from "@radix-ui/react-popover";
3import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider";
4import { Input } from "./Input";
5
6export const Combobox = ({
7 results,
8 onSelect,
9 children,
10 onOpenChange,
11 highlighted,
12 setHighlighted,
13 searchValue,
14 setSearchValue,
15 showSearch,
16 trigger,
17 triggerClassName,
18 sideOffset,
19 open: openProp,
20}: {
21 children: React.ReactNode;
22 trigger?: React.ReactNode;
23 triggerClassName?: string;
24 results: string[];
25 onSelect?: () => void;
26 onOpenChange?: (open: boolean) => void;
27 highlighted: string | undefined;
28 setHighlighted: (h: string | undefined) => void;
29 searchValue?: string;
30 setSearchValue?: (s: string) => void;
31 showSearch?: boolean;
32 sideOffset?: number;
33 open?: boolean;
34}) => {
35 let ref = useRef<HTMLDivElement>(null);
36 let [internalOpen, setInternalOpen] = useState(false);
37 let open = openProp ?? internalOpen;
38
39 useEffect(() => {
40 if (!highlighted || !results.find((result) => result === highlighted))
41 setHighlighted(results[0]);
42 if (results.length === 1) {
43 setHighlighted(results[0]);
44 }
45 }, [results, setHighlighted, highlighted]);
46
47 useEffect(() => {
48 let listener = async (e: KeyboardEvent) => {
49 let reverseDir = ref.current?.dataset.side === "top";
50 let currentHighlightIndex = results.findIndex(
51 (result) => highlighted && result === highlighted,
52 );
53
54 if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") {
55 setHighlighted(
56 results[
57 currentHighlightIndex === results.length - 1 ||
58 currentHighlightIndex === undefined
59 ? 0
60 : currentHighlightIndex + 1
61 ],
62 );
63 return;
64 }
65 if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") {
66 setHighlighted(
67 results[
68 currentHighlightIndex === 0 ||
69 currentHighlightIndex === undefined ||
70 currentHighlightIndex === -1
71 ? results.length - 1
72 : currentHighlightIndex - 1
73 ],
74 );
75 return;
76 }
77
78 // on enter, select the highlighted item
79 if (e.key === "Enter") {
80 e.preventDefault();
81 onSelect?.();
82 setInternalOpen(false);
83 }
84 };
85
86 window.addEventListener("keydown", listener);
87
88 return () => window.removeEventListener("keydown", listener);
89 }, [highlighted, setHighlighted, results]);
90
91 return (
92 <Popover.Root
93 open={open}
94 onOpenChange={(newOpen) => {
95 setInternalOpen(newOpen);
96 onOpenChange?.(newOpen);
97 }}
98 >
99 <Popover.Trigger asChild className={`${triggerClassName}`}>
100 <div>{trigger}</div>
101 </Popover.Trigger>
102 <Popover.Portal>
103 <Popover.Content
104 align="start"
105 sideOffset={sideOffset ? sideOffset : 16}
106 collisionPadding={16}
107 ref={ref}
108 onOpenAutoFocus={(e) => e.preventDefault()}
109 className={`
110 commandMenuContent group/cmd-menu
111 z-20 w-[264px]
112 flex data-[side=top]:items-end items-start
113 `}
114 >
115 <NestedCardThemeProvider>
116 <div
117 className={`commandMenuResults w-full max-h-(--radix-popover-content-available-height) overflow-auto no-scrollbar flex flex-col group-data-[side=top]/cmd-menu:flex-col-reverse bg-bg-page gap-0.5 border border-border rounded-md shadow-md `}
118 >
119 {showSearch && setSearchValue ? (
120 <Input
121 autoFocus
122 placeholder="search…"
123 className={`px-3 pb-1 pt-1 text-primary focus-within:outline-none! focus:outline-none! focus-visible:outline-none! appearance-none bg-bg-page border-border-light border-b group-data-[side=top]/cmd-menu:border-t group-data-[side=top]/cmd-menu:border-b-0
124 sticky group-data-[side=top]/cmd-menu:bottom-0 top-0`}
125 value={searchValue}
126 onChange={(e) => setSearchValue(e.target.value)}
127 onClick={(e) => e.stopPropagation()}
128 onPointerDown={(e) => e.stopPropagation()}
129 />
130 ) : null}
131 <div className="space h-1 w-full bg-transparent" />
132 {children}
133 <div className="space h-1 w-full bg-transparent" />
134 </div>
135 </NestedCardThemeProvider>
136 </Popover.Content>
137 </Popover.Portal>
138 </Popover.Root>
139 );
140};
141
142export const ComboboxResult = (props: {
143 result: string;
144 children: React.ReactNode;
145 onSelect: () => void;
146 highlighted: string | undefined;
147 setHighlighted: (state: string | undefined) => void;
148 className?: string;
149}) => {
150 let isHighlighted = props.highlighted === props.result;
151
152 return (
153 <button
154 className={`comboboxResult menuItem text-secondary font-normal! py-0.5! mx-1 ${props.className} ${isHighlighted && "bg-[var(--accent-light)]!"}`}
155 onMouseOver={() => {
156 props.setHighlighted(props.result);
157 }}
158 onMouseDown={(e) => {
159 e.preventDefault();
160 props.onSelect();
161 }}
162 >
163 <div className="truncate flex items-center">{props.children}</div>
164 </button>
165 );
166};