[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
837
components/ui/combobox.tsx
Normal file
837
components/ui/combobox.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
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
|
||||
Reference in New Issue
Block a user