Browse Source

Add a combobox component with auto-complete functionality

System233 10 months ago
parent
commit
d5b796263d

+ 6 - 0
webview-ui/src/__mocks__/lucide-react.ts

@@ -0,0 +1,6 @@
+import React from "react"
+
+export const Check = () => React.createElement("div")
+export const ChevronsUpDown = () => React.createElement("div")
+export const Loader = () => React.createElement("div")
+export const X = () => React.createElement("div")

+ 522 - 0
webview-ui/src/components/ui/combobox-primitive.tsx

@@ -0,0 +1,522 @@
+/* eslint-disable react/jsx-pascal-case */
+"use client"
+
+import * as React from "react"
+import { composeEventHandlers } from "@radix-ui/primitive"
+import { useComposedRefs } from "@radix-ui/react-compose-refs"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+import { Primitive } from "@radix-ui/react-primitive"
+import * as RovingFocusGroupPrimitive from "@radix-ui/react-roving-focus"
+import { useControllableState } from "@radix-ui/react-use-controllable-state"
+import { Command as CommandPrimitive } from "cmdk"
+
+export type ComboboxContextProps = {
+	inputValue: string
+	onInputValueChange: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
+	onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
+	open: boolean
+	onOpenChange: (open: boolean) => void
+	currentTabStopId: string | null
+	onCurrentTabStopIdChange: (currentTabStopId: string | null) => void
+	inputRef: React.RefObject<HTMLInputElement>
+	tagGroupRef: React.RefObject<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>
+	disabled?: boolean
+	required?: boolean
+} & (
+	| Required<Pick<ComboboxSingleProps, "type" | "value" | "onValueChange">>
+	| Required<Pick<ComboboxMultipleProps, "type" | "value" | "onValueChange">>
+)
+
+const ComboboxContext = React.createContext<ComboboxContextProps>({
+	type: "single",
+	value: "",
+	onValueChange: () => {},
+	inputValue: "",
+	onInputValueChange: () => {},
+	onInputBlur: () => {},
+	open: false,
+	onOpenChange: () => {},
+	currentTabStopId: null,
+	onCurrentTabStopIdChange: () => {},
+	inputRef: { current: null },
+	tagGroupRef: { current: null },
+	disabled: false,
+	required: false,
+})
+
+export const useComboboxContext = () => React.useContext(ComboboxContext)
+
+export type ComboboxType = "single" | "multiple"
+
+export interface ComboboxBaseProps
+	extends React.ComponentProps<typeof PopoverPrimitive.Root>,
+		Omit<React.ComponentProps<typeof CommandPrimitive>, "value" | "defaultValue" | "onValueChange"> {
+	type?: ComboboxType | undefined
+	inputValue?: string
+	defaultInputValue?: string
+	onInputValueChange?: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
+	onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
+	disabled?: boolean
+	required?: boolean
+}
+
+export type ComboboxValue<T extends ComboboxType = "single"> = T extends "single"
+	? string
+	: T extends "multiple"
+		? string[]
+		: never
+
+export interface ComboboxSingleProps {
+	type: "single"
+	value?: string
+	defaultValue?: string
+	onValueChange?: (value: string) => void
+}
+
+export interface ComboboxMultipleProps {
+	type: "multiple"
+	value?: string[]
+	defaultValue?: string[]
+	onValueChange?: (value: string[]) => void
+}
+
+export type ComboboxProps = ComboboxBaseProps & (ComboboxSingleProps | ComboboxMultipleProps)
+
+export const Combobox = React.forwardRef(
+	<T extends ComboboxType = "single">(
+		{
+			type = "single" as T,
+			open: openProp,
+			onOpenChange,
+			defaultOpen,
+			modal,
+			children,
+			value: valueProp,
+			defaultValue,
+			onValueChange,
+			inputValue: inputValueProp,
+			defaultInputValue,
+			onInputValueChange,
+			onInputBlur,
+			disabled,
+			required,
+			...props
+		}: ComboboxProps,
+		ref: React.ForwardedRef<React.ElementRef<typeof CommandPrimitive>>,
+	) => {
+		const [value = type === "multiple" ? [] : "", setValue] = useControllableState<ComboboxValue<T>>({
+			prop: valueProp as ComboboxValue<T>,
+			defaultProp: defaultValue as ComboboxValue<T>,
+			onChange: onValueChange as (value: ComboboxValue<T>) => void,
+		})
+		const [inputValue = "", setInputValue] = useControllableState({
+			prop: inputValueProp,
+			defaultProp: defaultInputValue,
+		})
+		const [open = false, setOpen] = useControllableState({
+			prop: openProp,
+			defaultProp: defaultOpen,
+			onChange: onOpenChange,
+		})
+		const [currentTabStopId, setCurrentTabStopId] = React.useState<string | null>(null)
+		const inputRef = React.useRef<HTMLInputElement>(null)
+		const tagGroupRef = React.useRef<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>(null)
+
+		const handleInputValueChange: ComboboxContextProps["onInputValueChange"] = React.useCallback(
+			(inputValue, reason) => {
+				setInputValue(inputValue)
+				onInputValueChange?.(inputValue, reason)
+			},
+			[setInputValue, onInputValueChange],
+		)
+
+		return (
+			<ComboboxContext.Provider
+				value={
+					{
+						type,
+						value,
+						onValueChange: setValue,
+						inputValue,
+						onInputValueChange: handleInputValueChange,
+						onInputBlur,
+						open,
+						onOpenChange: setOpen,
+						currentTabStopId,
+						onCurrentTabStopIdChange: setCurrentTabStopId,
+						inputRef,
+						tagGroupRef,
+						disabled,
+						required,
+					} as ComboboxContextProps
+				}>
+				<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={modal}>
+					<CommandPrimitive ref={ref} {...props}>
+						{children}
+						{!open && <CommandPrimitive.List aria-hidden hidden />}
+					</CommandPrimitive>
+				</PopoverPrimitive.Root>
+			</ComboboxContext.Provider>
+		)
+	},
+)
+Combobox.displayName = "Combobox"
+
+export const ComboboxTagGroup = React.forwardRef<
+	React.ElementRef<typeof RovingFocusGroupPrimitive.Root>,
+	React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Root>
+>((props, ref) => {
+	const { currentTabStopId, onCurrentTabStopIdChange, tagGroupRef, type } = useComboboxContext()
+
+	if (type !== "multiple") {
+		throw new Error('<ComboboxTagGroup> should only be used when type is "multiple"')
+	}
+
+	const composedRefs = useComposedRefs(ref, tagGroupRef)
+
+	return (
+		<RovingFocusGroupPrimitive.Root
+			ref={composedRefs}
+			tabIndex={-1}
+			currentTabStopId={currentTabStopId}
+			onCurrentTabStopIdChange={onCurrentTabStopIdChange}
+			onBlur={() => onCurrentTabStopIdChange(null)}
+			{...props}
+		/>
+	)
+})
+ComboboxTagGroup.displayName = "ComboboxTagGroup"
+
+export interface ComboboxTagGroupItemProps
+	extends React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Item> {
+	value: string
+	disabled?: boolean
+}
+
+const ComboboxTagGroupItemContext = React.createContext<Pick<ComboboxTagGroupItemProps, "value" | "disabled">>({
+	value: "",
+	disabled: false,
+})
+
+const useComboboxTagGroupItemContext = () => React.useContext(ComboboxTagGroupItemContext)
+
+export const ComboboxTagGroupItem = React.forwardRef<
+	React.ElementRef<typeof RovingFocusGroupPrimitive.Item>,
+	ComboboxTagGroupItemProps
+>(({ onClick, onKeyDown, value: valueProp, disabled, ...props }, ref) => {
+	const { value, onValueChange, inputRef, currentTabStopId, type } = useComboboxContext()
+
+	if (type !== "multiple") {
+		throw new Error('<ComboboxTagGroupItem> should only be used when type is "multiple"')
+	}
+
+	const lastItemValue = value.at(-1)
+
+	return (
+		<ComboboxTagGroupItemContext.Provider value={{ value: valueProp, disabled }}>
+			<RovingFocusGroupPrimitive.Item
+				ref={ref}
+				onKeyDown={composeEventHandlers(onKeyDown, (event) => {
+					if (event.key === "Escape") {
+						inputRef.current?.focus()
+					}
+					if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+						event.preventDefault()
+						inputRef.current?.focus()
+					}
+					if (event.key === "ArrowRight" && currentTabStopId === lastItemValue) {
+						inputRef.current?.focus()
+					}
+					if (event.key === "Backspace" || event.key === "Delete") {
+						onValueChange(value.filter((v) => v !== currentTabStopId))
+						inputRef.current?.focus()
+					}
+				})}
+				onClick={composeEventHandlers(onClick, () => disabled && inputRef.current?.focus())}
+				tabStopId={valueProp}
+				focusable={!disabled}
+				data-disabled={disabled}
+				active={valueProp === lastItemValue}
+				{...props}
+			/>
+		</ComboboxTagGroupItemContext.Provider>
+	)
+})
+ComboboxTagGroupItem.displayName = "ComboboxTagGroupItem"
+
+export const ComboboxTagGroupItemRemove = React.forwardRef<
+	React.ElementRef<typeof Primitive.button>,
+	React.ComponentPropsWithoutRef<typeof Primitive.button>
+>(({ onClick, ...props }, ref) => {
+	const { value, onValueChange, type } = useComboboxContext()
+
+	if (type !== "multiple") {
+		throw new Error('<ComboboxTagGroupItemRemove> should only be used when type is "multiple"')
+	}
+
+	const { value: valueProp, disabled } = useComboboxTagGroupItemContext()
+
+	return (
+		<Primitive.button
+			ref={ref}
+			aria-hidden
+			tabIndex={-1}
+			disabled={disabled}
+			onClick={composeEventHandlers(onClick, () => onValueChange(value.filter((v) => v !== valueProp)))}
+			{...props}
+		/>
+	)
+})
+ComboboxTagGroupItemRemove.displayName = "ComboboxTagGroupItemRemove"
+
+export const ComboboxInput = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Input>,
+	Omit<React.ComponentProps<typeof CommandPrimitive.Input>, "value" | "onValueChange">
+>(({ onKeyDown, onMouseDown, onFocus, onBlur, ...props }, ref) => {
+	const {
+		type,
+		inputValue,
+		onInputValueChange,
+		onInputBlur,
+		open,
+		onOpenChange,
+		value,
+		onValueChange,
+		inputRef,
+		disabled,
+		required,
+		tagGroupRef,
+	} = useComboboxContext()
+
+	const composedRefs = useComposedRefs(ref, inputRef)
+
+	return (
+		<CommandPrimitive.Input
+			ref={composedRefs}
+			disabled={disabled}
+			required={required}
+			value={inputValue}
+			onValueChange={(search) => {
+				if (!open) {
+					onOpenChange(true)
+				}
+				// Schedule input value change to the next tick.
+				setTimeout(() => onInputValueChange(search, "inputChange"))
+				if (!search && type === "single") {
+					onValueChange("")
+				}
+			}}
+			onKeyDown={composeEventHandlers(onKeyDown, (event) => {
+				if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+					if (!open) {
+						event.preventDefault()
+						onOpenChange(true)
+					}
+				}
+				if (type !== "multiple") {
+					return
+				}
+				if (event.key === "ArrowLeft" && !inputValue && value.length) {
+					tagGroupRef.current?.focus()
+				}
+				if (event.key === "Backspace" && !inputValue) {
+					onValueChange(value.slice(0, -1))
+				}
+			})}
+			onMouseDown={composeEventHandlers(onMouseDown, () => onOpenChange(!!inputValue || !open))}
+			onFocus={composeEventHandlers(onFocus, () => onOpenChange(true))}
+			onBlur={composeEventHandlers(onBlur, (event) => {
+				if (!event.relatedTarget?.hasAttribute("cmdk-list")) {
+					onInputBlur?.(event)
+				}
+			})}
+			{...props}
+		/>
+	)
+})
+ComboboxInput.displayName = "ComboboxInput"
+
+export const ComboboxClear = React.forwardRef<
+	React.ElementRef<typeof Primitive.button>,
+	React.ComponentPropsWithoutRef<typeof Primitive.button>
+>(({ onClick, ...props }, ref) => {
+	const { value, onValueChange, inputValue, onInputValueChange, type } = useComboboxContext()
+
+	const isValueEmpty = type === "single" ? !value : !value.length
+
+	return (
+		<Primitive.button
+			ref={ref}
+			disabled={isValueEmpty && !inputValue}
+			onClick={composeEventHandlers(onClick, () => {
+				if (type === "single") {
+					onValueChange("")
+				} else {
+					onValueChange([])
+				}
+				onInputValueChange("", "clearClick")
+			})}
+			{...props}
+		/>
+	)
+})
+ComboboxClear.displayName = "ComboboxClear"
+
+export const ComboboxTrigger = PopoverPrimitive.Trigger
+
+export const ComboboxAnchor = PopoverPrimitive.Anchor
+
+export const ComboboxPortal = PopoverPrimitive.Portal
+
+export const ComboboxContent = React.forwardRef<
+	React.ElementRef<typeof PopoverPrimitive.Content>,
+	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ children, onOpenAutoFocus, onInteractOutside, ...props }, ref) => (
+	<PopoverPrimitive.Content
+		asChild
+		ref={ref}
+		onOpenAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
+		onCloseAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
+		onInteractOutside={composeEventHandlers(onInteractOutside, (event) => {
+			if (event.target instanceof Element && event.target.hasAttribute("cmdk-input")) {
+				event.preventDefault()
+			}
+		})}
+		{...props}>
+		<CommandPrimitive.List>{children}</CommandPrimitive.List>
+	</PopoverPrimitive.Content>
+))
+ComboboxContent.displayName = "ComboboxContent"
+
+export const ComboboxEmpty = CommandPrimitive.Empty
+
+export const ComboboxLoading = CommandPrimitive.Loading
+
+export interface ComboboxItemProps extends Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>, "value"> {
+	value: string
+}
+
+const ComboboxItemContext = React.createContext({ isSelected: false })
+
+const useComboboxItemContext = () => React.useContext(ComboboxItemContext)
+
+const findComboboxItemText = (children: React.ReactNode) => {
+	let text = ""
+
+	React.Children.forEach(children, (child) => {
+		if (text) {
+			return
+		}
+
+		if (React.isValidElement<{ children: React.ReactNode }>(child)) {
+			if (child.type === ComboboxItemText) {
+				text = child.props.children as string
+			} else {
+				text = findComboboxItemText(child.props.children)
+			}
+		}
+	})
+
+	return text
+}
+
+export const ComboboxItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, ComboboxItemProps>(
+	({ value: valueProp, children, onMouseDown, ...props }, ref) => {
+		const { type, value, onValueChange, onInputValueChange, onOpenChange } = useComboboxContext()
+
+		const inputValue = React.useMemo(() => findComboboxItemText(children), [children])
+
+		const isSelected = type === "single" ? value === valueProp : value.includes(valueProp)
+
+		return (
+			<ComboboxItemContext.Provider value={{ isSelected }}>
+				<CommandPrimitive.Item
+					ref={ref}
+					onMouseDown={composeEventHandlers(onMouseDown, (event) => event.preventDefault())}
+					onSelect={() => {
+						if (type === "multiple") {
+							onValueChange(
+								value.includes(valueProp)
+									? value.filter((v) => v !== valueProp)
+									: [...value, valueProp],
+							)
+							onInputValueChange("", "itemSelect")
+						} else {
+							onValueChange(valueProp)
+							onInputValueChange(inputValue, "itemSelect")
+							// Schedule open change to the next tick.
+							setTimeout(() => onOpenChange(false))
+						}
+					}}
+					value={inputValue}
+					{...props}>
+					{children}
+				</CommandPrimitive.Item>
+			</ComboboxItemContext.Provider>
+		)
+	},
+)
+ComboboxItem.displayName = "ComboboxItem"
+
+export const ComboboxItemIndicator = React.forwardRef<
+	React.ElementRef<typeof Primitive.span>,
+	React.ComponentPropsWithoutRef<typeof Primitive.span>
+>((props, ref) => {
+	const { isSelected } = useComboboxItemContext()
+
+	if (!isSelected) {
+		return null
+	}
+
+	return <Primitive.span ref={ref} aria-hidden {...props} />
+})
+ComboboxItemIndicator.displayName = "ComboboxItemIndicator"
+
+export interface ComboboxItemTextProps extends React.ComponentPropsWithoutRef<typeof React.Fragment> {
+	children: string
+}
+
+export const ComboboxItemText = (props: ComboboxItemTextProps) => <React.Fragment {...props} />
+ComboboxItemText.displayName = "ComboboxItemText"
+
+export const ComboboxGroup = CommandPrimitive.Group
+
+export const ComboboxSeparator = CommandPrimitive.Separator
+
+const Root = Combobox
+const TagGroup = ComboboxTagGroup
+const TagGroupItem = ComboboxTagGroupItem
+const TagGroupItemRemove = ComboboxTagGroupItemRemove
+const Input = ComboboxInput
+const Clear = ComboboxClear
+const Trigger = ComboboxTrigger
+const Anchor = ComboboxAnchor
+const Portal = ComboboxPortal
+const Content = ComboboxContent
+const Empty = ComboboxEmpty
+const Loading = ComboboxLoading
+const Item = ComboboxItem
+const ItemIndicator = ComboboxItemIndicator
+const ItemText = ComboboxItemText
+const Group = ComboboxGroup
+const Separator = ComboboxSeparator
+
+export {
+	Root,
+	TagGroup,
+	TagGroupItem,
+	TagGroupItemRemove,
+	Input,
+	Clear,
+	Trigger,
+	Anchor,
+	Portal,
+	Content,
+	Empty,
+	Loading,
+	Item,
+	ItemIndicator,
+	ItemText,
+	Group,
+	Separator,
+}

+ 177 - 0
webview-ui/src/components/ui/combobox.tsx

@@ -0,0 +1,177 @@
+"use client"
+
+import * as React from "react"
+import { Slottable } from "@radix-ui/react-slot"
+import { cva } from "class-variance-authority"
+import { Check, ChevronsUpDown, Loader, X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import * as ComboboxPrimitive from "@/components/ui/combobox-primitive"
+import { badgeVariants } from "@/components/ui/badge"
+// import * as ComboboxPrimitive from "@/registry/default/ui/combobox-primitive"
+import {
+	InputBase,
+	InputBaseAdornmentButton,
+	InputBaseControl,
+	InputBaseFlexWrapper,
+	InputBaseInput,
+} from "@/components/ui/input-base"
+
+export const Combobox = ComboboxPrimitive.Root
+
+const ComboboxInputBase = React.forwardRef<
+	React.ElementRef<typeof InputBase>,
+	React.ComponentPropsWithoutRef<typeof InputBase>
+>(({ children, ...props }, ref) => (
+	<ComboboxPrimitive.Anchor asChild>
+		<InputBase ref={ref} {...props}>
+			{children}
+			<ComboboxPrimitive.Clear asChild>
+				<InputBaseAdornmentButton>
+					<X />
+				</InputBaseAdornmentButton>
+			</ComboboxPrimitive.Clear>
+			<ComboboxPrimitive.Trigger asChild>
+				<InputBaseAdornmentButton>
+					<ChevronsUpDown />
+				</InputBaseAdornmentButton>
+			</ComboboxPrimitive.Trigger>
+		</InputBase>
+	</ComboboxPrimitive.Anchor>
+))
+ComboboxInputBase.displayName = "ComboboxInputBase"
+
+export const ComboboxInput = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Input>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
+>((props, ref) => (
+	<ComboboxInputBase>
+		<InputBaseControl>
+			<ComboboxPrimitive.Input asChild>
+				<InputBaseInput ref={ref} {...props} />
+			</ComboboxPrimitive.Input>
+		</InputBaseControl>
+	</ComboboxInputBase>
+))
+ComboboxInput.displayName = "ComboboxInput"
+
+export const ComboboxTagsInput = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Input>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
+>(({ children, ...props }, ref) => (
+	<ComboboxInputBase>
+		<ComboboxPrimitive.ComboboxTagGroup asChild>
+			<InputBaseFlexWrapper className="flex items-center gap-2">
+				{children}
+				<InputBaseControl>
+					<ComboboxPrimitive.Input asChild>
+						<InputBaseInput ref={ref} {...props} />
+					</ComboboxPrimitive.Input>
+				</InputBaseControl>
+			</InputBaseFlexWrapper>
+		</ComboboxPrimitive.ComboboxTagGroup>
+	</ComboboxInputBase>
+))
+ComboboxTagsInput.displayName = "ComboboxTagsInput"
+
+export const ComboboxTag = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>
+>(({ children, className, ...props }, ref) => (
+	<ComboboxPrimitive.ComboboxTagGroupItem
+		ref={ref}
+		className={cn(
+			badgeVariants({ variant: "outline" }),
+			"group gap-1 pr-1.5 data-[disabled]:opacity-50",
+			className,
+		)}
+		{...props}>
+		<Slottable>{children}</Slottable>
+		<ComboboxPrimitive.ComboboxTagGroupItemRemove className="group-data-[disabled]:pointer-events-none">
+			<X className="size-4" />
+			<span className="sr-only">Remove</span>
+		</ComboboxPrimitive.ComboboxTagGroupItemRemove>
+	</ComboboxPrimitive.ComboboxTagGroupItem>
+))
+ComboboxTag.displayName = "ComboboxTag"
+
+export const ComboboxContent = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Content>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Content>
+>(({ className, align = "start", alignOffset = 0, ...props }, ref) => (
+	<ComboboxPrimitive.Portal>
+		<ComboboxPrimitive.Content
+			ref={ref}
+			align={align}
+			alignOffset={alignOffset}
+			className={cn(
+				"min-w-72 border-vscode-dropdown-border relative z-50 left-0 max-h-96 w-[--radix-popover-trigger-width] overflow-y-auto overflow-x-hidden rounded-xs border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+				className,
+			)}
+			{...props}
+		/>
+	</ComboboxPrimitive.Portal>
+))
+ComboboxContent.displayName = "ComboboxContent"
+
+export const ComboboxEmpty = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Empty>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Empty>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Empty ref={ref} className={cn("py-6 text-center text-sm", className)} {...props} />
+))
+ComboboxEmpty.displayName = "ComboboxEmpty"
+
+export const ComboboxLoading = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Loading>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Loading>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Loading
+		ref={ref}
+		className={cn("flex items-center justify-center px-1.5 py-2", className)}
+		{...props}>
+		<Loader className="size-4 animate-spin [mask:conic-gradient(transparent_45deg,_white)]" />
+	</ComboboxPrimitive.Loading>
+))
+ComboboxLoading.displayName = "ComboboxLoading"
+
+export const ComboboxGroup = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Group>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Group>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Group
+		ref={ref}
+		className={cn(
+			"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold",
+			className,
+		)}
+		{...props}
+	/>
+))
+ComboboxGroup.displayName = "ComboboxGroup"
+
+const ComboboxSeparator = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Separator>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
+))
+ComboboxSeparator.displayName = "ComboboxSeparator"
+
+export const comboboxItemStyle = cva(
+	"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-vscode-dropdown-foreground data-[disabled=true]:opacity-50",
+)
+
+export const ComboboxItem = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Item>,
+	Omit<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Item>, "children"> &
+		Pick<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ItemText>, "children">
+>(({ className, children, ...props }, ref) => (
+	<ComboboxPrimitive.Item ref={ref} className={cn(comboboxItemStyle(), className)} {...props}>
+		<ComboboxPrimitive.ItemText>{children}</ComboboxPrimitive.ItemText>
+		<ComboboxPrimitive.ItemIndicator className="absolute right-2 flex size-3.5 items-center justify-center">
+			<Check className="size-4" />
+		</ComboboxPrimitive.ItemIndicator>
+	</ComboboxPrimitive.Item>
+))
+ComboboxItem.displayName = "ComboboxItem"

+ 157 - 0
webview-ui/src/components/ui/input-base.tsx

@@ -0,0 +1,157 @@
+/* eslint-disable react/jsx-no-comment-textnodes */
+/* eslint-disable react/jsx-pascal-case */
+"use client"
+
+import * as React from "react"
+import { composeEventHandlers } from "@radix-ui/primitive"
+import { composeRefs } from "@radix-ui/react-compose-refs"
+import { Primitive } from "@radix-ui/react-primitive"
+import { Slot } from "@radix-ui/react-slot"
+
+import { cn } from "@/lib/utils"
+import { Button } from "./button"
+
+export type InputBaseContextProps = Pick<InputBaseProps, "autoFocus" | "disabled"> & {
+	controlRef: React.RefObject<HTMLElement>
+	onFocusedChange: (focused: boolean) => void
+}
+
+const InputBaseContext = React.createContext<InputBaseContextProps>({
+	autoFocus: false,
+	controlRef: { current: null },
+	disabled: false,
+	onFocusedChange: () => {},
+})
+
+const useInputBaseContext = () => React.useContext(InputBaseContext)
+
+export interface InputBaseProps extends React.ComponentPropsWithoutRef<typeof Primitive.div> {
+	autoFocus?: boolean
+	disabled?: boolean
+}
+
+export const InputBase = React.forwardRef<React.ElementRef<typeof Primitive.div>, InputBaseProps>(
+	({ autoFocus, disabled, className, onClick, ...props }, ref) => {
+		// eslint-disable-next-line @typescript-eslint/no-unused-vars
+		const [focused, setFocused] = React.useState(false)
+
+		const controlRef = React.useRef<HTMLElement>(null)
+
+		return (
+			<InputBaseContext.Provider
+				value={{
+					autoFocus,
+					controlRef,
+					disabled,
+					onFocusedChange: setFocused,
+				}}>
+				<Primitive.div
+					ref={ref}
+					onClick={composeEventHandlers(onClick, (event) => {
+						// Based on MUI's <InputBase /> implementation.
+						// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/InputBase/InputBase.js#L458~L460
+						if (controlRef.current && event.currentTarget === event.target) {
+							controlRef.current.focus()
+						}
+					})}
+					className={cn(
+						"flex w-full text-vscode-input-foreground border border-vscode-dropdown-border  bg-vscode-input-background rounded-xs px-3 py-0.5 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50",
+						disabled && "cursor-not-allowed opacity-50",
+						className,
+					)}
+					{...props}
+				/>
+			</InputBaseContext.Provider>
+		)
+	},
+)
+InputBase.displayName = "InputBase"
+
+export const InputBaseFlexWrapper = React.forwardRef<
+	React.ElementRef<typeof Primitive.div>,
+	React.ComponentPropsWithoutRef<typeof Primitive.div>
+>(({ className, ...props }, ref) => (
+	<Primitive.div ref={ref} className={cn("flex flex-1 flex-wrap", className)} {...props} />
+))
+InputBaseFlexWrapper.displayName = "InputBaseFlexWrapper"
+
+export const InputBaseControl = React.forwardRef<
+	React.ElementRef<typeof Slot>,
+	React.ComponentPropsWithoutRef<typeof Slot>
+>(({ onFocus, onBlur, ...props }, ref) => {
+	const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBaseContext()
+
+	return (
+		<Slot
+			ref={composeRefs(controlRef, ref)}
+			autoFocus={autoFocus}
+			onFocus={composeEventHandlers(onFocus, () => onFocusedChange(true))}
+			onBlur={composeEventHandlers(onBlur, () => onFocusedChange(false))}
+			{...{ disabled }}
+			{...props}
+		/>
+	)
+})
+InputBaseControl.displayName = "InputBaseControl"
+
+export interface InputBaseAdornmentProps extends React.ComponentPropsWithoutRef<"div"> {
+	asChild?: boolean
+	disablePointerEvents?: boolean
+}
+
+export const InputBaseAdornment = React.forwardRef<React.ElementRef<"div">, InputBaseAdornmentProps>(
+	({ className, disablePointerEvents, asChild, children, ...props }, ref) => {
+		const Comp = asChild ? Slot : typeof children === "string" ? "p" : "div"
+
+		const isAction = React.isValidElement(children) && children.type === InputBaseAdornmentButton
+
+		return (
+			<Comp
+				ref={ref}
+				className={cn(
+					"flex items-center text-muted-foreground [&_svg]:size-4",
+					(!isAction || disablePointerEvents) && "pointer-events-none",
+					className,
+				)}
+				{...props}>
+				{children}
+			</Comp>
+		)
+	},
+)
+InputBaseAdornment.displayName = "InputBaseAdornment"
+
+export const InputBaseAdornmentButton = React.forwardRef<
+	React.ElementRef<typeof Button>,
+	React.ComponentPropsWithoutRef<typeof Button>
+>(({ type = "button", variant = "ghost", size = "icon", disabled: disabledProp, className, ...props }, ref) => {
+	const { disabled } = useInputBaseContext()
+
+	return (
+		<Button
+			ref={ref}
+			type={type}
+			variant={variant}
+			size={size}
+			disabled={disabled || disabledProp}
+			className={cn("size-6", className)}
+			{...props}
+		/>
+	)
+})
+InputBaseAdornmentButton.displayName = "InputBaseAdornmentButton"
+
+export const InputBaseInput = React.forwardRef<
+	React.ElementRef<typeof Primitive.input>,
+	React.ComponentPropsWithoutRef<typeof Primitive.input>
+>(({ className, ...props }, ref) => (
+	<Primitive.input
+		ref={ref}
+		className={cn(
+			"w-full flex-1 bg-transparent file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none disabled:pointer-events-none",
+			className,
+		)}
+		{...props}
+	/>
+))
+InputBaseInput.displayName = "InputBaseInput"