diff --git a/webview-ui/src/__mocks__/lucide-react.ts b/webview-ui/src/__mocks__/lucide-react.ts new file mode 100644 index 0000000000..d85cd25d6a --- /dev/null +++ b/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") diff --git a/webview-ui/src/components/ui/combobox-primitive.tsx b/webview-ui/src/components/ui/combobox-primitive.tsx new file mode 100644 index 0000000000..13bad87aba --- /dev/null +++ b/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) => void + open: boolean + onOpenChange: (open: boolean) => void + currentTabStopId: string | null + onCurrentTabStopIdChange: (currentTabStopId: string | null) => void + inputRef: React.RefObject + tagGroupRef: React.RefObject> + disabled?: boolean + required?: boolean +} & ( + | Required> + | Required> +) + +const ComboboxContext = React.createContext({ + 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, + Omit, "value" | "defaultValue" | "onValueChange"> { + type?: ComboboxType | undefined + inputValue?: string + defaultInputValue?: string + onInputValueChange?: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void + onInputBlur?: (e: React.FocusEvent) => void + disabled?: boolean + required?: boolean +} + +export type ComboboxValue = 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( + ( + { + 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>, + ) => { + const [value = type === "multiple" ? [] : "", setValue] = useControllableState>({ + prop: valueProp as ComboboxValue, + defaultProp: defaultValue as ComboboxValue, + onChange: onValueChange as (value: ComboboxValue) => 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(null) + const inputRef = React.useRef(null) + const tagGroupRef = React.useRef>(null) + + const handleInputValueChange: ComboboxContextProps["onInputValueChange"] = React.useCallback( + (inputValue, reason) => { + setInputValue(inputValue) + onInputValueChange?.(inputValue, reason) + }, + [setInputValue, onInputValueChange], + ) + + return ( + + + + {children} + {!open && + + + ) + }, +) +Combobox.displayName = "Combobox" + +export const ComboboxTagGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + const { currentTabStopId, onCurrentTabStopIdChange, tagGroupRef, type } = useComboboxContext() + + if (type !== "multiple") { + throw new Error(' should only be used when type is "multiple"') + } + + const composedRefs = useComposedRefs(ref, tagGroupRef) + + return ( + onCurrentTabStopIdChange(null)} + {...props} + /> + ) +}) +ComboboxTagGroup.displayName = "ComboboxTagGroup" + +export interface ComboboxTagGroupItemProps + extends React.ComponentPropsWithoutRef { + value: string + disabled?: boolean +} + +const ComboboxTagGroupItemContext = React.createContext>({ + value: "", + disabled: false, +}) + +const useComboboxTagGroupItemContext = () => React.useContext(ComboboxTagGroupItemContext) + +export const ComboboxTagGroupItem = React.forwardRef< + React.ElementRef, + ComboboxTagGroupItemProps +>(({ onClick, onKeyDown, value: valueProp, disabled, ...props }, ref) => { + const { value, onValueChange, inputRef, currentTabStopId, type } = useComboboxContext() + + if (type !== "multiple") { + throw new Error(' should only be used when type is "multiple"') + } + + const lastItemValue = value.at(-1) + + return ( + + { + 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} + /> + + ) +}) +ComboboxTagGroupItem.displayName = "ComboboxTagGroupItem" + +export const ComboboxTagGroupItemRemove = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ onClick, ...props }, ref) => { + const { value, onValueChange, type } = useComboboxContext() + + if (type !== "multiple") { + throw new Error(' should only be used when type is "multiple"') + } + + const { value: valueProp, disabled } = useComboboxTagGroupItemContext() + + return ( + onValueChange(value.filter((v) => v !== valueProp)))} + {...props} + /> + ) +}) +ComboboxTagGroupItemRemove.displayName = "ComboboxTagGroupItemRemove" + +export const ComboboxInput = React.forwardRef< + React.ElementRef, + Omit, "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 ( + { + 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, + React.ComponentPropsWithoutRef +>(({ onClick, ...props }, ref) => { + const { value, onValueChange, inputValue, onInputValueChange, type } = useComboboxContext() + + const isValueEmpty = type === "single" ? !value : !value.length + + return ( + { + 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, + React.ComponentPropsWithoutRef +>(({ children, onOpenAutoFocus, onInteractOutside, ...props }, ref) => ( + 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}> + {children} + +)) +ComboboxContent.displayName = "ComboboxContent" + +export const ComboboxEmpty = CommandPrimitive.Empty + +export const ComboboxLoading = CommandPrimitive.Loading + +export interface ComboboxItemProps extends Omit, "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, 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 ( + + 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} + + + ) + }, +) +ComboboxItem.displayName = "ComboboxItem" + +export const ComboboxItemIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + const { isSelected } = useComboboxItemContext() + + if (!isSelected) { + return null + } + + return +}) +ComboboxItemIndicator.displayName = "ComboboxItemIndicator" + +export interface ComboboxItemTextProps extends React.ComponentPropsWithoutRef { + children: string +} + +export const ComboboxItemText = (props: ComboboxItemTextProps) => +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, +} diff --git a/webview-ui/src/components/ui/combobox.tsx b/webview-ui/src/components/ui/combobox.tsx new file mode 100644 index 0000000000..24b2f7be1f --- /dev/null +++ b/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, + React.ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + + {children} + + + + + + + + + + + + +)) +ComboboxInputBase.displayName = "ComboboxInputBase" + +export const ComboboxInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + + + + + + +)) +ComboboxInput.displayName = "ComboboxInput" + +export const ComboboxTagsInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + + + {children} + + + + + + + + +)) +ComboboxTagsInput.displayName = "ComboboxTagsInput" + +export const ComboboxTag = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, className, ...props }, ref) => ( + + {children} + + + Remove + + +)) +ComboboxTag.displayName = "ComboboxTag" + +export const ComboboxContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "start", alignOffset = 0, ...props }, ref) => ( + + + +)) +ComboboxContent.displayName = "ComboboxContent" + +export const ComboboxEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ComboboxEmpty.displayName = "ComboboxEmpty" + +export const ComboboxLoading = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ComboboxLoading.displayName = "ComboboxLoading" + +export const ComboboxGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ComboboxGroup.displayName = "ComboboxGroup" + +const ComboboxSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, + Omit, "children"> & + Pick, "children"> +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)) +ComboboxItem.displayName = "ComboboxItem" diff --git a/webview-ui/src/components/ui/input-base.tsx b/webview-ui/src/components/ui/input-base.tsx new file mode 100644 index 0000000000..9dbda6eb13 --- /dev/null +++ b/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 & { + controlRef: React.RefObject + onFocusedChange: (focused: boolean) => void +} + +const InputBaseContext = React.createContext({ + autoFocus: false, + controlRef: { current: null }, + disabled: false, + onFocusedChange: () => {}, +}) + +const useInputBaseContext = () => React.useContext(InputBaseContext) + +export interface InputBaseProps extends React.ComponentPropsWithoutRef { + autoFocus?: boolean + disabled?: boolean +} + +export const InputBase = React.forwardRef, 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(null) + + return ( + + { + // Based on MUI's 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} + /> + + ) + }, +) +InputBase.displayName = "InputBase" + +export const InputBaseFlexWrapper = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +InputBaseFlexWrapper.displayName = "InputBaseFlexWrapper" + +export const InputBaseControl = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ onFocus, onBlur, ...props }, ref) => { + const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBaseContext() + + return ( + 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, 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 ( + + {children} + + ) + }, +) +InputBaseAdornment.displayName = "InputBaseAdornment" + +export const InputBaseAdornmentButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ type = "button", variant = "ghost", size = "icon", disabled: disabledProp, className, ...props }, ref) => { + const { disabled } = useInputBaseContext() + + return ( +