Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
838 lines
35 KiB
TypeScript
838 lines
35 KiB
TypeScript
import { Check, ChevronDown, Plus, X } from "lucide-react"
|
|
import * as React from "react"
|
|
import { cn } from "../../lib/utils"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
|
|
|
export interface ComboboxOption {
|
|
value: string;
|
|
label: string;
|
|
sublabel?: string;
|
|
icon?: React.ReactNode;
|
|
labelStyle?: React.CSSProperties;
|
|
}
|
|
|
|
interface ComboboxProps {
|
|
options: ComboboxOption[];
|
|
value?: string;
|
|
onValueChange?: (value: string) => void;
|
|
placeholder?: string;
|
|
emptyText?: string;
|
|
allowCreate?: boolean;
|
|
onCreateNew?: (value: string) => void;
|
|
createText?: string;
|
|
icon?: React.ReactNode;
|
|
className?: string;
|
|
triggerClassName?: string;
|
|
inputStyle?: React.CSSProperties;
|
|
onInputValueChange?: (value: string) => void;
|
|
disabled?: boolean;
|
|
clearable?: boolean;
|
|
selectValueOnFocus?: boolean;
|
|
ariaLabel?: string;
|
|
}
|
|
|
|
export const comboboxWheelDeltaToPixels = (deltaY: number, deltaMode: number): number => {
|
|
if (deltaMode === 1) return deltaY * 16
|
|
if (deltaMode === 2) return deltaY * 280
|
|
return deltaY
|
|
}
|
|
|
|
export type ComboboxScrollableTarget = {
|
|
clientHeight: number;
|
|
scrollHeight: number;
|
|
scrollTop: number;
|
|
}
|
|
|
|
export const filterComboboxOptions = (
|
|
options: ComboboxOption[],
|
|
inputValue: string,
|
|
isSearching: boolean,
|
|
): ComboboxOption[] => {
|
|
if (!isSearching || !inputValue.trim()) return options
|
|
const lower = inputValue.trim().toLowerCase()
|
|
return options.filter(
|
|
(option) =>
|
|
option.label.toLowerCase().includes(lower) ||
|
|
option.value.toLowerCase().includes(lower) ||
|
|
option.sublabel?.toLowerCase().includes(lower)
|
|
)
|
|
}
|
|
|
|
export const applyComboboxWheelScroll = (
|
|
target: ComboboxScrollableTarget,
|
|
deltaY: number,
|
|
deltaMode: number,
|
|
): boolean => {
|
|
if (target.scrollHeight <= target.clientHeight) return false
|
|
|
|
target.scrollTop += comboboxWheelDeltaToPixels(deltaY, deltaMode)
|
|
return true
|
|
}
|
|
|
|
export const getNextComboboxActiveIndex = (
|
|
currentIndex: number,
|
|
optionCount: number,
|
|
direction: 1 | -1,
|
|
): number => {
|
|
if (optionCount <= 0) return -1
|
|
if (currentIndex < 0 || currentIndex >= optionCount) {
|
|
return direction === 1 ? 0 : optionCount - 1
|
|
}
|
|
return (currentIndex + direction + optionCount) % optionCount
|
|
}
|
|
|
|
export type ComboboxFocusableInput = Pick<HTMLInputElement, "focus" | "select">;
|
|
|
|
export const focusComboboxInput = (
|
|
input: ComboboxFocusableInput | null,
|
|
selectValue: boolean,
|
|
): void => {
|
|
input?.focus()
|
|
if (selectValue) input?.select()
|
|
}
|
|
|
|
export const selectComboboxInputIfFocused = (
|
|
input: ComboboxFocusableInput | null,
|
|
activeElement: Element | null,
|
|
): void => {
|
|
if (input && input === activeElement) input.select()
|
|
}
|
|
|
|
export const canComboboxOpen = (disabled: boolean, nextOpen: boolean): boolean =>
|
|
!disabled || !nextOpen
|
|
|
|
/**
|
|
* Incremental rendering limits for large option lists (e.g. the font pickers
|
|
* can list hundreds of locally installed fonts). Rendering every option at
|
|
* once freezes the UI, so we mount an initial slice and grow the window as
|
|
* the user scrolls deeper, and slide the bounded window when arrow
|
|
* navigation jumps to an option beyond it.
|
|
*/
|
|
export const COMBOBOX_INITIAL_RENDER_LIMIT = 60
|
|
export const COMBOBOX_RENDER_LIMIT_STEP = 120
|
|
const COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX = 80
|
|
|
|
export const comboboxNextRenderLimit = (
|
|
currentLimit: number,
|
|
optionCount: number,
|
|
): number | null => {
|
|
if (optionCount <= currentLimit) return null
|
|
return Math.min(currentLimit + COMBOBOX_RENDER_LIMIT_STEP, optionCount)
|
|
}
|
|
|
|
export const shouldExpandComboboxWindow = (
|
|
target: ComboboxScrollableTarget,
|
|
renderedCount: number,
|
|
optionCount: number,
|
|
): boolean => {
|
|
if (renderedCount >= optionCount) return false
|
|
const distanceFromBottom = target.scrollHeight - (target.scrollTop + target.clientHeight)
|
|
return distanceFromBottom <= COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX
|
|
}
|
|
|
|
export const shouldResetComboboxWindow = (target: ComboboxScrollableTarget): boolean =>
|
|
target.scrollTop <= COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX
|
|
|
|
/**
|
|
* Keyboard navigation can land beyond the mounted window (e.g. ArrowUp wraps
|
|
* straight to the last option). Instead of growing the prefix until that
|
|
* index is mounted — which mounts every row for large lists — slide the
|
|
* bounded window so the destination sits at its edge. Returns the new window
|
|
* start, or null when the active option is already mounted.
|
|
*/
|
|
export const comboboxWindowStartForActiveIndex = (
|
|
activeOptionIndex: number,
|
|
windowStart: number,
|
|
renderLimit: number,
|
|
optionCount: number,
|
|
): number | null => {
|
|
if (optionCount <= 0 || activeOptionIndex < 0) return null
|
|
if (activeOptionIndex < windowStart) return activeOptionIndex
|
|
if (activeOptionIndex >= windowStart + renderLimit) {
|
|
return Math.max(0, Math.min(activeOptionIndex - renderLimit + 1, optionCount - renderLimit))
|
|
}
|
|
return null
|
|
}
|
|
|
|
function ComboboxOptionsList({
|
|
children,
|
|
id,
|
|
listbox = false,
|
|
onScrollCapture,
|
|
scrollRef,
|
|
}: {
|
|
children: React.ReactNode;
|
|
id?: string;
|
|
listbox?: boolean;
|
|
onScrollCapture?: React.UIEventHandler<HTMLDivElement>;
|
|
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
}) {
|
|
const handleWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
|
|
const handled = applyComboboxWheelScroll(event.currentTarget, event.deltaY, event.deltaMode)
|
|
if (!handled) return
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
event.nativeEvent.stopImmediatePropagation()
|
|
}
|
|
|
|
return (
|
|
<div
|
|
id={id}
|
|
ref={scrollRef}
|
|
role={listbox ? "listbox" : undefined}
|
|
className="max-h-[280px] overflow-y-auto overscroll-contain p-1"
|
|
onWheelCapture={handleWheelCapture}
|
|
onScrollCapture={onScrollCapture}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Combobox({
|
|
options,
|
|
value,
|
|
onValueChange,
|
|
placeholder = "Select...",
|
|
emptyText = "No results found",
|
|
allowCreate = false,
|
|
onCreateNew,
|
|
createText = "Create",
|
|
icon,
|
|
className,
|
|
triggerClassName,
|
|
inputStyle,
|
|
onInputValueChange,
|
|
disabled = false,
|
|
clearable = true,
|
|
selectValueOnFocus = false,
|
|
ariaLabel,
|
|
}: ComboboxProps) {
|
|
const [open, setOpen] = React.useState(false)
|
|
const [inputValue, setInputValue] = React.useState("")
|
|
const [activeIndex, setActiveIndex] = React.useState(-1)
|
|
// Track if user is actively searching (typed something after opening)
|
|
const [isSearching, setIsSearching] = React.useState(false)
|
|
// Incremental rendering window for very large option lists
|
|
const [renderLimit, setRenderLimit] = React.useState(COMBOBOX_INITIAL_RENDER_LIMIT)
|
|
// First mounted option index, so keyboard jumps slide the window instead
|
|
// of mounting the entire prefix of a large list.
|
|
const [windowStart, setWindowStart] = React.useState(0)
|
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
const wasOpenRef = React.useRef(false)
|
|
const activeOptionRef = React.useRef<HTMLButtonElement>(null)
|
|
const optionsScrollRef = React.useRef<HTMLDivElement>(null)
|
|
// True while scroll events on the options list are caused by this
|
|
// component scrolling the active option into view (keyboard navigation,
|
|
// mouse hover) rather than by the user scrolling manually.
|
|
const navigationalScrollRef = React.useRef(false)
|
|
const listboxId = React.useId()
|
|
|
|
// Sync input value with external value when not focused
|
|
React.useEffect(() => {
|
|
const wasOpen = wasOpenRef.current
|
|
wasOpenRef.current = open
|
|
|
|
if (!open) {
|
|
const selected = options.find((opt) => opt.value === value)
|
|
setInputValue(selected?.label || value || "")
|
|
setIsSearching(false)
|
|
|
|
if (wasOpen && selectValueOnFocus) {
|
|
// The restored label lands after the close event. Reselect it on the next
|
|
// frame so the next keystroke replaces it instead of appending to it.
|
|
requestAnimationFrame(() => {
|
|
selectComboboxInputIfFocused(
|
|
inputRef.current,
|
|
typeof document === "undefined" ? null : document.activeElement,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
}, [value, options, open, selectValueOnFocus])
|
|
|
|
// Show all options when dropdown is open but user hasn't started searching
|
|
const filteredOptions = React.useMemo(() => {
|
|
return filterComboboxOptions(options, inputValue, isSearching)
|
|
}, [options, inputValue, isSearching])
|
|
|
|
// Restart the window from its initial size whenever the picker opens or
|
|
// the filtered result set changes (option set, search mode, or query), so
|
|
// a previously grown or slid window never persists across a changed list.
|
|
// The reset happens during render (React discards the render output and
|
|
// re-renders immediately after a render-phase state update), so a changed
|
|
// result set is never committed with a stale, oversized window.
|
|
const [windowKey, setWindowKey] = React.useState({ open, filteredOptions })
|
|
if (windowKey.open !== open || windowKey.filteredOptions !== filteredOptions) {
|
|
setWindowKey({ open, filteredOptions })
|
|
setRenderLimit(COMBOBOX_INITIAL_RENDER_LIMIT)
|
|
setWindowStart(0)
|
|
}
|
|
|
|
const renderedOptions = React.useMemo(() => {
|
|
return filteredOptions.slice(windowStart, windowStart + renderLimit)
|
|
}, [filteredOptions, windowStart, renderLimit])
|
|
|
|
// Resetting the render window alone is not enough: the listbox DOM node
|
|
// keeps (or clamps) its previous scrollTop, so a fresh initial slice
|
|
// would be displayed near its bottom instead of at its first match.
|
|
React.useEffect(() => {
|
|
if (optionsScrollRef.current) optionsScrollRef.current.scrollTop = 0
|
|
}, [open, filteredOptions])
|
|
|
|
const expandRenderWindow = React.useCallback(() => {
|
|
setRenderLimit((current) => {
|
|
const next = comboboxNextRenderLimit(current, options.length)
|
|
return next ?? current
|
|
})
|
|
}, [options.length])
|
|
|
|
const handleOptionsScrollCapture = React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
|
const target = event.currentTarget
|
|
const scrollTarget = {
|
|
clientHeight: target.clientHeight,
|
|
scrollHeight: target.scrollHeight,
|
|
scrollTop: target.scrollTop,
|
|
}
|
|
// Scrolling back to the top restores the initial window so options
|
|
// above a slid window become reachable again. The active index must
|
|
// be cleared too, otherwise the active-option effect immediately
|
|
// slides the window back to a stale active option.
|
|
// Scrolls emitted by scrolling the active option into view are not
|
|
// manual scrolls: skipping the reset keeps ArrowUp navigation able to
|
|
// slide into the preceding window without losing the active option.
|
|
if (windowStart > 0 && shouldResetComboboxWindow(scrollTarget)) {
|
|
if (navigationalScrollRef.current) return
|
|
setActiveIndex(-1)
|
|
setWindowStart(0)
|
|
setRenderLimit(COMBOBOX_INITIAL_RENDER_LIMIT)
|
|
return
|
|
}
|
|
// Programmatic scrolls from scrolling the active option into view can
|
|
// also land near the bottom of a slid window (e.g. at the last window
|
|
// while keyboard-navigating). Those are not manual scrolls either, so
|
|
// skip expansion to keep the keyboard window at a fixed size instead
|
|
// of growing it on every ArrowUp/ArrowDown wrap boundary.
|
|
if (navigationalScrollRef.current) return
|
|
if (!shouldExpandComboboxWindow(scrollTarget, renderedOptions.length, filteredOptions.length)) {
|
|
return
|
|
}
|
|
// A slid window that already reaches the final option cannot mount
|
|
// more rows by growing renderLimit (the slice is capped by the option
|
|
// count), so growing it here without mounting anything would leave an
|
|
// inflated limit behind; a later wrap back to index 0 would then reset
|
|
// windowStart to zero and mount the entire grown prefix in one commit.
|
|
if (windowStart + renderedOptions.length >= filteredOptions.length) return
|
|
expandRenderWindow()
|
|
}, [windowStart, renderedOptions.length, filteredOptions.length, expandRenderWindow])
|
|
|
|
const showCreateOption = React.useMemo(() => {
|
|
if (!allowCreate || !inputValue.trim() || !isSearching) return false
|
|
const lower = inputValue.toLowerCase().trim()
|
|
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
|
|
}, [allowCreate, inputValue, options, isSearching])
|
|
|
|
const selectableOptionCount = filteredOptions.length + (showCreateOption ? 1 : 0)
|
|
const hasActiveOption = activeIndex >= 0 && activeIndex < selectableOptionCount
|
|
|
|
// Keyboard navigation must never land on an option that is not mounted
|
|
// yet, otherwise its aria-activedescendant id would not exist. Slide the
|
|
// bounded window so the destination is mounted without mounting the
|
|
// entire prefix of a large list. This runs during render (React discards
|
|
// the render and re-renders immediately after a render-phase state
|
|
// update), so the commit that exposes a new active descendant already
|
|
// has that option mounted — a post-commit effect would commit an
|
|
// aria-activedescendant id that does not exist in the DOM yet.
|
|
const [activeWindowKey, setActiveWindowKey] = React.useState({
|
|
activeIndex,
|
|
showCreateOption,
|
|
windowStart,
|
|
renderLimit,
|
|
filteredCount: filteredOptions.length,
|
|
})
|
|
if (
|
|
activeIndex >= 0 &&
|
|
(activeWindowKey.activeIndex !== activeIndex ||
|
|
activeWindowKey.showCreateOption !== showCreateOption ||
|
|
activeWindowKey.windowStart !== windowStart ||
|
|
activeWindowKey.renderLimit !== renderLimit ||
|
|
activeWindowKey.filteredCount !== filteredOptions.length)
|
|
) {
|
|
setActiveWindowKey({
|
|
activeIndex,
|
|
showCreateOption,
|
|
windowStart,
|
|
renderLimit,
|
|
filteredCount: filteredOptions.length,
|
|
})
|
|
const optionIndex = activeIndex - (showCreateOption ? 1 : 0)
|
|
const nextWindowStart = comboboxWindowStartForActiveIndex(
|
|
optionIndex,
|
|
windowStart,
|
|
renderLimit,
|
|
filteredOptions.length,
|
|
)
|
|
if (nextWindowStart !== null) setWindowStart(nextWindowStart)
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
// Scrolling the active option into view emits scroll events even when
|
|
// keyboard navigation slides the window toward the top of the list.
|
|
// Mark them as navigational so the manual-scroll reset below does not
|
|
// mistake them for the user scrolling back to the top, which would
|
|
// clear the active option and make preceding windows unreachable by
|
|
// ArrowUp. The flag is cleared on the next frame, once the scroll
|
|
// events for this update have been dispatched.
|
|
navigationalScrollRef.current = true
|
|
activeOptionRef.current?.scrollIntoView({ block: 'nearest' })
|
|
requestAnimationFrame(() => {
|
|
navigationalScrollRef.current = false
|
|
})
|
|
}, [activeIndex, renderedOptions.length, windowStart])
|
|
|
|
React.useEffect(() => {
|
|
if (!disabled || !open) return
|
|
setOpen(false)
|
|
setActiveIndex(-1)
|
|
setIsSearching(false)
|
|
onInputValueChange?.(value ?? "")
|
|
}, [disabled, open, onInputValueChange, value])
|
|
|
|
const handleSelect = (optValue: string) => {
|
|
if (disabled) return
|
|
onValueChange?.(optValue)
|
|
onInputValueChange?.(optValue)
|
|
setOpen(false)
|
|
setActiveIndex(-1)
|
|
const selected = options.find((opt) => opt.value === optValue)
|
|
setInputValue(selected?.label || optValue)
|
|
}
|
|
|
|
const handleCreate = () => {
|
|
if (disabled) return
|
|
const newValue = inputValue.trim()
|
|
if (newValue) {
|
|
onCreateNew?.(newValue)
|
|
onValueChange?.(newValue)
|
|
onInputValueChange?.(newValue)
|
|
setOpen(false)
|
|
setActiveIndex(-1)
|
|
}
|
|
}
|
|
|
|
const focusAndSelectInput = () => {
|
|
// Defer so selection wins over click-to-place-caret on focus.
|
|
requestAnimationFrame(() => {
|
|
focusComboboxInput(inputRef.current, true)
|
|
})
|
|
}
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setInputValue(e.target.value)
|
|
onInputValueChange?.(e.target.value)
|
|
setIsSearching(true)
|
|
setActiveIndex(-1)
|
|
if (!open) setOpen(true)
|
|
}
|
|
|
|
const handleInputFocus = () => {
|
|
if (selectValueOnFocus) focusAndSelectInput()
|
|
}
|
|
|
|
const handleOpenChange = (nextOpen: boolean) => {
|
|
if (!canComboboxOpen(disabled, nextOpen)) return
|
|
setOpen(nextOpen)
|
|
setActiveIndex(-1)
|
|
if (nextOpen) {
|
|
if (selectValueOnFocus) {
|
|
// Opening a closed picker from its chevron should also replace on first keystroke.
|
|
focusAndSelectInput()
|
|
}
|
|
} else {
|
|
onInputValueChange?.(value ?? "")
|
|
}
|
|
}
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
if (!open) setOpen(true)
|
|
const direction = e.key === 'ArrowDown' ? 1 : -1
|
|
setActiveIndex((current) =>
|
|
getNextComboboxActiveIndex(current, selectableOptionCount, direction)
|
|
)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (hasActiveOption) {
|
|
if (showCreateOption && activeIndex === 0) {
|
|
handleCreate()
|
|
} else {
|
|
const optionIndex = activeIndex - (showCreateOption ? 1 : 0)
|
|
const activeOption = filteredOptions[optionIndex]
|
|
if (activeOption) handleSelect(activeOption.value)
|
|
}
|
|
} else if (showCreateOption) {
|
|
handleCreate()
|
|
} else if (filteredOptions.length === 1) {
|
|
handleSelect(filteredOptions[0].value)
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
handleOpenChange(false)
|
|
}
|
|
}
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
if (disabled) return
|
|
e.stopPropagation()
|
|
setInputValue("")
|
|
onInputValueChange?.("")
|
|
onValueChange?.("")
|
|
inputRef.current?.focus()
|
|
}
|
|
|
|
return (
|
|
<Popover open={open && !disabled} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger asChild disabled={disabled}>
|
|
<div
|
|
aria-disabled={disabled}
|
|
className={cn(
|
|
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm min-w-0 overflow-hidden",
|
|
"hover:bg-secondary/50 transition-colors",
|
|
"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring",
|
|
disabled && "cursor-not-allowed opacity-50 hover:bg-background",
|
|
triggerClassName
|
|
)}
|
|
>
|
|
{icon && <span className="pl-3 shrink-0 text-muted-foreground">{icon}</span>}
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={handleInputChange}
|
|
onFocus={handleInputFocus}
|
|
onKeyDown={handleInputKeyDown}
|
|
role="combobox"
|
|
aria-label={ariaLabel}
|
|
aria-autocomplete="list"
|
|
aria-expanded={open && !disabled}
|
|
aria-controls={listboxId}
|
|
aria-activedescendant={
|
|
open && !disabled && hasActiveOption
|
|
? `${listboxId}-option-${activeIndex}`
|
|
: undefined
|
|
}
|
|
placeholder={placeholder}
|
|
style={inputStyle}
|
|
className="flex-1 min-w-0 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
|
|
disabled={disabled}
|
|
/>
|
|
{clearable && !disabled && inputValue && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="pr-1 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50 pr-3 box-content" />
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className={cn("app-no-drag p-0 border-border/60", className)}
|
|
align="start"
|
|
sideOffset={4}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
>
|
|
{/* Options List */}
|
|
<ComboboxOptionsList id={listboxId} listbox scrollRef={optionsScrollRef} onScrollCapture={handleOptionsScrollCapture}>
|
|
{filteredOptions.length === 0 && !showCreateOption ? (
|
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
{emptyText}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Create new option */}
|
|
{showCreateOption && (
|
|
<button
|
|
ref={activeIndex === 0 ? activeOptionRef : undefined}
|
|
id={`${listboxId}-option-0`}
|
|
role="option"
|
|
aria-selected={false}
|
|
aria-posinset={1}
|
|
aria-setsize={selectableOptionCount}
|
|
tabIndex={-1}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm hover:bg-secondary/80 transition-colors text-left",
|
|
activeIndex === 0 && "bg-secondary/80",
|
|
)}
|
|
onClick={handleCreate}
|
|
onMouseEnter={() => setActiveIndex(0)}
|
|
>
|
|
<Plus size={16} className="text-primary shrink-0" />
|
|
<span className="text-muted-foreground">{createText}</span>
|
|
<span className="font-medium text-foreground">{inputValue}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Separator if both create and options exist */}
|
|
{showCreateOption && filteredOptions.length > 0 && (
|
|
<div className="h-px bg-border/60 my-1" />
|
|
)}
|
|
|
|
{/* Existing options (rendered incrementally for large lists) */}
|
|
{renderedOptions.map((option, optionIndex) => {
|
|
const selectableIndex = optionIndex + windowStart + (showCreateOption ? 1 : 0)
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
ref={activeIndex === selectableIndex ? activeOptionRef : undefined}
|
|
id={`${listboxId}-option-${selectableIndex}`}
|
|
role="option"
|
|
aria-selected={value === option.value}
|
|
aria-posinset={selectableIndex + 1}
|
|
aria-setsize={selectableOptionCount}
|
|
tabIndex={-1}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left",
|
|
value === option.value
|
|
? "bg-primary/10 text-foreground"
|
|
: "hover:bg-secondary/80",
|
|
activeIndex === selectableIndex && "bg-secondary/80",
|
|
)}
|
|
onClick={() => handleSelect(option.value)}
|
|
onMouseEnter={() => setActiveIndex(selectableIndex)}
|
|
>
|
|
{option.icon && (
|
|
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate font-medium" style={option.labelStyle}>{option.label}</div>
|
|
{option.sublabel && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{option.sublabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{value === option.value && (
|
|
<Check size={16} className="shrink-0 text-primary" />
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
</ComboboxOptionsList>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// Multi-select Combobox for tags
|
|
interface MultiComboboxProps {
|
|
options: ComboboxOption[];
|
|
values: string[];
|
|
onValuesChange?: (values: string[]) => void;
|
|
placeholder?: string;
|
|
emptyText?: string;
|
|
allowCreate?: boolean;
|
|
onCreateNew?: (value: string) => void;
|
|
createText?: string;
|
|
icon?: React.ReactNode;
|
|
className?: string;
|
|
triggerClassName?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function MultiCombobox({
|
|
options,
|
|
values,
|
|
onValuesChange,
|
|
placeholder = "Add...",
|
|
emptyText = "No results found",
|
|
allowCreate = false,
|
|
onCreateNew,
|
|
createText = "Create Tag",
|
|
icon,
|
|
className,
|
|
triggerClassName,
|
|
disabled = false,
|
|
}: MultiComboboxProps) {
|
|
const [open, setOpen] = React.useState(false)
|
|
const [inputValue, setInputValue] = React.useState("")
|
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
|
|
const filteredOptions = React.useMemo(() => {
|
|
if (!inputValue.trim()) return options
|
|
const lower = inputValue.toLowerCase()
|
|
return options.filter(
|
|
(opt) =>
|
|
opt.label.toLowerCase().includes(lower) ||
|
|
opt.value.toLowerCase().includes(lower)
|
|
)
|
|
}, [options, inputValue])
|
|
|
|
const showCreateOption = React.useMemo(() => {
|
|
if (!allowCreate || !inputValue.trim()) return false
|
|
const lower = inputValue.toLowerCase().trim()
|
|
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
|
|
}, [allowCreate, inputValue, options])
|
|
|
|
const handleToggle = (optValue: string) => {
|
|
const newValues = values.includes(optValue)
|
|
? values.filter((v) => v !== optValue)
|
|
: [...values, optValue]
|
|
onValuesChange?.(newValues)
|
|
}
|
|
|
|
const handleCreate = () => {
|
|
const newValue = inputValue.trim()
|
|
if (newValue && !values.includes(newValue)) {
|
|
onCreateNew?.(newValue)
|
|
onValuesChange?.([...values, newValue])
|
|
setInputValue("")
|
|
}
|
|
}
|
|
|
|
const handleRemove = (e: React.MouseEvent, val: string) => {
|
|
e.stopPropagation()
|
|
onValuesChange?.(values.filter((v) => v !== val))
|
|
}
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (showCreateOption) {
|
|
handleCreate()
|
|
} else if (filteredOptions.length === 1 && !values.includes(filteredOptions[0].value)) {
|
|
handleToggle(filteredOptions[0].value)
|
|
setInputValue("")
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
setOpen(false)
|
|
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
|
|
// Remove last tag on backspace when input is empty
|
|
onValuesChange?.(values.slice(0, -1))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild disabled={disabled}>
|
|
<div
|
|
className={cn(
|
|
"flex min-h-10 w-full items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm",
|
|
"hover:bg-secondary/50 transition-colors cursor-text",
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
triggerClassName
|
|
)}
|
|
onClick={() => inputRef.current?.focus()}
|
|
>
|
|
{icon && <span className="pl-1 shrink-0 text-muted-foreground">{icon}</span>}
|
|
<div className="flex-1 flex flex-wrap gap-1.5 items-center min-w-0">
|
|
{values.map((val) => (
|
|
<span
|
|
key={val}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium"
|
|
>
|
|
{val}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleRemove(e, val)}
|
|
className="hover:bg-primary/20 rounded p-0.5"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
setInputValue(e.target.value)
|
|
if (!open) setOpen(true)
|
|
}}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder={values.length === 0 ? placeholder : ""}
|
|
className="flex-1 min-w-[60px] h-6 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className={cn("app-no-drag p-0 border-border/60", className)}
|
|
align="start"
|
|
sideOffset={4}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
>
|
|
{/* Options List */}
|
|
<ComboboxOptionsList>
|
|
{filteredOptions.length === 0 && !showCreateOption ? (
|
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
{emptyText}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Create new option */}
|
|
{showCreateOption && (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm hover:bg-secondary/80 transition-colors text-left"
|
|
onClick={handleCreate}
|
|
>
|
|
<Plus size={16} className="text-primary shrink-0" />
|
|
<span className="text-muted-foreground">{createText}</span>
|
|
<span className="font-medium text-foreground">{inputValue}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Separator if both create and options exist */}
|
|
{showCreateOption && filteredOptions.length > 0 && (
|
|
<div className="h-px bg-border/60 my-1" />
|
|
)}
|
|
|
|
{/* Existing options */}
|
|
{filteredOptions.map((option) => {
|
|
const isSelected = values.includes(option.value)
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left",
|
|
isSelected
|
|
? "bg-primary/10 text-foreground"
|
|
: "hover:bg-secondary/80"
|
|
)}
|
|
onClick={() => {
|
|
handleToggle(option.value)
|
|
setInputValue("")
|
|
}}
|
|
>
|
|
<div className={cn(
|
|
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
|
isSelected ? "bg-primary border-primary" : "border-muted-foreground/40"
|
|
)}>
|
|
{isSelected && <Check size={12} className="text-primary-foreground" />}
|
|
</div>
|
|
<span className="truncate flex-1">{option.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
</ComboboxOptionsList>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export default Combobox
|