Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/prebuilds/configuration-input/Combobox.tsx
2501 views
1
/**
2
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
import * as RadixPopover from "@radix-ui/react-popover";
9
import { ChevronDown, CircleDashed } from "lucide-react";
10
import { cn } from "@podkit/lib/cn";
11
import { ComboboxItem } from "./ComboboxItem";
12
13
export interface ComboboxElement {
14
id: string;
15
element: JSX.Element;
16
isSelectable?: boolean;
17
}
18
19
export interface Props {
20
initialValue?: string;
21
getElements: (searchString: string) => ComboboxElement[];
22
disabled?: boolean;
23
loading?: boolean;
24
searchPlaceholder?: string;
25
disableSearch?: boolean;
26
expanded?: boolean;
27
className?: string;
28
dropDownClassName?: string;
29
itemClassName?: string;
30
onSelectionChange: (id: string) => void;
31
// Meant to allow consumers to react to search changes even though state is managed internally
32
onSearchChange?: (searchString: string) => void;
33
}
34
export const Combobox: FunctionComponent<Props> = ({
35
initialValue = "",
36
disabled = false,
37
loading = false,
38
expanded = false,
39
searchPlaceholder,
40
getElements,
41
disableSearch,
42
children,
43
className,
44
dropDownClassName,
45
itemClassName,
46
onSelectionChange,
47
onSearchChange,
48
}) => {
49
const inputEl = useRef<HTMLInputElement>(null);
50
const [showDropDown, setShowDropDown] = useState<boolean>(!disabled && !!expanded);
51
const [search, setSearch] = useState<string>("");
52
const filteredOptions = useMemo(() => getElements(search), [getElements, search]);
53
const [selectedElementTemp, setSelectedElementTemp] = useState<string | undefined>(
54
initialValue || filteredOptions[0]?.id,
55
);
56
57
const onSelected = useCallback(
58
(elementId: string) => {
59
onSelectionChange(elementId);
60
setShowDropDown(false);
61
},
62
[onSelectionChange],
63
);
64
65
// scroll to selected item when opened
66
useEffect(() => {
67
if (showDropDown && selectedElementTemp) {
68
setTimeout(() => {
69
document.getElementById(selectedElementTemp)?.scrollIntoView({ block: "nearest" });
70
}, 0);
71
}
72
// eslint-disable-next-line react-hooks/exhaustive-deps
73
}, [showDropDown]);
74
75
const updateSearch = useCallback(
76
(value: string) => {
77
setSearch(value);
78
onSearchChange?.(value);
79
},
80
[onSearchChange],
81
);
82
83
const handleInputChange = useCallback((e) => updateSearch(e.target.value), [updateSearch]);
84
85
const setActiveElement = useCallback(
86
(element: string) => {
87
setSelectedElementTemp(element);
88
const el = document.getElementById(element);
89
el?.scrollIntoView({ block: "nearest" });
90
},
91
[setSelectedElementTemp],
92
);
93
94
const handleOpenChange = useCallback(
95
(open: boolean) => {
96
updateSearch("");
97
setShowDropDown(open);
98
},
99
[updateSearch],
100
);
101
102
const focusNextElement = useCallback(() => {
103
let idx = filteredOptions.findIndex((e) => e.id === selectedElementTemp);
104
while (idx++ < filteredOptions.length - 1) {
105
const candidate = filteredOptions[idx];
106
if (candidate.isSelectable) {
107
setActiveElement(candidate.id);
108
return;
109
}
110
}
111
}, [filteredOptions, selectedElementTemp, setActiveElement]);
112
113
const focusPreviousElement = useCallback(() => {
114
let idx = filteredOptions.findIndex((e) => e.id === selectedElementTemp);
115
116
while (idx-- > 0) {
117
const candidate = filteredOptions[idx];
118
if (candidate.isSelectable) {
119
setActiveElement(candidate.id);
120
return;
121
}
122
}
123
}, [filteredOptions, selectedElementTemp, setActiveElement]);
124
125
const onKeyDown = useCallback(
126
(e: React.KeyboardEvent) => {
127
if (e.key === "ArrowDown") {
128
e.preventDefault();
129
focusNextElement();
130
return;
131
}
132
if (e.key === "ArrowUp") {
133
e.preventDefault();
134
focusPreviousElement();
135
return;
136
}
137
if (e.key === "Tab") {
138
e.preventDefault();
139
if (e.shiftKey) {
140
focusPreviousElement();
141
e.stopPropagation();
142
} else {
143
focusNextElement();
144
}
145
return;
146
}
147
// Capture escape ourselves instead of letting radix do it
148
// allows us to close the dropdown and preventDefault on event
149
if (e.key === "Escape") {
150
setShowDropDown(false);
151
e.preventDefault();
152
}
153
if (e.key === "Enter") {
154
if (selectedElementTemp && filteredOptions.some((e) => e.id === selectedElementTemp)) {
155
e.preventDefault();
156
onSelected(selectedElementTemp);
157
}
158
}
159
if (e.key === " " && search === "") {
160
handleOpenChange(false);
161
e.preventDefault();
162
}
163
},
164
[
165
filteredOptions,
166
focusNextElement,
167
focusPreviousElement,
168
handleOpenChange,
169
onSelected,
170
search,
171
selectedElementTemp,
172
],
173
);
174
175
const showInputLoadingIndicator = filteredOptions.length > 0 && loading;
176
const showResultsLoadingIndicator = filteredOptions.length === 0 && loading;
177
178
return (
179
<RadixPopover.Root defaultOpen={expanded} open={showDropDown} onOpenChange={handleOpenChange}>
180
<RadixPopover.Trigger
181
disabled={disabled}
182
className={cn(
183
"w-48 h-9 bg-pk-surface-primary hover:bg-pk-surface-primary flex flex-row items-center justify-start px-2 text-left border border-pk-border-base text-sm text-pk-content-primary",
184
// when open, just have border radius on top
185
showDropDown ? "rounded-none rounded-t-lg" : "rounded-lg",
186
// Dropshadow when expanded
187
showDropDown && "filter drop-shadow-xl",
188
// hover when not disabled or expanded
189
!showDropDown && !disabled && "cursor-pointer",
190
// opacity when disabled
191
disabled && "opacity-70",
192
className,
193
)}
194
>
195
{children}
196
<div className="flex-grow" />
197
<div
198
className={cn(
199
"mr-2 text-pk-content-secondary transition-transform",
200
showDropDown && "rotate-180 transition-all",
201
)}
202
>
203
<ChevronDown className="h-4 w-4 text-pk-content-disabled" />
204
</div>
205
</RadixPopover.Trigger>
206
<RadixPopover.Portal>
207
<RadixPopover.Content
208
className={cn(
209
"rounded-b-lg p-2 filter drop-shadow-xl z-50 outline-none",
210
"bg-pk-surface-primary",
211
"text-pk-content-primary",
212
"w-[--radix-popover-trigger-width]",
213
dropDownClassName,
214
)}
215
onKeyDown={onKeyDown}
216
>
217
{!disableSearch && (
218
<div className="relative mb-2">
219
<input
220
ref={inputEl}
221
type="text"
222
autoFocus
223
className={"w-full focus rounded-lg"}
224
placeholder={searchPlaceholder}
225
value={search}
226
onChange={handleInputChange}
227
/>
228
{showInputLoadingIndicator && (
229
<div className="absolute top-0 right-0 h-full flex items-center pr-2 animate-fade-in-fast">
230
<CircleDashed className="opacity-10 animate-spin-slow" />
231
</div>
232
)}
233
</div>
234
)}
235
<ul className="max-h-60 overflow-auto">
236
{showResultsLoadingIndicator && (
237
<div className="flex-col space-y-2 animate-pulse">
238
<div className="bg-pk-content-tertiary/25 h-5 rounded" />
239
<div className="bg-pk-content-tertiary/25 h-5 rounded" />
240
</div>
241
)}
242
{!showResultsLoadingIndicator && filteredOptions.length > 0 ? (
243
filteredOptions.map((element) => {
244
return (
245
<ComboboxItem
246
key={element.id}
247
element={element}
248
isActive={element.id === selectedElementTemp}
249
className={itemClassName}
250
onSelected={onSelected}
251
onFocused={setActiveElement}
252
/>
253
);
254
})
255
) : !showResultsLoadingIndicator ? (
256
<li key="no-elements" className={"rounded-md "}>
257
<div className="h-12 pl-8 py-3 text-pk-content-secondary">No results</div>
258
</li>
259
) : null}
260
</ul>
261
</RadixPopover.Content>
262
</RadixPopover.Portal>
263
</RadixPopover.Root>
264
);
265
};
266
267