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